lobsterboard 0.4.1 → 0.5.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/README.md +38 -0
- package/app.html +38 -0
- package/dist/lobsterboard.css +1 -1
- package/dist/lobsterboard.esm.js +1 -1
- package/dist/lobsterboard.esm.min.js +1 -1
- package/dist/lobsterboard.umd.js +1 -1
- package/dist/lobsterboard.umd.min.js +1 -1
- package/js/builder.js +182 -1
- package/js/widgets.js +213 -52
- package/package.json +1 -1
- package/server.cjs +135 -0
package/README.md
CHANGED
|
@@ -58,6 +58,44 @@ LobsterBoard ships with 5 built-in themes. Switch themes from the dropdown in ed
|
|
|
58
58
|
- **Feminine** — soft pink and lavender pastels with subtle glows
|
|
59
59
|
- **Feminine Dark** — pink/purple accents on a dark background
|
|
60
60
|
|
|
61
|
+
## Remote Server Monitoring
|
|
62
|
+
|
|
63
|
+
Monitor multiple servers from a single dashboard using [lobsterboard-agent](https://www.npmjs.com/package/lobsterboard-agent).
|
|
64
|
+
|
|
65
|
+
### Setup Remote Server
|
|
66
|
+
|
|
67
|
+
On your VPS/remote server:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
npm install -g lobsterboard-agent
|
|
71
|
+
lobsterboard-agent init # Generates API key - save it!
|
|
72
|
+
lobsterboard-agent serve # Starts on port 9090
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Add to LobsterBoard
|
|
76
|
+
|
|
77
|
+
1. Click **🖥️ Servers** in the header
|
|
78
|
+
2. Enter server name, URL (`http://your-server-ip:9090`), and API key
|
|
79
|
+
3. Click **Test Connection** to verify
|
|
80
|
+
4. Add widgets (Uptime Monitor, Docker, CPU/Memory, etc.)
|
|
81
|
+
5. Select your remote server from the **Server** dropdown in widget properties
|
|
82
|
+
|
|
83
|
+
### Supported Widgets
|
|
84
|
+
|
|
85
|
+
These widgets support remote server data:
|
|
86
|
+
|
|
87
|
+
| Widget | What It Shows |
|
|
88
|
+
|--------|---------------|
|
|
89
|
+
| **Uptime Monitor** | System uptime, CPU, memory |
|
|
90
|
+
| **CPU / Memory** | CPU usage + RAM usage |
|
|
91
|
+
| **Disk Usage** | Disk space with ring chart |
|
|
92
|
+
| **Network Speed** | Upload/download throughput |
|
|
93
|
+
| **Docker Containers** | Container list and status |
|
|
94
|
+
|
|
95
|
+
### Multi-Server Dashboard
|
|
96
|
+
|
|
97
|
+
Add multiple widgets and select different servers for each — monitor your entire infrastructure from one dashboard.
|
|
98
|
+
|
|
61
99
|
## Configuration
|
|
62
100
|
|
|
63
101
|
```bash
|
package/app.html
CHANGED
|
@@ -76,6 +76,7 @@
|
|
|
76
76
|
<input type="number" id="custom-height" placeholder="Height" style="display:none; width:80px;">
|
|
77
77
|
</div>
|
|
78
78
|
<div class="header-right">
|
|
79
|
+
<button class="btn btn-secondary" id="btn-servers">🖥️ Servers</button>
|
|
79
80
|
<button class="btn btn-secondary" id="btn-security">🔒 Security</button>
|
|
80
81
|
<button class="btn btn-secondary" id="btn-templates">📋 Templates</button>
|
|
81
82
|
<button class="btn btn-secondary" id="btn-export-template">📦 Export Template</button>
|
|
@@ -685,6 +686,14 @@
|
|
|
685
686
|
<div id="dir-browser" style="display:none;margin-top:8px;max-height:200px;overflow-y:auto;background:var(--bg-tertiary);border:1px solid var(--border-color);border-radius:6px;padding:8px;font-size:12px;">
|
|
686
687
|
</div>
|
|
687
688
|
</div>
|
|
689
|
+
<div class="prop-group" id="prop-server-group" style="display:none;">
|
|
690
|
+
<label>Server</label>
|
|
691
|
+
<select id="prop-server">
|
|
692
|
+
<option value="local">Local</option>
|
|
693
|
+
<!-- Remote servers populated dynamically -->
|
|
694
|
+
</select>
|
|
695
|
+
<small style="color:#8b949e;margin-top:4px;display:block;">Select data source for this widget</small>
|
|
696
|
+
</div>
|
|
688
697
|
<div class="prop-group">
|
|
689
698
|
<label>Refresh Interval (sec)</label>
|
|
690
699
|
<input type="number" id="prop-refresh" placeholder="60" min="0">
|
|
@@ -973,6 +982,35 @@
|
|
|
973
982
|
</div>
|
|
974
983
|
</div>
|
|
975
984
|
|
|
985
|
+
<!-- Servers Settings Modal -->
|
|
986
|
+
<div id="servers-modal" class="pin-modal-overlay" style="display:none;">
|
|
987
|
+
<div class="pin-modal" style="max-width:500px;">
|
|
988
|
+
<h3>🖥️ Remote Servers</h3>
|
|
989
|
+
<p style="color:#8b949e;font-size:12px;margin:0 0 16px;">
|
|
990
|
+
Connect to remote servers running <a href="https://www.npmjs.com/package/lobsterboard-agent" target="_blank" style="color:#58a6ff;">lobsterboard-agent</a>
|
|
991
|
+
</p>
|
|
992
|
+
<div id="servers-list" style="margin-bottom:16px;">
|
|
993
|
+
<!-- Server list will be populated here -->
|
|
994
|
+
</div>
|
|
995
|
+
<div class="server-add-form" style="background:var(--bg-tertiary);padding:12px;border-radius:6px;">
|
|
996
|
+
<h4 style="margin:0 0 12px;font-size:14px;">➕ Add Server</h4>
|
|
997
|
+
<div style="display:flex;flex-direction:column;gap:8px;">
|
|
998
|
+
<input type="text" id="server-name" placeholder="Server Name (e.g., Production VPS)" class="tpl-input" style="font-size:13px;">
|
|
999
|
+
<input type="text" id="server-url" placeholder="URL (e.g., http://192.168.1.100:9090)" class="tpl-input" style="font-size:13px;">
|
|
1000
|
+
<input type="password" id="server-apikey" placeholder="API Key (from lobsterboard-agent show-key)" class="tpl-input" style="font-size:13px;">
|
|
1001
|
+
<div style="display:flex;gap:8px;margin-top:4px;">
|
|
1002
|
+
<button class="btn btn-primary btn-sm" id="server-add-btn">Add Server</button>
|
|
1003
|
+
<button class="btn btn-secondary btn-sm" id="server-test-btn">Test Connection</button>
|
|
1004
|
+
</div>
|
|
1005
|
+
<div id="server-add-result" style="font-size:12px;margin-top:4px;"></div>
|
|
1006
|
+
</div>
|
|
1007
|
+
</div>
|
|
1008
|
+
<div style="margin-top:16px;text-align:right;">
|
|
1009
|
+
<button class="btn btn-secondary" id="servers-close">Close</button>
|
|
1010
|
+
</div>
|
|
1011
|
+
</div>
|
|
1012
|
+
</div>
|
|
1013
|
+
|
|
976
1014
|
<script src="js/templates.js"></script>
|
|
977
1015
|
</body>
|
|
978
1016
|
</html>
|
package/dist/lobsterboard.css
CHANGED
package/dist/lobsterboard.esm.js
CHANGED
package/dist/lobsterboard.umd.js
CHANGED
package/js/builder.js
CHANGED
|
@@ -624,8 +624,172 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
624
624
|
}
|
|
625
625
|
}
|
|
626
626
|
});
|
|
627
|
+
|
|
628
|
+
// Servers modal
|
|
629
|
+
document.getElementById('btn-servers').addEventListener('click', openServersModal);
|
|
630
|
+
document.getElementById('servers-close').addEventListener('click', () => {
|
|
631
|
+
document.getElementById('servers-modal').style.display = 'none';
|
|
632
|
+
});
|
|
633
|
+
document.getElementById('server-add-btn').addEventListener('click', addServer);
|
|
634
|
+
document.getElementById('server-test-btn').addEventListener('click', testServerConnection);
|
|
627
635
|
});
|
|
628
636
|
|
|
637
|
+
async function openServersModal() {
|
|
638
|
+
document.getElementById('servers-modal').style.display = 'flex';
|
|
639
|
+
await loadServersList();
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async function loadServersList() {
|
|
643
|
+
const container = document.getElementById('servers-list');
|
|
644
|
+
try {
|
|
645
|
+
const res = await fetch('/api/servers');
|
|
646
|
+
const data = await res.json();
|
|
647
|
+
if (!data.servers || data.servers.length === 0) {
|
|
648
|
+
container.innerHTML = '<p style="color:#8b949e;font-size:13px;">No servers configured. Add one below.</p>';
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
container.innerHTML = data.servers.map(s => `
|
|
652
|
+
<div class="server-item" style="display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:var(--bg-tertiary);border-radius:6px;margin-bottom:8px;">
|
|
653
|
+
<div>
|
|
654
|
+
<strong style="font-size:13px;">${_escHtml(s.name)}</strong>
|
|
655
|
+
${s.type === 'local' ? '<span style="color:#8b949e;font-size:11px;margin-left:8px;">(built-in)</span>' : `<span style="color:#8b949e;font-size:11px;margin-left:8px;">${_escHtml(s.url || '')}</span>`}
|
|
656
|
+
</div>
|
|
657
|
+
<div style="display:flex;gap:6px;">
|
|
658
|
+
${s.type !== 'local' ? `
|
|
659
|
+
<button class="btn btn-sm btn-secondary" onclick="testServer('${s.id}')">Test</button>
|
|
660
|
+
<button class="btn btn-sm btn-danger" onclick="deleteServer('${s.id}')">Delete</button>
|
|
661
|
+
` : '<span style="color:#3fb950;font-size:12px;">✓ Local</span>'}
|
|
662
|
+
</div>
|
|
663
|
+
</div>
|
|
664
|
+
`).join('');
|
|
665
|
+
} catch (e) {
|
|
666
|
+
container.innerHTML = '<p style="color:#f85149;">Failed to load servers</p>';
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async function addServer() {
|
|
671
|
+
const name = document.getElementById('server-name').value.trim();
|
|
672
|
+
const url = document.getElementById('server-url').value.trim();
|
|
673
|
+
const apiKey = document.getElementById('server-apikey').value.trim();
|
|
674
|
+
const resultEl = document.getElementById('server-add-result');
|
|
675
|
+
|
|
676
|
+
if (!name || !url || !apiKey) {
|
|
677
|
+
resultEl.innerHTML = '<span style="color:#f85149;">All fields are required</span>';
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
try {
|
|
682
|
+
const res = await fetch('/api/servers', {
|
|
683
|
+
method: 'POST',
|
|
684
|
+
headers: { 'Content-Type': 'application/json' },
|
|
685
|
+
body: JSON.stringify({ name, url, apiKey })
|
|
686
|
+
});
|
|
687
|
+
const data = await res.json();
|
|
688
|
+
if (data.status === 'success') {
|
|
689
|
+
resultEl.innerHTML = '<span style="color:#3fb950;">✓ Server added</span>';
|
|
690
|
+
document.getElementById('server-name').value = '';
|
|
691
|
+
document.getElementById('server-url').value = '';
|
|
692
|
+
document.getElementById('server-apikey').value = '';
|
|
693
|
+
invalidateServerCache();
|
|
694
|
+
await loadServersList();
|
|
695
|
+
} else {
|
|
696
|
+
resultEl.innerHTML = `<span style="color:#f85149;">${_escHtml(data.error || 'Failed to add')}</span>`;
|
|
697
|
+
}
|
|
698
|
+
} catch (e) {
|
|
699
|
+
resultEl.innerHTML = '<span style="color:#f85149;">Network error</span>';
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
async function testServerConnection() {
|
|
704
|
+
const url = document.getElementById('server-url').value.trim();
|
|
705
|
+
const apiKey = document.getElementById('server-apikey').value.trim();
|
|
706
|
+
const resultEl = document.getElementById('server-add-result');
|
|
707
|
+
|
|
708
|
+
if (!url || !apiKey) {
|
|
709
|
+
resultEl.innerHTML = '<span style="color:#f85149;">URL and API Key required</span>';
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
resultEl.innerHTML = '<span style="color:#8b949e;">Testing...</span>';
|
|
714
|
+
try {
|
|
715
|
+
const res = await fetch(url + '/health', {
|
|
716
|
+
headers: { 'X-API-Key': apiKey },
|
|
717
|
+
signal: AbortSignal.timeout(5000)
|
|
718
|
+
});
|
|
719
|
+
if (res.ok) {
|
|
720
|
+
const data = await res.json();
|
|
721
|
+
resultEl.innerHTML = `<span style="color:#3fb950;">✓ Connected to ${_escHtml(data.serverName || 'server')}</span>`;
|
|
722
|
+
} else {
|
|
723
|
+
resultEl.innerHTML = `<span style="color:#f85149;">HTTP ${res.status}</span>`;
|
|
724
|
+
}
|
|
725
|
+
} catch (e) {
|
|
726
|
+
resultEl.innerHTML = `<span style="color:#f85149;">Connection failed: ${_escHtml(e.message)}</span>`;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
async function testServer(id) {
|
|
731
|
+
try {
|
|
732
|
+
const res = await fetch(`/api/servers/${id}/test`, { method: 'POST' });
|
|
733
|
+
const data = await res.json();
|
|
734
|
+
if (data.status === 'ok') {
|
|
735
|
+
alert(`✓ Connected to ${data.serverName || 'server'}`);
|
|
736
|
+
} else {
|
|
737
|
+
alert(`Connection failed: ${data.message || 'Unknown error'}`);
|
|
738
|
+
}
|
|
739
|
+
} catch (e) {
|
|
740
|
+
alert('Network error');
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
async function deleteServer(id) {
|
|
745
|
+
if (!confirm('Delete this server?')) return;
|
|
746
|
+
try {
|
|
747
|
+
const res = await fetch(`/api/servers/${id}`, { method: 'DELETE' });
|
|
748
|
+
const data = await res.json();
|
|
749
|
+
if (data.status === 'success') {
|
|
750
|
+
invalidateServerCache();
|
|
751
|
+
await loadServersList();
|
|
752
|
+
} else {
|
|
753
|
+
alert(data.error || 'Failed to delete');
|
|
754
|
+
}
|
|
755
|
+
} catch (e) {
|
|
756
|
+
alert('Network error');
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function _escHtml(str) {
|
|
761
|
+
if (!str) return '';
|
|
762
|
+
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Server dropdown for system widgets
|
|
766
|
+
let _cachedServers = null;
|
|
767
|
+
async function populateServerDropdown(selectedValue) {
|
|
768
|
+
const select = document.getElementById('prop-server');
|
|
769
|
+
if (!select) return;
|
|
770
|
+
|
|
771
|
+
// Fetch servers if not cached
|
|
772
|
+
if (!_cachedServers) {
|
|
773
|
+
try {
|
|
774
|
+
const res = await fetch('/api/servers');
|
|
775
|
+
const data = await res.json();
|
|
776
|
+
_cachedServers = data.servers || [];
|
|
777
|
+
} catch (e) {
|
|
778
|
+
_cachedServers = [{ id: 'local', name: 'Local', type: 'local' }];
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// Populate dropdown
|
|
783
|
+
select.innerHTML = _cachedServers.map(s =>
|
|
784
|
+
`<option value="${_escHtml(s.id)}"${s.id === selectedValue ? ' selected' : ''}>${_escHtml(s.name)}</option>`
|
|
785
|
+
).join('');
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Invalidate server cache when servers are added/deleted
|
|
789
|
+
function invalidateServerCache() {
|
|
790
|
+
_cachedServers = null;
|
|
791
|
+
}
|
|
792
|
+
|
|
629
793
|
function initCanvas() {
|
|
630
794
|
const canvas = document.getElementById('canvas');
|
|
631
795
|
updateCanvasSize();
|
|
@@ -1083,6 +1247,9 @@ function initProperties() {
|
|
|
1083
1247
|
document.getElementById('prop-api-key').addEventListener('input', onPropertyChange);
|
|
1084
1248
|
document.getElementById('prop-api-key-value').addEventListener('input', onPropertyChange);
|
|
1085
1249
|
document.getElementById('prop-endpoint').addEventListener('input', onPropertyChange);
|
|
1250
|
+
if (document.getElementById('prop-server')) {
|
|
1251
|
+
document.getElementById('prop-server').addEventListener('change', onPropertyChange);
|
|
1252
|
+
}
|
|
1086
1253
|
if (document.getElementById('prop-directorypath')) {
|
|
1087
1254
|
document.getElementById('prop-directorypath').addEventListener('input', onPropertyChange);
|
|
1088
1255
|
document.getElementById('btn-browse-dir').addEventListener('click', () => openDirBrowser());
|
|
@@ -1166,6 +1333,7 @@ function showProperties(widget) {
|
|
|
1166
1333
|
// Hide all optional groups first
|
|
1167
1334
|
document.getElementById('prop-api-group').style.display = 'none';
|
|
1168
1335
|
document.getElementById('prop-endpoint-group').style.display = 'none';
|
|
1336
|
+
if (document.getElementById('prop-server-group')) document.getElementById('prop-server-group').style.display = 'none';
|
|
1169
1337
|
if (document.getElementById('prop-directorypath-group')) document.getElementById('prop-directorypath-group').style.display = 'none';
|
|
1170
1338
|
document.getElementById('prop-location-group').style.display = 'none';
|
|
1171
1339
|
document.getElementById('prop-locations-group').style.display = 'none';
|
|
@@ -1378,6 +1546,16 @@ function showProperties(widget) {
|
|
|
1378
1546
|
document.getElementById('prop-endpoint').value = widget.properties.endpoint || '';
|
|
1379
1547
|
}
|
|
1380
1548
|
|
|
1549
|
+
// Show server dropdown for system widgets
|
|
1550
|
+
const systemWidgets = ['uptime-monitor', 'docker-containers', 'disk-usage', 'network-speed', 'cpu-memory'];
|
|
1551
|
+
const serverGroup = document.getElementById('prop-server-group');
|
|
1552
|
+
if (serverGroup && systemWidgets.includes(widget.type)) {
|
|
1553
|
+
serverGroup.style.display = 'block';
|
|
1554
|
+
populateServerDropdown(widget.properties.server || 'local');
|
|
1555
|
+
} else if (serverGroup) {
|
|
1556
|
+
serverGroup.style.display = 'none';
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1381
1559
|
document.getElementById('prop-refresh').value = widget.properties.refreshInterval || 60;
|
|
1382
1560
|
|
|
1383
1561
|
// Widget font scale (per-widget override)
|
|
@@ -1414,7 +1592,7 @@ function showProperties(widget) {
|
|
|
1414
1592
|
|
|
1415
1593
|
// Properties already handled by hardcoded UI groups
|
|
1416
1594
|
const HANDLED_PROPS = new Set([
|
|
1417
|
-
'title', 'showHeader', 'refreshInterval', 'endpoint',
|
|
1595
|
+
'title', 'showHeader', 'refreshInterval', 'endpoint', 'server', 'path',
|
|
1418
1596
|
'fontSize', 'fontColor', 'textAlign', 'fontWeight',
|
|
1419
1597
|
'showBorder', 'lineColor', 'lineThickness', 'columns', 'feedUrl', 'layout',
|
|
1420
1598
|
'location', 'locations', 'units', 'format24h',
|
|
@@ -1777,6 +1955,9 @@ function onPropertyChange(e) {
|
|
|
1777
1955
|
case 'prop-endpoint':
|
|
1778
1956
|
widget.properties.endpoint = e.target.value;
|
|
1779
1957
|
break;
|
|
1958
|
+
case 'prop-server':
|
|
1959
|
+
widget.properties.server = e.target.value;
|
|
1960
|
+
break;
|
|
1780
1961
|
case 'prop-directorypath':
|
|
1781
1962
|
widget.properties.directoryPath = e.target.value;
|
|
1782
1963
|
break;
|
package/js/widgets.js
CHANGED
|
@@ -161,6 +161,109 @@ function onSystemStats(callback) {
|
|
|
161
161
|
}
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
// ─────────────────────────────────────────────
|
|
165
|
+
// Remote server polling for system stats
|
|
166
|
+
// ─────────────────────────────────────────────
|
|
167
|
+
const _remotePollers = {}; // serverId -> { interval, callbacks, lastData, errors, lastSuccess }
|
|
168
|
+
|
|
169
|
+
function onRemoteStats(serverId, callback, refreshMs = 10000) {
|
|
170
|
+
if (!_remotePollers[serverId]) {
|
|
171
|
+
_remotePollers[serverId] = {
|
|
172
|
+
callbacks: [],
|
|
173
|
+
interval: null,
|
|
174
|
+
lastData: null,
|
|
175
|
+
errors: 0,
|
|
176
|
+
lastSuccess: null,
|
|
177
|
+
offline: false
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const poll = async () => {
|
|
181
|
+
const poller = _remotePollers[serverId];
|
|
182
|
+
try {
|
|
183
|
+
const res = await fetch(`/api/servers/${serverId}/stats`, {
|
|
184
|
+
signal: AbortSignal.timeout(10000) // 10s timeout
|
|
185
|
+
});
|
|
186
|
+
if (res.ok) {
|
|
187
|
+
const data = await res.json();
|
|
188
|
+
const normalized = _normalizeRemoteStats(data);
|
|
189
|
+
poller.lastData = normalized;
|
|
190
|
+
poller.errors = 0;
|
|
191
|
+
poller.lastSuccess = Date.now();
|
|
192
|
+
poller.offline = false;
|
|
193
|
+
poller.callbacks.forEach(cb => cb(normalized));
|
|
194
|
+
} else {
|
|
195
|
+
throw new Error(`HTTP ${res.status}`);
|
|
196
|
+
}
|
|
197
|
+
} catch (e) {
|
|
198
|
+
poller.errors++;
|
|
199
|
+
console.warn(`Remote stats error (${serverId}, attempt ${poller.errors}):`, e.message);
|
|
200
|
+
|
|
201
|
+
// After 3 consecutive failures, mark as offline and notify widgets
|
|
202
|
+
if (poller.errors >= 3 && !poller.offline) {
|
|
203
|
+
poller.offline = true;
|
|
204
|
+
const offlineData = {
|
|
205
|
+
_offline: true,
|
|
206
|
+
_error: e.message,
|
|
207
|
+
_lastSuccess: poller.lastSuccess,
|
|
208
|
+
_serverId: serverId
|
|
209
|
+
};
|
|
210
|
+
poller.callbacks.forEach(cb => cb(offlineData));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
poll(); // Initial fetch
|
|
216
|
+
_remotePollers[serverId].interval = setInterval(poll, refreshMs);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
_remotePollers[serverId].callbacks.push(callback);
|
|
220
|
+
|
|
221
|
+
// If we have cached data, call immediately
|
|
222
|
+
if (_remotePollers[serverId].lastData) {
|
|
223
|
+
callback(_remotePollers[serverId].lastData);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Normalize remote agent stats to match local SSE format
|
|
228
|
+
function _normalizeRemoteStats(data) {
|
|
229
|
+
return {
|
|
230
|
+
uptime: data.uptime,
|
|
231
|
+
cpu: data.cpu ? {
|
|
232
|
+
currentLoad: data.cpu.usage || 0,
|
|
233
|
+
cores: data.cpu.cores || 0,
|
|
234
|
+
} : null,
|
|
235
|
+
memory: data.memory ? {
|
|
236
|
+
total: data.memory.total || 0,
|
|
237
|
+
active: data.memory.used || 0,
|
|
238
|
+
available: data.memory.available || 0,
|
|
239
|
+
} : null,
|
|
240
|
+
disk: data.disk ? [{
|
|
241
|
+
mount: data.disk.mount || '/',
|
|
242
|
+
size: data.disk.total || 0,
|
|
243
|
+
used: data.disk.used || 0,
|
|
244
|
+
}] : null,
|
|
245
|
+
network: data.network ? [{
|
|
246
|
+
rx_sec: data.network.rxSec || 0,
|
|
247
|
+
tx_sec: data.network.txSec || 0,
|
|
248
|
+
}] : null,
|
|
249
|
+
docker: data.docker,
|
|
250
|
+
openclaw: data.openclaw,
|
|
251
|
+
serverName: data.serverName,
|
|
252
|
+
_remote: true,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Unified stats function: local or remote
|
|
257
|
+
function onStats(serverId, callback, refreshMs = 10000) {
|
|
258
|
+
if (!serverId || serverId === 'local') {
|
|
259
|
+
onSystemStats(callback);
|
|
260
|
+
} else {
|
|
261
|
+
onRemoteStats(serverId, callback, refreshMs);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
window.onStats = onStats;
|
|
266
|
+
|
|
164
267
|
function _formatBytes(bytes, decimals = 1) {
|
|
165
268
|
if (bytes === 0 || bytes == null) return '0 B';
|
|
166
269
|
const k = 1024;
|
|
@@ -2106,13 +2209,13 @@ const WIDGETS = {
|
|
|
2106
2209
|
name: 'CPU / Memory',
|
|
2107
2210
|
icon: '💻',
|
|
2108
2211
|
category: 'small',
|
|
2109
|
-
description: 'Shows CPU and memory usage.
|
|
2212
|
+
description: 'Shows CPU and memory usage. Supports remote servers via lobsterboard-agent.',
|
|
2110
2213
|
defaultWidth: 200,
|
|
2111
2214
|
defaultHeight: 120,
|
|
2112
2215
|
hasApiKey: false,
|
|
2113
2216
|
properties: {
|
|
2114
2217
|
title: 'System',
|
|
2115
|
-
|
|
2218
|
+
server: 'local',
|
|
2116
2219
|
refreshInterval: 5
|
|
2117
2220
|
},
|
|
2118
2221
|
preview: `<div style="padding:8px;font-size:11px;">
|
|
@@ -2130,8 +2233,14 @@ const WIDGETS = {
|
|
|
2130
2233
|
</div>
|
|
2131
2234
|
</div>`,
|
|
2132
2235
|
generateJs: (props) => `
|
|
2133
|
-
// CPU/Memory Widget: ${props.id} —
|
|
2134
|
-
|
|
2236
|
+
// CPU/Memory Widget: ${props.id} — ${props.server === 'local' ? 'local SSE' : 'remote: ' + props.server}
|
|
2237
|
+
onStats('${props.server || 'local'}', function(data) {
|
|
2238
|
+
// Handle offline state
|
|
2239
|
+
if (data._offline) {
|
|
2240
|
+
document.getElementById('${props.id}-cpu').textContent = '⚠️';
|
|
2241
|
+
document.getElementById('${props.id}-mem').textContent = 'offline';
|
|
2242
|
+
return;
|
|
2243
|
+
}
|
|
2135
2244
|
if (data.cpu) {
|
|
2136
2245
|
document.getElementById('${props.id}-cpu').textContent = data.cpu.currentLoad.toFixed(0) + '%';
|
|
2137
2246
|
}
|
|
@@ -2140,7 +2249,7 @@ const WIDGETS = {
|
|
|
2140
2249
|
const total = (data.memory.total / (1024*1024*1024)).toFixed(1);
|
|
2141
2250
|
document.getElementById('${props.id}-mem').textContent = used + ' / ' + total + ' GB';
|
|
2142
2251
|
}
|
|
2143
|
-
});
|
|
2252
|
+
}, ${(props.refreshInterval || 5) * 1000});
|
|
2144
2253
|
`
|
|
2145
2254
|
},
|
|
2146
2255
|
|
|
@@ -2148,14 +2257,14 @@ const WIDGETS = {
|
|
|
2148
2257
|
name: 'Disk Usage',
|
|
2149
2258
|
icon: '💾',
|
|
2150
2259
|
category: 'small',
|
|
2151
|
-
description: 'Shows disk space usage.
|
|
2260
|
+
description: 'Shows disk space usage. Supports remote servers via lobsterboard-agent.',
|
|
2152
2261
|
defaultWidth: 160,
|
|
2153
2262
|
defaultHeight: 100,
|
|
2154
2263
|
hasApiKey: false,
|
|
2155
2264
|
properties: {
|
|
2156
2265
|
title: 'Disk',
|
|
2266
|
+
server: 'local',
|
|
2157
2267
|
path: '/',
|
|
2158
|
-
endpoint: '/api/disk',
|
|
2159
2268
|
refreshInterval: 60
|
|
2160
2269
|
},
|
|
2161
2270
|
preview: `<div style="text-align:center;padding:8px;">
|
|
@@ -2183,20 +2292,35 @@ const WIDGETS = {
|
|
|
2183
2292
|
</div>
|
|
2184
2293
|
</div>`,
|
|
2185
2294
|
generateJs: (props) => `
|
|
2186
|
-
// Disk Usage Widget: ${props.id} —
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2295
|
+
// Disk Usage Widget: ${props.id} — ${props.server === 'local' ? 'local SSE' : 'remote: ' + props.server}
|
|
2296
|
+
onStats('${props.server || 'local'}', function(data) {
|
|
2297
|
+
// Handle offline state
|
|
2298
|
+
if (data._offline) {
|
|
2299
|
+
document.getElementById('${props.id}-pct').textContent = '⚠️';
|
|
2300
|
+
document.getElementById('${props.id}-size').textContent = 'offline';
|
|
2301
|
+
document.getElementById('${props.id}-ring').style.strokeDashoffset = 125.66;
|
|
2302
|
+
return;
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
// Handle both local (array) and remote (object) disk data
|
|
2306
|
+
let d;
|
|
2307
|
+
if (Array.isArray(data.disk)) {
|
|
2308
|
+
if (data.disk.length === 0) return;
|
|
2309
|
+
const targetMount = '${props.path || '/'}';
|
|
2310
|
+
d = data.disk.find(x => x.mount === targetMount) || data.disk[0];
|
|
2311
|
+
} else if (data.disk) {
|
|
2312
|
+
d = data.disk;
|
|
2313
|
+
} else {
|
|
2314
|
+
return;
|
|
2315
|
+
}
|
|
2316
|
+
const pct = d.use || d.percent || 0;
|
|
2193
2317
|
const circumference = 125.66;
|
|
2194
2318
|
document.getElementById('${props.id}-ring').style.strokeDashoffset = circumference - (pct / 100) * circumference;
|
|
2195
2319
|
document.getElementById('${props.id}-pct').textContent = Math.round(pct) + '%';
|
|
2196
|
-
const usedGB = (d.used / (1024*1024*1024)).toFixed(1);
|
|
2197
|
-
const totalGB = (d.size / (1024*1024*1024)).toFixed(0);
|
|
2320
|
+
const usedGB = ((d.used || 0) / (1024*1024*1024)).toFixed(1);
|
|
2321
|
+
const totalGB = ((d.size || d.total || 0) / (1024*1024*1024)).toFixed(0);
|
|
2198
2322
|
document.getElementById('${props.id}-size').textContent = usedGB + ' / ' + totalGB + ' GB';
|
|
2199
|
-
});
|
|
2323
|
+
}, ${(props.refreshInterval || 60) * 1000});
|
|
2200
2324
|
`
|
|
2201
2325
|
},
|
|
2202
2326
|
|
|
@@ -2204,34 +2328,44 @@ const WIDGETS = {
|
|
|
2204
2328
|
name: 'Uptime Monitor',
|
|
2205
2329
|
icon: '📡',
|
|
2206
2330
|
category: 'large',
|
|
2207
|
-
description: 'Shows
|
|
2331
|
+
description: 'Shows system uptime, CPU, and memory. Supports remote servers via lobsterboard-agent.',
|
|
2208
2332
|
defaultWidth: 350,
|
|
2209
2333
|
defaultHeight: 220,
|
|
2210
2334
|
hasApiKey: false,
|
|
2211
2335
|
properties: {
|
|
2212
2336
|
title: 'Uptime',
|
|
2213
|
-
|
|
2337
|
+
server: 'local',
|
|
2214
2338
|
refreshInterval: 30
|
|
2215
2339
|
},
|
|
2216
2340
|
preview: `<div style="padding:4px;font-size:11px;">
|
|
2217
|
-
<div>🟢
|
|
2218
|
-
<div>🟢
|
|
2219
|
-
<div
|
|
2341
|
+
<div>🟢 System — 5d 12h</div>
|
|
2342
|
+
<div>🟢 CPU — 12.5%</div>
|
|
2343
|
+
<div>🟢 Memory — 45.2%</div>
|
|
2220
2344
|
</div>`,
|
|
2221
2345
|
generateHtml: (props) => `
|
|
2222
2346
|
<div class="dash-card" id="widget-${props.id}" style="height:100%;">
|
|
2223
2347
|
<div class="dash-card-head">
|
|
2224
2348
|
<span class="dash-card-title">${renderIcon('uptime')} ${props.title || 'Uptime'}</span>
|
|
2349
|
+
${props.server && props.server !== 'local' ? `<span class="dash-card-badge" style="font-size:10px;">🌐</span>` : ''}
|
|
2225
2350
|
</div>
|
|
2226
2351
|
<div class="dash-card-body" id="${props.id}-services">
|
|
2227
2352
|
<div class="uptime-row" style="color:var(--text-muted);justify-content:center;">Loading...</div>
|
|
2228
2353
|
</div>
|
|
2229
2354
|
</div>`,
|
|
2230
2355
|
generateJs: (props) => `
|
|
2231
|
-
// Uptime Monitor Widget: ${props.id} —
|
|
2232
|
-
|
|
2233
|
-
if (data.uptime == null) return;
|
|
2356
|
+
// Uptime Monitor Widget: ${props.id} — ${props.server === 'local' ? 'local SSE' : 'remote: ' + props.server}
|
|
2357
|
+
onStats('${props.server || 'local'}', function(data) {
|
|
2234
2358
|
const container = document.getElementById('${props.id}-services');
|
|
2359
|
+
|
|
2360
|
+
// Handle offline state
|
|
2361
|
+
if (data._offline) {
|
|
2362
|
+
const lastSeen = data._lastSuccess ? new Date(data._lastSuccess).toLocaleTimeString() : 'never';
|
|
2363
|
+
container.innerHTML = '<div class="uptime-row" style="color:#f85149;justify-content:center;">⚠️ Connection lost</div>' +
|
|
2364
|
+
'<div class="uptime-row" style="opacity:0.6;font-size:11px;justify-content:center;">Last: ' + lastSeen + '</div>';
|
|
2365
|
+
return;
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
if (data.uptime == null) return;
|
|
2235
2369
|
const secs = data.uptime;
|
|
2236
2370
|
const d = Math.floor(secs / 86400);
|
|
2237
2371
|
const h = Math.floor((secs % 86400) / 3600);
|
|
@@ -2248,8 +2382,11 @@ const WIDGETS = {
|
|
|
2248
2382
|
const memPct = ((data.memory.active / data.memory.total) * 100).toFixed(1);
|
|
2249
2383
|
html += '<div class="uptime-row"><span>' + window.renderIcon('memory') + ' Memory</span><span class="uptime-pct">' + memPct + '%</span></div>';
|
|
2250
2384
|
}
|
|
2385
|
+
if (data.serverName && data._remote) {
|
|
2386
|
+
html += '<div class="uptime-row" style="opacity:0.6;font-size:11px;"><span>📡 ' + data.serverName + '</span></div>';
|
|
2387
|
+
}
|
|
2251
2388
|
container.innerHTML = html;
|
|
2252
|
-
});
|
|
2389
|
+
}, ${(props.refreshInterval || 30) * 1000});
|
|
2253
2390
|
`
|
|
2254
2391
|
},
|
|
2255
2392
|
|
|
@@ -2257,13 +2394,13 @@ const WIDGETS = {
|
|
|
2257
2394
|
name: 'Docker Containers',
|
|
2258
2395
|
icon: '🐳',
|
|
2259
2396
|
category: 'large',
|
|
2260
|
-
description: 'Lists Docker containers with status.
|
|
2397
|
+
description: 'Lists Docker containers with status. Supports remote servers via lobsterboard-agent.',
|
|
2261
2398
|
defaultWidth: 380,
|
|
2262
2399
|
defaultHeight: 250,
|
|
2263
2400
|
hasApiKey: false,
|
|
2264
2401
|
properties: {
|
|
2265
2402
|
title: 'Containers',
|
|
2266
|
-
|
|
2403
|
+
server: 'local',
|
|
2267
2404
|
refreshInterval: 10
|
|
2268
2405
|
},
|
|
2269
2406
|
preview: `<div style="padding:4px;font-size:11px;">
|
|
@@ -2282,23 +2419,35 @@ const WIDGETS = {
|
|
|
2282
2419
|
</div>
|
|
2283
2420
|
</div>`,
|
|
2284
2421
|
generateJs: (props) => `
|
|
2285
|
-
// Docker Containers Widget: ${props.id} —
|
|
2286
|
-
|
|
2422
|
+
// Docker Containers Widget: ${props.id} — ${props.server === 'local' ? 'local SSE' : 'remote: ' + props.server}
|
|
2423
|
+
onStats('${props.server || 'local'}', function(data) {
|
|
2287
2424
|
const list = document.getElementById('${props.id}-list');
|
|
2288
2425
|
const badge = document.getElementById('${props.id}-badge');
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2426
|
+
|
|
2427
|
+
// Handle offline state
|
|
2428
|
+
if (data._offline) {
|
|
2429
|
+
list.innerHTML = '<div class="docker-row" style="color:#f85149;">⚠️ Connection lost</div>';
|
|
2430
|
+
badge.textContent = '—';
|
|
2292
2431
|
return;
|
|
2293
2432
|
}
|
|
2294
|
-
|
|
2433
|
+
|
|
2434
|
+
// Handle remote docker data structure
|
|
2435
|
+
const dockerData = data._remote && data.docker?.containers ? data.docker.containers : data.docker;
|
|
2436
|
+
if (!dockerData || dockerData.length === 0) {
|
|
2437
|
+
const msg = data._remote && data.docker?.available === false ? 'Docker not available' : 'No containers found';
|
|
2438
|
+
list.innerHTML = '<div class="docker-row" style="color:var(--text-muted);">' + msg + '</div>';
|
|
2439
|
+
badge.textContent = data._remote && data.docker ? (data.docker.running || 0) + '/' + (data.docker.total || 0) : '0';
|
|
2440
|
+
return;
|
|
2441
|
+
}
|
|
2442
|
+
const containers = dockerData;
|
|
2295
2443
|
list.innerHTML = containers.map(function(c) {
|
|
2296
|
-
const
|
|
2444
|
+
const running = c.state === 'running' || c.running === true;
|
|
2445
|
+
const icon = running ? '🟢' : '🔴';
|
|
2297
2446
|
const name = (c.name || '').replace(/^\\//, '');
|
|
2298
2447
|
return '<div class="docker-row">' + icon + ' ' + name + '<span class="docker-status">' + (c.state || c.status || '—') + '</span></div>';
|
|
2299
2448
|
}).join('');
|
|
2300
|
-
badge.textContent = containers.length;
|
|
2301
|
-
});
|
|
2449
|
+
badge.textContent = data._remote && data.docker ? (data.docker.running || 0) + '/' + (data.docker.total || 0) : containers.length;
|
|
2450
|
+
}, ${(props.refreshInterval || 10) * 1000});
|
|
2302
2451
|
`
|
|
2303
2452
|
},
|
|
2304
2453
|
|
|
@@ -2306,18 +2455,18 @@ const WIDGETS = {
|
|
|
2306
2455
|
name: 'Network Speed',
|
|
2307
2456
|
icon: '🌐',
|
|
2308
2457
|
category: 'small',
|
|
2309
|
-
description: 'Shows real-time network activity
|
|
2458
|
+
description: 'Shows real-time network activity. Supports remote servers via lobsterboard-agent.',
|
|
2310
2459
|
defaultWidth: 200,
|
|
2311
2460
|
defaultHeight: 100,
|
|
2312
2461
|
hasApiKey: false,
|
|
2313
2462
|
properties: {
|
|
2314
2463
|
title: 'Network',
|
|
2315
|
-
|
|
2316
|
-
refreshInterval:
|
|
2464
|
+
server: 'local',
|
|
2465
|
+
refreshInterval: 5
|
|
2317
2466
|
},
|
|
2318
2467
|
preview: `<div style="padding:8px;font-size:11px;">
|
|
2319
|
-
<div>↓ <span style="color:#3fb950;">45
|
|
2320
|
-
<div>↑ <span style="color:#58a6ff;">12
|
|
2468
|
+
<div>↓ <span style="color:#3fb950;">45 KB/s</span></div>
|
|
2469
|
+
<div>↑ <span style="color:#58a6ff;">12 KB/s</span></div>
|
|
2321
2470
|
</div>`,
|
|
2322
2471
|
generateHtml: (props) => `
|
|
2323
2472
|
<div class="dash-card" id="widget-${props.id}" style="height:100%;">
|
|
@@ -2330,26 +2479,38 @@ const WIDGETS = {
|
|
|
2330
2479
|
</div>
|
|
2331
2480
|
</div>`,
|
|
2332
2481
|
generateJs: (props) => `
|
|
2333
|
-
// Network Speed Widget: ${props.id} —
|
|
2482
|
+
// Network Speed Widget: ${props.id} — ${props.server === 'local' ? 'local SSE' : 'remote: ' + props.server}
|
|
2334
2483
|
function _fmtRate(bytes) {
|
|
2335
2484
|
if (bytes == null || bytes < 0) return '0 B/s';
|
|
2336
2485
|
if (bytes < 1024) return bytes.toFixed(0) + ' B/s';
|
|
2337
2486
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB/s';
|
|
2338
2487
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB/s';
|
|
2339
2488
|
}
|
|
2340
|
-
|
|
2489
|
+
onStats('${props.server || 'local'}', function(data) {
|
|
2490
|
+
// Handle offline state
|
|
2491
|
+
if (data._offline) {
|
|
2492
|
+
document.getElementById('${props.id}-down').textContent = '⚠️';
|
|
2493
|
+
document.getElementById('${props.id}-up').textContent = 'offline';
|
|
2494
|
+
return;
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2341
2497
|
if (!data.network || data.network.length === 0) return;
|
|
2342
|
-
//
|
|
2498
|
+
// Handle both local (array) and remote (object) formats
|
|
2343
2499
|
let rx = 0, tx = 0;
|
|
2344
|
-
data.network
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2500
|
+
if (Array.isArray(data.network)) {
|
|
2501
|
+
data.network.forEach(function(n) {
|
|
2502
|
+
if (n.iface !== 'lo' && n.iface !== 'lo0') {
|
|
2503
|
+
rx += (n.rx_sec || 0);
|
|
2504
|
+
tx += (n.tx_sec || 0);
|
|
2505
|
+
}
|
|
2506
|
+
});
|
|
2507
|
+
} else {
|
|
2508
|
+
rx = data.network.rx_sec || data.network.rxSec || 0;
|
|
2509
|
+
tx = data.network.tx_sec || data.network.txSec || 0;
|
|
2510
|
+
}
|
|
2350
2511
|
document.getElementById('${props.id}-down').textContent = _fmtRate(rx);
|
|
2351
2512
|
document.getElementById('${props.id}-up').textContent = _fmtRate(tx);
|
|
2352
|
-
});
|
|
2513
|
+
}, ${(props.refreshInterval || 5) * 1000});
|
|
2353
2514
|
`
|
|
2354
2515
|
},
|
|
2355
2516
|
|
package/package.json
CHANGED
package/server.cjs
CHANGED
|
@@ -1817,6 +1817,141 @@ const server = http.createServer(async (req, res) => {
|
|
|
1817
1817
|
return;
|
|
1818
1818
|
}
|
|
1819
1819
|
|
|
1820
|
+
// ─────────────────────────────────────────────
|
|
1821
|
+
// Server Profiles API (for remote LobsterBoard Agent connections)
|
|
1822
|
+
// ─────────────────────────────────────────────
|
|
1823
|
+
const SERVERS_FILE = path.join(__dirname, 'data', 'servers.json');
|
|
1824
|
+
|
|
1825
|
+
function loadServers() {
|
|
1826
|
+
try {
|
|
1827
|
+
if (fs.existsSync(SERVERS_FILE)) {
|
|
1828
|
+
return JSON.parse(fs.readFileSync(SERVERS_FILE, 'utf8'));
|
|
1829
|
+
}
|
|
1830
|
+
} catch (e) { /* ignore */ }
|
|
1831
|
+
return [{ id: 'local', name: 'Local', type: 'local' }];
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
function saveServers(servers) {
|
|
1835
|
+
fs.mkdirSync(path.dirname(SERVERS_FILE), { recursive: true });
|
|
1836
|
+
fs.writeFileSync(SERVERS_FILE, JSON.stringify(servers, null, 2));
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
// GET /api/servers - List all servers
|
|
1840
|
+
if (req.method === 'GET' && pathname === '/api/servers') {
|
|
1841
|
+
const servers = loadServers();
|
|
1842
|
+
// Mask API keys for security
|
|
1843
|
+
const masked = servers.map(s => ({
|
|
1844
|
+
...s,
|
|
1845
|
+
apiKey: s.apiKey ? s.apiKey.slice(0, 10) + '...' : undefined
|
|
1846
|
+
}));
|
|
1847
|
+
sendJson(res, 200, { servers: masked });
|
|
1848
|
+
return;
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
// POST /api/servers - Add a server
|
|
1852
|
+
if (req.method === 'POST' && pathname === '/api/servers') {
|
|
1853
|
+
let body = '';
|
|
1854
|
+
req.on('data', c => body += c);
|
|
1855
|
+
req.on('end', () => {
|
|
1856
|
+
try {
|
|
1857
|
+
const { name, url, apiKey } = JSON.parse(body);
|
|
1858
|
+
if (!name || !url || !apiKey) {
|
|
1859
|
+
return sendJson(res, 400, { error: 'name, url, and apiKey required' });
|
|
1860
|
+
}
|
|
1861
|
+
const servers = loadServers();
|
|
1862
|
+
const id = name.toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
|
1863
|
+
if (servers.find(s => s.id === id)) {
|
|
1864
|
+
return sendJson(res, 400, { error: 'Server with this name already exists' });
|
|
1865
|
+
}
|
|
1866
|
+
servers.push({ id, name, url, apiKey, type: 'remote' });
|
|
1867
|
+
saveServers(servers);
|
|
1868
|
+
sendJson(res, 200, { status: 'success', id });
|
|
1869
|
+
} catch (e) {
|
|
1870
|
+
sendJson(res, 400, { error: e.message });
|
|
1871
|
+
}
|
|
1872
|
+
});
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
// PUT /api/servers/:id - Update a server
|
|
1877
|
+
if (req.method === 'PUT' && pathname.startsWith('/api/servers/')) {
|
|
1878
|
+
const id = pathname.split('/')[3];
|
|
1879
|
+
let body = '';
|
|
1880
|
+
req.on('data', c => body += c);
|
|
1881
|
+
req.on('end', () => {
|
|
1882
|
+
try {
|
|
1883
|
+
const updates = JSON.parse(body);
|
|
1884
|
+
const servers = loadServers();
|
|
1885
|
+
const idx = servers.findIndex(s => s.id === id);
|
|
1886
|
+
if (idx === -1) return sendJson(res, 404, { error: 'Server not found' });
|
|
1887
|
+
if (id === 'local') return sendJson(res, 400, { error: 'Cannot modify local server' });
|
|
1888
|
+
servers[idx] = { ...servers[idx], ...updates, id }; // Don't allow id change
|
|
1889
|
+
saveServers(servers);
|
|
1890
|
+
sendJson(res, 200, { status: 'success' });
|
|
1891
|
+
} catch (e) {
|
|
1892
|
+
sendJson(res, 400, { error: e.message });
|
|
1893
|
+
}
|
|
1894
|
+
});
|
|
1895
|
+
return;
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
// DELETE /api/servers/:id - Delete a server
|
|
1899
|
+
if (req.method === 'DELETE' && pathname.startsWith('/api/servers/')) {
|
|
1900
|
+
const id = pathname.split('/')[3];
|
|
1901
|
+
if (id === 'local') return sendJson(res, 400, { error: 'Cannot delete local server' });
|
|
1902
|
+
const servers = loadServers();
|
|
1903
|
+
const filtered = servers.filter(s => s.id !== id);
|
|
1904
|
+
if (filtered.length === servers.length) {
|
|
1905
|
+
return sendJson(res, 404, { error: 'Server not found' });
|
|
1906
|
+
}
|
|
1907
|
+
saveServers(filtered);
|
|
1908
|
+
sendJson(res, 200, { status: 'success' });
|
|
1909
|
+
return;
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
// POST /api/servers/:id/test - Test connection to a server
|
|
1913
|
+
if (req.method === 'POST' && pathname.match(/^\/api\/servers\/[^/]+\/test$/)) {
|
|
1914
|
+
const id = pathname.split('/')[3];
|
|
1915
|
+
const servers = loadServers();
|
|
1916
|
+
const server = servers.find(s => s.id === id);
|
|
1917
|
+
if (!server) return sendJson(res, 404, { error: 'Server not found' });
|
|
1918
|
+
if (server.type === 'local') {
|
|
1919
|
+
return sendJson(res, 200, { status: 'ok', message: 'Local server' });
|
|
1920
|
+
}
|
|
1921
|
+
// Test remote connection
|
|
1922
|
+
fetch(server.url + '/health', {
|
|
1923
|
+
headers: { 'X-API-Key': server.apiKey },
|
|
1924
|
+
signal: AbortSignal.timeout(5000),
|
|
1925
|
+
})
|
|
1926
|
+
.then(r => r.json())
|
|
1927
|
+
.then(data => sendJson(res, 200, { status: 'ok', serverName: data.serverName }))
|
|
1928
|
+
.catch(e => sendJson(res, 200, { status: 'error', message: e.message }));
|
|
1929
|
+
return;
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
// GET /api/servers/:id/stats - Fetch stats from a remote server
|
|
1933
|
+
if (req.method === 'GET' && pathname.match(/^\/api\/servers\/[^/]+\/stats$/)) {
|
|
1934
|
+
const id = pathname.split('/')[3];
|
|
1935
|
+
const servers = loadServers();
|
|
1936
|
+
const server = servers.find(s => s.id === id);
|
|
1937
|
+
if (!server) return sendJson(res, 404, { error: 'Server not found' });
|
|
1938
|
+
if (server.type === 'local') {
|
|
1939
|
+
return sendJson(res, 400, { error: 'Use /api/stats/stream for local' });
|
|
1940
|
+
}
|
|
1941
|
+
// Fetch from remote agent
|
|
1942
|
+
fetch(server.url + '/stats', {
|
|
1943
|
+
headers: { 'X-API-Key': server.apiKey },
|
|
1944
|
+
signal: AbortSignal.timeout(10000),
|
|
1945
|
+
})
|
|
1946
|
+
.then(r => {
|
|
1947
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
1948
|
+
return r.json();
|
|
1949
|
+
})
|
|
1950
|
+
.then(data => sendJson(res, 200, data))
|
|
1951
|
+
.catch(e => sendJson(res, 500, { error: e.message }));
|
|
1952
|
+
return;
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1820
1955
|
// GET /config - Load dashboard configuration
|
|
1821
1956
|
if (req.method === 'GET' && pathname === '/config') {
|
|
1822
1957
|
fs.readFile(CONFIG_FILE, 'utf8', (err, data) => {
|