@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.
@@ -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: '最多 ' + String(max) + ' 个容器'
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 ? '' : '至少选择 2 个容器' }
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: '全部可见', needsBase: false },
560
- { key: 'unhealthy', label: '不健康', needsBase: false },
561
- { key: 'abnormal', label: '需关注', needsBase: false },
562
- { key: 'stopped', label: '已停止', needsBase: false },
563
- { key: 'sameImage', label: '同镜像', needsBase: true },
564
- { key: 'sameProject', label: '同项目', needsBase: true },
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', '被 OOM 杀', 0],
670
- ['dead', '僵死', 1],
671
- ['unhealthy', '不健康', 2],
672
- ['restarting', '反复重启', 3],
673
- ['exit-nonzero', '非零退出', 4],
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('结束于 ' + finished)
711
- else if (started !== '') parts.push('启动于 ' + started)
712
- if (typeof item.restartCount === 'number') parts.push('重启次数 ' + String(item.restartCount))
713
- if (typeof item.exitCode === 'number') parts.push('退出码 ' + String(item.exitCode))
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('需关注结果已截断:' + String(truncatedTargets) + ' 个目标实际共 ' + String(truncatedTotal) + ' 条,此处只列出前 ' + String(truncatedShown) + ' 条')
1859
+ notices.push(t('hint.attentionTruncated', { targets: truncatedTargets, total: truncatedTotal, shown: truncatedShown }))
841
1860
  }
842
1861
  if (degradedTargets > 0) {
843
- notices.push(String(degradedTargets) + ' 个目标的结果已降级(部分容器的详情没取到,OOM / 反复重启可能漏报)')
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' ? '不健康' : stateLabel(props.state)
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: '还没有配置 Docker 目标' }),
1021
- jsx('div', { className: 'dk_emptyHint', children: '到 插件配置 → Docker 容器面板 添加目标后,总览会在这里一屏汇总全部主机。' }),
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: '所有目标上都没有需要关注的容器(不健康 / 反复重启 / 被 OOM 杀 / 非零退出 / 僵死)。' }),
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: String(data.unreachable.length) + ' 个目标不可达',
1072
- hint: data.unreachable.map((card) => card.name + ':' + card.error).join(';') + '(其余目标的正常结果不受影响)',
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 === '' ? '切到该目标的容器列表' : 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' ? '本机' : 'SSH' }),
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', '运行中', card.running),
1092
- overviewCountSpan('stopped', '已停止', card.stopped),
1093
- overviewCountSpan('unhealthy', '不健康', card.unhealthy),
1094
- overviewCountSpan('attention', card.attentionApprox ? '需关注(粗判)' : '需关注', card.attention),
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 ? '需关注容器' : '需关注容器(' + String(data.rows.length) + ')' }, 2),
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
- ? '正在执行 ' + pendingAction + '…请稍候'
1280
- : (readOnly ? '需要打开「允许变更操作」' : label))
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: '镜像', value: item.image }, 'image'),
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') ? '(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: '创建', value: created }, 'created'),
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: '在容器内打开交互式终端(docker exec -it ' + item.name + ' sh)', onClick: () => props.onExec(item) }, 'exec'),
1335
- jsx(IconAction, { icon: ICON_LOGS, title: '查看日志', onClick: () => props.onOpen(item, 'logs') }, 'logs'),
1336
- jsx(IconAction, { icon: ICON_STATS, title: '资源占用', onClick: () => props.onOpen(item, 'stats') }, 'stats'),
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 ? '停止容器' : '启动容器'), disabled: readOnly || busy, busy: pendingAction === (running ? 'stop' : 'start'), onClick: () => props.onAction(running ? 'stop' : 'start', item) }, 'power'),
1339
- jsx(IconAction, { icon: ICON_RESTART, title: actionTitle('重启容器'), disabled: readOnly || busy, busy: pendingAction === 'restart', onClick: () => props.onAction('restart', item) }, 'restart'),
1340
- jsx(IconAction, { icon: ICON_TRASH, danger: true, title: actionTitle('删除容器(不可恢复)'), disabled: readOnly || busy, busy: pendingAction === 'remove', onClick: () => props.onAction('remove', item) }, 'remove'),
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: '宿主未提供 sessions 服务' }
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: '宿主缺少 conversation 服务' }
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 + ' 行 · ' + context.containers[0].name
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 + ' 行' : count + ' 行 · ' + [...services].slice(0, 3).join('/')
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('未能交给会话:' + result.message)
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 LOG_EXPORT_LABEL = '⬇ 导出'
2840
+ const logExportLabel = () => t('btn.export')
1822
2841
  function exportMenuItems(onPick) {
1823
2842
  return [
1824
- { label: '⬇ .log', hint: '纯文本,逐行原样', onPick: () => onPick('log') },
1825
- { label: '⬇ .md', hint: '带来源与行数表头,适合当工单附件', onPick: () => onPick('md') },
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 ? ' · 已折起终端,你在会话里' : conversationHiddenHint
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('已填入当前会话的输入框' + revealSessionSuffix(), 'ok')
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('已发送日志片段到当前会话' + revealSessionSuffix(), 'ok')
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: '问 Agent',
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('当前环境不支持 EventSource,无法实时跟随')
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('容器已退出' + (code === null ? '' : '(退出码 ' + String(code) + ')') + ',日志流结束,已切回快照')
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 '实时跟随中(docker stats)'
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 '实时跟随中(docker logs -f)'
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('当前环境不支持 EventSource,无法实时跟随')
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('统计流已结束' + (reason === 'stats-exit' ? '(docker stats 退出' + (code === null ? '' : ',退出码 ' + String(code)) + ')' : '') + ',已切回快照轮询')
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: '读取容器详情失败', hint: detailError })
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
- ['状态', detail.state + (detail.health === null ? '' : ' / ' + detail.health) + (detail.status === '' ? '' : '(' + detail.status + ')')],
2409
- ['镜像', detail.image],
2410
- ['容器 ID', detail.shortId],
2411
- ['启动时间', detail.startedAt ?? '—'],
2412
- ['结束时间', detail.finishedAt ?? '—'],
2413
- ['退出码', detail.exitCode === null ? '—' : String(detail.exitCode)],
2414
- ['重启次数', detail.restartCount === null ? '—' : String(detail.restartCount)],
2415
- ['重启策略', detail.restartPolicy ?? '—'],
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
- ['端口', detail.ports.length === 0 ? '—' : portsText(detail.ports)],
2418
- ['挂载', detail.mounts.length === 0 ? '—' : detail.mounts.map((mount) => mount.source + '→' + mount.destination + (mount.readWrite ? '' : '(只读)')).join('\n')],
2419
- ['网络', detail.networks.length === 0 ? '—' : detail.networks.map((net) => net.name + (net.ip === null ? '' : '(' + net.ip + ')')).join(', ')],
2420
- ['命令', (detail.entrypoint + ' ' + detail.command).trim() || '—'],
2421
- ['工作目录', detail.workingDir === '' ? '—' : detail.workingDir],
2422
- ['用户', detail.user === '' ? '—' : detail.user],
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 === '容器 ID' || key === '命令' || key === '镜像' ? ' dk_kvValMono' : ''), children: value }, 'v' + String(index)),
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: '最近健康检查输出:' + detail.healthLogTail }),
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: '一次性命令(docker exec)' }),
3451
+ jsx('div', { className: 'dk_cardSection', style: { marginTop: 16 }, children: t('panel.oneOffExec') }),
2432
3452
  config.allowExec !== true
