@hyzyn/dsh-docker 0.8.1 → 0.9.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +1 -0
- package/README.md +15 -1
- package/client-src/docker.css +119 -1
- package/client-src/index.js +1892 -534
- package/client-src/log-stream.js +7 -1
- package/client.js +143 -25
- package/lib/docker.d.ts +9 -2
- package/lib/docker.js +8 -3
- package/lib/docker.js.map +1 -1
- package/lib/index.d.ts +18 -0
- package/lib/index.js +299 -195
- package/lib/index.js.map +1 -1
- package/lib/ssh-exec.d.ts +142 -2
- package/lib/ssh-exec.js +549 -12
- package/lib/ssh-exec.js.map +1 -1
- package/package.json +2 -2
- package/scripts/client-smoke.mjs +8 -2
- package/scripts/route-smoke.mjs +15 -0
package/client-src/index.js
CHANGED
|
@@ -22,6 +22,1013 @@ import { currentSessionIdOf } from './current-session.js'
|
|
|
22
22
|
import { createLogBuffer, splitLogLines } from './log-buffer.js'
|
|
23
23
|
import { subscribeLogStream, reconnectTail, LOG_RECONNECT_BASE_MS, LOG_RECONNECT_MAX_MS } from './log-stream.js'
|
|
24
24
|
|
|
25
|
+
/* ================================ 国际化 ================================ */
|
|
26
|
+
|
|
27
|
+
/*
|
|
28
|
+
* 界面文案走宿主 `@deepseek-ai/dsh-client-locale` 的目录(方案见 docs/i18n.md)。
|
|
29
|
+
* 目录**内联在本文件**(不是单独一个模块):scripts/build-client.mjs 用 esbuild 打包单文件,
|
|
30
|
+
* 目录另起文件只会多一处构建面;下面这一对目录由 `scripts/check-i18n.mjs` 静态校验——
|
|
31
|
+
* **文案里不许出现字面 `**`**(D143):浏览器半体没有 markdown 渲染器,用户看到的就是两个星号;
|
|
32
|
+
* 强调用「」/ “” 或直接换行,`check-i18n.mjs` 不查这一条,只能靠人守。
|
|
33
|
+
* zh/en 键集一致、`{name}` 占位符两边一致、代码里 `t('…')` 用到的键必须在这里有定义。
|
|
34
|
+
*
|
|
35
|
+
* 兄弟模块(log-stream.js 等)不 import 本文件(会成环),需要 `t` 时**当参数传进去**。
|
|
36
|
+
*
|
|
37
|
+
* **模块级常量里的 `t()` 必须走 getter / 函数**:`t()` 在模块加载时求值等于把当前语言冻住
|
|
38
|
+
* (`installI18n(ctx)` 要等 apply 才跑,语言切换也不会重算)——PICK_PRESETS / ATTENTION_REASONS /
|
|
39
|
+
* LOG_LEVEL_OPTIONS / 导出按钮文案都因此改成了 getter 或取值函数。
|
|
40
|
+
*
|
|
41
|
+
* 不翻的:`console.warn` 的诊断前缀、品牌与技术词(Docker / Compose / CPU / OOM / docker ps 输出)、
|
|
42
|
+
* 路径与语法示例、宿主返回的错误正文,以及「问 Agent」拼给**模型**看的日志片段(agent 面向的文案,
|
|
43
|
+
* 见 docs/i18n.md § 范围纪律)。
|
|
44
|
+
*/
|
|
45
|
+
/* ==== dsh-i18n:begin ==== */
|
|
46
|
+
const I18N_NS = 'docker'
|
|
47
|
+
const I18N_ZH = {
|
|
48
|
+
'error.requestFailed': '请求失败',
|
|
49
|
+
'list.noPorts': '无端口映射',
|
|
50
|
+
'status.running': '运行中',
|
|
51
|
+
'status.stopped': '已停止',
|
|
52
|
+
'status.created': '已创建',
|
|
53
|
+
'status.paused': '已暂停',
|
|
54
|
+
'status.restarting': '重启中',
|
|
55
|
+
'status.removing': '删除中',
|
|
56
|
+
'status.unknown': '未知',
|
|
57
|
+
'hint.pickMaxLocal': '最多 {max} 个容器,浏览器并发长连接有限制',
|
|
58
|
+
'hint.pickMaxSsh': '最多 {max} 个容器(SSH 目标上一条连接要同时装实时流与刷新等短命令)',
|
|
59
|
+
'hint.pickMany': '连接数较多,浏览器并发长连接有限制',
|
|
60
|
+
'hint.pickAtLeastTwo': '至少选择 2 个容器',
|
|
61
|
+
'option.presetAll': '全部可见',
|
|
62
|
+
'status.unhealthy': '不健康',
|
|
63
|
+
'status.attention': '需关注',
|
|
64
|
+
'option.presetSameImage': '同镜像',
|
|
65
|
+
'option.presetSameProject': '同项目',
|
|
66
|
+
'status.oomKilled': '被 OOM 杀',
|
|
67
|
+
'status.dead': '僵死',
|
|
68
|
+
'status.restartingLoop': '反复重启',
|
|
69
|
+
'status.exitNonzero': '非零退出',
|
|
70
|
+
'hint.openDetail': '打开容器详情',
|
|
71
|
+
'meta.finishedAt': '结束于 ',
|
|
72
|
+
'meta.startedAt': '启动于 ',
|
|
73
|
+
'meta.restartCount': '重启次数 ',
|
|
74
|
+
'meta.exitCode': '退出码 ',
|
|
75
|
+
'error.unknown': '未知错误',
|
|
76
|
+
'hint.attentionTruncated': '需关注结果已截断:{targets} 个目标实际共 {total} 条,此处只列出前 {shown} 条',
|
|
77
|
+
'hint.attentionDegraded': '{count} 个目标的结果已降级(部分容器的详情没取到,OOM / 反复重启可能漏报)',
|
|
78
|
+
'msg.clauseSep': ';',
|
|
79
|
+
'list.noTargetsConfigured': '还没有配置 Docker 目标',
|
|
80
|
+
'hint.overviewNoTargets': '到 插件配置 → Docker 容器面板 添加目标后,总览会在这里一屏汇总全部主机。',
|
|
81
|
+
'list.loading': '读取中…',
|
|
82
|
+
'list.allGood': '一切正常',
|
|
83
|
+
'list.allGoodHint': '所有目标上都没有需要关注的容器(不健康 / 反复重启 / 被 OOM 杀 / 非零退出 / 僵死)。',
|
|
84
|
+
'field.containerName': '容器名',
|
|
85
|
+
'field.target': '目标',
|
|
86
|
+
'field.state': '状态',
|
|
87
|
+
'field.reason': '原因',
|
|
88
|
+
'field.image': '镜像',
|
|
89
|
+
'badge.unreachableTargets': '{count} 个目标不可达',
|
|
90
|
+
'hint.switchToTarget': '切到该目标的容器列表',
|
|
91
|
+
'option.local': '本机',
|
|
92
|
+
'status.attentionApprox': '需关注(粗判)',
|
|
93
|
+
'status.unreachable': '不可达',
|
|
94
|
+
'panel.attentionContainers': '需关注容器',
|
|
95
|
+
'panel.attentionContainersCount': '需关注容器({count})',
|
|
96
|
+
'btn.cancel': '取消',
|
|
97
|
+
'status.executing': '执行中…',
|
|
98
|
+
'status.sampling': '采样中…',
|
|
99
|
+
'status.pendingAction': '正在执行 {action}…请稍候',
|
|
100
|
+
'status.needMutations': '需要打开「允许变更操作」',
|
|
101
|
+
'field.ports': '端口',
|
|
102
|
+
'hint.hostNetwork': '(host 网络:端口即宿主机端口)',
|
|
103
|
+
'field.created': '创建',
|
|
104
|
+
'hint.openTerminal': '在容器内打开交互式终端(docker exec -it {name} sh)',
|
|
105
|
+
'btn.viewLogs': '查看日志',
|
|
106
|
+
'btn.stats': '资源占用',
|
|
107
|
+
'btn.stopContainer': '停止容器',
|
|
108
|
+
'btn.startContainer': '启动容器',
|
|
109
|
+
'btn.restartContainer': '重启容器',
|
|
110
|
+
'btn.removeContainer': '删除容器(不可恢复)',
|
|
111
|
+
'error.noSessionsService': '宿主未提供 sessions 服务',
|
|
112
|
+
'error.noOpenSession': '当前没有打开的会话',
|
|
113
|
+
'error.sessionNotReady': '会话尚未就绪(作用域未挂载)',
|
|
114
|
+
'error.noConversationService': '宿主缺少 conversation 服务',
|
|
115
|
+
'error.deliverFailed': '未能交给会话:',
|
|
116
|
+
'btn.export': '⬇ 导出',
|
|
117
|
+
'hint.exportLog': '纯文本,逐行原样',
|
|
118
|
+
'hint.exportMd': '带来源与行数表头,适合当工单附件',
|
|
119
|
+
'panel.exportLogs': '导出日志',
|
|
120
|
+
'error.copyRejected': '浏览器拒绝了复制',
|
|
121
|
+
'hint.revealSession': ' · 已折起终端,你在会话里',
|
|
122
|
+
'error.noInputFacade': '宿主未提供会话输入门面,无法只填草稿',
|
|
123
|
+
'msg.logDraftFilled': '日志片段已填入输入框,确认后再发送',
|
|
124
|
+
'msg.filledInCurrentSession': '已填入当前会话的输入框',
|
|
125
|
+
'msg.filledDraft': '已填入输入框',
|
|
126
|
+
'msg.logSentToSession': '日志片段已发送到会话',
|
|
127
|
+
'msg.logSentToCurrentSession': '已发送日志片段到当前会话',
|
|
128
|
+
'msg.sent': '已发送',
|
|
129
|
+
'btn.askAgent': '问 Agent',
|
|
130
|
+
'meta.currentSession': ' · 当前会话',
|
|
131
|
+
'btn.sendToSession': '直接发送到当前会话',
|
|
132
|
+
'hint.sendNow': '立即开始分析',
|
|
133
|
+
'btn.fillDraft': '填入输入框,我先改改',
|
|
134
|
+
'hint.fillDraft': '不发送;终端折起,你在会话里改完再发',
|
|
135
|
+
'hint.untrustedLogs': '日志是容器里的不可信内容:可能含凭证,也可能含试图操纵模型的指令文本,发送前请过目。',
|
|
136
|
+
'error.noEventSource': '当前环境不支持 EventSource,无法实时跟随',
|
|
137
|
+
'status.containerExited': '容器已退出',
|
|
138
|
+
'meta.exitCodeNote': '(退出码 {code})',
|
|
139
|
+
'status.streamEndedSnapshot': ',日志流结束,已切回快照',
|
|
140
|
+
'hint.logBacklog': '主机侧日志积压超出上限(推送速度超过浏览器消费速度),已断开并重连;重连只补新行,不重复历史',
|
|
141
|
+
'status.streamStoppedReconnecting': '服务端已停止日志流,正在重连…',
|
|
142
|
+
'status.statsFollowing': '实时跟随中(docker stats)',
|
|
143
|
+
'status.statsConnecting': '正在连接统计流…',
|
|
144
|
+
'status.reconnecting': '连接中断,正在自动重连…',
|
|
145
|
+
'status.statsClosed': '统计流已断开',
|
|
146
|
+
'status.statsStream': '统计流',
|
|
147
|
+
'status.logsFollowing': '实时跟随中(docker logs -f)',
|
|
148
|
+
'status.logsConnecting': '正在连接日志流…',
|
|
149
|
+
'status.logsClosed': '日志流已断开',
|
|
150
|
+
'status.logsStream': '日志流',
|
|
151
|
+
'status.statsEnded': '统计流已结束',
|
|
152
|
+
'status.statsExited': '(docker stats 退出,退出码 {code})',
|
|
153
|
+
'status.statsExitedNoCode': '(docker stats 退出)',
|
|
154
|
+
'status.backToSnapshotPolling': ',已切回快照轮询',
|
|
155
|
+
'error.statsStream': '统计流异常',
|
|
156
|
+
'error.inspectFailed': '读取容器详情失败',
|
|
157
|
+
'meta.parenValue': '({value})',
|
|
158
|
+
'field.containerId': '容器 ID',
|
|
159
|
+
'field.startedAt': '启动时间',
|
|
160
|
+
'field.finishedAt': '结束时间',
|
|
161
|
+
'field.exitCode': '退出码',
|
|
162
|
+
'field.restartCount': '重启次数',
|
|
163
|
+
'field.restartPolicy': '重启策略',
|
|
164
|
+
'field.mounts': '挂载',
|
|
165
|
+
'meta.readOnly': '(只读)',
|
|
166
|
+
'field.networks': '网络',
|
|
167
|
+
'field.command': '命令',
|
|
168
|
+
'field.workingDir': '工作目录',
|
|
169
|
+
'field.user': '用户',
|
|
170
|
+
'meta.healthLog': '最近健康检查输出:',
|
|
171
|
+
'panel.oneOffExec': '一次性命令(docker exec)',
|
|
172
|
+
'banner.execDisabled': 'exec 未启用',
|
|
173
|
+
'hint.execDisabled': '到 插件配置 → Docker 容器面板 打开「允许 exec」,或直接复制卡片上的 exec 命令到终端面板交互式进入容器。',
|
|
174
|
+
'placeholder.execCommand': '如 ls -la /app 或 cat /etc/nginx/nginx.conf',
|
|
175
|
+
'btn.exec': '执行',
|
|
176
|
+
'error.execFailed': '执行失败',
|
|
177
|
+
'meta.duration': ' · 耗时 {ms}ms',
|
|
178
|
+
'meta.truncated': ' · 输出已截断',
|
|
179
|
+
'list.noOutput': '(无输出)',
|
|
180
|
+
'hint.logTailTitle': '拉取的尾部行数。快照另受设置卡片「输出上限(KB)」限制(当前 {kb}KB)——行数够但字节超了仍会截断,实际行数可能更少;FOLLOW 流不受该字节上限约束(由本面板的缓冲上限收口)。',
|
|
181
|
+
'hint.followOff': '关闭实时跟随(回到日志快照)',
|
|
182
|
+
'hint.followOn': '实时跟随容器日志(docker logs -f)',
|
|
183
|
+
'hint.autoOff': 'FOLLOW 打开时暂停轮询',
|
|
184
|
+
'hint.autoOn': '按下方间隔重新拉取日志快照',
|
|
185
|
+
'hint.autoRefreshTitle': '自动刷新间隔(开启 AUTO REFRESH 后按此轮询)',
|
|
186
|
+
'btn.refreshLogs': '刷新日志',
|
|
187
|
+
'placeholder.filterLogs': '过滤日志…',
|
|
188
|
+
'btn.clearFilter': '清空过滤',
|
|
189
|
+
'hint.levelFilter': '按日志级别过滤(显示 ≥ 所选级别;无级别前缀的行是上一条的续行,跟随其级别)',
|
|
190
|
+
'hint.exportMenu': '导出当前显示内容:.log(纯文本)/ .md(带来源与行数表头,适合当工单附件)',
|
|
191
|
+
'meta.rowsSuffix': ' 行',
|
|
192
|
+
'panel.containerLogs': '容器日志',
|
|
193
|
+
'error.logsFailed': '读取日志失败',
|
|
194
|
+
'hint.fetchFailed': '(网络请求没到宿主:宿主可能刚重启、或连接被中断)',
|
|
195
|
+
'btn.retry': '重试',
|
|
196
|
+
'error.logStreamInterrupted': '日志流中断',
|
|
197
|
+
'hint.bufferExceeded': '日志超出缓冲上限({lines} 行 / {mb}MB),已丢弃最早内容',
|
|
198
|
+
'hint.streamKeepsRecent': '流式日志只保留最近的行;需要完整历史请关掉 FOLLOW 用快照,或调小「LINES」。',
|
|
199
|
+
'hint.outputTruncated': '日志输出超过「输出上限({kb}KB)」,已截断',
|
|
200
|
+
'hint.byteCap': '这是「字节」上限,不是行数上限——所以 LINES 选了 5000 也可能只回来一部分。想多留日志请到设置卡片调大「输出上限(KB)」,或打开 FOLLOW(流式不受它约束)。',
|
|
201
|
+
'list.waitingLogs': '等待日志…',
|
|
202
|
+
'list.noLogs': '(无日志)',
|
|
203
|
+
'list.noMatchingLogs': '(无匹配日志)',
|
|
204
|
+
'btn.backToBottom': '回到底部',
|
|
205
|
+
'hint.statsFollowOff': '关闭实时跟随(回到 docker stats 快照)',
|
|
206
|
+
'hint.statsFollowOn': '实时跟随资源占用(docker stats 每秒一行)',
|
|
207
|
+
'hint.sparkWindow': '60 点 ≈ 最近 1 分钟',
|
|
208
|
+
'hint.sparkFollow': '打开 FOLLOW 看实时趋势',
|
|
209
|
+
'error.statsFailed': '读取统计失败',
|
|
210
|
+
'field.metric': '指标',
|
|
211
|
+
'field.value': '数值',
|
|
212
|
+
'field.usageTrend': '占用 / 趋势',
|
|
213
|
+
'hint.cpuSpark': 'CPU% 最近 60 个采样',
|
|
214
|
+
'field.memory': '内存',
|
|
215
|
+
'hint.memSpark': '内存占用% 最近 60 个采样',
|
|
216
|
+
'field.netIO': '网络 IO',
|
|
217
|
+
'field.blockIO': '磁盘 IO',
|
|
218
|
+
'panel.tabOverview': '概览',
|
|
219
|
+
'panel.tabLogs': '日志',
|
|
220
|
+
'panel.tabStats': '统计',
|
|
221
|
+
'btn.backToContainers': '返回容器列表',
|
|
222
|
+
'btn.refresh': '刷新',
|
|
223
|
+
'btn.closePanel': '关闭面板',
|
|
224
|
+
'field.entrypoint': '入口',
|
|
225
|
+
'error.imageDetailFailed': '读取镜像详情失败',
|
|
226
|
+
'field.labels': '标签',
|
|
227
|
+
'list.dangling': '<none>(dangling)',
|
|
228
|
+
'field.size': '大小',
|
|
229
|
+
'field.virtualSize': '含父层',
|
|
230
|
+
'field.platform': '平台',
|
|
231
|
+
'field.layerCount': '层数',
|
|
232
|
+
'field.exposedPorts': '暴露端口',
|
|
233
|
+
'panel.layersCount': '层({count})',
|
|
234
|
+
'list.noLayerInfo': '该镜像没有层信息(scratch 构建或旧版 docker)。',
|
|
235
|
+
'panel.labelsCount': '标签({count})',
|
|
236
|
+
'error.historyFailed': '读取构建历史失败',
|
|
237
|
+
'list.noHistory': '没有构建历史',
|
|
238
|
+
'hint.noHistory': '该 docker 版本既没有 history --format(需要 Docker ≥ 26),纯文本表格也没解析出内容。',
|
|
239
|
+
'field.layerId': '层 ID',
|
|
240
|
+
'field.buildCommand': '构建命令',
|
|
241
|
+
'panel.tabHistory': '构建历史',
|
|
242
|
+
'btn.backToImages': '返回镜像列表',
|
|
243
|
+
'btn.refreshImageDetail': '刷新镜像详情',
|
|
244
|
+
'error.networkDetailFailed': '读取网络详情失败',
|
|
245
|
+
'field.name': '名称',
|
|
246
|
+
'field.driver': '驱动',
|
|
247
|
+
'field.scope': '范围',
|
|
248
|
+
'field.subnets': '子网',
|
|
249
|
+
'field.gateway': '网关',
|
|
250
|
+
'field.attributes': '属性',
|
|
251
|
+
'field.options': '选项',
|
|
252
|
+
'list.noContainersInNetwork': '没有容器接入这个网络',
|
|
253
|
+
'panel.containers': '容器',
|
|
254
|
+
'panel.attachedContainers': '接入的容器',
|
|
255
|
+
'btn.backToNetworks': '返回网络列表',
|
|
256
|
+
'btn.refreshNetworkDetail': '刷新网络详情',
|
|
257
|
+
'btn.removeNetwork': '删除网络(不可恢复)',
|
|
258
|
+
'btn.removeNetworkDisabled': '删除网络需要打开「允许变更操作」',
|
|
259
|
+
'error.removeNetworkFailed': '删除网络失败',
|
|
260
|
+
'btn.removeNetworkShort': '删除网络',
|
|
261
|
+
'confirm.removeNetwork': '确定删除网络 {name}?还有容器接着时 docker 会拒绝;删除后依赖它的容器会失去网络,需要重新创建或接入别的网络。',
|
|
262
|
+
'btn.delete': '删除',
|
|
263
|
+
'error.volumeDetailFailed': '读取卷详情失败',
|
|
264
|
+
'field.mountpoint': '挂载点',
|
|
265
|
+
'btn.backToVolumes': '返回卷列表',
|
|
266
|
+
'btn.refreshVolumeDetail': '刷新卷详情',
|
|
267
|
+
'btn.removeVolume': '删除卷(卷里的数据会一起没,不可恢复)',
|
|
268
|
+
'btn.removeVolumeDisabled': '删除卷需要打开「允许变更操作」',
|
|
269
|
+
'error.removeVolumeFailed': '删除卷失败',
|
|
270
|
+
'btn.removeVolumeShort': '删除卷',
|
|
271
|
+
'confirm.removeVolume': '确定删除卷 {name}?卷里的数据会一起删除且不可恢复;还有容器占用时 docker 会拒绝。',
|
|
272
|
+
'error.noEventSourcePull': '当前环境不支持 EventSource,无法显示拉取进度',
|
|
273
|
+
'status.pullDone': '拉取完成',
|
|
274
|
+
'status.pullEnded': '拉取结束(退出码 {code})',
|
|
275
|
+
'status.pulling': '正在拉取(docker pull)…',
|
|
276
|
+
'status.pullConnecting': '正在连接拉取流…',
|
|
277
|
+
'status.pullStreamClosed': '拉取流已断开',
|
|
278
|
+
'panel.pullImage': '拉取镜像',
|
|
279
|
+
'banner.pullNeedsMutations': '拉取镜像需要打开「允许变更操作」',
|
|
280
|
+
'hint.pullNeedsMutations': 'docker pull 会写入目标机的镜像存储并占用磁盘与带宽。到 插件配置 → Docker 容器面板 打开「允许变更操作」后即可在此拉取。',
|
|
281
|
+
'placeholder.imageRef': '镜像引用,如 nginx:1.27 或 ghcr.io/org/app:latest',
|
|
282
|
+
'btn.stop': '停止',
|
|
283
|
+
'btn.pull': '拉取',
|
|
284
|
+
'error.pullFailed': '拉取失败',
|
|
285
|
+
'hint.pullProgressDropped': '进度超过 {lines} 行,最早的进度行已被丢弃',
|
|
286
|
+
'meta.dotExitCode': ' · 退出码 {code}',
|
|
287
|
+
'list.waitingPull': '等待 docker pull 输出…',
|
|
288
|
+
'hint.pullPlaceholder': '填写镜像引用后点「拉取」,逐层进度会实时出现在这里。',
|
|
289
|
+
'badge.notCompose': '(非 compose 容器)',
|
|
290
|
+
'badge.runningRatio': '{running} / {total} 运行中',
|
|
291
|
+
'badge.unhealthyCount': '{count} 不健康',
|
|
292
|
+
'badge.serviceCount': '{count} 个服务',
|
|
293
|
+
'field.service': '服务',
|
|
294
|
+
'panel.aggregatedLogs': '聚合日志',
|
|
295
|
+
'btn.backToCompose': '返回 Compose 列表',
|
|
296
|
+
'option.allLevels': '全部级别',
|
|
297
|
+
'meta.source': '- 来源:',
|
|
298
|
+
'meta.containersLine': '- 容器({count}):{names}',
|
|
299
|
+
'meta.lines': '- 行数:{count}',
|
|
300
|
+
'meta.exportedAt': '- 导出时间:{time}',
|
|
301
|
+
'msg.listSep': '、',
|
|
302
|
+
'status.aggConnected': '已连接 {count} 条容器日志流(docker logs -f)',
|
|
303
|
+
'status.aggConnecting': '正在连接容器日志流…',
|
|
304
|
+
'status.aggReconnecting': '部分连接中断,正在自动重连…',
|
|
305
|
+
'status.aggPartialError': '部分容器日志流出错',
|
|
306
|
+
'status.aggClosed': '全部容器日志流已结束',
|
|
307
|
+
'status.noEventSource': '当前环境不支持 EventSource',
|
|
308
|
+
'status.aggEmpty': '该项目没有可聚合的容器',
|
|
309
|
+
'hint.aggBufferExceeded': '聚合日志超出缓冲上限({lines} 行 / {mb}MB),已丢弃最早内容',
|
|
310
|
+
'placeholder.filterServiceLogs': '过滤服务名 / 日志内容…',
|
|
311
|
+
'hint.aggTail': '每容器拉取的初始行数({containers} 个容器 → 约 {rows} 行);改动会重连全部流',
|
|
312
|
+
'btn.resumeLive': '恢复实时(会一次性显示暂停期间攒下的 {count} 行并回到底部)',
|
|
313
|
+
'hint.pause': '暂停(冻结当前画面:新日志继续接收但不追加,避免读屏被顶走)',
|
|
314
|
+
'status.live': '实时',
|
|
315
|
+
'btn.hideTimestamps': '隐藏每行时间戳',
|
|
316
|
+
'btn.showTimestamps': '显示每行时间戳(时间戳始终随流接收,只影响显示)',
|
|
317
|
+
'field.timestamps': '时间戳',
|
|
318
|
+
'option.orderArrivalHint': '按到达顺序显示(实时跟随零延迟)',
|
|
319
|
+
'hint.orderTimeHint': '按容器时间戳合并(跨容器成一条真时间线,代价约 {ms}ms 延迟)',
|
|
320
|
+
'option.orderTime': '按时间',
|
|
321
|
+
'option.orderArrival': '按到达',
|
|
322
|
+
'meta.containerCount': '{count} 个容器',
|
|
323
|
+
'panel.aggContainerLogs': '聚合容器日志',
|
|
324
|
+
'hint.eventsToggle': '容器事件活动(docker events):点击折叠 / 展开',
|
|
325
|
+
'panel.activity': '活动',
|
|
326
|
+
'list.noEvents': '暂无事件',
|
|
327
|
+
'list.recentEvents': '最近 {recent} / {total} 条',
|
|
328
|
+
'list.noEventsHint': '暂无事件(容器的 start / die / health 等动作会出现在这里)',
|
|
329
|
+
'status.picked': '已选 {count} 个容器',
|
|
330
|
+
'hint.aggRun': '把所选容器的日志聚合成一条流',
|
|
331
|
+
'panel.pickPresets': '按条件选中',
|
|
332
|
+
'hint.pickPreset': '在当前筛选结果里勾选「{label}」的容器(最多 {max} 个流)',
|
|
333
|
+
'hint.pickPresetOver': ';另有 {count} 个超出上限不会选中',
|
|
334
|
+
'btn.clearPicked': '清空勾选',
|
|
335
|
+
'btn.clear': '清空',
|
|
336
|
+
'btn.backToContainersExitPick': '返回容器列表(退出选择态)',
|
|
337
|
+
'panel.aggLogsTitle': '聚合日志 · {count} 个容器',
|
|
338
|
+
'hint.termResize': '拖动调整终端高度(双击折叠 / 展开;聚焦后 ↑/↓ 微调)',
|
|
339
|
+
'hint.termResizeAria': '调整终端抽屉高度',
|
|
340
|
+
'status.termCollapsed': '已折叠 · 会话保持运行',
|
|
341
|
+
'status.termDocked': '由终端面板承载 · 折叠保留会话',
|
|
342
|
+
'btn.expandTerminal': '展开终端(会话未中断)',
|
|
343
|
+
'btn.collapseTerminal': '折叠终端(会话保持运行)',
|
|
344
|
+
'btn.endTerminal': '结束终端会话并收起抽屉',
|
|
345
|
+
'error.terminalStart': '终端启动失败:',
|
|
346
|
+
'status.eventsLive': '实时接收中(docker events)',
|
|
347
|
+
'status.eventsConnecting': '正在连接事件流…',
|
|
348
|
+
'status.eventsClosed': '事件流已断开',
|
|
349
|
+
'status.eventsStream': '事件流',
|
|
350
|
+
'status.eventsEnded': '事件流已结束',
|
|
351
|
+
'status.backToListRefresh': ',列表回到 AUTO REFRESH / 手动刷新',
|
|
352
|
+
'hint.conversationHidden': ' · 会话在面板后面:关掉或最小化面板/终端即可看到',
|
|
353
|
+
'msg.copied': '已复制:',
|
|
354
|
+
'error.copyManual': '复制失败,请手动执行:',
|
|
355
|
+
'hint.ttyOutdated': '终端面板版本过旧(交互式进入容器需要 dsh-tty ≥ 0.14.0),命令已复制,可粘贴到系统终端执行',
|
|
356
|
+
'hint.ttyNotInstalled': '未安装 dsh-tty 终端面板,命令已复制,可粘贴到系统终端执行(装 dsh-tty 后可直接在此开终端)',
|
|
357
|
+
'hint.inlineCreds': '内联目标用了 key/password 认证,浏览器端拿不到凭证',
|
|
358
|
+
'btn.removeContainerShort': '删除容器',
|
|
359
|
+
'confirm.removeContainer': '确定删除容器 {name}?容器的可写层与配置会被删除(命名数据卷保留),此操作不可恢复。',
|
|
360
|
+
'confirm.containerAction': '确定对容器 {name} 执行{action}操作?',
|
|
361
|
+
'btn.start': '启动',
|
|
362
|
+
'btn.restart': '重启',
|
|
363
|
+
'btn.confirm': '确定',
|
|
364
|
+
'msg.actionResult': '{action} {name}:{message}',
|
|
365
|
+
'btn.removeImage': '删除镜像',
|
|
366
|
+
'confirm.removeImage': '确定删除镜像 {ref}?镜像被容器或子镜像引用时会失败;删除后需要重新拉取或构建才能恢复,且不可撤销。',
|
|
367
|
+
'msg.imageDeleted': '已删除 {ref}:{message}',
|
|
368
|
+
'btn.pruneDangling': '清理 dangling 镜像',
|
|
369
|
+
'confirm.pruneImages': '清理该目标上所有无标签(<none>:<none>)的镜像层,释放磁盘空间;不会删除有 tag 的镜像。',
|
|
370
|
+
'btn.prune': '清理',
|
|
371
|
+
'msg.prunedImages': '已清理 dangling 镜像:',
|
|
372
|
+
'btn.pruneNetworks': '清理未使用的网络',
|
|
373
|
+
'confirm.pruneNetworks': '清理该目标上所有没有容器接入的网络。compose 创建的项目网络也在其中(下次 up 会重建),但正在跑的项目会短暂失去网络。',
|
|
374
|
+
'msg.prunedNetworks': '已清理未使用网络:',
|
|
375
|
+
'btn.pruneVolumes': '清理未使用的卷',
|
|
376
|
+
'confirm.pruneVolumes': '清理该目标上所有没有被容器使用的卷——卷里的数据会一起删除且不可恢复。docker ≥ 23 只删匿名卷(不带 --all),更老的版本会连命名卷一起删;执行前请确认没有需要保留的数据卷。',
|
|
377
|
+
'msg.prunedVolumes': '已清理未使用卷:',
|
|
378
|
+
'banner.switching': '正在切换到 {target}{host}。下面仍是 {listTarget}{listHost} 的数据,切换完成前不可操作。',
|
|
379
|
+
'status.switchingTo': '正在切换到',
|
|
380
|
+
'status.showing': '当前显示:',
|
|
381
|
+
'list.sessionHostNotTarget': '当前会话主机还不是 Docker 目标',
|
|
382
|
+
'hint.sessionHostNotTarget': '按上面的提示到 插件配置 → Docker 容器面板 添加一条目标(推荐直接选连接簿条目),保存后回到这里刷新。为避免张冠李戴,面板不会自动切到其他目标。',
|
|
383
|
+
'hint.addTargetEmpty': '到 插件配置 → Docker 容器面板 添加一个目标:本机直接选「本机」;远程主机可以引用 tty 终端面板的连接簿条目。',
|
|
384
|
+
'list.targetError': '这个目标的数据没读到',
|
|
385
|
+
'hint.targetError': '上面的错误条里有原因(目标不可达 / docker 未运行 / 权限不足)。修好后点右上角刷新即可。',
|
|
386
|
+
'list.noImages': '没有镜像',
|
|
387
|
+
'list.noCompose': '没有 Compose 项目',
|
|
388
|
+
'list.noNetworks': '没有网络',
|
|
389
|
+
'list.noVolumes': '没有卷',
|
|
390
|
+
'list.noContainers': '没有容器',
|
|
391
|
+
'list.noMatch': '目标上没有匹配的数据,或筛选条件过窄。',
|
|
392
|
+
'list.noMatchFor': '没有匹配「{query}」的结果。',
|
|
393
|
+
'field.actions': '操作',
|
|
394
|
+
'btn.viewImageDetail': '查看镜像详情(层 / 构建历史)',
|
|
395
|
+
'btn.removeImageFull': '删除镜像(不可恢复)',
|
|
396
|
+
'btn.viewNetworkDetail': '查看网络详情',
|
|
397
|
+
'btn.viewVolumeDetail': '查看卷详情',
|
|
398
|
+
'error.copyFailed': '复制失败,请手动复制',
|
|
399
|
+
'msg.pickedAddedSkipped': '已新增 {added} 个,另有 {skipped} 个超出上限(最多 {max} 个流)未选',
|
|
400
|
+
'msg.pickedNone': '没有可新增的容器(已被勾选或不在当前筛选结果里)',
|
|
401
|
+
'msg.pickedAdded': '已新增 {count} 个',
|
|
402
|
+
'panel.title': 'Docker 容器',
|
|
403
|
+
'badge.readOnly': '只读模式',
|
|
404
|
+
'btn.refreshList': '刷新列表',
|
|
405
|
+
'option.overviewAllTargets': '(总览 · 全部目标)',
|
|
406
|
+
'option.noTargetSelected': '(未选择目标)',
|
|
407
|
+
'btn.exitOverview': '退出总览,回到当前目标的容器列表',
|
|
408
|
+
'btn.enterOverview': '不选目标,一屏看全部目标的容器概况(只读)',
|
|
409
|
+
'panel.overview': '总览',
|
|
410
|
+
'field.volume': '卷',
|
|
411
|
+
'btn.exitPick': '退出选择并清空勾选(Esc)',
|
|
412
|
+
'btn.pickMode': '多选容器,把它们的日志临时聚合成一条流',
|
|
413
|
+
'btn.exitSelection': '退出选择',
|
|
414
|
+
'btn.aggSelection': '聚合选择',
|
|
415
|
+
'placeholder.searchContainers': '搜索名称 / 镜像 / ID',
|
|
416
|
+
'placeholder.searchCompose': '搜索项目 / 服务 / 容器',
|
|
417
|
+
'placeholder.searchImages': '搜索镜像(仓库 / 标签 / ID)',
|
|
418
|
+
'list.imageCountRatio': '{filtered} / {total} 个镜像',
|
|
419
|
+
'btn.pullImage': '拉取镜像(docker pull,逐层实时进度)',
|
|
420
|
+
'btn.pruneImages': '清理 dangling(无标签)镜像',
|
|
421
|
+
'btn.pruneImagesDisabled': '清理 dangling 需要打开「允许变更操作」',
|
|
422
|
+
'placeholder.searchNetworks': '搜索网络(名称 / 驱动 / ID)',
|
|
423
|
+
'placeholder.searchVolumes': '搜索卷(名称 / 驱动 / 挂载点)',
|
|
424
|
+
'list.networkCountRatio': '{filtered} / {total} 个网络',
|
|
425
|
+
'list.volumeCountRatio': '{filtered} / {total} 个卷',
|
|
426
|
+
'btn.pruneNetworksFull': '清理未使用的网络(docker network prune)',
|
|
427
|
+
'btn.pruneNetworksDisabled': '清理网络需要打开「允许变更操作」',
|
|
428
|
+
'btn.pruneVolumesFull': '清理未使用的卷(docker volume prune,会删数据)',
|
|
429
|
+
'btn.pruneVolumesDisabled': '清理卷需要打开「允许变更操作」',
|
|
430
|
+
'meta.projectCount': '{count} 个项目',
|
|
431
|
+
'option.filterAll': '全部',
|
|
432
|
+
'check.includeStopped': '含已停止',
|
|
433
|
+
'check.autoRefresh': '自动刷新',
|
|
434
|
+
'banner.actionFailed': '操作失败',
|
|
435
|
+
'banner.sessionHostNotTarget': '当前会话主机还没配置为 Docker 目标',
|
|
436
|
+
'meta.sessionHost': '会话主机:',
|
|
437
|
+
'meta.bookParen': '(连接簿:{book})',
|
|
438
|
+
'hint.addSshTarget': ' — 到 插件配置 → Docker 容器面板 添加一条 kind=ssh 目标',
|
|
439
|
+
'hint.addSshInline': '(填 host/username,或用连接簿条目)',
|
|
440
|
+
'hint.addSshBook': ',直接选连接簿条目「{book}」',
|
|
441
|
+
'hint.addSshTail': ',保存后回到这里刷新即可。',
|
|
442
|
+
'banner.readOnlyMode': '当前为只读模式',
|
|
443
|
+
'hint.readOnlyMode': '容器的启动 / 停止 / 重启 / 删除,以及镜像、网络、卷的删除与清理,都需要到 插件配置 → Docker 容器面板 打开「允许变更操作」。',
|
|
444
|
+
'confirm.endTerminalTitle': '结束容器终端会话',
|
|
445
|
+
'confirm.endTerminalText': '关闭面板会结束「{label}」的终端会话(docker exec -it …)。',
|
|
446
|
+
'confirm.endTerminalHint': '若只是想给日志 / 列表腾地方,先点抽屉右上角的折叠按钮即可,会话会保持运行。',
|
|
447
|
+
'btn.endAndClose': '结束并关闭',
|
|
448
|
+
'error.configLoad': '读取配置失败:',
|
|
449
|
+
'list.newTargetName': '目标{index}',
|
|
450
|
+
'msg.saved': '已保存并热生效',
|
|
451
|
+
'msg.savedDirty': '已保存并热生效(表单在保存期间有新编辑,未覆盖你正在输入的内容)',
|
|
452
|
+
'error.saveFailed': '保存失败:',
|
|
453
|
+
'card.desc': '本机与 SSH 主机的容器与镜像:容器 / 镜像 / 网络 / 卷查看,默认只读,变更操作需显式开启。',
|
|
454
|
+
'card.name': 'Docker 容器面板',
|
|
455
|
+
'card.summary': '本机 / SSH 主机上的容器与镜像;默认只读,变更操作需显式开启',
|
|
456
|
+
'list.loadingConfig': '读取配置…',
|
|
457
|
+
'section.basic': '基本',
|
|
458
|
+
'check.enabled': '启用插件',
|
|
459
|
+
'check.announce': '向 agent 公告能力',
|
|
460
|
+
'hint.dockerBin': '默认 docker;podman 可填 podman',
|
|
461
|
+
'field.pollInterval': '统计刷新间隔(秒)',
|
|
462
|
+
'field.logTailDefault': '日志默认行数',
|
|
463
|
+
'hint.logTailDefault': '面板日志页 LINES 的初始值(面板内可临时改);它只是「行数」上限——快照还要过下面那道字节闸,所以不保证一定拿得到这么多行',
|
|
464
|
+
'field.maxOutput': '输出上限(KB)',
|
|
465
|
+
'hint.maxOutput': '单次输出的「字节」上限:日志快照 / inspect / exec 共用;日志行数够但字节超了会被截断(面板会给出截断横幅)。FOLLOW 流式日志不受它约束',
|
|
466
|
+
'field.execTimeout': 'exec 超时(秒)',
|
|
467
|
+
'section.capabilities': '能力开关(默认关闭)',
|
|
468
|
+
'check.allowMutations': '允许变更操作(容器启停删、镜像拉取 / 删除 / 清理)',
|
|
469
|
+
'check.allowExec': '允许 exec(在容器内执行命令)',
|
|
470
|
+
'hint.socketRoot': 'docker socket 等价于目标主机的 root 权限。开启后,浏览器面板与 agent 都能执行对应操作,请只在可信环境下打开。',
|
|
471
|
+
'hint.capabilityNotGranted': '⚠ 宿主尚未授权:这两个开关现在打不开,点它会给出一条就地确认的命令(免重启)。关掉它们随时可用。',
|
|
472
|
+
'badge.notEffective': '未生效:未获宿主授权',
|
|
473
|
+
'elev.title': '就地授权(免重启)',
|
|
474
|
+
'elev.lockedWhy': '本机任意进程都能发回环请求,所以「打开危险能力」这件事不能由这个页面自己说了算——必须在宿主的文件系统上确认一次。',
|
|
475
|
+
'elev.step': '在宿主的终端里执行下一条命令,开关会自动解锁:',
|
|
476
|
+
'elev.copy': '复制命令',
|
|
477
|
+
'elev.copied': '已复制',
|
|
478
|
+
'elev.expiresIn': '{sec} 秒后失效',
|
|
479
|
+
'elev.expired': '本次确认已过期,请重新生成。',
|
|
480
|
+
'elev.waiting': '等待确认…执行完命令这里会自动变成已授权。',
|
|
481
|
+
'elev.regenerate': '重新生成',
|
|
482
|
+
'elev.granted': '已授权并生效。',
|
|
483
|
+
'elev.grantedNeedSave': '已授权。点「保存」后生效。',
|
|
484
|
+
'elev.revoke': '撤销宿主授权',
|
|
485
|
+
'elev.grantedAt': '已授权 · {time}',
|
|
486
|
+
'elev.revoked': '已撤销宿主授权(配置开关保持不变,重新授权后会立刻生效)。',
|
|
487
|
+
'elev.viaEnv': '由启动环境变量授权;要撤销需在启动环境里去掉它并重启宿主。',
|
|
488
|
+
'elev.close': '收起',
|
|
489
|
+
'elev.disableFirst': '先关掉这个配置开关',
|
|
490
|
+
'elev.otherWay': '另一种方式(最强,需要重启宿主)',
|
|
491
|
+
'elev.envHow': '在「启动 dsh 的那个环境」里 export {env}=1,然后重启宿主。注意:启动之后再设、或写进别的配置文件都不算授权——这道闸门防的就是「运行期能改的东西冒充授权」。',
|
|
492
|
+
'elev.error': '就地授权没成功:',
|
|
493
|
+
'elev.copyManual': '当前环境拿不到剪贴板,请手动选中上面那条命令复制。',
|
|
494
|
+
'placeholder.targetName': '目标名',
|
|
495
|
+
'option.sshHost': 'SSH 主机',
|
|
496
|
+
'hint.localTarget': '宿主所在机器上的 docker',
|
|
497
|
+
'hint.staleBook': '引用的连接簿条目「{book}」不存在——请改选一个已有条目,或清空改为手填',
|
|
498
|
+
'option.noTtyBooks': '(无 tty 连接簿,请填内联信息)',
|
|
499
|
+
'option.inlineConnection': '(不用连接簿,手填)',
|
|
500
|
+
'option.staleBook': '⚠ 条目已不存在:',
|
|
501
|
+
'option.bookNamed': '连接簿:{name}',
|
|
502
|
+
'hint.staleInline': '引用的条目「{book}」不在 tty 连接簿里——请改选,或清空后手填',
|
|
503
|
+
'option.authKey': '私钥',
|
|
504
|
+
'option.authPassword': '密码',
|
|
505
|
+
'hint.keyPath': '支持 ~ 与 ~/ 展开(不支持 ~user);Windows 请写绝对路径',
|
|
506
|
+
'placeholder.passwordSet': '(已设置,留空保持不变)',
|
|
507
|
+
'btn.addTarget': '添加目标',
|
|
508
|
+
'hint.addTarget': 'SSH 目标推荐直接选 tty 终端面板的连接簿条目(凭证只需维护一处);手填时密码 / 口令建议写 env:NAME(凭据引用:由官方凭据存储解析,缺失时退回环境变量)。',
|
|
509
|
+
'section.tofu': 'SSH 主机密钥记录(TOFU)',
|
|
510
|
+
'list.noHostKeys': '暂无记录 — 首次 SSH 连接成功后自动记录主机指纹(若 tty 已记录同一主机,会直接复用)。',
|
|
511
|
+
'hint.tofu': '指纹变更时连接会被拒绝(防中间人);确认安全后删除对应记录即可重连。删除记录在点「保存」后生效。',
|
|
512
|
+
'status.saving': '保存中…',
|
|
513
|
+
'btn.save': '保存',
|
|
514
|
+
'card.guide': '本机与 SSH 主机的容器、镜像、Compose、网络与卷',
|
|
515
|
+
'hint.openPanelForTarget': '打开该主机的 Docker 容器面板(目标:{target})',
|
|
516
|
+
'hint.openPanelCurrentHost': '打开当前会话主机的 Docker 容器面板',
|
|
517
|
+
'hint.openPanelUnconfigured': '该主机尚未配置为 Docker 目标 — 点击打开面板查看/配置',
|
|
518
|
+
'error.logStream': '日志流异常',
|
|
519
|
+
'meta.targetError': '{name}:{error}',
|
|
520
|
+
'hint.unreachableTail': '(其余目标的正常结果不受影响)',
|
|
521
|
+
}
|
|
522
|
+
const I18N_EN = {
|
|
523
|
+
'error.requestFailed': 'Request failed',
|
|
524
|
+
'list.noPorts': 'No port mappings',
|
|
525
|
+
'status.running': 'Running',
|
|
526
|
+
'status.stopped': 'Stopped',
|
|
527
|
+
'status.created': 'Created',
|
|
528
|
+
'status.paused': 'Paused',
|
|
529
|
+
'status.restarting': 'Restarting',
|
|
530
|
+
'status.removing': 'Removing',
|
|
531
|
+
'status.unknown': 'Unknown',
|
|
532
|
+
'hint.pickMaxLocal': 'At most {max} containers; browsers limit concurrent long-lived connections',
|
|
533
|
+
'hint.pickMaxSsh': 'At most {max} containers (a single SSH connection must also carry live streams and short refresh commands)',
|
|
534
|
+
'hint.pickMany': 'Many streams — browsers limit concurrent long-lived connections',
|
|
535
|
+
'hint.pickAtLeastTwo': 'Select at least 2 containers',
|
|
536
|
+
'option.presetAll': 'All visible',
|
|
537
|
+
'status.unhealthy': 'Unhealthy',
|
|
538
|
+
'status.attention': 'Needs attention',
|
|
539
|
+
'option.presetSameImage': 'Same image',
|
|
540
|
+
'option.presetSameProject': 'Same project',
|
|
541
|
+
'status.oomKilled': 'OOM-killed',
|
|
542
|
+
'status.dead': 'Dead',
|
|
543
|
+
'status.restartingLoop': 'Restart loop',
|
|
544
|
+
'status.exitNonzero': 'Non-zero exit',
|
|
545
|
+
'hint.openDetail': 'Open container details',
|
|
546
|
+
'meta.finishedAt': 'Ended at ',
|
|
547
|
+
'meta.startedAt': 'Started at ',
|
|
548
|
+
'meta.restartCount': 'Restarts ',
|
|
549
|
+
'meta.exitCode': 'Exit code ',
|
|
550
|
+
'error.unknown': 'Unknown error',
|
|
551
|
+
'hint.attentionTruncated': 'Attention results truncated: {targets} target(s) returned {total} rows in total; only the first {shown} are listed',
|
|
552
|
+
'hint.attentionDegraded': '{count} target(s) returned degraded results (some container details were unavailable — OOM / restart loops may be missed)',
|
|
553
|
+
'msg.clauseSep': '; ',
|
|
554
|
+
'list.noTargetsConfigured': 'No Docker targets configured yet',
|
|
555
|
+
'hint.overviewNoTargets': 'Add targets under Settings → Docker containers and the overview summarizes every host on one screen.',
|
|
556
|
+
'list.loading': 'Loading…',
|
|
557
|
+
'list.allGood': 'All good',
|
|
558
|
+
'list.allGoodHint': 'No containers need attention on any target (unhealthy / restart loop / OOM-killed / non-zero exit / dead).',
|
|
559
|
+
'field.containerName': 'Container',
|
|
560
|
+
'field.target': 'Target',
|
|
561
|
+
'field.state': 'State',
|
|
562
|
+
'field.reason': 'Reason',
|
|
563
|
+
'field.image': 'Image',
|
|
564
|
+
'badge.unreachableTargets': '{count} target(s) unreachable',
|
|
565
|
+
'hint.switchToTarget': 'Switch to this target\'s container list',
|
|
566
|
+
'option.local': 'Local',
|
|
567
|
+
'status.attentionApprox': 'Needs attention (heuristic)',
|
|
568
|
+
'status.unreachable': 'Unreachable',
|
|
569
|
+
'panel.attentionContainers': 'Containers needing attention',
|
|
570
|
+
'panel.attentionContainersCount': 'Containers needing attention ({count})',
|
|
571
|
+
'btn.cancel': 'Cancel',
|
|
572
|
+
'status.executing': 'Running…',
|
|
573
|
+
'status.sampling': 'Sampling…',
|
|
574
|
+
'status.pendingAction': 'Running {action}… please wait',
|
|
575
|
+
'status.needMutations': 'Enable “Allow changes” first',
|
|
576
|
+
'field.ports': 'Ports',
|
|
577
|
+
'hint.hostNetwork': '(host network: ports are host ports)',
|
|
578
|
+
'field.created': 'Created',
|
|
579
|
+
'hint.openTerminal': 'Open an interactive terminal in the container (docker exec -it {name} sh)',
|
|
580
|
+
'btn.viewLogs': 'Logs',
|
|
581
|
+
'btn.stats': 'Stats',
|
|
582
|
+
'btn.stopContainer': 'Stop container',
|
|
583
|
+
'btn.startContainer': 'Start container',
|
|
584
|
+
'btn.restartContainer': 'Restart container',
|
|
585
|
+
'btn.removeContainer': 'Remove container (irreversible)',
|
|
586
|
+
'error.noSessionsService': 'The host provides no sessions service',
|
|
587
|
+
'error.noOpenSession': 'No session is open',
|
|
588
|
+
'error.sessionNotReady': 'Session not ready (scope not mounted)',
|
|
589
|
+
'error.noConversationService': 'The host has no conversation service',
|
|
590
|
+
'error.deliverFailed': 'Could not hand off to the session: ',
|
|
591
|
+
'btn.export': '⬇ Export',
|
|
592
|
+
'hint.exportLog': 'Plain text, lines exactly as they are',
|
|
593
|
+
'hint.exportMd': 'With source and row-count header, good as a ticket attachment',
|
|
594
|
+
'panel.exportLogs': 'Export logs',
|
|
595
|
+
'error.copyRejected': 'The browser refused the copy',
|
|
596
|
+
'hint.revealSession': ' · terminal collapsed, you are in the session',
|
|
597
|
+
'error.noInputFacade': 'The host provides no session input facade, cannot fill a draft',
|
|
598
|
+
'msg.logDraftFilled': 'Log snippet inserted into the input box, review before sending',
|
|
599
|
+
'msg.filledInCurrentSession': 'Inserted into the current session input box',
|
|
600
|
+
'msg.filledDraft': 'Inserted into the input box',
|
|
601
|
+
'msg.logSentToSession': 'Log snippet sent to the session',
|
|
602
|
+
'msg.logSentToCurrentSession': 'Log snippet sent to the current session',
|
|
603
|
+
'msg.sent': 'Sent',
|
|
604
|
+
'btn.askAgent': 'Ask Agent',
|
|
605
|
+
'meta.currentSession': ' · current session',
|
|
606
|
+
'btn.sendToSession': 'Send straight to the current session',
|
|
607
|
+
'hint.sendNow': 'Start analysing right away',
|
|
608
|
+
'btn.fillDraft': 'Insert into the input box, I\'ll edit first',
|
|
609
|
+
'hint.fillDraft': 'Not sent; the terminal is collapsed, edit it in the session and send',
|
|
610
|
+
'hint.untrustedLogs': 'Logs are untrusted content from the container: they may contain credentials or text that tries to steer the model. Review before sending.',
|
|
611
|
+
'error.noEventSource': 'This environment has no EventSource, live follow is unavailable',
|
|
612
|
+
'status.containerExited': 'Container exited',
|
|
613
|
+
'meta.exitCodeNote': ' (exit code {code})',
|
|
614
|
+
'status.streamEndedSnapshot': ', log stream ended, back to the snapshot',
|
|
615
|
+
'hint.logBacklog': 'Host-side log backlog exceeded the limit (the push rate outran the browser); disconnected and reconnecting. A reconnect only appends new lines, history is not replayed',
|
|
616
|
+
'status.streamStoppedReconnecting': 'The server stopped the log stream, reconnecting…',
|
|
617
|
+
'status.statsFollowing': 'Live (docker stats)',
|
|
618
|
+
'status.statsConnecting': 'Connecting to the stats stream…',
|
|
619
|
+
'status.reconnecting': 'Connection lost, reconnecting…',
|
|
620
|
+
'status.statsClosed': 'Stats stream closed',
|
|
621
|
+
'status.statsStream': 'Stats stream',
|
|
622
|
+
'status.logsFollowing': 'Live (docker logs -f)',
|
|
623
|
+
'status.logsConnecting': 'Connecting to the log stream…',
|
|
624
|
+
'status.logsClosed': 'Log stream closed',
|
|
625
|
+
'status.logsStream': 'Log stream',
|
|
626
|
+
'status.statsEnded': 'Stats stream ended',
|
|
627
|
+
'status.statsExited': ' (docker stats exited, exit code {code})',
|
|
628
|
+
'status.statsExitedNoCode': ' (docker stats exited)',
|
|
629
|
+
'status.backToSnapshotPolling': ', back to snapshot polling',
|
|
630
|
+
'error.statsStream': 'Stats stream error',
|
|
631
|
+
'error.inspectFailed': 'Failed to load container details',
|
|
632
|
+
'meta.parenValue': ' ({value})',
|
|
633
|
+
'field.containerId': 'Container ID',
|
|
634
|
+
'field.startedAt': 'Started at',
|
|
635
|
+
'field.finishedAt': 'Finished at',
|
|
636
|
+
'field.exitCode': 'Exit code',
|
|
637
|
+
'field.restartCount': 'Restart count',
|
|
638
|
+
'field.restartPolicy': 'Restart policy',
|
|
639
|
+
'field.mounts': 'Mounts',
|
|
640
|
+
'meta.readOnly': ' (read-only)',
|
|
641
|
+
'field.networks': 'Network',
|
|
642
|
+
'field.command': 'Command',
|
|
643
|
+
'field.workingDir': 'Working dir',
|
|
644
|
+
'field.user': 'User',
|
|
645
|
+
'meta.healthLog': 'Recent health check output: ',
|
|
646
|
+
'panel.oneOffExec': 'One-off command (docker exec)',
|
|
647
|
+
'banner.execDisabled': 'exec disabled',
|
|
648
|
+
'hint.execDisabled': 'Enable “Allow exec” under Settings → Docker containers, or copy the exec command from the card into the terminal panel to enter the container interactively.',
|
|
649
|
+
'placeholder.execCommand': 'e.g. ls -la /app or cat /etc/nginx/nginx.conf',
|
|
650
|
+
'btn.exec': 'Run',
|
|
651
|
+
'error.execFailed': 'Run failed',
|
|
652
|
+
'meta.duration': ' · took {ms}ms',
|
|
653
|
+
'meta.truncated': ' · output truncated',
|
|
654
|
+
'list.noOutput': '(no output)',
|
|
655
|
+
'hint.logTailTitle': 'Rows fetched from the tail. The snapshot is also capped by the “Output limit (KB)” setting (currently {kb}KB) — enough rows can still truncate on bytes, so fewer rows may come back; the FOLLOW stream is not bound by that byte cap (this panel\'s buffer limit applies instead).',
|
|
656
|
+
'hint.followOff': 'Stop live follow (back to log snapshots)',
|
|
657
|
+
'hint.followOn': 'Follow container logs live (docker logs -f)',
|
|
658
|
+
'hint.autoOff': 'Pause polling while FOLLOW is on',
|
|
659
|
+
'hint.autoOn': 'Re-fetch log snapshots at the interval below',
|
|
660
|
+
'hint.autoRefreshTitle': 'Auto refresh interval (used while AUTO REFRESH is on)',
|
|
661
|
+
'btn.refreshLogs': 'Refresh logs',
|
|
662
|
+
'placeholder.filterLogs': 'Filter logs…',
|
|
663
|
+
'btn.clearFilter': 'Clear filter',
|
|
664
|
+
'hint.levelFilter': 'Filter by log level (shows ≥ the selected level; lines without a level prefix continue the previous line and follow its level)',
|
|
665
|
+
'hint.exportMenu': 'Export what is displayed: .log (plain text) / .md (with source and row-count header, good as a ticket attachment)',
|
|
666
|
+
'meta.rowsSuffix': ' rows',
|
|
667
|
+
'panel.containerLogs': 'Container logs',
|
|
668
|
+
'error.logsFailed': 'Failed to load logs',
|
|
669
|
+
'hint.fetchFailed': ' (the request never reached the host: it may have just restarted, or the connection was interrupted)',
|
|
670
|
+
'btn.retry': 'Retry',
|
|
671
|
+
'error.logStreamInterrupted': 'Log stream interrupted',
|
|
672
|
+
'hint.bufferExceeded': 'Logs exceeded the buffer limit ({lines} rows / {mb}MB); the oldest content was dropped',
|
|
673
|
+
'hint.streamKeepsRecent': 'The stream keeps only the most recent rows; for full history turn off FOLLOW and use snapshots, or lower “LINES”.',
|
|
674
|
+
'hint.outputTruncated': 'Log output exceeded the “Output limit ({kb}KB)” and was truncated',
|
|
675
|
+
'hint.byteCap': 'This is a “byte” cap, not a row cap — so LINES = 5000 may still return only part of it. To keep more logs, raise “Output limit (KB)” in the settings card, or turn on FOLLOW (the stream is not bound by it).',
|
|
676
|
+
'list.waitingLogs': 'Waiting for logs…',
|
|
677
|
+
'list.noLogs': '(no logs)',
|
|
678
|
+
'list.noMatchingLogs': '(no matching logs)',
|
|
679
|
+
'btn.backToBottom': 'Back to bottom',
|
|
680
|
+
'hint.statsFollowOff': 'Stop live follow (back to docker stats snapshots)',
|
|
681
|
+
'hint.statsFollowOn': 'Follow resource usage live (docker stats, one row per second)',
|
|
682
|
+
'hint.sparkWindow': '60 points ≈ the last minute',
|
|
683
|
+
'hint.sparkFollow': 'Turn on FOLLOW for the live trend',
|
|
684
|
+
'error.statsFailed': 'Failed to load stats',
|
|
685
|
+
'field.metric': 'Metric',
|
|
686
|
+
'field.value': 'Value',
|
|
687
|
+
'field.usageTrend': 'Usage / trend',
|
|
688
|
+
'hint.cpuSpark': 'CPU% over the last 60 samples',
|
|
689
|
+
'field.memory': 'Memory',
|
|
690
|
+
'hint.memSpark': 'Memory usage % over the last 60 samples',
|
|
691
|
+
'field.netIO': 'Network IO',
|
|
692
|
+
'field.blockIO': 'Disk IO',
|
|
693
|
+
'panel.tabOverview': 'Overview',
|
|
694
|
+
'panel.tabLogs': 'Logs',
|
|
695
|
+
'panel.tabStats': 'Stats',
|
|
696
|
+
'btn.backToContainers': 'Back to containers',
|
|
697
|
+
'btn.refresh': 'Refresh',
|
|
698
|
+
'btn.closePanel': 'Close panel',
|
|
699
|
+
'field.entrypoint': 'Entrypoint',
|
|
700
|
+
'error.imageDetailFailed': 'Failed to load image details',
|
|
701
|
+
'field.labels': 'Labels',
|
|
702
|
+
'list.dangling': '<none> (dangling)',
|
|
703
|
+
'field.size': 'Size',
|
|
704
|
+
'field.virtualSize': 'With parent layers',
|
|
705
|
+
'field.platform': 'Platform',
|
|
706
|
+
'field.layerCount': 'Layers',
|
|
707
|
+
'field.exposedPorts': 'Exposed ports',
|
|
708
|
+
'panel.layersCount': 'Layers ({count})',
|
|
709
|
+
'list.noLayerInfo': 'This image has no layer information (scratch build or an old docker).',
|
|
710
|
+
'panel.labelsCount': 'Labels ({count})',
|
|
711
|
+
'error.historyFailed': 'Failed to load the build history',
|
|
712
|
+
'list.noHistory': 'No build history',
|
|
713
|
+
'hint.noHistory': 'This docker version has neither history --format (needs Docker ≥ 26) nor a plain-text table that could be parsed.',
|
|
714
|
+
'field.layerId': 'Layer ID',
|
|
715
|
+
'field.buildCommand': 'Build command',
|
|
716
|
+
'panel.tabHistory': 'Build history',
|
|
717
|
+
'btn.backToImages': 'Back to images',
|
|
718
|
+
'btn.refreshImageDetail': 'Refresh image details',
|
|
719
|
+
'error.networkDetailFailed': 'Failed to load network details',
|
|
720
|
+
'field.name': 'Name',
|
|
721
|
+
'field.driver': 'Driver',
|
|
722
|
+
'field.scope': 'Scope',
|
|
723
|
+
'field.subnets': 'Subnets',
|
|
724
|
+
'field.gateway': 'Gateway',
|
|
725
|
+
'field.attributes': 'Attributes',
|
|
726
|
+
'field.options': 'Options',
|
|
727
|
+
'list.noContainersInNetwork': 'No container is attached to this network',
|
|
728
|
+
'panel.containers': 'Containers',
|
|
729
|
+
'panel.attachedContainers': 'Attached containers',
|
|
730
|
+
'btn.backToNetworks': 'Back to networks',
|
|
731
|
+
'btn.refreshNetworkDetail': 'Refresh network details',
|
|
732
|
+
'btn.removeNetwork': 'Remove network (irreversible)',
|
|
733
|
+
'btn.removeNetworkDisabled': 'Enable “Allow changes” to remove a network',
|
|
734
|
+
'error.removeNetworkFailed': 'Failed to remove the network',
|
|
735
|
+
'btn.removeNetworkShort': 'Remove network',
|
|
736
|
+
'confirm.removeNetwork': 'Remove network {name}? Docker refuses while containers are still attached; afterwards its containers lose the network and must be recreated or attached elsewhere.',
|
|
737
|
+
'btn.delete': 'Delete',
|
|
738
|
+
'error.volumeDetailFailed': 'Failed to load volume details',
|
|
739
|
+
'field.mountpoint': 'Mountpoint',
|
|
740
|
+
'btn.backToVolumes': 'Back to volumes',
|
|
741
|
+
'btn.refreshVolumeDetail': 'Refresh volume details',
|
|
742
|
+
'btn.removeVolume': 'Remove volume (its data is lost, irreversible)',
|
|
743
|
+
'btn.removeVolumeDisabled': 'Enable “Allow changes” to remove a volume',
|
|
744
|
+
'error.removeVolumeFailed': 'Failed to remove the volume',
|
|
745
|
+
'btn.removeVolumeShort': 'Remove volume',
|
|
746
|
+
'confirm.removeVolume': 'Remove volume {name}? Its data is deleted with it and cannot be recovered; docker refuses while containers still use it.',
|
|
747
|
+
'error.noEventSourcePull': 'This environment has no EventSource, pull progress is unavailable',
|
|
748
|
+
'status.pullDone': 'Pull complete',
|
|
749
|
+
'status.pullEnded': 'Pull finished (exit code {code})',
|
|
750
|
+
'status.pulling': 'Pulling (docker pull)…',
|
|
751
|
+
'status.pullConnecting': 'Connecting to the pull stream…',
|
|
752
|
+
'status.pullStreamClosed': 'Pull stream closed',
|
|
753
|
+
'panel.pullImage': 'Pull image',
|
|
754
|
+
'banner.pullNeedsMutations': 'Enable “Allow changes” to pull an image',
|
|
755
|
+
'hint.pullNeedsMutations': 'docker pull writes to the target\'s image store and uses disk and bandwidth. Enable “Allow changes” under Settings → Docker containers and you can pull here.',
|
|
756
|
+
'placeholder.imageRef': 'Image reference, e.g. nginx:1.27 or ghcr.io/org/app:latest',
|
|
757
|
+
'btn.stop': 'Stop',
|
|
758
|
+
'btn.pull': 'Pull',
|
|
759
|
+
'error.pullFailed': 'Pull failed',
|
|
760
|
+
'hint.pullProgressDropped': 'Progress exceeded {lines} rows; the earliest progress lines were dropped',
|
|
761
|
+
'meta.dotExitCode': ' · exit code {code}',
|
|
762
|
+
'list.waitingPull': 'Waiting for docker pull output…',
|
|
763
|
+
'hint.pullPlaceholder': 'Enter an image reference and hit “Pull”; the per-layer progress appears here live.',
|
|
764
|
+
'badge.notCompose': '(non-Compose container)',
|
|
765
|
+
'badge.runningRatio': '{running} / {total} running',
|
|
766
|
+
'badge.unhealthyCount': '{count} unhealthy',
|
|
767
|
+
'badge.serviceCount': '{count} services',
|
|
768
|
+
'field.service': 'Service',
|
|
769
|
+
'panel.aggregatedLogs': 'Aggregated logs',
|
|
770
|
+
'btn.backToCompose': 'Back to Compose projects',
|
|
771
|
+
'option.allLevels': 'All levels',
|
|
772
|
+
'meta.source': '- Source: ',
|
|
773
|
+
'meta.containersLine': '- Containers ({count}): {names}',
|
|
774
|
+
'meta.lines': '- Rows: {count}',
|
|
775
|
+
'meta.exportedAt': '- Exported at: {time}',
|
|
776
|
+
'msg.listSep': ', ',
|
|
777
|
+
'status.aggConnected': 'Connected to {count} container log stream(s) (docker logs -f)',
|
|
778
|
+
'status.aggConnecting': 'Connecting to container log streams…',
|
|
779
|
+
'status.aggReconnecting': 'Some connections were lost, reconnecting…',
|
|
780
|
+
'status.aggPartialError': 'Some container log streams errored',
|
|
781
|
+
'status.aggClosed': 'All container log streams ended',
|
|
782
|
+
'status.noEventSource': 'This environment has no EventSource',
|
|
783
|
+
'status.aggEmpty': 'This project has no containers to aggregate',
|
|
784
|
+
'hint.aggBufferExceeded': 'Aggregated logs exceeded the buffer limit ({lines} rows / {mb}MB); the oldest content was dropped',
|
|
785
|
+
'placeholder.filterServiceLogs': 'Filter service name / log content…',
|
|
786
|
+
'hint.aggTail': 'Initial rows fetched per container ({containers} containers → about {rows} rows); changing it reconnects every stream',
|
|
787
|
+
'btn.resumeLive': 'Resume live (shows the {count} rows buffered while paused at once and scrolls to the bottom)',
|
|
788
|
+
'hint.pause': 'Pause (freeze the current picture: new logs keep arriving but are not appended, so the view is not pushed away)',
|
|
789
|
+
'status.live': 'Live',
|
|
790
|
+
'btn.hideTimestamps': 'Hide the timestamp on each row',
|
|
791
|
+
'btn.showTimestamps': 'Show the timestamp on each row (timestamps always arrive with the stream, this only affects display)',
|
|
792
|
+
'field.timestamps': 'Timestamps',
|
|
793
|
+
'option.orderArrivalHint': 'Show in arrival order (live follow, zero delay)',
|
|
794
|
+
'hint.orderTimeHint': 'Merge by container timestamp (one true timeline across containers, costs about {ms}ms of delay)',
|
|
795
|
+
'option.orderTime': 'By time',
|
|
796
|
+
'option.orderArrival': 'By arrival',
|
|
797
|
+
'meta.containerCount': '{count} containers',
|
|
798
|
+
'panel.aggContainerLogs': 'Aggregated container logs',
|
|
799
|
+
'hint.eventsToggle': 'Container event activity (docker events): click to collapse / expand',
|
|
800
|
+
'panel.activity': 'Activity',
|
|
801
|
+
'list.noEvents': 'No events yet',
|
|
802
|
+
'list.recentEvents': 'latest {recent} / {total}',
|
|
803
|
+
'list.noEventsHint': 'No events yet (container start / die / health actions show up here)',
|
|
804
|
+
'status.picked': '{count} selected',
|
|
805
|
+
'hint.aggRun': 'Aggregate the selected containers\' logs into one stream',
|
|
806
|
+
'panel.pickPresets': 'Select by condition',
|
|
807
|
+
'hint.pickPreset': 'Tick containers matching “{label}” in the current filter (at most {max} streams)',
|
|
808
|
+
'hint.pickPresetOver': '; {count} more exceed the limit and will not be selected',
|
|
809
|
+
'btn.clearPicked': 'Clear selection',
|
|
810
|
+
'btn.clear': 'Clear',
|
|
811
|
+
'btn.backToContainersExitPick': 'Back to containers (leave selection mode)',
|
|
812
|
+
'panel.aggLogsTitle': 'Aggregated logs · {count} containers',
|
|
813
|
+
'hint.termResize': 'Drag to resize the terminal (double-click to collapse / expand; focus and use ↑/↓ to fine-tune)',
|
|
814
|
+
'hint.termResizeAria': 'Resize the terminal drawer',
|
|
815
|
+
'status.termCollapsed': 'Collapsed · session keeps running',
|
|
816
|
+
'status.termDocked': 'Hosted by the terminal panel · collapsing keeps the session',
|
|
817
|
+
'btn.expandTerminal': 'Expand the terminal (session is still running)',
|
|
818
|
+
'btn.collapseTerminal': 'Collapse the terminal (session keeps running)',
|
|
819
|
+
'btn.endTerminal': 'End the terminal session and close the drawer',
|
|
820
|
+
'error.terminalStart': 'Terminal failed to start: ',
|
|
821
|
+
'status.eventsLive': 'Receiving live (docker events)',
|
|
822
|
+
'status.eventsConnecting': 'Connecting to the event stream…',
|
|
823
|
+
'status.eventsClosed': 'Event stream closed',
|
|
824
|
+
'status.eventsStream': 'Event stream',
|
|
825
|
+
'status.eventsEnded': 'Event stream ended',
|
|
826
|
+
'status.backToListRefresh': ', the list returns to AUTO REFRESH / manual refresh',
|
|
827
|
+
'hint.conversationHidden': ' · the session is behind this panel: close or minimise the panel/terminal to see it',
|
|
828
|
+
'msg.copied': 'Copied: ',
|
|
829
|
+
'error.copyManual': 'Copy failed, run it manually: ',
|
|
830
|
+
'hint.ttyOutdated': 'The terminal panel is too old (interactive container access needs dsh-tty ≥ 0.14.0). The command was copied — paste it into a system terminal',
|
|
831
|
+
'hint.ttyNotInstalled': 'The dsh-tty terminal panel is not installed. The command was copied — paste it into a system terminal (install dsh-tty to open a terminal right here)',
|
|
832
|
+
'hint.inlineCreds': 'The inline target uses key/password auth; the browser cannot obtain the credentials',
|
|
833
|
+
'btn.removeContainerShort': 'Remove container',
|
|
834
|
+
'confirm.removeContainer': 'Remove container {name}? Its writable layer and configuration are deleted (named volumes are kept). This cannot be undone.',
|
|
835
|
+
'confirm.containerAction': '{action} container {name}?',
|
|
836
|
+
'btn.start': 'Start',
|
|
837
|
+
'btn.restart': 'Restart',
|
|
838
|
+
'btn.confirm': 'OK',
|
|
839
|
+
'msg.actionResult': '{action} {name}: {message}',
|
|
840
|
+
'btn.removeImage': 'Remove image',
|
|
841
|
+
'confirm.removeImage': 'Remove image {ref}? It fails while containers or child images reference it; afterwards you must pull or rebuild to restore it, and this cannot be undone.',
|
|
842
|
+
'msg.imageDeleted': 'Deleted {ref}: {message}',
|
|
843
|
+
'btn.pruneDangling': 'Prune dangling images',
|
|
844
|
+
'confirm.pruneImages': 'Prune every untagged (<none>:<none>) image layer on this target to free disk space; tagged images are untouched.',
|
|
845
|
+
'btn.prune': 'Prune',
|
|
846
|
+
'msg.prunedImages': 'Pruned dangling images: ',
|
|
847
|
+
'btn.pruneNetworks': 'Prune unused networks',
|
|
848
|
+
'confirm.pruneNetworks': 'Prune every network with no containers attached on this target. Compose project networks are included (they are recreated on the next up), but a running project briefly loses its network.',
|
|
849
|
+
'msg.prunedNetworks': 'Pruned unused networks: ',
|
|
850
|
+
'btn.pruneVolumes': 'Prune unused volumes',
|
|
851
|
+
'confirm.pruneVolumes': 'Prune every volume not used by a container on this target — the data inside is deleted with it and cannot be recovered. docker ≥ 23 removes only anonymous volumes (no --all), older versions also remove named ones; check that no data volume must be kept.',
|
|
852
|
+
'msg.prunedVolumes': 'Pruned unused volumes: ',
|
|
853
|
+
'banner.switching': 'Switching to {target}{host}. The list below is still {listTarget}{listHost} data; it is read-only until the switch finishes.',
|
|
854
|
+
'status.switchingTo': 'Switching to',
|
|
855
|
+
'status.showing': 'Showing: ',
|
|
856
|
+
'list.sessionHostNotTarget': 'The current session host is not a Docker target yet',
|
|
857
|
+
'hint.sessionHostNotTarget': 'Follow the hint above and add a target under Settings → Docker containers (picking a bookmark is recommended), then come back and refresh. To avoid mixing up hosts, the panel never switches to another target on its own.',
|
|
858
|
+
'hint.addTargetEmpty': 'Add a target under Settings → Docker containers: pick “Local” for this machine; for a remote host you can reference a tty terminal panel bookmark.',
|
|
859
|
+
'list.targetError': 'This target\'s data could not be loaded',
|
|
860
|
+
'hint.targetError': 'The error banner above says why (target unreachable / docker not running / insufficient permissions). Fix it and hit refresh at the top right.',
|
|
861
|
+
'list.noImages': 'No images',
|
|
862
|
+
'list.noCompose': 'No Compose projects',
|
|
863
|
+
'list.noNetworks': 'No networks',
|
|
864
|
+
'list.noVolumes': 'No volumes',
|
|
865
|
+
'list.noContainers': 'No containers',
|
|
866
|
+
'list.noMatch': 'No matching data on the target, or the filter is too narrow.',
|
|
867
|
+
'list.noMatchFor': 'No results matching “{query}”.',
|
|
868
|
+
'field.actions': 'Actions',
|
|
869
|
+
'btn.viewImageDetail': 'View image details (layers / build history)',
|
|
870
|
+
'btn.removeImageFull': 'Remove image (irreversible)',
|
|
871
|
+
'btn.viewNetworkDetail': 'View network details',
|
|
872
|
+
'btn.viewVolumeDetail': 'View volume details',
|
|
873
|
+
'error.copyFailed': 'Copy failed, copy it manually',
|
|
874
|
+
'msg.pickedAddedSkipped': 'Added {added}; {skipped} more exceed the limit (at most {max} streams)',
|
|
875
|
+
'msg.pickedNone': 'No containers can be added (already selected or outside the current filter)',
|
|
876
|
+
'msg.pickedAdded': 'Added {count}',
|
|
877
|
+
'panel.title': 'Docker containers',
|
|
878
|
+
'badge.readOnly': 'Read-only',
|
|
879
|
+
'btn.refreshList': 'Refresh list',
|
|
880
|
+
'option.overviewAllTargets': '(Overview · all targets)',
|
|
881
|
+
'option.noTargetSelected': '(No target selected)',
|
|
882
|
+
'btn.exitOverview': 'Leave the overview, back to the current target\'s container list',
|
|
883
|
+
'btn.enterOverview': 'Pick no target and see every target\'s containers on one screen (read-only)',
|
|
884
|
+
'panel.overview': 'Overview',
|
|
885
|
+
'field.volume': 'Volumes',
|
|
886
|
+
'btn.exitPick': 'Leave selection and clear it (Esc)',
|
|
887
|
+
'btn.pickMode': 'Select several containers to aggregate their logs into one stream',
|
|
888
|
+
'btn.exitSelection': 'Leave selection',
|
|
889
|
+
'btn.aggSelection': 'Aggregate selection',
|
|
890
|
+
'placeholder.searchContainers': 'Search name / image / ID',
|
|
891
|
+
'placeholder.searchCompose': 'Search project / service / container',
|
|
892
|
+
'placeholder.searchImages': 'Search images (repository / tag / ID)',
|
|
893
|
+
'list.imageCountRatio': '{filtered} / {total} images',
|
|
894
|
+
'btn.pullImage': 'Pull image (docker pull, live per-layer progress)',
|
|
895
|
+
'btn.pruneImages': 'Prune dangling (untagged) images',
|
|
896
|
+
'btn.pruneImagesDisabled': 'Enable “Allow changes” to prune dangling images',
|
|
897
|
+
'placeholder.searchNetworks': 'Search networks (name / driver / ID)',
|
|
898
|
+
'placeholder.searchVolumes': 'Search volumes (name / driver / mountpoint)',
|
|
899
|
+
'list.networkCountRatio': '{filtered} / {total} networks',
|
|
900
|
+
'list.volumeCountRatio': '{filtered} / {total} volumes',
|
|
901
|
+
'btn.pruneNetworksFull': 'Prune unused networks (docker network prune)',
|
|
902
|
+
'btn.pruneNetworksDisabled': 'Enable “Allow changes” to prune networks',
|
|
903
|
+
'btn.pruneVolumesFull': 'Prune unused volumes (docker volume prune, deletes data)',
|
|
904
|
+
'btn.pruneVolumesDisabled': 'Enable “Allow changes” to prune volumes',
|
|
905
|
+
'meta.projectCount': '{count} projects',
|
|
906
|
+
'option.filterAll': 'All',
|
|
907
|
+
'check.includeStopped': 'Include stopped',
|
|
908
|
+
'check.autoRefresh': 'Auto refresh',
|
|
909
|
+
'banner.actionFailed': 'Action failed',
|
|
910
|
+
'banner.sessionHostNotTarget': 'The current session host is not configured as a Docker target',
|
|
911
|
+
'meta.sessionHost': 'Session host: ',
|
|
912
|
+
'meta.bookParen': ' (bookmark: {book})',
|
|
913
|
+
'hint.addSshTarget': ' — add a kind=ssh target under Settings → Docker containers',
|
|
914
|
+
'hint.addSshInline': ' (fill in host/username, or pick a bookmark)',
|
|
915
|
+
'hint.addSshBook': ', then pick the bookmark “{book}”',
|
|
916
|
+
'hint.addSshTail': ', save, and refresh here.',
|
|
917
|
+
'banner.readOnlyMode': 'Currently read-only',
|
|
918
|
+
'hint.readOnlyMode': 'Starting / stopping / restarting / removing containers, and removing or pruning images, networks and volumes, all require enabling “Allow changes” under Settings → Docker containers.',
|
|
919
|
+
'confirm.endTerminalTitle': 'End the container terminal session',
|
|
920
|
+
'confirm.endTerminalText': 'Closing the panel ends the terminal session for “{label}” (docker exec -it …).',
|
|
921
|
+
'confirm.endTerminalHint': 'If you only need room for the logs or the list, hit the collapse button at the top right of the drawer instead — the session keeps running.',
|
|
922
|
+
'btn.endAndClose': 'End and close',
|
|
923
|
+
'error.configLoad': 'Failed to load the config: ',
|
|
924
|
+
'list.newTargetName': 'Target {index}',
|
|
925
|
+
'msg.saved': 'Saved and applied live',
|
|
926
|
+
'msg.savedDirty': 'Saved and applied live (the form got new edits while saving; what you were typing was not overwritten)',
|
|
927
|
+
'error.saveFailed': 'Save failed: ',
|
|
928
|
+
'card.desc': 'Containers and images on this machine and SSH hosts: containers / images / networks / volumes, read-only by default, changes must be enabled explicitly.',
|
|
929
|
+
'card.name': 'Docker containers',
|
|
930
|
+
'card.summary': 'Containers and images on this machine / SSH hosts; read-only by default, changes must be enabled explicitly',
|
|
931
|
+
'list.loadingConfig': 'Loading config…',
|
|
932
|
+
'section.basic': 'Basics',
|
|
933
|
+
'check.enabled': 'Enable the plugin',
|
|
934
|
+
'check.announce': 'Announce capabilities to the agent',
|
|
935
|
+
'hint.dockerBin': 'docker by default; for podman put podman',
|
|
936
|
+
'field.pollInterval': 'Stats refresh interval (seconds)',
|
|
937
|
+
'field.logTailDefault': 'Default log rows',
|
|
938
|
+
'hint.logTailDefault': 'Initial value of LINES on the panel\'s log page (adjustable there); it is only a “row” cap — the snapshot also has to pass the byte gate below, so this many rows is not guaranteed',
|
|
939
|
+
'field.maxOutput': 'Output limit (KB)',
|
|
940
|
+
'hint.maxOutput': '“Byte” cap for a single output: shared by log snapshots / inspect / exec; enough rows can still truncate on bytes (the panel shows a truncation banner). The FOLLOW stream is not bound by it',
|
|
941
|
+
'field.execTimeout': 'exec timeout (seconds)',
|
|
942
|
+
'section.capabilities': 'Capability switches (off by default)',
|
|
943
|
+
'check.allowMutations': 'Allow changes (start/stop/remove containers, pull / remove / prune images)',
|
|
944
|
+
'check.allowExec': 'Allow exec (run commands inside containers)',
|
|
945
|
+
'hint.socketRoot': 'The docker socket is equivalent to root on the target host. Once enabled, both the browser panel and the agent can run these operations — only turn it on in a trusted environment.',
|
|
946
|
+
'hint.capabilityNotGranted': '⚠ Not granted by the host: these two switches cannot be turned on right now — clicking one gives you a command to confirm in place (no restart). Turning them off always works.',
|
|
947
|
+
'badge.notEffective': 'Not effective: not granted by the host',
|
|
948
|
+
'elev.title': 'Grant in place (no restart)',
|
|
949
|
+
'elev.lockedWhy': 'Any local process can send loopback requests, so “turn on a dangerous capability” cannot be decided by this page alone — it has to be confirmed on the host’s filesystem once.',
|
|
950
|
+
'elev.step': 'Run the command below in a terminal on the host and the switch unlocks by itself:',
|
|
951
|
+
'elev.copy': 'Copy command',
|
|
952
|
+
'elev.copied': 'Copied',
|
|
953
|
+
'elev.expiresIn': 'expires in {sec}s',
|
|
954
|
+
'elev.expired': 'This confirmation has expired — generate a new one.',
|
|
955
|
+
'elev.waiting': 'Waiting for confirmation… once you run the command this turns into “granted” automatically.',
|
|
956
|
+
'elev.regenerate': 'Generate a new one',
|
|
957
|
+
'elev.granted': 'Granted and now in effect.',
|
|
958
|
+
'elev.grantedNeedSave': 'Granted. Press “Save” to apply.',
|
|
959
|
+
'elev.revoke': 'Revoke host grant',
|
|
960
|
+
'elev.grantedAt': 'Granted · {time}',
|
|
961
|
+
'elev.revoked': 'Host grant revoked (the config switch is left untouched; granting again takes effect immediately).',
|
|
962
|
+
'elev.viaEnv': 'Granted by a launch environment variable; to revoke, remove it from the launch environment and restart the host.',
|
|
963
|
+
'elev.close': 'Collapse',
|
|
964
|
+
'elev.disableFirst': 'Turn this config switch off first',
|
|
965
|
+
'elev.otherWay': 'The other way (strongest, needs a host restart)',
|
|
966
|
+
'elev.envHow': 'In the environment that launched dsh: export {env}=1, then restart the host. Setting it after launch — or writing it into some other config file — does not count as a grant; that is exactly what this gate guards against.',
|
|
967
|
+
'elev.error': 'In-place grant failed: ',
|
|
968
|
+
'elev.copyManual': 'The clipboard is unavailable here — select the command above and copy it manually.',
|
|
969
|
+
'placeholder.targetName': 'Target name',
|
|
970
|
+
'option.sshHost': 'SSH host',
|
|
971
|
+
'hint.localTarget': 'docker on the machine running the host',
|
|
972
|
+
'hint.staleBook': 'The referenced bookmark “{book}” no longer exists — pick an existing one, or clear it and fill in the connection manually',
|
|
973
|
+
'option.noTtyBooks': '(no tty bookmarks, fill in the connection inline)',
|
|
974
|
+
'option.inlineConnection': '(no bookmark, fill in manually)',
|
|
975
|
+
'option.staleBook': '⚠ Bookmark no longer exists: ',
|
|
976
|
+
'option.bookNamed': 'Bookmark: {name}',
|
|
977
|
+
'hint.staleInline': 'The referenced bookmark “{book}” is not in the tty bookmarks — pick another one, or clear it and fill in manually',
|
|
978
|
+
'option.authKey': 'Private key',
|
|
979
|
+
'option.authPassword': 'Password',
|
|
980
|
+
'hint.keyPath': 'Supports ~ and ~/ expansion (not ~user); use absolute paths on Windows',
|
|
981
|
+
'placeholder.passwordSet': '(already set, leave empty to keep)',
|
|
982
|
+
'btn.addTarget': 'Add target',
|
|
983
|
+
'hint.addTarget': 'For an SSH target, prefer picking a tty terminal panel bookmark (credentials live in one place); when filling in manually, write the password / passphrase as env:NAME (credential reference: resolved by the official credential store, falling back to the environment variable).',
|
|
984
|
+
'section.tofu': 'SSH host key records (TOFU)',
|
|
985
|
+
'list.noHostKeys': 'No records yet — the host fingerprint is recorded automatically after the first successful SSH connection (reused directly if tty already recorded the same host).',
|
|
986
|
+
'hint.tofu': 'Connections are refused when the fingerprint changes (anti-MITM); once you have confirmed it is safe, delete the record to reconnect. Record deletions take effect after you hit “Save”.',
|
|
987
|
+
'status.saving': 'Saving…',
|
|
988
|
+
'btn.save': 'Save',
|
|
989
|
+
'card.guide': 'Containers, images, Compose projects, networks and volumes on this machine and SSH hosts',
|
|
990
|
+
'hint.openPanelForTarget': 'Open the Docker container panel for this host (target: {target})',
|
|
991
|
+
'hint.openPanelCurrentHost': 'Open the Docker container panel for the current session host',
|
|
992
|
+
'hint.openPanelUnconfigured': 'This host is not configured as a Docker target yet — click to open the panel and view/configure',
|
|
993
|
+
'error.logStream': 'Log stream error',
|
|
994
|
+
'meta.targetError': '{name}: {error}',
|
|
995
|
+
'hint.unreachableTail': '(other targets are unaffected)',
|
|
996
|
+
}
|
|
997
|
+
/* ==== dsh-i18n:end ==== */
|
|
998
|
+
|
|
999
|
+
/** 占位符替换:`{name}` → params.name(缺参留空,不抛错——文案不该打死界面)。 */
|
|
1000
|
+
function i18nFormat(text, params) {
|
|
1001
|
+
if (params === undefined) return text
|
|
1002
|
+
return String(text).replace(/\{(\w+)\}/g, (_match, name) => (params[name] === undefined ? '' : String(params[name])))
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/** 中文兜底:老宿主(DSH ≤0.1.5)没有 locale 服务时,界面不能变成一串键名。 */
|
|
1006
|
+
function i18nFallback(key, params) {
|
|
1007
|
+
return i18nFormat(I18N_ZH[key] !== undefined ? I18N_ZH[key] : key, params)
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
let t = i18nFallback
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* 注册目录并绑定翻译函数。**动态 inject**:老宿主上回调永不触发、`t` 保持中文兜底;
|
|
1014
|
+
* 写成静态 `inject: ['locale']` 会让整张卡片在老宿主上根本不挂。
|
|
1015
|
+
*
|
|
1016
|
+
* 语言切换不用自己订阅:插槽 outlet 随 locale revision 重渲染,而 `bind()` 返回的翻译
|
|
1017
|
+
* 函数在**调用时**读当前语言。
|
|
1018
|
+
*/
|
|
1019
|
+
function installI18n(ctx) {
|
|
1020
|
+
ctx.inject(['locale'], (i18nCtx) => {
|
|
1021
|
+
const disposeZh = i18nCtx.locale.register(I18N_NS, 'zh', I18N_ZH)
|
|
1022
|
+
const disposeEn = i18nCtx.locale.register(I18N_NS, 'en', I18N_EN)
|
|
1023
|
+
t = i18nCtx.locale.bind(I18N_NS)
|
|
1024
|
+
return () => {
|
|
1025
|
+
disposeEn()
|
|
1026
|
+
disposeZh()
|
|
1027
|
+
t = i18nFallback
|
|
1028
|
+
}
|
|
1029
|
+
})
|
|
1030
|
+
}
|
|
1031
|
+
|
|
25
1032
|
const API = '/api/dsh-docker'
|
|
26
1033
|
const PANEL_STYLE_ID = 'dsh-docker-style'
|
|
27
1034
|
/** 右侧栏标签的注册身份:id 是「实现」的名字(body 注册键用它,不是 kind)。 */
|
|
@@ -69,7 +1076,7 @@ async function request(path, init) {
|
|
|
69
1076
|
throw new Error(message)
|
|
70
1077
|
}
|
|
71
1078
|
if (payload !== null && payload.ok === false) {
|
|
72
|
-
throw new Error(typeof payload.error === 'string' ? payload.error : '
|
|
1079
|
+
throw new Error(typeof payload.error === 'string' ? payload.error : t('error.requestFailed'))
|
|
73
1080
|
}
|
|
74
1081
|
return payload
|
|
75
1082
|
}
|
|
@@ -98,6 +1105,13 @@ const api = {
|
|
|
98
1105
|
volumePrune: (target) => request('/volumes/prune', { method: 'POST', body: JSON.stringify({ target }) }),
|
|
99
1106
|
action: (target, action, id) => request('/action', { method: 'POST', body: JSON.stringify({ target, action, id }) }),
|
|
100
1107
|
exec: (target, id, command, timeoutSec) => request('/exec', { method: 'POST', body: JSON.stringify({ target, id, command, timeoutSec }) }),
|
|
1108
|
+
/*
|
|
1109
|
+
* 就地提权三条(全 POST):宿主侧要求同源证明,所以只能从页面里调——这正是设计意图
|
|
1110
|
+
* (页内请求不是凭据,真正的凭据是「在宿主的文件系统上落地那个文件」)。
|
|
1111
|
+
*/
|
|
1112
|
+
elevateBegin: (capability) => request('/elevate', { method: 'POST', body: JSON.stringify({ capability }) }),
|
|
1113
|
+
elevateStatus: (capability) => request('/elevate/status', { method: 'POST', body: JSON.stringify({ capability }) }),
|
|
1114
|
+
elevateRevoke: (capability) => request('/elevate/revoke', { method: 'POST', body: JSON.stringify({ capability }) }),
|
|
101
1115
|
}
|
|
102
1116
|
|
|
103
1117
|
/** SSE 订阅 URL 的唯一构造点(四条流都在这里拼 query)。 */
|
|
@@ -118,7 +1132,7 @@ function portText(port) {
|
|
|
118
1132
|
}
|
|
119
1133
|
|
|
120
1134
|
function portsText(ports) {
|
|
121
|
-
if (!Array.isArray(ports) || ports.length === 0) return '
|
|
1135
|
+
if (!Array.isArray(ports) || ports.length === 0) return t('list.noPorts')
|
|
122
1136
|
// 同一映射会在 IPv4 / IPv6 各出现一次:按「宿主端口→容器端口/协议」文本去重
|
|
123
1137
|
const seen = new Set()
|
|
124
1138
|
const out = []
|
|
@@ -152,14 +1166,14 @@ function fmtBytes(value) {
|
|
|
152
1166
|
|
|
153
1167
|
function stateLabel(state) {
|
|
154
1168
|
const map = {
|
|
155
|
-
running: '
|
|
156
|
-
exited: '
|
|
157
|
-
created: '
|
|
158
|
-
paused: '
|
|
159
|
-
restarting: '
|
|
1169
|
+
running: t('status.running'),
|
|
1170
|
+
exited: t('status.stopped'),
|
|
1171
|
+
created: t('status.created'),
|
|
1172
|
+
paused: t('status.paused'),
|
|
1173
|
+
restarting: t('status.restarting'),
|
|
160
1174
|
dead: 'dead',
|
|
161
|
-
removing: '
|
|
162
|
-
unknown: '
|
|
1175
|
+
removing: t('status.removing'),
|
|
1176
|
+
unknown: t('status.unknown'),
|
|
163
1177
|
}
|
|
164
1178
|
return map[state] ?? state
|
|
165
1179
|
}
|
|
@@ -525,12 +1539,11 @@ function pickDecide(count, ssh = false) {
|
|
|
525
1539
|
if (count > max) {
|
|
526
1540
|
return {
|
|
527
1541
|
canRun: false,
|
|
528
|
-
hint:
|
|
529
|
-
+ (ssh === true ? '(SSH 目标上一条连接要同时装实时流与刷新等短命令)' : ',浏览器并发长连接有限制'),
|
|
1542
|
+
hint: ssh === true ? t('hint.pickMaxSsh', { max }) : t('hint.pickMaxLocal', { max }),
|
|
530
1543
|
}
|
|
531
1544
|
}
|
|
532
|
-
if (count > PICK_SOFT_MAX) return { canRun: true, hint: '
|
|
533
|
-
if (count < 2) return { canRun: false, hint: count === 0 ? '' : '
|
|
1545
|
+
if (count > PICK_SOFT_MAX) return { canRun: true, hint: t('hint.pickMany') }
|
|
1546
|
+
if (count < 2) return { canRun: false, hint: count === 0 ? '' : t('hint.pickAtLeastTwo') }
|
|
534
1547
|
return { canRun: true, hint: '' }
|
|
535
1548
|
}
|
|
536
1549
|
|
|
@@ -556,12 +1569,12 @@ function pickReconcile(ids, containers) {
|
|
|
556
1569
|
* `needsBase` 的项要先有勾选(拿第一个勾选的容器当基准:同镜像 / 同项目)。
|
|
557
1570
|
*/
|
|
558
1571
|
const PICK_PRESETS = [
|
|
559
|
-
{ key: 'all', label
|
|
560
|
-
{ key: 'unhealthy', label
|
|
561
|
-
{ key: 'abnormal', label
|
|
562
|
-
{ key: 'stopped', label
|
|
563
|
-
{ key: 'sameImage', label
|
|
564
|
-
{ key: 'sameProject', label
|
|
1572
|
+
{ key: 'all', get label() { return t('option.presetAll') }, needsBase: false },
|
|
1573
|
+
{ key: 'unhealthy', get label() { return t('status.unhealthy') }, needsBase: false },
|
|
1574
|
+
{ key: 'abnormal', get label() { return t('status.attention') }, needsBase: false },
|
|
1575
|
+
{ key: 'stopped', get label() { return t('status.stopped') }, needsBase: false },
|
|
1576
|
+
{ key: 'sameImage', get label() { return t('option.presetSameImage') }, needsBase: true },
|
|
1577
|
+
{ key: 'sameProject', get label() { return t('option.presetSameProject') }, needsBase: true },
|
|
565
1578
|
]
|
|
566
1579
|
|
|
567
1580
|
const isLiveState = (state) => state === 'running' || state === 'paused' || state === 'restarting'
|
|
@@ -664,18 +1677,24 @@ const OVERVIEW_ERROR_MAX = 120
|
|
|
664
1677
|
/**
|
|
665
1678
|
* 需关注原因的展示口径(宿主 /attention 与摘要兜底共用同一套 reason key)。
|
|
666
1679
|
* 严重度顺序同时用于排序:OOM > 僵死 > 不健康 > 反复重启 > 非零退出。
|
|
1680
|
+
*
|
|
1681
|
+
* 文案是**取值函数**而不是字符串:模块加载时求值会把语言冻住(见上方 i18n 注释);
|
|
1682
|
+
* JSDoc 元组类型是给 client-lint 的 tsc(--checkJs)看的——不加它,`hit[1]` 会被推成
|
|
1683
|
+
* `string | (() => string) | number`,多一条 TS2349 噪音。
|
|
1684
|
+
*
|
|
1685
|
+
* @type {Array<[string, () => string, number]>}
|
|
667
1686
|
*/
|
|
668
1687
|
const ATTENTION_REASONS = [
|
|
669
|
-
['oom',
|
|
670
|
-
['dead', '
|
|
671
|
-
['unhealthy', '
|
|
672
|
-
['restarting', '
|
|
673
|
-
['exit-nonzero', '
|
|
1688
|
+
['oom', () => t('status.oomKilled'), 0],
|
|
1689
|
+
['dead', () => t('status.dead'), 1],
|
|
1690
|
+
['unhealthy', () => t('status.unhealthy'), 2],
|
|
1691
|
+
['restarting', () => t('status.restartingLoop'), 3],
|
|
1692
|
+
['exit-nonzero', () => t('status.exitNonzero'), 4],
|
|
674
1693
|
]
|
|
675
1694
|
|
|
676
1695
|
const reasonLabel = (reason) => {
|
|
677
1696
|
const hit = ATTENTION_REASONS.find(([key]) => key === reason)
|
|
678
|
-
return hit === undefined ? reason : hit[1]
|
|
1697
|
+
return hit === undefined ? reason : hit[1]()
|
|
679
1698
|
}
|
|
680
1699
|
const reasonRank = (reason) => {
|
|
681
1700
|
const hit = ATTENTION_REASONS.find(([key]) => key === reason)
|
|
@@ -704,13 +1723,13 @@ function attentionTitle(item) {
|
|
|
704
1723
|
if (!Number.isFinite(time)) return ''
|
|
705
1724
|
return new Date(time).toLocaleString()
|
|
706
1725
|
}
|
|
707
|
-
const parts = ['
|
|
1726
|
+
const parts = [t('hint.openDetail')]
|
|
708
1727
|
const finished = at(item.finishedAt)
|
|
709
1728
|
const started = at(item.startedAt)
|
|
710
|
-
if (finished !== '') parts.push('
|
|
711
|
-
else if (started !== '') parts.push('
|
|
712
|
-
if (typeof item.restartCount === 'number') parts.push('
|
|
713
|
-
if (typeof item.exitCode === 'number') parts.push('
|
|
1729
|
+
if (finished !== '') parts.push(t('meta.finishedAt') + finished)
|
|
1730
|
+
else if (started !== '') parts.push(t('meta.startedAt') + started)
|
|
1731
|
+
if (typeof item.restartCount === 'number') parts.push(t('meta.restartCount') + String(item.restartCount))
|
|
1732
|
+
if (typeof item.exitCode === 'number') parts.push(t('meta.exitCode') + String(item.exitCode))
|
|
714
1733
|
return parts.join(' · ')
|
|
715
1734
|
}
|
|
716
1735
|
|
|
@@ -760,7 +1779,7 @@ function overviewSortRows(rows) {
|
|
|
760
1779
|
/** 失败原因压成一行(取首行 + 截断):它会长在卡片里、也会拼进横幅。 */
|
|
761
1780
|
function overviewErrorText(error) {
|
|
762
1781
|
const line = String(error ?? '').split('\n')[0].trim()
|
|
763
|
-
if (line === '') return '
|
|
1782
|
+
if (line === '') return t('error.unknown')
|
|
764
1783
|
return line.length > OVERVIEW_ERROR_MAX ? line.slice(0, OVERVIEW_ERROR_MAX) + '…' : line
|
|
765
1784
|
}
|
|
766
1785
|
|
|
@@ -837,10 +1856,10 @@ function overviewData(groups) {
|
|
|
837
1856
|
})
|
|
838
1857
|
const notices = []
|
|
839
1858
|
if (truncatedTargets > 0) {
|
|
840
|
-
notices.push('
|
|
1859
|
+
notices.push(t('hint.attentionTruncated', { targets: truncatedTargets, total: truncatedTotal, shown: truncatedShown }))
|
|
841
1860
|
}
|
|
842
1861
|
if (degradedTargets > 0) {
|
|
843
|
-
notices.push(
|
|
1862
|
+
notices.push(t('hint.attentionDegraded', { count: degradedTargets }))
|
|
844
1863
|
}
|
|
845
1864
|
return {
|
|
846
1865
|
cards,
|
|
@@ -849,7 +1868,7 @@ function overviewData(groups) {
|
|
|
849
1868
|
// 还有目标没落地:此时「一切正常」是「还不知道」,不能当成没问题显示
|
|
850
1869
|
loading: groups.some((group) => group.loaded !== true),
|
|
851
1870
|
// 空串 = 没有需要说明的(正文据此决定渲不渲染那一行)
|
|
852
|
-
attentionNotice: notices.join('
|
|
1871
|
+
attentionNotice: notices.join(t('msg.clauseSep')),
|
|
853
1872
|
}
|
|
854
1873
|
}
|
|
855
1874
|
/* ========================== 事件活动流(纯逻辑) ========================== */
|
|
@@ -968,7 +1987,7 @@ window.__ModuleLoader__.load({
|
|
|
968
1987
|
|
|
969
1988
|
function Badge(props) {
|
|
970
1989
|
const state = props.health === 'unhealthy' ? 'unhealthy' : props.state
|
|
971
|
-
const label = props.health === 'unhealthy' ? '
|
|
1990
|
+
const label = props.health === 'unhealthy' ? t('status.unhealthy') : stateLabel(props.state)
|
|
972
1991
|
return jsx('span', { className: 'dk_badge', 'data-state': state, title: props.status ?? '', children: label })
|
|
973
1992
|
}
|
|
974
1993
|
|
|
@@ -1017,8 +2036,8 @@ window.__ModuleLoader__.load({
|
|
|
1017
2036
|
function overviewBody(data, actions) {
|
|
1018
2037
|
if (data.cards.length === 0) {
|
|
1019
2038
|
return jsxs('div', { className: 'dk_empty', children: [
|
|
1020
|
-
jsx('div', { className: 'dk_emptyTitle', children: '
|
|
1021
|
-
jsx('div', { className: 'dk_emptyHint', children: '
|
|
2039
|
+
jsx('div', { className: 'dk_emptyTitle', children: t('list.noTargetsConfigured') }),
|
|
2040
|
+
jsx('div', { className: 'dk_emptyHint', children: t('hint.overviewNoTargets') }),
|
|
1022
2041
|
] })
|
|
1023
2042
|
}
|
|
1024
2043
|
const abnormal = data.rows.length === 0
|
|
@@ -1026,19 +2045,19 @@ window.__ModuleLoader__.load({
|
|
|
1026
2045
|
? (data.loading
|
|
1027
2046
|
? jsxs('div', { className: 'dk_empty dk_ovEmpty', children: [
|
|
1028
2047
|
jsx('span', { className: 'dk_spin' }),
|
|
1029
|
-
jsx('div', { children: '
|
|
2048
|
+
jsx('div', { children: t('list.loading') }),
|
|
1030
2049
|
] }, 'loading')
|
|
1031
2050
|
: jsxs('div', { className: 'dk_empty dk_ovEmpty', children: [
|
|
1032
|
-
jsx('div', { className: 'dk_emptyTitle', children: '
|
|
1033
|
-
jsx('div', { className: 'dk_emptyHint', children: '
|
|
2051
|
+
jsx('div', { className: 'dk_emptyTitle', children: t('list.allGood') }),
|
|
2052
|
+
jsx('div', { className: 'dk_emptyHint', children: t('list.allGoodHint') }),
|
|
1034
2053
|
] }, 'empty'))
|
|
1035
2054
|
: jsx('div', { className: 'dk_tableWrap', children: jsxs('table', { className: 'dk_images dk_ovTable', children: [
|
|
1036
2055
|
jsx('thead', { children: jsxs('tr', { children: [
|
|
1037
|
-
jsx('th', { children: '
|
|
1038
|
-
jsx('th', { children: '
|
|
1039
|
-
jsx('th', { children: '
|
|
1040
|
-
jsx('th', { children: '
|
|
1041
|
-
jsx('th', { children: '
|
|
2056
|
+
jsx('th', { children: t('field.containerName') }),
|
|
2057
|
+
jsx('th', { children: t('field.target') }),
|
|
2058
|
+
jsx('th', { children: t('field.state') }),
|
|
2059
|
+
jsx('th', { children: t('field.reason') }),
|
|
2060
|
+
jsx('th', { children: t('field.image') }),
|
|
1042
2061
|
] }) }),
|
|
1043
2062
|
jsx('tbody', { children: data.rows.map((row) => jsxs('tr', {
|
|
1044
2063
|
className: 'dk_rowClickable',
|
|
@@ -1068,42 +2087,42 @@ window.__ModuleLoader__.load({
|
|
|
1068
2087
|
] }) }, 0)
|
|
1069
2088
|
return jsxs('div', { className: 'dk_imagesView dk_ovView', children: [
|
|
1070
2089
|
data.unreachable.length === 0 ? null : jsx(Banner, {
|
|
1071
|
-
title:
|
|
1072
|
-
hint: data.unreachable.map((card) => card.name
|
|
2090
|
+
title: t('badge.unreachableTargets', { count: data.unreachable.length }),
|
|
2091
|
+
hint: data.unreachable.map((card) => t('meta.targetError', { name: card.name, error: card.error })).join(t('msg.clauseSep')) + t('hint.unreachableTail'),
|
|
1073
2092
|
}, 'unreachable'),
|
|
1074
2093
|
jsx('div', { className: 'dk_ovCards', children: data.cards.map((card) => jsxs('button', {
|
|
1075
2094
|
type: 'button',
|
|
1076
2095
|
className: 'dk_ovCard',
|
|
1077
2096
|
'data-state': card.error !== '' ? 'error' : (card.loaded === true ? 'ok' : 'loading'),
|
|
1078
|
-
title: card.error === '' ? '
|
|
2097
|
+
title: card.error === '' ? t('hint.switchToTarget') : card.error,
|
|
1079
2098
|
onClick: () => actions.onOpenTarget(card.name),
|
|
1080
2099
|
children: [
|
|
1081
2100
|
jsxs('div', { className: 'dk_ovCardHead', children: [
|
|
1082
2101
|
jsx('span', { className: 'dk_ovCardName', title: card.label === '' ? card.name : card.label, children: card.name }),
|
|
1083
2102
|
// local / ssh 是两套完全不同的执行通道,值得一眼区分
|
|
1084
|
-
jsx('span', { className: 'dk_badge', 'data-state': 'paused', children: card.kind === 'local' ? '
|
|
2103
|
+
jsx('span', { className: 'dk_badge', 'data-state': 'paused', children: card.kind === 'local' ? t('option.local') : 'SSH' }),
|
|
1085
2104
|
] }, 'head'),
|
|
1086
2105
|
card.error === ''
|
|
1087
2106
|
// 还没落地的目标不能显示 0/0/0——那会被读成「这台机器没有容器」,
|
|
1088
2107
|
// 而它其实只是还没答(SSH 目标不可达最长要等 readyTimeout 20s)
|
|
1089
2108
|
? (card.loaded === true
|
|
1090
2109
|
? jsxs('div', { className: 'dk_ovCardCounts', children: [
|
|
1091
|
-
overviewCountSpan('running', '
|
|
1092
|
-
overviewCountSpan('stopped', '
|
|
1093
|
-
overviewCountSpan('unhealthy', '
|
|
1094
|
-
overviewCountSpan('attention', card.attentionApprox ? '
|
|
2110
|
+
overviewCountSpan('running', t('status.running'), card.running),
|
|
2111
|
+
overviewCountSpan('stopped', t('status.stopped'), card.stopped),
|
|
2112
|
+
overviewCountSpan('unhealthy', t('status.unhealthy'), card.unhealthy),
|
|
2113
|
+
overviewCountSpan('attention', card.attentionApprox ? t('status.attentionApprox') : t('status.attention'), card.attention),
|
|
1095
2114
|
] }, 'counts')
|
|
1096
2115
|
: jsxs('div', { className: 'dk_ovCardLoading', children: [
|
|
1097
2116
|
jsx('span', { className: 'dk_spin' }),
|
|
1098
|
-
jsx('span', { children: '
|
|
2117
|
+
jsx('span', { children: t('list.loading') }),
|
|
1099
2118
|
] }, 'loading'))
|
|
1100
2119
|
: jsxs('div', { className: 'dk_ovCardError', children: [
|
|
1101
|
-
jsx('span', { className: 'dk_badge', 'data-state': 'dead', children: '
|
|
2120
|
+
jsx('span', { className: 'dk_badge', 'data-state': 'dead', children: t('status.unreachable') }),
|
|
1102
2121
|
jsx('span', { className: 'dk_ovCardErrorText', title: card.error, children: card.error }),
|
|
1103
2122
|
] }, 'error'),
|
|
1104
2123
|
],
|
|
1105
2124
|
}, card.name)) }, 1),
|
|
1106
|
-
jsx('div', { className: 'dk_ovSection', children: data.rows.length === 0 ? '
|
|
2125
|
+
jsx('div', { className: 'dk_ovSection', children: data.rows.length === 0 ? t('panel.attentionContainers') : t('panel.attentionContainersCount', { count: data.rows.length }) }, 2),
|
|
1107
2126
|
// 截断 / 降级提示(D101):计数卡里的「需关注」是服务端的实际命中数(total),
|
|
1108
2127
|
// 表里列出的可能只是前 N 条——两处对不上时必须说一句,否则会被读成表格漏行。
|
|
1109
2128
|
data.attentionNotice === '' ? null : jsx('div', { className: 'dk_hint', style: { marginBottom: 8 }, children: data.attentionNotice }, 'attentionNotice'),
|
|
@@ -1130,7 +2149,7 @@ window.__ModuleLoader__.load({
|
|
|
1130
2149
|
jsx('div', { className: 'dk_confirmTitle', children: props.title }),
|
|
1131
2150
|
jsx('div', { className: 'dk_confirmText', children: props.text }),
|
|
1132
2151
|
jsxs('div', { className: 'dk_confirmActions', children: [
|
|
1133
|
-
jsx('button', { type: 'button', className: 'dk_btn', disabled: busy, onClick: props.onCancel, children: '
|
|
2152
|
+
jsx('button', { type: 'button', className: 'dk_btn', disabled: busy, onClick: props.onCancel, children: t('btn.cancel') }),
|
|
1134
2153
|
jsx('button', {
|
|
1135
2154
|
type: 'button',
|
|
1136
2155
|
className: 'dk_btn dk_btnDanger',
|
|
@@ -1138,7 +2157,7 @@ window.__ModuleLoader__.load({
|
|
|
1138
2157
|
'aria-busy': busy ? 'true' : undefined,
|
|
1139
2158
|
onClick: props.onConfirm,
|
|
1140
2159
|
children: busy
|
|
1141
|
-
? jsxs('span', { className: 'dk_confirmBusy', children: [jsx('span', { className: 'dk_spin' }), '
|
|
2160
|
+
? jsxs('span', { className: 'dk_confirmBusy', children: [jsx('span', { className: 'dk_spin' }), t('status.executing')] })
|
|
1142
2161
|
: props.confirmLabel,
|
|
1143
2162
|
}),
|
|
1144
2163
|
] }),
|
|
@@ -1200,7 +2219,7 @@ window.__ModuleLoader__.load({
|
|
|
1200
2219
|
'data-alert': alert ? '1' : undefined,
|
|
1201
2220
|
title: props.title ?? '',
|
|
1202
2221
|
children: points.length < 2
|
|
1203
|
-
? jsx('span', { className: 'dk_sparkEmpty', children: '
|
|
2222
|
+
? jsx('span', { className: 'dk_sparkEmpty', children: t('status.sampling') })
|
|
1204
2223
|
: jsx('svg', {
|
|
1205
2224
|
viewBox: '0 0 ' + String(width) + ' ' + String(height),
|
|
1206
2225
|
preserveAspectRatio: 'none',
|
|
@@ -1276,8 +2295,8 @@ window.__ModuleLoader__.load({
|
|
|
1276
2295
|
const pendingAction = typeof props.pending === 'string' ? props.pending : ''
|
|
1277
2296
|
const busy = pendingAction !== ''
|
|
1278
2297
|
const actionTitle = (label) => (busy
|
|
1279
|
-
? '
|
|
1280
|
-
: (readOnly ? '
|
|
2298
|
+
? t('status.pendingAction', { action: pendingAction })
|
|
2299
|
+
: (readOnly ? t('status.needMutations') : label))
|
|
1281
2300
|
/*
|
|
1282
2301
|
* 选择态下卡片本体就是勾选开关(不再进详情):点击 / 回车 / 空格都切换勾选,
|
|
1283
2302
|
* 角色也跟着换成 checkbox,读屏用户不会以为点进去是详情。
|
|
@@ -1312,14 +2331,14 @@ window.__ModuleLoader__.load({
|
|
|
1312
2331
|
jsx(Badge, { state: item.state, health: item.health, status: item.status }),
|
|
1313
2332
|
] }, 'head'),
|
|
1314
2333
|
jsxs('div', { className: 'dk_cardRows', children: [
|
|
1315
|
-
jsx(CardRow, { label: '
|
|
2334
|
+
jsx(CardRow, { label: t('field.image'), value: item.image }, 'image'),
|
|
1316
2335
|
jsx(CardRow, { label: 'ID', value: item.shortId }, 'id'),
|
|
1317
2336
|
// host 网络容器在 ps 里没有端口映射:空值加说明,免得被读成「没暴露端口」(D131)
|
|
1318
2337
|
jsx(CardRow, {
|
|
1319
|
-
label: '
|
|
1320
|
-
value: portsText(item.ports) + (item.ports.length === 0 && Array.isArray(item.networks) && item.networks.includes('host') ? '
|
|
2338
|
+
label: t('field.ports'),
|
|
2339
|
+
value: portsText(item.ports) + (item.ports.length === 0 && Array.isArray(item.networks) && item.networks.includes('host') ? t('hint.hostNetwork') : ''),
|
|
1321
2340
|
}, 'ports'),
|
|
1322
|
-
jsx(CardRow, { label: '
|
|
2341
|
+
jsx(CardRow, { label: t('field.created'), value: created }, 'created'),
|
|
1323
2342
|
item.composeProject === null ? null : jsx(CardRow, { label: 'compose', value: item.composeProject + (item.composeService === null ? '' : '/' + item.composeService) }, 'compose'),
|
|
1324
2343
|
] }, 'rows'),
|
|
1325
2344
|
/*
|
|
@@ -1331,13 +2350,13 @@ window.__ModuleLoader__.load({
|
|
|
1331
2350
|
* 选择态整条收起:一边勾选聚合、一边还能点到启停删,是纯粹的事故入口。
|
|
1332
2351
|
*/
|
|
1333
2352
|
pickMode ? null : jsxs('div', { className: 'dk_actionBar', children: [
|
|
1334
|
-
jsx(IconAction, { icon: ICON_TERM, title: '
|
|
1335
|
-
jsx(IconAction, { icon: ICON_LOGS, title: '
|
|
1336
|
-
jsx(IconAction, { icon: ICON_STATS, title: '
|
|
2353
|
+
jsx(IconAction, { icon: ICON_TERM, title: t('hint.openTerminal', { name: item.name }), onClick: () => props.onExec(item) }, 'exec'),
|
|
2354
|
+
jsx(IconAction, { icon: ICON_LOGS, title: t('btn.viewLogs'), onClick: () => props.onOpen(item, 'logs') }, 'logs'),
|
|
2355
|
+
jsx(IconAction, { icon: ICON_STATS, title: t('btn.stats'), onClick: () => props.onOpen(item, 'stats') }, 'stats'),
|
|
1337
2356
|
jsx('span', { className: 'dk_actionBarSep', 'aria-hidden': 'true' }, 'sep'),
|
|
1338
|
-
jsx(IconAction, { icon: running ? ICON_STOP : ICON_PLAY, title: actionTitle(running ? '
|
|
1339
|
-
jsx(IconAction, { icon: ICON_RESTART, title: actionTitle('
|
|
1340
|
-
jsx(IconAction, { icon: ICON_TRASH, danger: true, title: actionTitle('
|
|
2357
|
+
jsx(IconAction, { icon: running ? ICON_STOP : ICON_PLAY, title: actionTitle(running ? t('btn.stopContainer') : t('btn.startContainer')), disabled: readOnly || busy, busy: pendingAction === (running ? 'stop' : 'start'), onClick: () => props.onAction(running ? 'stop' : 'start', item) }, 'power'),
|
|
2358
|
+
jsx(IconAction, { icon: ICON_RESTART, title: actionTitle(t('btn.restartContainer')), disabled: readOnly || busy, busy: pendingAction === 'restart', onClick: () => props.onAction('restart', item) }, 'restart'),
|
|
2359
|
+
jsx(IconAction, { icon: ICON_TRASH, danger: true, title: actionTitle(t('btn.removeContainer')), disabled: readOnly || busy, busy: pendingAction === 'remove', onClick: () => props.onAction('remove', item) }, 'remove'),
|
|
1341
2360
|
] }, 'actions'),
|
|
1342
2361
|
],
|
|
1343
2362
|
})
|
|
@@ -1487,19 +2506,19 @@ window.__ModuleLoader__.load({
|
|
|
1487
2506
|
* `current`,改看 `retainedBy.mainView > 0`。
|
|
1488
2507
|
*/
|
|
1489
2508
|
function askTarget() {
|
|
1490
|
-
if (sessionsSvc === null) return { ok: false, reason: '
|
|
2509
|
+
if (sessionsSvc === null) return { ok: false, reason: t('error.noSessionsService') }
|
|
1491
2510
|
let id
|
|
1492
2511
|
try {
|
|
1493
2512
|
id = currentSessionIdOf(sessionsSvc.list?.getSnapshot?.())
|
|
1494
2513
|
} catch (error) {
|
|
1495
2514
|
return { ok: false, reason: error instanceof Error ? error.message : String(error) }
|
|
1496
2515
|
}
|
|
1497
|
-
if (typeof id !== 'string' || id === '') return { ok: false, reason: '
|
|
2516
|
+
if (typeof id !== 'string' || id === '') return { ok: false, reason: t('error.noOpenSession') }
|
|
1498
2517
|
try {
|
|
1499
2518
|
const actx = sessionsSvc.scope(id)
|
|
1500
|
-
if (actx === undefined) return { ok: false, reason: '
|
|
2519
|
+
if (actx === undefined) return { ok: false, reason: t('error.sessionNotReady') }
|
|
1501
2520
|
const conversation = actx.get?.('conversation') ?? actx.conversation ?? null
|
|
1502
|
-
if (conversation === null) return { ok: false, reason: '
|
|
2521
|
+
if (conversation === null) return { ok: false, reason: t('error.noConversationService') }
|
|
1503
2522
|
return { ok: true, id, actx, conversation }
|
|
1504
2523
|
} catch (error) {
|
|
1505
2524
|
return { ok: false, reason: error instanceof Error ? error.message : String(error) }
|
|
@@ -1599,13 +2618,13 @@ window.__ModuleLoader__.load({
|
|
|
1599
2618
|
/** 选中区间的可读描述,用于菜单标题(「6 行 · ems-service」)。 */
|
|
1600
2619
|
function describeSelection(context, range) {
|
|
1601
2620
|
const count = String(range.to - range.from + 1)
|
|
1602
|
-
if (context.containers.length === 1) return count + '
|
|
2621
|
+
if (context.containers.length === 1) return count + t('meta.rowsSuffix') + ' · ' + context.containers[0].name
|
|
1603
2622
|
const services = new Set()
|
|
1604
2623
|
for (let k = range.from; k <= range.to; k += 1) {
|
|
1605
2624
|
const svc = readLogRow(range.rows[k]).svc
|
|
1606
2625
|
if (svc !== '') services.add(svc)
|
|
1607
2626
|
}
|
|
1608
|
-
return services.size === 0 ? count + '
|
|
2627
|
+
return services.size === 0 ? count + t('meta.rowsSuffix') : count + t('meta.rowsSuffix') + ' · ' + [...services].slice(0, 3).join('/')
|
|
1609
2628
|
}
|
|
1610
2629
|
|
|
1611
2630
|
/**
|
|
@@ -1628,7 +2647,7 @@ window.__ModuleLoader__.load({
|
|
|
1628
2647
|
const out = []
|
|
1629
2648
|
out.push('[dsh-docker] 容器日志片段')
|
|
1630
2649
|
out.push('')
|
|
1631
|
-
out.push('- 目标:' + (context.targetLabel !== '' ? context.targetLabel : (context.target !== '' ? context.target : '
|
|
2650
|
+
out.push('- 目标:' + (context.targetLabel !== '' ? context.targetLabel : (context.target !== '' ? context.target : t('status.unknown'))))
|
|
1632
2651
|
for (const item of context.containers.slice(0, 3)) {
|
|
1633
2652
|
out.push('- 容器:' + item.name + '(' + String(item.id) + (item.image === undefined || item.image === '' ? '' : ',镜像 ' + String(item.image)) + ')')
|
|
1634
2653
|
}
|
|
@@ -1733,7 +2752,7 @@ window.__ModuleLoader__.load({
|
|
|
1733
2752
|
let conversationHiddenHint = ''
|
|
1734
2753
|
|
|
1735
2754
|
function reportDelivery(result) {
|
|
1736
|
-
if (result.ok !== true) flashAskNotice('
|
|
2755
|
+
if (result.ok !== true) flashAskNotice(t('error.deliverFailed') + result.message)
|
|
1737
2756
|
}
|
|
1738
2757
|
|
|
1739
2758
|
/**
|
|
@@ -1818,11 +2837,11 @@ window.__ModuleLoader__.load({
|
|
|
1818
2837
|
* 是这么对齐的)。格式集合、hint、按钮文案一旦分家,改一处忘一处,症状还很轻——
|
|
1819
2838
|
* 只是两个视图长得不一样,没人报 bug。
|
|
1820
2839
|
*/
|
|
1821
|
-
const
|
|
2840
|
+
const logExportLabel = () => t('btn.export')
|
|
1822
2841
|
function exportMenuItems(onPick) {
|
|
1823
2842
|
return [
|
|
1824
|
-
{ label: '⬇ .log', hint: '
|
|
1825
|
-
{ label: '⬇ .md', hint: '
|
|
2843
|
+
{ label: '⬇ .log', hint: t('hint.exportLog'), onPick: () => onPick('log') },
|
|
2844
|
+
{ label: '⬇ .md', hint: t('hint.exportMd'), onPick: () => onPick('md') },
|
|
1826
2845
|
]
|
|
1827
2846
|
}
|
|
1828
2847
|
|
|
@@ -1844,7 +2863,7 @@ window.__ModuleLoader__.load({
|
|
|
1844
2863
|
openLogMenu({
|
|
1845
2864
|
x: rect === null ? 0 : rect.left,
|
|
1846
2865
|
y: rect === null ? 0 : rect.bottom + 4,
|
|
1847
|
-
head: '
|
|
2866
|
+
head: t('panel.exportLogs'),
|
|
1848
2867
|
...(options.sub === undefined ? {} : { sub: options.sub }),
|
|
1849
2868
|
items: exportMenuItems(options.onPick),
|
|
1850
2869
|
})
|
|
@@ -1867,7 +2886,7 @@ window.__ModuleLoader__.load({
|
|
|
1867
2886
|
try { ok = document.execCommand('copy') } catch { ok = false }
|
|
1868
2887
|
area.remove()
|
|
1869
2888
|
if (ok) resolve()
|
|
1870
|
-
else reject(new Error('
|
|
2889
|
+
else reject(new Error(t('error.copyRejected')))
|
|
1871
2890
|
})
|
|
1872
2891
|
}
|
|
1873
2892
|
|
|
@@ -1904,7 +2923,7 @@ window.__ModuleLoader__.load({
|
|
|
1904
2923
|
const api = panelApi
|
|
1905
2924
|
if (api === null || Number(api.version ?? 0) < 2 || typeof api.minimize !== 'function') return conversationHiddenHint
|
|
1906
2925
|
if (typeof api.isOpen === 'function' && api.isOpen() !== true) return conversationHiddenHint
|
|
1907
|
-
return api.minimize() === true ? '
|
|
2926
|
+
return api.minimize() === true ? t('hint.revealSession') : conversationHiddenHint
|
|
1908
2927
|
} catch {
|
|
1909
2928
|
return conversationHiddenHint
|
|
1910
2929
|
}
|
|
@@ -1919,17 +2938,17 @@ window.__ModuleLoader__.load({
|
|
|
1919
2938
|
? target.conversation.input.for(target.actx)
|
|
1920
2939
|
: null
|
|
1921
2940
|
if (input === null || typeof input.setDraft !== 'function') {
|
|
1922
|
-
return { ok: false, message: '
|
|
2941
|
+
return { ok: false, message: t('error.noInputFacade') }
|
|
1923
2942
|
}
|
|
1924
2943
|
input.setDraft(prompt)
|
|
1925
|
-
notifySession(target, '
|
|
1926
|
-
flashAskNotice('
|
|
1927
|
-
return { ok: true, message: '
|
|
2944
|
+
notifySession(target, t('msg.logDraftFilled'))
|
|
2945
|
+
flashAskNotice(t('msg.filledInCurrentSession') + revealSessionSuffix(), 'ok')
|
|
2946
|
+
return { ok: true, message: t('msg.filledDraft') }
|
|
1928
2947
|
}
|
|
1929
2948
|
await target.conversation.send(prompt)
|
|
1930
|
-
notifySession(target, '
|
|
1931
|
-
flashAskNotice('
|
|
1932
|
-
return { ok: true, message: '
|
|
2949
|
+
notifySession(target, t('msg.logSentToSession'))
|
|
2950
|
+
flashAskNotice(t('msg.logSentToCurrentSession') + revealSessionSuffix(), 'ok')
|
|
2951
|
+
return { ok: true, message: t('msg.sent') }
|
|
1933
2952
|
} catch (error) {
|
|
1934
2953
|
return { ok: false, message: error instanceof Error ? error.message : String(error) }
|
|
1935
2954
|
}
|
|
@@ -1965,25 +2984,25 @@ window.__ModuleLoader__.load({
|
|
|
1965
2984
|
openLogMenu({
|
|
1966
2985
|
x,
|
|
1967
2986
|
y,
|
|
1968
|
-
head: '
|
|
1969
|
-
sub: describeSelection(context, range) + (disabled ? ' · ' + reason : '
|
|
2987
|
+
head: t('btn.askAgent'),
|
|
2988
|
+
sub: describeSelection(context, range) + (disabled ? ' · ' + reason : t('meta.currentSession')),
|
|
1970
2989
|
items: [
|
|
1971
2990
|
{
|
|
1972
|
-
label: '
|
|
1973
|
-
hint: '
|
|
2991
|
+
label: t('btn.sendToSession'),
|
|
2992
|
+
hint: t('hint.sendNow'),
|
|
1974
2993
|
disabled,
|
|
1975
2994
|
reason,
|
|
1976
2995
|
onPick: () => { void deliverToSession(build(), 'send').then(reportDelivery) },
|
|
1977
2996
|
},
|
|
1978
2997
|
{
|
|
1979
|
-
label: '
|
|
1980
|
-
hint: '
|
|
2998
|
+
label: t('btn.fillDraft'),
|
|
2999
|
+
hint: t('hint.fillDraft'),
|
|
1981
3000
|
disabled,
|
|
1982
3001
|
reason,
|
|
1983
3002
|
onPick: () => { void deliverToSession(build(), 'draft').then(reportDelivery) },
|
|
1984
3003
|
},
|
|
1985
3004
|
],
|
|
1986
|
-
note: '
|
|
3005
|
+
note: t('hint.untrustedLogs'),
|
|
1987
3006
|
})
|
|
1988
3007
|
}
|
|
1989
3008
|
|
|
@@ -2133,7 +3152,7 @@ window.__ModuleLoader__.load({
|
|
|
2133
3152
|
if (!active) return undefined
|
|
2134
3153
|
if (tab !== 'logs' || !follow) return undefined
|
|
2135
3154
|
if (typeof EventSource !== 'function') {
|
|
2136
|
-
setFollowError('
|
|
3155
|
+
setFollowError(t('error.noEventSource'))
|
|
2137
3156
|
setFollow(false)
|
|
2138
3157
|
return undefined
|
|
2139
3158
|
}
|
|
@@ -2165,6 +3184,7 @@ window.__ModuleLoader__.load({
|
|
|
2165
3184
|
* 把最后 tail 行当新行再推一遍,客户端就会重复回填(还会挤掉真正的历史)。
|
|
2166
3185
|
*/
|
|
2167
3186
|
subscription = subscribeLogStream({
|
|
3187
|
+
t,
|
|
2168
3188
|
buildUrl: (tailNow) => streamUrl('/logs/stream', {
|
|
2169
3189
|
target: props.target,
|
|
2170
3190
|
id: item.id,
|
|
@@ -2182,7 +3202,7 @@ window.__ModuleLoader__.load({
|
|
|
2182
3202
|
const code = payload !== null && typeof payload.code === 'number' ? payload.code : null
|
|
2183
3203
|
if (reason === 'container-exit') {
|
|
2184
3204
|
// 容器停止 → docker logs -f 自然退出:关流、切回快照并立即补一次刷新
|
|
2185
|
-
setFollowNotice('
|
|
3205
|
+
setFollowNotice(t('status.containerExited') + (code === null ? '' : t('meta.exitCodeNote', { code })) + t('status.streamEndedSnapshot'))
|
|
2186
3206
|
subscription?.close()
|
|
2187
3207
|
cancelFollowFlush()
|
|
2188
3208
|
setFollow(false)
|
|
@@ -2191,12 +3211,12 @@ window.__ModuleLoader__.load({
|
|
|
2191
3211
|
}
|
|
2192
3212
|
if (reason === 'output-limit') {
|
|
2193
3213
|
// 宿主侧背压队列溢出(推送快过浏览器消费):不是容器的问题,重连即可
|
|
2194
|
-
setFollowNotice('
|
|
3214
|
+
setFollowNotice(t('hint.logBacklog'))
|
|
2195
3215
|
controls.reconnect()
|
|
2196
3216
|
return
|
|
2197
3217
|
}
|
|
2198
3218
|
// 服务端主动停流(插件禁用 / 配置热更新):重连也会补新行
|
|
2199
|
-
setFollowNotice('
|
|
3219
|
+
setFollowNotice(t('status.streamStoppedReconnecting'))
|
|
2200
3220
|
controls.reconnect()
|
|
2201
3221
|
},
|
|
2202
3222
|
onError: (message) => {
|
|
@@ -2252,11 +3272,11 @@ window.__ModuleLoader__.load({
|
|
|
2252
3272
|
|
|
2253
3273
|
/** 统计流连接状态文案(连接层错误只动这里,不进错误横幅)。 */
|
|
2254
3274
|
const statsStatusText = () => {
|
|
2255
|
-
if (statsStatus === 'open') return '
|
|
2256
|
-
if (statsStatus === 'connecting') return '
|
|
2257
|
-
if (statsStatus === 'reconnecting') return '
|
|
2258
|
-
if (statsStatus === 'closed') return '
|
|
2259
|
-
return '
|
|
3275
|
+
if (statsStatus === 'open') return t('status.statsFollowing')
|
|
3276
|
+
if (statsStatus === 'connecting') return t('status.statsConnecting')
|
|
3277
|
+
if (statsStatus === 'reconnecting') return t('status.reconnecting')
|
|
3278
|
+
if (statsStatus === 'closed') return t('status.statsClosed')
|
|
3279
|
+
return t('status.statsStream')
|
|
2260
3280
|
}
|
|
2261
3281
|
|
|
2262
3282
|
const scrollToBottom = () => {
|
|
@@ -2273,11 +3293,11 @@ window.__ModuleLoader__.load({
|
|
|
2273
3293
|
|
|
2274
3294
|
/** 连接状态文案(连接层错误只动这里,不进错误横幅)。 */
|
|
2275
3295
|
const followStatusText = () => {
|
|
2276
|
-
if (followStatus === 'open') return '
|
|
2277
|
-
if (followStatus === 'connecting') return '
|
|
2278
|
-
if (followStatus === 'reconnecting') return '
|
|
2279
|
-
if (followStatus === 'closed') return '
|
|
2280
|
-
return '
|
|
3296
|
+
if (followStatus === 'open') return t('status.logsFollowing')
|
|
3297
|
+
if (followStatus === 'connecting') return t('status.logsConnecting')
|
|
3298
|
+
if (followStatus === 'reconnecting') return t('status.reconnecting')
|
|
3299
|
+
if (followStatus === 'closed') return t('status.logsClosed')
|
|
3300
|
+
return t('status.logsStream')
|
|
2281
3301
|
}
|
|
2282
3302
|
|
|
2283
3303
|
// 快照轮询:FOLLOW 打开时让位(实时流已经在推),关闭即恢复。
|
|
@@ -2319,7 +3339,7 @@ window.__ModuleLoader__.load({
|
|
|
2319
3339
|
if (!active) return undefined
|
|
2320
3340
|
if (tab !== 'stats' || !statsFollow) return undefined
|
|
2321
3341
|
if (typeof EventSource !== 'function') {
|
|
2322
|
-
setStatsNotice('
|
|
3342
|
+
setStatsNotice(t('error.noEventSource'))
|
|
2323
3343
|
setStatsFollow(false)
|
|
2324
3344
|
return undefined
|
|
2325
3345
|
}
|
|
@@ -2357,14 +3377,14 @@ window.__ModuleLoader__.load({
|
|
|
2357
3377
|
try { payload = JSON.parse(event.data) } catch { /* 畸形载荷按自然结束处理 */ }
|
|
2358
3378
|
const reason = payload !== null && typeof payload.reason === 'string' ? payload.reason : 'stats-exit'
|
|
2359
3379
|
const code = payload !== null && typeof payload.code === 'number' ? payload.code : null
|
|
2360
|
-
setStatsNotice('
|
|
3380
|
+
setStatsNotice(t('status.statsEnded') + (reason === 'stats-exit' ? (code === null ? t('status.statsExitedNoCode') : t('status.statsExited', { code })) : '') + t('status.backToSnapshotPolling'))
|
|
2361
3381
|
close()
|
|
2362
3382
|
setStatsFollow(false)
|
|
2363
3383
|
}
|
|
2364
3384
|
const onError = (event) => {
|
|
2365
3385
|
// 服务端 event:error 是带 data 的 MessageEvent;连接层错误是普通 Event
|
|
2366
3386
|
if (typeof event.data === 'string' && event.data !== '') {
|
|
2367
|
-
let message = '
|
|
3387
|
+
let message = t('error.statsStream')
|
|
2368
3388
|
try {
|
|
2369
3389
|
const payload = JSON.parse(event.data)
|
|
2370
3390
|
if (payload !== null && typeof payload.message === 'string') message = payload.message
|
|
@@ -2402,51 +3422,51 @@ window.__ModuleLoader__.load({
|
|
|
2402
3422
|
}
|
|
2403
3423
|
|
|
2404
3424
|
const overview = () => {
|
|
2405
|
-
if (detailError !== '') return jsx(Banner, { title: '
|
|
2406
|
-
if (detail === null) return jsx('div', { className: 'dk_empty', children: [jsx('span', { className: 'dk_spin' }), jsx('div', { children: '
|
|
3425
|
+
if (detailError !== '') return jsx(Banner, { title: t('error.inspectFailed'), hint: detailError })
|
|
3426
|
+
if (detail === null) return jsx('div', { className: 'dk_empty', children: [jsx('span', { className: 'dk_spin' }), jsx('div', { children: t('list.loading') })] })
|
|
2407
3427
|
const rows = [
|
|
2408
|
-
['
|
|
2409
|
-
['
|
|
2410
|
-
['
|
|
2411
|
-
['
|
|
2412
|
-
['
|
|
2413
|
-
['
|
|
2414
|
-
['
|
|
2415
|
-
['
|
|
3428
|
+
[t('field.state'), detail.state + (detail.health === null ? '' : ' / ' + detail.health) + (detail.status === '' ? '' : t('meta.parenValue', { value: detail.status }))],
|
|
3429
|
+
[t('field.image'), detail.image],
|
|
3430
|
+
[t('field.containerId'), detail.shortId],
|
|
3431
|
+
[t('field.startedAt'), detail.startedAt ?? '—'],
|
|
3432
|
+
[t('field.finishedAt'), detail.finishedAt ?? '—'],
|
|
3433
|
+
[t('field.exitCode'), detail.exitCode === null ? '—' : String(detail.exitCode)],
|
|
3434
|
+
[t('field.restartCount'), detail.restartCount === null ? '—' : String(detail.restartCount)],
|
|
3435
|
+
[t('field.restartPolicy'), detail.restartPolicy ?? '—'],
|
|
2416
3436
|
['PID', detail.pid === null ? '—' : String(detail.pid)],
|
|
2417
|
-
['
|
|
2418
|
-
['
|
|
2419
|
-
['
|
|
2420
|
-
['
|
|
2421
|
-
['
|
|
2422
|
-
['
|
|
3437
|
+
[t('field.ports'), detail.ports.length === 0 ? '—' : portsText(detail.ports)],
|
|
3438
|
+
[t('field.mounts'), detail.mounts.length === 0 ? '—' : detail.mounts.map((mount) => mount.source + '→' + mount.destination + (mount.readWrite ? '' : t('meta.readOnly'))).join('\n')],
|
|
3439
|
+
[t('field.networks'), detail.networks.length === 0 ? '—' : detail.networks.map((net) => net.name + (net.ip === null ? '' : t('meta.parenValue', { value: net.ip }))).join(', ')],
|
|
3440
|
+
[t('field.command'), (detail.entrypoint + ' ' + detail.command).trim() || '—'],
|
|
3441
|
+
[t('field.workingDir'), detail.workingDir === '' ? '—' : detail.workingDir],
|
|
3442
|
+
[t('field.user'), detail.user === '' ? '—' : detail.user],
|
|
2423
3443
|
]
|
|
2424
3444
|
const kv = jsxs('div', { className: 'dk_kv', children: rows.flatMap(([key, value], index) => [
|
|
2425
3445
|
jsx('div', { className: 'dk_kvKey', children: key }, 'k' + String(index)),
|
|
2426
|
-
jsx('div', { className: 'dk_kvVal' + (key === '
|
|
3446
|
+
jsx('div', { className: 'dk_kvVal' + (key === t('field.containerId') || key === t('field.command') || key === t('field.image') ? ' dk_kvValMono' : ''), children: value }, 'v' + String(index)),
|
|
2427
3447
|
]) })
|
|
2428
3448
|
return jsxs('div', { children: [
|
|
2429
|
-
detail.healthLogTail === null ? null : jsx('div', { className: 'dk_hint', style: { marginBottom: 8 }, children: '
|
|
3449
|
+
detail.healthLogTail === null ? null : jsx('div', { className: 'dk_hint', style: { marginBottom: 8 }, children: t('meta.healthLog') + detail.healthLogTail }),
|
|
2430
3450
|
kv,
|
|
2431
|
-
jsx('div', { className: 'dk_cardSection', style: { marginTop: 16 }, children: '
|
|
3451
|
+
jsx('div', { className: 'dk_cardSection', style: { marginTop: 16 }, children: t('panel.oneOffExec') }),
|
|
2432
3452
|
config.allowExec !== true
|
|
2433
|
-
? jsx(Banner, { kind: 'info', title: '
|
|
3453
|
+
? jsx(Banner, { kind: 'info', title: t('banner.execDisabled'), hint: t('hint.execDisabled') })
|
|
2434
3454
|
: jsxs('div', { children: [
|
|
2435
3455
|
jsxs('div', { className: 'dk_row', children: [
|
|
2436
3456
|
jsx('input', {
|
|
2437
3457
|
className: 'dk_input',
|
|
2438
3458
|
style: { flex: '1 1 auto' },
|
|
2439
|
-
placeholder: '
|
|
3459
|
+
placeholder: t('placeholder.execCommand'),
|
|
2440
3460
|
value: execCommand,
|
|
2441
3461
|
onChange: (event) => setExecCommand(event.target.value),
|
|
2442
3462
|
onKeyDown: (event) => { if (event.key === 'Enter') runExec() },
|
|
2443
3463
|
}),
|
|
2444
|
-
jsx('button', { type: 'button', className: 'dk_btn dk_btnPrimary', disabled: execRunning, onClick: runExec, children: execRunning ? '
|
|
3464
|
+
jsx('button', { type: 'button', className: 'dk_btn dk_btnPrimary', disabled: execRunning, onClick: runExec, children: execRunning ? t('status.executing') : t('btn.exec') }),
|
|
2445
3465
|
] }),
|
|
2446
|
-
execError === '' ? null : jsx(Banner, { title: '
|
|
3466
|
+
execError === '' ? null : jsx(Banner, { title: t('error.execFailed'), hint: execError }),
|
|
2447
3467
|
execResult === null ? null : jsxs('div', { style: { marginTop: 8 }, children: [
|
|
2448
|
-
jsx('div', { className: 'dk_hint', children: '
|
|
2449
|
-
jsx('pre', { className: 'dk_logBox', style: { marginTop: 6 }, children: (execResult.stdout || '') + (execResult.stderr === '' ? '' : '\n[stderr]\n' + execResult.stderr) || '
|
|
3468
|
+
jsx('div', { className: 'dk_hint', children: t('meta.exitCode') + (execResult.code === null ? '?' : String(execResult.code)) + t('meta.duration', { ms: execResult.durationMs }) + (execResult.truncated ? t('meta.truncated') : '') }),
|
|
3469
|
+
jsx('pre', { className: 'dk_logBox', style: { marginTop: 6 }, children: (execResult.stdout || '') + (execResult.stderr === '' ? '' : '\n[stderr]\n' + execResult.stderr) || t('list.noOutput') }),
|
|
2450
3470
|
] }),
|
|
2451
3471
|
] }),
|
|
2452
3472
|
] })
|
|
@@ -2504,7 +3524,7 @@ window.__ModuleLoader__.load({
|
|
|
2504
3524
|
className: 'dk_select dk_selectSm',
|
|
2505
3525
|
value: String(logOptions.tail),
|
|
2506
3526
|
// 行数与字节是两个闸:快照还会被设置卡片的「输出上限(KB)」按字节截断
|
|
2507
|
-
title: '
|
|
3527
|
+
title: t('hint.logTailTitle', { kb: outputCapKb }),
|
|
2508
3528
|
onChange: (event) => setLogOptions({ ...logOptions, tail: Number(event.target.value) }),
|
|
2509
3529
|
children: tailOptions.map((value) => jsx('option', { value: String(value), children: value === 5000 ? 'Last 5000' : 'Last ' + String(value) }, String(value))),
|
|
2510
3530
|
}),
|
|
@@ -2513,13 +3533,13 @@ window.__ModuleLoader__.load({
|
|
|
2513
3533
|
jsx('span', { className: 'dk_toolLabel', children: 'FOLLOW' }),
|
|
2514
3534
|
logPill(follow, follow ? 'On' : 'Off', toggleFollow, {
|
|
2515
3535
|
className: ' dk_pillFollow',
|
|
2516
|
-
title: follow ? '
|
|
3536
|
+
title: follow ? t('hint.followOff') : t('hint.followOn'),
|
|
2517
3537
|
}),
|
|
2518
3538
|
jsx('span', { className: 'dk_toolLabel', children: 'AUTO REFRESH' }),
|
|
2519
3539
|
// FOLLOW 已开时轮询无意义:置灰并给出原因(关闭 FOLLOW 后自动恢复可用)
|
|
2520
3540
|
logPill(logAuto, logAuto ? 'On' : 'Off', () => setLogAuto((value) => !value), {
|
|
2521
3541
|
disabled: follow,
|
|
2522
|
-
title: follow ? '
|
|
3542
|
+
title: follow ? t('hint.autoOff') : t('hint.autoOn'),
|
|
2523
3543
|
}),
|
|
2524
3544
|
jsx('select', {
|
|
2525
3545
|
className: 'dk_select dk_selectSm',
|
|
@@ -2527,7 +3547,7 @@ window.__ModuleLoader__.load({
|
|
|
2527
3547
|
// 间隔随时可改:关掉 AUTO REFRESH 时先选好、再开,不该被禁用;
|
|
2528
3548
|
// 只有 FOLLOW 期间禁用(轮询已停)
|
|
2529
3549
|
disabled: follow,
|
|
2530
|
-
title: '
|
|
3550
|
+
title: t('hint.autoRefreshTitle'),
|
|
2531
3551
|
onChange: (event) => setLogIntervalSec(Number(event.target.value)),
|
|
2532
3552
|
children: [2, 3, 5, 10].map((value) => jsx('option', { value: String(value), children: String(value) + 's' }, String(value))),
|
|
2533
3553
|
}),
|
|
@@ -2539,7 +3559,7 @@ window.__ModuleLoader__.load({
|
|
|
2539
3559
|
* 看不出是谁在转。与概览页 / 列表页的刷新按钮同款(IconAction 的 spin →
|
|
2540
3560
|
* data-spin → 图标转 + 高亮当前色)。
|
|
2541
3561
|
*/
|
|
2542
|
-
jsx(IconAction, { icon: ICON_REFRESH, title: '
|
|
3562
|
+
jsx(IconAction, { icon: ICON_REFRESH, title: t('btn.refreshLogs'), spin: logsLoading, onClick: loadLogs }, 'refresh'),
|
|
2543
3563
|
// 导出移到过滤条(与聚合日志同处、同两种格式);工具条只留传输与显示控制
|
|
2544
3564
|
] })
|
|
2545
3565
|
}
|
|
@@ -2555,7 +3575,7 @@ window.__ModuleLoader__.load({
|
|
|
2555
3575
|
jsxs('div', { className: 'dk_filterWrap', children: [
|
|
2556
3576
|
jsx('input', {
|
|
2557
3577
|
className: 'dk_input dk_filterInput',
|
|
2558
|
-
placeholder: '
|
|
3578
|
+
placeholder: t('placeholder.filterLogs'),
|
|
2559
3579
|
value: logFilter,
|
|
2560
3580
|
onChange: (event) => setLogFilter(event.target.value),
|
|
2561
3581
|
// Esc 清空过滤(而不是冒泡去最小化 / 关闭面板)
|
|
@@ -2569,8 +3589,8 @@ window.__ModuleLoader__.load({
|
|
|
2569
3589
|
logFilter === '' ? null : jsx('button', {
|
|
2570
3590
|
type: 'button',
|
|
2571
3591
|
className: 'dk_filterClear',
|
|
2572
|
-
title: '
|
|
2573
|
-
'aria-label': '
|
|
3592
|
+
title: t('btn.clearFilter'),
|
|
3593
|
+
'aria-label': t('btn.clearFilter'),
|
|
2574
3594
|
onClick: () => setLogFilter(''),
|
|
2575
3595
|
children: jsx('span', { className: 'dk_iconGlyph', dangerouslySetInnerHTML: { __html: ICON_CLOSE } }),
|
|
2576
3596
|
}, 'clear'),
|
|
@@ -2578,24 +3598,24 @@ window.__ModuleLoader__.load({
|
|
|
2578
3598
|
jsx('select', {
|
|
2579
3599
|
className: 'dk_select dk_selectSm',
|
|
2580
3600
|
value: String(levelMin),
|
|
2581
|
-
title: '
|
|
3601
|
+
title: t('hint.levelFilter'),
|
|
2582
3602
|
onChange: (event) => setLevelMin(Number(event.target.value)),
|
|
2583
|
-
children:
|
|
3603
|
+
children: logLevelOptions().map((option) => jsx('option', { value: String(option.value), children: option.label }, String(option.value))),
|
|
2584
3604
|
}, 'level'),
|
|
2585
3605
|
jsx('button', {
|
|
2586
3606
|
type: 'button',
|
|
2587
3607
|
className: 'dk_chip',
|
|
2588
3608
|
disabled: matched.length === 0,
|
|
2589
3609
|
'aria-haspopup': 'menu',
|
|
2590
|
-
title: '
|
|
2591
|
-
onClick: (event) => openExportMenu(event, { sub: item.name + ' · ' + String(matched.length) + '
|
|
2592
|
-
children:
|
|
3610
|
+
title: t('hint.exportMenu'),
|
|
3611
|
+
onClick: (event) => openExportMenu(event, { sub: item.name + ' · ' + String(matched.length) + t('meta.rowsSuffix'), onPick: doLogExport }),
|
|
3612
|
+
children: logExportLabel(),
|
|
2593
3613
|
}, 'export'),
|
|
2594
3614
|
jsx('span', {
|
|
2595
3615
|
className: 'dk_filterCount',
|
|
2596
3616
|
children: needle === '' && levelMin === 0
|
|
2597
|
-
? String(total) + '
|
|
2598
|
-
: String(matched.length) + ' / ' + String(total) + '
|
|
3617
|
+
? String(total) + t('meta.rowsSuffix')
|
|
3618
|
+
: String(matched.length) + ' / ' + String(total) + t('meta.rowsSuffix'),
|
|
2599
3619
|
}, 'count'),
|
|
2600
3620
|
] })
|
|
2601
3621
|
}
|
|
@@ -2612,7 +3632,7 @@ window.__ModuleLoader__.load({
|
|
|
2612
3632
|
})
|
|
2613
3633
|
const text = buildLogExport(rows, {
|
|
2614
3634
|
format,
|
|
2615
|
-
scope: '
|
|
3635
|
+
scope: t('panel.containerLogs'),
|
|
2616
3636
|
target: props.target,
|
|
2617
3637
|
targetLabel: props.targetLabel,
|
|
2618
3638
|
items: [item],
|
|
@@ -2625,26 +3645,26 @@ window.__ModuleLoader__.load({
|
|
|
2625
3645
|
const { needle, matched } = logStats()
|
|
2626
3646
|
return jsxs('div', { className: 'dk_logs', children: [
|
|
2627
3647
|
logsError === '' ? null : jsx(Banner, {
|
|
2628
|
-
title: '
|
|
2629
|
-
hint: logsError + (logsError.includes('Failed to fetch') ? '
|
|
2630
|
-
action: jsx('button', { type: 'button', className: 'dk_btn', disabled: logsLoading, onClick: loadLogs, children: '
|
|
3648
|
+
title: t('error.logsFailed'),
|
|
3649
|
+
hint: logsError + (logsError.includes('Failed to fetch') ? t('hint.fetchFailed') : ''),
|
|
3650
|
+
action: jsx('button', { type: 'button', className: 'dk_btn', disabled: logsLoading, onClick: loadLogs, children: t('btn.retry') }),
|
|
2631
3651
|
}),
|
|
2632
3652
|
// 流异常(服务端 event:error / 环境不支持)才弹横幅;连接层断线只进状态行
|
|
2633
3653
|
followError === '' ? null : jsx(Banner, {
|
|
2634
|
-
title: '
|
|
3654
|
+
title: t('error.logStreamInterrupted'),
|
|
2635
3655
|
hint: followError,
|
|
2636
|
-
action: jsx('button', { type: 'button', className: 'dk_btn', onClick: toggleFollow, children: '
|
|
3656
|
+
action: jsx('button', { type: 'button', className: 'dk_btn', onClick: toggleFollow, children: t('btn.retry') }),
|
|
2637
3657
|
}),
|
|
2638
3658
|
followNotice === '' ? null : jsx(Banner, { kind: 'info', title: followNotice }),
|
|
2639
3659
|
followDropped ? jsx(Banner, {
|
|
2640
3660
|
kind: 'warn',
|
|
2641
|
-
title: '
|
|
2642
|
-
hint: '
|
|
3661
|
+
title: t('hint.bufferExceeded', { lines: FOLLOW_LINE_LIMIT, mb: Math.round(FOLLOW_BYTE_LIMIT / 1024 / 1024) }),
|
|
3662
|
+
hint: t('hint.streamKeepsRecent'),
|
|
2643
3663
|
}) : null,
|
|
2644
3664
|
!follow && logs !== null && logs.truncated === true ? jsx(Banner, {
|
|
2645
3665
|
kind: 'warn',
|
|
2646
|
-
title: '
|
|
2647
|
-
hint: '
|
|
3666
|
+
title: t('hint.outputTruncated', { kb: outputCapKb }),
|
|
3667
|
+
hint: t('hint.byteCap'),
|
|
2648
3668
|
}) : null,
|
|
2649
3669
|
follow ? jsx('div', { className: 'dk_followState', 'data-state': followStatus, children: followStatusText() }) : null,
|
|
2650
3670
|
jsxs('div', {
|
|
@@ -2652,7 +3672,7 @@ window.__ModuleLoader__.load({
|
|
|
2652
3672
|
ref: logBodyRef,
|
|
2653
3673
|
// 可聚焦(D64):键盘用户 Tab 进来才能滚动日志、用键盘触发「问 Agent」
|
|
2654
3674
|
tabIndex: 0,
|
|
2655
|
-
'aria-label': '
|
|
3675
|
+
'aria-label': t('panel.containerLogs'),
|
|
2656
3676
|
onScroll: onLogScroll,
|
|
2657
3677
|
// 右键「问 Agent」:单容器视图里上下文就是当前这一条容器
|
|
2658
3678
|
onContextMenu: (event) => onLogContextMenu(event, logBodyRef.current, {
|
|
@@ -2665,14 +3685,14 @@ window.__ModuleLoader__.load({
|
|
|
2665
3685
|
logsError !== ''
|
|
2666
3686
|
? null
|
|
2667
3687
|
: (!follow && logs === null)
|
|
2668
|
-
? jsx('div', { className: 'dk_logLine', children: '
|
|
3688
|
+
? jsx('div', { className: 'dk_logLine', children: t('list.loading') }, 'loading')
|
|
2669
3689
|
: (matched.length === 0
|
|
2670
|
-
? jsx('div', { className: 'dk_logLine', children: follow ? '
|
|
3690
|
+
? jsx('div', { className: 'dk_logLine', children: follow ? t('list.waitingLogs') : (needle === '' ? t('list.noLogs') : t('list.noMatchingLogs')) }, 'empty')
|
|
2671
3691
|
: matched.map((entry) => renderLogLine(entry, needle))),
|
|
2672
3692
|
],
|
|
2673
3693
|
}),
|
|
2674
3694
|
follow && !followAtBottom
|
|
2675
|
-
? jsx('button', { type: 'button', className: 'dk_backToBottom', onClick: scrollToBottom, children: '
|
|
3695
|
+
? jsx('button', { type: 'button', className: 'dk_backToBottom', onClick: scrollToBottom, children: t('btn.backToBottom') })
|
|
2676
3696
|
: null,
|
|
2677
3697
|
] })
|
|
2678
3698
|
}
|
|
@@ -2683,9 +3703,9 @@ window.__ModuleLoader__.load({
|
|
|
2683
3703
|
jsx('span', { className: 'dk_toolLabel', children: 'FOLLOW' }),
|
|
2684
3704
|
logPill(following, following ? 'On' : 'Off', toggleStatsFollow, {
|
|
2685
3705
|
className: ' dk_pillFollow',
|
|
2686
|
-
title: following ? '
|
|
3706
|
+
title: following ? t('hint.statsFollowOff') : t('hint.statsFollowOn'),
|
|
2687
3707
|
}),
|
|
2688
|
-
jsx('span', { className: 'dk_hint', children: following ? '
|
|
3708
|
+
jsx('span', { className: 'dk_hint', children: following ? t('hint.sparkWindow') : t('hint.sparkFollow') }),
|
|
2689
3709
|
jsx('span', { className: 'dk_headerSpacer' }),
|
|
2690
3710
|
following ? jsx('span', { className: 'dk_followState', 'data-state': statsStatus, children: statsStatusText() }) : null,
|
|
2691
3711
|
] })
|
|
@@ -2695,13 +3715,13 @@ window.__ModuleLoader__.load({
|
|
|
2695
3715
|
if (statsNotice !== '') {
|
|
2696
3716
|
return wrap(jsxs('div', { children: [
|
|
2697
3717
|
jsx(Banner, { kind: 'info', title: statsNotice }),
|
|
2698
|
-
statsError !== '' ? jsx(Banner, { title: '
|
|
2699
|
-
? jsx('div', { className: 'dk_empty', children: [jsx('span', { className: 'dk_spin' }), jsx('div', { children: '
|
|
3718
|
+
statsError !== '' ? jsx(Banner, { title: t('error.statsFailed'), hint: statsError }) : stats === null
|
|
3719
|
+
? jsx('div', { className: 'dk_empty', children: [jsx('span', { className: 'dk_spin' }), jsx('div', { children: t('list.loading') })] })
|
|
2700
3720
|
: statsBody(),
|
|
2701
3721
|
] }))
|
|
2702
3722
|
}
|
|
2703
|
-
if (statsError !== '') return wrap(jsx(Banner, { title: '
|
|
2704
|
-
if (stats === null) return wrap(jsx('div', { className: 'dk_empty', children: [jsx('span', { className: 'dk_spin' }), jsx('div', { children: '
|
|
3723
|
+
if (statsError !== '') return wrap(jsx(Banner, { title: t('error.statsFailed'), hint: statsError }))
|
|
3724
|
+
if (stats === null) return wrap(jsx('div', { className: 'dk_empty', children: [jsx('span', { className: 'dk_spin' }), jsx('div', { children: t('list.loading') })] }))
|
|
2705
3725
|
return wrap(statsBody())
|
|
2706
3726
|
|
|
2707
3727
|
function statsBody() {
|
|
@@ -2723,27 +3743,27 @@ window.__ModuleLoader__.load({
|
|
|
2723
3743
|
] }, label)
|
|
2724
3744
|
return jsxs('table', { className: 'dk_stats', children: [
|
|
2725
3745
|
jsx('thead', { children: jsxs('tr', { children: [
|
|
2726
|
-
jsx('th', { children: '
|
|
3746
|
+
jsx('th', { children: t('field.metric') }), jsx('th', { children: t('field.value') }), jsx('th', { children: t('field.usageTrend') }),
|
|
2727
3747
|
] }) }),
|
|
2728
3748
|
jsx('tbody', { children: [
|
|
2729
3749
|
row('CPU', fmtPercent(stats.cpuPercent), jsxs('div', { className: 'dk_trend', children: [
|
|
2730
3750
|
bar(cpu),
|
|
2731
3751
|
// 没开过 FOLLOW 时没有采样点,别显示一个空趋势图(只留占用条)
|
|
2732
|
-
following || statsSeries.cpu.length > 0 ? jsx(Sparkline, { values: statsSeries.cpu, max: cpuMax, alertAt: 85, title: '
|
|
3752
|
+
following || statsSeries.cpu.length > 0 ? jsx(Sparkline, { values: statsSeries.cpu, max: cpuMax, alertAt: 85, title: t('hint.cpuSpark') }) : null,
|
|
2733
3753
|
] })),
|
|
2734
|
-
row('
|
|
3754
|
+
row(t('field.memory'), stats.memUsage, jsxs('div', { className: 'dk_trend', children: [
|
|
2735
3755
|
bar(mem),
|
|
2736
|
-
following || statsSeries.mem.length > 0 ? jsx(Sparkline, { values: statsSeries.mem, max: 100, alertAt: 85, title: '
|
|
3756
|
+
following || statsSeries.mem.length > 0 ? jsx(Sparkline, { values: statsSeries.mem, max: 100, alertAt: 85, title: t('hint.memSpark') }) : null,
|
|
2737
3757
|
] })),
|
|
2738
|
-
row('
|
|
2739
|
-
row('
|
|
3758
|
+
row(t('field.netIO'), stats.netIO, null),
|
|
3759
|
+
row(t('field.blockIO'), stats.blockIO, null),
|
|
2740
3760
|
row('PIDs', stats.pids === null ? '—' : String(stats.pids), null),
|
|
2741
3761
|
] }),
|
|
2742
3762
|
] })
|
|
2743
3763
|
}
|
|
2744
3764
|
}
|
|
2745
3765
|
|
|
2746
|
-
const tabs = [['overview', '
|
|
3766
|
+
const tabs = [['overview', t('panel.tabOverview')], ['logs', t('panel.tabLogs')], ['stats', t('panel.tabStats')]]
|
|
2747
3767
|
// 当前页是否还在取数:刷新按钮据此转圈(点刷新后必然转,首次打开也转,符合「正在取数」的直觉)
|
|
2748
3768
|
const refreshing = tab === 'overview'
|
|
2749
3769
|
? detail === null && detailError === ''
|
|
@@ -2753,14 +3773,14 @@ window.__ModuleLoader__.load({
|
|
|
2753
3773
|
// 第二行 = 标签页 +(日志页:过滤行)
|
|
2754
3774
|
return jsxs('div', { className: 'dk_detail', children: [
|
|
2755
3775
|
jsxs('div', { className: 'dk_header dk_headerDetail', children: [
|
|
2756
|
-
jsx(IconAction, { icon: ICON_BACK, title: '
|
|
3776
|
+
jsx(IconAction, { icon: ICON_BACK, title: t('btn.backToContainers'), onClick: props.onBack }, 'back'),
|
|
2757
3777
|
jsx('span', { className: 'dk_detailTitle', title: item.name, children: item.name }),
|
|
2758
3778
|
jsx(Badge, { state: item.state, health: item.health, status: item.status }),
|
|
2759
3779
|
jsx('span', { className: 'dk_detailSub', children: props.targetLabel ?? '' }),
|
|
2760
3780
|
jsx('span', { className: 'dk_headerSpacer' }),
|
|
2761
|
-
tab === 'logs' ? logControls() : jsx(IconAction, { icon: ICON_REFRESH, title: '
|
|
3781
|
+
tab === 'logs' ? logControls() : jsx(IconAction, { icon: ICON_REFRESH, title: t('btn.refresh'), spin: refreshing, onClick: props.onRefresh }, 'refresh'),
|
|
2762
3782
|
// dock 模式的 ✕ 在 tty 的挂载位标题栏上(这里再来一个会重复)
|
|
2763
|
-
props.docked === true ? null : jsx('button', { type: 'button', className: 'dk_iconBtn', title: '
|
|
3783
|
+
props.docked === true ? null : jsx('button', { type: 'button', className: 'dk_iconBtn', title: t('btn.closePanel'), onClick: props.onClose, children: jsx('span', { className: 'dk_iconGlyph', dangerouslySetInnerHTML: { __html: ICON_CLOSE } }) }, 'close'),
|
|
2764
3784
|
] }),
|
|
2765
3785
|
jsxs('div', { className: 'dk_tabs', children: [
|
|
2766
3786
|
...tabs.map(([key, label]) => jsx('button', {
|
|
@@ -2807,39 +3827,39 @@ window.__ModuleLoader__.load({
|
|
|
2807
3827
|
|
|
2808
3828
|
const kv = (rows) => jsxs('div', { className: 'dk_kv', children: rows.flatMap(([key, value], index) => [
|
|
2809
3829
|
jsx('div', { className: 'dk_kvKey', children: key }, 'k' + String(index)),
|
|
2810
|
-
jsx('div', { className: 'dk_kvVal' + (['ID', '
|
|
3830
|
+
jsx('div', { className: 'dk_kvVal' + (['ID', t('field.entrypoint'), 'digest'].indexOf(key) >= 0 ? ' dk_kvValMono' : ''), children: value }, 'v' + String(index)),
|
|
2811
3831
|
]) })
|
|
2812
3832
|
|
|
2813
3833
|
const overview = () => {
|
|
2814
|
-
if (error !== '') return jsx(Banner, { title: '
|
|
2815
|
-
if (data === null) return jsx('div', { className: 'dk_empty', children: [jsx('span', { className: 'dk_spin' }), jsx('div', { children: '
|
|
3834
|
+
if (error !== '') return jsx(Banner, { title: t('error.imageDetailFailed'), hint: error, action: jsx('button', { type: 'button', className: 'dk_btn', onClick: load, children: t('btn.retry') }) })
|
|
3835
|
+
if (data === null) return jsx('div', { className: 'dk_empty', children: [jsx('span', { className: 'dk_spin' }), jsx('div', { children: t('list.loading') })] })
|
|
2816
3836
|
const d = data.detail
|
|
2817
3837
|
const rows = [
|
|
2818
|
-
['
|
|
3838
|
+
[t('field.labels'), d.repoTags.length === 0 ? t('list.dangling') : d.repoTags.join('\n')],
|
|
2819
3839
|
['ID', d.id],
|
|
2820
|
-
['
|
|
2821
|
-
['
|
|
2822
|
-
['
|
|
2823
|
-
['
|
|
2824
|
-
['
|
|
2825
|
-
['
|
|
2826
|
-
['
|
|
2827
|
-
['
|
|
2828
|
-
['
|
|
3840
|
+
[t('field.size'), d.size === null ? '—' : fmtBytes(d.size)],
|
|
3841
|
+
[t('field.virtualSize'), d.virtualSize === null ? '—' : fmtBytes(d.virtualSize)],
|
|
3842
|
+
[t('field.created'), d.created === '' ? '—' : fmtCreated(d.created)],
|
|
3843
|
+
[t('field.platform'), (d.os === '' && d.architecture === '') ? '—' : d.os + '/' + d.architecture],
|
|
3844
|
+
[t('field.layerCount'), String(d.layerCount)],
|
|
3845
|
+
[t('field.entrypoint'), (d.entrypoint + ' ' + d.command).trim() || '—'],
|
|
3846
|
+
[t('field.workingDir'), d.workingDir === '' ? '—' : d.workingDir],
|
|
3847
|
+
[t('field.user'), d.user === '' ? '—' : d.user],
|
|
3848
|
+
[t('field.exposedPorts'), d.exposedPorts.length === 0 ? '—' : d.exposedPorts.join(', ')],
|
|
2829
3849
|
['digest', d.repoDigests.length === 0 ? '—' : d.repoDigests.join('\n')],
|
|
2830
3850
|
]
|
|
2831
3851
|
const labelEntries = Object.entries(d.labels)
|
|
2832
3852
|
return jsxs('div', { children: [
|
|
2833
3853
|
kv(rows),
|
|
2834
|
-
jsx('div', { className: 'dk_cardSection', style: { marginTop: 16 }, children: '
|
|
3854
|
+
jsx('div', { className: 'dk_cardSection', style: { marginTop: 16 }, children: t('panel.layersCount', { count: d.layerCount }) }),
|
|
2835
3855
|
d.layers.length === 0
|
|
2836
|
-
? jsx('span', { className: 'dk_hint', children: '
|
|
3856
|
+
? jsx('span', { className: 'dk_hint', children: t('list.noLayerInfo') })
|
|
2837
3857
|
: jsx('div', { className: 'dk_layerList', children: d.layers.map((layer, index) => jsxs('div', { className: 'dk_layerItem', children: [
|
|
2838
3858
|
jsx('span', { className: 'dk_layerIndex', children: '#' + String(index) }),
|
|
2839
3859
|
jsx('span', { className: 'dk_mono dk_layerId', title: layer, children: layer.replace(/^sha256:/, '') }),
|
|
2840
3860
|
] }, layer + String(index))) }),
|
|
2841
3861
|
labelEntries.length === 0 ? null : jsxs('div', { children: [
|
|
2842
|
-
jsx('div', { className: 'dk_cardSection', style: { marginTop: 16 }, children: '
|
|
3862
|
+
jsx('div', { className: 'dk_cardSection', style: { marginTop: 16 }, children: t('panel.labelsCount', { count: labelEntries.length }) }),
|
|
2843
3863
|
jsx('div', { className: 'dk_labelList', children: labelEntries.map(([key, value]) => jsxs('div', { className: 'dk_labelItem', children: [
|
|
2844
3864
|
jsx('span', { className: 'dk_labelKey', children: key }),
|
|
2845
3865
|
jsx('span', { className: 'dk_labelVal', title: value, children: value }),
|
|
@@ -2849,16 +3869,16 @@ window.__ModuleLoader__.load({
|
|
|
2849
3869
|
}
|
|
2850
3870
|
|
|
2851
3871
|
const history = () => {
|
|
2852
|
-
if (error !== '') return jsx(Banner, { title: '
|
|
2853
|
-
if (data === null) return jsx('div', { className: 'dk_empty', children: [jsx('span', { className: 'dk_spin' }), jsx('div', { children: '
|
|
2854
|
-
if (data.historyError !== null) return jsx(Banner, { kind: 'warn', title: '
|
|
3872
|
+
if (error !== '') return jsx(Banner, { title: t('error.imageDetailFailed'), hint: error })
|
|
3873
|
+
if (data === null) return jsx('div', { className: 'dk_empty', children: [jsx('span', { className: 'dk_spin' }), jsx('div', { children: t('list.loading') })] })
|
|
3874
|
+
if (data.historyError !== null) return jsx(Banner, { kind: 'warn', title: t('error.historyFailed'), hint: data.historyError })
|
|
2855
3875
|
if (data.history.length === 0) return jsxs('div', { className: 'dk_empty', children: [
|
|
2856
|
-
jsx('div', { className: 'dk_emptyTitle', children: '
|
|
2857
|
-
jsx('div', { className: 'dk_emptyHint', children: '
|
|
3876
|
+
jsx('div', { className: 'dk_emptyTitle', children: t('list.noHistory') }),
|
|
3877
|
+
jsx('div', { className: 'dk_emptyHint', children: t('hint.noHistory') }),
|
|
2858
3878
|
] })
|
|
2859
3879
|
return jsx('div', { className: 'dk_tableWrap', children: jsxs('table', { className: 'dk_images dk_historyTable', children: [
|
|
2860
3880
|
jsx('thead', { children: jsxs('tr', { children: [
|
|
2861
|
-
jsx('th', { children: '
|
|
3881
|
+
jsx('th', { children: t('field.layerId') }), jsx('th', { children: t('field.created') }), jsx('th', { children: t('field.size') }), jsx('th', { children: t('field.buildCommand') }),
|
|
2862
3882
|
] }) }),
|
|
2863
3883
|
jsx('tbody', { children: data.history.map((step, index) => jsxs('tr', { children: [
|
|
2864
3884
|
jsx('td', { className: 'dk_mono', children: step.shortId }),
|
|
@@ -2869,16 +3889,16 @@ window.__ModuleLoader__.load({
|
|
|
2869
3889
|
] }) })
|
|
2870
3890
|
}
|
|
2871
3891
|
|
|
2872
|
-
const tabs = [['overview', '
|
|
3892
|
+
const tabs = [['overview', t('panel.tabOverview')], ['history', t('panel.tabHistory')]]
|
|
2873
3893
|
return jsxs('div', { className: 'dk_detail', children: [
|
|
2874
3894
|
jsxs('div', { className: 'dk_header dk_headerDetail', children: [
|
|
2875
|
-
jsx(IconAction, { icon: ICON_BACK, title: '
|
|
3895
|
+
jsx(IconAction, { icon: ICON_BACK, title: t('btn.backToImages'), onClick: props.onBack }, 'back'),
|
|
2876
3896
|
jsx('span', { className: 'dk_detailTitle', title: ref, children: ref }),
|
|
2877
3897
|
item.dangling === true ? jsx('span', { className: 'dk_badge', 'data-state': 'paused', children: 'dangling' }) : null,
|
|
2878
3898
|
jsx('span', { className: 'dk_detailSub', children: props.targetLabel ?? '' }),
|
|
2879
3899
|
jsx('span', { className: 'dk_headerSpacer' }),
|
|
2880
|
-
jsx(IconAction, { icon: ICON_REFRESH, title: '
|
|
2881
|
-
props.docked === true ? null : jsx('button', { type: 'button', className: 'dk_iconBtn', title: '
|
|
3900
|
+
jsx(IconAction, { icon: ICON_REFRESH, title: t('btn.refreshImageDetail'), spin: loading, onClick: load }, 'refresh'),
|
|
3901
|
+
props.docked === true ? null : jsx('button', { type: 'button', className: 'dk_iconBtn', title: t('btn.closePanel'), onClick: props.onClose, children: jsx('span', { className: 'dk_iconGlyph', dangerouslySetInnerHTML: { __html: ICON_CLOSE } }) }, 'close'),
|
|
2882
3902
|
] }),
|
|
2883
3903
|
jsx('div', { className: 'dk_tabs', children: tabs.map(([key, label]) => jsx('button', {
|
|
2884
3904
|
type: 'button', className: 'dk_tab', 'data-on': tab === key ? '1' : '0', onClick: () => setTab(key), children: label,
|
|
@@ -2931,37 +3951,37 @@ window.__ModuleLoader__.load({
|
|
|
2931
3951
|
}
|
|
2932
3952
|
|
|
2933
3953
|
const overview = () => {
|
|
2934
|
-
if (error !== '') return jsx(Banner, { title: '
|
|
2935
|
-
if (data === null) return jsx('div', { className: 'dk_empty', children: [jsx('span', { className: 'dk_spin' }), jsx('div', { children: '
|
|
3954
|
+
if (error !== '') return jsx(Banner, { title: t('error.networkDetailFailed'), hint: error, action: jsx('button', { type: 'button', className: 'dk_btn', onClick: load, children: t('btn.retry') }) })
|
|
3955
|
+
if (data === null) return jsx('div', { className: 'dk_empty', children: [jsx('span', { className: 'dk_spin' }), jsx('div', { children: t('list.loading') })] })
|
|
2936
3956
|
const d = data.detail
|
|
2937
3957
|
const rows = [
|
|
2938
|
-
['
|
|
3958
|
+
[t('field.name'), d.name],
|
|
2939
3959
|
['ID', d.id],
|
|
2940
|
-
['
|
|
2941
|
-
['
|
|
2942
|
-
['
|
|
2943
|
-
['
|
|
2944
|
-
['
|
|
2945
|
-
['
|
|
3960
|
+
[t('field.driver'), d.driver === '' ? '—' : d.driver],
|
|
3961
|
+
[t('field.scope'), d.scope === '' ? '—' : d.scope],
|
|
3962
|
+
[t('field.created'), d.created === '' ? '—' : fmtCreated(d.created)],
|
|
3963
|
+
[t('field.subnets'), d.subnets.length === 0 ? '—' : d.subnets.map((entry) => entry.subnet === '' ? '—' : entry.subnet).join('\n')],
|
|
3964
|
+
[t('field.gateway'), d.subnets.length === 0 ? '—' : d.subnets.map((entry) => entry.gateway === '' ? '—' : entry.gateway).join('\n')],
|
|
3965
|
+
[t('field.attributes'), [
|
|
2946
3966
|
d.internal ? 'internal' : '',
|
|
2947
3967
|
d.attachable ? 'attachable' : '',
|
|
2948
3968
|
d.ingress ? 'ingress' : '',
|
|
2949
3969
|
d.enableIpv6 ? 'ipv6' : '',
|
|
2950
3970
|
].filter((text) => text !== '').join(' · ') || '—'],
|
|
2951
|
-
['
|
|
2952
|
-
['
|
|
3971
|
+
[t('field.options'), Object.keys(d.options).length === 0 ? '—' : Object.entries(d.options).map(([key, value]) => key + '=' + value).join('\n')],
|
|
3972
|
+
[t('field.labels'), Object.keys(d.labels).length === 0 ? '—' : Object.entries(d.labels).map(([key, value]) => key + '=' + value).join('\n')],
|
|
2953
3973
|
]
|
|
2954
|
-
return jsx(KvList, { rows, mono: ['ID', '
|
|
3974
|
+
return jsx(KvList, { rows, mono: ['ID', t('field.subnets'), t('field.gateway'), t('field.options'), t('field.labels')] })
|
|
2955
3975
|
}
|
|
2956
3976
|
|
|
2957
3977
|
const containersTab = () => {
|
|
2958
|
-
if (error !== '') return jsx(Banner, { title: '
|
|
2959
|
-
if (data === null) return jsx('div', { className: 'dk_empty', children: [jsx('span', { className: 'dk_spin' }), jsx('div', { children: '
|
|
3978
|
+
if (error !== '') return jsx(Banner, { title: t('error.networkDetailFailed'), hint: error })
|
|
3979
|
+
if (data === null) return jsx('div', { className: 'dk_empty', children: [jsx('span', { className: 'dk_spin' }), jsx('div', { children: t('list.loading') })] })
|
|
2960
3980
|
const rows = data.detail.containers
|
|
2961
|
-
if (rows.length === 0) return jsx('div', { className: 'dk_empty', children: [jsx('div', { className: 'dk_emptyTitle', children: '
|
|
3981
|
+
if (rows.length === 0) return jsx('div', { className: 'dk_empty', children: [jsx('div', { className: 'dk_emptyTitle', children: t('list.noContainersInNetwork') })] })
|
|
2962
3982
|
return jsx('div', { className: 'dk_tableWrap', children: jsxs('table', { className: 'dk_images', children: [
|
|
2963
3983
|
jsx('thead', { children: jsxs('tr', { children: [
|
|
2964
|
-
jsx('th', { children: '
|
|
3984
|
+
jsx('th', { children: t('panel.containers') }), jsx('th', { children: 'IPv4' }), jsx('th', { children: 'IPv6' }), jsx('th', { children: 'MAC' }),
|
|
2965
3985
|
] }) }),
|
|
2966
3986
|
jsx('tbody', { children: rows.map((row) => jsxs('tr', { children: [
|
|
2967
3987
|
jsx('td', { className: 'dk_mono', title: row.id, children: row.name === '' ? row.shortId : row.name }),
|
|
@@ -2972,36 +3992,36 @@ window.__ModuleLoader__.load({
|
|
|
2972
3992
|
] }) })
|
|
2973
3993
|
}
|
|
2974
3994
|
|
|
2975
|
-
const tabs = [['overview', '
|
|
3995
|
+
const tabs = [['overview', t('panel.tabOverview')], ['containers', t('panel.attachedContainers') + (data === null ? '' : t('meta.parenValue', { value: data.detail.containers.length }))]]
|
|
2976
3996
|
return jsxs('div', { className: 'dk_detail', children: [
|
|
2977
3997
|
jsxs('div', { className: 'dk_header dk_headerDetail', children: [
|
|
2978
|
-
jsx(IconAction, { icon: ICON_BACK, title: '
|
|
3998
|
+
jsx(IconAction, { icon: ICON_BACK, title: t('btn.backToNetworks'), onClick: props.onBack }, 'back'),
|
|
2979
3999
|
jsx('span', { className: 'dk_projectIcon', dangerouslySetInnerHTML: { __html: ICON_NETWORK } }),
|
|
2980
4000
|
jsx('span', { className: 'dk_detailTitle', title: name, children: name }),
|
|
2981
4001
|
item.internal === true ? jsx('span', { className: 'dk_badge', 'data-state': 'paused', children: 'internal' }) : null,
|
|
2982
4002
|
jsx('span', { className: 'dk_detailSub', children: props.targetLabel ?? '' }),
|
|
2983
4003
|
jsx('span', { className: 'dk_headerSpacer' }),
|
|
2984
|
-
jsx(IconAction, { icon: ICON_REFRESH, title: '
|
|
4004
|
+
jsx(IconAction, { icon: ICON_REFRESH, title: t('btn.refreshNetworkDetail'), spin: loading, onClick: load }, 'refresh'),
|
|
2985
4005
|
jsx(IconAction, {
|
|
2986
4006
|
icon: ICON_TRASH,
|
|
2987
4007
|
danger: true,
|
|
2988
4008
|
disabled: props.allowMutations !== true,
|
|
2989
|
-
title: props.allowMutations === true ? '
|
|
4009
|
+
title: props.allowMutations === true ? t('btn.removeNetwork') : t('btn.removeNetworkDisabled'),
|
|
2990
4010
|
onClick: () => setConfirming(true),
|
|
2991
4011
|
}, 'remove'),
|
|
2992
|
-
props.docked === true ? null : jsx('button', { type: 'button', className: 'dk_iconBtn', title: '
|
|
4012
|
+
props.docked === true ? null : jsx('button', { type: 'button', className: 'dk_iconBtn', title: t('btn.closePanel'), onClick: props.onClose, children: jsx('span', { className: 'dk_iconGlyph', dangerouslySetInnerHTML: { __html: ICON_CLOSE } }) }, 'close'),
|
|
2993
4013
|
] }),
|
|
2994
4014
|
jsx('div', { className: 'dk_tabs', children: tabs.map(([key, label]) => jsx('button', {
|
|
2995
4015
|
type: 'button', className: 'dk_tab', 'data-on': tab === key ? '1' : '0', onClick: () => setTab(key), children: label,
|
|
2996
4016
|
}, key)) }),
|
|
2997
4017
|
jsxs('div', { className: 'dk_detailBody', children: [
|
|
2998
|
-
removeError === '' ? null : jsx(Banner, { title: '
|
|
4018
|
+
removeError === '' ? null : jsx(Banner, { title: t('error.removeNetworkFailed'), hint: removeError }),
|
|
2999
4019
|
tab === 'overview' ? overview() : containersTab(),
|
|
3000
4020
|
] }),
|
|
3001
4021
|
confirming ? jsx(ConfirmDialog, {
|
|
3002
|
-
title: '
|
|
3003
|
-
text: '
|
|
3004
|
-
confirmLabel: '
|
|
4022
|
+
title: t('btn.removeNetworkShort'),
|
|
4023
|
+
text: t('confirm.removeNetwork', { name }),
|
|
4024
|
+
confirmLabel: t('btn.delete'),
|
|
3005
4025
|
busy: removing,
|
|
3006
4026
|
onCancel: () => setConfirming(false),
|
|
3007
4027
|
onConfirm: runRemove,
|
|
@@ -3044,46 +4064,46 @@ window.__ModuleLoader__.load({
|
|
|
3044
4064
|
}
|
|
3045
4065
|
|
|
3046
4066
|
const body = () => {
|
|
3047
|
-
if (error !== '') return jsx(Banner, { title: '
|
|
3048
|
-
if (data === null) return jsx('div', { className: 'dk_empty', children: [jsx('span', { className: 'dk_spin' }), jsx('div', { children: '
|
|
4067
|
+
if (error !== '') return jsx(Banner, { title: t('error.volumeDetailFailed'), hint: error, action: jsx('button', { type: 'button', className: 'dk_btn', onClick: load, children: t('btn.retry') }) })
|
|
4068
|
+
if (data === null) return jsx('div', { className: 'dk_empty', children: [jsx('span', { className: 'dk_spin' }), jsx('div', { children: t('list.loading') })] })
|
|
3049
4069
|
const d = data.detail
|
|
3050
4070
|
const rows = [
|
|
3051
|
-
['
|
|
3052
|
-
['
|
|
3053
|
-
['
|
|
3054
|
-
['
|
|
3055
|
-
['
|
|
3056
|
-
['
|
|
3057
|
-
['
|
|
4071
|
+
[t('field.name'), d.name],
|
|
4072
|
+
[t('field.driver'), d.driver === '' ? '—' : d.driver],
|
|
4073
|
+
[t('field.scope'), d.scope === '' ? '—' : d.scope],
|
|
4074
|
+
[t('field.mountpoint'), d.mountpoint === '' ? '—' : d.mountpoint],
|
|
4075
|
+
[t('field.created'), d.created === '' ? '—' : fmtCreated(d.created)],
|
|
4076
|
+
[t('field.options'), Object.keys(d.options).length === 0 ? '—' : Object.entries(d.options).map(([key, value]) => key + '=' + value).join('\n')],
|
|
4077
|
+
[t('field.labels'), Object.keys(d.labels).length === 0 ? '—' : Object.entries(d.labels).map(([key, value]) => key + '=' + value).join('\n')],
|
|
3058
4078
|
]
|
|
3059
|
-
return jsx(KvList, { rows, mono: ['
|
|
4079
|
+
return jsx(KvList, { rows, mono: [t('field.mountpoint'), t('field.options'), t('field.labels')] })
|
|
3060
4080
|
}
|
|
3061
4081
|
|
|
3062
4082
|
return jsxs('div', { className: 'dk_detail', children: [
|
|
3063
4083
|
jsxs('div', { className: 'dk_header dk_headerDetail', children: [
|
|
3064
|
-
jsx(IconAction, { icon: ICON_BACK, title: '
|
|
4084
|
+
jsx(IconAction, { icon: ICON_BACK, title: t('btn.backToVolumes'), onClick: props.onBack }, 'back'),
|
|
3065
4085
|
jsx('span', { className: 'dk_projectIcon', dangerouslySetInnerHTML: { __html: ICON_VOLUME } }),
|
|
3066
4086
|
jsx('span', { className: 'dk_detailTitle', title: name, children: name }),
|
|
3067
4087
|
jsx('span', { className: 'dk_detailSub', children: props.targetLabel ?? '' }),
|
|
3068
4088
|
jsx('span', { className: 'dk_headerSpacer' }),
|
|
3069
|
-
jsx(IconAction, { icon: ICON_REFRESH, title: '
|
|
4089
|
+
jsx(IconAction, { icon: ICON_REFRESH, title: t('btn.refreshVolumeDetail'), spin: loading, onClick: load }, 'refresh'),
|
|
3070
4090
|
jsx(IconAction, {
|
|
3071
4091
|
icon: ICON_TRASH,
|
|
3072
4092
|
danger: true,
|
|
3073
4093
|
disabled: props.allowMutations !== true,
|
|
3074
|
-
title: props.allowMutations === true ? '
|
|
4094
|
+
title: props.allowMutations === true ? t('btn.removeVolume') : t('btn.removeVolumeDisabled'),
|
|
3075
4095
|
onClick: () => setConfirming(true),
|
|
3076
4096
|
}, 'remove'),
|
|
3077
|
-
props.docked === true ? null : jsx('button', { type: 'button', className: 'dk_iconBtn', title: '
|
|
4097
|
+
props.docked === true ? null : jsx('button', { type: 'button', className: 'dk_iconBtn', title: t('btn.closePanel'), onClick: props.onClose, children: jsx('span', { className: 'dk_iconGlyph', dangerouslySetInnerHTML: { __html: ICON_CLOSE } }) }, 'close'),
|
|
3078
4098
|
] }),
|
|
3079
4099
|
jsxs('div', { className: 'dk_detailBody', children: [
|
|
3080
|
-
removeError === '' ? null : jsx(Banner, { title: '
|
|
4100
|
+
removeError === '' ? null : jsx(Banner, { title: t('error.removeVolumeFailed'), hint: removeError }),
|
|
3081
4101
|
body(),
|
|
3082
4102
|
] }),
|
|
3083
4103
|
confirming ? jsx(ConfirmDialog, {
|
|
3084
|
-
title: '
|
|
3085
|
-
text: '
|
|
3086
|
-
confirmLabel: '
|
|
4104
|
+
title: t('btn.removeVolumeShort'),
|
|
4105
|
+
text: t('confirm.removeVolume', { name }),
|
|
4106
|
+
confirmLabel: t('btn.delete'),
|
|
3087
4107
|
busy: removing,
|
|
3088
4108
|
onCancel: () => setConfirming(false),
|
|
3089
4109
|
onConfirm: runRemove,
|
|
@@ -3182,7 +4202,7 @@ window.__ModuleLoader__.load({
|
|
|
3182
4202
|
useEffect(() => {
|
|
3183
4203
|
if (!running) return undefined
|
|
3184
4204
|
if (typeof EventSource !== 'function') {
|
|
3185
|
-
setError('
|
|
4205
|
+
setError(t('error.noEventSourcePull'))
|
|
3186
4206
|
setRunning(false)
|
|
3187
4207
|
return undefined
|
|
3188
4208
|
}
|
|
@@ -3214,12 +4234,12 @@ window.__ModuleLoader__.load({
|
|
|
3214
4234
|
flushNow()
|
|
3215
4235
|
setExitCode(code)
|
|
3216
4236
|
setRunning(false)
|
|
3217
|
-
setStatus(code === 0 ? '
|
|
4237
|
+
setStatus(code === 0 ? t('status.pullDone') : t('status.pullEnded', { code: code === null ? '?' : code }))
|
|
3218
4238
|
if (code === 0) props.onDone?.()
|
|
3219
4239
|
}
|
|
3220
4240
|
const onError = (event) => {
|
|
3221
4241
|
if (typeof event.data === 'string' && event.data !== '') {
|
|
3222
|
-
let message = '
|
|
4242
|
+
let message = t('error.pullFailed')
|
|
3223
4243
|
try {
|
|
3224
4244
|
const payload = JSON.parse(event.data)
|
|
3225
4245
|
if (payload !== null && typeof payload.message === 'string') message = payload.message
|
|
@@ -3265,48 +4285,48 @@ window.__ModuleLoader__.load({
|
|
|
3265
4285
|
}
|
|
3266
4286
|
const stop = () => {
|
|
3267
4287
|
setRunning(false)
|
|
3268
|
-
setStatus('
|
|
4288
|
+
setStatus(t('status.stopped'))
|
|
3269
4289
|
}
|
|
3270
4290
|
|
|
3271
4291
|
const statusText = () => {
|
|
3272
|
-
if (status === 'open') return '
|
|
3273
|
-
if (status === 'connecting') return '
|
|
3274
|
-
if (status === 'reconnecting') return '
|
|
3275
|
-
if (status === 'closed') return '
|
|
4292
|
+
if (status === 'open') return t('status.pulling')
|
|
4293
|
+
if (status === 'connecting') return t('status.pullConnecting')
|
|
4294
|
+
if (status === 'reconnecting') return t('status.reconnecting')
|
|
4295
|
+
if (status === 'closed') return t('status.pullStreamClosed')
|
|
3276
4296
|
return status
|
|
3277
4297
|
}
|
|
3278
4298
|
|
|
3279
4299
|
return jsxs('div', { className: 'dk_detail', children: [
|
|
3280
4300
|
jsxs('div', { className: 'dk_header dk_headerDetail', children: [
|
|
3281
|
-
jsx(IconAction, { icon: ICON_BACK, title: '
|
|
3282
|
-
jsx('span', { className: 'dk_detailTitle', children: '
|
|
4301
|
+
jsx(IconAction, { icon: ICON_BACK, title: t('btn.backToImages'), onClick: props.onBack }, 'back'),
|
|
4302
|
+
jsx('span', { className: 'dk_detailTitle', children: t('panel.pullImage') }),
|
|
3283
4303
|
jsx('span', { className: 'dk_detailSub', children: props.targetLabel ?? '' }),
|
|
3284
4304
|
jsx('span', { className: 'dk_headerSpacer' }),
|
|
3285
|
-
props.docked === true ? null : jsx('button', { type: 'button', className: 'dk_iconBtn', title: '
|
|
4305
|
+
props.docked === true ? null : jsx('button', { type: 'button', className: 'dk_iconBtn', title: t('btn.closePanel'), onClick: props.onClose, children: jsx('span', { className: 'dk_iconGlyph', dangerouslySetInnerHTML: { __html: ICON_CLOSE } }) }, 'close'),
|
|
3286
4306
|
] }),
|
|
3287
4307
|
jsxs('div', { className: 'dk_detailBody dk_pullBody', children: [
|
|
3288
4308
|
props.allowMutations !== true
|
|
3289
|
-
? jsx(Banner, { kind: 'info', title: '
|
|
4309
|
+
? jsx(Banner, { kind: 'info', title: t('banner.pullNeedsMutations'), hint: t('hint.pullNeedsMutations') })
|
|
3290
4310
|
: jsxs('div', { className: 'dk_row', children: [
|
|
3291
4311
|
jsx('input', {
|
|
3292
4312
|
className: 'dk_input',
|
|
3293
4313
|
style: { flex: '1 1 auto' },
|
|
3294
|
-
placeholder: '
|
|
4314
|
+
placeholder: t('placeholder.imageRef'),
|
|
3295
4315
|
value: ref,
|
|
3296
4316
|
disabled: running,
|
|
3297
4317
|
onChange: (event) => setRef(event.target.value),
|
|
3298
4318
|
onKeyDown: (event) => { if (event.key === 'Enter') start() },
|
|
3299
4319
|
}),
|
|
3300
4320
|
running
|
|
3301
|
-
? jsx('button', { type: 'button', className: 'dk_btn dk_btnDanger', onClick: stop, children: '
|
|
3302
|
-
: jsx('button', { type: 'button', className: 'dk_btn dk_btnPrimary', disabled: props.allowMutations !== true, onClick: start, children: '
|
|
4321
|
+
? jsx('button', { type: 'button', className: 'dk_btn dk_btnDanger', onClick: stop, children: t('btn.stop') })
|
|
4322
|
+
: jsx('button', { type: 'button', className: 'dk_btn dk_btnPrimary', disabled: props.allowMutations !== true, onClick: start, children: t('btn.pull') }),
|
|
3303
4323
|
] }),
|
|
3304
|
-
error === '' ? null : jsx(Banner, { title: '
|
|
3305
|
-
dropped ? jsx(Banner, { kind: 'warn', title: '
|
|
3306
|
-
status === '' ? null : jsx('div', { className: 'dk_hint', children: statusText() + (exitCode === null ? '' : '
|
|
4324
|
+
error === '' ? null : jsx(Banner, { title: t('error.pullFailed'), hint: error }),
|
|
4325
|
+
dropped ? jsx(Banner, { kind: 'warn', title: t('hint.pullProgressDropped', { lines: PULL_LINE_LIMIT }) }) : null,
|
|
4326
|
+
status === '' ? null : jsx('div', { className: 'dk_hint', children: statusText() + (exitCode === null ? '' : t('meta.dotExitCode', { code: exitCode })) }),
|
|
3307
4327
|
jsxs('div', { className: 'dk_pullBox', ref: bodyRef, children: [
|
|
3308
4328
|
lines.length === 0
|
|
3309
|
-
? jsx('div', { className: 'dk_pullLine', children: running ? '
|
|
4329
|
+
? jsx('div', { className: 'dk_pullLine', children: running ? t('list.waitingPull') : t('hint.pullPlaceholder') })
|
|
3310
4330
|
: lines.map((line, index) => jsx('div', { className: 'dk_pullLine', 'data-key': line.key ?? undefined, children: line.text }, String(index))),
|
|
3311
4331
|
] }),
|
|
3312
4332
|
] }),
|
|
@@ -3339,7 +4359,7 @@ window.__ModuleLoader__.load({
|
|
|
3339
4359
|
const running = group.items.filter((item) => isRunningState(item.state)).length
|
|
3340
4360
|
const unhealthy = group.items.filter((item) => item.health === 'unhealthy').length
|
|
3341
4361
|
const services = [...new Set(group.items.map((item) => item.composeService === null ? item.name : item.composeService))]
|
|
3342
|
-
const title = group.project === '' ? '
|
|
4362
|
+
const title = group.project === '' ? t('badge.notCompose') : group.project
|
|
3343
4363
|
return jsxs('div', {
|
|
3344
4364
|
className: 'dk_project',
|
|
3345
4365
|
role: 'button',
|
|
@@ -3355,10 +4375,10 @@ window.__ModuleLoader__.load({
|
|
|
3355
4375
|
jsxs('div', { className: 'dk_projectHead', children: [
|
|
3356
4376
|
jsx('span', { className: 'dk_projectIcon', dangerouslySetInnerHTML: { __html: ICON_PROJECT } }),
|
|
3357
4377
|
jsx('span', { className: 'dk_projectName', title, children: title }),
|
|
3358
|
-
jsx('span', { className: 'dk_badge', 'data-state': running === group.items.length ? 'running' : (running === 0 ? 'exited' : 'paused'), children:
|
|
3359
|
-
unhealthy > 0 ? jsx('span', { className: 'dk_badge', 'data-state': 'unhealthy', children:
|
|
4378
|
+
jsx('span', { className: 'dk_badge', 'data-state': running === group.items.length ? 'running' : (running === 0 ? 'exited' : 'paused'), children: t('badge.runningRatio', { running, total: group.items.length }) }),
|
|
4379
|
+
unhealthy > 0 ? jsx('span', { className: 'dk_badge', 'data-state': 'unhealthy', children: t('badge.unhealthyCount', { count: unhealthy }) }) : null,
|
|
3360
4380
|
jsx('span', { className: 'dk_headerSpacer' }),
|
|
3361
|
-
jsx('span', { className: 'dk_hint', children:
|
|
4381
|
+
jsx('span', { className: 'dk_hint', children: t('badge.serviceCount', { count: services.length }) }),
|
|
3362
4382
|
] }),
|
|
3363
4383
|
jsx('div', { className: 'dk_projectRows', children: group.items.map((item) => jsxs('div', { className: 'dk_projectRow', children: [
|
|
3364
4384
|
jsx('span', { className: 'dk_projectSvc', children: item.composeService === null ? '—' : item.composeService }),
|
|
@@ -3375,12 +4395,12 @@ window.__ModuleLoader__.load({
|
|
|
3375
4395
|
function ComposeProjectView(props) {
|
|
3376
4396
|
const [tab, setTab] = useState('services')
|
|
3377
4397
|
const items = props.items
|
|
3378
|
-
const title = props.project === '' ? '
|
|
4398
|
+
const title = props.project === '' ? t('badge.notCompose') : props.project
|
|
3379
4399
|
const running = items.filter((item) => isRunningState(item.state)).length
|
|
3380
4400
|
|
|
3381
4401
|
const servicesTab = () => jsx('div', { className: 'dk_tableWrap', children: jsxs('table', { className: 'dk_images dk_composeTable', children: [
|
|
3382
4402
|
jsx('thead', { children: jsxs('tr', { children: [
|
|
3383
|
-
jsx('th', { children: '
|
|
4403
|
+
jsx('th', { children: t('field.service') }), jsx('th', { children: t('panel.containers') }), jsx('th', { children: t('field.state') }), jsx('th', { children: t('field.ports') }), jsx('th', { children: t('field.image') }),
|
|
3384
4404
|
] }) }),
|
|
3385
4405
|
jsx('tbody', { children: items.map((item) => jsxs('tr', { children: [
|
|
3386
4406
|
jsx('td', { children: item.composeService === null ? '—' : item.composeService }),
|
|
@@ -3391,16 +4411,16 @@ window.__ModuleLoader__.load({
|
|
|
3391
4411
|
] }, item.id)) }),
|
|
3392
4412
|
] }) })
|
|
3393
4413
|
|
|
3394
|
-
const tabs = [['services', '
|
|
4414
|
+
const tabs = [['services', t('field.service')], ['logs', t('panel.aggregatedLogs')]]
|
|
3395
4415
|
return jsxs('div', { className: 'dk_detail', children: [
|
|
3396
4416
|
jsxs('div', { className: 'dk_header dk_headerDetail', children: [
|
|
3397
|
-
jsx(IconAction, { icon: ICON_BACK, title: '
|
|
4417
|
+
jsx(IconAction, { icon: ICON_BACK, title: t('btn.backToCompose'), onClick: props.onBack }, 'back'),
|
|
3398
4418
|
jsx('span', { className: 'dk_projectIcon', dangerouslySetInnerHTML: { __html: ICON_PROJECT } }),
|
|
3399
4419
|
jsx('span', { className: 'dk_detailTitle', title, children: title }),
|
|
3400
|
-
jsx('span', { className: 'dk_badge', 'data-state': running === items.length ? 'running' : (running === 0 ? 'exited' : 'paused'), children:
|
|
4420
|
+
jsx('span', { className: 'dk_badge', 'data-state': running === items.length ? 'running' : (running === 0 ? 'exited' : 'paused'), children: t('badge.runningRatio', { running, total: items.length }) }),
|
|
3401
4421
|
jsx('span', { className: 'dk_detailSub', children: props.targetLabel ?? '' }),
|
|
3402
4422
|
jsx('span', { className: 'dk_headerSpacer' }),
|
|
3403
|
-
props.docked === true ? null : jsx('button', { type: 'button', className: 'dk_iconBtn', title: '
|
|
4423
|
+
props.docked === true ? null : jsx('button', { type: 'button', className: 'dk_iconBtn', title: t('btn.closePanel'), onClick: props.onClose, children: jsx('span', { className: 'dk_iconGlyph', dangerouslySetInnerHTML: { __html: ICON_CLOSE } }) }, 'close'),
|
|
3404
4424
|
] }),
|
|
3405
4425
|
jsx('div', { className: 'dk_tabs', children: tabs.map(([key, label]) => jsx('button', {
|
|
3406
4426
|
type: 'button', className: 'dk_tab', 'data-on': tab === key ? '1' : '0', onClick: () => setTab(key), children: label,
|
|
@@ -3445,8 +4465,8 @@ window.__ModuleLoader__.load({
|
|
|
3445
4465
|
*
|
|
3446
4466
|
* 一份定义的另一个好处:两个视图不可能再各写一套选项而走岔(这正是上一轮刚对齐过的东西)。
|
|
3447
4467
|
*/
|
|
3448
|
-
const
|
|
3449
|
-
{ value: 0, label: '
|
|
4468
|
+
const logLevelOptions = () => [
|
|
4469
|
+
{ value: 0, label: t('option.allLevels') },
|
|
3450
4470
|
{ value: 2, label: 'INFO+' },
|
|
3451
4471
|
{ value: 3, label: 'WARN+' },
|
|
3452
4472
|
{ value: 4, label: 'ERROR+' },
|
|
@@ -3558,7 +4578,7 @@ window.__ModuleLoader__.load({
|
|
|
3558
4578
|
if (options?.format !== 'md') return body
|
|
3559
4579
|
const items = Array.isArray(options.items) ? options.items : []
|
|
3560
4580
|
// scope:单容器日志传「容器日志」,不传即聚合(两边同一套表头,只有标题与容器数不同)
|
|
3561
|
-
const scope = typeof options.scope === 'string' && options.scope !== '' ? options.scope : '
|
|
4581
|
+
const scope = typeof options.scope === 'string' && options.scope !== '' ? options.scope : t('panel.aggregatedLogs')
|
|
3562
4582
|
// 围栏取「正文里最长的反引号串 + 1」(至少 3):日志里出现 ``` 时不会被提前
|
|
3563
4583
|
// 闭合,后续日志被当 markdown 正文渲染(D58)
|
|
3564
4584
|
let backtickRun = 0
|
|
@@ -3567,10 +4587,10 @@ window.__ModuleLoader__.load({
|
|
|
3567
4587
|
const lines = [
|
|
3568
4588
|
'# ' + scope,
|
|
3569
4589
|
'',
|
|
3570
|
-
'
|
|
3571
|
-
'
|
|
3572
|
-
'
|
|
3573
|
-
'
|
|
4590
|
+
t('meta.source') + (typeof options.targetLabel === 'string' && options.targetLabel !== '' ? options.targetLabel + ' · ' : '') + (options.target ?? ''),
|
|
4591
|
+
t('meta.containersLine', { count: items.length, names: items.map((item) => item.name).join(t('msg.listSep')) }),
|
|
4592
|
+
t('meta.lines', { count: rows.length }),
|
|
4593
|
+
t('meta.exportedAt', { time: new Date().toLocaleString() }),
|
|
3574
4594
|
'',
|
|
3575
4595
|
fence + 'text',
|
|
3576
4596
|
body,
|
|
@@ -3769,6 +4789,7 @@ window.__ModuleLoader__.load({
|
|
|
3769
4789
|
}
|
|
3770
4790
|
// timestamps=1:聚合视图为「按时间合并」与可选显示时间戳固定带上的参数
|
|
3771
4791
|
const handle = subscribeLogStream({
|
|
4792
|
+
t,
|
|
3772
4793
|
buildUrl: (tailNow) => streamUrl('/logs/stream', { target: props.target, id: item.id, tail: String(tailNow), timestamps: '1' }),
|
|
3773
4794
|
tail,
|
|
3774
4795
|
onStatus: (next) => {
|
|
@@ -3885,23 +4906,23 @@ window.__ModuleLoader__.load({
|
|
|
3885
4906
|
}
|
|
3886
4907
|
|
|
3887
4908
|
const statusText = () => {
|
|
3888
|
-
if (status === 'open') return '
|
|
3889
|
-
if (status === 'connecting') return '
|
|
3890
|
-
if (status === 'reconnecting') return '
|
|
3891
|
-
if (status === 'partial') return '
|
|
3892
|
-
if (status === 'closed') return '
|
|
3893
|
-
if (status === 'unsupported') return '
|
|
3894
|
-
if (status === 'empty') return '
|
|
3895
|
-
return '
|
|
4909
|
+
if (status === 'open') return t('status.aggConnected', { count: items.length })
|
|
4910
|
+
if (status === 'connecting') return t('status.aggConnecting')
|
|
4911
|
+
if (status === 'reconnecting') return t('status.aggReconnecting')
|
|
4912
|
+
if (status === 'partial') return t('status.aggPartialError')
|
|
4913
|
+
if (status === 'closed') return t('status.aggClosed')
|
|
4914
|
+
if (status === 'unsupported') return t('status.noEventSource')
|
|
4915
|
+
if (status === 'empty') return t('status.aggEmpty')
|
|
4916
|
+
return t('panel.aggregatedLogs')
|
|
3896
4917
|
}
|
|
3897
4918
|
|
|
3898
4919
|
return jsxs('div', { className: 'dk_logs', children: [
|
|
3899
|
-
dropped ? jsx(Banner, { kind: 'warn', title: '
|
|
4920
|
+
dropped ? jsx(Banner, { kind: 'warn', title: t('hint.aggBufferExceeded', { lines: FOLLOW_LINE_LIMIT, mb: Math.round(FOLLOW_BYTE_LIMIT / 1024 / 1024) }) }) : null,
|
|
3900
4921
|
jsxs('div', { className: 'dk_filterBar', children: [
|
|
3901
4922
|
jsxs('div', { className: 'dk_filterWrap', children: [
|
|
3902
4923
|
jsx('input', {
|
|
3903
4924
|
className: 'dk_input dk_filterInput',
|
|
3904
|
-
placeholder: '
|
|
4925
|
+
placeholder: t('placeholder.filterServiceLogs'),
|
|
3905
4926
|
value: filter,
|
|
3906
4927
|
onChange: (event) => setFilter(event.target.value),
|
|
3907
4928
|
onKeyDown: (event) => {
|
|
@@ -3914,7 +4935,7 @@ window.__ModuleLoader__.load({
|
|
|
3914
4935
|
filter === '' ? null : jsx('button', {
|
|
3915
4936
|
type: 'button',
|
|
3916
4937
|
className: 'dk_filterClear',
|
|
3917
|
-
title: '
|
|
4938
|
+
title: t('btn.clearFilter'),
|
|
3918
4939
|
onClick: () => setFilter(''),
|
|
3919
4940
|
children: jsx('span', { className: 'dk_iconGlyph', dangerouslySetInnerHTML: { __html: ICON_CLOSE } }),
|
|
3920
4941
|
}, 'clear'),
|
|
@@ -3924,7 +4945,7 @@ window.__ModuleLoader__.load({
|
|
|
3924
4945
|
className: 'dk_select dk_selectSm',
|
|
3925
4946
|
value: String(tail),
|
|
3926
4947
|
// 说清"每容器"是这条控件最容易误解的地方:8 个容器 × 500 行 = 4000 行
|
|
3927
|
-
title: '
|
|
4948
|
+
title: t('hint.aggTail', { containers: items.length, rows: items.length * tail }),
|
|
3928
4949
|
onChange: (event) => setTail(Number(event.target.value)),
|
|
3929
4950
|
children: AGG_TAIL_OPTIONS.map((value) => jsx('option', { value: String(value), children: 'Last ' + String(value) }, String(value))),
|
|
3930
4951
|
}, 'aggTail'),
|
|
@@ -3934,48 +4955,48 @@ window.__ModuleLoader__.load({
|
|
|
3934
4955
|
'data-on': paused ? '0' : '1',
|
|
3935
4956
|
'data-paused': paused ? '1' : undefined,
|
|
3936
4957
|
title: paused
|
|
3937
|
-
? '
|
|
3938
|
-
: '
|
|
4958
|
+
? t('btn.resumeLive', { count: bufferedCount })
|
|
4959
|
+
: t('hint.pause'),
|
|
3939
4960
|
onClick: togglePause,
|
|
3940
4961
|
children: paused
|
|
3941
|
-
? (bufferedCount > 0 ? '
|
|
3942
|
-
: '
|
|
4962
|
+
? (bufferedCount > 0 ? t('status.paused') + ' +' + String(bufferedCount) : t('status.paused'))
|
|
4963
|
+
: t('status.live'),
|
|
3943
4964
|
}),
|
|
3944
4965
|
jsx('button', {
|
|
3945
4966
|
type: 'button',
|
|
3946
4967
|
className: 'dk_pill',
|
|
3947
4968
|
'data-on': showTs ? '1' : '0',
|
|
3948
|
-
title: showTs ? '
|
|
4969
|
+
title: showTs ? t('btn.hideTimestamps') : t('btn.showTimestamps'),
|
|
3949
4970
|
onClick: () => setShowTs((value) => !value),
|
|
3950
|
-
children: '
|
|
4971
|
+
children: t('field.timestamps'),
|
|
3951
4972
|
}),
|
|
3952
4973
|
jsx('button', {
|
|
3953
4974
|
type: 'button',
|
|
3954
4975
|
className: 'dk_pill',
|
|
3955
4976
|
'data-on': orderMode === 'time' ? '1' : '0',
|
|
3956
4977
|
title: orderMode === 'time'
|
|
3957
|
-
? '
|
|
3958
|
-
: '
|
|
4978
|
+
? t('option.orderArrivalHint')
|
|
4979
|
+
: t('hint.orderTimeHint', { ms: LOG_MERGE_WINDOW_MS }),
|
|
3959
4980
|
onClick: () => toggleOrderMode(),
|
|
3960
|
-
children: orderMode === 'time' ? '
|
|
4981
|
+
children: orderMode === 'time' ? t('option.orderTime') : t('option.orderArrival'),
|
|
3961
4982
|
}),
|
|
3962
4983
|
jsx('select', {
|
|
3963
4984
|
className: 'dk_select dk_selectSm',
|
|
3964
4985
|
value: String(levelMin),
|
|
3965
|
-
title: '
|
|
4986
|
+
title: t('hint.levelFilter'),
|
|
3966
4987
|
onChange: (event) => setLevelMin(Number(event.target.value)),
|
|
3967
|
-
children:
|
|
4988
|
+
children: logLevelOptions().map((option) => jsx('option', { value: String(option.value), children: option.label }, String(option.value))),
|
|
3968
4989
|
}, 'level'),
|
|
3969
4990
|
jsx('button', {
|
|
3970
4991
|
type: 'button',
|
|
3971
4992
|
className: 'dk_chip',
|
|
3972
4993
|
disabled: matched.length === 0,
|
|
3973
4994
|
'aria-haspopup': 'menu',
|
|
3974
|
-
title: '
|
|
3975
|
-
onClick: (event) => openExportMenu(event, { sub:
|
|
3976
|
-
children:
|
|
4995
|
+
title: t('hint.exportMenu'),
|
|
4996
|
+
onClick: (event) => openExportMenu(event, { sub: t('meta.containerCount', { count: items.length }) + ' · ' + String(matched.length) + t('meta.rowsSuffix'), onPick: doExport }),
|
|
4997
|
+
children: logExportLabel(),
|
|
3977
4998
|
}, 'export'),
|
|
3978
|
-
jsx('span', { className: 'dk_filterCount', children: needle === '' && levelMin === 0 ? String(entries.length) + '
|
|
4999
|
+
jsx('span', { className: 'dk_filterCount', children: needle === '' && levelMin === 0 ? String(entries.length) + t('meta.rowsSuffix') : String(matched.length) + ' / ' + String(entries.length) + t('meta.rowsSuffix') }),
|
|
3979
5000
|
] }),
|
|
3980
5001
|
jsx('div', { className: 'dk_followState', 'data-state': status === 'open' ? 'open' : (status === 'closed' ? 'closed' : 'connecting'), children: statusText() }),
|
|
3981
5002
|
jsx('div', {
|
|
@@ -3983,7 +5004,7 @@ window.__ModuleLoader__.load({
|
|
|
3983
5004
|
ref: bodyRef,
|
|
3984
5005
|
// 可聚焦(D64):键盘用户 Tab 进来才能滚动聚合日志、用键盘触发「问 Agent」
|
|
3985
5006
|
tabIndex: 0,
|
|
3986
|
-
'aria-label': '
|
|
5007
|
+
'aria-label': t('panel.aggContainerLogs'),
|
|
3987
5008
|
onScroll: onBodyScroll,
|
|
3988
5009
|
// 右键「问 Agent」:聚合视图把本次聚合的容器集合一起交出去,
|
|
3989
5010
|
// 具体是哪个容器由每行的 [service] 前缀决定
|
|
@@ -3995,11 +5016,11 @@ window.__ModuleLoader__.load({
|
|
|
3995
5016
|
}),
|
|
3996
5017
|
children: [
|
|
3997
5018
|
matched.length === 0
|
|
3998
|
-
? jsx('div', { className: 'dk_logLine', children: status === 'open' ? '
|
|
5019
|
+
? jsx('div', { className: 'dk_logLine', children: status === 'open' ? t('list.waitingLogs') : statusText() }, 'empty')
|
|
3999
5020
|
: matched.map((entry) => renderAggLine(entry, needle, showTs)),
|
|
4000
5021
|
],
|
|
4001
5022
|
}),
|
|
4002
|
-
!paused && !atBottom ? jsx('button', { type: 'button', className: 'dk_backToBottom', onClick: backToBottom, children: '
|
|
5023
|
+
!paused && !atBottom ? jsx('button', { type: 'button', className: 'dk_backToBottom', onClick: backToBottom, children: t('btn.backToBottom') }) : null,
|
|
4003
5024
|
] })
|
|
4004
5025
|
}
|
|
4005
5026
|
|
|
@@ -4040,19 +5061,19 @@ window.__ModuleLoader__.load({
|
|
|
4040
5061
|
type: 'button',
|
|
4041
5062
|
className: 'dk_activityHead',
|
|
4042
5063
|
'aria-expanded': open,
|
|
4043
|
-
title: '
|
|
5064
|
+
title: t('hint.eventsToggle'),
|
|
4044
5065
|
onClick: props.onToggle,
|
|
4045
5066
|
children: [
|
|
4046
|
-
jsx('span', { className: 'dk_activityTitle', children: '
|
|
5067
|
+
jsx('span', { className: 'dk_activityTitle', children: t('panel.activity') }),
|
|
4047
5068
|
jsx('span', { className: 'dk_activityState', 'data-state': props.status ?? '', children: props.statusText ?? '' }),
|
|
4048
5069
|
jsx('span', { className: 'dk_headerSpacer' }),
|
|
4049
|
-
jsx('span', { className: 'dk_hint', children: events.length === 0 ? '
|
|
5070
|
+
jsx('span', { className: 'dk_hint', children: events.length === 0 ? t('list.noEvents') : t('list.recentEvents', { recent: recent.length, total: events.length }) }),
|
|
4050
5071
|
jsx('span', { className: 'dk_activityChevron', dangerouslySetInnerHTML: { __html: ICON_CHEVRON } }),
|
|
4051
5072
|
],
|
|
4052
5073
|
}),
|
|
4053
5074
|
open === false ? null : (
|
|
4054
5075
|
recent.length === 0
|
|
4055
|
-
? jsx('div', { className: 'dk_activityEmpty', children: '
|
|
5076
|
+
? jsx('div', { className: 'dk_activityEmpty', children: t('list.noEventsHint') })
|
|
4056
5077
|
: jsx('div', { className: 'dk_activityList', children: recent.map(activityItem) })
|
|
4057
5078
|
),
|
|
4058
5079
|
] })
|
|
@@ -4076,7 +5097,7 @@ window.__ModuleLoader__.load({
|
|
|
4076
5097
|
const showPresets = presets.some((preset) => preset.count > 0) || props.count > 0
|
|
4077
5098
|
return jsxs('div', { className: 'dk_pickBar', children: [
|
|
4078
5099
|
jsxs('div', { className: 'dk_pickRow', children: [
|
|
4079
|
-
jsx('span', { className: 'dk_pickCount', children: '
|
|
5100
|
+
jsx('span', { className: 'dk_pickCount', children: t('status.picked', { count: props.count }) }),
|
|
4080
5101
|
info.hint === '' ? null : jsx('span', { className: 'dk_hint dk_pickHint', children: info.hint }),
|
|
4081
5102
|
jsx('span', { className: 'dk_headerSpacer' }),
|
|
4082
5103
|
jsx('button', {
|
|
@@ -4085,28 +5106,28 @@ window.__ModuleLoader__.load({
|
|
|
4085
5106
|
disabled: info.canRun !== true,
|
|
4086
5107
|
// 0 个勾选时 pickDecide 不给提示(刚进选择态别一上来就飘一行灰字),
|
|
4087
5108
|
// 但按钮自己的 title 仍要说清为什么点不动
|
|
4088
|
-
title: info.hint !== '' ? info.hint : (info.canRun === true ? '
|
|
5109
|
+
title: info.hint !== '' ? info.hint : (info.canRun === true ? t('hint.aggRun') : t('hint.pickAtLeastTwo')),
|
|
4089
5110
|
onClick: props.onRun,
|
|
4090
|
-
children: '
|
|
5111
|
+
children: t('panel.aggregatedLogs'),
|
|
4091
5112
|
}),
|
|
4092
|
-
jsx('button', { type: 'button', className: 'dk_btn', onClick: props.onCancel, children: '
|
|
5113
|
+
jsx('button', { type: 'button', className: 'dk_btn', onClick: props.onCancel, children: t('btn.cancel') }),
|
|
4093
5114
|
] }),
|
|
4094
5115
|
showPresets ? jsxs('div', { className: 'dk_pickPresets', children: [
|
|
4095
|
-
jsx('span', { className: 'dk_pickPresetsLabel', children: '
|
|
5116
|
+
jsx('span', { className: 'dk_pickPresetsLabel', children: t('panel.pickPresets') }),
|
|
4096
5117
|
...presets.filter((preset) => preset.count > 0).map((preset) => jsx('button', {
|
|
4097
5118
|
type: 'button',
|
|
4098
5119
|
className: 'dk_chip',
|
|
4099
|
-
title: '
|
|
4100
|
-
+ (preset.over > 0 ? '
|
|
5120
|
+
title: t('hint.pickPreset', { label: preset.label, max: props.max ?? PICK_MAX })
|
|
5121
|
+
+ (preset.over > 0 ? t('hint.pickPresetOver', { count: preset.over }) : ''),
|
|
4101
5122
|
onClick: () => props.onPreset(preset.key),
|
|
4102
5123
|
children: preset.label + ' ' + String(preset.count),
|
|
4103
5124
|
}, preset.key)),
|
|
4104
5125
|
props.count > 0 ? jsx('button', {
|
|
4105
5126
|
type: 'button',
|
|
4106
5127
|
className: 'dk_chip dk_chipQuiet',
|
|
4107
|
-
title: '
|
|
5128
|
+
title: t('btn.clearPicked'),
|
|
4108
5129
|
onClick: props.onClear,
|
|
4109
|
-
children: '
|
|
5130
|
+
children: t('btn.clear'),
|
|
4110
5131
|
}, 'clear') : null,
|
|
4111
5132
|
props.notice === '' ? null : jsx('span', { className: 'dk_hint dk_pickNotice', children: props.notice }),
|
|
4112
5133
|
] }) : null,
|
|
@@ -4121,12 +5142,12 @@ window.__ModuleLoader__.load({
|
|
|
4121
5142
|
function AggregateLogsView(props) {
|
|
4122
5143
|
return jsxs('div', { className: 'dk_detail', children: [
|
|
4123
5144
|
jsxs('div', { className: 'dk_header dk_headerDetail', children: [
|
|
4124
|
-
jsx(IconAction, { icon: ICON_BACK, title: '
|
|
5145
|
+
jsx(IconAction, { icon: ICON_BACK, title: t('btn.backToContainersExitPick'), onClick: props.onBack }, 'back'),
|
|
4125
5146
|
jsx('span', { className: 'dk_projectIcon', dangerouslySetInnerHTML: { __html: ICON_LOGS } }),
|
|
4126
|
-
jsx('span', { className: 'dk_detailTitle', children: '
|
|
5147
|
+
jsx('span', { className: 'dk_detailTitle', children: t('panel.aggLogsTitle', { count: props.items.length }) }),
|
|
4127
5148
|
jsx('span', { className: 'dk_detailSub', children: props.targetLabel ?? '' }),
|
|
4128
5149
|
jsx('span', { className: 'dk_headerSpacer' }),
|
|
4129
|
-
props.docked === true ? null : jsx('button', { type: 'button', className: 'dk_iconBtn', title: '
|
|
5150
|
+
props.docked === true ? null : jsx('button', { type: 'button', className: 'dk_iconBtn', title: t('btn.closePanel'), onClick: props.onClose, children: jsx('span', { className: 'dk_iconGlyph', dangerouslySetInnerHTML: { __html: ICON_CLOSE } }) }, 'close'),
|
|
4130
5151
|
] }),
|
|
4131
5152
|
jsx('div', { className: 'dk_detailBody', children: jsx(ComposeLogs, { target: props.target, targetLabel: props.targetLabel, items: props.items }) }),
|
|
4132
5153
|
] })
|
|
@@ -4154,13 +5175,13 @@ window.__ModuleLoader__.load({
|
|
|
4154
5175
|
children: [
|
|
4155
5176
|
jsx('div', {
|
|
4156
5177
|
className: 'dk_drawerResize',
|
|
4157
|
-
title: '
|
|
5178
|
+
title: t('hint.termResize'),
|
|
4158
5179
|
onMouseDown: props.onResizeStart,
|
|
4159
5180
|
onDoubleClick: props.onToggleCollapse,
|
|
4160
5181
|
// 键盘可达(D64):纯鼠标 div 改成分隔符角色,↑/↓ 方向键调高
|
|
4161
5182
|
role: 'separator',
|
|
4162
5183
|
'aria-orientation': 'horizontal',
|
|
4163
|
-
'aria-label': '
|
|
5184
|
+
'aria-label': t('hint.termResizeAria'),
|
|
4164
5185
|
tabIndex: 0,
|
|
4165
5186
|
onKeyDown: (event) => {
|
|
4166
5187
|
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return
|
|
@@ -4181,20 +5202,20 @@ window.__ModuleLoader__.load({
|
|
|
4181
5202
|
jsx('span', { className: 'dk_drawerTitle', title: props.label, children: props.label }),
|
|
4182
5203
|
jsx('span', {
|
|
4183
5204
|
className: 'dk_drawerHint',
|
|
4184
|
-
children: collapsed ? '
|
|
5205
|
+
children: collapsed ? t('status.termCollapsed') : t('status.termDocked'),
|
|
4185
5206
|
}),
|
|
4186
5207
|
jsx('span', { className: 'dk_headerSpacer' }),
|
|
4187
5208
|
jsx('button', {
|
|
4188
5209
|
type: 'button',
|
|
4189
5210
|
className: 'dk_iconBtn dk_drawerFold',
|
|
4190
|
-
title: collapsed ? '
|
|
5211
|
+
title: collapsed ? t('btn.expandTerminal') : t('btn.collapseTerminal'),
|
|
4191
5212
|
onClick: props.onToggleCollapse,
|
|
4192
5213
|
children: jsx('span', { className: 'dk_iconGlyph', dangerouslySetInnerHTML: { __html: ICON_CHEVRON } }),
|
|
4193
5214
|
}, 'fold'),
|
|
4194
5215
|
jsx('button', {
|
|
4195
5216
|
type: 'button',
|
|
4196
5217
|
className: 'dk_iconBtn',
|
|
4197
|
-
title: '
|
|
5218
|
+
title: t('btn.endTerminal'),
|
|
4198
5219
|
onClick: props.onClose,
|
|
4199
5220
|
children: jsx('span', { dangerouslySetInnerHTML: { __html: ICON_CLOSE } }),
|
|
4200
5221
|
}, 'close'),
|
|
@@ -4428,7 +5449,7 @@ window.__ModuleLoader__.load({
|
|
|
4428
5449
|
try {
|
|
4429
5450
|
dispose = terminalApi.mount(host, exec.options)
|
|
4430
5451
|
} catch (error) {
|
|
4431
|
-
setNotice('
|
|
5452
|
+
setNotice(t('error.terminalStart') + (error instanceof Error ? error.message : String(error)))
|
|
4432
5453
|
setExec(null)
|
|
4433
5454
|
return undefined
|
|
4434
5455
|
}
|
|
@@ -4848,12 +5869,12 @@ window.__ModuleLoader__.load({
|
|
|
4848
5869
|
|
|
4849
5870
|
/** 事件流连接状态文案(连接层错误只动这里,不弹横幅)。 */
|
|
4850
5871
|
const eventsStatusText = () => {
|
|
4851
|
-
if (eventsStatus === 'open') return '
|
|
4852
|
-
if (eventsStatus === 'connecting') return '
|
|
4853
|
-
if (eventsStatus === 'reconnecting') return '
|
|
4854
|
-
if (eventsStatus === 'closed') return '
|
|
4855
|
-
if (eventsStatus === 'unsupported') return '
|
|
4856
|
-
return '
|
|
5872
|
+
if (eventsStatus === 'open') return t('status.eventsLive')
|
|
5873
|
+
if (eventsStatus === 'connecting') return t('status.eventsConnecting')
|
|
5874
|
+
if (eventsStatus === 'reconnecting') return t('status.reconnecting')
|
|
5875
|
+
if (eventsStatus === 'closed') return t('status.eventsClosed')
|
|
5876
|
+
if (eventsStatus === 'unsupported') return t('status.noEventSource')
|
|
5877
|
+
return t('status.eventsStream')
|
|
4857
5878
|
}
|
|
4858
5879
|
|
|
4859
5880
|
/**
|
|
@@ -4907,7 +5928,7 @@ window.__ModuleLoader__.load({
|
|
|
4907
5928
|
const code = payload !== null && typeof payload.code === 'number' ? payload.code : null
|
|
4908
5929
|
setEventsStatus('closed')
|
|
4909
5930
|
// 这条流断了就不会自己回来:明确说一声,别让用户以为活动条只是「最近没动静」
|
|
4910
|
-
setNotice('
|
|
5931
|
+
setNotice(t('status.eventsEnded') + (code === null ? '' : t('meta.exitCodeNote', { code })) + t('status.backToListRefresh'))
|
|
4911
5932
|
close()
|
|
4912
5933
|
}
|
|
4913
5934
|
const onError = (raw) => {
|
|
@@ -4957,7 +5978,7 @@ window.__ModuleLoader__.load({
|
|
|
4957
5978
|
* 形态下只剩 tty 画的外壳,看起来就是「面板空白」。实测踩过,别改回去。
|
|
4958
5979
|
*/
|
|
4959
5980
|
useEffect(() => {
|
|
4960
|
-
conversationHiddenHint = props.carrier === 'tab' ? '' : '
|
|
5981
|
+
conversationHiddenHint = props.carrier === 'tab' ? '' : t('hint.conversationHidden')
|
|
4961
5982
|
return () => { conversationHiddenHint = '' }
|
|
4962
5983
|
}, [props.carrier])
|
|
4963
5984
|
|
|
@@ -4967,8 +5988,8 @@ window.__ModuleLoader__.load({
|
|
|
4967
5988
|
// 走 copyToClipboard(D20):非安全上下文(IP 访问)没有 navigator.clipboard,
|
|
4968
5989
|
// 直接属性访问就抛 TypeError,.catch 永远接不住
|
|
4969
5990
|
copyToClipboard(command).then(() => {
|
|
4970
|
-
setNotice('
|
|
4971
|
-
}).catch(() => setNotice('
|
|
5991
|
+
setNotice(t('msg.copied') + command + (reason === undefined ? '' : t('meta.parenValue', { value: reason })))
|
|
5992
|
+
}).catch(() => setNotice(t('error.copyManual') + command))
|
|
4972
5993
|
}
|
|
4973
5994
|
|
|
4974
5995
|
/**
|
|
@@ -4986,8 +6007,8 @@ window.__ModuleLoader__.load({
|
|
|
4986
6007
|
// ttyTerminal 服务)。提示要能直接指导用户下一步做什么。
|
|
4987
6008
|
const ttyInstalled = document.querySelector('[data-dsh-tty-entry]') !== null
|
|
4988
6009
|
copyExecCommand(item, ttyInstalled
|
|
4989
|
-
? '
|
|
4990
|
-
: '
|
|
6010
|
+
? t('hint.ttyOutdated')
|
|
6011
|
+
: t('hint.ttyNotInstalled'))
|
|
4991
6012
|
return
|
|
4992
6013
|
}
|
|
4993
6014
|
const cfg = (config?.targets ?? []).find((entry) => entry.name === target)
|
|
@@ -5007,7 +6028,7 @@ window.__ModuleLoader__.load({
|
|
|
5007
6028
|
return null
|
|
5008
6029
|
})()
|
|
5009
6030
|
if (options === null) {
|
|
5010
|
-
copyExecCommand(item, '
|
|
6031
|
+
copyExecCommand(item, t('hint.inlineCreds'))
|
|
5011
6032
|
return
|
|
5012
6033
|
}
|
|
5013
6034
|
// 0) 借 tty 面板开标签的两种情形——不就地嵌抽屉:
|
|
@@ -5087,17 +6108,17 @@ window.__ModuleLoader__.load({
|
|
|
5087
6108
|
// 按钮已经置灰,这里是兜底:同一容器的上一条命令还在飞就不接第二条
|
|
5088
6109
|
if (pending[item.id] !== undefined) return
|
|
5089
6110
|
setConfirm({
|
|
5090
|
-
title: action === 'remove' ? '
|
|
6111
|
+
title: action === 'remove' ? t('btn.removeContainerShort') : (action === 'stop' ? t('btn.stopContainer') : (action === 'start' ? t('btn.startContainer') : t('btn.restartContainer'))),
|
|
5091
6112
|
text: action === 'remove'
|
|
5092
|
-
?
|
|
5093
|
-
:
|
|
5094
|
-
confirmLabel: action === 'remove' ? '
|
|
6113
|
+
? t('confirm.removeContainer', { name: item.name })
|
|
6114
|
+
: t('confirm.containerAction', { name: item.name, action: action === 'stop' ? t('btn.stop') : action === 'start' ? t('btn.start') : t('btn.restart') }),
|
|
6115
|
+
confirmLabel: action === 'remove' ? t('btn.delete') : t('btn.confirm'),
|
|
5095
6116
|
run: () => runConfirmed(
|
|
5096
6117
|
async () => {
|
|
5097
6118
|
setPending((map) => ({ ...map, [item.id]: action }))
|
|
5098
6119
|
try {
|
|
5099
6120
|
const payload = await api.action(target, action, item.id)
|
|
5100
|
-
setNotice(
|
|
6121
|
+
setNotice(t('msg.actionResult', { action: payload.result.action, name: item.name, message: payload.result.message }))
|
|
5101
6122
|
} catch (error_) {
|
|
5102
6123
|
// 命令失败也要解锁,否则这张卡片会一直转圈
|
|
5103
6124
|
clearPending(item.id)
|
|
@@ -5119,12 +6140,12 @@ window.__ModuleLoader__.load({
|
|
|
5119
6140
|
const doImageRemove = (item) => {
|
|
5120
6141
|
const ref = imageRefOf(item)
|
|
5121
6142
|
setConfirm({
|
|
5122
|
-
title: '
|
|
5123
|
-
text: '
|
|
5124
|
-
confirmLabel: '
|
|
6143
|
+
title: t('btn.removeImage'),
|
|
6144
|
+
text: t('confirm.removeImage', { ref }),
|
|
6145
|
+
confirmLabel: t('btn.delete'),
|
|
5125
6146
|
run: () => runConfirmed(async () => {
|
|
5126
6147
|
const payload = await api.imageRemove(target, ref)
|
|
5127
|
-
setNotice('
|
|
6148
|
+
setNotice(t('msg.imageDeleted', { ref, message: payload.result.message }))
|
|
5128
6149
|
if (imageDetail !== null && imageRefOf(imageDetail) === ref) setImageDetail(null)
|
|
5129
6150
|
await loadImages()
|
|
5130
6151
|
}),
|
|
@@ -5134,13 +6155,13 @@ window.__ModuleLoader__.load({
|
|
|
5134
6155
|
/** 清理 dangling 镜像(docker image prune -f;只删无标签镜像)。 */
|
|
5135
6156
|
const doImagePrune = () => {
|
|
5136
6157
|
setConfirm({
|
|
5137
|
-
title: '
|
|
5138
|
-
text: '
|
|
5139
|
-
confirmLabel: '
|
|
6158
|
+
title: t('btn.pruneDangling'),
|
|
6159
|
+
text: t('confirm.pruneImages'),
|
|
6160
|
+
confirmLabel: t('btn.prune'),
|
|
5140
6161
|
run: () => runConfirmed(async () => {
|
|
5141
6162
|
const payload = await api.imagePrune(target)
|
|
5142
6163
|
const tail = String(payload.result.message).trim().split('\n').filter((line) => line !== '')
|
|
5143
|
-
setNotice('
|
|
6164
|
+
setNotice(t('msg.prunedImages') + (tail.length === 0 ? 'ok' : tail[tail.length - 1]))
|
|
5144
6165
|
await loadImages()
|
|
5145
6166
|
}),
|
|
5146
6167
|
})
|
|
@@ -5149,13 +6170,13 @@ window.__ModuleLoader__.load({
|
|
|
5149
6170
|
/** 清理未使用的网络(docker network prune -f;compose 的自定义网络也会被清掉)。 */
|
|
5150
6171
|
const doNetworkPrune = () => {
|
|
5151
6172
|
setConfirm({
|
|
5152
|
-
title: '
|
|
5153
|
-
text: '
|
|
5154
|
-
confirmLabel: '
|
|
6173
|
+
title: t('btn.pruneNetworks'),
|
|
6174
|
+
text: t('confirm.pruneNetworks'),
|
|
6175
|
+
confirmLabel: t('btn.prune'),
|
|
5155
6176
|
run: () => runConfirmed(async () => {
|
|
5156
6177
|
const payload = await api.networkPrune(target)
|
|
5157
6178
|
const tail = String(payload.result.message).trim().split('\n').filter((line) => line !== '')
|
|
5158
|
-
setNotice('
|
|
6179
|
+
setNotice(t('msg.prunedNetworks') + (tail.length === 0 ? 'ok' : tail[tail.length - 1]))
|
|
5159
6180
|
await loadNetworks()
|
|
5160
6181
|
}),
|
|
5161
6182
|
})
|
|
@@ -5168,13 +6189,13 @@ window.__ModuleLoader__.load({
|
|
|
5168
6189
|
*/
|
|
5169
6190
|
const doVolumePrune = () => {
|
|
5170
6191
|
setConfirm({
|
|
5171
|
-
title: '
|
|
5172
|
-
text: '
|
|
5173
|
-
confirmLabel: '
|
|
6192
|
+
title: t('btn.pruneVolumes'),
|
|
6193
|
+
text: t('confirm.pruneVolumes'),
|
|
6194
|
+
confirmLabel: t('btn.prune'),
|
|
5174
6195
|
run: () => runConfirmed(async () => {
|
|
5175
6196
|
const payload = await api.volumePrune(target)
|
|
5176
6197
|
const tail = String(payload.result.message).trim().split('\n').filter((line) => line !== '')
|
|
5177
|
-
setNotice('
|
|
6198
|
+
setNotice(t('msg.prunedVolumes') + (tail.length === 0 ? 'ok' : tail[tail.length - 1]))
|
|
5178
6199
|
await loadVolumes()
|
|
5179
6200
|
}),
|
|
5180
6201
|
})
|
|
@@ -5217,16 +6238,15 @@ window.__ModuleLoader__.load({
|
|
|
5217
6238
|
*/
|
|
5218
6239
|
const switchPill = () => jsxs('div', {
|
|
5219
6240
|
className: 'dk_switchPill',
|
|
5220
|
-
title: '
|
|
5221
|
-
+ '。下面仍是 ' + listTarget + titleHost(listTarget) + '的数据,切换完成前不可操作。',
|
|
6241
|
+
title: t('banner.switching', { target, host: titleHost(target), listTarget, listHost: titleHost(listTarget) }),
|
|
5222
6242
|
children: [
|
|
5223
6243
|
jsx('span', { className: 'dk_spin dk_spinSm' }),
|
|
5224
6244
|
jsxs('span', { className: 'dk_switchText', children: [
|
|
5225
|
-
jsx('span', { children: '
|
|
6245
|
+
jsx('span', { children: t('status.switchingTo') }),
|
|
5226
6246
|
jsx('strong', { children: target }),
|
|
5227
6247
|
jsx('span', { className: 'dk_switchDot', children: '·' }),
|
|
5228
6248
|
jsxs('span', { className: 'dk_switchSub', children: [
|
|
5229
|
-
jsx('span', { children: '
|
|
6249
|
+
jsx('span', { children: t('status.showing') }),
|
|
5230
6250
|
jsx('span', { className: 'dk_switchName', children: listTarget }),
|
|
5231
6251
|
] }),
|
|
5232
6252
|
] }),
|
|
@@ -5241,7 +6261,7 @@ window.__ModuleLoader__.load({
|
|
|
5241
6261
|
const titleHost = (name) => {
|
|
5242
6262
|
const found = targets.find((item) => item.name === name)
|
|
5243
6263
|
const label = found === undefined || typeof found.label !== 'string' ? '' : found.label
|
|
5244
|
-
return label === '' || label === name ? '' : '
|
|
6264
|
+
return label === '' || label === name ? '' : t('meta.parenValue', { value: label })
|
|
5245
6265
|
}
|
|
5246
6266
|
|
|
5247
6267
|
const staleList = listTarget !== '' && listTarget !== target && !sessionScoped
|
|
@@ -5253,30 +6273,30 @@ window.__ModuleLoader__.load({
|
|
|
5253
6273
|
}
|
|
5254
6274
|
|
|
5255
6275
|
const empty = () => {
|
|
5256
|
-
if (loading) return jsx('div', { className: 'dk_empty', children: [jsx('span', { className: 'dk_spin' }), jsx('div', { children: '
|
|
6276
|
+
if (loading) return jsx('div', { className: 'dk_empty', children: [jsx('span', { className: 'dk_spin' }), jsx('div', { children: t('list.loading') })] })
|
|
5257
6277
|
if (target === '') {
|
|
5258
6278
|
if (sessionScoped) {
|
|
5259
6279
|
return jsxs('div', { className: 'dk_empty', children: [
|
|
5260
|
-
jsx('div', { className: 'dk_emptyTitle', children: '
|
|
5261
|
-
jsx('div', { className: 'dk_emptyHint', children: '
|
|
6280
|
+
jsx('div', { className: 'dk_emptyTitle', children: t('list.sessionHostNotTarget') }),
|
|
6281
|
+
jsx('div', { className: 'dk_emptyHint', children: t('hint.sessionHostNotTarget') }),
|
|
5262
6282
|
] })
|
|
5263
6283
|
}
|
|
5264
6284
|
return jsxs('div', { className: 'dk_empty', children: [
|
|
5265
|
-
jsx('div', { className: 'dk_emptyTitle', children: '
|
|
5266
|
-
jsx('div', { className: 'dk_emptyHint', children: '
|
|
6285
|
+
jsx('div', { className: 'dk_emptyTitle', children: t('list.noTargetsConfigured') }),
|
|
6286
|
+
jsx('div', { className: 'dk_emptyHint', children: t('hint.addTargetEmpty') }),
|
|
5267
6287
|
] })
|
|
5268
6288
|
}
|
|
5269
6289
|
// 读取失败时列表本来就会被清空(切目标失败尤其如此):此时别把空列表说成
|
|
5270
6290
|
// 「筛选条件过窄」——那是两回事,会让人去反复改筛选器而看不到真正的错误。
|
|
5271
6291
|
if (error !== '' && containers.length === 0) {
|
|
5272
6292
|
return jsxs('div', { className: 'dk_empty', children: [
|
|
5273
|
-
jsx('div', { className: 'dk_emptyTitle', children: '
|
|
5274
|
-
jsx('div', { className: 'dk_emptyHint', children: '
|
|
6293
|
+
jsx('div', { className: 'dk_emptyTitle', children: t('list.targetError') }),
|
|
6294
|
+
jsx('div', { className: 'dk_emptyHint', children: t('hint.targetError') }),
|
|
5275
6295
|
] })
|
|
5276
6296
|
}
|
|
5277
6297
|
return jsxs('div', { className: 'dk_empty', children: [
|
|
5278
|
-
jsx('div', { className: 'dk_emptyTitle', children: view === 'images' ? '
|
|
5279
|
-
jsx('div', { className: 'dk_emptyHint', children: (search.trim() === '' ? '
|
|
6298
|
+
jsx('div', { className: 'dk_emptyTitle', children: view === 'images' ? t('list.noImages') : (view === 'compose' ? t('list.noCompose') : (view === 'networks' ? t('list.noNetworks') : (view === 'volumes' ? t('list.noVolumes') : t('list.noContainers')))) }),
|
|
6299
|
+
jsx('div', { className: 'dk_emptyHint', children: (search.trim() === '' ? t('list.noMatch') : t('list.noMatchFor', { query: search.trim() })) }),
|
|
5280
6300
|
] })
|
|
5281
6301
|
}
|
|
5282
6302
|
|
|
@@ -5295,16 +6315,16 @@ window.__ModuleLoader__.load({
|
|
|
5295
6315
|
? empty()
|
|
5296
6316
|
: jsx('div', { className: 'dk_tableWrap', children: jsxs('table', { className: 'dk_images', children: [
|
|
5297
6317
|
jsx('thead', { children: jsxs('tr', { children: [
|
|
5298
|
-
jsx('th', { children: '
|
|
6318
|
+
jsx('th', { children: t('field.image') }), jsx('th', { children: t('field.size') }), jsx('th', { children: t('field.created') }), jsx('th', { children: 'ID' }), jsx('th', { className: 'dk_colActions', children: t('field.actions') }),
|
|
5299
6319
|
] }) }),
|
|
5300
6320
|
jsx('tbody', { children: filteredImages.map((item) => jsxs('tr', { children: [
|
|
5301
|
-
jsx('td', { className: 'dk_mono', title: item.reference, children: item.dangling ? '
|
|
6321
|
+
jsx('td', { className: 'dk_mono', title: item.reference, children: item.dangling ? t('list.dangling') : item.reference }),
|
|
5302
6322
|
jsx('td', { children: item.sizeText === '' ? (item.size === null ? '—' : fmtBytes(item.size)) : item.sizeText }),
|
|
5303
6323
|
jsx('td', { children: item.createdSince }),
|
|
5304
6324
|
jsx('td', { className: 'dk_mono', children: item.shortId }),
|
|
5305
6325
|
jsx('td', { className: 'dk_colActions', children: jsxs('div', { className: 'dk_rowActions', children: [
|
|
5306
|
-
jsx(IconAction, { icon: ICON_IMAGE, title: '
|
|
5307
|
-
jsx(IconAction, { icon: ICON_TRASH, danger: true, disabled: config?.allowMutations !== true, title: config?.allowMutations === true ? '
|
|
6326
|
+
jsx(IconAction, { icon: ICON_IMAGE, title: t('btn.viewImageDetail'), onClick: () => setImageDetail(item) }, 'inspect'),
|
|
6327
|
+
jsx(IconAction, { icon: ICON_TRASH, danger: true, disabled: config?.allowMutations !== true, title: config?.allowMutations === true ? t('btn.removeImageFull') : t('status.needMutations'), onClick: () => doImageRemove(item) }, 'remove'),
|
|
5308
6328
|
] }) }, 'actions'),
|
|
5309
6329
|
] }, item.id + item.reference)) }),
|
|
5310
6330
|
] }) }),
|
|
@@ -5322,12 +6342,12 @@ window.__ModuleLoader__.load({
|
|
|
5322
6342
|
? empty()
|
|
5323
6343
|
: jsx('div', { className: 'dk_tableWrap', children: jsxs('table', { className: 'dk_images', children: [
|
|
5324
6344
|
jsx('thead', { children: jsxs('tr', { children: [
|
|
5325
|
-
jsx('th', { children: '
|
|
6345
|
+
jsx('th', { children: t('field.name') }), jsx('th', { children: t('field.driver') }), jsx('th', { children: t('field.scope') }), jsx('th', { children: t('field.attributes') }), jsx('th', { children: 'ID' }),
|
|
5326
6346
|
] }) }),
|
|
5327
6347
|
jsx('tbody', { children: filteredNetworks.map((item) => jsxs('tr', {
|
|
5328
6348
|
className: 'dk_rowClickable',
|
|
5329
6349
|
onClick: () => setNetworkDetail(item),
|
|
5330
|
-
title: '
|
|
6350
|
+
title: t('btn.viewNetworkDetail'),
|
|
5331
6351
|
children: [
|
|
5332
6352
|
jsx('td', { className: 'dk_mono', title: item.name, children: item.name }),
|
|
5333
6353
|
jsx('td', { children: item.driver === '' ? '—' : item.driver }),
|
|
@@ -5346,12 +6366,12 @@ window.__ModuleLoader__.load({
|
|
|
5346
6366
|
? empty()
|
|
5347
6367
|
: jsx('div', { className: 'dk_tableWrap', children: jsxs('table', { className: 'dk_images', children: [
|
|
5348
6368
|
jsx('thead', { children: jsxs('tr', { children: [
|
|
5349
|
-
jsx('th', { children: '
|
|
6369
|
+
jsx('th', { children: t('field.name') }), jsx('th', { children: t('field.driver') }), jsx('th', { children: t('field.scope') }), jsx('th', { children: t('field.mountpoint') }),
|
|
5350
6370
|
] }) }),
|
|
5351
6371
|
jsx('tbody', { children: filteredVolumes.map((item) => jsxs('tr', {
|
|
5352
6372
|
className: 'dk_rowClickable',
|
|
5353
6373
|
onClick: () => setVolumeDetail(item),
|
|
5354
|
-
title: '
|
|
6374
|
+
title: t('btn.viewVolumeDetail'),
|
|
5355
6375
|
children: [
|
|
5356
6376
|
jsx('td', { className: 'dk_mono', title: item.name, children: item.name }),
|
|
5357
6377
|
jsx('td', { children: item.driver === '' ? '—' : item.driver }),
|
|
@@ -5380,7 +6400,7 @@ window.__ModuleLoader__.load({
|
|
|
5380
6400
|
onCopyExec: (picked) => {
|
|
5381
6401
|
// 走 copyToClipboard(D20):非安全上下文下 navigator.clipboard 是 undefined
|
|
5382
6402
|
const command = buildExecCommand(picked.name)
|
|
5383
|
-
copyToClipboard(command).then(() => setNotice('
|
|
6403
|
+
copyToClipboard(command).then(() => setNotice(t('msg.copied') + command)).catch(() => setNotice(t('error.copyFailed')))
|
|
5384
6404
|
},
|
|
5385
6405
|
}, item.id)) })
|
|
5386
6406
|
}
|
|
@@ -5414,11 +6434,11 @@ window.__ModuleLoader__.load({
|
|
|
5414
6434
|
const next = pickApply(filtered, pickedIds, key, pickBase, pickMax)
|
|
5415
6435
|
setPickedIds(next.ids)
|
|
5416
6436
|
if (next.skipped > 0) {
|
|
5417
|
-
setPickNotice('
|
|
6437
|
+
setPickNotice(t('msg.pickedAddedSkipped', { added: next.added, skipped: next.skipped, max: pickMax }))
|
|
5418
6438
|
} else if (next.added === 0) {
|
|
5419
|
-
setPickNotice('
|
|
6439
|
+
setPickNotice(t('msg.pickedNone'))
|
|
5420
6440
|
} else {
|
|
5421
|
-
setPickNotice('
|
|
6441
|
+
setPickNotice(t('msg.pickedAdded', { count: next.added }))
|
|
5422
6442
|
}
|
|
5423
6443
|
}
|
|
5424
6444
|
const composeItems = composeDetail === null
|
|
@@ -5522,12 +6542,12 @@ window.__ModuleLoader__.load({
|
|
|
5522
6542
|
*/
|
|
5523
6543
|
docked ? null : jsxs('div', { className: 'dk_header', children: [
|
|
5524
6544
|
jsx('span', { className: 'dk_titleIcon', dangerouslySetInnerHTML: { __html: ICON_BOX } }),
|
|
5525
|
-
jsx('span', { className: 'dk_title', children: '
|
|
5526
|
-
config?.allowMutations === true ? null : jsx('span', { className: 'dk_badge', 'data-state': 'paused', children: '
|
|
6545
|
+
jsx('span', { className: 'dk_title', children: t('panel.title') }),
|
|
6546
|
+
config?.allowMutations === true ? null : jsx('span', { className: 'dk_badge', 'data-state': 'paused', children: t('badge.readOnly') }),
|
|
5527
6547
|
jsx('span', { className: 'dk_headerSpacer' }),
|
|
5528
6548
|
// 刷新中让图标自己转(loading 也用于镜像列表)
|
|
5529
|
-
selected !== null ? null : jsx('button', { type: 'button', className: 'dk_iconBtn', title: '
|
|
5530
|
-
jsx('button', { type: 'button', className: 'dk_iconBtn', title: '
|
|
6549
|
+
selected !== null ? null : jsx('button', { type: 'button', className: 'dk_iconBtn', title: t('btn.refreshList'), 'data-spin': loading ? '1' : undefined, onClick: refresh, children: jsx('span', { className: 'dk_iconGlyph', dangerouslySetInnerHTML: { __html: ICON_REFRESH } }) }),
|
|
6550
|
+
jsx('button', { type: 'button', className: 'dk_iconBtn', title: t('btn.closePanel'), onClick: requestClose, children: jsx('span', { dangerouslySetInnerHTML: { __html: ICON_CLOSE } }) }),
|
|
5531
6551
|
] }),
|
|
5532
6552
|
/* 工具栏:只在列表视图显示;容器详情是整栏视图,列表筛选在这里没有意义 */
|
|
5533
6553
|
selected !== null ? null : jsxs('div', { className: 'dk_toolbar', children: [
|
|
@@ -5554,9 +6574,9 @@ window.__ModuleLoader__.load({
|
|
|
5554
6574
|
},
|
|
5555
6575
|
children: [
|
|
5556
6576
|
...(view === 'overview'
|
|
5557
|
-
? [jsx('option', { value: '', children: '
|
|
6577
|
+
? [jsx('option', { value: '', children: t('option.overviewAllTargets') }, '__overview')]
|
|
5558
6578
|
// 会话主机未匹配目标时保持「未选择」,让用户显式挑一个,不替他默认
|
|
5559
|
-
: (target === '' ? [jsx('option', { value: '', children: '
|
|
6579
|
+
: (target === '' ? [jsx('option', { value: '', children: t('option.noTargetSelected') }, '__none')] : [])),
|
|
5560
6580
|
...(targets.length === 0 && target !== '' ? [{ name: target, label: undefined }] : targets)
|
|
5561
6581
|
.map((item) => jsx('option', { value: item.name, children: targetLabel(item.name) }, item.name)),
|
|
5562
6582
|
],
|
|
@@ -5570,7 +6590,7 @@ window.__ModuleLoader__.load({
|
|
|
5570
6590
|
type: 'button',
|
|
5571
6591
|
className: 'dk_pill dk_pillOverview',
|
|
5572
6592
|
'data-on': view === 'overview' ? '1' : '0',
|
|
5573
|
-
title: view === 'overview' ? '
|
|
6593
|
+
title: view === 'overview' ? t('btn.exitOverview') : t('btn.enterOverview'),
|
|
5574
6594
|
onClick: () => {
|
|
5575
6595
|
if (view !== 'overview') {
|
|
5576
6596
|
setView('overview')
|
|
@@ -5588,14 +6608,14 @@ window.__ModuleLoader__.load({
|
|
|
5588
6608
|
setDetail(null)
|
|
5589
6609
|
resetPick()
|
|
5590
6610
|
},
|
|
5591
|
-
children: '
|
|
6611
|
+
children: t('panel.overview'),
|
|
5592
6612
|
}),
|
|
5593
6613
|
/*
|
|
5594
6614
|
* 五段:容器 / 镜像 / Compose / 网络 / 卷。都是短词,窄栏放得下;
|
|
5595
6615
|
* 真的溢出时 .dk_seg 允许横向滚动(见 docker.css),不做二级菜单——
|
|
5596
6616
|
* 二级菜单会把「一次点击切页」变成两次,而切页是这里最高频的动作。
|
|
5597
6617
|
*/
|
|
5598
|
-
jsx('div', { className: 'dk_seg', children: [['containers', '
|
|
6618
|
+
jsx('div', { className: 'dk_seg', children: [['containers', t('panel.containers')], ['images', t('field.image')], ['compose', 'Compose'], ['networks', t('field.networks')], ['volumes', t('field.volume')]].map(([key, label]) => jsx('button', {
|
|
5599
6619
|
type: 'button',
|
|
5600
6620
|
className: 'dk_segBtn',
|
|
5601
6621
|
'data-on': view === key ? '1' : '0',
|
|
@@ -5616,56 +6636,56 @@ window.__ModuleLoader__.load({
|
|
|
5616
6636
|
type: 'button',
|
|
5617
6637
|
className: 'dk_pill dk_pillPick',
|
|
5618
6638
|
'data-on': pickMode ? '1' : '0',
|
|
5619
|
-
title: pickMode ? '
|
|
6639
|
+
title: pickMode ? t('btn.exitPick') : t('btn.pickMode'),
|
|
5620
6640
|
onClick: togglePickMode,
|
|
5621
|
-
children: pickMode ? '
|
|
6641
|
+
children: pickMode ? t('btn.exitSelection') : t('btn.aggSelection'),
|
|
5622
6642
|
}) : null,
|
|
5623
6643
|
view === 'containers' ? jsx('input', {
|
|
5624
6644
|
className: 'dk_input dk_search',
|
|
5625
|
-
placeholder: '
|
|
6645
|
+
placeholder: t('placeholder.searchContainers'),
|
|
5626
6646
|
value: search,
|
|
5627
6647
|
onChange: (event) => setSearch(event.target.value),
|
|
5628
6648
|
}) : null,
|
|
5629
6649
|
view === 'compose' ? jsx('input', {
|
|
5630
6650
|
className: 'dk_input dk_search',
|
|
5631
|
-
placeholder: '
|
|
6651
|
+
placeholder: t('placeholder.searchCompose'),
|
|
5632
6652
|
value: search,
|
|
5633
6653
|
onChange: (event) => setSearch(event.target.value),
|
|
5634
6654
|
}) : null,
|
|
5635
6655
|
// 镜像搜索与容器搜索同处工具条(固定区):长列表滚起来后搜索框仍在原地
|
|
5636
6656
|
view === 'images' ? jsx('input', {
|
|
5637
6657
|
className: 'dk_input dk_search',
|
|
5638
|
-
placeholder: '
|
|
6658
|
+
placeholder: t('placeholder.searchImages'),
|
|
5639
6659
|
value: imageSearch,
|
|
5640
6660
|
onChange: (event) => setImageSearch(event.target.value),
|
|
5641
6661
|
}) : null,
|
|
5642
|
-
view === 'images' ? jsx('span', { className: 'dk_hint dk_searchCount', children:
|
|
6662
|
+
view === 'images' ? jsx('span', { className: 'dk_hint dk_searchCount', children: t('list.imageCountRatio', { filtered: filteredImages.length, total: images.length }) }) : null,
|
|
5643
6663
|
/*
|
|
5644
6664
|
* 镜像的变更入口(拉取 = SSE 进度流;清理只删 dangling):与容器动作一样受
|
|
5645
6665
|
* allowMutations 门控。两个都做成图标——镜像页工具条已经被搜索框 / 计数 /
|
|
5646
6666
|
* (dock 模式还有刷新)占满,文字的「拉取镜像」会把窄栏挤换行;
|
|
5647
6667
|
* 图标化后 title 就是唯一的可发现入口,所以两态的文案都带上动作名。
|
|
5648
6668
|
*/
|
|
5649
|
-
view === 'images' ? jsx(IconAction, { icon: ICON_PULL, disabled: config?.allowMutations !== true, title: config?.allowMutations === true ? '
|
|
5650
|
-
view === 'images' ? jsx(IconAction, { icon: ICON_PRUNE, danger: true, disabled: config?.allowMutations !== true, title: config?.allowMutations === true ? '
|
|
6669
|
+
view === 'images' ? jsx(IconAction, { icon: ICON_PULL, disabled: config?.allowMutations !== true, title: config?.allowMutations === true ? t('btn.pullImage') : t('banner.pullNeedsMutations'), onClick: () => setPullOpen(true) }, 'pull') : null,
|
|
6670
|
+
view === 'images' ? jsx(IconAction, { icon: ICON_PRUNE, danger: true, disabled: config?.allowMutations !== true, title: config?.allowMutations === true ? t('btn.pruneImages') : t('btn.pruneImagesDisabled'), onClick: doImagePrune }, 'prune') : null,
|
|
5651
6671
|
view === 'networks' ? jsx('input', {
|
|
5652
6672
|
className: 'dk_input dk_search',
|
|
5653
|
-
placeholder: '
|
|
6673
|
+
placeholder: t('placeholder.searchNetworks'),
|
|
5654
6674
|
value: networkSearch,
|
|
5655
6675
|
onChange: (event) => setNetworkSearch(event.target.value),
|
|
5656
6676
|
}) : null,
|
|
5657
6677
|
view === 'volumes' ? jsx('input', {
|
|
5658
6678
|
className: 'dk_input dk_search',
|
|
5659
|
-
placeholder: '
|
|
6679
|
+
placeholder: t('placeholder.searchVolumes'),
|
|
5660
6680
|
value: volumeSearch,
|
|
5661
6681
|
onChange: (event) => setVolumeSearch(event.target.value),
|
|
5662
6682
|
}) : null,
|
|
5663
|
-
view === 'networks' ? jsx('span', { className: 'dk_hint dk_searchCount', children:
|
|
5664
|
-
view === 'volumes' ? jsx('span', { className: 'dk_hint dk_searchCount', children:
|
|
5665
|
-
view === 'networks' ? jsx(IconAction, { icon: ICON_PRUNE, danger: true, disabled: config?.allowMutations !== true, title: config?.allowMutations === true ? '
|
|
5666
|
-
view === 'volumes' ? jsx(IconAction, { icon: ICON_PRUNE, danger: true, disabled: config?.allowMutations !== true, title: config?.allowMutations === true ? '
|
|
5667
|
-
view === 'compose' ? jsx('span', { className: 'dk_hint dk_searchCount', children:
|
|
5668
|
-
view === 'containers' ? jsx('div', { className: 'dk_seg', children: [['all', '
|
|
6683
|
+
view === 'networks' ? jsx('span', { className: 'dk_hint dk_searchCount', children: t('list.networkCountRatio', { filtered: filteredNetworks.length, total: networks.length }) }) : null,
|
|
6684
|
+
view === 'volumes' ? jsx('span', { className: 'dk_hint dk_searchCount', children: t('list.volumeCountRatio', { filtered: filteredVolumes.length, total: volumes.length }) }) : null,
|
|
6685
|
+
view === 'networks' ? jsx(IconAction, { icon: ICON_PRUNE, danger: true, disabled: config?.allowMutations !== true, title: config?.allowMutations === true ? t('btn.pruneNetworksFull') : t('btn.pruneNetworksDisabled'), onClick: doNetworkPrune }, 'prune') : null,
|
|
6686
|
+
view === 'volumes' ? jsx(IconAction, { icon: ICON_PRUNE, danger: true, disabled: config?.allowMutations !== true, title: config?.allowMutations === true ? t('btn.pruneVolumesFull') : t('btn.pruneVolumesDisabled'), onClick: doVolumePrune }, 'prune') : null,
|
|
6687
|
+
view === 'compose' ? jsx('span', { className: 'dk_hint dk_searchCount', children: t('meta.projectCount', { count: groupCompose(filtered).length }) + ' · ' + t('meta.containerCount', { count: filtered.length }) }) : null,
|
|
6688
|
+
view === 'containers' ? jsx('div', { className: 'dk_seg', children: [['all', t('option.filterAll')], ['running', t('status.running')], ['stopped', t('status.stopped')], ['unhealthy', t('status.unhealthy')]].map(([key, label]) => jsx('button', {
|
|
5669
6689
|
type: 'button',
|
|
5670
6690
|
className: 'dk_segBtn',
|
|
5671
6691
|
'data-on': stateFilter === key ? '1' : '0',
|
|
@@ -5680,7 +6700,7 @@ window.__ModuleLoader__.load({
|
|
|
5680
6700
|
view === 'containers' || view === 'compose' || view === 'overview' ? jsxs('div', { className: 'dk_toolbarToggles', children: [
|
|
5681
6701
|
view === 'containers' || view === 'compose' ? jsx('label', { className: 'dk_check', children: [
|
|
5682
6702
|
jsx('input', { type: 'checkbox', checked: all, onChange: (event) => setAll(event.target.checked) }),
|
|
5683
|
-
'
|
|
6703
|
+
t('check.includeStopped'),
|
|
5684
6704
|
] }, 'all') : null,
|
|
5685
6705
|
/*
|
|
5686
6706
|
* 自动刷新只服务「状态会变」的页(容器 / Compose / 总览)。镜像、网络、卷都是
|
|
@@ -5690,7 +6710,7 @@ window.__ModuleLoader__.load({
|
|
|
5690
6710
|
*/
|
|
5691
6711
|
jsx('label', { className: 'dk_check', children: [
|
|
5692
6712
|
jsx('input', { type: 'checkbox', checked: autoRefresh, onChange: (event) => setAutoRefresh(event.target.checked) }),
|
|
5693
|
-
'
|
|
6713
|
+
t('check.autoRefresh'),
|
|
5694
6714
|
] }, 'auto'),
|
|
5695
6715
|
] }) : null,
|
|
5696
6716
|
/*
|
|
@@ -5699,9 +6719,9 @@ window.__ModuleLoader__.load({
|
|
|
5699
6719
|
* 右端才是「对整栏生效的动作」,也与非 dock 模式头部里的位置一致。
|
|
5700
6720
|
*/
|
|
5701
6721
|
docked ? jsxs('div', { className: 'dk_toolbarEnd', children: [
|
|
5702
|
-
config?.allowMutations !== true ? jsx('span', { className: 'dk_badge', 'data-state': 'paused', children: '
|
|
6722
|
+
config?.allowMutations !== true ? jsx('span', { className: 'dk_badge', 'data-state': 'paused', children: t('badge.readOnly') }, 'readonly') : null,
|
|
5703
6723
|
// 刷新中图标自己转,所以这里不再另挂 spinner
|
|
5704
|
-
jsx('button', { type: 'button', className: 'dk_iconBtn', title: '
|
|
6724
|
+
jsx('button', { type: 'button', className: 'dk_iconBtn', title: t('btn.refreshList'), 'data-spin': loading ? '1' : undefined, onClick: refresh, children: jsx('span', { className: 'dk_iconGlyph', dangerouslySetInnerHTML: { __html: ICON_REFRESH } }) }, 'refresh'),
|
|
5705
6725
|
] }) : null,
|
|
5706
6726
|
] }),
|
|
5707
6727
|
/*
|
|
@@ -5741,19 +6761,19 @@ window.__ModuleLoader__.load({
|
|
|
5741
6761
|
* 逐目标落在卡片的红色态与顶部「N 个目标不可达」横幅上——两条一起出现,
|
|
5742
6762
|
* 同一个错误会被读成两个故障。
|
|
5743
6763
|
*/
|
|
5744
|
-
error === '' || view === 'overview' ? null : jsx(Banner, { title: '
|
|
6764
|
+
error === '' || view === 'overview' ? null : jsx(Banner, { title: t('banner.actionFailed'), hint: error }),
|
|
5745
6765
|
notice === '' ? null : jsx(Banner, { kind: 'info', title: notice }),
|
|
5746
6766
|
props.sessionHint === undefined || sessionHintStale ? null : jsx(Banner, {
|
|
5747
6767
|
kind: 'info',
|
|
5748
|
-
title: '
|
|
5749
|
-
hint: '
|
|
5750
|
-
+ (props.sessionHint.book === '' ? '' : '
|
|
5751
|
-
+ '
|
|
5752
|
-
+ (props.sessionHint.book === '' ? '
|
|
5753
|
-
+ '
|
|
6768
|
+
title: t('banner.sessionHostNotTarget'),
|
|
6769
|
+
hint: t('meta.sessionHost') + props.sessionHint.host + (props.sessionHint.port === 22 ? '' : ':' + String(props.sessionHint.port))
|
|
6770
|
+
+ (props.sessionHint.book === '' ? '' : t('meta.bookParen', { book: props.sessionHint.book }))
|
|
6771
|
+
+ t('hint.addSshTarget')
|
|
6772
|
+
+ (props.sessionHint.book === '' ? t('hint.addSshInline') : t('hint.addSshBook', { book: props.sessionHint.book }))
|
|
6773
|
+
+ t('hint.addSshTail'),
|
|
5754
6774
|
}),
|
|
5755
6775
|
config !== null && config.allowMutations !== true
|
|
5756
|
-
? jsx(Banner, { kind: 'info', title: '
|
|
6776
|
+
? jsx(Banner, { kind: 'info', title: t('banner.readOnlyMode'), hint: t('hint.readOnlyMode') })
|
|
5757
6777
|
: null,
|
|
5758
6778
|
/*
|
|
5759
6779
|
* 活动条贴在列表头部(横幅之下、列表之上):它讲的是「刚刚发生了什么」,
|
|
@@ -5792,10 +6812,10 @@ window.__ModuleLoader__.load({
|
|
|
5792
6812
|
}, 'execDrawer'),
|
|
5793
6813
|
/* 有活动终端会话时关面板要确认(见 requestClose) */
|
|
5794
6814
|
closeConfirm && exec !== null ? jsx(ConfirmDialog, {
|
|
5795
|
-
title: '
|
|
5796
|
-
text: '
|
|
5797
|
-
+ '
|
|
5798
|
-
confirmLabel: '
|
|
6815
|
+
title: t('confirm.endTerminalTitle'),
|
|
6816
|
+
text: t('confirm.endTerminalText', { label: exec.label })
|
|
6817
|
+
+ t('confirm.endTerminalHint'),
|
|
6818
|
+
confirmLabel: t('btn.endAndClose'),
|
|
5799
6819
|
onCancel: () => setCloseConfirm(false),
|
|
5800
6820
|
onConfirm: () => {
|
|
5801
6821
|
setCloseConfirm(false)
|
|
@@ -5949,17 +6969,34 @@ window.__ModuleLoader__.load({
|
|
|
5949
6969
|
const [numberDrafts, setNumberDrafts] = useState({})
|
|
5950
6970
|
/** 本卡片会话里被用户删除的主机密钥记录(D03/D10):保存时走显式 hostKeysRemove。 */
|
|
5951
6971
|
const removedHostKeysRef = useRef([])
|
|
6972
|
+
/*
|
|
6973
|
+
* 就地提权(见 kit 的 elevation.ts):点开关 → 面板给出一条「在宿主上落地一个随机名文件」的
|
|
6974
|
+
* 命令 → 宿主发现文件即授权,免重启。状态放在卡片里而不是子组件里:离线冒烟的 React 桩不执行
|
|
6975
|
+
* 函数组件体(见文件里其它地方的说明),状态留在卡片里才测得动。
|
|
6976
|
+
*/
|
|
6977
|
+
const [elevation, setElevation] = useState(null)
|
|
6978
|
+
const [elevationNotice, setElevationNotice] = useState('')
|
|
6979
|
+
const [copied, setCopied] = useState(false)
|
|
6980
|
+
/** 每秒推进一次的计数:只为了让「还剩 N 秒」自己走(读的是渲染期的时间差)。 */
|
|
6981
|
+
const [nowTick, setNowTick] = useState(0)
|
|
6982
|
+
/** 授权到达后要「先把开关写进表单、再自动保存」:保存读的是表单镜像,得等一次提交。 */
|
|
6983
|
+
const [autoSavePending, setAutoSavePending] = useState(false)
|
|
6984
|
+
/** 服务端确认过的保存 payload(JSON):用来判断有没有**除授权之外**的未保存改动。 */
|
|
6985
|
+
const cleanPayloadRef = useRef('')
|
|
6986
|
+
const saveRef = useRef(null)
|
|
5952
6987
|
|
|
5953
6988
|
const load = useCallback(() => {
|
|
5954
6989
|
api.config().then((payload) => {
|
|
5955
|
-
|
|
6990
|
+
const next = toCardForm(payload.config)
|
|
6991
|
+
setForm(next)
|
|
6992
|
+
cleanPayloadRef.current = JSON.stringify(toPayload(next))
|
|
5956
6993
|
removedHostKeysRef.current = []
|
|
5957
6994
|
primeTargetsCache(payload.config)
|
|
5958
6995
|
publishConfig(payload.config)
|
|
5959
6996
|
loadedCountRef.current = Array.isArray(payload.config?.targets) ? payload.config.targets.length : 0
|
|
5960
6997
|
setLoaded(true)
|
|
5961
6998
|
}).catch((error) => {
|
|
5962
|
-
setMessage({ kind: 'error', text: '
|
|
6999
|
+
setMessage({ kind: 'error', text: t('error.configLoad') + error.message })
|
|
5963
7000
|
setLoaded(true)
|
|
5964
7001
|
})
|
|
5965
7002
|
}, [])
|
|
@@ -5975,7 +7012,7 @@ window.__ModuleLoader__.load({
|
|
|
5975
7012
|
|
|
5976
7013
|
const addTarget = () => setForm((current) => ({
|
|
5977
7014
|
...current,
|
|
5978
|
-
targets: [...current.targets, { name: '
|
|
7015
|
+
targets: [...current.targets, { name: t('list.newTargetName', { index: current.targets.length + 1 }), kind: 'local', book: '', host: '', port: 22, username: '', auth: 'agent', keyPath: '', agentForward: false, passwordSet: false, passphraseSet: false }],
|
|
5979
7016
|
}))
|
|
5980
7017
|
|
|
5981
7018
|
const removeTarget = (index) => setForm((current) => ({ ...current, targets: current.targets.filter((_, i) => i !== index) }))
|
|
@@ -5988,6 +7025,42 @@ window.__ModuleLoader__.load({
|
|
|
5988
7025
|
}))
|
|
5989
7026
|
}
|
|
5990
7027
|
|
|
7028
|
+
/**
|
|
7029
|
+
* 表单 → 保存 payload。**也是「脏不脏」的比较口径**:只有这些字段会被写进去,所以
|
|
7030
|
+
* 快照里那些只读字段(`*Granted` / `*GrantSource` / `*GrantedAt`)变化不算用户改动——
|
|
7031
|
+
* 否则授权一到,「表单变脏了」会让自动保存白白放弃。
|
|
7032
|
+
*/
|
|
7033
|
+
const toPayload = (source) => ({
|
|
7034
|
+
enabled: source.enabled,
|
|
7035
|
+
announceToAgent: source.announceToAgent,
|
|
7036
|
+
dockerBin: source.dockerBin,
|
|
7037
|
+
allowMutations: source.allowMutations,
|
|
7038
|
+
allowExec: source.allowExec,
|
|
7039
|
+
execTimeoutSec: source.execTimeoutSec,
|
|
7040
|
+
pollIntervalSec: source.pollIntervalSec,
|
|
7041
|
+
logTailDefault: source.logTailDefault,
|
|
7042
|
+
maxOutputKb: source.maxOutputKb,
|
|
7043
|
+
targets: source.targets.map((item) => ({
|
|
7044
|
+
name: item.name,
|
|
7045
|
+
kind: item.kind,
|
|
7046
|
+
book: item.book ?? '',
|
|
7047
|
+
host: item.host ?? '',
|
|
7048
|
+
port: Number(item.port) || 22,
|
|
7049
|
+
username: item.username ?? '',
|
|
7050
|
+
auth: item.auth ?? 'agent',
|
|
7051
|
+
keyPath: item.keyPath ?? '',
|
|
7052
|
+
// 空字符串 = 保留原有凭证(宿主按 undefined 处理会覆盖为空,这里显式跳过)
|
|
7053
|
+
...(item.password === undefined || item.password === '' ? {} : { password: item.password }),
|
|
7054
|
+
...(item.passphrase === undefined || item.passphrase === '' ? {} : { passphrase: item.passphrase }),
|
|
7055
|
+
agentForward: item.agentForward === true,
|
|
7056
|
+
})),
|
|
7057
|
+
// hostKeys 不整表回传(D10):服务端并集合并,运行期新增的钉扎不会被表单快照冲掉;
|
|
7058
|
+
// 删除某条记录走显式 hostKeysRemove
|
|
7059
|
+
...(removedHostKeysRef.current.length > 0 ? { hostKeysRemove: removedHostKeysRef.current } : {}),
|
|
7060
|
+
// 只有「加载到过非空目标、现在被用户删空」才算显式清空;否则宿主会拒绝写入空数组
|
|
7061
|
+
...(source.targets.length === 0 && loadedCountRef.current > 0 ? { clearTargets: true } : {}),
|
|
7062
|
+
})
|
|
7063
|
+
|
|
5991
7064
|
const save = () => {
|
|
5992
7065
|
setSaving(true)
|
|
5993
7066
|
setMessage({ kind: '', text: '' })
|
|
@@ -6000,36 +7073,7 @@ window.__ModuleLoader__.load({
|
|
|
6000
7073
|
* 记下发出时的数组引用,响应里按长度前缀切掉即可。
|
|
6001
7074
|
*/
|
|
6002
7075
|
const sentRemovals = removedHostKeysRef.current
|
|
6003
|
-
const payload =
|
|
6004
|
-
enabled: form.enabled,
|
|
6005
|
-
announceToAgent: form.announceToAgent,
|
|
6006
|
-
dockerBin: form.dockerBin,
|
|
6007
|
-
allowMutations: form.allowMutations,
|
|
6008
|
-
allowExec: form.allowExec,
|
|
6009
|
-
execTimeoutSec: form.execTimeoutSec,
|
|
6010
|
-
pollIntervalSec: form.pollIntervalSec,
|
|
6011
|
-
logTailDefault: form.logTailDefault,
|
|
6012
|
-
maxOutputKb: form.maxOutputKb,
|
|
6013
|
-
targets: form.targets.map((item) => ({
|
|
6014
|
-
name: item.name,
|
|
6015
|
-
kind: item.kind,
|
|
6016
|
-
book: item.book ?? '',
|
|
6017
|
-
host: item.host ?? '',
|
|
6018
|
-
port: Number(item.port) || 22,
|
|
6019
|
-
username: item.username ?? '',
|
|
6020
|
-
auth: item.auth ?? 'agent',
|
|
6021
|
-
keyPath: item.keyPath ?? '',
|
|
6022
|
-
// 空字符串 = 保留原有凭证(宿主按 undefined 处理会覆盖为空,这里显式跳过)
|
|
6023
|
-
...(item.password === undefined || item.password === '' ? {} : { password: item.password }),
|
|
6024
|
-
...(item.passphrase === undefined || item.passphrase === '' ? {} : { passphrase: item.passphrase }),
|
|
6025
|
-
agentForward: item.agentForward === true,
|
|
6026
|
-
})),
|
|
6027
|
-
// hostKeys 不整表回传(D10):服务端并集合并,运行期新增的钉扎不会被
|
|
6028
|
-
// 表单快照冲掉;删除某条记录走显式 hostKeysRemove
|
|
6029
|
-
...(sentRemovals.length > 0 ? { hostKeysRemove: sentRemovals } : {}),
|
|
6030
|
-
// 只有「加载到过非空目标、现在被用户删空」才算显式清空;否则宿主会拒绝写入空数组
|
|
6031
|
-
...(form.targets.length === 0 && loadedCountRef.current > 0 ? { clearTargets: true } : {}),
|
|
6032
|
-
}
|
|
7076
|
+
const payload = toPayload(form)
|
|
6033
7077
|
api.saveConfig(payload).then((response) => {
|
|
6034
7078
|
/*
|
|
6035
7079
|
* 只清「已经发出」的那批(D82):按发出时的长度切掉前缀,飞行期间新入队的
|
|
@@ -6040,19 +7084,317 @@ window.__ModuleLoader__.load({
|
|
|
6040
7084
|
primeTargetsCache(response.config)
|
|
6041
7085
|
publishConfig(response.config)
|
|
6042
7086
|
loadedCountRef.current = Array.isArray(response.config?.targets) ? response.config.targets.length : 0
|
|
7087
|
+
cleanPayloadRef.current = JSON.stringify(toPayload(toCardForm(response.config)))
|
|
6043
7088
|
// 请求飞行期间的编辑不能被服务端快照整表回滚(D22):只在表单没有新改动时同步
|
|
6044
|
-
if (JSON.stringify(formRef.current) === formSnapshot) setForm(response.config)
|
|
7089
|
+
if (JSON.stringify(formRef.current) === formSnapshot) setForm(toCardForm(response.config))
|
|
6045
7090
|
// 目标增删会影响 tty 连接栏按钮:刷新解析后的目标列表
|
|
6046
7091
|
void refreshTargetsCache()
|
|
6047
7092
|
setMessage(response.warning === undefined
|
|
6048
|
-
? { kind: 'ok', text: JSON.stringify(formRef.current) === formSnapshot ? '
|
|
7093
|
+
? { kind: 'ok', text: JSON.stringify(formRef.current) === formSnapshot ? t('msg.saved') : t('msg.savedDirty') }
|
|
6049
7094
|
: { kind: 'error', text: response.warning })
|
|
6050
7095
|
}).catch((error) => {
|
|
6051
|
-
setMessage({ kind: 'error', text: '
|
|
7096
|
+
setMessage({ kind: 'error', text: t('error.saveFailed') + error.message })
|
|
6052
7097
|
}).finally(() => setSaving(false))
|
|
6053
7098
|
}
|
|
6054
7099
|
|
|
7100
|
+
/* ---------------- 就地提权(capability elevation) ---------------- */
|
|
7101
|
+
|
|
7102
|
+
/** 能力开关名 → 宿主侧环境变量名(「另一种方式」那一栏要写出具体变量名)。 */
|
|
7103
|
+
const CAPABILITY_ENV = {
|
|
7104
|
+
allowMutations: 'DSH_DOCKER_ALLOW_MUTATIONS',
|
|
7105
|
+
allowExec: 'DSH_DOCKER_ALLOW_EXEC',
|
|
7106
|
+
}
|
|
7107
|
+
|
|
7108
|
+
/**
|
|
7109
|
+
* 快照 → 表单:能力开关在表单里存**配置值**(用户写下的那个),不是快照里的有效值。
|
|
7110
|
+
*
|
|
7111
|
+
* 为什么必须换:有效值 = 配置 && 已授权。配置开着但没授权时有效值是 false,若拿它当表单值,
|
|
7112
|
+
* 用户一按保存就把配置关掉了——而界面还显示着「未生效」,用户完全看不出发生了什么。
|
|
7113
|
+
*/
|
|
7114
|
+
const toCardForm = (config) => ({
|
|
7115
|
+
...config,
|
|
7116
|
+
allowMutations: config.allowMutationsConfigured === true,
|
|
7117
|
+
allowExec: config.allowExecConfigured === true,
|
|
7118
|
+
})
|
|
7119
|
+
|
|
7120
|
+
/** 只刷新与授权有关的那几个只读字段(**不动**用户正在编辑的字段)。 */
|
|
7121
|
+
const refreshGrantState = () => {
|
|
7122
|
+
api.config().then((payload) => {
|
|
7123
|
+
primeTargetsCache(payload.config)
|
|
7124
|
+
publishConfig(payload.config)
|
|
7125
|
+
setForm((current) => (current === null ? current : {
|
|
7126
|
+
...current,
|
|
7127
|
+
allowMutationsGranted: payload.config.allowMutationsGranted === true,
|
|
7128
|
+
allowMutationsGrantSource: payload.config.allowMutationsGrantSource ?? null,
|
|
7129
|
+
allowMutationsGrantedAt: Number.isFinite(payload.config.allowMutationsGrantedAt) ? payload.config.allowMutationsGrantedAt : null,
|
|
7130
|
+
allowExecGranted: payload.config.allowExecGranted === true,
|
|
7131
|
+
allowExecGrantSource: payload.config.allowExecGrantSource ?? null,
|
|
7132
|
+
allowExecGrantedAt: Number.isFinite(payload.config.allowExecGrantedAt) ? payload.config.allowExecGrantedAt : null,
|
|
7133
|
+
}))
|
|
7134
|
+
}).catch(() => {
|
|
7135
|
+
/* 刷新失败静默:卡片本身照旧可用,下一次加载会同步 */
|
|
7136
|
+
})
|
|
7137
|
+
}
|
|
7138
|
+
|
|
7139
|
+
/**
|
|
7140
|
+
* 授权到了:把开关写进表单、刷新授权状态,并在「表单本来是干净的」时顺手保存。
|
|
7141
|
+
*
|
|
7142
|
+
* 自动保存只在干净表单下做:保存是**整表**提交,脏表单下自动保存会把用户没打算提交的编辑
|
|
7143
|
+
* 一起写进去。脏的时候只把开关置上并提示「点保存生效」——不替用户做决定。
|
|
7144
|
+
*/
|
|
7145
|
+
const finishElevation = (capability) => {
|
|
7146
|
+
setElevation(null)
|
|
7147
|
+
setCopied(false)
|
|
7148
|
+
const clean = JSON.stringify(toPayload(formRef.current)) === cleanPayloadRef.current
|
|
7149
|
+
setForm((current) => (current === null ? current : { ...current, [capability]: true }))
|
|
7150
|
+
setElevationNotice(clean ? t('elev.granted') : t('elev.grantedNeedSave'))
|
|
7151
|
+
if (clean) setAutoSavePending(true)
|
|
7152
|
+
refreshGrantState()
|
|
7153
|
+
}
|
|
7154
|
+
|
|
7155
|
+
const beginElevation = (capability) => {
|
|
7156
|
+
setElevationNotice('')
|
|
7157
|
+
setCopied(false)
|
|
7158
|
+
setElevation({ capability })
|
|
7159
|
+
api.elevateBegin(capability).then((result) => {
|
|
7160
|
+
if (result !== null && result.status === 'granted') {
|
|
7161
|
+
finishElevation(capability)
|
|
7162
|
+
return
|
|
7163
|
+
}
|
|
7164
|
+
if (result !== null && result.status === 'pending') {
|
|
7165
|
+
setElevation({ capability, command: String(result.command), expiresAt: Number(result.expiresAt) })
|
|
7166
|
+
return
|
|
7167
|
+
}
|
|
7168
|
+
setElevation({ capability, error: String((result && result.error) ?? '') })
|
|
7169
|
+
}).catch((error) => setElevation({ capability, error: String(error.message) }))
|
|
7170
|
+
}
|
|
7171
|
+
|
|
7172
|
+
const revokeElevation = (capability) => {
|
|
7173
|
+
setElevationNotice('')
|
|
7174
|
+
api.elevateRevoke(capability).then(() => {
|
|
7175
|
+
setElevationNotice(t('elev.revoked'))
|
|
7176
|
+
refreshGrantState()
|
|
7177
|
+
}).catch((error) => setElevationNotice(t('elev.error') + String(error.message)))
|
|
7178
|
+
}
|
|
7179
|
+
|
|
7180
|
+
/** 复制命令:拿不到剪贴板(老宿主 / 非安全上下文)时退回「请手动复制」。 */
|
|
7181
|
+
const copyCommand = (command) => {
|
|
7182
|
+
const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard
|
|
7183
|
+
if (clipboard === undefined || typeof clipboard.writeText !== 'function') {
|
|
7184
|
+
setElevationNotice(t('elev.copyManual'))
|
|
7185
|
+
return
|
|
7186
|
+
}
|
|
7187
|
+
clipboard.writeText(command).then(() => setCopied(true)).catch(() => setElevationNotice(t('elev.copyManual')))
|
|
7188
|
+
}
|
|
7189
|
+
|
|
7190
|
+
/*
|
|
7191
|
+
* 授权成功后的自动保存:必须等表单提交一次再保存——`save()` 读的是表单镜像
|
|
7192
|
+
* (`formRef.current`,在渲染期同步),在同一个 tick 里调它写的是**改之前**的表单。
|
|
7193
|
+
*/
|
|
7194
|
+
saveRef.current = save
|
|
7195
|
+
useEffect(() => {
|
|
7196
|
+
if (!autoSavePending) return
|
|
7197
|
+
setAutoSavePending(false)
|
|
7198
|
+
if (saveRef.current !== null) saveRef.current()
|
|
7199
|
+
}, [autoSavePending])
|
|
7200
|
+
|
|
7201
|
+
/*
|
|
7202
|
+
* 面板开着时轮询授权状态(1.5s)并顺带推进倒计时。轮询而不是等推送:这条通道本来就是
|
|
7203
|
+
* 「用户在别的终端里做了一件事」,宿主没有可订阅的事件面。
|
|
7204
|
+
*/
|
|
7205
|
+
const elevationCapability = elevation === null ? '' : String(elevation.capability)
|
|
7206
|
+
useEffect(() => {
|
|
7207
|
+
if (elevationCapability === '') return undefined
|
|
7208
|
+
const timer = setInterval(() => {
|
|
7209
|
+
setNowTick((value) => value + 1)
|
|
7210
|
+
api.elevateStatus(elevationCapability).then((status) => {
|
|
7211
|
+
if (status !== null && status.status === 'granted') finishElevation(elevationCapability)
|
|
7212
|
+
}).catch(() => {
|
|
7213
|
+
/* 轮询失败静默:宿主可能在重启,下一次再试 */
|
|
7214
|
+
})
|
|
7215
|
+
}, 1500)
|
|
7216
|
+
return () => clearInterval(timer)
|
|
7217
|
+
}, [elevationCapability])
|
|
7218
|
+
|
|
7219
|
+
/* ---------------- 能力开关那一段的渲染 ---------------- */
|
|
7220
|
+
|
|
7221
|
+
/**
|
|
7222
|
+
* 一个能力开关 + 「未生效」徽标。返回**两个**节点:复选框与文字标签。
|
|
7223
|
+
*
|
|
7224
|
+
* 未获授权时点它 = **发起就地授权**(展开面板给出确认命令),不是「点一下弹一句」的循环,
|
|
7225
|
+
* 也**不是**把「看着开着」的配置关掉——用户点它是想说「我要它真的生效」。要关掉配置,
|
|
7226
|
+
* 面板里有单独的「先关掉这个配置开关」。
|
|
7227
|
+
*
|
|
7228
|
+
* 复选框与文字拆成两格(见 `capabilityRow` 的网格):授权状态行要落在**文字**下面,而不是
|
|
7229
|
+
* 落在复选框下面——那需要一个「文字左边缘」的参照,用 `padding` 猜一个像素值会随字号与平台
|
|
7230
|
+
* 漂,两列网格是同一件事的确定写法(`htmlFor` 保住「点文字也能勾」)。
|
|
7231
|
+
*/
|
|
7232
|
+
const capabilityCheck = (capability, label, id) => {
|
|
7233
|
+
const configured = form[capability] === true
|
|
7234
|
+
const granted = form[`${capability}Granted`] === true
|
|
7235
|
+
return [
|
|
7236
|
+
jsx('input', {
|
|
7237
|
+
id,
|
|
7238
|
+
type: 'checkbox',
|
|
7239
|
+
checked: configured,
|
|
7240
|
+
onChange: (event) => {
|
|
7241
|
+
if (granted !== true) {
|
|
7242
|
+
beginElevation(capability)
|
|
7243
|
+
return
|
|
7244
|
+
}
|
|
7245
|
+
patch({ [capability]: event.target.checked })
|
|
7246
|
+
},
|
|
7247
|
+
}, 'box'),
|
|
7248
|
+
jsxs('label', { className: 'dk_capLabel', htmlFor: id, children: [
|
|
7249
|
+
label,
|
|
7250
|
+
configured && !granted
|
|
7251
|
+
? jsx('span', { className: 'dk_badge', 'data-state': 'paused', children: t('badge.notEffective') })
|
|
7252
|
+
: null,
|
|
7253
|
+
] }, 'label'),
|
|
7254
|
+
]
|
|
7255
|
+
}
|
|
7256
|
+
|
|
7257
|
+
/**
|
|
7258
|
+
* 授权时刻 → **固定格式** `YYYY-MM-DD HH:mm:ss`(本地时区)。
|
|
7259
|
+
*
|
|
7260
|
+
* 为什么不用 `toLocaleString()`:它给的是 `2026/9/26 22:37:30` 这种斜杠 + 不补零的形状,
|
|
7261
|
+
* 两个能力的时刻并排时既不好扫读、也不跟宿主日志里的 ISO 时刻对得上(审计要能对照)。
|
|
7262
|
+
* 秒保留:与日志的 `grantedAt=` 逐字段对得上,比省两个字符值。
|
|
7263
|
+
*/
|
|
7264
|
+
const formatGrantedAt = (seconds) => {
|
|
7265
|
+
const date = new Date(Number(seconds) * 1000)
|
|
7266
|
+
if (Number.isNaN(date.getTime())) return String(seconds)
|
|
7267
|
+
const pad = (value) => String(value).padStart(2, '0')
|
|
7268
|
+
return `${String(date.getFullYear())}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
|
7269
|
+
}
|
|
7270
|
+
|
|
7271
|
+
/**
|
|
7272
|
+
* 一个能力**自己的**授权状态行(跟在这个能力的开关后面,而不是两个能力合排一行)。
|
|
7273
|
+
*
|
|
7274
|
+
* 为什么必须贴着各自的开关:`file` 通道的授权是**逐能力**的,两个「撤销宿主授权」并排放在
|
|
7275
|
+
* 一行里时,没人能看出哪个按钮撤的是哪个能力——那是「界面说了但没说清」的典型。
|
|
7276
|
+
*
|
|
7277
|
+
* 顺带带上**授权时刻**(kit D09):授权是持久的,重启后它直接生效、不再有任何确认,用户至少
|
|
7278
|
+
* 该看得出它是什么时候来的。`*GrantSource === 'env'` 没有时刻(启动环境没有「授权时刻」),
|
|
7279
|
+
* 那一档给的是「去改启动环境」的说明,不给按钮——环境变量授权撤不了。
|
|
7280
|
+
*/
|
|
7281
|
+
const grantState = (capability) => {
|
|
7282
|
+
if (form[`${capability}Granted`] !== true) return null
|
|
7283
|
+
if (form[`${capability}GrantSource`] === 'env') {
|
|
7284
|
+
return jsx('span', { className: 'dk_capState', children: t('elev.viaEnv') })
|
|
7285
|
+
}
|
|
7286
|
+
const grantedAt = form[`${capability}GrantedAt`]
|
|
7287
|
+
return jsxs('span', { className: 'dk_capState', children: [
|
|
7288
|
+
Number.isFinite(grantedAt)
|
|
7289
|
+
? jsx('span', { children: t('elev.grantedAt', { time: formatGrantedAt(grantedAt) }) }, 'at')
|
|
7290
|
+
: null,
|
|
7291
|
+
/*
|
|
7292
|
+
* 危险色(`dk_btnDanger`,D148):与同一个卡片里的「删除目标 / 删除指纹」同色——
|
|
7293
|
+
* 三者都是「按下去就少一条记录、要重新配回来」的动作,红/灰混用会让人以为撤销可随手点。
|
|
7294
|
+
* tty 卡片那份(`tt_capRevoke`)本来就是危险色,两处必须一致(用户反馈:为什么风格不统一)。
|
|
7295
|
+
*/
|
|
7296
|
+
jsx('button', {
|
|
7297
|
+
type: 'button',
|
|
7298
|
+
className: 'dk_btn dk_btnDanger dk_capRevoke',
|
|
7299
|
+
onClick: () => revokeElevation(capability),
|
|
7300
|
+
children: t('elev.revoke'),
|
|
7301
|
+
}, 'revoke'),
|
|
7302
|
+
] })
|
|
7303
|
+
}
|
|
7304
|
+
|
|
7305
|
+
/**
|
|
7306
|
+
* 一个能力一块:`[复选框] [开关文字]` 一行,授权状态**另起一行、与文字左对齐**。
|
|
7307
|
+
*
|
|
7308
|
+
* 为什么不做成「一行内右对齐」(试过,实测不合格):标签长度随语言变(中文「允许变更操作
|
|
7309
|
+
* (容器启停删、镜像拉取 / 删除 / 清理)」313px、英文更长),一行里塞「文字 + 状态 + 按钮」
|
|
7310
|
+
* 必然有的行换行、有的不换行——实测:短标签那行按钮右缘 1020,长标签那行整组被挤到下一行、
|
|
7311
|
+
* 右缘只有 812(按钮的 `margin-left:auto` 生效在状态 span **内部**,推不到行右缘)。
|
|
7312
|
+
* 状态另起一行后,换行不再取决于标签长度;按钮落在第二列上(状态撑满该列 + 按钮自动右推),
|
|
7313
|
+
* 两个能力的按钮天然在同一竖线上。
|
|
7314
|
+
*/
|
|
7315
|
+
const capabilityRow = (capability, label) => {
|
|
7316
|
+
const id = `dk-cap-${capability}`
|
|
7317
|
+
return jsxs('div', { className: 'dk_capRow', children: [
|
|
7318
|
+
...capabilityCheck(capability, label, id),
|
|
7319
|
+
grantState(capability),
|
|
7320
|
+
] })
|
|
7321
|
+
}
|
|
7322
|
+
|
|
7323
|
+
/** 就地提权面板(内联展开;不用 confirm()——那会在 React 之外同步阻塞渲染)。 */
|
|
7324
|
+
const elevationPanel = () => {
|
|
7325
|
+
const capability = String(elevation.capability)
|
|
7326
|
+
const label = capability === 'allowMutations' ? t('check.allowMutations') : t('check.allowExec')
|
|
7327
|
+
const seconds = elevation.expiresAt === undefined
|
|
7328
|
+
? 0
|
|
7329
|
+
: Math.max(0, Math.ceil((Number(elevation.expiresAt) - Date.now()) / 1000))
|
|
7330
|
+
const close = () => {
|
|
7331
|
+
setElevation(null)
|
|
7332
|
+
setCopied(false)
|
|
7333
|
+
}
|
|
7334
|
+
return jsxs('div', { className: 'dk_elevPanel', children: [
|
|
7335
|
+
jsxs('div', { className: 'dk_row', children: [
|
|
7336
|
+
jsx('span', { className: 'dk_label', children: t('elev.title') + ' · ' + label }),
|
|
7337
|
+
jsx('button', { type: 'button', className: 'dk_btn', onClick: close, children: t('elev.close') }, 'close'),
|
|
7338
|
+
] }),
|
|
7339
|
+
jsx('span', { className: 'dk_hint', children: t('elev.lockedWhy') }),
|
|
7340
|
+
elevation.error === undefined
|
|
7341
|
+
? null
|
|
7342
|
+
: jsx('span', { className: 'dk_hint dk_hintWarn', children: t('elev.error') + String(elevation.error) }),
|
|
7343
|
+
elevation.command === undefined
|
|
7344
|
+
? null
|
|
7345
|
+
: jsxs('div', { className: 'dk_elevSteps', children: [
|
|
7346
|
+
jsx('span', { className: 'dk_hint', children: t('elev.step') }),
|
|
7347
|
+
jsx('code', { className: 'dk_elevCommand', children: String(elevation.command) }),
|
|
7348
|
+
/*
|
|
7349
|
+
* 动作行:复制 / 重新生成 / 倒计时**并排一行、紧贴命令**。
|
|
7350
|
+
*
|
|
7351
|
+
* 早先这三样各自占一条栅格行,而且「重新生成」被「282 秒后失效」「等待确认…」两行
|
|
7352
|
+
* 说明文字隔在下面——2026-09-27 的真机报告(Windows)里那块看上去就是散的:
|
|
7353
|
+
* 按钮离自己的命令越远,越像「不知道点了会发生什么」。命令与它的按钮是一件事(docker D149)。
|
|
7354
|
+
*/
|
|
7355
|
+
jsxs('div', { className: 'dk_elevActions', children: [
|
|
7356
|
+
jsx('button', {
|
|
7357
|
+
type: 'button',
|
|
7358
|
+
className: 'dk_btn dk_btnPrimary',
|
|
7359
|
+
onClick: () => copyCommand(String(elevation.command)),
|
|
7360
|
+
children: copied ? t('elev.copied') : t('elev.copy'),
|
|
7361
|
+
}, 'copy'),
|
|
7362
|
+
jsx('button', {
|
|
7363
|
+
type: 'button',
|
|
7364
|
+
className: 'dk_btn',
|
|
7365
|
+
onClick: () => beginElevation(capability),
|
|
7366
|
+
children: t('elev.regenerate'),
|
|
7367
|
+
}, 'regen'),
|
|
7368
|
+
jsx('span', {
|
|
7369
|
+
className: seconds > 0 ? 'dk_hint' : 'dk_hint dk_hintWarn',
|
|
7370
|
+
children: seconds > 0 ? t('elev.expiresIn', { sec: seconds }) : t('elev.expired'),
|
|
7371
|
+
}),
|
|
7372
|
+
] }),
|
|
7373
|
+
jsx('span', { className: 'dk_hint', children: t('elev.waiting') }),
|
|
7374
|
+
jsxs('details', { className: 'dk_elevOther', children: [
|
|
7375
|
+
jsx('summary', { children: t('elev.otherWay') }),
|
|
7376
|
+
jsx('span', {
|
|
7377
|
+
className: 'dk_hint',
|
|
7378
|
+
children: t('elev.envHow', { env: CAPABILITY_ENV[capability] ?? '' }),
|
|
7379
|
+
}),
|
|
7380
|
+
] }),
|
|
7381
|
+
] }),
|
|
7382
|
+
form[capability] === true
|
|
7383
|
+
? jsx('button', {
|
|
7384
|
+
type: 'button',
|
|
7385
|
+
className: 'dk_btn',
|
|
7386
|
+
onClick: () => {
|
|
7387
|
+
patch({ [capability]: false })
|
|
7388
|
+
close()
|
|
7389
|
+
},
|
|
7390
|
+
children: t('elev.disableFirst'),
|
|
7391
|
+
}, 'disable')
|
|
7392
|
+
: null,
|
|
7393
|
+
] })
|
|
7394
|
+
}
|
|
7395
|
+
|
|
6055
7396
|
const sectionTitle = (text) => jsx('div', { className: 'dk_cardSection', children: text })
|
|
7397
|
+
|
|
6056
7398
|
const field = (label, control, hint, span) => jsxs('div', {
|
|
6057
7399
|
className: 'dk_field',
|
|
6058
7400
|
'data-span': span === undefined ? undefined : String(span),
|
|
@@ -6091,7 +7433,7 @@ window.__ModuleLoader__.load({
|
|
|
6091
7433
|
})
|
|
6092
7434
|
|
|
6093
7435
|
if (view === 'summary') {
|
|
6094
|
-
return '
|
|
7436
|
+
return t('card.desc')
|
|
6095
7437
|
}
|
|
6096
7438
|
|
|
6097
7439
|
/*
|
|
@@ -6112,8 +7454,8 @@ window.__ModuleLoader__.load({
|
|
|
6112
7454
|
onClick: () => setOpen((value) => !value),
|
|
6113
7455
|
children: [
|
|
6114
7456
|
jsxs('span', { className: 'dk_settingsHeadText', children: [
|
|
6115
|
-
jsx('span', { className: 'dk_settingsName', children: '
|
|
6116
|
-
jsx('span', { className: 'dk_settingsDesc', children: '
|
|
7457
|
+
jsx('span', { className: 'dk_settingsName', children: t('card.name') }),
|
|
7458
|
+
jsx('span', { className: 'dk_settingsDesc', children: t('card.summary') }),
|
|
6117
7459
|
] }),
|
|
6118
7460
|
jsx('span', { className: 'dshkit_badge', children: 'Kit' }),
|
|
6119
7461
|
jsx('span', { className: 'dk_settingsChevron', dangerouslySetInnerHTML: { __html: ICON_CHEVRON } }),
|
|
@@ -6125,32 +7467,46 @@ window.__ModuleLoader__.load({
|
|
|
6125
7467
|
|
|
6126
7468
|
if (!open) return card(null)
|
|
6127
7469
|
if (!loaded || form === null) {
|
|
6128
|
-
return card(jsxs('div', { className: 'dk_row', children: [jsx('span', { className: 'dk_spin' }), '
|
|
7470
|
+
return card(jsxs('div', { className: 'dk_row', children: [jsx('span', { className: 'dk_spin' }), t('list.loadingConfig')] }))
|
|
6129
7471
|
}
|
|
6130
7472
|
|
|
6131
7473
|
return card([
|
|
6132
|
-
sectionTitle('
|
|
7474
|
+
sectionTitle(t('section.basic')),
|
|
6133
7475
|
jsxs('div', { className: 'dk_row', children: [
|
|
6134
|
-
jsx('label', { className: 'dk_check', children: [jsx('input', { type: 'checkbox', checked: form.enabled, onChange: (event) => patch({ enabled: event.target.checked }) }), '
|
|
6135
|
-
jsx('label', { className: 'dk_check', children: [jsx('input', { type: 'checkbox', checked: form.announceToAgent, onChange: (event) => patch({ announceToAgent: event.target.checked }) }), '
|
|
7476
|
+
jsx('label', { className: 'dk_check', children: [jsx('input', { type: 'checkbox', checked: form.enabled, onChange: (event) => patch({ enabled: event.target.checked }) }), t('check.enabled')] }),
|
|
7477
|
+
jsx('label', { className: 'dk_check', children: [jsx('input', { type: 'checkbox', checked: form.announceToAgent, onChange: (event) => patch({ announceToAgent: event.target.checked }) }), t('check.announce')] }),
|
|
6136
7478
|
] }),
|
|
6137
7479
|
// 字段用栅格而不是 flex 换行:标签 + 控件按列对齐,数字框宽度一致
|
|
6138
7480
|
jsxs('div', { className: 'dk_fieldGrid', children: [
|
|
6139
|
-
field('docker CLI', jsx('input', { className: 'dk_input', value: form.dockerBin, onChange: (event) => patch({ dockerBin: event.target.value }) }), '
|
|
6140
|
-
field('
|
|
6141
|
-
field('
|
|
6142
|
-
field('
|
|
6143
|
-
field('
|
|
7481
|
+
field('docker CLI', jsx('input', { className: 'dk_input', value: form.dockerBin, onChange: (event) => patch({ dockerBin: event.target.value }) }), t('hint.dockerBin')),
|
|
7482
|
+
field(t('field.pollInterval'), numberInput('pollIntervalSec', 1, 60)),
|
|
7483
|
+
field(t('field.logTailDefault'), numberInput('logTailDefault', 1, 5000), t('hint.logTailDefault')),
|
|
7484
|
+
field(t('field.maxOutput'), numberInput('maxOutputKb', 1, 8192), t('hint.maxOutput')),
|
|
7485
|
+
field(t('field.execTimeout'), numberInput('execTimeoutSec', 1, 120)),
|
|
6144
7486
|
] }),
|
|
6145
7487
|
|
|
6146
|
-
sectionTitle('
|
|
6147
|
-
|
|
6148
|
-
|
|
6149
|
-
|
|
7488
|
+
sectionTitle(t('section.capabilities')),
|
|
7489
|
+
/*
|
|
7490
|
+
* 两个开关**不再 disabled**。未获宿主授权时点它就地发起授权(见 capabilityCheck);
|
|
7491
|
+
* 已授权时它就是普通开关,关掉随时可用(宿主侧不拦降权:紧急刹车不能依赖重启)。
|
|
7492
|
+
*
|
|
7493
|
+
* **一个能力一行**(不是两个开关并排 + 底下一行授权状态):授权是逐能力的,并排时两个
|
|
7494
|
+
* 「撤销宿主授权」谁管谁全靠猜。行内是「开关 … 状态 …(右)撤销按钮」,扫一眼就对上。
|
|
7495
|
+
*/
|
|
7496
|
+
jsxs('div', { className: 'dk_capList', children: [
|
|
7497
|
+
capabilityRow('allowMutations', t('check.allowMutations')),
|
|
7498
|
+
capabilityRow('allowExec', t('check.allowExec')),
|
|
6150
7499
|
] }),
|
|
6151
|
-
jsx('span', { className: 'dk_hint', children: '
|
|
6152
|
-
|
|
6153
|
-
|
|
7500
|
+
jsx('span', { className: 'dk_hint', children: t('hint.socketRoot') }),
|
|
7501
|
+
form.allowMutationsGranted === true && form.allowExecGranted === true
|
|
7502
|
+
? null
|
|
7503
|
+
: jsx('span', { className: 'dk_hint dk_hintWarn', children: t('hint.capabilityNotGranted') }),
|
|
7504
|
+
elevationNotice === ''
|
|
7505
|
+
? null
|
|
7506
|
+
: jsx('span', { className: 'dk_hint', children: elevationNotice }),
|
|
7507
|
+
elevation === null ? null : elevationPanel(),
|
|
7508
|
+
|
|
7509
|
+
sectionTitle(t('field.target')),
|
|
6154
7510
|
...form.targets.map((item, index) => {
|
|
6155
7511
|
/*
|
|
6156
7512
|
* 失效引用(引用的条目名不在 tty 连接簿里):下拉会**渲染成空白**——没有任何
|
|
@@ -6160,58 +7516,58 @@ window.__ModuleLoader__.load({
|
|
|
6160
7516
|
*/
|
|
6161
7517
|
const stale = staleBookRef(item, form.ttyBooks)
|
|
6162
7518
|
return jsxs('div', { className: 'dk_targetRow', 'data-stale': stale !== undefined ? '1' : undefined, children: [
|
|
6163
|
-
jsx('input', { className: 'dk_input', value: item.name, placeholder: '
|
|
7519
|
+
jsx('input', { className: 'dk_input', value: item.name, placeholder: t('placeholder.targetName'), onChange: (event) => patchTarget(index, { name: event.target.value }) }),
|
|
6164
7520
|
jsx('select', { className: 'dk_select', value: item.kind, onChange: (event) => patchTarget(index, { kind: event.target.value }), children: [
|
|
6165
|
-
jsx('option', { value: 'local', children: '
|
|
6166
|
-
jsx('option', { value: 'ssh', children: '
|
|
7521
|
+
jsx('option', { value: 'local', children: t('option.local') }),
|
|
7522
|
+
jsx('option', { value: 'ssh', children: t('option.sshHost') }),
|
|
6167
7523
|
] }),
|
|
6168
7524
|
item.kind === 'local'
|
|
6169
|
-
? jsx('span', { className: 'dk_hint', children: '
|
|
7525
|
+
? jsx('span', { className: 'dk_hint', children: t('hint.localTarget') })
|
|
6170
7526
|
: jsxs('div', { className: 'dk_row', style: { gridColumn: 'span 1' }, children: [
|
|
6171
7527
|
jsx('select', {
|
|
6172
7528
|
className: 'dk_select',
|
|
6173
7529
|
value: item.book ?? '',
|
|
6174
7530
|
onChange: (event) => patchTarget(index, { book: event.target.value }),
|
|
6175
|
-
title: stale !== undefined ?
|
|
7531
|
+
title: stale !== undefined ? t('hint.staleBook', { book: stale }) : undefined,
|
|
6176
7532
|
children: [
|
|
6177
|
-
jsx('option', { value: '', children: form.ttyBooks.length === 0 ? '
|
|
7533
|
+
jsx('option', { value: '', children: form.ttyBooks.length === 0 ? t('option.noTtyBooks') : t('option.inlineConnection') }),
|
|
6178
7534
|
// 失效的名字排在最前并显式标注:否则它没有对应 option,下拉显示空白
|
|
6179
|
-
...(stale !== undefined ? [jsx('option', { value: stale, children: '
|
|
6180
|
-
...form.ttyBooks.map((name) => jsx('option', { value: name, children: '
|
|
7535
|
+
...(stale !== undefined ? [jsx('option', { value: stale, children: t('option.staleBook') + stale }, stale)] : []),
|
|
7536
|
+
...form.ttyBooks.map((name) => jsx('option', { value: name, children: t('option.bookNamed', { name }) }, name)),
|
|
6181
7537
|
],
|
|
6182
7538
|
}),
|
|
6183
7539
|
stale !== undefined
|
|
6184
|
-
? jsx('span', { className: 'dk_hint dk_hintWarn', children:
|
|
7540
|
+
? jsx('span', { className: 'dk_hint dk_hintWarn', children: t('hint.staleInline', { book: stale }) })
|
|
6185
7541
|
: null,
|
|
6186
7542
|
] }),
|
|
6187
|
-
jsx('button', { type: 'button', className: 'dk_btn dk_btnDanger', onClick: () => removeTarget(index), children: '
|
|
7543
|
+
jsx('button', { type: 'button', className: 'dk_btn dk_btnDanger', onClick: () => removeTarget(index), children: t('btn.delete') }),
|
|
6188
7544
|
/*
|
|
6189
7545
|
* 内联 SSH 字段:栅格而不是 flex 换行——host / port / username / auth 与上方
|
|
6190
7546
|
* 目标名那行上下对齐,凭据(私钥或密码)单独占一行,复选框固定在行尾列。
|
|
6191
7547
|
*/
|
|
6192
7548
|
item.kind === 'ssh' && (item.book ?? '') === '' ? jsxs('div', { className: 'dk_targetInline', children: [
|
|
6193
7549
|
jsx('input', { className: 'dk_input', placeholder: 'host', value: item.host ?? '', onChange: (event) => patchTarget(index, { host: event.target.value }) }),
|
|
6194
|
-
jsx('input', { className: 'dk_input', placeholder: '22', title: '
|
|
7550
|
+
jsx('input', { className: 'dk_input', placeholder: '22', title: t('field.ports'), value: item.port ?? 22, onChange: (event) => patchTarget(index, { port: Number(event.target.value) || 22 }) }),
|
|
6195
7551
|
jsx('input', { className: 'dk_input', placeholder: 'username', value: item.username ?? '', onChange: (event) => patchTarget(index, { username: event.target.value }) }),
|
|
6196
7552
|
jsx('select', { className: 'dk_select', value: item.auth ?? 'agent', onChange: (event) => patchTarget(index, { auth: event.target.value }), children: [
|
|
6197
7553
|
jsx('option', { value: 'agent', children: 'ssh-agent' }),
|
|
6198
|
-
jsx('option', { value: 'key', children: '
|
|
6199
|
-
jsx('option', { value: 'password', children: '
|
|
7554
|
+
jsx('option', { value: 'key', children: t('option.authKey') }),
|
|
7555
|
+
jsx('option', { value: 'password', children: t('option.authPassword') }),
|
|
6200
7556
|
] }),
|
|
6201
|
-
(item.auth ?? 'agent') === 'key' ? jsx('input', { className: 'dk_input dk_credential', placeholder: '~/.ssh/id_ed25519', title: '
|
|
6202
|
-
(item.auth ?? 'agent') === 'password' ? jsx('input', { className: 'dk_input dk_credential', type: 'password', placeholder: item.passwordSet === true ? '
|
|
7557
|
+
(item.auth ?? 'agent') === 'key' ? jsx('input', { className: 'dk_input dk_credential', placeholder: '~/.ssh/id_ed25519', title: t('hint.keyPath'), value: item.keyPath ?? '', onChange: (event) => patchTarget(index, { keyPath: event.target.value }) }) : null,
|
|
7558
|
+
(item.auth ?? 'agent') === 'password' ? jsx('input', { className: 'dk_input dk_credential', type: 'password', placeholder: item.passwordSet === true ? t('placeholder.passwordSet') : 'env:SSH_PASSWORD', value: item.password ?? '', onChange: (event) => patchTarget(index, { password: event.target.value }) }) : null,
|
|
6203
7559
|
jsx('label', { className: 'dk_check', children: [jsx('input', { type: 'checkbox', checked: item.agentForward === true, onChange: (event) => patchTarget(index, { agentForward: event.target.checked }) }), 'agent forwarding'] }),
|
|
6204
7560
|
] }) : null,
|
|
6205
7561
|
] }, String(index))
|
|
6206
7562
|
}),
|
|
6207
7563
|
jsxs('div', { className: 'dk_row', children: [
|
|
6208
|
-
jsx('button', { type: 'button', className: 'dk_btn', onClick: addTarget, children: '
|
|
6209
|
-
jsx('span', { className: 'dk_hint', children: '
|
|
7564
|
+
jsx('button', { type: 'button', className: 'dk_btn', onClick: addTarget, children: t('btn.addTarget') }),
|
|
7565
|
+
jsx('span', { className: 'dk_hint', children: t('hint.addTarget') }),
|
|
6210
7566
|
] }),
|
|
6211
7567
|
|
|
6212
|
-
sectionTitle('
|
|
7568
|
+
sectionTitle(t('section.tofu')),
|
|
6213
7569
|
...(form.hostKeys.length === 0
|
|
6214
|
-
? [jsx('span', { className: 'dk_hint', children: '
|
|
7570
|
+
? [jsx('span', { className: 'dk_hint', children: t('list.noHostKeys') }, 'none')]
|
|
6215
7571
|
: form.hostKeys.map((record) => jsxs('div', { className: 'dk_targetRow', children: [
|
|
6216
7572
|
jsx('span', { children: record.host + ':' + String(record.port) }),
|
|
6217
7573
|
// 一台主机可能有多个指纹(rsa + ed25519 各一条,D03);旧版宿主仍是单数字段
|
|
@@ -6220,12 +7576,13 @@ window.__ModuleLoader__.load({
|
|
|
6220
7576
|
style: { gridColumn: 'span 2', wordBreak: 'break-all' },
|
|
6221
7577
|
children: hostKeyFingerprints(record).map((fp) => 'sha256:' + fp).join(' '),
|
|
6222
7578
|
}),
|
|
6223
|
-
|
|
7579
|
+
// 与目标行的删除**同风格**(D146):同一个卡片里两种「删除」外观是说不通的
|
|
7580
|
+
jsx('button', { type: 'button', className: 'dk_btn dk_btnDanger', onClick: () => removeHostKey(record), children: t('btn.delete') }),
|
|
6224
7581
|
] }, record.host + ':' + String(record.port)))),
|
|
6225
|
-
jsx('span', { className: 'dk_hint', children: '
|
|
7582
|
+
jsx('span', { className: 'dk_hint', children: t('hint.tofu') }),
|
|
6226
7583
|
|
|
6227
7584
|
jsxs('div', { className: 'dk_row', children: [
|
|
6228
|
-
jsx('button', { type: 'button', className: 'dk_btn dk_btnPrimary', disabled: saving, onClick: save, children: saving ? '
|
|
7585
|
+
jsx('button', { type: 'button', className: 'dk_btn dk_btnPrimary', disabled: saving, onClick: save, children: saving ? t('status.saving') : t('btn.save') }),
|
|
6229
7586
|
jsx('span', { className: 'dk_msg', 'data-kind': message.kind, children: message.text }),
|
|
6230
7587
|
] }),
|
|
6231
7588
|
])
|
|
@@ -6398,7 +7755,7 @@ window.__ModuleLoader__.load({
|
|
|
6398
7755
|
let pane = null
|
|
6399
7756
|
try {
|
|
6400
7757
|
pane = panelApi.mountPane({
|
|
6401
|
-
title: '
|
|
7758
|
+
title: t('panel.title'),
|
|
6402
7759
|
hint: options?.target === undefined || options.target === '' ? '' : options.target,
|
|
6403
7760
|
size: 520,
|
|
6404
7761
|
min: 360,
|
|
@@ -6454,10 +7811,10 @@ window.__ModuleLoader__.load({
|
|
|
6454
7811
|
entry.dataset.dshDockerEntry = ''
|
|
6455
7812
|
entry.className = 'dk_sidebarEntry'
|
|
6456
7813
|
entry.setAttribute('role', 'button')
|
|
6457
|
-
entry.setAttribute('aria-label', '
|
|
7814
|
+
entry.setAttribute('aria-label', t('panel.containers'))
|
|
6458
7815
|
entry.innerHTML =
|
|
6459
7816
|
'<span class="dk_entryIcon">' + ICON_BOX + '</span>' +
|
|
6460
|
-
'<span class="dk_entryLabel"
|
|
7817
|
+
'<span class="dk_entryLabel">' + t('panel.containers') + '</span>'
|
|
6461
7818
|
entry.addEventListener('click', (event) => {
|
|
6462
7819
|
event.preventDefault()
|
|
6463
7820
|
openContainerPanel()
|
|
@@ -6673,14 +8030,15 @@ window.__ModuleLoader__.load({
|
|
|
6673
8030
|
filterLinesByLevel,
|
|
6674
8031
|
buildLogExport,
|
|
6675
8032
|
/* 导出按钮文案与两个格式的菜单项:两个视图共用一份,产物里只该出现一次(D137)。 */
|
|
6676
|
-
EXPORT_LABEL
|
|
8033
|
+
get EXPORT_LABEL() { return logExportLabel() },
|
|
6677
8034
|
exportMenuItems,
|
|
6678
|
-
LEVEL_OPTIONS
|
|
8035
|
+
get LEVEL_OPTIONS() { return logLevelOptions() },
|
|
6679
8036
|
TAIL_OPTIONS: AGG_TAIL_OPTIONS,
|
|
6680
8037
|
TAIL_DEFAULT: AGG_TAIL_DEFAULT,
|
|
6681
8038
|
exportText: buildLogExport,
|
|
6682
8039
|
}
|
|
6683
8040
|
exports.apply = (ctx) => {
|
|
8041
|
+
installI18n(ctx)
|
|
6684
8042
|
ensureStyle()
|
|
6685
8043
|
// 侧栏入口先按可见挂载(与旧行为一致),config 确认禁用后由闸门收起;
|
|
6686
8044
|
// 运行期显隐由 syncEntryFromConfig(缓存刷新 / 设置卡片保存后)驱动
|
|
@@ -6716,7 +8074,7 @@ window.__ModuleLoader__.load({
|
|
|
6716
8074
|
name: 'settings.kit.item',
|
|
6717
8075
|
id: 'docker',
|
|
6718
8076
|
order: 70,
|
|
6719
|
-
label: () =>
|
|
8077
|
+
label: () => t('card.name'),
|
|
6720
8078
|
}, DockerSettingsCard))
|
|
6721
8079
|
// DSH ≤0.1.5:设置 → 插件 的「插件配置」标签页,keyed 插槽按 settings 命名空间派发。
|
|
6722
8080
|
const disposeCard = ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
|
|
@@ -6800,11 +8158,11 @@ window.__ModuleLoader__.load({
|
|
|
6800
8158
|
id: DOCKER_TAB_ID,
|
|
6801
8159
|
kind: DOCKER_TAB_KIND,
|
|
6802
8160
|
priority: 'extension',
|
|
6803
|
-
title: () => '
|
|
8161
|
+
title: () => t('panel.title'),
|
|
6804
8162
|
guide: [{
|
|
6805
8163
|
order: 90,
|
|
6806
|
-
title: () => '
|
|
6807
|
-
description: () => '
|
|
8164
|
+
title: () => t('panel.title'),
|
|
8165
|
+
description: () => t('card.guide'),
|
|
6808
8166
|
}],
|
|
6809
8167
|
})
|
|
6810
8168
|
const disposeBody = tabCtx.slots.inject('sidebar.right.pane.tab', () => tabCtx.slots.register({
|
|
@@ -6867,12 +8225,12 @@ window.__ModuleLoader__.load({
|
|
|
6867
8225
|
const liveTarget = typeof payload?.tab?.target === 'string' ? payload.tab.target : ''
|
|
6868
8226
|
const matched = matchTargetForSession(spec, bookName, liveTarget)
|
|
6869
8227
|
const title = matched !== undefined
|
|
6870
|
-
?
|
|
8228
|
+
? t('hint.openPanelForTarget', { target: matched })
|
|
6871
8229
|
: configCache === null
|
|
6872
8230
|
// 缓存还没拿到(挂载时的请求可能仍在飞/失败):别断言「未配置」
|
|
6873
|
-
? '
|
|
6874
|
-
: '
|
|
6875
|
-
payload.addAction(ICON_BOX_SM, '
|
|
8231
|
+
? t('hint.openPanelCurrentHost')
|
|
8232
|
+
: t('hint.openPanelUnconfigured')
|
|
8233
|
+
payload.addAction(ICON_BOX_SM, t('panel.containers'), title, () => {
|
|
6876
8234
|
// 点击时以「现场解析」为准:缓存没命中就现拉一次,避免启动期竞态
|
|
6877
8235
|
void (async () => {
|
|
6878
8236
|
const resolved = await resolveTargetForSession(spec, bookName, liveTarget)
|