@goodandready/dsh-remote-workspace 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -90,6 +90,22 @@ window.__ModuleLoader__.load({
90
90
  .drw-modal-dialog{background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;width:100%;max-width:800px;max-height:85vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 12px 32px rgba(0,0,0,0.4)}
91
91
  .drw-modal-hdr{padding:12px 16px;border-bottom:1px solid var(--dsw-alias-border-l2);display:flex;align-items:center;justify-content:space-between;font-weight:600}
92
92
  .drw-modal-body{flex:1;padding:12px 16px;overflow-y:auto}
93
+
94
+ .drw-health-bar{display:grid;grid-template-columns:repeat(auto-fit, minmax(130px, 1fr));gap:8px;padding:8px 12px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;font-size:11.5px;font-family:monospace}
95
+ .drw-health-item{display:flex;flex-direction:column;gap:2px}
96
+ .drw-progress{height:6px;background:var(--dsw-alias-bg-layer-1);border-radius:3px;overflow:hidden}
97
+ .drw-progress-fill{height:100%;border-radius:3px;transition:width .2s ease}
98
+
99
+ .drw-container-row{display:grid;grid-template-columns:2fr 2fr 1.5fr 1.5fr auto;align-items:center;gap:10px;padding:8px 10px;border-bottom:1px solid var(--dsw-alias-border-l2);font-size:12.5px;font-family:monospace}
100
+ .drw-container-row:hover{background:var(--dsw-alias-bg-layer-3)}
101
+
102
+ .drw-env-table{width:100%;border-collapse:collapse}
103
+ .drw-env-row{display:grid;grid-template-columns:220px 1fr 90px;gap:8px;padding:6px 0;align-items:center;border-bottom:1px solid var(--dsw-alias-border-l2)}
104
+ .drw-alert-badge{padding:4px 8px;border-radius:6px;font-size:11px;font-family:monospace;display:flex;align-items:center;gap:6px}
105
+ .drw-alert-critical{background:rgba(239,68,68,0.15);color:var(--dsw-alias-state-error-primary);border:1px solid rgba(239,68,68,0.4)}
106
+ .drw-alert-warning{background:rgba(245,158,11,0.15);color:var(--dsw-alias-state-warning-primary);border:1px solid rgba(245,158,11,0.4)}
107
+
108
+
93
109
  `
94
110
  document.head.appendChild(style)
95
111
  }
@@ -100,6 +116,48 @@ window.__ModuleLoader__.load({
100
116
  status: 'Ready',
101
117
 
102
118
  tabProfiles: '🖥️ Host Profiles',
119
+
120
+ tabContainers: '🐳 Containers',
121
+
122
+ tabEnv: '🗃️ .env Manager',
123
+ btnImportSsh: 'Import ~/.ssh/config',
124
+ envTitle: 'Remote Environment Variables (.env)',
125
+ envKey: 'Key',
126
+ envVal: 'Value',
127
+ envSaveVar: 'Save',
128
+ envShowVal: 'Show',
129
+ envHideVal: 'Hide',
130
+ tarSyncFast: '🚀 Fast Tarball Stream',
131
+ diagnoseTitle: 'Quick Diagnostics',
132
+ diagnosePorts: 'Open Ports',
133
+ diagnoseOom: 'OOM Killer Check',
134
+ diagnoseDisk: 'Disk Usage',
135
+ alertLabel: 'Alerts ({count})',
136
+
137
+ containersTitle: 'Remote Docker / Podman Engine',
138
+ containerName: 'Container',
139
+ containerImage: 'Image',
140
+ containerStatus: 'Status',
141
+ containerPorts: 'Ports',
142
+ btnLogs: 'Logs',
143
+ btnRestart: 'Restart',
144
+ btnStop: 'Stop',
145
+ noContainers: 'No active containers found or Docker daemon not running.',
146
+ healthCpu: 'CPU: {val}%',
147
+ healthMem: 'RAM: {val}% ({used}/{total} MB)',
148
+ healthDisk: 'Disk: {val}%',
149
+ healthUptime: 'Up: {val}',
150
+ autoSyncTitle: 'Live Auto-Sync on Save',
151
+ autoSyncDesc: 'Automatically push modified files to remote workspace on local save.',
152
+ btnTransfer: 'Copy to Host...',
153
+ transferTitle: 'Server-to-Server Direct Transfer',
154
+ transferDestHost: 'Destination Server',
155
+ transferDestPath: 'Destination File Path',
156
+ transferBtnExec: 'Transfer Directly',
157
+ transferSuccess: 'Successfully transferred {bytes} bytes to {host}:{path}',
158
+ fJumpHost: 'Jump Host (Bastion)',
159
+ fJumpHostNone: 'Direct Connection (No Jump Host)',
160
+
103
161
  tabTerminal: '⚡ Web Terminal',
104
162
  tabExplorer: '📂 File Explorer',
105
163
  tabTunnels: '🔗 Port Tunnels',
@@ -191,80 +249,141 @@ window.__ModuleLoader__.load({
191
249
  tOpenErr: 'Failed to open tunnel: {err}'
192
250
  }
193
251
 
194
- const ru = {
195
- title: 'Удалённое рабочее пространство',
196
- subtitle: 'SSH-подключения, доступ к файлам по SFTP, 3-way синхронизация зеркала и проброс портов.',
197
- status: 'Готово',
198
- badgeOnline: 'Активный хост: {name} ({latency} мс)',
199
- badgeOffline: 'Хост не выбран',
200
- badgeTunnels: 'Туннелей: {count}',
201
- badgeKey: '🔑 Авторизация по ключу',
202
- badgePassword: '🔒 Авторизация по паролю',
203
- profilesTitle: '🖥️ Профили SSH серверов',
204
- profilesDesc: 'Настройка удалённых серверов для запуска команд, SFTP-навигации и синхронизации зеркала.',
205
- addBtn: '+ Добавить хост',
206
- nameCol: 'Имя',
207
- hostCol: 'Хост / Пользователь',
208
- portCol: 'Порт',
209
- pathCol: 'Удалённый путь',
210
- actionsCol: 'Действия',
211
- noHosts: 'Удалённые хосты ещё не настроены. Нажмите «+ Добавить хост» для создания подключения.',
212
- activeBadge: 'Активен',
213
- pinBtn: 'Сделать активным',
214
- editBtn: 'Править',
215
- delBtn: 'Удалить',
216
- delConfirm: 'Удалить профиль хоста «{name}»?',
217
- formTitleNew: 'Добавление нового SSH хоста',
218
- formTitleEdit: 'Редактирование профиля хоста',
219
- fName: 'Название подключения',
220
- fHost: 'Хост / IP-адрес',
221
- fPort: 'Порт',
222
- fUser: 'Имя пользователя',
223
- fAuthType: 'Способ авторизации',
224
- fAuthKey: 'SSH-ключ',
225
- fAuthPass: 'Пароль',
226
- fKeyPath: 'Путь к приватному ключу',
227
- fKeyPathHint: 'Путь к ~/.ssh/id_rsa или /home/user/.ssh/id_ed25519',
228
- fPassphrase: 'Кодовая фраза ключа (если есть)',
229
- fPassword: 'Пароль учётной записи',
230
- fRemotePath: 'Удалённая папка проекта',
231
- fRemotePathHint: 'Абсолютный путь на сервере (например, /home/user/project)',
232
- fLocalMirror: 'Локальная папка зеркала',
233
- fLocalMirrorHint: 'Локальная папка для быстрого зеркалирования и бесконфликтной 3-way синхронизации',
234
- btnBrowse: '📁 Обзор на сервере…',
235
- btnSelectThis: 'Выбрать папку',
236
- btnTest: '⚡ Проверить связь (Ping)',
237
- btnSave: 'Сохранить профиль',
238
- btnCancel: 'Отмена',
239
- testing: 'Проверка соединения…',
240
- saving: 'Сохранение…',
241
- testOk: '✅ Соединение успешно! Задержка: {latency} мс | ОС: {os}',
242
- testFail: '❌ Ошибка подключения: {err}',
243
- diagTitle: '📊 Диагностика активного подключения',
244
- diagDesc: 'Телеметрия в реальном времени и состояние подсистемы для выбранного сервера.',
245
- diagProbeBtn: '⚡ Опросить сервер',
246
- syncTitle: '🔄 Двусторонняя синхронизация зеркала',
247
- syncDesc: 'Бесконфликтная 3-way синхронизация файлов между локальным зеркалом и сервером.',
248
- pullBtn: '⬇️ Скачать с сервера (Pull)',
249
- pushBtn: '⬆️ Загрузить на сервер (Push)',
250
- pulling: 'Скачивание файлов…',
251
- pushing: 'Загрузка файлов…',
252
- syncOk: 'Синхронизация завершена! Обновлено файлов: {count}.',
253
- syncConflict: 'Внимание: конфликт изменений в {count} файлах.',
254
- syncErr: 'Ошибка синхронизации: {err}',
255
- tunnelsTitle: '🔀 SSH-туннели (Port Forwarding)',
256
- tunnelsDesc: 'Проброс удалённых портов (базы данных, dev-серверы, API) на локальный компьютер через зашифрованный канал.',
257
- tLocalPort: 'Локальный порт',
258
- tRemotePort: 'Удалённый порт',
259
- tOpenBtn: '+ Открыть туннель',
260
- tOpening: 'Открытие…',
261
- tStopBtn: 'Закрыть',
262
- tNoTunnels: 'Нет активных туннелей проброса портов.',
263
- tunnelsColId: 'ID туннеля',
264
- tunnelsColRoute: 'Маршрут перенаправления',
265
- tunnelsColActions: 'Действия',
266
- tOpenOk: 'Туннель запущен: 127.0.0.1:{localPort} -> Remote:{remotePort}',
267
- tOpenErr: 'Не удалось запустить туннель: {err}'
252
+ const zh = {
253
+ title: '远程开发工作区',
254
+ subtitle: '支持原生 SSH 连接、SFTP 文件访问、3-way 镜像同步与端口转发隧道。',
255
+ status: '就绪',
256
+
257
+ tabProfiles: '🖥️ 主机配置',
258
+ tabContainers: '🐳 容器管理',
259
+ tabEnv: '🗃️ 环境变量',
260
+ btnImportSsh: '导入 ~/.ssh/config',
261
+ envTitle: '远程环境变量配置 (.env)',
262
+ envKey: '变量名',
263
+ envVal: '变量值',
264
+ envSaveVar: '保存',
265
+ envShowVal: '显示',
266
+ envHideVal: '隐藏',
267
+ tarSyncFast: '🚀 快速 Tarball 流式同步',
268
+ diagnoseTitle: '快速诊断',
269
+ diagnosePorts: '开放端口',
270
+ diagnoseOom: 'OOM 检查',
271
+ diagnoseDisk: '磁盘分析',
272
+ alertLabel: '告警 ({count})',
273
+
274
+ containersTitle: '远程 Docker / Podman 引擎',
275
+ containerName: '容器名',
276
+ containerImage: '镜像',
277
+ containerStatus: '运行状态',
278
+ containerPorts: '端口映射',
279
+ btnLogs: '日志',
280
+ btnRestart: '重启',
281
+ btnStop: '停止',
282
+ noContainers: '未找到运行中的容器或 Docker 守护进程未启动。',
283
+ healthCpu: 'CPU: {val}%',
284
+ healthMem: '内存: {val}% ({used}/{total} MB)',
285
+ healthDisk: '磁盘: {val}%',
286
+ healthUptime: '运行: {val}',
287
+ autoSyncTitle: '保存时自动增量同步',
288
+ autoSyncDesc: '本地文件保存时,后台自动增量推送到远程对应工作目录。',
289
+ btnTransfer: '跨主机传输...',
290
+ transferTitle: '主机间直接流式传输',
291
+ transferDestHost: '目标主机',
292
+ transferDestPath: '目标文件路径',
293
+ transferBtnExec: '立即直传',
294
+ transferSuccess: '成功传输 {bytes} 字节到 {host}:{path}',
295
+ fJumpHost: '跳板机 (Bastion)',
296
+ fJumpHostNone: '直连模式 (无跳板机)',
297
+
298
+ tabTerminal: '⚡ 网页终端',
299
+ tabExplorer: '📂 文件管理',
300
+ tabTunnels: '🔗 端口隧道',
301
+ tabSync: '🔄 同步控制台',
302
+ vaultProtectedBadge: '🛡️ .env 保管库加密保护',
303
+ termStart: '连接终端',
304
+ termStop: '断开连接',
305
+ termClear: '清屏',
306
+ termPlaceholder: '输入命令后回车 (如 htop, ls, docker ps)...',
307
+ termConnected: '已连接至主机 {host} 的 PTY 会话 {id}',
308
+ termDisconnected: '终端已断开。',
309
+ explorerTitle: '远程文件系统浏览',
310
+ filePreviewTitle: '文件查看器: {path}',
311
+ fileSize: '{size} KB',
312
+ btnSaveFile: '保存文件更改',
313
+ fileSaved: '文件保存成功。',
314
+ tunnelTraffic: '下行: {rx} | 上行: {tx}',
315
+ tunnelUptime: '运行: {sec}秒',
316
+ openLocalLink: '🌐 打开本地端口',
317
+ badgeOnline: '当前主机: {name} ({latency} ms)',
318
+ badgeOffline: '未选定活跃主机',
319
+ badgeTunnels: '活动隧道: {count}',
320
+ badgeKey: '🔑 私钥认证',
321
+ badgePassword: '🔒 密码认证',
322
+ profilesTitle: '🖥️ 远程 SSH 主机配置',
323
+ profilesDesc: '配置用于命令执行、SFTP 浏览与后台镜像同步的远程服务器。',
324
+ addBtn: '+ 添加主机',
325
+ nameCol: '配置名称',
326
+ hostCol: '主机 / 用户',
327
+ portCol: '端口',
328
+ pathCol: '远程工作区',
329
+ actionsCol: '操作',
330
+ noHosts: '暂无配置的主机。点击 "+ 添加主机" 建立首个连接。',
331
+ activeBadge: '活动中',
332
+ pinBtn: '设为默认',
333
+ editBtn: '编辑',
334
+ delBtn: '删除',
335
+ delConfirm: '确认删除主机配置 "{name}" 吗?',
336
+ formTitleNew: '添加新主机配置',
337
+ formTitleEdit: '编辑主机配置',
338
+ fName: '配置别名',
339
+ fHost: '主机名 / IP 地址',
340
+ fPort: '端口',
341
+ fUser: '登录用户名',
342
+ fAuthType: '身份认证方式',
343
+ fAuthKey: 'SSH 私钥',
344
+ fAuthPass: '账户密码',
345
+ fKeyPath: '私钥文件路径',
346
+ fKeyPathHint: '例如 ~/.ssh/id_rsa 或 /home/user/.ssh/id_ed25519',
347
+ fPassphrase: '私钥口令 (可选)',
348
+ fPassword: '登录密码',
349
+ fRemotePath: '远程项目工作区目录',
350
+ fRemotePathHint: '服务器上的绝对路径 (例如 /home/user/my-project)',
351
+ fLocalMirror: '本地镜像同步目录',
352
+ fLocalMirrorHint: '用于 3-way 差异比对与快速同步的本地镜像路径',
353
+ btnBrowse: '📁 浏览远程目录...',
354
+ btnSelectThis: '选择当前目录',
355
+ btnTest: '⚡ 测试网络连通性 (Ping)',
356
+ btnSave: '保存配置',
357
+ btnCancel: '取消',
358
+ testing: '正在测试连接...',
359
+ saving: '正在保存...',
360
+ testOk: '✅ 连接成功!延迟: {latency} ms | 系统: {os}',
361
+ testFail: '❌ 连接失败: {err}',
362
+ diagTitle: '📊 实时连接状态诊断',
363
+ diagDesc: '查看当前选定主机的网络延迟、系统资源及底层服务运行指标。',
364
+ diagProbeBtn: '⚡ 即时探测',
365
+ syncTitle: '🔄 双向工作区镜像同步',
366
+ syncDesc: '基于三方冲突感知的快速差异同步,支持本地与远程端双向推拉。',
367
+ pullBtn: '⬇️ 拉取至本地 (Pull)',
368
+ pushBtn: '⬆️ 推送至远程 (Push)',
369
+ pulling: '正在拉取文件…',
370
+ pushing: '正在推送文件…',
371
+ syncOk: '同步完成!共更新 {count} 个文件。',
372
+ syncConflict: '冲突警告: 检测到 {count} 个文件在两端均有修改。',
373
+ syncErr: '同步异常: {err}',
374
+ tunnelsTitle: '🔀 SSH 端口安全隧道',
375
+ tunnelsDesc: '通过加密 SSH 隧道将远程端口 (如数据库、开发服务、API) 映射至本地访问。',
376
+ tLocalPort: '本地监听端口',
377
+ tRemotePort: '远程目标端口',
378
+ tOpenBtn: '+ 建立隧道',
379
+ tOpening: '正在建立…',
380
+ tStopBtn: '关闭',
381
+ tNoTunnels: '当前没有运行中的端口转发隧道。',
382
+ tunnelsColId: '隧道标识',
383
+ tunnelsColRoute: '转发路由',
384
+ tunnelsColActions: '管理操作',
385
+ tOpenOk: '隧道已启动: 127.0.0.1:{localPort} -> Remote:{remotePort}',
386
+ tOpenErr: '启动隧道失败: {err}'
268
387
  }
269
388
 
270
389
  function makeT(dict, fallback) {
@@ -294,6 +413,261 @@ window.__ModuleLoader__.load({
294
413
  }
295
414
 
296
415
 
416
+
417
+
418
+ // --- Sub-component: Remote Environment Tab (New in v0.3.1) ---
419
+ function EnvTab(props) {
420
+ const { activeProfile, t } = props
421
+ const [envEntries, setEnvEntries] = React.useState([])
422
+ const [loading, setLoading] = React.useState(false)
423
+ const [visibleKeys, setVisibleKeys] = React.useState({})
424
+ const [editValues, setEditValues] = React.useState({})
425
+ const [msg, setMsg] = React.useState('')
426
+
427
+ const loadEnv = async () => {
428
+ if (!activeProfile) return
429
+ setLoading(true)
430
+ try {
431
+ const res = await fetch('/dsh-remote-workspace/env/view', {
432
+ method: 'POST',
433
+ headers: { 'Content-Type': 'application/json' },
434
+ body: JSON.stringify({ profileId: activeProfile.id })
435
+ })
436
+ const data = await res.json()
437
+ if (data.ok && Array.isArray(data.entries)) {
438
+ setEnvEntries(data.entries.filter((e) => e.type === 'var'))
439
+ const initialEdits = {}
440
+ data.entries.forEach((e) => {
441
+ if (e.type === 'var') initialEdits[e.key] = e.value
442
+ })
443
+ setEditValues(initialEdits)
444
+ }
445
+ } catch (err) {
446
+ setMsg(`Error loading .env: ${err.message}`)
447
+ }
448
+ setLoading(false)
449
+ }
450
+
451
+ React.useEffect(() => {
452
+ loadEnv()
453
+ }, [activeProfile?.id])
454
+
455
+ const toggleVisible = (key) => {
456
+ setVisibleKeys((prev) => ({ ...prev, [key]: !prev[key] }))
457
+ }
458
+
459
+ const saveVar = async (key) => {
460
+ try {
461
+ const res = await fetch('/dsh-remote-workspace/env/save', {
462
+ method: 'POST',
463
+ headers: { 'Content-Type': 'application/json' },
464
+ body: JSON.stringify({
465
+ profileId: activeProfile.id,
466
+ key,
467
+ value: editValues[key] || ''
468
+ })
469
+ })
470
+ const data = await res.json()
471
+ if (data.ok) {
472
+ setMsg(`Saved ${key} successfully!`)
473
+ setTimeout(() => setMsg(''), 3000)
474
+ }
475
+ } catch (err) {
476
+ setMsg(`Save failed: ${err.message}`)
477
+ }
478
+ }
479
+
480
+ return React.createElement(
481
+ 'div',
482
+ { style: { display: 'flex', flexDirection: 'column', gap: '14px' } },
483
+ React.createElement(
484
+ 'div',
485
+ { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
486
+ React.createElement('div', { style: { fontWeight: 600, fontSize: '14px' } }, t('envTitle')),
487
+ React.createElement('button', { type: 'button', className: 'drw-btn', onClick: loadEnv, disabled: loading }, loading ? 'Loading...' : '🔄 Refresh')
488
+ ),
489
+ msg ? React.createElement('div', { className: 'drw-badge drw-badge-ok' }, msg) : null,
490
+ React.createElement(
491
+ 'div',
492
+ { style: { border: '1px solid var(--dsw-alias-border-l2)', borderRadius: '8px', padding: '12px', background: 'var(--dsw-alias-bg-layer-2)' } },
493
+ envEntries.length === 0
494
+ ? React.createElement('div', { style: { textAlign: 'center', padding: '16px', color: 'var(--dsw-alias-label-secondary)' } }, 'No variables found in remote .env')
495
+ : envEntries.map((e) =>
496
+ React.createElement(
497
+ 'div',
498
+ { key: e.key, className: 'drw-env-row' },
499
+ React.createElement('span', { style: { fontWeight: 600, fontFamily: 'monospace', fontSize: '12.5px' } }, e.key),
500
+ React.createElement('input', {
501
+ type: visibleKeys[e.key] ? 'text' : 'password',
502
+ className: 'drw-input',
503
+ value: editValues[e.key] !== undefined ? editValues[e.key] : e.value,
504
+ onChange: (ev) => {
505
+ const val = ev.target.value
506
+ setEditValues((prev) => ({ ...prev, [e.key]: val }))
507
+ }
508
+ }),
509
+ React.createElement(
510
+ 'div',
511
+ { style: { display: 'flex', gap: '4px', justifyContent: 'flex-end' } },
512
+ React.createElement('button', { type: 'button', className: 'drw-btn', style: { padding: '2px 6px', fontSize: '11px' }, onClick: () => toggleVisible(e.key) }, visibleKeys[e.key] ? t('envHideVal') : t('envShowVal')),
513
+ React.createElement('button', { type: 'button', className: 'drw-btn drw-btn-primary', style: { padding: '2px 6px', fontSize: '11px' }, onClick: () => saveVar(e.key) }, t('envSaveVar'))
514
+ )
515
+ )
516
+ )
517
+ )
518
+ )
519
+ }
520
+
521
+ // --- Sub-component: Docker Containers Tab ---
522
+ function ContainersTab(props) {
523
+ const { activeProfile, t } = props
524
+ const [containers, setContainers] = React.useState([])
525
+ const [loading, setLoading] = React.useState(false)
526
+ const [logsModal, setLogsModal] = React.useState(null)
527
+ const [logsContent, setLogsContent] = React.useState('')
528
+ const [logsLoading, setLogsLoading] = React.useState(false)
529
+
530
+ const loadContainers = async () => {
531
+ if (!activeProfile) return
532
+ setLoading(true)
533
+ try {
534
+ const res = await fetch(`/dsh-remote-workspace/docker/list?profileId=${activeProfile.id}`)
535
+ const data = await res.json()
536
+ if (data.ok && Array.isArray(data.containers)) {
537
+ setContainers(data.containers)
538
+ } else {
539
+ setContainers([])
540
+ }
541
+ } catch (_) {}
542
+ setLoading(false)
543
+ }
544
+
545
+ React.useEffect(() => {
546
+ loadContainers()
547
+ }, [activeProfile?.id])
548
+
549
+ const handleAction = async (containerId, action) => {
550
+ try {
551
+ await fetch('/dsh-remote-workspace/docker/action', {
552
+ method: 'POST',
553
+ headers: { 'Content-Type': 'application/json' },
554
+ body: JSON.stringify({ profileId: activeProfile.id, containerId, action })
555
+ })
556
+ await loadContainers()
557
+ } catch (_) {}
558
+ }
559
+
560
+ const showLogs = async (containerId, name) => {
561
+ setLogsModal({ id: containerId, name })
562
+ setLogsLoading(true)
563
+ try {
564
+ const res = await fetch('/dsh-remote-workspace/docker/logs', {
565
+ method: 'POST',
566
+ headers: { 'Content-Type': 'application/json' },
567
+ body: JSON.stringify({ profileId: activeProfile.id, containerId, tail: 150 })
568
+ })
569
+ const data = await res.json()
570
+ setLogsContent(data.logs || '[No logs output]')
571
+ } catch (err) {
572
+ setLogsContent(`[Error: ${err.message}]`)
573
+ }
574
+ setLogsLoading(false)
575
+ }
576
+
577
+ return React.createElement(
578
+ 'div',
579
+ { style: { display: 'flex', flexDirection: 'column', gap: '12px' } },
580
+ React.createElement(
581
+ 'div',
582
+ { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
583
+ React.createElement('div', { style: { fontWeight: 600, fontSize: '14px' } }, t('containersTitle')),
584
+ React.createElement(
585
+ 'button',
586
+ {
587
+ type: 'button',
588
+ className: 'drw-btn',
589
+ onClick: loadContainers,
590
+ disabled: loading
591
+ },
592
+ loading ? 'Refreshing...' : '🔄 Refresh'
593
+ )
594
+ ),
595
+ React.createElement(
596
+ 'div',
597
+ { style: { border: '1px solid var(--dsw-alias-border-l2)', borderRadius: '8px', overflow: 'hidden', background: 'var(--dsw-alias-bg-layer-2)' } },
598
+ React.createElement(
599
+ 'div',
600
+ { className: 'drw-container-row', style: { fontWeight: 600, color: 'var(--dsw-alias-label-secondary)', background: 'var(--dsw-alias-bg-layer-1)' } },
601
+ React.createElement('span', null, t('containerName')),
602
+ React.createElement('span', null, t('containerImage')),
603
+ React.createElement('span', null, t('containerStatus')),
604
+ React.createElement('span', null, t('containerPorts')),
605
+ React.createElement('span', { style: { textAlign: 'right' } }, t('actionsCol'))
606
+ ),
607
+ containers.length === 0
608
+ ? React.createElement('div', { style: { padding: '24px', textAlign: 'center', color: 'var(--dsw-alias-label-secondary)' } }, t('noContainers'))
609
+ : containers.map((c) =>
610
+ React.createElement(
611
+ 'div',
612
+ { key: c.id, className: 'drw-container-row' },
613
+ React.createElement(
614
+ 'div',
615
+ { style: { display: 'flex', alignItems: 'center', gap: '6px' } },
616
+ React.createElement('span', { className: `drw-badge ${c.state === 'running' ? 'drw-badge-ok' : 'drw-badge-warn'}` }, c.state),
617
+ React.createElement('span', { style: { fontWeight: 600 } }, c.names)
618
+ ),
619
+ React.createElement('span', { style: { opacity: 0.8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, c.image),
620
+ React.createElement('span', { style: { fontSize: '11px', opacity: 0.7 } }, c.status),
621
+ React.createElement('span', { style: { fontSize: '11px', opacity: 0.7, overflow: 'hidden', textOverflow: 'ellipsis' } }, c.ports || '—'),
622
+ React.createElement(
623
+ 'div',
624
+ { style: { display: 'flex', gap: '4px', justifyContent: 'flex-end' } },
625
+ React.createElement(
626
+ 'button',
627
+ { type: 'button', className: 'drw-btn', style: { padding: '2px 6px', fontSize: '11px' }, onClick: () => showLogs(c.id, c.names) },
628
+ t('btnLogs')
629
+ ),
630
+ React.createElement(
631
+ 'button',
632
+ { type: 'button', className: 'drw-btn', style: { padding: '2px 6px', fontSize: '11px' }, onClick: () => handleAction(c.id, 'restart') },
633
+ t('btnRestart')
634
+ ),
635
+ c.state === 'running'
636
+ ? React.createElement(
637
+ 'button',
638
+ { type: 'button', className: 'drw-btn drw-btn-danger', style: { padding: '2px 6px', fontSize: '11px' }, onClick: () => handleAction(c.id, 'stop') },
639
+ t('btnStop')
640
+ )
641
+ : null
642
+ )
643
+ )
644
+ )
645
+ ),
646
+ // Container Logs Modal
647
+ logsModal
648
+ ? React.createElement(
649
+ 'div',
650
+ { className: 'drw-modal-overlay', onClick: () => setLogsModal(null) },
651
+ React.createElement(
652
+ 'div',
653
+ { className: 'drw-modal-dialog', onClick: (e) => e.stopPropagation() },
654
+ React.createElement(
655
+ 'div',
656
+ { className: 'drw-modal-hdr' },
657
+ React.createElement('span', { style: { fontFamily: 'monospace' } }, `Docker Logs: ${logsModal.name} (${logsModal.id})`),
658
+ React.createElement('button', { type: 'button', className: 'drw-btn', onClick: () => setLogsModal(null) }, '✕')
659
+ ),
660
+ React.createElement(
661
+ 'div',
662
+ { className: 'drw-modal-body' },
663
+ React.createElement('pre', { style: { background: '#0d1117', color: '#c9d1d9', padding: '12px', borderRadius: '8px', fontSize: '12px', maxHeight: '420px', overflowY: 'auto', whiteSpace: 'pre-wrap' } }, logsLoading ? 'Fetching logs...' : logsContent)
664
+ )
665
+ )
666
+ )
667
+ : null
668
+ )
669
+ }
670
+
297
671
  // --- Sub-component: Web Terminal Tab ---
298
672
  function TerminalTab(props) {
299
673
  const { activeProfile, t } = props
@@ -737,8 +1111,10 @@ window.__ModuleLoader__.load({
737
1111
 
738
1112
  function SettingsView(props) {
739
1113
  const [activeTab, setActiveTab] = React.useState('profiles')
1114
+ const [healthData, setHealthData] = React.useState(null)
1115
+ const [autoSync, setAutoSync] = React.useState(false)
740
1116
 
741
- const t = props.locale === 'ru' ? makeT(ru, en) : makeT(en, ru)
1117
+ const t = props.t || (props.locale === 'zh' ? makeT(zh, en) : makeT(en, zh))
742
1118
  const [profiles, setProfiles] = React.useState([])
743
1119
  const [activeId, setActiveId] = React.useState(null)
744
1120
  const [tunnels, setTunnels] = React.useState([])
@@ -768,6 +1144,10 @@ window.__ModuleLoader__.load({
768
1144
  setProfiles(data.profiles || [])
769
1145
  setActiveId(data.activeId || null)
770
1146
  setTunnels(data.tunnels || [])
1147
+ setAutoSync(Boolean(data.autoSync))
1148
+ if (data.activeId) {
1149
+ fetch(`/dsh-remote-workspace/health?profileId=${data.activeId}`).then(r => r.json()).then(h => { if (h.ok) setHealthData(h.health) }).catch(() => {})
1150
+ }
771
1151
  }
772
1152
  }
773
1153
  } catch (_) {}
@@ -1482,6 +1862,39 @@ window.__ModuleLoader__.load({
1482
1862
  { className: 'drw-card' },
1483
1863
  React.createElement('div', { className: 'drw-card-title' }, t('syncTitle')),
1484
1864
  React.createElement('div', { className: 'drw-card-desc' }, t('syncDesc')),
1865
+
1866
+ React.createElement(
1867
+ 'div',
1868
+ { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', background: 'var(--dsw-alias-bg-layer-2)', borderRadius: '8px', border: '1px solid var(--dsw-alias-border-l2)', marginBottom: '12px' } },
1869
+ React.createElement(
1870
+ 'div',
1871
+ { style: { display: 'flex', flexDirection: 'column', gap: '2px' } },
1872
+ React.createElement('span', { style: { fontWeight: 600, fontSize: '13px' } }, t('autoSyncTitle')),
1873
+ React.createElement('span', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } }, t('autoSyncDesc'))
1874
+ ),
1875
+ React.createElement(
1876
+ 'button',
1877
+ {
1878
+ type: 'button',
1879
+ className: `drw-btn ${autoSync ? 'drw-btn-primary' : ''}`,
1880
+ style: { padding: '4px 12px', fontSize: '12px' },
1881
+ onClick: async () => {
1882
+ const next = !autoSync
1883
+ try {
1884
+ const res = await fetch('/dsh-remote-workspace/watcher/toggle', {
1885
+ method: 'POST',
1886
+ headers: { 'Content-Type': 'application/json' },
1887
+ body: JSON.stringify({ enabled: next })
1888
+ })
1889
+ const d = await res.json()
1890
+ if (d.ok) setAutoSync(d.autoSync)
1891
+ } catch (_) {}
1892
+ }
1893
+ },
1894
+ autoSync ? '⚡ Active (ON)' : '⚪ Off'
1895
+ )
1896
+ ),
1897
+
1485
1898
  React.createElement(
1486
1899
  'div',
1487
1900
  { className: 'drw-row' },
@@ -1641,7 +2054,7 @@ window.__ModuleLoader__.load({
1641
2054
  // Accordion item for Settings -> Plugins section
1642
2055
  function PluginCard(props) {
1643
2056
  const [open, setOpen] = React.useState(false)
1644
- const t = props.locale === 'ru' ? makeT(ru, en) : makeT(en, ru)
2057
+ const t = props.t || (props.locale === 'zh' ? makeT(zh, en) : makeT(en, zh))
1645
2058
 
1646
2059
  React.useEffect(() => {
1647
2060
  ensureCss()
@@ -1731,7 +2144,7 @@ window.__ModuleLoader__.load({
1731
2144
 
1732
2145
  function apply(ctx) {
1733
2146
  if (ctx.locale && typeof ctx.locale.register === 'function') {
1734
- try { ctx.locale.register(NS, { en, ru }) } catch (_) {}
2147
+ try { ctx.locale.register(NS, { en, zh }) } catch (_) {}
1735
2148
  }
1736
2149
 
1737
2150
  if (ctx.slots && typeof ctx.slots.inject === 'function') {