@goodandready/dsh-remote-workspace 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +225 -0
- package/lib/docker-service.js +93 -0
- package/lib/health-service.js +117 -0
- package/lib/index.js +57 -7
- package/lib/routes.js +126 -2
- package/lib/ssh-service.js +59 -20
- package/lib/tools.js +99 -1
- package/lib/watcher-service.js +111 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -90,6 +90,15 @@ 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
|
+
|
|
93
102
|
`
|
|
94
103
|
document.head.appendChild(style)
|
|
95
104
|
}
|
|
@@ -100,6 +109,32 @@ window.__ModuleLoader__.load({
|
|
|
100
109
|
status: 'Ready',
|
|
101
110
|
|
|
102
111
|
tabProfiles: '🖥️ Host Profiles',
|
|
112
|
+
|
|
113
|
+
tabContainers: '🐳 Containers',
|
|
114
|
+
containersTitle: 'Remote Docker / Podman Engine',
|
|
115
|
+
containerName: 'Container',
|
|
116
|
+
containerImage: 'Image',
|
|
117
|
+
containerStatus: 'Status',
|
|
118
|
+
containerPorts: 'Ports',
|
|
119
|
+
btnLogs: 'Logs',
|
|
120
|
+
btnRestart: 'Restart',
|
|
121
|
+
btnStop: 'Stop',
|
|
122
|
+
noContainers: 'No active containers found or Docker daemon not running.',
|
|
123
|
+
healthCpu: 'CPU: {val}%',
|
|
124
|
+
healthMem: 'RAM: {val}% ({used}/{total} MB)',
|
|
125
|
+
healthDisk: 'Disk: {val}%',
|
|
126
|
+
healthUptime: 'Up: {val}',
|
|
127
|
+
autoSyncTitle: 'Live Auto-Sync on Save',
|
|
128
|
+
autoSyncDesc: 'Automatically push modified files to remote workspace on local save.',
|
|
129
|
+
btnTransfer: 'Copy to Host...',
|
|
130
|
+
transferTitle: 'Server-to-Server Direct Transfer',
|
|
131
|
+
transferDestHost: 'Destination Server',
|
|
132
|
+
transferDestPath: 'Destination File Path',
|
|
133
|
+
transferBtnExec: 'Transfer Directly',
|
|
134
|
+
transferSuccess: 'Successfully transferred {bytes} bytes to {host}:{path}',
|
|
135
|
+
fJumpHost: 'Jump Host (Bastion)',
|
|
136
|
+
fJumpHostNone: 'Direct Connection (No Jump Host)',
|
|
137
|
+
|
|
103
138
|
tabTerminal: '⚡ Web Terminal',
|
|
104
139
|
tabExplorer: '📂 File Explorer',
|
|
105
140
|
tabTunnels: '🔗 Port Tunnels',
|
|
@@ -294,6 +329,157 @@ window.__ModuleLoader__.load({
|
|
|
294
329
|
}
|
|
295
330
|
|
|
296
331
|
|
|
332
|
+
|
|
333
|
+
// --- Sub-component: Docker Containers Tab ---
|
|
334
|
+
function ContainersTab(props) {
|
|
335
|
+
const { activeProfile, t } = props
|
|
336
|
+
const [containers, setContainers] = React.useState([])
|
|
337
|
+
const [loading, setLoading] = React.useState(false)
|
|
338
|
+
const [logsModal, setLogsModal] = React.useState(null)
|
|
339
|
+
const [logsContent, setLogsContent] = React.useState('')
|
|
340
|
+
const [logsLoading, setLogsLoading] = React.useState(false)
|
|
341
|
+
|
|
342
|
+
const loadContainers = async () => {
|
|
343
|
+
if (!activeProfile) return
|
|
344
|
+
setLoading(true)
|
|
345
|
+
try {
|
|
346
|
+
const res = await fetch(`/dsh-remote-workspace/docker/list?profileId=${activeProfile.id}`)
|
|
347
|
+
const data = await res.json()
|
|
348
|
+
if (data.ok && Array.isArray(data.containers)) {
|
|
349
|
+
setContainers(data.containers)
|
|
350
|
+
} else {
|
|
351
|
+
setContainers([])
|
|
352
|
+
}
|
|
353
|
+
} catch (_) {}
|
|
354
|
+
setLoading(false)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
React.useEffect(() => {
|
|
358
|
+
loadContainers()
|
|
359
|
+
}, [activeProfile?.id])
|
|
360
|
+
|
|
361
|
+
const handleAction = async (containerId, action) => {
|
|
362
|
+
try {
|
|
363
|
+
await fetch('/dsh-remote-workspace/docker/action', {
|
|
364
|
+
method: 'POST',
|
|
365
|
+
headers: { 'Content-Type': 'application/json' },
|
|
366
|
+
body: JSON.stringify({ profileId: activeProfile.id, containerId, action })
|
|
367
|
+
})
|
|
368
|
+
await loadContainers()
|
|
369
|
+
} catch (_) {}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const showLogs = async (containerId, name) => {
|
|
373
|
+
setLogsModal({ id: containerId, name })
|
|
374
|
+
setLogsLoading(true)
|
|
375
|
+
try {
|
|
376
|
+
const res = await fetch('/dsh-remote-workspace/docker/logs', {
|
|
377
|
+
method: 'POST',
|
|
378
|
+
headers: { 'Content-Type': 'application/json' },
|
|
379
|
+
body: JSON.stringify({ profileId: activeProfile.id, containerId, tail: 150 })
|
|
380
|
+
})
|
|
381
|
+
const data = await res.json()
|
|
382
|
+
setLogsContent(data.logs || '[No logs output]')
|
|
383
|
+
} catch (err) {
|
|
384
|
+
setLogsContent(`[Error: ${err.message}]`)
|
|
385
|
+
}
|
|
386
|
+
setLogsLoading(false)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return React.createElement(
|
|
390
|
+
'div',
|
|
391
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: '12px' } },
|
|
392
|
+
React.createElement(
|
|
393
|
+
'div',
|
|
394
|
+
{ style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
|
|
395
|
+
React.createElement('div', { style: { fontWeight: 600, fontSize: '14px' } }, t('containersTitle')),
|
|
396
|
+
React.createElement(
|
|
397
|
+
'button',
|
|
398
|
+
{
|
|
399
|
+
type: 'button',
|
|
400
|
+
className: 'drw-btn',
|
|
401
|
+
onClick: loadContainers,
|
|
402
|
+
disabled: loading
|
|
403
|
+
},
|
|
404
|
+
loading ? 'Refreshing...' : '🔄 Refresh'
|
|
405
|
+
)
|
|
406
|
+
),
|
|
407
|
+
React.createElement(
|
|
408
|
+
'div',
|
|
409
|
+
{ style: { border: '1px solid var(--dsw-alias-border-l2)', borderRadius: '8px', overflow: 'hidden', background: 'var(--dsw-alias-bg-layer-2)' } },
|
|
410
|
+
React.createElement(
|
|
411
|
+
'div',
|
|
412
|
+
{ className: 'drw-container-row', style: { fontWeight: 600, color: 'var(--dsw-alias-label-secondary)', background: 'var(--dsw-alias-bg-layer-1)' } },
|
|
413
|
+
React.createElement('span', null, t('containerName')),
|
|
414
|
+
React.createElement('span', null, t('containerImage')),
|
|
415
|
+
React.createElement('span', null, t('containerStatus')),
|
|
416
|
+
React.createElement('span', null, t('containerPorts')),
|
|
417
|
+
React.createElement('span', { style: { textAlign: 'right' } }, t('actionsCol'))
|
|
418
|
+
),
|
|
419
|
+
containers.length === 0
|
|
420
|
+
? React.createElement('div', { style: { padding: '24px', textAlign: 'center', color: 'var(--dsw-alias-label-secondary)' } }, t('noContainers'))
|
|
421
|
+
: containers.map((c) =>
|
|
422
|
+
React.createElement(
|
|
423
|
+
'div',
|
|
424
|
+
{ key: c.id, className: 'drw-container-row' },
|
|
425
|
+
React.createElement(
|
|
426
|
+
'div',
|
|
427
|
+
{ style: { display: 'flex', alignItems: 'center', gap: '6px' } },
|
|
428
|
+
React.createElement('span', { className: `drw-badge ${c.state === 'running' ? 'drw-badge-ok' : 'drw-badge-warn'}` }, c.state),
|
|
429
|
+
React.createElement('span', { style: { fontWeight: 600 } }, c.names)
|
|
430
|
+
),
|
|
431
|
+
React.createElement('span', { style: { opacity: 0.8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, c.image),
|
|
432
|
+
React.createElement('span', { style: { fontSize: '11px', opacity: 0.7 } }, c.status),
|
|
433
|
+
React.createElement('span', { style: { fontSize: '11px', opacity: 0.7, overflow: 'hidden', textOverflow: 'ellipsis' } }, c.ports || '—'),
|
|
434
|
+
React.createElement(
|
|
435
|
+
'div',
|
|
436
|
+
{ style: { display: 'flex', gap: '4px', justifyContent: 'flex-end' } },
|
|
437
|
+
React.createElement(
|
|
438
|
+
'button',
|
|
439
|
+
{ type: 'button', className: 'drw-btn', style: { padding: '2px 6px', fontSize: '11px' }, onClick: () => showLogs(c.id, c.names) },
|
|
440
|
+
t('btnLogs')
|
|
441
|
+
),
|
|
442
|
+
React.createElement(
|
|
443
|
+
'button',
|
|
444
|
+
{ type: 'button', className: 'drw-btn', style: { padding: '2px 6px', fontSize: '11px' }, onClick: () => handleAction(c.id, 'restart') },
|
|
445
|
+
t('btnRestart')
|
|
446
|
+
),
|
|
447
|
+
c.state === 'running'
|
|
448
|
+
? React.createElement(
|
|
449
|
+
'button',
|
|
450
|
+
{ type: 'button', className: 'drw-btn drw-btn-danger', style: { padding: '2px 6px', fontSize: '11px' }, onClick: () => handleAction(c.id, 'stop') },
|
|
451
|
+
t('btnStop')
|
|
452
|
+
)
|
|
453
|
+
: null
|
|
454
|
+
)
|
|
455
|
+
)
|
|
456
|
+
)
|
|
457
|
+
),
|
|
458
|
+
// Container Logs Modal
|
|
459
|
+
logsModal
|
|
460
|
+
? React.createElement(
|
|
461
|
+
'div',
|
|
462
|
+
{ className: 'drw-modal-overlay', onClick: () => setLogsModal(null) },
|
|
463
|
+
React.createElement(
|
|
464
|
+
'div',
|
|
465
|
+
{ className: 'drw-modal-dialog', onClick: (e) => e.stopPropagation() },
|
|
466
|
+
React.createElement(
|
|
467
|
+
'div',
|
|
468
|
+
{ className: 'drw-modal-hdr' },
|
|
469
|
+
React.createElement('span', { style: { fontFamily: 'monospace' } }, `Docker Logs: ${logsModal.name} (${logsModal.id})`),
|
|
470
|
+
React.createElement('button', { type: 'button', className: 'drw-btn', onClick: () => setLogsModal(null) }, '✕')
|
|
471
|
+
),
|
|
472
|
+
React.createElement(
|
|
473
|
+
'div',
|
|
474
|
+
{ className: 'drw-modal-body' },
|
|
475
|
+
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)
|
|
476
|
+
)
|
|
477
|
+
)
|
|
478
|
+
)
|
|
479
|
+
: null
|
|
480
|
+
)
|
|
481
|
+
}
|
|
482
|
+
|
|
297
483
|
// --- Sub-component: Web Terminal Tab ---
|
|
298
484
|
function TerminalTab(props) {
|
|
299
485
|
const { activeProfile, t } = props
|
|
@@ -737,6 +923,8 @@ window.__ModuleLoader__.load({
|
|
|
737
923
|
|
|
738
924
|
function SettingsView(props) {
|
|
739
925
|
const [activeTab, setActiveTab] = React.useState('profiles')
|
|
926
|
+
const [healthData, setHealthData] = React.useState(null)
|
|
927
|
+
const [autoSync, setAutoSync] = React.useState(false)
|
|
740
928
|
|
|
741
929
|
const t = props.locale === 'ru' ? makeT(ru, en) : makeT(en, ru)
|
|
742
930
|
const [profiles, setProfiles] = React.useState([])
|
|
@@ -768,6 +956,10 @@ window.__ModuleLoader__.load({
|
|
|
768
956
|
setProfiles(data.profiles || [])
|
|
769
957
|
setActiveId(data.activeId || null)
|
|
770
958
|
setTunnels(data.tunnels || [])
|
|
959
|
+
setAutoSync(Boolean(data.autoSync))
|
|
960
|
+
if (data.activeId) {
|
|
961
|
+
fetch(`/dsh-remote-workspace/health?profileId=${data.activeId}`).then(r => r.json()).then(h => { if (h.ok) setHealthData(h.health) }).catch(() => {})
|
|
962
|
+
}
|
|
771
963
|
}
|
|
772
964
|
}
|
|
773
965
|
} catch (_) {}
|
|
@@ -1482,6 +1674,39 @@ window.__ModuleLoader__.load({
|
|
|
1482
1674
|
{ className: 'drw-card' },
|
|
1483
1675
|
React.createElement('div', { className: 'drw-card-title' }, t('syncTitle')),
|
|
1484
1676
|
React.createElement('div', { className: 'drw-card-desc' }, t('syncDesc')),
|
|
1677
|
+
|
|
1678
|
+
React.createElement(
|
|
1679
|
+
'div',
|
|
1680
|
+
{ 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' } },
|
|
1681
|
+
React.createElement(
|
|
1682
|
+
'div',
|
|
1683
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: '2px' } },
|
|
1684
|
+
React.createElement('span', { style: { fontWeight: 600, fontSize: '13px' } }, t('autoSyncTitle')),
|
|
1685
|
+
React.createElement('span', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } }, t('autoSyncDesc'))
|
|
1686
|
+
),
|
|
1687
|
+
React.createElement(
|
|
1688
|
+
'button',
|
|
1689
|
+
{
|
|
1690
|
+
type: 'button',
|
|
1691
|
+
className: `drw-btn ${autoSync ? 'drw-btn-primary' : ''}`,
|
|
1692
|
+
style: { padding: '4px 12px', fontSize: '12px' },
|
|
1693
|
+
onClick: async () => {
|
|
1694
|
+
const next = !autoSync
|
|
1695
|
+
try {
|
|
1696
|
+
const res = await fetch('/dsh-remote-workspace/watcher/toggle', {
|
|
1697
|
+
method: 'POST',
|
|
1698
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1699
|
+
body: JSON.stringify({ enabled: next })
|
|
1700
|
+
})
|
|
1701
|
+
const d = await res.json()
|
|
1702
|
+
if (d.ok) setAutoSync(d.autoSync)
|
|
1703
|
+
} catch (_) {}
|
|
1704
|
+
}
|
|
1705
|
+
},
|
|
1706
|
+
autoSync ? '⚡ Active (ON)' : '⚪ Off'
|
|
1707
|
+
)
|
|
1708
|
+
),
|
|
1709
|
+
|
|
1485
1710
|
React.createElement(
|
|
1486
1711
|
'div',
|
|
1487
1712
|
{ className: 'drw-row' },
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export class DockerService {
|
|
2
|
+
/**
|
|
3
|
+
* @param {import('./ssh-service.js').SshService} sshService
|
|
4
|
+
*/
|
|
5
|
+
constructor(sshService) {
|
|
6
|
+
this.sshService = sshService;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* List active or all containers with JSON formatting
|
|
11
|
+
*/
|
|
12
|
+
async listContainers(profile, all = true) {
|
|
13
|
+
// Detect docker or podman
|
|
14
|
+
const flag = all ? '-a' : '';
|
|
15
|
+
// Format as line-delimited JSON or tabbed format
|
|
16
|
+
const cmd = `docker ps ${flag} --format '{{json .}}' 2>/dev/null || podman ps ${flag} --format '{{json .}}' 2>/dev/null`;
|
|
17
|
+
const res = await this.sshService.exec(profile, cmd);
|
|
18
|
+
|
|
19
|
+
if (res.code !== 0 && !res.stdout.trim()) {
|
|
20
|
+
return {
|
|
21
|
+
available: false,
|
|
22
|
+
error: res.stderr || 'Docker not found or daemon not running',
|
|
23
|
+
containers: []
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const containers = [];
|
|
28
|
+
const lines = res.stdout.split('\n').filter(Boolean);
|
|
29
|
+
for (const line of lines) {
|
|
30
|
+
try {
|
|
31
|
+
const item = JSON.parse(line);
|
|
32
|
+
containers.push({
|
|
33
|
+
id: item.ID || item.Id || '',
|
|
34
|
+
names: item.Names || item.Name || '',
|
|
35
|
+
image: item.Image || '',
|
|
36
|
+
status: item.Status || '',
|
|
37
|
+
state: (item.State || '').toLowerCase(),
|
|
38
|
+
ports: item.Ports || '',
|
|
39
|
+
created: item.CreatedAt || item.Created || ''
|
|
40
|
+
});
|
|
41
|
+
} catch (_) {}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
available: true,
|
|
46
|
+
containers
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Container action: start, stop, restart, pause, unpause
|
|
52
|
+
*/
|
|
53
|
+
async containerAction(profile, containerId, action) {
|
|
54
|
+
const validActions = ['start', 'stop', 'restart', 'pause', 'unpause', 'rm'];
|
|
55
|
+
if (!validActions.includes(action)) {
|
|
56
|
+
throw new Error(`Invalid container action: ${action}`);
|
|
57
|
+
}
|
|
58
|
+
const cmd = `docker ${action} ${JSON.stringify(containerId)} 2>/dev/null || podman ${action} ${JSON.stringify(containerId)}`;
|
|
59
|
+
const res = await this.sshService.exec(profile, cmd);
|
|
60
|
+
return {
|
|
61
|
+
success: res.code === 0,
|
|
62
|
+
stdout: res.stdout.trim(),
|
|
63
|
+
stderr: res.stderr.trim()
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Fetch container logs
|
|
69
|
+
*/
|
|
70
|
+
async containerLogs(profile, containerId, tail = 100) {
|
|
71
|
+
const cmd = `docker logs --tail ${tail} ${JSON.stringify(containerId)} 2>&1 || podman logs --tail ${tail} ${JSON.stringify(containerId)} 2>&1`;
|
|
72
|
+
const res = await this.sshService.exec(profile, cmd);
|
|
73
|
+
return {
|
|
74
|
+
success: res.code === 0,
|
|
75
|
+
logs: res.stdout || res.stderr || ''
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Docker compose lifecycle
|
|
81
|
+
*/
|
|
82
|
+
async composeAction(profile, cwd, action = 'ps') {
|
|
83
|
+
const valid = ['up -d', 'down', 'ps', 'restart', 'pull'];
|
|
84
|
+
const safeAction = valid.includes(action) ? action : 'ps';
|
|
85
|
+
const cmd = `docker compose ${safeAction} 2>/dev/null || docker-compose ${safeAction} 2>/dev/null || podman-compose ${safeAction}`;
|
|
86
|
+
const res = await this.sshService.exec(profile, cmd, cwd);
|
|
87
|
+
return {
|
|
88
|
+
success: res.code === 0,
|
|
89
|
+
stdout: res.stdout,
|
|
90
|
+
stderr: res.stderr
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
export class HealthService {
|
|
2
|
+
/**
|
|
3
|
+
* @param {import('./ssh-service.js').SshService} sshService
|
|
4
|
+
*/
|
|
5
|
+
constructor(sshService) {
|
|
6
|
+
this.sshService = sshService;
|
|
7
|
+
/** @type {Map<string, { data: any, expiresAt: number }>} */
|
|
8
|
+
this.cache = new Map();
|
|
9
|
+
this.cacheTtlMs = 4000;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async getHealth(profile, bypassCache = false) {
|
|
13
|
+
if (!profile || !profile.host) {
|
|
14
|
+
throw new Error('Invalid profile');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const cacheKey = profile.id;
|
|
18
|
+
if (!bypassCache) {
|
|
19
|
+
const cached = this.cache.get(cacheKey);
|
|
20
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
21
|
+
return cached.data;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Single compact shell command to gather all stats at once:
|
|
26
|
+
// 1. uptime (load averages)
|
|
27
|
+
// 2. memory (free -m)
|
|
28
|
+
// 3. disk usage (df -k /)
|
|
29
|
+
// 4. cpu info (cores & idle via /proc/stat or top)
|
|
30
|
+
const probeCmd = `
|
|
31
|
+
echo "---UPTIME---"
|
|
32
|
+
uptime 2>/dev/null || true
|
|
33
|
+
echo "---MEM---"
|
|
34
|
+
free -m 2>/dev/null || true
|
|
35
|
+
echo "---DISK---"
|
|
36
|
+
df -m / 2>/dev/null || df -m . 2>/dev/null || true
|
|
37
|
+
echo "---CPU---"
|
|
38
|
+
grep 'cpu ' /proc/stat 2>/dev/null || true
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
const res = await this.sshService.exec(profile, probeCmd);
|
|
42
|
+
const text = res.stdout || '';
|
|
43
|
+
|
|
44
|
+
const uptimeMatch = text.match(/---UPTIME---\n([\s\S]*?)(?=---MEM---)/);
|
|
45
|
+
const memMatch = text.match(/---MEM---\n([\s\S]*?)(?=---DISK---)/);
|
|
46
|
+
const diskMatch = text.match(/---DISK---\n([\s\S]*?)(?=---CPU---)/);
|
|
47
|
+
|
|
48
|
+
// 1. Parse Uptime & Load Avg
|
|
49
|
+
let uptimeStr = 'Unknown';
|
|
50
|
+
let loadAvg = [0, 0, 0];
|
|
51
|
+
if (uptimeMatch) {
|
|
52
|
+
const upLine = uptimeMatch[1].trim();
|
|
53
|
+
const loadMatch = upLine.match(/load average[s]?:\s*([\d.]+),?\s*([\d.]+),?\s*([\d.]+)/);
|
|
54
|
+
if (loadMatch) {
|
|
55
|
+
loadAvg = [parseFloat(loadMatch[1]), parseFloat(loadMatch[2]), parseFloat(loadMatch[3])];
|
|
56
|
+
}
|
|
57
|
+
const upPart = upLine.match(/up\s+(.*?),\s+\d+\s+user/);
|
|
58
|
+
if (upPart) uptimeStr = upPart[1];
|
|
59
|
+
else uptimeStr = upLine.split(',')[0] || 'Online';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 2. Parse Memory
|
|
63
|
+
let memTotal = 0;
|
|
64
|
+
let memUsed = 0;
|
|
65
|
+
let memPercent = 0;
|
|
66
|
+
if (memMatch) {
|
|
67
|
+
const memLines = memMatch[1].trim().split('\n');
|
|
68
|
+
for (const line of memLines) {
|
|
69
|
+
if (line.startsWith('Mem:')) {
|
|
70
|
+
const parts = line.split(/\s+/);
|
|
71
|
+
memTotal = parseInt(parts[1], 10) || 0;
|
|
72
|
+
memUsed = parseInt(parts[2], 10) || 0;
|
|
73
|
+
if (memTotal > 0) {
|
|
74
|
+
memPercent = Math.round((memUsed / memTotal) * 100);
|
|
75
|
+
}
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 3. Parse Disk
|
|
82
|
+
let diskTotal = 0;
|
|
83
|
+
let diskUsed = 0;
|
|
84
|
+
let diskPercent = 0;
|
|
85
|
+
if (diskMatch) {
|
|
86
|
+
const diskLines = diskMatch[1].trim().split('\n');
|
|
87
|
+
if (diskLines.length > 1) {
|
|
88
|
+
const parts = diskLines[1].split(/\s+/);
|
|
89
|
+
diskTotal = parseInt(parts[1], 10) || 0;
|
|
90
|
+
diskUsed = parseInt(parts[2], 10) || 0;
|
|
91
|
+
const pStr = parts[4] || '';
|
|
92
|
+
diskPercent = parseInt(pStr.replace('%', ''), 10) || (diskTotal > 0 ? Math.round((diskUsed / diskTotal) * 100) : 0);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const result = {
|
|
97
|
+
ok: true,
|
|
98
|
+
timestamp: Date.now(),
|
|
99
|
+
uptime: uptimeStr,
|
|
100
|
+
loadAverage: loadAvg,
|
|
101
|
+
cpuLoad: Math.min(100, Math.round(loadAvg[0] * 25)), // Normalized approx representation
|
|
102
|
+
memory: {
|
|
103
|
+
totalMb: memTotal,
|
|
104
|
+
usedMb: memUsed,
|
|
105
|
+
percent: memPercent
|
|
106
|
+
},
|
|
107
|
+
disk: {
|
|
108
|
+
totalMb: diskTotal,
|
|
109
|
+
usedMb: diskUsed,
|
|
110
|
+
percent: diskPercent
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
this.cache.set(cacheKey, { data: result, expiresAt: Date.now() + this.cacheTtlMs });
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -4,6 +4,9 @@ import { SshService } from './ssh-service.js';
|
|
|
4
4
|
import { RemoteFsService } from './remote-fs-service.js';
|
|
5
5
|
import { MirrorSyncService } from './mirror-sync-service.js';
|
|
6
6
|
import { TunnelService } from './tunnel-service.js';
|
|
7
|
+
import { DockerService } from './docker-service.js';
|
|
8
|
+
import { HealthService } from './health-service.js';
|
|
9
|
+
import { WatcherService } from './watcher-service.js';
|
|
7
10
|
import { registerRemoteTools } from './tools.js';
|
|
8
11
|
import { registerApiRoutes } from './routes.js';
|
|
9
12
|
|
|
@@ -26,9 +29,11 @@ export const Config = z.object({
|
|
|
26
29
|
passphrase: z.string().default(''),
|
|
27
30
|
password: z.string().default(''),
|
|
28
31
|
remoteWorkspace: z.string().default(''),
|
|
29
|
-
localMirrorPath: z.string().default('')
|
|
32
|
+
localMirrorPath: z.string().default(''),
|
|
33
|
+
jumpHostId: z.string().default('')
|
|
30
34
|
})).default([]).description('List of configured remote machines and workspaces.'),
|
|
31
|
-
activeProfileId: z.string().default('').description('ID of active remote profile.')
|
|
35
|
+
activeProfileId: z.string().default('').description('ID of active remote profile.'),
|
|
36
|
+
autoSyncActive: z.boolean().default(false).description('Whether Live Auto-Sync watcher is enabled.')
|
|
32
37
|
});
|
|
33
38
|
|
|
34
39
|
export function apply(ctx, config = {}) {
|
|
@@ -36,7 +41,8 @@ export function apply(ctx, config = {}) {
|
|
|
36
41
|
let settingsApi = null;
|
|
37
42
|
let currentConfig = {
|
|
38
43
|
profiles: Array.isArray(cfg.profiles) ? cfg.profiles : [],
|
|
39
|
-
activeProfileId: cfg.activeProfileId || null
|
|
44
|
+
activeProfileId: cfg.activeProfileId || null,
|
|
45
|
+
autoSyncActive: Boolean(cfg.autoSyncActive)
|
|
40
46
|
};
|
|
41
47
|
|
|
42
48
|
const vaultService = new VaultService();
|
|
@@ -44,6 +50,9 @@ export function apply(ctx, config = {}) {
|
|
|
44
50
|
const remoteFs = new RemoteFsService(sshService);
|
|
45
51
|
const mirrorSync = new MirrorSyncService(remoteFs, sshService);
|
|
46
52
|
const tunnelService = new TunnelService(sshService);
|
|
53
|
+
const dockerService = new DockerService(sshService);
|
|
54
|
+
const healthService = new HealthService(sshService);
|
|
55
|
+
const watcherService = new WatcherService();
|
|
47
56
|
|
|
48
57
|
// Migrate any existing plain passwords into vault upon start
|
|
49
58
|
for (const p of currentConfig.profiles) {
|
|
@@ -78,11 +87,22 @@ export function apply(ctx, config = {}) {
|
|
|
78
87
|
const p = (currentConfig.profiles || []).find((x) => x.id === currentConfig.activeProfileId);
|
|
79
88
|
return p ? vaultService.hydrateProfile(p) : undefined;
|
|
80
89
|
},
|
|
90
|
+
isAutoSyncEnabled() { return Boolean(currentConfig.autoSyncActive); },
|
|
91
|
+
async setAutoSyncEnabled(enabled) {
|
|
92
|
+
currentConfig.autoSyncActive = Boolean(enabled);
|
|
93
|
+
const active = store.getActiveProfile();
|
|
94
|
+
if (active) {
|
|
95
|
+
if (enabled) watcherService.start(active, mirrorSync);
|
|
96
|
+
else watcherService.stop(active.id);
|
|
97
|
+
}
|
|
98
|
+
if (settingsApi?.replace) {
|
|
99
|
+
try { await settingsApi.replace(Config(currentConfig)); } catch (_) {}
|
|
100
|
+
}
|
|
101
|
+
},
|
|
81
102
|
async saveProfile(rawP) {
|
|
82
103
|
const list = [...(currentConfig.profiles || [])];
|
|
83
104
|
const p = { ...rawP };
|
|
84
105
|
|
|
85
|
-
// Save secret credentials into isolated .env file if provided
|
|
86
106
|
const secretsToUpdate = {};
|
|
87
107
|
if (p.password !== undefined && p.password !== '••••••••') {
|
|
88
108
|
secretsToUpdate.password = p.password;
|
|
@@ -97,7 +117,6 @@ export function apply(ctx, config = {}) {
|
|
|
97
117
|
vaultService.setProfileSecrets(p.id, secretsToUpdate);
|
|
98
118
|
}
|
|
99
119
|
|
|
100
|
-
// Do NOT keep raw secrets in public config
|
|
101
120
|
p.password = '';
|
|
102
121
|
p.privateKey = '';
|
|
103
122
|
p.passphrase = '';
|
|
@@ -116,6 +135,7 @@ export function apply(ctx, config = {}) {
|
|
|
116
135
|
}
|
|
117
136
|
},
|
|
118
137
|
async deleteProfile(id) {
|
|
138
|
+
watcherService.stop(id);
|
|
119
139
|
currentConfig.profiles = (currentConfig.profiles || []).filter((p) => p.id !== id);
|
|
120
140
|
if (currentConfig.activeProfileId === id) {
|
|
121
141
|
currentConfig.activeProfileId = currentConfig.profiles[0]?.id || null;
|
|
@@ -128,12 +148,25 @@ export function apply(ctx, config = {}) {
|
|
|
128
148
|
},
|
|
129
149
|
async setActiveId(id) {
|
|
130
150
|
currentConfig.activeProfileId = id;
|
|
151
|
+
if (currentConfig.autoSyncActive) {
|
|
152
|
+
watcherService.stopAll();
|
|
153
|
+
const active = store.getActiveProfile();
|
|
154
|
+
if (active) watcherService.start(active, mirrorSync);
|
|
155
|
+
}
|
|
131
156
|
if (settingsApi?.replace) {
|
|
132
157
|
try { await settingsApi.replace(Config(currentConfig)); } catch (_) {}
|
|
133
158
|
}
|
|
134
159
|
}
|
|
135
160
|
};
|
|
136
161
|
|
|
162
|
+
sshService.setJumpProfileResolver((jumpId) => store.getProfile(jumpId));
|
|
163
|
+
|
|
164
|
+
// If autoSync was enabled on restart, resume watcher for active profile
|
|
165
|
+
if (currentConfig.autoSyncActive && currentConfig.activeProfileId) {
|
|
166
|
+
const active = store.getActiveProfile();
|
|
167
|
+
if (active) watcherService.start(active, mirrorSync);
|
|
168
|
+
}
|
|
169
|
+
|
|
137
170
|
// Register settings namespace if settings service is available
|
|
138
171
|
ctx.inject(['settings'], (sctx) => {
|
|
139
172
|
const settingsService = sctx.get ? (sctx.get('settings') ?? sctx.settings) : sctx.settings;
|
|
@@ -145,6 +178,7 @@ export function apply(ctx, config = {}) {
|
|
|
145
178
|
if (snap) {
|
|
146
179
|
if (Array.isArray(snap.profiles)) currentConfig.profiles = snap.profiles;
|
|
147
180
|
if (snap.activeProfileId !== undefined) currentConfig.activeProfileId = snap.activeProfileId;
|
|
181
|
+
if (snap.autoSyncActive !== undefined) currentConfig.autoSyncActive = snap.autoSyncActive;
|
|
148
182
|
}
|
|
149
183
|
sctx.effect(() => () => {
|
|
150
184
|
settingsApi = null;
|
|
@@ -156,21 +190,37 @@ export function apply(ctx, config = {}) {
|
|
|
156
190
|
ctx.provide('remoteSync');
|
|
157
191
|
ctx.provide('remoteTunnel');
|
|
158
192
|
ctx.provide('remoteVault');
|
|
193
|
+
ctx.provide('remoteDocker');
|
|
194
|
+
ctx.provide('remoteHealth');
|
|
195
|
+
ctx.provide('remoteWatcher');
|
|
159
196
|
|
|
160
197
|
ctx.remoteSsh = sshService;
|
|
161
198
|
ctx.remoteFs = remoteFs;
|
|
162
199
|
ctx.remoteSync = mirrorSync;
|
|
163
200
|
ctx.remoteTunnel = tunnelService;
|
|
164
201
|
ctx.remoteVault = vaultService;
|
|
202
|
+
ctx.remoteDocker = dockerService;
|
|
203
|
+
ctx.remoteHealth = healthService;
|
|
204
|
+
ctx.remoteWatcher = watcherService;
|
|
165
205
|
|
|
166
206
|
// Tools registration
|
|
167
|
-
registerRemoteTools(
|
|
207
|
+
registerRemoteTools(
|
|
208
|
+
ctx,
|
|
209
|
+
sshService,
|
|
210
|
+
remoteFs,
|
|
211
|
+
mirrorSync,
|
|
212
|
+
tunnelService,
|
|
213
|
+
() => store.getActiveProfile(),
|
|
214
|
+
(id) => store.getProfile(id),
|
|
215
|
+
dockerService
|
|
216
|
+
);
|
|
168
217
|
|
|
169
218
|
// API Routes
|
|
170
|
-
registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelService, store);
|
|
219
|
+
registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelService, store, dockerService, healthService, watcherService);
|
|
171
220
|
|
|
172
221
|
// Cleanup on unload
|
|
173
222
|
ctx.on('dispose', () => {
|
|
223
|
+
watcherService.stopAll();
|
|
174
224
|
tunnelService.stopAll();
|
|
175
225
|
sshService.disconnectAll();
|
|
176
226
|
});
|
package/lib/routes.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelService, store) {
|
|
1
|
+
export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelService, store, dockerService, healthService, watcherService) {
|
|
2
2
|
function writeJson(res, statusCode, body) {
|
|
3
3
|
if (typeof res.setHeader === 'function') {
|
|
4
4
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
@@ -54,11 +54,13 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
|
|
|
54
54
|
const profiles = store.getProfiles();
|
|
55
55
|
const activeId = store.getActiveId() || (profiles[0]?.id || '');
|
|
56
56
|
const tunnels = tunnelService.listActiveTunnels();
|
|
57
|
+
const autoSync = store.isAutoSyncEnabled ? store.isAutoSyncEnabled() : false;
|
|
57
58
|
writeJson(res, 200, {
|
|
58
59
|
ok: true,
|
|
59
60
|
profiles,
|
|
60
61
|
activeId,
|
|
61
62
|
tunnels,
|
|
63
|
+
autoSync,
|
|
62
64
|
vaultProtected: true
|
|
63
65
|
});
|
|
64
66
|
} catch (err) {
|
|
@@ -282,7 +284,6 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
|
|
|
282
284
|
});
|
|
283
285
|
}
|
|
284
286
|
|
|
285
|
-
// Send historical buffer
|
|
286
287
|
for (const chunk of session.buffer) {
|
|
287
288
|
res.write(`data: ${JSON.stringify({ chunk })}\n\n`);
|
|
288
289
|
}
|
|
@@ -394,5 +395,128 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
|
|
|
394
395
|
}
|
|
395
396
|
}
|
|
396
397
|
}), 'dsh-remote-workspace: /tunnels/telemetry');
|
|
398
|
+
|
|
399
|
+
// 17. GET /dsh-remote-workspace/health
|
|
400
|
+
sctx.effect(() => webServer.register({
|
|
401
|
+
kind: 'exact',
|
|
402
|
+
path: '/dsh-remote-workspace/health',
|
|
403
|
+
handler: async (req, res) => {
|
|
404
|
+
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
|
|
405
|
+
try {
|
|
406
|
+
const url = new URL(req.url, 'http://localhost');
|
|
407
|
+
const profileId = url.searchParams.get('profileId');
|
|
408
|
+
const profile = profileId ? store.getProfile(profileId) : store.getActiveProfile();
|
|
409
|
+
if (!profile) return writeJson(res, 404, { ok: false, error: 'No active profile' });
|
|
410
|
+
|
|
411
|
+
const health = await healthService.getHealth(profile);
|
|
412
|
+
writeJson(res, 200, { ok: true, health });
|
|
413
|
+
} catch (err) {
|
|
414
|
+
writeJson(res, 500, { ok: false, error: err.message });
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}), 'dsh-remote-workspace: /health');
|
|
418
|
+
|
|
419
|
+
// 18. GET /dsh-remote-workspace/docker/list
|
|
420
|
+
sctx.effect(() => webServer.register({
|
|
421
|
+
kind: 'exact',
|
|
422
|
+
path: '/dsh-remote-workspace/docker/list',
|
|
423
|
+
handler: async (req, res) => {
|
|
424
|
+
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
|
|
425
|
+
try {
|
|
426
|
+
const url = new URL(req.url, 'http://localhost');
|
|
427
|
+
const profileId = url.searchParams.get('profileId');
|
|
428
|
+
const profile = profileId ? store.getProfile(profileId) : store.getActiveProfile();
|
|
429
|
+
if (!profile) return writeJson(res, 404, { ok: false, error: 'No active profile' });
|
|
430
|
+
|
|
431
|
+
const result = await dockerService.listContainers(profile, true);
|
|
432
|
+
writeJson(res, 200, { ok: true, ...result });
|
|
433
|
+
} catch (err) {
|
|
434
|
+
writeJson(res, 500, { ok: false, error: err.message });
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}), 'dsh-remote-workspace: /docker/list');
|
|
438
|
+
|
|
439
|
+
// 19. POST /dsh-remote-workspace/docker/action
|
|
440
|
+
sctx.effect(() => webServer.register({
|
|
441
|
+
kind: 'exact',
|
|
442
|
+
path: '/dsh-remote-workspace/docker/action',
|
|
443
|
+
handler: async (req, res) => {
|
|
444
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
445
|
+
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
446
|
+
try {
|
|
447
|
+
const { profileId, containerId, action } = await readBody(req);
|
|
448
|
+
const profile = profileId ? store.getProfile(profileId) : store.getActiveProfile();
|
|
449
|
+
if (!profile) return writeJson(res, 404, { ok: false, error: 'No active profile' });
|
|
450
|
+
if (!containerId || !action) return writeJson(res, 400, { ok: false, error: 'containerId and action required' });
|
|
451
|
+
|
|
452
|
+
const result = await dockerService.containerAction(profile, containerId, action);
|
|
453
|
+
writeJson(res, 200, { ok: result.success, ...result });
|
|
454
|
+
} catch (err) {
|
|
455
|
+
writeJson(res, 500, { ok: false, error: err.message });
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}), 'dsh-remote-workspace: /docker/action');
|
|
459
|
+
|
|
460
|
+
// 20. POST /dsh-remote-workspace/docker/logs
|
|
461
|
+
sctx.effect(() => webServer.register({
|
|
462
|
+
kind: 'exact',
|
|
463
|
+
path: '/dsh-remote-workspace/docker/logs',
|
|
464
|
+
handler: async (req, res) => {
|
|
465
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
466
|
+
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
467
|
+
try {
|
|
468
|
+
const { profileId, containerId, tail } = await readBody(req);
|
|
469
|
+
const profile = profileId ? store.getProfile(profileId) : store.getActiveProfile();
|
|
470
|
+
if (!profile) return writeJson(res, 404, { ok: false, error: 'No active profile' });
|
|
471
|
+
if (!containerId) return writeJson(res, 400, { ok: false, error: 'containerId required' });
|
|
472
|
+
|
|
473
|
+
const result = await dockerService.containerLogs(profile, containerId, tail || 100);
|
|
474
|
+
writeJson(res, 200, { ok: result.success, ...result });
|
|
475
|
+
} catch (err) {
|
|
476
|
+
writeJson(res, 500, { ok: false, error: err.message });
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}), 'dsh-remote-workspace: /docker/logs');
|
|
480
|
+
|
|
481
|
+
// 21. POST /dsh-remote-workspace/watcher/toggle
|
|
482
|
+
sctx.effect(() => webServer.register({
|
|
483
|
+
kind: 'exact',
|
|
484
|
+
path: '/dsh-remote-workspace/watcher/toggle',
|
|
485
|
+
handler: async (req, res) => {
|
|
486
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
487
|
+
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
488
|
+
try {
|
|
489
|
+
const { enabled } = await readBody(req);
|
|
490
|
+
await store.setAutoSyncEnabled(Boolean(enabled));
|
|
491
|
+
writeJson(res, 200, { ok: true, autoSync: store.isAutoSyncEnabled() });
|
|
492
|
+
} catch (err) {
|
|
493
|
+
writeJson(res, 500, { ok: false, error: err.message });
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}), 'dsh-remote-workspace: /watcher/toggle');
|
|
497
|
+
|
|
498
|
+
// 22. POST /dsh-remote-workspace/transfer
|
|
499
|
+
sctx.effect(() => webServer.register({
|
|
500
|
+
kind: 'exact',
|
|
501
|
+
path: '/dsh-remote-workspace/transfer',
|
|
502
|
+
handler: async (req, res) => {
|
|
503
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
|
|
504
|
+
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
505
|
+
try {
|
|
506
|
+
const { sourceProfileId, sourcePath, destProfileId, destPath } = await readBody(req);
|
|
507
|
+
const srcProfile = sourceProfileId ? store.getProfile(sourceProfileId) : store.getActiveProfile();
|
|
508
|
+
const destProfile = store.getProfile(destProfileId);
|
|
509
|
+
|
|
510
|
+
if (!srcProfile) return writeJson(res, 404, { ok: false, error: 'Source profile not found' });
|
|
511
|
+
if (!destProfile) return writeJson(res, 404, { ok: false, error: 'Destination profile not found' });
|
|
512
|
+
if (!sourcePath || !destPath) return writeJson(res, 400, { ok: false, error: 'sourcePath and destPath required' });
|
|
513
|
+
|
|
514
|
+
const result = await sshService.transferFileBetweenHosts(srcProfile, sourcePath, destProfile, destPath);
|
|
515
|
+
writeJson(res, 200, { ok: result.success, ...result });
|
|
516
|
+
} catch (err) {
|
|
517
|
+
writeJson(res, 500, { ok: false, error: err.message });
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}), 'dsh-remote-workspace: /transfer');
|
|
397
521
|
});
|
|
398
522
|
}
|
package/lib/ssh-service.js
CHANGED
|
@@ -8,7 +8,6 @@ import os from 'node:os';
|
|
|
8
8
|
*/
|
|
9
9
|
export function stripAnsi(str) {
|
|
10
10
|
if (!str) return '';
|
|
11
|
-
// Match standard ANSI escape codes, OSC sequences, and CSI codes
|
|
12
11
|
return str.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '');
|
|
13
12
|
}
|
|
14
13
|
|
|
@@ -26,15 +25,17 @@ export class SshService {
|
|
|
26
25
|
this.sftpSessions = new Map();
|
|
27
26
|
/** @type {Map<string, any>} */
|
|
28
27
|
this.terminalSessions = new Map();
|
|
28
|
+
this.getJumpProfile = null;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
setVaultService(vault) {
|
|
32
32
|
this.vaultService = vault;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
setJumpProfileResolver(resolver) {
|
|
36
|
+
this.getJumpProfile = resolver;
|
|
37
|
+
}
|
|
38
|
+
|
|
38
39
|
resolvePrivateKey(profile) {
|
|
39
40
|
if (profile.privateKey && profile.privateKey.trim()) {
|
|
40
41
|
return profile.privateKey;
|
|
@@ -54,7 +55,7 @@ export class SshService {
|
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
/**
|
|
57
|
-
* Connect to remote machine with pooling, keep-alive,
|
|
58
|
+
* Connect to remote machine with pooling, keep-alive, auto-reconnect and ProxyJump
|
|
58
59
|
*/
|
|
59
60
|
async getConnection(rawProfile) {
|
|
60
61
|
const profile = this.vaultService ? this.vaultService.hydrateProfile(rawProfile) : rawProfile;
|
|
@@ -65,10 +66,24 @@ export class SshService {
|
|
|
65
66
|
return existing;
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
// Clean any broken reference
|
|
69
69
|
this.connections.delete(connId);
|
|
70
70
|
this.sftpSessions.delete(connId);
|
|
71
71
|
|
|
72
|
+
// If jumpHost is configured, obtain socket through the jump host first
|
|
73
|
+
let sock = undefined;
|
|
74
|
+
if (profile.jumpHostId && this.getJumpProfile) {
|
|
75
|
+
const jumpProfile = this.getJumpProfile(profile.jumpHostId);
|
|
76
|
+
if (jumpProfile) {
|
|
77
|
+
const jumpConn = await this.getConnection(jumpProfile);
|
|
78
|
+
sock = await new Promise((resolve, reject) => {
|
|
79
|
+
jumpConn.forwardOut('127.0.0.1', 0, profile.host, profile.port || 22, (err, stream) => {
|
|
80
|
+
if (err) return reject(new Error(`ProxyJump via ${jumpProfile.host} failed: ${err.message}`));
|
|
81
|
+
resolve(stream);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
72
87
|
return new Promise((resolve, reject) => {
|
|
73
88
|
const conn = new Client();
|
|
74
89
|
const connConfig = {
|
|
@@ -80,6 +95,10 @@ export class SshService {
|
|
|
80
95
|
readyTimeout: 20000
|
|
81
96
|
};
|
|
82
97
|
|
|
98
|
+
if (sock) {
|
|
99
|
+
connConfig.sock = sock;
|
|
100
|
+
}
|
|
101
|
+
|
|
83
102
|
if (profile.authType === 'password') {
|
|
84
103
|
connConfig.password = profile.password || '';
|
|
85
104
|
} else if (profile.authType === 'agent') {
|
|
@@ -116,9 +135,6 @@ export class SshService {
|
|
|
116
135
|
});
|
|
117
136
|
}
|
|
118
137
|
|
|
119
|
-
/**
|
|
120
|
-
* Test connection and measure ping / detect OS
|
|
121
|
-
*/
|
|
122
138
|
async testConnection(profile) {
|
|
123
139
|
if (profile.id) {
|
|
124
140
|
this.disconnect(profile.id);
|
|
@@ -154,9 +170,6 @@ export class SshService {
|
|
|
154
170
|
});
|
|
155
171
|
}
|
|
156
172
|
|
|
157
|
-
/**
|
|
158
|
-
* Obtain active SFTP wrapper with connection reuse
|
|
159
|
-
*/
|
|
160
173
|
async getSftp(profile) {
|
|
161
174
|
const connId = profile.id;
|
|
162
175
|
if (this.sftpSessions.has(connId)) {
|
|
@@ -172,13 +185,6 @@ export class SshService {
|
|
|
172
185
|
});
|
|
173
186
|
}
|
|
174
187
|
|
|
175
|
-
/**
|
|
176
|
-
* Execute command on remote host with PTY, streaming, timeout, and ANSI sanitization
|
|
177
|
-
* @param {any} profile
|
|
178
|
-
* @param {string} command
|
|
179
|
-
* @param {string|object} [optionsOrCwd] - cwd string or options object
|
|
180
|
-
* @param {function} [legacyOnData]
|
|
181
|
-
*/
|
|
182
188
|
async exec(profile, command, optionsOrCwd = null, legacyOnData = null) {
|
|
183
189
|
let cwd = null;
|
|
184
190
|
let onData = legacyOnData;
|
|
@@ -256,8 +262,41 @@ export class SshService {
|
|
|
256
262
|
}
|
|
257
263
|
|
|
258
264
|
/**
|
|
259
|
-
*
|
|
265
|
+
* Direct Server-to-Server file transfer streaming between two SSH hosts.
|
|
266
|
+
* Reads from srcProfile SFTP stream and pipes directly into destProfile SFTP stream.
|
|
267
|
+
* No intermediate files touch the local DSH disk!
|
|
260
268
|
*/
|
|
269
|
+
async transferFileBetweenHosts(srcProfile, srcPath, destProfile, destPath) {
|
|
270
|
+
const srcSftp = await this.getSftp(srcProfile);
|
|
271
|
+
const destSftp = await this.getSftp(destProfile);
|
|
272
|
+
|
|
273
|
+
return new Promise((resolve, reject) => {
|
|
274
|
+
const readStream = srcSftp.createReadStream(srcPath);
|
|
275
|
+
const writeStream = destSftp.createWriteStream(destPath);
|
|
276
|
+
let bytesTransferred = 0;
|
|
277
|
+
|
|
278
|
+
readStream.on('data', (chunk) => {
|
|
279
|
+
bytesTransferred += chunk.length;
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
writeStream.on('finish', () => {
|
|
283
|
+
resolve({
|
|
284
|
+
success: true,
|
|
285
|
+
bytesTransferred,
|
|
286
|
+
srcHost: srcProfile.host,
|
|
287
|
+
destHost: destProfile.host,
|
|
288
|
+
srcPath,
|
|
289
|
+
destPath
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
readStream.on('error', (err) => reject(new Error(`Read from ${srcProfile.host} failed: ${err.message}`)));
|
|
294
|
+
writeStream.on('error', (err) => reject(new Error(`Write to ${destProfile.host} failed: ${err.message}`)));
|
|
295
|
+
|
|
296
|
+
readStream.pipe(writeStream);
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
261
300
|
async createTerminalSession(profile, options = {}) {
|
|
262
301
|
const conn = await this.getConnection(profile);
|
|
263
302
|
const cols = options.cols || 80;
|
package/lib/tools.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunnelService, getActiveProfile) {
|
|
1
|
+
export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunnelService, getActiveProfile, getProfileById, dockerService) {
|
|
2
2
|
if (!ctx.tools || typeof ctx.tools.register !== 'function') {
|
|
3
3
|
return;
|
|
4
4
|
}
|
|
@@ -149,4 +149,102 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
|
|
|
149
149
|
}
|
|
150
150
|
}
|
|
151
151
|
});
|
|
152
|
+
|
|
153
|
+
// 5. remote_docker
|
|
154
|
+
if (dockerService) {
|
|
155
|
+
ctx.tools.register({
|
|
156
|
+
name: 'remote_docker',
|
|
157
|
+
description: 'Manage Docker/Podman containers and Docker Compose on remote host',
|
|
158
|
+
parameters: {
|
|
159
|
+
action: { type: 'string', required: true, description: 'list, logs, start, stop, restart, compose' },
|
|
160
|
+
containerId: { type: 'string', description: 'Container ID or name' },
|
|
161
|
+
tail: { type: 'number', description: 'Number of log lines to retrieve (default 50)' },
|
|
162
|
+
composeAction: { type: 'string', description: 'Compose action: up -d, down, ps, restart' },
|
|
163
|
+
composeCwd: { type: 'string', description: 'Directory containing docker-compose.yml' }
|
|
164
|
+
},
|
|
165
|
+
output: genericOutput,
|
|
166
|
+
async execute(args) {
|
|
167
|
+
const profile = getActiveProfile();
|
|
168
|
+
if (!profile) throw new Error('No active remote workspace profile selected');
|
|
169
|
+
|
|
170
|
+
switch (args.action) {
|
|
171
|
+
case 'list':
|
|
172
|
+
return await dockerService.listContainers(profile, true);
|
|
173
|
+
case 'logs':
|
|
174
|
+
if (!args.containerId) throw new Error('containerId required for logs');
|
|
175
|
+
return await dockerService.containerLogs(profile, args.containerId, args.tail || 50);
|
|
176
|
+
case 'start':
|
|
177
|
+
case 'stop':
|
|
178
|
+
case 'restart':
|
|
179
|
+
if (!args.containerId) throw new Error(`containerId required for ${args.action}`);
|
|
180
|
+
return await dockerService.containerAction(profile, args.containerId, args.action);
|
|
181
|
+
case 'compose':
|
|
182
|
+
const dir = args.composeCwd || profile.remoteWorkspace;
|
|
183
|
+
return await dockerService.composeAction(profile, dir, args.composeAction || 'ps');
|
|
184
|
+
default:
|
|
185
|
+
throw new Error(`Unknown docker action: ${args.action}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 6. remote_service
|
|
192
|
+
ctx.tools.register({
|
|
193
|
+
name: 'remote_service',
|
|
194
|
+
description: 'Inspect and manage Linux systemd or PM2 services on the remote machine',
|
|
195
|
+
parameters: {
|
|
196
|
+
action: { type: 'string', required: true, description: 'status, start, stop, restart, logs' },
|
|
197
|
+
service: { type: 'string', required: true, description: 'Service name (e.g. nginx, redis, my-app)' },
|
|
198
|
+
manager: { type: 'string', description: 'systemd (default) or pm2' },
|
|
199
|
+
lines: { type: 'number', description: 'Number of log lines for logs action (default 40)' }
|
|
200
|
+
},
|
|
201
|
+
output: genericOutput,
|
|
202
|
+
async execute(args) {
|
|
203
|
+
const profile = getActiveProfile();
|
|
204
|
+
if (!profile) throw new Error('No active remote workspace profile selected');
|
|
205
|
+
|
|
206
|
+
const mgr = args.manager || 'systemd';
|
|
207
|
+
const svc = args.service;
|
|
208
|
+
const lines = args.lines || 40;
|
|
209
|
+
|
|
210
|
+
let cmd = '';
|
|
211
|
+
if (mgr === 'pm2') {
|
|
212
|
+
if (args.action === 'logs') cmd = `pm2 logs ${JSON.stringify(svc)} --lines ${lines} --nostream`;
|
|
213
|
+
else cmd = `pm2 ${args.action} ${JSON.stringify(svc)}`;
|
|
214
|
+
} else {
|
|
215
|
+
// systemd
|
|
216
|
+
if (args.action === 'logs') cmd = `journalctl -u ${JSON.stringify(svc)} -n ${lines} --no-pager`;
|
|
217
|
+
else if (args.action === 'status') cmd = `systemctl status ${JSON.stringify(svc)} --no-pager`;
|
|
218
|
+
else cmd = `sudo systemctl ${args.action} ${JSON.stringify(svc)} || systemctl ${args.action} ${JSON.stringify(svc)}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const res = await sshService.exec(profile, cmd, { cleanAnsi: true });
|
|
222
|
+
return {
|
|
223
|
+
exitCode: res.code,
|
|
224
|
+
output: res.stdout || res.stderr
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// 7. remote_transfer
|
|
230
|
+
ctx.tools.register({
|
|
231
|
+
name: 'remote_transfer',
|
|
232
|
+
description: 'Transfer a file directly between two configured remote servers via in-memory SFTP streams',
|
|
233
|
+
parameters: {
|
|
234
|
+
sourcePath: { type: 'string', required: true, description: 'File path on the source remote server' },
|
|
235
|
+
destPath: { type: 'string', required: true, description: 'File path on the destination remote server' },
|
|
236
|
+
destProfileId: { type: 'string', required: true, description: 'Target remote profile ID' },
|
|
237
|
+
sourceProfileId: { type: 'string', description: 'Source remote profile ID (defaults to active profile)' }
|
|
238
|
+
},
|
|
239
|
+
output: genericOutput,
|
|
240
|
+
async execute(args) {
|
|
241
|
+
const srcProfile = args.sourceProfileId && getProfileById ? getProfileById(args.sourceProfileId) : getActiveProfile();
|
|
242
|
+
if (!srcProfile) throw new Error('Source profile not found or no active profile');
|
|
243
|
+
|
|
244
|
+
const destProfile = getProfileById ? getProfileById(args.destProfileId) : null;
|
|
245
|
+
if (!destProfile) throw new Error(`Destination profile "${args.destProfileId}" not found`);
|
|
246
|
+
|
|
247
|
+
return await sshService.transferFileBetweenHosts(srcProfile, args.sourcePath, destProfile, args.destPath);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
152
250
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export class WatcherService {
|
|
5
|
+
constructor() {
|
|
6
|
+
/** @type {Map<string, { watcher: fs.FSWatcher, profile: any, running: boolean }>} */
|
|
7
|
+
this.watchers = new Map();
|
|
8
|
+
/** @type {Map<string, NodeJS.Timeout>} */
|
|
9
|
+
this.debounceTimers = new Map();
|
|
10
|
+
this.debounceMs = 350;
|
|
11
|
+
this.onSyncCallback = null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
setOnSync(cb) {
|
|
15
|
+
this.onSyncCallback = cb;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
isIgnored(filename) {
|
|
19
|
+
if (!filename) return true;
|
|
20
|
+
const parts = filename.split(/[\\/]/);
|
|
21
|
+
const ignoreList = ['.git', 'node_modules', '.dsh-sync-snapshot.json', '.cache', 'dist', 'build', '.next'];
|
|
22
|
+
for (const part of parts) {
|
|
23
|
+
if (ignoreList.includes(part)) return true;
|
|
24
|
+
if (part.endsWith('.tmp') || part.endsWith('.swp') || part.endsWith('~')) return true;
|
|
25
|
+
}
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
start(profile, mirrorSync) {
|
|
30
|
+
if (!profile || !profile.id || !profile.localMirrorPath || !profile.remoteWorkspace) {
|
|
31
|
+
return { success: false, error: 'Both localMirrorPath and remoteWorkspace must be set' };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (this.watchers.has(profile.id)) {
|
|
35
|
+
return { success: true, alreadyRunning: true };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!fs.existsSync(profile.localMirrorPath)) {
|
|
39
|
+
try {
|
|
40
|
+
fs.mkdirSync(profile.localMirrorPath, { recursive: true });
|
|
41
|
+
} catch (err) {
|
|
42
|
+
return { success: false, error: `Failed to create local mirror dir: ${err.message}` };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const watcher = fs.watch(profile.localMirrorPath, { recursive: true }, (_eventType, filename) => {
|
|
48
|
+
if (!filename || this.isIgnored(filename)) return;
|
|
49
|
+
|
|
50
|
+
// Debounce sync triggers
|
|
51
|
+
if (this.debounceTimers.has(profile.id)) {
|
|
52
|
+
clearTimeout(this.debounceTimers.get(profile.id));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const timer = setTimeout(async () => {
|
|
56
|
+
this.debounceTimers.delete(profile.id);
|
|
57
|
+
try {
|
|
58
|
+
const syncRes = await mirrorSync.push(profile, profile.localMirrorPath, profile.remoteWorkspace);
|
|
59
|
+
if (this.onSyncCallback) {
|
|
60
|
+
this.onSyncCallback({ profileId: profile.id, syncRes });
|
|
61
|
+
}
|
|
62
|
+
} catch (_) {}
|
|
63
|
+
}, this.debounceMs);
|
|
64
|
+
|
|
65
|
+
this.debounceTimers.set(profile.id, timer);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
this.watchers.set(profile.id, { watcher, profile, running: true });
|
|
69
|
+
return { success: true };
|
|
70
|
+
} catch (err) {
|
|
71
|
+
return { success: false, error: err.message };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
stop(profileId) {
|
|
76
|
+
if (this.debounceTimers.has(profileId)) {
|
|
77
|
+
clearTimeout(this.debounceTimers.get(profileId));
|
|
78
|
+
this.debounceTimers.delete(profileId);
|
|
79
|
+
}
|
|
80
|
+
const item = this.watchers.get(profileId);
|
|
81
|
+
if (item) {
|
|
82
|
+
try {
|
|
83
|
+
item.watcher.close();
|
|
84
|
+
} catch (_) {}
|
|
85
|
+
this.watchers.delete(profileId);
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
getStatus(profileId) {
|
|
92
|
+
return this.watchers.has(profileId);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
listActive() {
|
|
96
|
+
return Array.from(this.watchers.keys());
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
stopAll() {
|
|
100
|
+
for (const [id, item] of this.watchers.entries()) {
|
|
101
|
+
try {
|
|
102
|
+
item.watcher.close();
|
|
103
|
+
} catch (_) {}
|
|
104
|
+
if (this.debounceTimers.has(id)) {
|
|
105
|
+
clearTimeout(this.debounceTimers.get(id));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
this.watchers.clear();
|
|
109
|
+
this.debounceTimers.clear();
|
|
110
|
+
}
|
|
111
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-remote-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Enterprise Remote Workspace Plugin for DeepSeek Harness: SSH2 connection pool, SFTP operations, 3-way conflict-aware mirror sync, port forwarding, and native DSH UI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|