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 }