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 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>
@@ -1,4 +1,4 @@
1
- /* LobsterBoard v0.4.1 - Dashboard Styles */
1
+ /* LobsterBoard v0.5.0 - Dashboard Styles */
2
2
  /* LobsterBoard Dashboard - Generated Styles */
3
3
 
4
4
  :root {
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * LobsterBoard v0.4.1
2
+ * LobsterBoard v0.5.0
3
3
  * Dashboard builder with customizable widgets
4
4
  * https://github.com/curbob/LobsterBoard
5
5
  * @license MIT
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * LobsterBoard v0.4.1
2
+ * LobsterBoard v0.5.0
3
3
  * Dashboard builder with customizable widgets
4
4
  * https://github.com/curbob/LobsterBoard
5
5
  * @license MIT
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * LobsterBoard v0.4.1
2
+ * LobsterBoard v0.5.0
3
3
  * Dashboard builder with customizable widgets
4
4
  * https://github.com/curbob/LobsterBoard
5
5
  * @license MIT
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * LobsterBoard v0.4.1
2
+ * LobsterBoard v0.5.0
3
3
  * Dashboard builder with customizable widgets
4
4
  * https://github.com/curbob/LobsterBoard
5
5
  * @license MIT
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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. Requires system stats API.',
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
- endpoint: '/api/system',
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} — live via SSE
2134
- onSystemStats(function(data) {
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. Requires system stats API.',
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} — live via SSE
2187
- onSystemStats(function(data) {
2188
- if (!data.disk || data.disk.length === 0) return;
2189
- // Find the configured mount or default to first/root
2190
- const targetMount = '${props.path || '/'}';
2191
- const d = data.disk.find(x => x.mount === targetMount) || data.disk[0];
2192
- const pct = d.use || 0;
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 service uptime. Requires uptime monitoring backend.',
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
- services: 'Website,API,Database',
2337
+ server: 'local',
2214
2338
  refreshInterval: 30
2215
2339
  },
2216
2340
  preview: `<div style="padding:4px;font-size:11px;">
2217
- <div>🟢 Website99.9%</div>
2218
- <div>🟢 API100%</div>
2219
- <div>🟡 Database98.2%</div>
2341
+ <div>🟢 System5d 12h</div>
2342
+ <div>🟢 CPU12.5%</div>
2343
+ <div>🟢 Memory45.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} — live via SSE
2232
- onSystemStats(function(data) {
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. Requires Docker API proxy.',
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
- endpoint: '/api/docker',
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} — live via SSE
2286
- onSystemStats(function(data) {
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
- if (!data.docker || data.docker.length === 0) {
2290
- list.innerHTML = '<div class="docker-row" style="color:var(--text-muted);">No containers found</div>';
2291
- badge.textContent = '0';
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
- const containers = data.docker;
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 icon = c.state === 'running' ? '🟢' : '🔴';
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 (upload/download throughput). Updates every 2 seconds.',
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
- endpoint: '/api/network',
2316
- refreshInterval: 2
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 Mbps</span></div>
2320
- <div>↑ <span style="color:#58a6ff;">12 Mbps</span></div>
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} — live via SSE
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
- onSystemStats(function(data) {
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
- // Sum all interfaces or pick the first non-loopback
2498
+ // Handle both local (array) and remote (object) formats
2343
2499
  let rx = 0, tx = 0;
2344
- data.network.forEach(function(n) {
2345
- if (n.iface !== 'lo' && n.iface !== 'lo0') {
2346
- rx += (n.rx_sec || 0);
2347
- tx += (n.tx_sec || 0);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lobsterboard",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Self-hosted drag-and-drop dashboard builder with 50 widgets, template gallery, and custom pages. Works standalone or with OpenClaw.",
5
5
  "keywords": [
6
6
  "dashboard",
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) => {