2433
- ? jsx(Banner, { kind: 'info', title: 'exec 未启用', hint: '到 插件配置 → Docker 容器面板 打开「允许 exec」,或直接复制卡片上的 exec 命令到终端面板交互式进入容器。' })
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: '如 ls -la /app 或 cat /etc/nginx/nginx.conf',
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: '执行失败', hint: execError }),
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: '退出码 ' + (execResult.code === null ? '?' : String(execResult.code)) + ' · 耗时 ' + String(execResult.durationMs) + 'ms' + (execResult.truncated ? ' · 输出已截断' : '') }),
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: '拉取的尾部行数。快照另受设置卡片「输出上限(KB)」限制(当前 ' + String(outputCapKb) + 'KB)——行数够但字节超了仍会截断,实际行数可能更少;FOLLOW 流不受该字节上限约束(由本面板的缓冲上限收口)。',
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 ? '关闭实时跟随(回到日志快照)' : '实时跟随容器日志(docker logs -f)',
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 ? '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: '自动刷新间隔(开启 AUTO REFRESH 后按此轮询)',
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: '刷新日志', spin: logsLoading, onClick: loadLogs }, 'refresh'),
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: LOG_LEVEL_OPTIONS.map((option) => jsx('option', { value: String(option.value), children: option.label }, String(option.value))),
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: '导出当前显示内容:.log(纯文本)/ .md(带来源与行数表头,适合当工单附件)',
2591
- onClick: (event) => openExportMenu(event, { sub: item.name + ' · ' + String(matched.length) + ' 行', onPick: doLogExport }),
2592
- children: LOG_EXPORT_LABEL,
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: '日志超出缓冲上限(' + String(FOLLOW_LINE_LIMIT) + ' 行 / ' + String(Math.round(FOLLOW_BYTE_LIMIT / 1024 / 1024)) + 'MB),已丢弃最早内容',
2642
- hint: '流式日志只保留最近的行;需要完整历史请关掉 FOLLOW 用快照,或调小「LINES」。',
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: '日志输出超过「输出上限(' + String(outputCapKb) + 'KB)」,已截断',
2647
- hint: '这是**字节**上限,不是行数上限——所以 LINES 选了 5000 也可能只回来一部分。想多留日志请到设置卡片调大「输出上限(KB)」,或打开 FOLLOW(流式不受它约束)。',
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: '读取中…' }, 'loading')
3688
+ ? jsx('div', { className: 'dk_logLine', children: t('list.loading') }, 'loading')
2669
3689
  : (matched.length === 0
2670
- ? jsx('div', { className: 'dk_logLine', children: follow ? '等待日志…' : (needle === '' ? '(无日志)' : '(无匹配日志)') }, 'empty')
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 ? '关闭实时跟随(回到 docker stats 快照)' : '实时跟随资源占用(docker stats 每秒一行)',
3706
+ title: following ? t('hint.statsFollowOff') : t('hint.statsFollowOn'),
2687
3707
  }),
