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 +62 -9
- package/app.html +38 -0
- package/css/themes.css +64 -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 +227 -57
- package/package.json +1 -1
- package/server.cjs +135 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 🦞 LobsterBoard
|
|
2
2
|
|
|
3
|
-
A self-hosted, drag-and-drop dashboard builder with
|
|
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
|
-
- **
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
|
128
|
-
|
|
129
|
-
|
|
|
130
|
-
|
|
|
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);
|
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;
|
|
@@ -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
|
|
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
|
|
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;">' +
|
|
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;">' +
|
|
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.
|
|
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
|
-
|
|
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} —
|
|
2125
|
-
|
|
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.
|
|
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} —
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
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
|
|
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
|
-
|
|
2337
|
+
server: 'local',
|
|
2205
2338
|
refreshInterval: 30
|
|
2206
2339
|
},
|
|
2207
2340
|
preview: `<div style="padding:4px;font-size:11px;">
|
|
2208
|
-
<div>🟢
|
|
2209
|
-
<div>🟢
|
|
2210
|
-
<div
|
|
2341
|
+
<div>🟢 System — 5d 12h</div>
|
|
2342
|
+
<div>🟢 CPU — 12.5%</div>
|
|
2343
|
+
<div>🟢 Memory — 45.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} —
|
|
2223
|
-
|
|
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.
|
|
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
|
-
|
|
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} —
|
|
2277
|
-
|
|
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
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
2307
|
-
refreshInterval:
|
|
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
|
|
2311
|
-
<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>
|
|
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} —
|
|
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
|
-
|
|
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
|
-
//
|
|
2498
|
+
// Handle both local (array) and remote (object) formats
|
|
2334
2499
|
let rx = 0, tx = 0;
|
|
2335
|
-
data.network
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
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
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) => {
|