lobsterboard 0.4.0 → 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
@@ -1,6 +1,6 @@
1
1
  # 🦞 LobsterBoard
2
2
 
3
- A self-hosted, drag-and-drop dashboard builder with 50 widgets, a template gallery, custom pages, and zero cloud dependencies. One Node.js server, no frameworks, no build step needed.
3
+ A self-hosted, drag-and-drop dashboard builder with 60+ widgets, a template gallery, custom pages, and zero cloud dependencies. One Node.js server, no frameworks, no build step needed.
4
4
 
5
5
  **Works standalone or with [OpenClaw](https://github.com/openclaw/openclaw).** LobsterBoard is a general-purpose dashboard — use it to monitor your homelab, track stocks, display weather, manage todos, or anything else. OpenClaw users get bonus widgets (auth status, cron jobs, activity logs), but they're completely optional.
6
6
 
@@ -32,7 +32,7 @@ Open **http://localhost:8080** → press **Ctrl+E** to enter edit mode → drag
32
32
  ## Features
33
33
 
34
34
  - **Drag-and-drop editor** — visual layout with 20px snap grid, resize handles, property panel
35
- - **50 widgets** — system monitoring, weather, calendars, RSS, smart home, finance, AI/LLM tracking, notes, and more
35
+ - **60+ widgets** — system monitoring, weather, calendars, RSS, smart home, finance, AI/LLM tracking, notes, and more
36
36
  - **Template Gallery** — export, import, and share dashboard layouts with auto-screenshot previews; import as merge or full replace
37
37
  - **Custom pages** — extend your dashboard with full custom pages (notes, kanban boards, anything)
38
38
  - **Canvas sizes** — preset resolutions (1920×1080, 2560×1440, etc.) or custom sizes
@@ -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
@@ -121,13 +159,28 @@ LobsterBoard includes a built-in template system for sharing and reusing dashboa
121
159
  | Quick Links | Bookmark grid |
122
160
 
123
161
  ### 🤖 AI / LLM Monitoring
124
- | Widget | Description |
125
- |--------|-------------|
126
- | Claude Usage | Anthropic API spend tracking |
127
- | AI Cost Tracker | Monthly cost breakdown |
128
- | API Status | Provider availability |
129
- | Active Sessions | OpenClaw session monitor |
130
- | Token Gauge | Context window usage |
162
+
163
+ Track your AI coding subscriptions in real-time. Inspired by [OpenUsage](https://github.com/robinebers/openusage) by Robin Ebers.
164
+
165
+ | Widget | Description | Setup |
166
+ |--------|-------------|-------|
167
+ | AI Usage | Combined view of all providers | — |
168
+ | Claude Code | Session, weekly, Opus limits | Run `claude` once |
169
+ | Codex CLI | Session, weekly, code reviews | Run `codex` once |
170
+ | GitHub Copilot | Premium, chat, completions | Run `gh auth login` |
171
+ | Cursor | Credits, usage breakdown | Just use Cursor IDE |
172
+ | Gemini CLI | Pro, flash models | Run `gemini` once |
173
+ | Amp | Free tier, credits | Run `amp` once |
174
+ | Factory / Droid | Standard, premium tokens | Run `factory` once |
175
+ | Kimi Code | Session, weekly | Run `kimi` once |
176
+ | JetBrains AI | Quota tracking | Sign in via IDE |
177
+ | Antigravity | Gemini 3, Claude via Google | Run `antigravity-usage login` |
178
+ | MiniMax | Coding plan session | Set `MINIMAX_API_KEY` |
179
+ | Z.ai | Session, weekly | Set `ZAI_API_KEY` |
180
+ | AI Cost Tracker | Monthly cost breakdown | — |
181
+ | API Status | Provider availability | — |
182
+ | Active Sessions | OpenClaw session monitor | — |
183
+ | Token Gauge | Context window usage | — |
131
184
 
132
185
  ### 💰 Finance
133
186
  | Widget | Description |
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/css/themes.css CHANGED
@@ -740,12 +740,27 @@ body.theme-paper {
740
740
  .theme-terminal .lb-icon[data-icon="pages"]::after { content: "\eb03"; } /* files */
741
741
 
742
742
  /* AI / Monitoring icons */
743
+ .theme-terminal .lb-icon[data-icon="ai-usage"]::after { content: "\ecc6"; } /* robot */
743
744
  .theme-terminal .lb-icon[data-icon="ai-claude"]::after { content: "\ea38"; } /* circle */
744
745
  .theme-terminal .lb-icon[data-icon="ai-cost"]::after { content: "\ea81"; } /* currency-dollar */
745
746
  .theme-terminal .lb-icon[data-icon="api-status"]::after { content: "\e96f"; } /* arrows-clockwise */
746
747
  .theme-terminal .lb-icon[data-icon="sessions"]::after { content: "\ea25"; } /* chat-dots */
747
748
  .theme-terminal .lb-icon[data-icon="tokens"]::after { content: "\ea18"; } /* chart-bar */
748
749
 
750
+ /* AI Provider icons */
751
+ .theme-terminal .lb-icon[data-icon="claude-code"]::after { content: "\ea38"; } /* circle */
752
+ .theme-terminal .lb-icon[data-icon="codex-cli"]::after { content: "\ea38"; } /* circle */
753
+ .theme-terminal .lb-icon[data-icon="github-copilot"]::after { content: "\ea38"; } /* circle */
754
+ .theme-terminal .lb-icon[data-icon="cursor"]::after { content: "\ea38"; } /* circle */
755
+ .theme-terminal .lb-icon[data-icon="gemini-cli"]::after { content: "\eaa9"; } /* diamond */
756
+ .theme-terminal .lb-icon[data-icon="amp-code"]::after { content: "\ebb3"; } /* lightning */
757
+ .theme-terminal .lb-icon[data-icon="factory"]::after { content: "\eaf4"; } /* factory */
758
+ .theme-terminal .lb-icon[data-icon="kimi-code"]::after { content: "\ec10"; } /* moon */
759
+ .theme-terminal .lb-icon[data-icon="jetbrains-ai"]::after { content: "\e9d1"; } /* brain */
760
+ .theme-terminal .lb-icon[data-icon="minimax"]::after { content: "\eaa9"; } /* diamond */
761
+ .theme-terminal .lb-icon[data-icon="zai"]::after { content: "\ecd9"; } /* sparkle */
762
+ .theme-terminal .lb-icon[data-icon="antigravity"]::after { content: "\ec42"; } /* planet */
763
+
749
764
  /* Finance icons */
750
765
  .theme-terminal .lb-icon[data-icon="stock"]::after { content: "\ea1c"; } /* chart-line-up */
751
766
  .theme-terminal .lb-icon[data-icon="crypto"]::after { content: "\ea7d"; } /* currency-btc */
@@ -1122,6 +1137,55 @@ body.theme-paper {
1122
1137
  .theme-feminine .lb-icon[data-icon="memory"]::after { content: "\e9d1"; } /* brain */
1123
1138
  .theme-paper .lb-icon[data-icon="memory"]::after { content: "\e9d1"; } /* brain */
1124
1139
 
1140
+ /* AI Provider icons for all icon-mapped themes */
1141
+ .theme-paper .lb-icon[data-icon="ai-usage"]::after,
1142
+ .theme-feminine .lb-icon[data-icon="ai-usage"]::after,
1143
+ .theme-feminine-dark .lb-icon[data-icon="ai-usage"]::after { content: "\ecc6"; } /* robot */
1144
+
1145
+ .theme-paper .lb-icon[data-icon="claude-code"]::after,
1146
+ .theme-paper .lb-icon[data-icon="codex-cli"]::after,
1147
+ .theme-paper .lb-icon[data-icon="github-copilot"]::after,
1148
+ .theme-paper .lb-icon[data-icon="cursor"]::after,
1149
+ .theme-feminine .lb-icon[data-icon="claude-code"]::after,
1150
+ .theme-feminine .lb-icon[data-icon="codex-cli"]::after,
1151
+ .theme-feminine .lb-icon[data-icon="github-copilot"]::after,
1152
+ .theme-feminine .lb-icon[data-icon="cursor"]::after,
1153
+ .theme-feminine-dark .lb-icon[data-icon="claude-code"]::after,
1154
+ .theme-feminine-dark .lb-icon[data-icon="codex-cli"]::after,
1155
+ .theme-feminine-dark .lb-icon[data-icon="github-copilot"]::after,
1156
+ .theme-feminine-dark .lb-icon[data-icon="cursor"]::after { content: "\ea38"; } /* circle */
1157
+
1158
+ .theme-paper .lb-icon[data-icon="gemini-cli"]::after,
1159
+ .theme-paper .lb-icon[data-icon="minimax"]::after,
1160
+ .theme-feminine .lb-icon[data-icon="gemini-cli"]::after,
1161
+ .theme-feminine .lb-icon[data-icon="minimax"]::after,
1162
+ .theme-feminine-dark .lb-icon[data-icon="gemini-cli"]::after,
1163
+ .theme-feminine-dark .lb-icon[data-icon="minimax"]::after { content: "\eaa9"; } /* diamond */
1164
+
1165
+ .theme-paper .lb-icon[data-icon="amp-code"]::after,
1166
+ .theme-feminine .lb-icon[data-icon="amp-code"]::after,
1167
+ .theme-feminine-dark .lb-icon[data-icon="amp-code"]::after { content: "\ebb3"; } /* lightning */
1168
+
1169
+ .theme-paper .lb-icon[data-icon="factory"]::after,
1170
+ .theme-feminine .lb-icon[data-icon="factory"]::after,
1171
+ .theme-feminine-dark .lb-icon[data-icon="factory"]::after { content: "\eaf4"; } /* factory */
1172
+
1173
+ .theme-paper .lb-icon[data-icon="kimi-code"]::after,
1174
+ .theme-feminine .lb-icon[data-icon="kimi-code"]::after,
1175
+ .theme-feminine-dark .lb-icon[data-icon="kimi-code"]::after { content: "\ec10"; } /* moon */
1176
+
1177
+ .theme-paper .lb-icon[data-icon="jetbrains-ai"]::after,
1178
+ .theme-feminine .lb-icon[data-icon="jetbrains-ai"]::after,
1179
+ .theme-feminine-dark .lb-icon[data-icon="jetbrains-ai"]::after { content: "\e9d1"; } /* brain */
1180
+
1181
+ .theme-paper .lb-icon[data-icon="zai"]::after,
1182
+ .theme-feminine .lb-icon[data-icon="zai"]::after,
1183
+ .theme-feminine-dark .lb-icon[data-icon="zai"]::after { content: "\ecd9"; } /* sparkle */
1184
+
1185
+ .theme-paper .lb-icon[data-icon="antigravity"]::after,
1186
+ .theme-feminine .lb-icon[data-icon="antigravity"]::after,
1187
+ .theme-feminine-dark .lb-icon[data-icon="antigravity"]::after { content: "\ec42"; } /* planet */
1188
+
1125
1189
  /* RSS Ticker overrides for light themes */
1126
1190
  .theme-paper .news-ticker-wrap {
1127
1191
  background: var(--bg-tertiary);
@@ -1,4 +1,4 @@
1
- /* LobsterBoard v0.4.0 - 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.0
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.0
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.0
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.0
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;
@@ -853,9 +956,9 @@ const WIDGETS = {
853
956
  allProviders = allProviders.filter(p => providerFilter.includes(p.provider));
854
957
  }
855
958
 
856
- // Hide unauthenticated providers if option is set
959
+ // Hide unauthenticated/errored providers if option is set
857
960
  if (hideUnauth) {
858
- allProviders = allProviders.filter(p => !p.error || !p.error.includes('Not logged in'));
961
+ allProviders = allProviders.filter(p => !p.error);
859
962
  }
860
963
 
861
964
  const validProviders = allProviders.filter(p => !p.error);
@@ -866,14 +969,23 @@ const WIDGETS = {
866
969
  const compact = ${props.compactMode || false};
867
970
  const showPlan = ${props.showPlan !== false};
868
971
 
972
+ // Map provider IDs to icon IDs for theming
973
+ const providerIconMap = {
974
+ claude: 'claude-code', codex: 'codex-cli', copilot: 'github-copilot',
975
+ cursor: 'cursor', gemini: 'gemini-cli', amp: 'amp-code', factory: 'factory',
976
+ kimi: 'kimi-code', jetbrains: 'jetbrains-ai', minimax: 'minimax', zai: 'zai',
977
+ antigravity: 'antigravity'
978
+ };
979
+
869
980
  for (const prov of allProviders) {
870
- const icon = _esc(prov.icon || '');
981
+ const iconId = providerIconMap[prov.provider] || 'ai-usage';
982
+ const iconEmoji = _esc(prov.icon || '⚪');
871
983
  const name = _esc(prov.name || prov.provider || 'Unknown');
872
984
 
873
985
  if (prov.error) {
874
986
  html += '<div style="padding:6px 0;border-bottom:1px solid var(--border,#30363d);">';
875
987
  html += '<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">';
876
- html += '<span style="font-size:16px;">' + icon + '</span>';
988
+ html += '<span class="lb-icon" data-icon="' + iconId + '" style="font-size:16px;">' + iconEmoji + '</span>';
877
989
  html += '<span style="font-weight:500;font-size:13px;">' + name + '</span>';
878
990
  html += '</div>';
879
991
  html += '<div style="color:#f85149;font-size:11px;padding-left:22px;">' + _esc(prov.error) + '</div>';
@@ -883,7 +995,7 @@ const WIDGETS = {
883
995
 
884
996
  html += '<div style="padding:6px 0;border-bottom:1px solid var(--border,#30363d);">';
885
997
  html += '<div style="display:flex;align-items:center;gap:6px;margin-bottom:' + (compact ? '2px' : '6px') + ';">';
886
- html += '<span style="font-size:16px;">' + icon + '</span>';
998
+ html += '<span class="lb-icon" data-icon="' + iconId + '" style="font-size:16px;">' + iconEmoji + '</span>';
887
999
  html += '<span style="font-weight:500;font-size:13px;">' + name + '</span>';
888
1000
  if (showPlan && prov.plan) {
889
1001
  html += '<span style="font-size:10px;color:var(--text-muted);background:var(--bg-secondary);padding:1px 6px;border-radius:4px;margin-left:auto;">' + _esc(prov.plan) + '</span>';
@@ -2097,13 +2209,13 @@ const WIDGETS = {
2097
2209
  name: 'CPU / Memory',
2098
2210
  icon: '💻',
2099
2211
  category: 'small',
2100
- description: 'Shows CPU and memory usage. Requires system stats API.',
2212
+ description: 'Shows CPU and memory usage. Supports remote servers via lobsterboard-agent.',
2101
2213
  defaultWidth: 200,
2102
2214
  defaultHeight: 120,
2103
2215
  hasApiKey: false,
2104
2216
  properties: {
2105
2217
  title: 'System',
2106
- endpoint: '/api/system',
2218
+ server: 'local',
2107
2219
  refreshInterval: 5
2108
2220
  },
2109
2221
  preview: `<div style="padding:8px;font-size:11px;">
@@ -2121,8 +2233,14 @@ const WIDGETS = {
2121
2233
  </div>
2122
2234
  </div>`,
2123
2235
  generateJs: (props) => `
2124
- // CPU/Memory Widget: ${props.id} — live via SSE
2125
- 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
+ }
2126
2244
  if (data.cpu) {
2127
2245
  document.getElementById('${props.id}-cpu').textContent = data.cpu.currentLoad.toFixed(0) + '%';
2128
2246
  }
@@ -2131,7 +2249,7 @@ const WIDGETS = {
2131
2249
  const total = (data.memory.total / (1024*1024*1024)).toFixed(1);
2132
2250
  document.getElementById('${props.id}-mem').textContent = used + ' / ' + total + ' GB';
2133
2251
  }
2134
- });
2252
+ }, ${(props.refreshInterval || 5) * 1000});
2135
2253
  `
2136
2254
  },
2137
2255
 
@@ -2139,14 +2257,14 @@ const WIDGETS = {
2139
2257
  name: 'Disk Usage',
2140
2258
  icon: '💾',
2141
2259
  category: 'small',
2142
- description: 'Shows disk space usage. Requires system stats API.',
2260
+ description: 'Shows disk space usage. Supports remote servers via lobsterboard-agent.',
2143
2261
  defaultWidth: 160,
2144
2262
  defaultHeight: 100,
2145
2263
  hasApiKey: false,
2146
2264
  properties: {
2147
2265
  title: 'Disk',
2266
+ server: 'local',
2148
2267
  path: '/',
2149
- endpoint: '/api/disk',
2150
2268
  refreshInterval: 60
2151
2269
  },
2152
2270
  preview: `<div style="text-align:center;padding:8px;">
@@ -2174,20 +2292,35 @@ const WIDGETS = {
2174
2292
  </div>
2175
2293
  </div>`,
2176
2294
  generateJs: (props) => `
2177
- // Disk Usage Widget: ${props.id} — live via SSE
2178
- onSystemStats(function(data) {
2179
- if (!data.disk || data.disk.length === 0) return;
2180
- // Find the configured mount or default to first/root
2181
- const targetMount = '${props.path || '/'}';
2182
- const d = data.disk.find(x => x.mount === targetMount) || data.disk[0];
2183
- 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;
2184
2317
  const circumference = 125.66;
2185
2318
  document.getElementById('${props.id}-ring').style.strokeDashoffset = circumference - (pct / 100) * circumference;
2186
2319
  document.getElementById('${props.id}-pct').textContent = Math.round(pct) + '%';
2187
- const usedGB = (d.used / (1024*1024*1024)).toFixed(1);
2188
- 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);
2189
2322
  document.getElementById('${props.id}-size').textContent = usedGB + ' / ' + totalGB + ' GB';
2190
- });
2323
+ }, ${(props.refreshInterval || 60) * 1000});
2191
2324
  `
2192
2325
  },
2193
2326
 
@@ -2195,34 +2328,44 @@ const WIDGETS = {
2195
2328
  name: 'Uptime Monitor',
2196
2329
  icon: '📡',
2197
2330
  category: 'large',
2198
- description: 'Shows service uptime. Requires uptime monitoring backend.',
2331
+ description: 'Shows system uptime, CPU, and memory. Supports remote servers via lobsterboard-agent.',
2199
2332
  defaultWidth: 350,
2200
2333
  defaultHeight: 220,
2201
2334
  hasApiKey: false,
2202
2335
  properties: {
2203
2336
  title: 'Uptime',
2204
- services: 'Website,API,Database',
2337
+ server: 'local',
2205
2338
  refreshInterval: 30
2206
2339
  },
2207
2340
  preview: `<div style="padding:4px;font-size:11px;">
2208
- <div>🟢 Website99.9%</div>
2209
- <div>🟢 API100%</div>
2210
- <div>🟡 Database98.2%</div>
2341
+ <div>🟢 System5d 12h</div>
2342
+ <div>🟢 CPU12.5%</div>
2343
+ <div>🟢 Memory45.2%</div>
2211
2344
  </div>`,
2212
2345
  generateHtml: (props) => `
2213
2346
  <div class="dash-card" id="widget-${props.id}" style="height:100%;">
2214
2347
  <div class="dash-card-head">
2215
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>` : ''}
2216
2350
  </div>
2217
2351
  <div class="dash-card-body" id="${props.id}-services">
2218
2352
  <div class="uptime-row" style="color:var(--text-muted);justify-content:center;">Loading...</div>
2219
2353
  </div>
2220
2354
  </div>`,
2221
2355
  generateJs: (props) => `
2222
- // Uptime Monitor Widget: ${props.id} — live via SSE
2223
- onSystemStats(function(data) {
2224
- 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) {
2225
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;
2226
2369
  const secs = data.uptime;
2227
2370
  const d = Math.floor(secs / 86400);
2228
2371
  const h = Math.floor((secs % 86400) / 3600);
@@ -2239,8 +2382,11 @@ const WIDGETS = {
2239
2382
  const memPct = ((data.memory.active / data.memory.total) * 100).toFixed(1);
2240
2383
  html += '<div class="uptime-row"><span>' + window.renderIcon('memory') + ' Memory</span><span class="uptime-pct">' + memPct + '%</span></div>';
2241
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
+ }
2242
2388
  container.innerHTML = html;
2243
- });
2389
+ }, ${(props.refreshInterval || 30) * 1000});
2244
2390
  `
2245
2391
  },
2246
2392
 
@@ -2248,13 +2394,13 @@ const WIDGETS = {
2248
2394
  name: 'Docker Containers',
2249
2395
  icon: '🐳',
2250
2396
  category: 'large',
2251
- description: 'Lists Docker containers with status. Requires Docker API proxy.',
2397
+ description: 'Lists Docker containers with status. Supports remote servers via lobsterboard-agent.',
2252
2398
  defaultWidth: 380,
2253
2399
  defaultHeight: 250,
2254
2400
  hasApiKey: false,
2255
2401
  properties: {
2256
2402
  title: 'Containers',
2257
- endpoint: '/api/docker',
2403
+ server: 'local',
2258
2404
  refreshInterval: 10
2259
2405
  },
2260
2406
  preview: `<div style="padding:4px;font-size:11px;">
@@ -2273,23 +2419,35 @@ const WIDGETS = {
2273
2419
  </div>
2274
2420
  </div>`,
2275
2421
  generateJs: (props) => `
2276
- // Docker Containers Widget: ${props.id} — live via SSE
2277
- onSystemStats(function(data) {
2422
+ // Docker Containers Widget: ${props.id} — ${props.server === 'local' ? 'local SSE' : 'remote: ' + props.server}
2423
+ onStats('${props.server || 'local'}', function(data) {
2278
2424
  const list = document.getElementById('${props.id}-list');
2279
2425
  const badge = document.getElementById('${props.id}-badge');
2280
- if (!data.docker || data.docker.length === 0) {
2281
- list.innerHTML = '<div class="docker-row" style="color:var(--text-muted);">No containers found</div>';
2282
- 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 = '—';
2283
2431
  return;
2284
2432
  }
2285
- 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;
2286
2443
  list.innerHTML = containers.map(function(c) {
2287
- const icon = c.state === 'running' ? '🟢' : '🔴';
2444
+ const running = c.state === 'running' || c.running === true;
2445
+ const icon = running ? '🟢' : '🔴';
2288
2446
  const name = (c.name || '').replace(/^\\//, '');
2289
2447
  return '<div class="docker-row">' + icon + ' ' + name + '<span class="docker-status">' + (c.state || c.status || '—') + '</span></div>';
2290
2448
  }).join('');
2291
- badge.textContent = containers.length;
2292
- });
2449
+ badge.textContent = data._remote && data.docker ? (data.docker.running || 0) + '/' + (data.docker.total || 0) : containers.length;
2450
+ }, ${(props.refreshInterval || 10) * 1000});
2293
2451
  `
2294
2452
  },
2295
2453
 
@@ -2297,18 +2455,18 @@ const WIDGETS = {
2297
2455
  name: 'Network Speed',
2298
2456
  icon: '🌐',
2299
2457
  category: 'small',
2300
- 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.',
2301
2459
  defaultWidth: 200,
2302
2460
  defaultHeight: 100,
2303
2461
  hasApiKey: false,
2304
2462
  properties: {
2305
2463
  title: 'Network',
2306
- endpoint: '/api/network',
2307
- refreshInterval: 2
2464
+ server: 'local',
2465
+ refreshInterval: 5
2308
2466
  },
2309
2467
  preview: `<div style="padding:8px;font-size:11px;">
2310
- <div>↓ <span style="color:#3fb950;">45 Mbps</span></div>
2311
- <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>
2312
2470
  </div>`,
2313
2471
  generateHtml: (props) => `
2314
2472
  <div class="dash-card" id="widget-${props.id}" style="height:100%;">
@@ -2321,26 +2479,38 @@ const WIDGETS = {
2321
2479
  </div>
2322
2480
  </div>`,
2323
2481
  generateJs: (props) => `
2324
- // Network Speed Widget: ${props.id} — live via SSE
2482
+ // Network Speed Widget: ${props.id} — ${props.server === 'local' ? 'local SSE' : 'remote: ' + props.server}
2325
2483
  function _fmtRate(bytes) {
2326
2484
  if (bytes == null || bytes < 0) return '0 B/s';
2327
2485
  if (bytes < 1024) return bytes.toFixed(0) + ' B/s';
2328
2486
  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB/s';
2329
2487
  return (bytes / (1024 * 1024)).toFixed(1) + ' MB/s';
2330
2488
  }
2331
- 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
+
2332
2497
  if (!data.network || data.network.length === 0) return;
2333
- // Sum all interfaces or pick the first non-loopback
2498
+ // Handle both local (array) and remote (object) formats
2334
2499
  let rx = 0, tx = 0;
2335
- data.network.forEach(function(n) {
2336
- if (n.iface !== 'lo' && n.iface !== 'lo0') {
2337
- rx += (n.rx_sec || 0);
2338
- tx += (n.tx_sec || 0);
2339
- }
2340
- });
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
+ }
2341
2511
  document.getElementById('${props.id}-down').textContent = _fmtRate(rx);
2342
2512
  document.getElementById('${props.id}-up').textContent = _fmtRate(tx);
2343
- });
2513
+ }, ${(props.refreshInterval || 5) * 1000});
2344
2514
  `
2345
2515
  },
2346
2516
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lobsterboard",
3
- "version": "0.4.0",
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) => {