2688
- jsx('span', { className: 'dk_hint', children: following ? '60 点 ≈ 最近 1 分钟' : '打开 FOLLOW 看实时趋势' }),
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: '读取统计失败', hint: statsError }) : stats === null
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: '读取统计失败', hint: statsError }))
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: '指标' }), jsx('th', { children: '数值' }), 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: 'CPU% 最近 60 个采样' }) : null,
3752
+ following || statsSeries.cpu.length > 0 ? jsx(Sparkline, { values: statsSeries.cpu, max: cpuMax, alertAt: 85, title: t('hint.cpuSpark') }) : null,
2733
3753
  ] })),
2734
- row('内存', stats.memUsage, jsxs('div', { className: 'dk_trend', children: [
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: '内存占用% 最近 60 个采样' }) : null,
3756
+ following || statsSeries.mem.length > 0 ? jsx(Sparkline, { values: statsSeries.mem, max: 100, alertAt: 85, title: t('hint.memSpark') }) : null,
2737
3757
  ] })),
2738
- row('网络 IO', stats.netIO, null),
2739
- row('磁盘 IO', stats.blockIO, null),
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', '概览'], ['logs', '日志'], ['stats', '统计']]
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: '返回容器列表', onClick: props.onBack }, 'back'),
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: '刷新', spin: refreshing, onClick: props.onRefresh }, 'refresh'),
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: '关闭面板', onClick: props.onClose, children: jsx('span', { className: 'dk_iconGlyph', dangerouslySetInnerHTML: { __html: ICON_CLOSE } }) }, 'close'),
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', '入口', 'digest'].indexOf(key) >= 0 ? ' dk_kvValMono' : ''), children: value }, 'v' + String(index)),
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: '读取镜像详情失败', hint: error, action: jsx('button', { type: 'button', className: 'dk_btn', onClick: load, children: '重试' }) })
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
- ['标签', d.repoTags.length === 0 ? '<none>(dangling)' : d.repoTags.join('\n')],
3838
+ [t('field.labels'), d.repoTags.length === 0 ? t('list.dangling') : d.repoTags.join('\n')],
2819
3839
  ['ID', d.id],
2820
- ['大小', d.size === null ? '—' : fmtBytes(d.size)],
2821
- ['含父层', d.virtualSize === null ? '—' : fmtBytes(d.virtualSize)],
2822
- ['创建', d.created === '' ? '—' : fmtCreated(d.created)],
2823
- ['平台', (d.os === '' && d.architecture === '') ? '—' : d.os + '/' + d.architecture],
2824
- ['层数', String(d.layerCount)],
2825
- ['入口', (d.entrypoint + ' ' + d.command).trim() || '—'],
2826
- ['工作目录', d.workingDir === '' ? '—' : d.workingDir],
2827
- ['用户', d.user === '' ? '—' : d.user],
2828
- ['暴露端口', d.exposedPorts.length === 0 ? '—' : d.exposedPorts.join(', ')],
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: '层(' + String(d.layerCount) + ')' }),
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: '该镜像没有层信息(scratch 构建或旧版 docker)。' })
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: '标签(' + String(labelEntries.length) + ')' }),
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: '读取镜像详情失败', hint: error })
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: '读取构建历史失败', hint: data.historyError })
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: '该 docker 版本既没有 history --format(需要 Docker ≥ 26),纯文本表格也没解析出内容。' }),
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: '层 ID' }), jsx('th', { children: '创建' }), jsx('th', { children: '大小' }), 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', '概览'], ['history', '构建历史']]
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: '返回镜像列表', onClick: props.onBack }, 'back'),
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: '刷新镜像详情', spin: loading, onClick: load }, 'refresh'),
2881
- props.docked === true ? null : jsx('button', { type: 'button', className: 'dk_iconBtn', title: '关闭面板', onClick: props.onClose, children: jsx('span', { className: 'dk_iconGlyph', dangerouslySetInnerHTML: { __html: ICON_CLOSE } }) }, 'close'),
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: '读取网络详情失败', hint: error, action: jsx('button', { type: 'button', className: 'dk_btn', onClick: load, children: '重试' }) })
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
- ['名称', d.name],
3958
+ [t('field.name'), d.name],
2939
3959
  ['ID', d.id],
