atproto user agency toolkit for individuals and groups
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add server-rendered admin HTML dashboard

Self-contained HTML page at /xrpc/org.p2pds.admin.dashboard that
fetches existing admin JSON APIs and renders system overview,
replication table, network, policies, and verification sections
with 30s auto-refresh. No auth required on the dashboard route;
token from config is embedded for client-side API calls.

+299 -1
+4
CLAUDE.md
··· 29 29 1. Single-user PDS working as local node service 30 30 2. Record replication with local storage 31 31 3. IPFS integration for replicated records 32 + 33 + ## Tool Usage Rules 34 + 35 + - **Never use Bash for file operations.** Use the dedicated tools: Read (not cat/head/tail/sed), Edit (not sed/awk), Write (not echo/cat heredoc), Glob (not find/ls), Grep (not grep/rg). Bash commands for file operations will be blocked by permission prompts when the user is away.
+3
src/index.ts
··· 563 563 app.get("/xrpc/org.p2pds.admin.getPolicies", requireAuth, (c) => 564 564 admin.getPolicies(c, replicationManager), 565 565 ); 566 + app.get("/xrpc/org.p2pds.admin.dashboard", (c) => 567 + admin.getDashboard(c, networkService, replicationManager), 568 + ); 566 569 567 570 // ============================================ 568 571 // MST Proof serving
+41
src/xrpc/admin.test.ts
··· 534 534 expect(ps.policies[0]!.id).toBe("mutual-aid"); 535 535 }); 536 536 }); 537 + 538 + // ============================================ 539 + // getDashboard 540 + // ============================================ 541 + 542 + describe("Admin: getDashboard", () => { 543 + let tmpDir: string; 544 + let db: InstanceType<typeof Database>; 545 + 546 + beforeEach(() => { 547 + tmpDir = mkdtempSync(join(tmpdir(), "admin-dashboard-test-")); 548 + db = new Database(join(tmpDir, "test.db")); 549 + }); 550 + 551 + afterEach(() => { 552 + db.close(); 553 + try { rmSync(tmpDir, { recursive: true, force: true }); } catch {} 554 + }); 555 + 556 + it("returns HTML dashboard with expected structure", async () => { 557 + const config = testConfig(tmpDir); 558 + const repoManager = new RepoManager(db, config); 559 + repoManager.init(); 560 + const firehose = new Firehose(repoManager); 561 + const app = createApp(config, repoManager, firehose); 562 + 563 + const res = await noAuthGet(app, "/xrpc/org.p2pds.admin.dashboard"); 564 + expect(res.status).toBe(200); 565 + expect(res.headers.get("content-type")).toContain("text/html"); 566 + 567 + const html = await res.text(); 568 + expect(html).toContain("P2PDS Admin"); 569 + expect(html).toContain('id="section-overview"'); 570 + expect(html).toContain('id="section-replication"'); 571 + expect(html).toContain('id="section-network"'); 572 + expect(html).toContain('id="section-policies"'); 573 + expect(html).toContain('id="section-verification"'); 574 + expect(html).toContain("<script>"); 575 + expect(html).toContain("test-auth-token"); 576 + }); 577 + });
+251 -1
src/xrpc/admin.ts
··· 1 1 import type { Context } from "hono"; 2 - import type { AuthedAppEnv } from "../types.js"; 2 + import type { AppEnv, AuthedAppEnv } from "../types.js"; 3 3 import type { ReplicationManager } from "../replication/replication-manager.js"; 4 4 import type { NetworkService } from "../ipfs.js"; 5 5 ··· 120 120 multiaddrs: networkService.getMultiaddrs(), 121 121 connections: networkService.getConnectionCount(), 122 122 }); 123 + } 124 + 125 + export function getDashboard( 126 + c: Context<AppEnv>, 127 + networkService: NetworkService | undefined, 128 + replicationManager: ReplicationManager | undefined, 129 + ): Response { 130 + const authHeader = c.req.header("Authorization") ?? ""; 131 + const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : c.env.AUTH_TOKEN; 132 + 133 + const html = `<!DOCTYPE html> 134 + <html lang="en"> 135 + <head> 136 + <meta charset="utf-8"> 137 + <meta name="viewport" content="width=device-width, initial-scale=1"> 138 + <title>P2PDS Admin</title> 139 + <style> 140 + * { margin: 0; padding: 0; box-sizing: border-box; } 141 + body { 142 + font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace; 143 + background: #f0f0f0; 144 + color: #000; 145 + padding: 1.5rem; 146 + font-size: 14px; 147 + } 148 + header { 149 + display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; 150 + margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 2px solid #000; 151 + } 152 + header h1 { font-size: 1.4rem; letter-spacing: 0.1em; } 153 + .badge { font-size: 0.7rem; background: #000; color: #fff; padding: 2px 8px; border-radius: 3px; } 154 + .meta { margin-left: auto; font-size: 0.75rem; color: #666; display: flex; gap: 1rem; align-items: center; } 155 + .meta label { cursor: pointer; } 156 + .card { 157 + background: #fff; border-radius: 6px; padding: 1.2rem; margin-bottom: 1rem; 158 + box-shadow: 0 1px 3px rgba(0,0,0,0.08); 159 + } 160 + .card h2 { font-size: 1rem; margin-bottom: 0.8rem; border-bottom: 1px solid #eee; padding-bottom: 0.4rem; } 161 + .kv { display: grid; grid-template-columns: 160px 1fr; gap: 0.3rem 1rem; font-size: 0.85rem; } 162 + .kv dt { color: #666; } 163 + .kv dd { word-break: break-all; } 164 + table { width: 100%; border-collapse: collapse; font-size: 0.82rem; } 165 + th { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 2px solid #eee; color: #666; font-weight: 600; } 166 + td { padding: 0.4rem 0.6rem; border-bottom: 1px solid #f0f0f0; } 167 + tr.clickable { cursor: pointer; } 168 + tr.clickable:hover { background: #f8f8f8; } 169 + .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; vertical-align: middle; } 170 + .dot-synced { background: #22c55e; } 171 + .dot-syncing { background: #eab308; } 172 + .dot-pending { background: #9ca3af; } 173 + .dot-error { background: #ef4444; } 174 + .detail-row td { padding: 0.8rem; background: #fafafa; font-size: 0.8rem; } 175 + .detail-row pre { white-space: pre-wrap; word-break: break-all; max-height: 300px; overflow: auto; } 176 + .policy-list { list-style: none; font-size: 0.85rem; } 177 + .policy-list li { padding: 0.4rem 0; border-bottom: 1px solid #f0f0f0; } 178 + .verify-pass { color: #22c55e; font-weight: 600; } 179 + .verify-fail { color: #ef4444; font-weight: 600; } 180 + .loading { color: #999; font-style: italic; } 181 + </style> 182 + </head> 183 + <body> 184 + <header> 185 + <h1>P2PDS Admin</h1> 186 + <span class="badge" id="version-badge">v-</span> 187 + <div class="meta"> 188 + <span id="last-refresh">-</span> 189 + <label><input type="checkbox" id="auto-refresh" checked> auto-refresh</label> 190 + </div> 191 + </header> 192 + 193 + <section class="card" id="section-overview"> 194 + <h2>System Overview</h2> 195 + <div id="overview-content" class="loading">Loading...</div> 196 + </section> 197 + 198 + <section class="card" id="section-replication"> 199 + <h2>Replication</h2> 200 + <div id="replication-content" class="loading">Loading...</div> 201 + </section> 202 + 203 + <section class="card" id="section-network"> 204 + <h2>Network</h2> 205 + <div id="network-content" class="loading">Loading...</div> 206 + </section> 207 + 208 + <section class="card" id="section-policies"> 209 + <h2>Policies</h2> 210 + <div id="policies-content" class="loading">Loading...</div> 211 + </section> 212 + 213 + <section class="card" id="section-verification"> 214 + <h2>Verification</h2> 215 + <div id="verification-content" class="loading">Loading...</div> 216 + </section> 217 + 218 + <script> 219 + const TOKEN = ${JSON.stringify(token)}; 220 + const HEADERS = { "Authorization": "Bearer " + TOKEN }; 221 + 222 + function esc(s) { const d = document.createElement("div"); d.textContent = String(s ?? "-"); return d.innerHTML; } 223 + 224 + async function apiFetch(endpoint, params) { 225 + const url = new URL("/xrpc/" + endpoint, location.origin); 226 + if (params) Object.entries(params).forEach(([k,v]) => url.searchParams.set(k, v)); 227 + const res = await fetch(url, { headers: HEADERS }); 228 + return res.json(); 229 + } 230 + 231 + function statusDot(status) { 232 + const cls = status === "synced" ? "dot-synced" 233 + : status === "syncing" ? "dot-syncing" 234 + : status === "error" ? "dot-error" 235 + : "dot-pending"; 236 + return '<span class="dot ' + cls + '"></span>' + esc(status); 237 + } 238 + 239 + function renderOverview(data) { 240 + const el = document.getElementById("overview-content"); 241 + const net = data.network || {}; 242 + const fh = data.firehose || {}; 243 + el.innerHTML = '<dl class="kv">' 244 + + "<dt>DID</dt><dd>" + esc(data.did) + "</dd>" 245 + + "<dt>Version</dt><dd>" + esc(data.version) + "</dd>" 246 + + "<dt>Peer ID</dt><dd>" + esc(net.peerId) + "</dd>" 247 + + "<dt>Connections</dt><dd>" + esc(net.connections ?? 0) + "</dd>" 248 + + "<dt>Multiaddrs</dt><dd>" + esc((net.multiaddrs || []).length) + "</dd>" 249 + + "<dt>Firehose</dt><dd>" + (data.firehose ? esc(fh.url || "connected") : "disabled") + "</dd>" 250 + + "</dl>"; 251 + document.getElementById("version-badge").textContent = "v" + data.version; 252 + } 253 + 254 + function renderReplication(data) { 255 + const el = document.getElementById("replication-content"); 256 + const repl = data.replication; 257 + if (!repl || !repl.enabled) { el.innerHTML = "Replication disabled"; return; } 258 + const states = repl.syncStates || []; 259 + if (states.length === 0) { el.innerHTML = "No tracked DIDs"; return; } 260 + let html = "<table><thead><tr><th>DID</th><th>Status</th><th>Rev</th><th>Root CID</th><th>Last Sync</th><th>Error</th></tr></thead><tbody>"; 261 + for (const s of states) { 262 + const st = s.status || "pending"; 263 + const rid = "detail-" + s.did.replace(/[^a-zA-Z0-9]/g, "_"); 264 + html += '<tr class="clickable" data-did="' + esc(s.did) + '" data-rid="' + rid + '">' 265 + + "<td>" + esc(s.did) + "</td>" 266 + + "<td>" + statusDot(st) + "</td>" 267 + + "<td>" + esc(s.lastSyncRev) + "</td>" 268 + + "<td>" + esc(s.rootCid ? s.rootCid.substring(0, 16) + "..." : "-") + "</td>" 269 + + "<td>" + esc(s.lastSyncAt) + "</td>" 270 + + "<td>" + esc(s.lastError || "-") + "</td>" 271 + + "</tr>"; 272 + html += '<tr class="detail-row" id="' + rid + '" style="display:none"><td colspan="6"><pre class="loading">Click to load...</pre></td></tr>'; 273 + } 274 + html += "</tbody></table>"; 275 + el.innerHTML = html; 276 + 277 + el.querySelectorAll("tr.clickable").forEach(function(row) { 278 + row.addEventListener("click", async function() { 279 + const did = this.dataset.did; 280 + const detailRow = document.getElementById(this.dataset.rid); 281 + if (detailRow.style.display === "none") { 282 + detailRow.style.display = ""; 283 + const pre = detailRow.querySelector("pre"); 284 + pre.textContent = "Loading..."; 285 + try { 286 + const detail = await apiFetch("org.p2pds.admin.getDidStatus", { did: did }); 287 + pre.textContent = JSON.stringify(detail, null, 2); 288 + } catch (e) { pre.textContent = "Error: " + e.message; } 289 + } else { 290 + detailRow.style.display = "none"; 291 + } 292 + }); 293 + }); 294 + } 295 + 296 + function renderNetwork(data) { 297 + const el = document.getElementById("network-content"); 298 + el.innerHTML = '<dl class="kv">' 299 + + "<dt>Peer ID</dt><dd>" + esc(data.peerId) + "</dd>" 300 + + "<dt>Connections</dt><dd>" + esc(data.connections) + "</dd>" 301 + + "<dt>Multiaddrs</dt><dd>" + (data.multiaddrs || []).map(function(m) { return esc(m); }).join("<br>") + "</dd>" 302 + + "</dl>"; 303 + } 304 + 305 + function renderPolicies(data) { 306 + const el = document.getElementById("policies-content"); 307 + if (!data.enabled) { el.innerHTML = "Policy engine disabled"; return; } 308 + const ps = data.policySet; 309 + let html = '<dl class="kv"><dt>Enabled</dt><dd>Yes</dd>' 310 + + "<dt>Explicit DIDs</dt><dd>" + esc((data.explicitDids || []).join(", ") || "none") + "</dd></dl>"; 311 + if (ps && ps.policies && ps.policies.length > 0) { 312 + html += '<ul class="policy-list">'; 313 + for (const p of ps.policies) { 314 + html += "<li><strong>" + esc(p.name || p.id) + "</strong>" 315 + + " &mdash; target: " + esc(p.target.type) 316 + + ", priority: " + esc(p.priority) 317 + + ", enabled: " + esc(p.enabled) + "</li>"; 318 + } 319 + html += "</ul>"; 320 + } 321 + el.innerHTML = html; 322 + } 323 + 324 + function renderVerification(data) { 325 + const el = document.getElementById("verification-content"); 326 + const results = (data.verification && data.verification.results) || []; 327 + if (results.length === 0) { el.innerHTML = "No verification results"; return; } 328 + let html = "<table><thead><tr><th>DID</th><th>L0 Commit</th><th>L1 Sampling</th><th>Last Check</th></tr></thead><tbody>"; 329 + for (const r of results) { 330 + const l0 = r.layers && r.layers.l0; 331 + const l1 = r.layers && r.layers.l1; 332 + html += "<tr>" 333 + + "<td>" + esc(r.did) + "</td>" 334 + + "<td>" + (l0 ? (l0.pass ? '<span class="verify-pass">PASS</span>' : '<span class="verify-fail">FAIL</span>') : "-") + "</td>" 335 + + "<td>" + (l1 ? (l1.pass ? '<span class="verify-pass">PASS</span>' : '<span class="verify-fail">FAIL</span>') : "-") + "</td>" 336 + + "<td>" + esc(r.lastCheck) + "</td>" 337 + + "</tr>"; 338 + } 339 + html += "</tbody></table>"; 340 + el.innerHTML = html; 341 + } 342 + 343 + async function refresh() { 344 + try { 345 + const [overview, network, policies] = await Promise.all([ 346 + apiFetch("org.p2pds.admin.getOverview"), 347 + apiFetch("org.p2pds.admin.getNetworkStatus"), 348 + apiFetch("org.p2pds.admin.getPolicies"), 349 + ]); 350 + renderOverview(overview); 351 + renderReplication(overview); 352 + renderNetwork(network); 353 + renderPolicies(policies); 354 + renderVerification(overview); 355 + document.getElementById("last-refresh").textContent = "Updated: " + new Date().toLocaleTimeString(); 356 + } catch (e) { 357 + console.error("Dashboard refresh error:", e); 358 + } 359 + } 360 + 361 + let intervalId = setInterval(refresh, 30000); 362 + document.getElementById("auto-refresh").addEventListener("change", function() { 363 + if (this.checked) { intervalId = setInterval(refresh, 30000); refresh(); } 364 + else { clearInterval(intervalId); } 365 + }); 366 + 367 + refresh(); 368 + </script> 369 + </body> 370 + </html>`; 371 + 372 + return c.html(html); 123 373 } 124 374 125 375 export function getPolicies(