feat(server): add real-time lease display to admin panel
- Add convertLeaseEntriesToRows function to process lease data from RelayServer into displayable rows for the admin page - Integrate lease rows into the admin page data structure and rendering - Introduce /api/leases endpoint to serve lease information as JSON for dynamic updates - Enhance HTML template with CSS for refresh button and JavaScript for auto-refreshing lease data every 5 seconds This improves the admin interface by providing live monitoring of active leases, including connection status, TTL, and peer details, enabling better visibility into server operations.
lemon-mint committed
Oct 28, 2025 at 12:40 UTC
8342ad99f3bdd6d0491fe9334c815ec42f9915e2
2 files changed
+216
cmd/server/view.go
+185
@@ -3,8 +3,10 @@ package main
3
import (
4
"context"
5
"encoding/json"
6
+ "fmt"
7
"html/template"
8
"net/http"
9
+ "time"
10
11
"github.com/gorilla/websocket"
12
"github.com/rs/zerolog/log"
@@ -30,6 +32,80 @@ type adminPageData struct {
32
Rows []leaseRow
33
}
34
35
+// convertLeaseEntriesToRows converts LeaseEntry data from LeaseManager to leaseRow format for the admin page
36
+func convertLeaseEntriesToRows(serv *relaydns.RelayServer) []leaseRow {
37
+ // Get all lease entries directly from the lease manager
38
+ leaseEntries := serv.GetAllLeaseEntries()
39
+
40
+ var rows []leaseRow
41
+ now := time.Now()
42
+
43
+ for _, leaseEntry := range leaseEntries {
44
+ // Check if lease is still valid
45
+ if now.After(leaseEntry.Expires) {
46
+ continue
47
+ }
48
+
49
+ lease := leaseEntry.Lease
50
+ identityID := string(lease.Identity.Id)
51
+
52
+ // Calculate TTL
53
+ ttl := time.Until(leaseEntry.Expires)
54
+ ttlStr := ""
55
+ if ttl > 0 {
56
+ if ttl > time.Hour {
57
+ ttlStr = fmt.Sprintf("%.0fh", ttl.Hours())
58
+ } else if ttl > time.Minute {
59
+ ttlStr = fmt.Sprintf("%.0fm", ttl.Minutes())
60
+ } else {
61
+ ttlStr = fmt.Sprintf("%.0fs", ttl.Seconds())
62
+ }
63
+ }
64
+
65
+ // Format last seen time
66
+ lastSeenStr := leaseEntry.LastSeen.Format("2006-01-02 15:04:05")
67
+
68
+ // Check if connection is still active by checking if the connection ID exists in the connections map
69
+ connected := serv.IsConnectionActive(leaseEntry.ConnectionID)
70
+
71
+ // Use name from lease if available
72
+ name := lease.Name
73
+ if name == "" {
74
+ name = "(unnamed)"
75
+ }
76
+
77
+ // Determine kind/type based on ALPN if available
78
+ kind := "client"
79
+ if len(lease.Alpn) > 0 {
80
+ kind = lease.Alpn[0]
81
+ }
82
+
83
+ // Create DNS label from identity (first 8 chars for display)
84
+ dnsLabel := identityID
85
+ if len(dnsLabel) > 8 {
86
+ dnsLabel = dnsLabel[:8] + "..."
87
+ }
88
+
89
+ // Create link for the lease
90
+ link := fmt.Sprintf("/peer/%s", identityID)
91
+
92
+ row := leaseRow{
93
+ Peer: identityID,
94
+ Name: name,
95
+ Kind: kind,
96
+ Connected: connected,
97
+ DNS: dnsLabel,
98
+ LastSeen: lastSeenStr,
99
+ TTL: ttlStr,
100
+ Link: link,
101
+ }
102
+
103
+ rows = append(rows, row)
104
+ }
105
+
106
+ return rows
107
+}
108
+
109
var wsUpgrader = websocket.Upgrader{
110
ReadBufferSize: 1024,
111
WriteBufferSize: 1024,
@@ -74,9 +150,13 @@ func serveHTTP(ctx context.Context, addr string, serv *relaydns.RelayServer, nod
150
return
151
}
152
153
+ // Convert lease entries to rows for the admin page
154
+ rows := convertLeaseEntriesToRows(serv)
155
+
156
data := adminPageData{
157
NodeID: nodeID,
158
Bootstraps: bootstraps,
159
+ Rows: rows,
160
}
161
162
w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -86,6 +166,23 @@ func serveHTTP(ctx context.Context, addr string, serv *relaydns.RelayServer, nod
166
}
167
})
168
169
+ mux.HandleFunc("/api/leases", func(w http.ResponseWriter, r *http.Request) {
170
+ if r.Method != http.MethodGet {
171
+ w.Header().Set("Allow", http.MethodGet)
172
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
173
+ return
174
+ }
175
+
176
+ // Convert lease entries to rows
177
+ rows := convertLeaseEntriesToRows(serv)
178
+
179
+ w.Header().Set("Content-Type", "application/json")
180
+ if err := json.NewEncoder(w).Encode(rows); err != nil {
181
+ log.Error().Err(err).Msg("[server] failed to encode lease data")
182
+ http.Error(w, "internal server error", http.StatusInternalServerError)
183
+ }
184
+ })
185
+
186
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
187
type info struct {
188
Status string `json:"status"`
@@ -140,7 +237,95 @@ var serverTmpl = template.Must(template.New("admin-index").Parse(`<!doctype html
237
.pill.bad .dot { background:var(--bad) }
238
.head { display:flex; align-items:center; justify-content:space-between; gap:12px }
239
.btn { display:inline-block; background:var(--primary); color:#fff; text-decoration:none; border-radius:10px; padding:10px 14px; font-weight:800; margin-top:8px }
240
+ .refresh-btn { background:var(--muted); font-size:12px; padding:6px 10px; margin-left:8px }
241
</style>
242
+ <script>
243
+ // Auto-refresh lease data every 5 seconds
244
+ async function refreshLeases() {
245
+ try {
246
+ const response = await fetch('/api/leases');
247
+ if (!response.ok) throw new Error('Failed to fetch leases');
248
+
249
+ const leases = await response.json();
250
+ updateLeaseDisplay(leases);
251
+ } catch (error) {
252
+ console.error('Error refreshing leases:', error);
253
+ }
254
+ }
255
+
256
+ function updateLeaseDisplay(leases) {
257
+ const main = document.querySelector('main');
258
+
259
+ // Find the existing sections
260
+ const serverSection = main.querySelector('.section');
261
+ const existingLeaseSections = main.querySelectorAll('.section[id^="peer-"]');
262
+ const noClientsSection = main.querySelector('.section:not([id])');
263
+
264
+ // Remove existing lease sections and "no clients" section
265
+ existingLeaseSections.forEach(section => section.remove());
266
+ if (noClientsSection && noClientsSection.textContent.includes('No clients discovered')) {
267
+ noClientsSection.remove();
268
+ }
269
+
270
+ // Add lease sections
271
+ if (leases.length === 0) {
272
+ const noClientsSection = document.createElement('section');
273
+ noClientsSection.className = 'section';
274
+ noClientsSection.innerHTML = '<div class="title">No clients discovered</div><div class="muted">Start a client and ensure bootstrap URLs point at this server\'s /relay WebSocket endpoint.</div>';
275
+ main.appendChild(noClientsSection);
276
+ } else {
277
+ leases.forEach(lease => {
278
+ const section = document.createElement('section');
279
+ section.className = 'section';
280
+ section.id = 'peer-' + lease.Peer;
281
+ section.setAttribute('data-peer', lease.Peer);
282
+ section.setAttribute('data-name', lease.Name);
283
+
284
+ const connectedClass = lease.Connected ? 'ok' : 'bad';
285
+ const connectedText = lease.Connected ? 'Connected' : 'Disconnected';
286
+ const displayName = lease.Name || '(unnamed)';
287
+
288
+ let html = '<div class="head"><div class="title">' + displayName + '</div><div><span class="muted" style="margin-right:8px">' + lease.Kind + '</span><span class="pill ' + connectedClass + '"><span class="dot"></span>' + connectedText + '</span></div></div>';
289
+ if (lease.DNS) {
290
+ html += '<div class="muted">DNS Label: <span class="mono">' + lease.DNS + '</span></div>';
291
+ }
292
+ html += '<div class="muted">Lease Identity</div><div class="mono">' + lease.Peer + '</div><div class="muted" style="margin-top:6px">Last seen: ' + lease.LastSeen;
293
+ if (lease.TTL) {
294
+ html += ' - TTL: ' + lease.TTL;
295
+ }
296
+ html += '</div><a class="btn" href="' + lease.Link + '">Open</a>';
297
+ section.innerHTML = html;
298
+
299
+ main.appendChild(section);
300
+ });
301
+ }
302
+
303
+ // Update active clients count
304
+ const activeCountElement = serverSection.querySelector('.muted');
305
+ if (activeCountElement && activeCountElement.textContent.includes('Active clients:')) {
306
+ activeCountElement.textContent = 'Active clients: ' + leases.length;
307
+ }
308
+ }
309
+
310
+ // Start auto-refresh when page loads
311
+ document.addEventListener('DOMContentLoaded', () => {
312
+ // Initial refresh
313
+ refreshLeases();
314
+
315
+ // Set up interval for auto-refresh
316
+ setInterval(refreshLeases, 5000);
317
+
318
+ // Add manual refresh button
319
+ const title = document.querySelector('.title');
320
+ if (title && title.textContent === 'Server') {
321
+ const refreshBtn = document.createElement('button');
322
+ refreshBtn.className = 'btn refresh-btn';
323
+ refreshBtn.textContent = 'Refresh';
324
+ refreshBtn.onclick = refreshLeases;
325
+ title.parentNode.appendChild(refreshBtn);
326
+ }
327
+ });
328
+ </script>
329
</head>
330
<body>
331
<div class="wrap">
relaydns/relay.go
+31
@@ -193,6 +193,37 @@ func (g *RelayServer) relayInfo() *rdverb.RelayInfo {
193
}
194
}
195
196
+// GetLeaseManager returns the lease manager instance
197
+func (g *RelayServer) GetLeaseManager() *LeaseManager {
198
+ return g.leaseManager
199
+}
200
+
201
+// IsConnectionActive checks if a connection with the given ID is still active
202
+func (g *RelayServer) IsConnectionActive(connectionID int64) bool {
203
+ g.connectionsLock.RLock()
204
+ defer g.connectionsLock.RUnlock()
205
+
206
+ _, exists := g.connections[connectionID]
207
+ return exists
208
+}
209
+
210
+// GetAllLeaseEntries returns all lease entries from the lease manager
211
+func (g *RelayServer) GetAllLeaseEntries() []*LeaseEntry {
212
+ g.leaseManager.leasesLock.RLock()
213
+ defer g.leaseManager.leasesLock.RUnlock()
214
+
215
+ var entries []*LeaseEntry
216
+ now := time.Now()
217
+
218
+ for _, entry := range g.leaseManager.leases {
219
+ if now.Before(entry.Expires) {
220
+ entries = append(entries, entry)
221
+ }
222
+ }
223
+
224
+ return entries
225
+}
226
+
227
func (g *RelayServer) Start() {
228
g.leaseManager.Start()
229
}