2940
- ['驱动', d.driver === '' ? '—' : d.driver],
2941
- ['范围', d.scope === '' ? '—' : d.scope],
2942
- ['创建', d.created === '' ? '—' : fmtCreated(d.created)],
2943
- ['子网', d.subnets.length === 0 ? '—' : d.subnets.map((entry) => entry.subnet === '' ? '—' : entry.subnet).join('\n')],
2944
- ['网关', d.subnets.length === 0 ? '—' : d.subnets.map((entry) => entry.gateway === '' ? '—' : entry.gateway).join('\n')],
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
- ['选项', Object.keys(d.options).length === 0 ? '—' : Object.entries(d.options).map(([key, value]) => key + '=' + value).join('\n')],
2952
- ['标签', Object.keys(d.labels).length === 0 ? '—' : Object.entries(d.labels).map(([key, value]) => key + '=' + value).join('\n')],
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: '读取网络详情失败', hint: error })
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: '容器' }), jsx('th', { children: 'IPv4' }), jsx('th', { children: 'IPv6' }), jsx('th', { children: 'MAC' }),
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', '概览'], ['containers', '接入的容器' + (data === null ? '' : '(' + String(data.detail.containers.length) + ')')]]
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: '返回网络列表', onClick: props.onBack }, 'back'),
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: '刷新网络详情', spin: loading, onClick: load }, 'refresh'),
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: '关闭面板', onClick: props.onClose, children: jsx('span', { className: 'dk_iconGlyph', dangerouslySetInnerHTML: { __html: ICON_CLOSE } }) }, 'close'),
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: '删除网络失败', hint: removeError }),
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: '确定删除网络 ' + name + '?还有容器接着时 docker 会拒绝;删除后依赖它的容器会失去网络,需要重新创建或接入别的网络。',
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: '读取卷详情失败', hint: error, action: jsx('button', { type: 'button', className: 'dk_btn', onClick: load, children: '重试' }) })
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
- ['名称', d.name],
3052
- ['驱动', d.driver === '' ? '—' : d.driver],
3053
- ['范围', d.scope === '' ? '—' : d.scope],
3054
- ['挂载点', d.mountpoint === '' ? '—' : d.mountpoint],
3055
- ['创建', d.created === '' ? '—' : fmtCreated(d.created)],
3056
- ['选项', Object.keys(d.options).length === 0 ? '—' : Object.entries(d.options).map(([key, value]) => key + '=' + value).join('\n')],
3057
- ['标签', Object.keys(d.labels).length === 0 ? '—' : Object.entries(d.labels).map(([key, value]) => key + '=' + value).join('\n')],
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: '返回卷列表', onClick: props.onBack }, 'back'),
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: '刷新卷详情', spin: loading, onClick: load }, 'refresh'),
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: '关闭面板', onClick: props.onClose, children: jsx('span', { className: 'dk_iconGlyph', dangerouslySetInnerHTML: { __html: ICON_CLOSE } }) }, 'close'),
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: '删除卷失败', hint: removeError }),
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: '确定删除卷 ' + name + '?卷里的数据会一起删除且不可恢复;还有容器占用时 docker 会拒绝。',
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('当前环境不支持 EventSource,无法显示拉取进度')
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 ? '拉取完成' : '拉取结束(退出码 ' + String(code === null ? '?' : code) + ')')
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 '正在拉取(docker pull)…'
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: '返回镜像列表', onClick: props.onBack }, 'back'),
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: '关闭面板', onClick: props.onClose, children: jsx('span', { className: 'dk_iconGlyph', dangerouslySetInnerHTML: { __html: ICON_CLOSE } }) }, 'close'),
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: '拉取镜像需要打开「允许变更操作」', hint: 'docker pull 会写入目标机的镜像存储并占用磁盘与带宽。到 插件配置 → Docker 容器面板 打开「允许变更操作」后即可在此拉取。' })
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: '镜像引用,如 nginx:1.27 或 ghcr.io/org/app:latest',
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: '拉取失败', hint: error }),
3305
- dropped ? jsx(Banner, { kind: 'warn', title: '进度超过 ' + String(PULL_LINE_LIMIT) + ' 行,最早的进度行已被丢弃' }) : null,
3306
- status === '' ? null : jsx('div', { className: 'dk_hint', children: statusText() + (exitCode === null ? '' : ' · 退出码 ' + String(exitCode)) }),
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 ? '等待 docker pull 输出…' : '填写镜像引用后点「拉取」,逐层进度会实时出现在这里。' })
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 === '' ? '(非 compose 容器)' : 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: String(running) + ' / ' + String(group.items.length) + ' 运行中' }),
3359
- unhealthy > 0 ? jsx('span', { className: 'dk_badge', 'data-state': 'unhealthy', children: String(unhealthy) + ' 不健康' }) : null,
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: String(services.length) + ' 个服务' }),
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 === '' ? '(非 compose 容器)' : 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: '服务' }), jsx('th', { children: '容器' }), jsx('th', { children: '状态' }), jsx('th', { children: '端口' }), 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', '服务'], ['logs', '聚合日志']]
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: '返回 Compose 列表', onClick: props.onBack }, 'back'),
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: String(running) + ' / ' + String(items.length) + ' 运行中' }),
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: '关闭面板', onClick: props.onClose, children: jsx('span', { className: 'dk_iconGlyph', dangerouslySetInnerHTML: { __html: ICON_CLOSE } }) }, 'close'),
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 LOG_LEVEL_OPTIONS = [
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
- '- 来源:' + (typeof options.targetLabel === 'string' && options.targetLabel !== '' ? options.targetLabel + ' · ' : '') + (options.target ?? ''),
3571
- '- 容器(' + String(items.length) + '):' + items.map((item) => item.name).join('、'),
3572
- '- 行数:' + String(rows.length),
3573
- '- 导出时间:' + new Date().toLocaleString(),
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 '已连接 ' + String(items.length) + ' 条容器日志流(docker logs -f)'
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 '当前环境不支持 EventSource'
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: '聚合日志超出缓冲上限(' + String(FOLLOW_LINE_LIMIT) + ' 行 / ' + String(Math.round(FOLLOW_BYTE_LIMIT / 1024 / 1024)) + 'MB),已丢弃最早内容' }) : null,
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: '每容器拉取的初始行数(' + String(items.length) + ' 个容器 → 约 ' + String(items.length * tail) + ' 行);改动会重连全部流',
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
- ? '恢复实时(会一次性显示暂停期间攒下的 ' + String(bufferedCount) + ' 行并回到底部)'
3938
- : '暂停(冻结当前画面:新日志继续接收但不追加,避免读屏被顶走)',
4958
+ ? t('btn.resumeLive', { count: bufferedCount })
4959
+ : t('hint.pause'),
3939
4960
  onClick: togglePause,
3940
4961
  children: paused
3941
- ? (bufferedCount > 0 ? '已暂停 +' + String(bufferedCount) : '已暂停')
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
- : '按容器时间戳合并(跨容器成一条真时间线,代价约 ' + String(LOG_MERGE_WINDOW_MS) + 'ms 延迟)',
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: LOG_LEVEL_OPTIONS.map((option) => jsx('option', { value: String(option.value), children: option.label }, String(option.value))),
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: '导出当前显示内容:.log(纯文本)/ .md(带来源与行数表头,适合当工单附件)',
3975
- onClick: (event) => openExportMenu(event, { sub: String(items.length) + ' 个容器 · ' + String(matched.length) + ' 行', onPick: doExport }),
3976
- children: LOG_EXPORT_LABEL,
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) + ' 行' : String(matched.length) + ' / ' + 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' ? '等待日志…' : statusText() }, 'empty')
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: '回到底部' }) : null,
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: '容器事件活动(docker events):点击折叠 / 展开',
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 ? '暂无事件' : '最近 ' + String(recent.length) + ' / ' + String(events.length) + ' 条' }),
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: '暂无事件(容器的 start / die / health 等动作会出现在这里)' })
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: '已选 ' + String(props.count) + ' 个容器' }),
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 ? '把所选容器的日志聚合成一条流' : '至少选择 2 个容器'),
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: '在当前筛选结果里勾选「' + preset.label + '」的容器(最多 ' + String(props.max ?? PICK_MAX) + ' 个流)'
4100
- + (preset.over > 0 ? ';另有 ' + String(preset.over) + ' 个超出上限不会选中' : ''),
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: '返回容器列表(退出选择态)', onClick: props.onBack }, 'back'),
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: '聚合日志 · ' + String(props.items.length) + ' 个容器' }),
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: '关闭面板', onClick: props.onClose, children: jsx('span', { className: 'dk_iconGlyph', dangerouslySetInnerHTML: { __html: ICON_CLOSE } }) }, 'close'),
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('终端启动失败:' + (error instanceof Error ? error.message : String(error)))
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 '实时接收中(docker events)'
4852
- if (eventsStatus === 'connecting') return '正在连接事件流…'
4853
- if (eventsStatus === 'reconnecting') return '连接中断,正在自动重连…'
4854
- if (eventsStatus === 'closed') return '事件流已断开'
4855
- if (eventsStatus === 'unsupported') return '当前环境不支持 EventSource'
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('事件流已结束' + (code === null ? '' : '(退出码 ' + String(code) + ')') + ',列表回到 AUTO REFRESH / 手动刷新')
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('已复制:' + command + (reason === undefined ? '' : '(' + reason + ')'))
4971
- }).catch(() => setNotice('复制失败,请手动执行:' + command))
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
- ? '终端面板版本过旧(交互式进入容器需要 dsh-tty ≥ 0.14.0),命令已复制,可粘贴到系统终端执行'
4990
- : '未安装 dsh-tty 终端面板,命令已复制,可粘贴到系统终端执行(装 dsh-tty 后可直接在此开终端)')
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, '内联目标用了 key/password 认证,浏览器端拿不到凭证')
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' ? '删除容器' : (action === 'stop' ? '停止容器' : (action === 'start' ? '启动容器' : '重启容器')),
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
- ? `确定删除容器 ${item.name}?容器的可写层与配置会被删除(命名数据卷保留),此操作不可恢复。`
5093
- : `确定对容器 ${item.name} 执行${action === 'stop' ? '停止' : action === 'start' ? '启动' : '重启'}操作?`,
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(`${payload.result.action} ${item.name}:${payload.result.message}`)
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: '确定删除镜像 ' + ref + '?镜像被容器或子镜像引用时会失败;删除后需要重新拉取或构建才能恢复,且不可撤销。',
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('已删除 ' + ref + ':' + payload.result.message)
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: '清理 dangling 镜像',
5138
- text: '清理该目标上所有无标签(<none>:<none>)的镜像层,释放磁盘空间;不会删除有 tag 的镜像。',
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('已清理 dangling 镜像:' + (tail.length === 0 ? 'ok' : tail[tail.length - 1]))
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: '清理该目标上所有没有容器接入的网络。compose 创建的项目网络也在其中(下次 up 会重建),但正在跑的项目会短暂失去网络。',
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('已清理未使用网络:' + (tail.length === 0 ? 'ok' : tail[tail.length - 1]))
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: '清理该目标上所有没有被容器使用的卷——卷里的数据会一起删除且不可恢复。docker ≥ 23 只删匿名卷(不带 --all),更老的版本会连命名卷一起删;执行前请确认没有需要保留的数据卷。',
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('已清理未使用卷:' + (tail.length === 0 ? 'ok' : tail[tail.length - 1]))
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: '正在切换到 ' + target + titleHost(target)
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 ? '' : '(' + label + ')'
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: '当前会话主机还不是 Docker 目标' }),
5261
- jsx('div', { className: 'dk_emptyHint', children: '按上面的提示到 插件配置 → Docker 容器面板 添加一条目标(推荐直接选连接簿条目),保存后回到这里刷新。为避免张冠李戴,面板不会自动切到其他目标。' }),
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: '还没有配置 Docker 目标' }),
5266
- jsx('div', { className: 'dk_emptyHint', children: '到 插件配置 → Docker 容器面板 添加一个目标:本机直接选「本机」;远程主机可以引用 tty 终端面板的连接簿条目。' }),
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: '上面的错误条里有原因(目标不可达 / docker 未运行 / 权限不足)。修好后点右上角刷新即可。' }),
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' ? '没有镜像' : (view === 'compose' ? '没有 Compose 项目' : (view === 'networks' ? '没有网络' : (view === 'volumes' ? '没有卷' : '没有容器'))) }),
5279
- jsx('div', { className: 'dk_emptyHint', children: (search.trim() === '' ? '目标上没有匹配的数据,或筛选条件过窄。' : '没有匹配「' + 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: '镜像' }), jsx('th', { children: '大小' }), jsx('th', { children: '创建' }), jsx('th', { children: 'ID' }), jsx('th', { className: 'dk_colActions', 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 ? '<none>(dangling)' : item.reference }),
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: '查看镜像详情(层 / 构建历史)', onClick: () => setImageDetail(item) }, 'inspect'),
5307
- jsx(IconAction, { icon: ICON_TRASH, danger: true, disabled: config?.allowMutations !== true, title: config?.allowMutations === true ? '删除镜像(不可恢复)' : '需要打开「允许变更操作」', onClick: () => doImageRemove(item) }, 'remove'),
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: '名称' }), jsx('th', { children: '驱动' }), jsx('th', { children: '范围' }), jsx('th', { children: '属性' }), jsx('th', { children: 'ID' }),
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: '名称' }), jsx('th', { children: '驱动' }), jsx('th', { children: '范围' }), 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('已复制:' + command)).catch(() => 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('已新增 ' + String(next.added) + ' 个,另有 ' + String(next.skipped) + ' 个超出上限(最多 ' + String(pickMax) + ' 个流)未选')
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('已新增 ' + String(next.added) + ' 个')
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: 'Docker 容器' }),
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: '刷新列表', 'data-spin': loading ? '1' : undefined, onClick: refresh, children: jsx('span', { className: 'dk_iconGlyph', dangerouslySetInnerHTML: { __html: ICON_REFRESH } }) }),
5530
- jsx('button', { type: 'button', className: 'dk_iconBtn', title: '关闭面板', onClick: requestClose, children: jsx('span', { dangerouslySetInnerHTML: { __html: ICON_CLOSE } }) }),
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: '(总览 · 全部目标)' }, '__overview')]
6577
+ ? [jsx('option', { value: '', children: t('option.overviewAllTargets') }, '__overview')]
5558
6578
  // 会话主机未匹配目标时保持「未选择」,让用户显式挑一个,不替他默认
5559
- : (target === '' ? [jsx('option', { value: '', children: '(未选择目标)' }, '__none')] : [])),
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', '容器'], ['images', '镜像'], ['compose', 'Compose'], ['networks', '网络'], ['volumes', '卷']].map(([key, label]) => jsx('button', {
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 ? '退出选择并清空勾选(Esc)' : '多选容器,把它们的日志临时聚合成一条流',
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: '搜索名称 / 镜像 / ID',
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: '搜索镜像(仓库 / 标签 / ID)',
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: String(filteredImages.length) + ' / ' + String(images.length) + ' 个镜像' }) : null,
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 ? '拉取镜像(docker pull,逐层实时进度)' : '拉取镜像需要打开「允许变更操作」', onClick: () => setPullOpen(true) }, 'pull') : null,
5650
- view === 'images' ? jsx(IconAction, { icon: ICON_PRUNE, danger: true, disabled: config?.allowMutations !== true, title: config?.allowMutations === true ? '清理 dangling(无标签)镜像' : '清理 dangling 需要打开「允许变更操作」', onClick: doImagePrune }, 'prune') : null,
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: '搜索网络(名称 / 驱动 / ID)',
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: String(filteredNetworks.length) + ' / ' + String(networks.length) + ' 个网络' }) : null,
5664
- view === 'volumes' ? jsx('span', { className: 'dk_hint dk_searchCount', children: String(filteredVolumes.length) + ' / ' + String(volumes.length) + ' 个卷' }) : null,
5665
- view === 'networks' ? jsx(IconAction, { icon: ICON_PRUNE, danger: true, disabled: config?.allowMutations !== true, title: config?.allowMutations === true ? '清理未使用的网络(docker network prune)' : '清理网络需要打开「允许变更操作」', onClick: doNetworkPrune }, 'prune') : null,
5666
- view === 'volumes' ? jsx(IconAction, { icon: ICON_PRUNE, danger: true, disabled: config?.allowMutations !== true, title: config?.allowMutations === true ? '清理未使用的卷(docker volume prune,会删数据)' : '清理卷需要打开「允许变更操作」', onClick: doVolumePrune }, 'prune') : null,
5667
- view === 'compose' ? jsx('span', { className: 'dk_hint dk_searchCount', children: String(groupCompose(filtered).length) + ' 个项目 · ' + String(filtered.length) + ' 个容器' }) : null,
5668
- view === 'containers' ? jsx('div', { className: 'dk_seg', children: [['all', '全部'], ['running', '运行中'], ['stopped', '已停止'], ['unhealthy', '不健康']].map(([key, label]) => jsx('button', {
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: '只读模式' }, 'readonly') : null,
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: '刷新列表', 'data-spin': loading ? '1' : undefined, onClick: refresh, children: jsx('span', { className: 'dk_iconGlyph', dangerouslySetInnerHTML: { __html: ICON_REFRESH } }) }, 'refresh'),
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: '操作失败', hint: error }),
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: '当前会话主机还没配置为 Docker 目标',
5749
- hint: '会话主机:' + props.sessionHint.host + (props.sessionHint.port === 22 ? '' : ':' + String(props.sessionHint.port))
5750
- + (props.sessionHint.book === '' ? '' : '(连接簿:' + props.sessionHint.book + ')')
5751
- + ' — 到 插件配置 → Docker 容器面板 添加一条 kind=ssh 目标'
5752
- + (props.sessionHint.book === '' ? '(填 host/username,或用连接簿条目)' : ',直接选连接簿条目「' + 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: '当前为只读模式', hint: '容器的启动 / 停止 / 重启 / 删除,以及镜像、网络、卷的删除与清理,都需要到 插件配置 → Docker 容器面板 打开「允许变更操作」。' })
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: '关闭面板会结束「' + exec.label + '」的终端会话(docker exec -it …)。'
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
- setForm(payload.config)
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: '读取配置失败:' + error.message })
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: '目标' + String(current.targets.length + 1), kind: 'local', book: '', host: '', port: 22, username: '', auth: 'agent', keyPath: '', agentForward: false, passwordSet: false, passphraseSet: false }],
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: '保存失败:' + error.message })
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 '本机与 SSH 主机的容器与镜像:容器 / 镜像 / 网络 / 卷查看,默认只读,变更操作需显式开启。'
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: 'Docker 容器面板' }),
6116
- jsx('span', { className: 'dk_settingsDesc', children: '本机 / SSH 主机上的容器与镜像;默认只读,变更操作需显式开启' }),
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 }) }), '向 agent 公告能力'] }),
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 }) }), '默认 docker;podman 可填 podman'),
6140
- field('统计刷新间隔(秒)', numberInput('pollIntervalSec', 1, 60)),
6141
- field('日志默认行数', numberInput('logTailDefault', 1, 5000), '面板日志页 LINES 的初始值(面板内可临时改);它只是**行数**上限——快照还要过下面那道字节闸,所以不保证一定拿得到这么多行'),
6142
- field('输出上限(KB)', numberInput('maxOutputKb', 1, 8192), '单次输出的**字节**上限:日志快照 / inspect / exec 共用;日志行数够但字节超了会被截断(面板会给出截断横幅)。FOLLOW 流式日志不受它约束'),
6143
- field('exec 超时(秒)', numberInput('execTimeoutSec', 1, 120)),
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
- jsxs('div', { className: 'dk_row', children: [
6148
- jsx('label', { className: 'dk_check', children: [jsx('input', { type: 'checkbox', checked: form.allowMutations, onChange: (event) => patch({ allowMutations: event.target.checked }) }), '允许变更操作(容器启停删、镜像拉取 / 删除 / 清理)'] }),
6149
- jsx('label', { className: 'dk_check', children: [jsx('input', { type: 'checkbox', checked: form.allowExec, onChange: (event) => patch({ allowExec: event.target.checked }) }), '允许 exec(在容器内执行命令)'] }),
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: 'docker socket 等价于目标主机的 root 权限。开启后,浏览器面板与 agent 都能执行对应操作,请只在可信环境下打开。' }),
6152
-
6153
- sectionTitle('目标'),
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: '目标名', onChange: (event) => patchTarget(index, { name: event.target.value }) }),
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: 'SSH 主机' }),
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: '宿主所在机器上的 docker' })
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 ? `引用的连接簿条目「${stale}」不存在——请改选一个已有条目,或清空改为手填` : undefined,
7531
+ title: stale !== undefined ? t('hint.staleBook', { book: stale }) : undefined,
6176
7532
  children: [
6177
- jsx('option', { value: '', children: form.ttyBooks.length === 0 ? '(无 tty 连接簿,请填内联信息)' : '(不用连接簿,手填)' }),
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: '⚠ 条目已不存在:' + stale }, stale)] : []),
6180
- ...form.ttyBooks.map((name) => jsx('option', { value: name, children: '连接簿:' + name }, name)),
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: `引用的条目「${stale}」不在 tty 连接簿里——请改选,或清空后手填` })
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: '端口', value: item.port ?? 22, onChange: (event) => patchTarget(index, { port: Number(event.target.value) || 22 }) }),
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: '支持 ~ 与 ~/ 展开(不支持 ~user);Windows 请写绝对路径', value: item.keyPath ?? '', onChange: (event) => patchTarget(index, { keyPath: event.target.value }) }) : null,
6202
- (item.auth ?? 'agent') === 'password' ? jsx('input', { className: 'dk_input dk_credential', type: 'password', placeholder: item.passwordSet === true ? '(已设置,留空保持不变)' : 'env:SSH_PASSWORD', value: item.password ?? '', onChange: (event) => patchTarget(index, { password: event.target.value }) }) : null,
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: 'SSH 目标推荐直接选 tty 终端面板的连接簿条目(凭证只需维护一处);手填时密码 / 口令建议写 env:NAME(凭据引用:由官方凭据存储解析,缺失时退回环境变量)。' }),
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('SSH 主机密钥记录(TOFU)'),
7568
+ sectionTitle(t('section.tofu')),
6213
7569
  ...(form.hostKeys.length === 0
6214
- ? [jsx('span', { className: 'dk_hint', children: '暂无记录 — 首次 SSH 连接成功后自动记录主机指纹(若 tty 已记录同一主机,会直接复用)。' }, 'none')]
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
- jsx('button', { type: 'button', className: 'dk_btn', onClick: () => removeHostKey(record), children: '删除' }),
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: 'Docker 容器',
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">容器</span>'
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: LOG_EXPORT_LABEL,
8033
+ get EXPORT_LABEL() { return logExportLabel() },
6677
8034
  exportMenuItems,
6678
- LEVEL_OPTIONS: LOG_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: () => "Docker 容器面板",
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: () => 'Docker 容器',
8161
+ title: () => t('panel.title'),
6804
8162
  guide: [{
6805
8163
  order: 90,
6806
- title: () => 'Docker 容器',
6807
- description: () => '本机与 SSH 主机的容器、镜像、Compose、网络与卷',
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
- ? `打开该主机的 Docker 容器面板(目标:${matched})`
8228
+ ? t('hint.openPanelForTarget', { target: matched })
6871
8229
  : configCache === null
6872
8230
  // 缓存还没拿到(挂载时的请求可能仍在飞/失败):别断言「未配置」
6873
- ? '打开当前会话主机的 Docker 容器面板'
6874
- : '该主机尚未配置为 Docker 目标 — 点击打开面板查看/配置'
6875
- payload.addAction(ICON_BOX_SM, '容器', title, () => {
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)