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 Lit UI, SSE live sync progress, and minimal libp2p config

- Lit web component UI (esbuild-bundled): account, system, replications,
sync history, network, policies, verification, incoming offers cards
- SSE endpoint (org.p2pds.app.syncProgress) streams real-time sync events
from ReplicationManager to the browser via EventSource
- Progress events emitted at key sync milestones: start, car-received,
blocks-stored, verified, blob-progress, complete, error, cycle boundaries
- UI merges live progress into replication rows (block/blob counters tick
up during sync, full refresh on completion)
- Layout: Account card top-left, System card (with network info) top-right,
removed separate Network section
- Improved add-account form: same-height row, inline x clear button,
renamed policy options (reciprocal/consensual/non-consensual archive)
- Reduced libp2p footprint: removed kadDHT, 2 bootstrap peers, max 10
connections (kept autoNAT for dialability checks)

+2679 -1358
+69
package-lock.json
··· 29 29 "@atproto/oauth-client-node": "^0.3.16", 30 30 "@atproto/repo": "^0.8.12", 31 31 "@hono/node-server": "^1.13.8", 32 + "@libp2p/autonat": "^3.0.10", 33 + "@libp2p/bootstrap": "^12.0.11", 32 34 "@libp2p/gossipsub": "^15.0.12", 35 + "@libp2p/kad-dht": "^16.1.3", 36 + "@libp2p/ping": "^3.0.10", 37 + "@preact/signals-core": "^1.13.0", 33 38 "bcryptjs": "^3.0.3", 34 39 "better-sqlite3": "^11.8.1", 35 40 "blockstore-fs": "^3.0.2", ··· 37 42 "helia": "^6.0.20", 38 43 "hono": "^4.11.3", 39 44 "jose": "^6.1.3", 45 + "lit": "^3.3.2", 40 46 "picocolors": "^1.1.1", 41 47 "ws": "^8.18.3" 42 48 }, ··· 44 50 "@types/bcryptjs": "^3.0.0", 45 51 "@types/better-sqlite3": "^7.6.12", 46 52 "@types/ws": "^8.18.1", 53 + "esbuild": "^0.27.3", 47 54 "tsx": "^4.21.0", 48 55 "typescript": "^5.9.3", 49 56 "vitest": "^3.0.0" ··· 3346 3353 "multiformats": "^13.0.0" 3347 3354 } 3348 3355 }, 3356 + "node_modules/@lit-labs/ssr-dom-shim": { 3357 + "version": "1.5.1", 3358 + "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.5.1.tgz", 3359 + "integrity": "sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==", 3360 + "license": "BSD-3-Clause" 3361 + }, 3362 + "node_modules/@lit/reactive-element": { 3363 + "version": "2.1.2", 3364 + "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.2.tgz", 3365 + "integrity": "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==", 3366 + "license": "BSD-3-Clause", 3367 + "dependencies": { 3368 + "@lit-labs/ssr-dom-shim": "^1.5.0" 3369 + } 3370 + }, 3349 3371 "node_modules/@multiformats/dns": { 3350 3372 "version": "1.0.13", 3351 3373 "resolved": "https://registry.npmjs.org/@multiformats/dns/-/dns-1.0.13.tgz", ··· 3694 3716 }, 3695 3717 "engines": { 3696 3718 "node": ">=20.0.0" 3719 + } 3720 + }, 3721 + "node_modules/@preact/signals-core": { 3722 + "version": "1.13.0", 3723 + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.13.0.tgz", 3724 + "integrity": "sha512-slT6XeTCAbdql61GVLlGU4x7XHI7kCZV5Um5uhE4zLX4ApgiiXc0UYFvVOKq06xcovzp7p+61l68oPi563ARKg==", 3725 + "license": "MIT", 3726 + "funding": { 3727 + "type": "opencollective", 3728 + "url": "https://opencollective.com/preact" 3697 3729 } 3698 3730 }, 3699 3731 "node_modules/@react-native/assets-registry": { ··· 4701 4733 "license": "MIT", 4702 4734 "peer": true 4703 4735 }, 4736 + "node_modules/@types/trusted-types": { 4737 + "version": "2.0.7", 4738 + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", 4739 + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", 4740 + "license": "MIT" 4741 + }, 4704 4742 "node_modules/@types/ws": { 4705 4743 "version": "8.18.1", 4706 4744 "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", ··· 7770 7808 "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", 7771 7809 "license": "MIT", 7772 7810 "peer": true 7811 + }, 7812 + "node_modules/lit": { 7813 + "version": "3.3.2", 7814 + "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.2.tgz", 7815 + "integrity": "sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==", 7816 + "license": "BSD-3-Clause", 7817 + "dependencies": { 7818 + "@lit/reactive-element": "^2.1.0", 7819 + "lit-element": "^4.2.0", 7820 + "lit-html": "^3.3.0" 7821 + } 7822 + }, 7823 + "node_modules/lit-element": { 7824 + "version": "4.2.2", 7825 + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.2.tgz", 7826 + "integrity": "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==", 7827 + "license": "BSD-3-Clause", 7828 + "dependencies": { 7829 + "@lit-labs/ssr-dom-shim": "^1.5.0", 7830 + "@lit/reactive-element": "^2.1.0", 7831 + "lit-html": "^3.3.0" 7832 + } 7833 + }, 7834 + "node_modules/lit-html": { 7835 + "version": "3.3.2", 7836 + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.2.tgz", 7837 + "integrity": "sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw==", 7838 + "license": "BSD-3-Clause", 7839 + "dependencies": { 7840 + "@types/trusted-types": "^2.0.2" 7841 + } 7773 7842 }, 7774 7843 "node_modules/locate-path": { 7775 7844 "version": "5.0.0",
+9 -1
package.json
··· 6 6 "main": "dist/server.js", 7 7 "scripts": { 8 8 "dev": "tsx watch src/server.ts", 9 - "build": "tsc", 9 + "build": "tsc && node dist/build-ui.js", 10 + "build:ui": "node dist/build-ui.js", 10 11 "start": "node dist/server.js", 11 12 "typecheck": "tsc --noEmit", 12 13 "test": "vitest run", ··· 43 44 "@atproto/oauth-client-node": "^0.3.16", 44 45 "@atproto/repo": "^0.8.12", 45 46 "@hono/node-server": "^1.13.8", 47 + "@libp2p/autonat": "^3.0.10", 48 + "@libp2p/bootstrap": "^12.0.11", 46 49 "@libp2p/gossipsub": "^15.0.12", 50 + "@libp2p/kad-dht": "^16.1.3", 51 + "@libp2p/ping": "^3.0.10", 52 + "@preact/signals-core": "^1.13.0", 47 53 "bcryptjs": "^3.0.3", 48 54 "better-sqlite3": "^11.8.1", 49 55 "blockstore-fs": "^3.0.2", ··· 51 57 "helia": "^6.0.20", 52 58 "hono": "^4.11.3", 53 59 "jose": "^6.1.3", 60 + "lit": "^3.3.2", 54 61 "picocolors": "^1.1.1", 55 62 "ws": "^8.18.3" 56 63 }, ··· 58 65 "@types/bcryptjs": "^3.0.0", 59 66 "@types/better-sqlite3": "^7.6.12", 60 67 "@types/ws": "^8.18.1", 68 + "esbuild": "^0.27.3", 61 69 "tsx": "^4.21.0", 62 70 "typescript": "^5.9.3", 63 71 "vitest": "^3.0.0"
+4 -4
scripts/check-api.sh
··· 13 13 14 14 # Get auth token from the page 15 15 HTML=$(curl -s "$BASE/") 16 - TOKEN=$(echo "$HTML" | grep -o 'const TOKEN = "[^"]*"' | sed 's/const TOKEN = "//;s/"//') 16 + TOKEN=$(echo "$HTML" | grep -o '__P2PDS_TOKEN__="[^"]*"' | sed 's/__P2PDS_TOKEN__="//;s/"//') 17 17 18 18 echo "=== Server ===" 19 19 echo " URL: $BASE" ··· 43 43 44 44 echo "" 45 45 echo "=== Page HTML markers ===" 46 - echo " Has TOKEN: $(echo "$HTML" | grep -c 'const TOKEN')" 47 - echo " Has 'Connect an account': $(echo "$HTML" | grep -c 'Connect an account')" 48 - echo " Has refreshAccount: $(echo "$HTML" | grep -c 'refreshAccount')" 46 + echo " Has TOKEN: $(echo "$HTML" | grep -c '__P2PDS_TOKEN__')" 47 + echo " Has p2p-app: $(echo "$HTML" | grep -c '<p2p-app>')" 48 + echo " Has bundle: $(echo "$HTML" | grep -c 'app.js')" 49 49 echo " Cache-Control: $(curl -sI "$BASE/" | grep -i cache-control || echo 'none')"
+19
src/build-ui.ts
··· 1 + import { build } from "esbuild"; 2 + import { join, dirname } from "node:path"; 3 + import { fileURLToPath } from "node:url"; 4 + 5 + const __dirname = dirname(fileURLToPath(import.meta.url)); 6 + // __dirname is dist/ after tsc, root is one level up 7 + const root = join(__dirname, ".."); 8 + 9 + await build({ 10 + entryPoints: [join(root, "src", "ui", "app.ts")], 11 + bundle: true, 12 + format: "esm", 13 + outfile: join(root, "dist", "ui", "app.js"), 14 + minify: true, 15 + sourcemap: true, 16 + target: "es2022", 17 + }); 18 + 19 + console.log("UI bundle built: dist/ui/app.js");
+31 -1
src/index.ts
··· 282 282 app_routes.getApp(c, networkService, replicationManager), 283 283 ); 284 284 285 + // Static files (UI bundle) 286 + app.get("/static/ui/:file", async (c) => { 287 + const file = c.req.param("file"); 288 + if (!file || file.includes("..")) { 289 + return c.json({ error: "NotFound" }, 404); 290 + } 291 + const { readFile } = await import("node:fs/promises"); 292 + const { join } = await import("node:path"); 293 + const filePath = join(process.cwd(), "dist", "ui", file); 294 + try { 295 + const content = await readFile(filePath); 296 + const ext = file.split(".").pop(); 297 + const contentType = ext === "js" ? "application/javascript" 298 + : ext === "css" ? "text/css" 299 + : ext === "map" ? "application/json" 300 + : "application/octet-stream"; 301 + return new Response(content, { 302 + headers: { 303 + "Content-Type": contentType, 304 + "Cache-Control": "no-cache", 305 + }, 306 + }); 307 + } catch { 308 + return c.json({ error: "NotFound" }, 404); 309 + } 310 + }); 311 + 285 312 // ============================================ 286 313 // Sync endpoints (federation) 287 314 // ============================================ ··· 688 715 app_routes.getDidStatus(c, replicationManager), 689 716 ); 690 717 app.get("/xrpc/org.p2pds.app.getNetworkStatus", requireAuth, (c) => 691 - app_routes.getNetworkStatus(c, networkService), 718 + app_routes.getNetworkStatus(c, networkService, ipfsService, config.PUBLIC_URL), 692 719 ); 693 720 app.get("/xrpc/org.p2pds.app.getPolicies", requireAuth, (c) => 694 721 app_routes.getPolicies(c, replicationManager), ··· 725 752 ); 726 753 app.post("/xrpc/org.p2pds.app.setMyConsent", requireAuth, (c) => 727 754 app_routes.setMyConsent(c, pdsClientRef), 755 + ); 756 + app.get("/xrpc/org.p2pds.app.syncProgress", (c) => 757 + app_routes.streamSyncProgress(c, replicationManager), 728 758 ); 729 759 730 760 // ============================================
+98 -6
src/ipfs.ts
··· 132 132 const { noise } = await import("@chainsafe/libp2p-noise"); 133 133 const { yamux } = await import("@chainsafe/libp2p-yamux"); 134 134 const { identify } = await import("@libp2p/identify"); 135 + const { autoNAT } = await import("@libp2p/autonat"); 136 + // kadDHT removed — not needed for minimal networking 137 + const { bootstrap } = await import("@libp2p/bootstrap"); 138 + const { ping } = await import("@libp2p/ping"); 135 139 const datastore = new SqliteDatastore(this.config.db); 136 140 137 - // Minimal libp2p config: only what we need for direct peer connections. 138 - // No DHT, gossipsub, relay, autoNAT, UPnP, WebRTC — those peg CPU 139 - // connecting to random peers. We dial p2pds peers directly. 141 + // libp2p config: direct peer connections + lightweight diagnostics. 142 + // - Amino DHT in client mode: connects to public IPFS bootstrap peers 143 + // for routing but doesn't serve DHT queries. Lightweight. 144 + // - AutoNAT: asks connected peers to dial us back, confirming reachability. 145 + // - Bootstrap: connects to standard IPFS bootstrap peers on startup. 140 146 // Cast SQLite stores to Helia's expected interfaces. 141 147 // Our implementations are duck-type compatible but use Promise 142 148 // returns instead of AwaitGenerator, which Helia handles fine at runtime. ··· 148 154 streamMuxers: [yamux()], 149 155 services: { 150 156 identify: identify(), 157 + ping: ping(), 158 + autoNAT: autoNAT(), 151 159 }, 160 + peerDiscovery: [ 161 + bootstrap({ 162 + list: [ 163 + "/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN", 164 + "/dnsaddr/bootstrap.libp2p.io/p2p/QmQCU2EcMqAqQPR2i9bChDtGNJchTbq5TbXJJ16u19uLTa", 165 + ], 166 + }), 167 + ], 152 168 connectionManager: { 153 - maxConnections: 20, 154 - maxIncomingPendingConnections: 5, 155 - inboundConnectionThreshold: 3, 169 + maxConnections: 10, 170 + maxIncomingPendingConnections: 3, 171 + inboundConnectionThreshold: 2, 156 172 }, 157 173 }, 158 174 blockstore: this.blockstore as any, ··· 517 533 } 518 534 } 519 535 536 + /** 537 + * Get dialability diagnostics: listening addrs, public addrs, NAT status, 538 + * and HTTP reachability (if PUBLIC_URL is configured). 539 + */ 540 + async getDialability(publicUrl?: string): Promise<{ 541 + listeningAddrs: string[]; 542 + publicAddrs: string[]; 543 + natStatus: "unknown" | "public" | "private"; 544 + httpReachable: boolean | null; 545 + }> { 546 + const allAddrs = this.getMultiaddrs(); 547 + const publicAddrs = allAddrs.filter((a) => !isPrivateMultiaddr(a)); 548 + 549 + // Determine NAT status from multiaddr analysis 550 + // getMultiaddrs() includes addresses confirmed by identify/autoNAT 551 + let natStatus: "unknown" | "public" | "private" = "unknown"; 552 + if (this.helia) { 553 + // If we have public addresses in our advertised multiaddrs, 554 + // that indicates we're publicly reachable 555 + if (publicAddrs.length > 0) { 556 + natStatus = "public"; 557 + } else if (allAddrs.length > 0) { 558 + natStatus = "private"; 559 + } 560 + } 561 + 562 + // Check HTTP reachability 563 + let httpReachable: boolean | null = null; 564 + if (publicUrl) { 565 + try { 566 + const res = await fetch(publicUrl + "/health", { 567 + signal: AbortSignal.timeout(5000), 568 + }); 569 + httpReachable = res.ok; 570 + } catch { 571 + httpReachable = false; 572 + } 573 + } 574 + 575 + return { listeningAddrs: allAddrs, publicAddrs, natStatus, httpReachable }; 576 + } 577 + 520 578 isRunning(): boolean { 521 579 return this.running; 522 580 } ··· 540 598 await this.helia.libp2p.dial(ma(multiaddr)); 541 599 } 542 600 } 601 + 602 + /** 603 + * Check if a multiaddr string contains a private/loopback IP address. 604 + * Filters: 127.x.x.x, 10.x.x.x, 192.168.x.x, 172.16-31.x.x, ::1, fe80:: 605 + */ 606 + function isPrivateMultiaddr(addr: string): boolean { 607 + // Extract the IP from the multiaddr (e.g. /ip4/10.0.0.1/tcp/4001 → 10.0.0.1) 608 + const ip4Match = addr.match(/\/ip4\/([\d.]+)/); 609 + if (ip4Match) { 610 + const ip = ip4Match[1]!; 611 + if (ip.startsWith("127.")) return true; 612 + if (ip.startsWith("10.")) return true; 613 + if (ip.startsWith("192.168.")) return true; 614 + // 172.16.0.0 - 172.31.255.255 615 + const parts = ip.split("."); 616 + if (parts[0] === "172") { 617 + const second = parseInt(parts[1]!, 10); 618 + if (second >= 16 && second <= 31) return true; 619 + } 620 + return false; 621 + } 622 + 623 + const ip6Match = addr.match(/\/ip6\/([a-fA-F0-9:]+)/); 624 + if (ip6Match) { 625 + const ip = ip6Match[1]!.toLowerCase(); 626 + if (ip === "::1") return true; 627 + if (ip.startsWith("fe80")) return true; 628 + if (ip === "::") return true; 629 + return false; 630 + } 631 + 632 + // dnsaddr and other non-IP addrs are considered public 633 + return false; 634 + }
+1 -1
src/oauth/routes.ts
··· 337 337 /** 338 338 * Publish org.p2pds.peer/self record to the user's PDS. 339 339 */ 340 - async function publishPeerRecord( 340 + export async function publishPeerRecord( 341 341 pdsClient: PdsClient, 342 342 networkService?: NetworkService, 343 343 publicUrl?: string,
+125 -1
src/replication/replication-manager.ts
··· 43 43 import { fetchRepoFromPeer } from "./libp2p-sync.js"; 44 44 import type { Libp2p } from "@libp2p/interface"; 45 45 46 + /** Progress event emitted during sync operations for live UI updates. */ 47 + export interface SyncProgressEvent { 48 + type: string; 49 + did: string; 50 + sourceType?: string; 51 + carBytes?: number; 52 + blocksStored?: number; 53 + blocksSizeKb?: number; 54 + blobsFetched?: number; 55 + blobsTotal?: number; 56 + blobBytes?: number; 57 + durationMs?: number; 58 + error?: string; 59 + missingBlocks?: number; 60 + } 61 + 46 62 /** How old cached peer info can be before re-fetching (1 hour). */ 47 63 const PEER_INFO_TTL_MS = 60 * 60 * 1000; 48 64 ··· 77 93 private notificationCleanupTimer: ReturnType<typeof setInterval> | null = null; 78 94 /** libp2p node for peer-first repo sync. Set via setLibp2p(). */ 79 95 private libp2p: Libp2p | null = null; 96 + /** Cached multiaddrs from last peer record publish, for change detection. */ 97 + private lastPublishedAddrs: Set<string> = new Set(); 98 + /** Callback to republish peer record when multiaddrs change. */ 99 + private publishPeerRecordFn?: () => Promise<void>; 100 + /** Sync progress event subscribers for live UI updates via SSE. */ 101 + private progressCallbacks: Set<(event: SyncProgressEvent) => void> = new Set(); 80 102 81 103 constructor( 82 104 db: Database.Database, ··· 160 182 } 161 183 162 184 /** 185 + * Set a callback to republish the peer record when multiaddrs change. 186 + * Called from start.ts after IPFS and replication are ready. 187 + */ 188 + setPublishPeerRecordFn(fn: () => Promise<void>): void { 189 + this.publishPeerRecordFn = fn; 190 + // Seed the cache with current addrs so the first check doesn't spuriously trigger 191 + this.lastPublishedAddrs = new Set(this.networkService.getMultiaddrs()); 192 + } 193 + 194 + /** Subscribe to sync progress events (for SSE streaming). */ 195 + onSyncProgress(cb: (event: SyncProgressEvent) => void): void { 196 + this.progressCallbacks.add(cb); 197 + } 198 + 199 + /** Unsubscribe from sync progress events. */ 200 + offSyncProgress(cb: (event: SyncProgressEvent) => void): void { 201 + this.progressCallbacks.delete(cb); 202 + } 203 + 204 + /** Emit a progress event to all subscribers. */ 205 + private emitProgress(event: SyncProgressEvent): void { 206 + for (const cb of this.progressCallbacks) { 207 + try { cb(event); } catch { /* non-fatal */ } 208 + } 209 + } 210 + 211 + /** 212 + * Check if multiaddrs have changed since last peer record publish. 213 + * If changed, republish the peer record and update the cache. 214 + */ 215 + private async _checkMultiaddrsChanged(): Promise<void> { 216 + if (!this.publishPeerRecordFn) return; 217 + 218 + const current = new Set(this.networkService.getMultiaddrs()); 219 + if (current.size === 0) return; 220 + 221 + // Compare sets 222 + if ( 223 + current.size === this.lastPublishedAddrs.size && 224 + [...current].every((a) => this.lastPublishedAddrs.has(a)) 225 + ) { 226 + return; 227 + } 228 + 229 + this.lastPublishedAddrs = current; 230 + try { 231 + await this.publishPeerRecordFn(); 232 + console.log("[replication] Multiaddrs changed, republished peer record"); 233 + } catch (err) { 234 + console.warn( 235 + "[replication] Failed to republish peer record:", 236 + err instanceof Error ? err.message : String(err), 237 + ); 238 + } 239 + } 240 + 241 + /** 163 242 * Initialize replication: create tables, sync manifests. 164 243 */ 165 244 async init(): Promise<void> { ··· 842 921 ? this.sortDidsByPriority(dids) 843 922 : dids; 844 923 924 + this.emitProgress({ type: "sync-cycle:start", did: "*" }); 925 + 845 926 for (const did of sortedDids) { 846 927 if (this.stopped) break; 847 928 ··· 876 957 877 958 // Re-run offer discovery to pick up new/revoked offers 878 959 await this.runOfferDiscovery(); 960 + 961 + // Check if multiaddrs changed and republish peer record if needed 962 + await this._checkMultiaddrsChanged(); 963 + 964 + this.emitProgress({ type: "sync-cycle:complete", did: "*" }); 879 965 } 880 966 881 967 /** ··· 1039 1125 } 1040 1126 1041 1127 // Start sync event after source is determined 1128 + this.emitProgress({ type: "sync:start", did, sourceType }); 1042 1129 const syncEventId = this.syncStorage.startSyncEvent(did, sourceType, trigger); 1043 1130 1044 1131 try { 1045 1132 // 4. Parse CAR and store blocks 1046 1133 const carSizeKb = (carBytes.length / 1024).toFixed(1); 1047 1134 console.log(`[sync] ${did} — CAR received: ${carSizeKb} KB, parsing...`); 1135 + this.emitProgress({ type: "sync:car-received", did, carBytes: carBytes.length }); 1048 1136 const { root, blocks } = await readCarWithRoot(carBytes); 1049 1137 1050 1138 await this.blockStore.putBlocks(blocks); ··· 1069 1157 this.syncStorage.trackBlocksWithSize(did, blockEntries); 1070 1158 } 1071 1159 1160 + const totalBlockSizeKb = blockEntries.reduce((s, b) => s + b.sizeBytes, 0) / 1024; 1161 + this.emitProgress({ type: "sync:blocks-stored", did, blocksStored: blockEntries.length, blocksSizeKb: Math.round(totalBlockSizeKb) }); 1162 + 1072 1163 // 6. Announce to DHT (fire-and-forget) 1073 1164 this.networkService.provideBlocks(cidStrs).catch(() => {}); 1074 1165 ··· 1081 1172 ); 1082 1173 } 1083 1174 this.syncStorage.updateVerifiedAt(did); 1175 + this.emitProgress({ type: "sync:verified", did, missingBlocks: verification.missing.length }); 1084 1176 1085 1177 // 8. Extract actual rev from the commit block 1086 1178 const rootCidStr = root.toString(); ··· 1222 1314 rootCid: rootCidStr, 1223 1315 incremental: !!since, 1224 1316 }); 1317 + this.emitProgress({ 1318 + type: "sync:complete", 1319 + did, 1320 + blocksStored: blockEntries.length, 1321 + blobsFetched: blobResult.fetched, 1322 + blobBytes: blobResult.totalBytes, 1323 + durationMs: Date.now() - syncStart, 1324 + }); 1225 1325 } catch (err) { 1226 1326 this.syncStorage.completeSyncEvent(syncEventId, { 1227 1327 status: "error", 1228 1328 errorMessage: err instanceof Error ? err.message : String(err), 1229 1329 durationMs: Date.now() - syncStart, 1230 1330 incremental: !!since, 1331 + }); 1332 + this.emitProgress({ 1333 + type: "sync:error", 1334 + did, 1335 + error: err instanceof Error ? err.message : String(err), 1231 1336 }); 1232 1337 throw err; 1233 1338 } ··· 1384 1489 } 1385 1490 }, tickMs); 1386 1491 1387 - // Start verification on a separate timer 1492 + // Run verification once on startup, then on a timer 1493 + this.runVerification().catch((err) => { 1494 + console.error("Initial verification error:", err); 1495 + }); 1388 1496 this.verificationTimer = setInterval(() => { 1389 1497 if (!this.stopped) { 1390 1498 this.runVerification().catch((err) => { ··· 1600 1708 this.syncStorage.trackBlocksWithSize(did, blockEntries); 1601 1709 } 1602 1710 1711 + const fhBlockSizeKb = blockEntries.reduce((s, b) => s + b.sizeBytes, 0) / 1024; 1712 + this.emitProgress({ type: "sync:blocks-stored", did, blocksStored: blockEntries.length, blocksSizeKb: Math.round(fhBlockSizeKb) }); 1713 + 1603 1714 // 5. Announce to DHT (fire-and-forget) 1604 1715 this.networkService.provideBlocks(cidStrs).catch(() => {}); 1605 1716 ··· 1704 1815 rootCid: rootCidStr, 1705 1816 incremental: true, 1706 1817 }); 1818 + this.emitProgress({ 1819 + type: "sync:complete", 1820 + did, 1821 + blocksStored: blockEntries.length, 1822 + durationMs: Date.now() - syncStart, 1823 + }); 1707 1824 } catch (err) { 1708 1825 this.syncStorage.completeSyncEvent(syncEventId, { 1709 1826 status: "error", 1710 1827 errorMessage: err instanceof Error ? err.message : String(err), 1711 1828 durationMs: Date.now() - syncStart, 1712 1829 incremental: true, 1830 + }); 1831 + this.emitProgress({ 1832 + type: "sync:error", 1833 + did, 1834 + error: err instanceof Error ? err.message : String(err), 1713 1835 }); 1714 1836 throw err; 1715 1837 } ··· 1995 2117 let skipped = 0; 1996 2118 let errors = 0; 1997 2119 let totalBytes = 0; 2120 + const total = allBlobCids.size; 1998 2121 1999 2122 for (const blobCid of allBlobCids) { 2000 2123 if (this.syncStorage.hasBlobCid(did, blobCid)) { ··· 2024 2147 this.networkService.provideBlocks([blobCid]).catch(() => {}); 2025 2148 fetched++; 2026 2149 totalBytes += bytes.length; 2150 + this.emitProgress({ type: "sync:blob-progress", did, blobsFetched: fetched, blobsTotal: total, blobBytes: totalBytes }); 2027 2151 } catch (err) { 2028 2152 console.warn( 2029 2153 `[replication] Failed to fetch blob ${blobCid} for ${did}:`,
+19 -45
src/replication/replication.test.ts
··· 861 861 try { rmSync(tmpDir, { recursive: true, force: true }); } catch {} 862 862 }); 863 863 864 - it("Layer 0: passes when commit root is served via RASL", async () => { 864 + it("Layer 0: passes when local root matches remote getHead", async () => { 865 865 const bytes = new TextEncoder().encode("root-block"); 866 866 const cidStr = await makeCidStr(bytes); 867 867 await ipfsService.putBlock(cidStr, bytes); 868 868 869 869 const mockFetch = async (url: string | URL | Request) => { 870 870 const urlStr = typeof url === "string" ? url : url.toString(); 871 - if (urlStr.includes(cidStr)) { 872 - return new Response(bytes, { status: 200 }); 871 + if (urlStr.includes("getHead")) { 872 + return new Response(JSON.stringify({ root: cidStr }), { 873 + status: 200, 874 + headers: { "Content-Type": "application/json" }, 875 + }); 873 876 } 874 877 return new Response(null, { status: 404 }); 875 878 }; ··· 983 986 expect(layer1!.available).toBe(5); 984 987 }); 985 988 986 - it("Layer 1: fails when some blocks return 404", async () => { 989 + it("Layer 1: fails when some blocks missing locally", async () => { 987 990 const presentBytes = new TextEncoder().encode("present-block"); 988 991 const presentCid = await makeCidStr(presentBytes); 989 992 await ipfsService.putBlock(presentCid, presentBytes); 990 993 991 994 const missingBytes = new TextEncoder().encode("missing-block"); 992 995 const missingCid = await makeCidStr(missingBytes); 993 - await ipfsService.putBlock(missingCid, missingBytes); 996 + // Don't store missingCid — it's tracked but not in blockstore 994 997 995 - const mockFetch = async (url: string | URL | Request) => { 996 - const urlStr = typeof url === "string" ? url : url.toString(); 997 - if (urlStr.includes(presentCid)) { 998 - return new Response(presentBytes, { status: 200 }); 999 - } 1000 - return new Response(null, { status: 404 }); 1001 - }; 998 + const mockFetch = async () => new Response(null, { status: 404 }); 1002 999 1003 1000 const verifier = new RemoteVerifier( 1004 1001 ipfsService, ··· 1019 1016 expect(layer1!.missing).toContain(missingCid); 1020 1017 }); 1021 1018 1022 - it("Layer 1: detects content mismatch", async () => { 1023 - const bytes = new TextEncoder().encode("real-content"); 1024 - const cidStr = await makeCidStr(bytes); 1025 - await ipfsService.putBlock(cidStr, bytes); 1026 - 1027 - const wrongBytes = new TextEncoder().encode("tampered-content"); 1028 - 1029 - const mockFetch = async () => { 1030 - return new Response(wrongBytes, { status: 200 }); 1031 - }; 1032 - 1033 - const verifier = new RemoteVerifier( 1034 - ipfsService, 1035 - { raslSampleSize: 100 }, 1036 - mockFetch as unknown as typeof fetch, 1037 - ); 1038 - 1039 - const result = await verifier.verifyPeer( 1040 - "did:plc:test", 1041 - "https://pds.example.com", 1042 - null, 1043 - [cidStr], 1044 - ); 1045 - 1046 - const layer1 = result.layers.find((l) => l.layer === 1); 1047 - expect(layer1!.passed).toBe(false); 1048 - expect(layer1!.missing).toContain(cidStr); 1049 - }); 1050 - 1051 1019 it("combined: overallPassed is true when all layers pass", async () => { 1052 1020 const bytes = new TextEncoder().encode("all-pass"); 1053 1021 const cidStr = await makeCidStr(bytes); ··· 1055 1023 1056 1024 const mockFetch = async (url: string | URL | Request) => { 1057 1025 const urlStr = typeof url === "string" ? url : url.toString(); 1058 - if (urlStr.includes(cidStr)) { 1059 - return new Response(bytes, { status: 200 }); 1026 + if (urlStr.includes("getHead")) { 1027 + return new Response(JSON.stringify({ root: cidStr }), { 1028 + status: 200, 1029 + headers: { "Content-Type": "application/json" }, 1030 + }); 1060 1031 } 1061 1032 return new Response(null, { status: 404 }); 1062 1033 }; ··· 1104 1075 expect(result.overallPassed).toBe(false); 1105 1076 }); 1106 1077 1107 - it("skips Layer 0 when rootCid is null", async () => { 1078 + it("Layer 0: fails gracefully when rootCid is null", async () => { 1108 1079 const mockFetch = async () => { 1109 1080 return new Response(null, { status: 404 }); 1110 1081 }; ··· 1122 1093 [], 1123 1094 ); 1124 1095 1125 - expect(result.layers.find((l) => l.layer === 0)).toBeUndefined(); 1096 + const layer0 = result.layers.find((l) => l.layer === 0); 1097 + expect(layer0).toBeDefined(); 1098 + expect(layer0!.passed).toBe(false); 1099 + expect(layer0!.error).toContain("no local root CID"); 1126 1100 }); 1127 1101 1128 1102 it("skips Layer 1 when blockCids is empty", async () => {
+52 -49
src/replication/verification.ts
··· 81 81 } 82 82 83 83 /** 84 - * Layered remote verification. 84 + * Layered verification. 85 85 * 86 - * Verifies that a remote PDS is actually hosting blocks it claims to serve. 87 - * Uses content-addressed retrieval (RASL endpoint) so responses are unforgeable. 88 - * 89 - * Layer 0: Commit root — fetch the root CID via RASL to confirm the remote serves it. 90 - * Layer 1: RASL sampling — fetch random blocks via HTTP, compare with local copy. 91 - * Layer 2: Bitswap (future) — fetch blocks via IPFS network. 92 - * Layer 3: MST path proof (future) — verify MST path proofs via sync.getRecord. 86 + * Layer 0: Commit root — verify local root matches source PDS head via standard atproto API. 87 + * Layer 1: Local block sampling — verify we actually have the blocks we claim to store. 88 + * Layer 2: Block-sample challenge — p2pds peer-to-peer challenge-response. 89 + * Layer 3: MST path proof challenge — p2pds peer-to-peer challenge-response. 93 90 */ 94 91 export class RemoteVerifier { 95 92 private config: VerificationConfig; ··· 110 107 } 111 108 112 109 /** 113 - * Run all verification layers against a remote peer. 110 + * Run all verification layers for a replicated DID. 114 111 */ 115 112 async verifyPeer( 116 113 did: string, ··· 121 118 ): Promise<LayeredVerificationResult> { 122 119 const layers: LayerResult[] = []; 123 120 124 - // Layer 0: Commit root fetch 125 - if (rootCid) { 126 - layers.push(await this.verifyCommitRoot(pdsEndpoint, rootCid)); 127 - } 121 + // Layer 0: Commit root — verify local root matches source PDS head 122 + layers.push(await this.verifyCommitRoot(did, pdsEndpoint, rootCid)); 128 123 129 - // Layer 1: RASL sampling 124 + // Layer 1: Local block availability sampling 130 125 if (blockCids.length > 0) { 131 - layers.push(await this.verifyViaRasl(pdsEndpoint, blockCids)); 126 + layers.push(await this.verifyLocalBlocks(blockCids)); 132 127 } 133 128 134 129 // Layer 2: Block-sample challenge (or stub if no transport) ··· 333 328 } 334 329 335 330 /** 336 - * Layer 0: Fetch the commit root CID via RASL. 337 - * A 200 with correct bytes proves the remote serves the current repo head. 331 + * Layer 0: Verify local commit root matches the source PDS head. 332 + * Uses standard atproto com.atproto.sync.getHead to get the current rev. 338 333 */ 339 334 private async verifyCommitRoot( 335 + did: string, 340 336 pdsEndpoint: string, 341 - rootCid: string, 337 + localRootCid: string | null, 342 338 ): Promise<LayerResult> { 343 339 const start = Date.now(); 340 + if (!localRootCid) { 341 + return { 342 + layer: 0, 343 + name: "commit-root", 344 + passed: false, 345 + checked: 1, 346 + available: 0, 347 + missing: [], 348 + error: "no local root CID available", 349 + durationMs: Date.now() - start, 350 + }; 351 + } 344 352 try { 345 - const url = `${pdsEndpoint}/.well-known/rasl/${rootCid}`; 353 + const url = `${pdsEndpoint}/xrpc/com.atproto.sync.getHead?did=${encodeURIComponent(did)}`; 346 354 const res = await this.fetchFn(url); 347 - const passed = res.status === 200; 355 + if (res.status !== 200) { 356 + return { 357 + layer: 0, 358 + name: "commit-root", 359 + passed: false, 360 + checked: 1, 361 + available: 0, 362 + missing: [localRootCid], 363 + error: `getHead returned ${res.status}`, 364 + durationMs: Date.now() - start, 365 + }; 366 + } 367 + const data = await res.json() as { root: string }; 368 + const passed = data.root === localRootCid; 348 369 return { 349 370 layer: 0, 350 371 name: "commit-root", 351 372 passed, 352 373 checked: 1, 353 374 available: passed ? 1 : 0, 354 - missing: passed ? [] : [rootCid], 375 + missing: passed ? [] : [localRootCid], 376 + error: passed ? undefined : `local root ${localRootCid} != remote ${data.root}`, 355 377 durationMs: Date.now() - start, 356 378 }; 357 379 } catch (err) { ··· 361 383 passed: false, 362 384 checked: 1, 363 385 available: 0, 364 - missing: [rootCid], 386 + missing: [localRootCid], 365 387 error: err instanceof Error ? err.message : String(err), 366 388 durationMs: Date.now() - start, 367 389 }; ··· 369 391 } 370 392 371 393 /** 372 - * Layer 1: Fetch a random sample of blocks via RASL and verify against local copies. 373 - * Content-addressed retrieval is unforgeable: correct bytes = peer has the data. 394 + * Layer 1: Verify a random sample of tracked blocks exist in our local blockstore. 395 + * Confirms we actually hold the data we claim to replicate. 374 396 */ 375 - private async verifyViaRasl( 376 - pdsEndpoint: string, 397 + private async verifyLocalBlocks( 377 398 cids: string[], 378 399 ): Promise<LayerResult> { 379 400 const start = Date.now(); ··· 387 408 let available = 0; 388 409 389 410 for (const cid of sample) { 390 - try { 391 - const url = `${pdsEndpoint}/.well-known/rasl/${cid}`; 392 - const res = await this.fetchFn(url); 393 - if (res.status === 200) { 394 - const remoteBytes = new Uint8Array(await res.arrayBuffer()); 395 - const localBytes = await this.blockStore.getBlock(cid); 396 - if ( 397 - localBytes && 398 - Buffer.from(remoteBytes).equals(Buffer.from(localBytes)) 399 - ) { 400 - available++; 401 - } else if (!localBytes) { 402 - // We don't have it locally to compare, but remote served it 403 - available++; 404 - } else { 405 - // Content mismatch — serious integrity issue 406 - missing.push(cid); 407 - } 408 - } else { 409 - missing.push(cid); 410 - } 411 - } catch { 411 + const has = await this.blockStore.hasBlock(cid); 412 + if (has) { 413 + available++; 414 + } else { 412 415 missing.push(cid); 413 416 } 414 417 } 415 418 416 419 return { 417 420 layer: 1, 418 - name: "rasl-sampling", 421 + name: "local-blocks", 419 422 passed: missing.length === 0, 420 423 checked: sample.length, 421 424 available,
+8 -1
src/start.ts
··· 36 36 import { RateLimiter } from "./rate-limiter.js"; 37 37 import { createOAuthClient, type OAuthClientManager } from "./oauth/client.js"; 38 38 import { PdsClient } from "./oauth/pds-client.js"; 39 - import type { PdsClientRef } from "./oauth/routes.js"; 39 + import { type PdsClientRef, publishPeerRecord } from "./oauth/routes.js"; 40 40 import { registerRepoSyncProtocol } from "./replication/libp2p-sync.js"; 41 41 42 42 export interface StartServerOpts { ··· 277 277 replicationManager.startChallengeScheduler(challengeTransport); 278 278 console.log(pc.dim(` Challenges: scheduler started (${libp2pNode ? "libp2p+HTTP failover" : "HTTP"} transport)`)); 279 279 } 280 + 281 + // Set up auto-republish of peer record when multiaddrs change 282 + replicationManager.setPublishPeerRecordFn(async () => { 283 + if (pdsClientRef.current && ipfsService) { 284 + await publishPeerRecord(pdsClientRef.current, ipfsService, config.PUBLIC_URL); 285 + } 286 + }); 280 287 } catch (err) { 281 288 console.error(pc.red(` Replication startup failed:`), err); 282 289 }
+22
src/ui/app.ts
··· 1 + // UI entry point — injects global styles and registers all components 2 + import { themeCSS } from "./styles/theme"; 3 + 4 + // Inject global styles 5 + const style = document.createElement("style"); 6 + style.textContent = themeCSS; 7 + document.head.appendChild(style); 8 + 9 + // Register all components (side-effect imports) 10 + import "./components/app-shell"; 11 + import "./components/app-header"; 12 + import "./components/system-card"; 13 + import "./components/account-card"; 14 + import "./components/account-search"; 15 + import "./components/incoming-offers"; 16 + import "./components/replication-summary"; 17 + import "./components/replications-card"; 18 + import "./components/sync-history"; 19 + import "./components/network-card"; 20 + import "./components/policies-card"; 21 + import "./components/verification-card"; 22 + import "./components/confirm-dialog";
+149
src/ui/components/account-card.ts
··· 1 + import { LitElement, html } from "lit"; 2 + import { customElement, property, state } from "lit/decorators.js"; 3 + import { apiFetch, apiPost } from "../state/api.js"; 4 + import type { ConfirmDialog } from "./confirm-dialog.js"; 5 + import "./account-search.js"; 6 + 7 + @customElement("p2p-account-card") 8 + export class AccountCard extends LitElement { 9 + createRenderRoot() { 10 + return this; 11 + } 12 + 13 + @property({ attribute: false }) authStatus: any = null; 14 + 15 + @state() private _consentEnabled: boolean | null = null; 16 + @state() private _consentLoading = false; 17 + @state() private _connectHandle: string | null = null; 18 + 19 + connectedCallback() { 20 + super.connectedCallback(); 21 + if (this.authStatus?.authenticated) { 22 + this._loadConsent(); 23 + } 24 + } 25 + 26 + updated(changed: Map<string, unknown>) { 27 + if (changed.has("authStatus") && this.authStatus?.authenticated) { 28 + this._loadConsent(); 29 + } 30 + } 31 + 32 + private async _loadConsent() { 33 + try { 34 + const data = await apiFetch("org.p2pds.app.getMyConsent"); 35 + this._consentEnabled = data.consented ?? false; 36 + } catch { 37 + // ignore 38 + } 39 + } 40 + 41 + private async _toggleConsent(e: Event) { 42 + const enabled = (e.target as HTMLInputElement).checked; 43 + this._consentLoading = true; 44 + try { 45 + await apiPost("org.p2pds.app.setMyConsent", { enabled }); 46 + this._consentEnabled = enabled; 47 + } catch { 48 + // revert on failure 49 + this._consentEnabled = !enabled; 50 + } finally { 51 + this._consentLoading = false; 52 + } 53 + } 54 + 55 + private async _disconnect() { 56 + const dialog = document.querySelector("p2p-confirm-dialog") as ConfirmDialog | null; 57 + if (!dialog) return; 58 + const confirmed = await dialog.confirm( 59 + "Are you sure you want to disconnect? This will log out and purge all local data.", 60 + ); 61 + if (!confirmed) return; 62 + try { 63 + await fetch("/oauth/logout?disconnect=true", { method: "POST" }); 64 + } catch { 65 + // ignore 66 + } 67 + window.location.href = "/"; 68 + } 69 + 70 + private _onActorSelected(e: CustomEvent) { 71 + this._connectHandle = e.detail.handle; 72 + } 73 + 74 + private _onActorCleared() { 75 + this._connectHandle = null; 76 + } 77 + 78 + private _connect() { 79 + if (!this._connectHandle) return; 80 + window.location.href = `/oauth/login?handle=${encodeURIComponent(this._connectHandle)}`; 81 + } 82 + 83 + render() { 84 + const auth = this.authStatus; 85 + if (!auth) return html``; 86 + 87 + if (auth.authenticated) { 88 + const initial = ((auth.handle || auth.did || "?")[0] as string).toUpperCase(); 89 + return html` 90 + <div class="card"> 91 + <h2>Account</h2> 92 + <div class="account-info"> 93 + ${auth.avatar 94 + ? html`<img src="${auth.avatar}" alt="" style="width:40px;height:40px;border-radius:50%;vertical-align:middle;margin-right:0.5rem">` 95 + : html`<span style="display:inline-block;width:40px;height:40px;border-radius:50%;background:var(--acct-placeholder-bg);text-align:center;line-height:40px;font-size:1rem;vertical-align:middle;margin-right:0.5rem">${initial}</span>`} 96 + <div> 97 + <strong>${auth.displayName || auth.handle}</strong> 98 + <div style="color:var(--muted);font-size:0.8rem">@${auth.handle}</div> 99 + <div style="color:var(--faint);font-size:0.7rem">${auth.did}</div> 100 + </div> 101 + </div> 102 + <div style="margin-top:0.75rem;display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap"> 103 + <span class="dot dot-synced"></span> Authenticated 104 + <button class="btn-danger" @click=${this._disconnect}>Disconnect</button> 105 + </div> 106 + ${this._consentEnabled !== null 107 + ? html` 108 + <label class="consent-toggle" style="margin-top:0.75rem;display:flex;align-items:center;gap:0.4rem"> 109 + <input 110 + type="checkbox" 111 + .checked=${this._consentEnabled} 112 + .disabled=${this._consentLoading} 113 + @change=${this._toggleConsent} 114 + /> 115 + Publicly consent to be archived 116 + </label> 117 + ` 118 + : ""} 119 + </div> 120 + `; 121 + } 122 + 123 + return html` 124 + <div class="card"> 125 + <h2>Account</h2> 126 + <p style="margin-bottom:0.75rem">Connect your Bluesky account to get started.</p> 127 + <p2p-account-search 128 + placeholder="Search your Bluesky handle..." 129 + @actor-selected=${this._onActorSelected} 130 + @actor-cleared=${this._onActorCleared} 131 + ></p2p-account-search> 132 + <button 133 + class="btn-primary" 134 + style="margin-top:0.75rem" 135 + .disabled=${!this._connectHandle} 136 + @click=${this._connect} 137 + > 138 + Connect 139 + </button> 140 + </div> 141 + `; 142 + } 143 + } 144 + 145 + declare global { 146 + interface HTMLElementTagNameMap { 147 + "p2p-account-card": AccountCard; 148 + } 149 + }
+157
src/ui/components/account-search.ts
··· 1 + import { LitElement, html } from "lit"; 2 + import { customElement, property, state } from "lit/decorators.js"; 3 + 4 + interface ActorResult { 5 + handle: string; 6 + did: string | null; 7 + displayName: string | null; 8 + avatar: string | null; 9 + } 10 + 11 + @customElement("p2p-account-search") 12 + export class AccountSearch extends LitElement { 13 + createRenderRoot() { 14 + return this; 15 + } 16 + 17 + @property() placeholder: string = "Search account..."; 18 + 19 + @state() results: any[] = []; 20 + @state() visible = false; 21 + @state() activeIndex = -1; 22 + @state() selectedActor: ActorResult | null = null; 23 + 24 + private _debounceTimer: ReturnType<typeof setTimeout> | null = null; 25 + 26 + clear() { 27 + this.selectedActor = null; 28 + this.results = []; 29 + this.visible = false; 30 + this.activeIndex = -1; 31 + } 32 + 33 + private async _onInput(e: Event) { 34 + const value = (e.target as HTMLInputElement).value.trim(); 35 + if (this._debounceTimer) clearTimeout(this._debounceTimer); 36 + if (!value) { 37 + this.results = []; 38 + this.visible = false; 39 + return; 40 + } 41 + this._debounceTimer = setTimeout(() => this._search(value), 250); 42 + } 43 + 44 + private async _search(q: string) { 45 + try { 46 + const url = `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(q)}&limit=8`; 47 + const res = await fetch(url); 48 + if (!res.ok) return; 49 + const data = await res.json(); 50 + this.results = data.actors ?? []; 51 + this.visible = this.results.length > 0; 52 + this.activeIndex = -1; 53 + } catch { 54 + // ignore 55 + } 56 + } 57 + 58 + private _onKeydown(e: KeyboardEvent) { 59 + if (!this.visible) return; 60 + if (e.key === "ArrowDown") { 61 + e.preventDefault(); 62 + this.activeIndex = Math.min(this.activeIndex + 1, this.results.length - 1); 63 + } else if (e.key === "ArrowUp") { 64 + e.preventDefault(); 65 + this.activeIndex = Math.max(this.activeIndex - 1, 0); 66 + } else if (e.key === "Enter") { 67 + e.preventDefault(); 68 + if (this.activeIndex >= 0 && this.activeIndex < this.results.length) { 69 + this._selectActor(this.results[this.activeIndex]); 70 + } 71 + } else if (e.key === "Escape") { 72 + this.visible = false; 73 + } 74 + } 75 + 76 + private _selectActor(actor: any) { 77 + this.selectedActor = { 78 + handle: actor.handle, 79 + did: actor.did ?? null, 80 + displayName: actor.displayName ?? null, 81 + avatar: actor.avatar ?? null, 82 + }; 83 + this.results = []; 84 + this.visible = false; 85 + this.activeIndex = -1; 86 + this.dispatchEvent( 87 + new CustomEvent("actor-selected", { 88 + detail: this.selectedActor, 89 + bubbles: true, 90 + composed: true, 91 + }), 92 + ); 93 + } 94 + 95 + private _clearSelection() { 96 + this.selectedActor = null; 97 + this.dispatchEvent( 98 + new CustomEvent("actor-cleared", { 99 + bubbles: true, 100 + composed: true, 101 + }), 102 + ); 103 + } 104 + 105 + render() { 106 + if (this.selectedActor) { 107 + const a = this.selectedActor; 108 + const initial = ((a.handle || "?")[0] as string).toUpperCase(); 109 + return html` 110 + <div class="account-selected"> 111 + ${a.avatar 112 + ? html`<img src="${a.avatar}" alt="" class="account-selected-avatar">` 113 + : html`<span class="account-selected-placeholder">${initial}</span>`} 114 + <div class="account-selected-info"> 115 + <strong>${a.displayName || a.handle}</strong> 116 + <span style="color:var(--muted);margin-left:0.3rem;font-size:0.75rem">@${a.handle}</span> 117 + </div> 118 + <button class="account-selected-x" @click=${this._clearSelection} title="Clear selection">&times;</button> 119 + </div> 120 + `; 121 + } 122 + 123 + return html` 124 + <div class="account-search-wrap"> 125 + <input 126 + type="text" 127 + .placeholder=${this.placeholder} 128 + @input=${this._onInput} 129 + @keydown=${this._onKeydown} 130 + @blur=${() => setTimeout(() => { this.visible = false; }, 200)} 131 + /> 132 + <div class="account-results ${this.visible ? "visible" : ""}"> 133 + ${this.results.map( 134 + (actor, i) => html` 135 + <div 136 + class="account-result-item ${i === this.activeIndex ? "active" : ""}" 137 + @mousedown=${() => this._selectActor(actor)} 138 + > 139 + ${actor.avatar 140 + ? html`<img src="${actor.avatar}" alt="" style="width:20px;height:20px;border-radius:50%;vertical-align:middle;margin-right:0.3rem">` 141 + : html`<span style="display:inline-block;width:20px;height:20px;border-radius:50%;background:var(--acct-placeholder-bg);text-align:center;line-height:20px;font-size:0.6rem;vertical-align:middle;margin-right:0.3rem">${((actor.handle || "?")[0] as string).toUpperCase()}</span>`} 142 + <strong style="font-size:0.8rem">${actor.displayName || actor.handle}</strong> 143 + <span style="color:var(--muted);font-size:0.75rem;margin-left:0.3rem">@${actor.handle}</span> 144 + </div> 145 + `, 146 + )} 147 + </div> 148 + </div> 149 + `; 150 + } 151 + } 152 + 153 + declare global { 154 + interface HTMLElementTagNameMap { 155 + "p2p-account-search": AccountSearch; 156 + } 157 + }
+53
src/ui/components/app-header.ts
··· 1 + import { LitElement, html } from "lit"; 2 + import { customElement, property, state } from "lit/decorators.js"; 3 + 4 + @customElement("p2p-header") 5 + export class AppHeader extends LitElement { 6 + createRenderRoot() { 7 + return this; 8 + } 9 + 10 + @property() version = ""; 11 + @property({ type: Boolean }) refreshing = false; 12 + @property({ type: Boolean }) autoRefresh = true; 13 + @state() lastRefresh = "-"; 14 + 15 + setLastRefresh(time: string) { 16 + this.lastRefresh = time; 17 + } 18 + 19 + private _onAutoRefreshChange(e: Event) { 20 + const checked = (e.target as HTMLInputElement).checked; 21 + this.dispatchEvent( 22 + new CustomEvent("toggle-auto-refresh", { 23 + detail: checked, 24 + bubbles: true, 25 + composed: true, 26 + }), 27 + ); 28 + } 29 + 30 + render() { 31 + return html` 32 + <header> 33 + <h1>P2PDS</h1> 34 + <span class="badge">v${this.version}</span> 35 + <div class="meta"> 36 + <span class="activity-spinner ${this.refreshing ? "active" : ""}"></span> 37 + <span>${this.lastRefresh}</span> 38 + <label><input 39 + type="checkbox" 40 + .checked=${this.autoRefresh} 41 + @change=${this._onAutoRefreshChange} 42 + > auto-refresh</label> 43 + </div> 44 + </header> 45 + `; 46 + } 47 + } 48 + 49 + declare global { 50 + interface HTMLElementTagNameMap { 51 + "p2p-header": AppHeader; 52 + } 53 + }
+184
src/ui/components/app-shell.ts
··· 1 + import { LitElement, html } from "lit"; 2 + import { customElement, state } from "lit/decorators.js"; 3 + import { apiFetch, connectSyncProgress } from "../state/api"; 4 + import type { SyncProgressEvent } from "../state/api"; 5 + import { authStatus } from "../state/signals"; 6 + 7 + @customElement("p2p-app") 8 + export class AppShell extends LitElement { 9 + createRenderRoot() { return this; } 10 + 11 + @state() overview: any = null; 12 + @state() network: any = null; 13 + @state() policies: any = null; 14 + @state() syncHistory: any = null; 15 + @state() auth: any = null; 16 + @state() refreshing = false; 17 + @state() autoRefreshEnabled = true; 18 + @state() version = "0.1.0"; 19 + @state() liveProgress: Record<string, SyncProgressEvent> = {}; 20 + 21 + private intervalId: ReturnType<typeof setInterval> | null = null; 22 + private sseCleanup: (() => void) | null = null; 23 + 24 + connectedCallback() { 25 + super.connectedCallback(); 26 + this.refresh(); 27 + this.startAutoRefresh(); 28 + this.connectSSE(); 29 + } 30 + 31 + disconnectedCallback() { 32 + super.disconnectedCallback(); 33 + this.stopAutoRefresh(); 34 + this.disconnectSSE(); 35 + } 36 + 37 + private connectSSE() { 38 + this.disconnectSSE(); 39 + this.sseCleanup = connectSyncProgress((event) => this.handleSyncEvent(event)); 40 + } 41 + 42 + private disconnectSSE() { 43 + if (this.sseCleanup) { 44 + this.sseCleanup(); 45 + this.sseCleanup = null; 46 + } 47 + } 48 + 49 + private handleSyncEvent(event: SyncProgressEvent) { 50 + if (event.type === "sync:complete") { 51 + // Clear live progress for this DID and do a full refresh for authoritative state 52 + const { [event.did]: _, ...rest } = this.liveProgress; 53 + this.liveProgress = rest; 54 + this.refresh(); 55 + return; 56 + } 57 + 58 + if (event.type === "sync:error") { 59 + const { [event.did]: _, ...rest } = this.liveProgress; 60 + this.liveProgress = rest; 61 + return; 62 + } 63 + 64 + if (event.type === "sync-cycle:start" || event.type === "sync-cycle:complete") { 65 + return; 66 + } 67 + 68 + // Merge progressive updates 69 + this.liveProgress = { ...this.liveProgress, [event.did]: event }; 70 + } 71 + 72 + private startAutoRefresh() { 73 + this.stopAutoRefresh(); 74 + if (this.autoRefreshEnabled) { 75 + this.intervalId = setInterval(() => this.refresh(), 30000); 76 + } 77 + } 78 + 79 + private stopAutoRefresh() { 80 + if (this.intervalId) { 81 + clearInterval(this.intervalId); 82 + this.intervalId = null; 83 + } 84 + } 85 + 86 + private handleToggleAutoRefresh(e: Event) { 87 + this.autoRefreshEnabled = (e as CustomEvent).detail; 88 + if (this.autoRefreshEnabled) { 89 + this.startAutoRefresh(); 90 + this.refresh(); 91 + } else { 92 + this.stopAutoRefresh(); 93 + } 94 + } 95 + 96 + async refresh() { 97 + this.refreshing = true; 98 + try { 99 + // Check account status first 100 + try { 101 + const res = await fetch("/oauth/status"); 102 + if (res.ok) { 103 + this.auth = await res.json(); 104 + authStatus.value = this.auth; 105 + } else { 106 + this.auth = null; 107 + authStatus.value = null; 108 + } 109 + } catch { 110 + this.auth = null; 111 + authStatus.value = null; 112 + } 113 + 114 + // If not authenticated, show empty state 115 + if (this.auth && this.auth.authenticated === false) { 116 + this.overview = { did: "", handle: "", network: {}, replication: { enabled: false }, policy: {} }; 117 + this.network = { peerId: null, connections: 0 }; 118 + this.policies = { enabled: false }; 119 + this.syncHistory = { history: [] }; 120 + this.updateHeader(); 121 + this.refreshing = this.isAnySyncing(); 122 + return; 123 + } 124 + 125 + const [overview, network, policies, syncHistory] = await Promise.all([ 126 + apiFetch("org.p2pds.app.getOverview"), 127 + apiFetch("org.p2pds.app.getNetworkStatus"), 128 + apiFetch("org.p2pds.app.getPolicies"), 129 + apiFetch("org.p2pds.app.getSyncHistory", { limit: "20" }), 130 + ]); 131 + this.overview = overview; 132 + this.network = network; 133 + this.policies = policies; 134 + this.syncHistory = syncHistory; 135 + this.updateHeader(); 136 + this.refreshing = this.isAnySyncing(); 137 + } catch (e) { 138 + console.error("App refresh error:", e); 139 + this.refreshing = false; 140 + } 141 + } 142 + 143 + private isAnySyncing(): boolean { 144 + const states = this.overview?.replication?.syncStates || []; 145 + return states.some((s: any) => s.status === "syncing" || s.status === "pending"); 146 + } 147 + 148 + private updateHeader() { 149 + const header = this.querySelector("p2p-header") as any; 150 + if (header?.setLastRefresh) { 151 + header.setLastRefresh("Updated: " + new Date().toLocaleTimeString()); 152 + } 153 + } 154 + 155 + render() { 156 + return html` 157 + <p2p-header 158 + .version=${this.version} 159 + .refreshing=${this.refreshing} 160 + .autoRefresh=${this.autoRefreshEnabled} 161 + @toggle-auto-refresh=${this.handleToggleAutoRefresh} 162 + ></p2p-header> 163 + 164 + <div class="top-row"> 165 + <p2p-account-card .authStatus=${this.auth} @refresh-requested=${() => this.refresh()}></p2p-account-card> 166 + <p2p-system-card .data=${this.overview} .network=${this.network}></p2p-system-card> 167 + </div> 168 + 169 + <p2p-incoming-offers .data=${this.overview} @refresh-requested=${() => this.refresh()}></p2p-incoming-offers> 170 + 171 + <p2p-replication-summary .data=${this.overview}></p2p-replication-summary> 172 + 173 + <p2p-replications .data=${this.overview} .liveProgress=${this.liveProgress} @refresh-requested=${() => this.refresh()}></p2p-replications> 174 + 175 + <p2p-sync-history .data=${this.syncHistory}></p2p-sync-history> 176 + 177 + <p2p-policies .data=${this.policies}></p2p-policies> 178 + 179 + <p2p-verification .data=${this.overview}></p2p-verification> 180 + 181 + <p2p-confirm-dialog></p2p-confirm-dialog> 182 + `; 183 + } 184 + }
+57
src/ui/components/confirm-dialog.ts
··· 1 + import { LitElement, html } from "lit"; 2 + import { customElement, state } from "lit/decorators.js"; 3 + 4 + @customElement("p2p-confirm-dialog") 5 + export class ConfirmDialog extends LitElement { 6 + createRenderRoot() { 7 + return this; 8 + } 9 + 10 + @state() private _message = ""; 11 + private _resolve: ((value: boolean) => void) | null = null; 12 + 13 + confirm(message: string): Promise<boolean> { 14 + this._message = message; 15 + return new Promise<boolean>((resolve) => { 16 + this._resolve = resolve; 17 + const dialog = this.querySelector("dialog") as HTMLDialogElement; 18 + dialog.showModal(); 19 + }); 20 + } 21 + 22 + private _handleConfirm() { 23 + const dialog = this.querySelector("dialog") as HTMLDialogElement; 24 + dialog.close(); 25 + if (this._resolve) { 26 + this._resolve(true); 27 + this._resolve = null; 28 + } 29 + } 30 + 31 + private _handleCancel() { 32 + const dialog = this.querySelector("dialog") as HTMLDialogElement; 33 + dialog.close(); 34 + if (this._resolve) { 35 + this._resolve(false); 36 + this._resolve = null; 37 + } 38 + } 39 + 40 + render() { 41 + return html` 42 + <dialog @cancel=${this._handleCancel}> 43 + <p>${this._message}</p> 44 + <div class="dialog-actions"> 45 + <button @click=${this._handleCancel}>Cancel</button> 46 + <button class="confirm-btn" @click=${this._handleConfirm}>Confirm</button> 47 + </div> 48 + </dialog> 49 + `; 50 + } 51 + } 52 + 53 + declare global { 54 + interface HTMLElementTagNameMap { 55 + "p2p-confirm-dialog": ConfirmDialog; 56 + } 57 + }
+106
src/ui/components/incoming-offers.ts
··· 1 + import { LitElement, html } from "lit"; 2 + import { customElement, property, state } from "lit/decorators.js"; 3 + import { apiPost } from "../state/api.js"; 4 + import { fetchProfile, displayName, getCachedProfile } from "../state/profile-cache.js"; 5 + import { renderAccountCell } from "../styles/shared.js"; 6 + import type { ConfirmDialog } from "./confirm-dialog.js"; 7 + 8 + @customElement("p2p-incoming-offers") 9 + export class IncomingOffers extends LitElement { 10 + createRenderRoot() { 11 + return this; 12 + } 13 + 14 + @property({ attribute: false }) data: any = null; 15 + 16 + @state() private _profiles: Record<string, any> = {}; 17 + 18 + updated(changed: Map<string, unknown>) { 19 + if (changed.has("data")) { 20 + this._resolveProfiles(); 21 + } 22 + } 23 + 24 + private async _resolveProfiles() { 25 + const offers = this.data?.incomingOffers ?? []; 26 + for (const offer of offers) { 27 + const did = offer.offererDid ?? offer.did; 28 + if (!did || this._profiles[did]) continue; 29 + fetchProfile(did).then((p) => { 30 + if (p) { 31 + this._profiles = { ...this._profiles, [did]: p }; 32 + } 33 + }); 34 + } 35 + } 36 + 37 + private async _accept(offererDid: string) { 38 + const dialog = document.querySelector("p2p-confirm-dialog") as ConfirmDialog | null; 39 + if (!dialog) return; 40 + const name = displayName(offererDid); 41 + const confirmed = await dialog.confirm(`Accept and replicate ${name}?`); 42 + if (!confirmed) return; 43 + try { 44 + await apiPost("org.p2pds.app.acceptOffer", { offererDid }); 45 + this.dispatchEvent( 46 + new CustomEvent("refresh-requested", { bubbles: true, composed: true }), 47 + ); 48 + } catch { 49 + // ignore 50 + } 51 + } 52 + 53 + private async _reject(offererDid: string) { 54 + const dialog = document.querySelector("p2p-confirm-dialog") as ConfirmDialog | null; 55 + if (!dialog) return; 56 + const name = displayName(offererDid); 57 + const confirmed = await dialog.confirm(`Reject offer from ${name}?`); 58 + if (!confirmed) return; 59 + try { 60 + await apiPost("org.p2pds.app.rejectOffer", { offererDid }); 61 + this.dispatchEvent( 62 + new CustomEvent("refresh-requested", { bubbles: true, composed: true }), 63 + ); 64 + } catch { 65 + // ignore 66 + } 67 + } 68 + 69 + render() { 70 + const offers = this.data?.incomingOffers ?? []; 71 + if (offers.length === 0) { 72 + return html`<div class="card" style="display:none"></div>`; 73 + } 74 + 75 + return html` 76 + <div class="card"> 77 + <details open> 78 + <summary><h2 style="display:inline">Incoming Offers (${offers.length})</h2></summary> 79 + <div class="offer-list" style="margin-top:0.5rem"> 80 + ${offers.map((offer: any) => { 81 + const did = offer.offererDid ?? offer.did; 82 + const profile = this._profiles[did] ?? getCachedProfile(did) ?? null; 83 + return html` 84 + <div class="offer-row" style="display:flex;align-items:center;gap:0.75rem;padding:0.5rem 0;border-bottom:1px solid var(--border)"> 85 + <div style="flex:1;min-width:0"> 86 + ${renderAccountCell(did, profile)} 87 + </div> 88 + <div style="display:flex;gap:0.4rem"> 89 + <button class="btn-primary btn-small" @click=${() => this._accept(did)}>Accept</button> 90 + <button class="btn-danger btn-small" @click=${() => this._reject(did)}>Reject</button> 91 + </div> 92 + </div> 93 + `; 94 + })} 95 + </div> 96 + </details> 97 + </div> 98 + `; 99 + } 100 + } 101 + 102 + declare global { 103 + interface HTMLElementTagNameMap { 104 + "p2p-incoming-offers": IncomingOffers; 105 + } 106 + }
+68
src/ui/components/network-card.ts
··· 1 + import { LitElement, html } from "lit"; 2 + import { customElement, property } from "lit/decorators.js"; 3 + 4 + @customElement("p2p-network-card") 5 + export class NetworkCard extends LitElement { 6 + createRenderRoot() { 7 + return this; 8 + } 9 + 10 + @property({ attribute: false }) data: any = null; 11 + 12 + render() { 13 + if (!this.data) return html``; 14 + const d = this.data; 15 + 16 + if (!d.peerId) { 17 + return html` 18 + <div class="card"> 19 + <h2>Network</h2> 20 + <p>Networking disabled (local blockstore only)</p> 21 + </div> 22 + `; 23 + } 24 + 25 + const dial = d.dialability; 26 + 27 + return html` 28 + <div class="card"> 29 + <h2>Network</h2> 30 + <dl class="kv"> 31 + <dt>Peer ID</dt> 32 + <dd>${d.peerId}</dd> 33 + <dt>Listening</dt> 34 + <dd>${Array.isArray(d.multiaddrs) ? d.multiaddrs.join(", ") : "-"}</dd> 35 + <dt>Connections</dt> 36 + <dd>${d.connections ?? 0}</dd> 37 + ${dial ? html` 38 + <dt>NAT Status</dt> 39 + <dd>${this._natLabel(dial.natStatus)}</dd> 40 + <dt>Public Addrs</dt> 41 + <dd>${dial.publicAddrs.length > 0 ? dial.publicAddrs.join(", ") : "none"}</dd> 42 + ${dial.httpReachable !== null ? html` 43 + <dt>HTTP Reachable</dt> 44 + <dd>${dial.httpReachable ? "yes" : "no"}</dd> 45 + ` : ""} 46 + ` : ""} 47 + </dl> 48 + </div> 49 + `; 50 + } 51 + 52 + private _natLabel(status: string): string { 53 + switch (status) { 54 + case "public": 55 + return "public (reachable)"; 56 + case "private": 57 + return "private (behind NAT)"; 58 + default: 59 + return "unknown"; 60 + } 61 + } 62 + } 63 + 64 + declare global { 65 + interface HTMLElementTagNameMap { 66 + "p2p-network-card": NetworkCard; 67 + } 68 + }
+62
src/ui/components/policies-card.ts
··· 1 + import { LitElement, html } from "lit"; 2 + import { customElement, property } from "lit/decorators.js"; 3 + 4 + @customElement("p2p-policies") 5 + export class PoliciesCard extends LitElement { 6 + createRenderRoot() { 7 + return this; 8 + } 9 + 10 + @property({ attribute: false }) data: any = null; 11 + 12 + render() { 13 + if (!this.data) return html``; 14 + const d = this.data; 15 + 16 + if (!d.enabled) { 17 + return html` 18 + <div class="card"> 19 + <h2>Policies</h2> 20 + <p>Policy engine disabled</p> 21 + </div> 22 + `; 23 + } 24 + 25 + const policies = d.policySet?.policies ?? []; 26 + const explicitDids = d.explicitDids ?? []; 27 + 28 + return html` 29 + <div class="card"> 30 + <h2>Policies</h2> 31 + <dl class="kv"> 32 + <dt>Enabled</dt> 33 + <dd>Yes</dd> 34 + <dt>Policies</dt> 35 + <dd>${policies.length}</dd> 36 + <dt>Explicit DIDs</dt> 37 + <dd>${explicitDids.length}</dd> 38 + </dl> 39 + ${policies.length > 0 40 + ? html` 41 + <ul class="policy-list"> 42 + ${policies.map( 43 + (p: any) => html` 44 + <li> 45 + <strong>${p.name ?? p.id}</strong> 46 + ${p.description ? html` &mdash; ${p.description}` : ""} 47 + </li> 48 + `, 49 + )} 50 + </ul> 51 + ` 52 + : ""} 53 + </div> 54 + `; 55 + } 56 + } 57 + 58 + declare global { 59 + interface HTMLElementTagNameMap { 60 + "p2p-policies": PoliciesCard; 61 + } 62 + }
+215
src/ui/components/replication-row.ts
··· 1 + import { html } from "lit"; 2 + import type { TemplateResult } from "lit"; 3 + import { 4 + formatBytes, 5 + formatNumber, 6 + timeAgo, 7 + statusDot, 8 + didSourceBadge, 9 + triggerBadge, 10 + renderAccountCell, 11 + } from "../styles/shared.js"; 12 + 13 + export interface RowHandlers { 14 + onExpand: (did: string) => void; 15 + onRevoke: (did: string) => void; 16 + onRemoveOffer: (did: string) => void; 17 + } 18 + 19 + export function renderReplicationRows( 20 + states: any[], 21 + sources: Record<string, string>, 22 + offeredDids: any[], 23 + nodeDid: string, 24 + expandedDid: string | null, 25 + detailData: any | null, 26 + profiles: Record<string, any>, 27 + perDidMetrics: Record<string, any>, 28 + handlers: RowHandlers, 29 + liveProgress: Record<string, any> = {}, 30 + ): TemplateResult { 31 + const trackedDids = new Set(states.map((s: any) => s.did)); 32 + 33 + const rows: TemplateResult[] = []; 34 + 35 + for (const s of states) { 36 + const source = sources[s.did] ?? "unknown"; 37 + const profile = profiles[s.did] ?? null; 38 + const metrics = perDidMetrics[s.did] ?? null; 39 + const isExpanded = expandedDid === s.did; 40 + const live = liveProgress[s.did]; 41 + const isSyncing = s.status === "syncing" || s.status === "pending" || !!live; 42 + 43 + // Build live progress indicator text 44 + let progressText = ""; 45 + if (live) { 46 + if (live.type === "sync:blocks-stored" && live.blocksStored) { 47 + progressText = `${live.blocksStored} blocks`; 48 + } else if (live.type === "sync:blob-progress" && live.blobsFetched != null) { 49 + progressText = `${live.blobsFetched}/${live.blobsTotal} blobs`; 50 + } else if (live.type === "sync:car-received") { 51 + progressText = "receiving..."; 52 + } else if (live.type === "sync:verified") { 53 + progressText = "verified"; 54 + } else if (live.type === "sync:start") { 55 + progressText = "starting..."; 56 + } 57 + } 58 + 59 + rows.push(html` 60 + <tr class="replication-row ${isExpanded ? "expanded" : ""}" @click=${() => handlers.onExpand(s.did)} style="cursor:pointer"> 61 + <td>${renderAccountCell(s.did, profile)}</td> 62 + <td>${statusDot(s.status)}${isSyncing && progressText ? html`<span class="live-progress">${progressText}</span>` : ""}</td> 63 + <td>${didSourceBadge(source)}</td> 64 + <td>${metrics ? formatNumber(metrics.records) : "-"}</td> 65 + <td>${metrics ? formatNumber(metrics.blocks) : "-"}${live?.blocksStored ? html`<span class="live-progress">+${live.blocksStored}</span>` : ""}</td> 66 + <td>${metrics ? formatBytes(metrics.totalBytes) : "-"}</td> 67 + <td>${timeAgo(s.lastSyncAt)}</td> 68 + </tr> 69 + `); 70 + 71 + if (isExpanded) { 72 + rows.push(html` 73 + <tr class="detail-row"> 74 + <td colspan="7"> 75 + ${_renderDetail(s, source, nodeDid, detailData, handlers)} 76 + </td> 77 + </tr> 78 + `); 79 + } 80 + } 81 + 82 + // Offered DIDs not yet tracked 83 + for (const od of offeredDids) { 84 + if (trackedDids.has(od.did)) continue; 85 + const profile = profiles[od.did] ?? null; 86 + 87 + rows.push(html` 88 + <tr class="replication-row offered-row"> 89 + <td>${renderAccountCell(od.did, profile)}</td> 90 + <td>${statusDot("offered")}</td> 91 + <td>${didSourceBadge("offer")}</td> 92 + <td>-</td> 93 + <td>-</td> 94 + <td>-</td> 95 + <td> 96 + <button class="btn-danger btn-small" @click=${(e: Event) => { e.stopPropagation(); handlers.onRemoveOffer(od.did); }}>Remove</button> 97 + </td> 98 + </tr> 99 + `); 100 + } 101 + 102 + if (rows.length === 0) { 103 + rows.push(html` 104 + <tr> 105 + <td colspan="7" style="text-align:center;color:var(--muted);padding:1.5rem"> 106 + No replications yet. Add an account above to get started. 107 + </td> 108 + </tr> 109 + `); 110 + } 111 + 112 + return html`${rows}`; 113 + } 114 + 115 + function _renderDetail( 116 + s: any, 117 + source: string, 118 + nodeDid: string, 119 + detailData: any | null, 120 + handlers: RowHandlers, 121 + ): TemplateResult { 122 + if (!detailData) { 123 + return html`<div class="detail-loading">Loading...</div>`; 124 + } 125 + 126 + const d = detailData; 127 + const metrics = { 128 + records: d.recordCount ?? 0, 129 + blocks: d.blockCount ?? 0, 130 + blobs: d.blobCount ?? 0, 131 + totalBytes: d.bytesHeld ?? 0, 132 + transferred24h: d.transferred24h ?? 0, 133 + }; 134 + const recentSyncs = d.recentSyncs ?? []; 135 + const canRevoke = source === "user" && s.did !== nodeDid; 136 + 137 + return html` 138 + <div class="detail-content"> 139 + <div class="metrics-grid" style="margin-bottom:0.75rem"> 140 + <div class="metric-box"> 141 + <div class="value">${formatNumber(metrics.records)}</div> 142 + <div class="label">Records</div> 143 + </div> 144 + <div class="metric-box"> 145 + <div class="value">${formatNumber(metrics.blocks)}</div> 146 + <div class="label">Blocks</div> 147 + </div> 148 + <div class="metric-box"> 149 + <div class="value">${formatNumber(metrics.blobs)}</div> 150 + <div class="label">Blobs</div> 151 + </div> 152 + <div class="metric-box"> 153 + <div class="value">${formatBytes(metrics.totalBytes)}</div> 154 + <div class="label">Stored</div> 155 + </div> 156 + <div class="metric-box"> 157 + <div class="value">${formatBytes(metrics.transferred24h)}</div> 158 + <div class="label">Transferred 24h</div> 159 + </div> 160 + </div> 161 + 162 + ${d.triggerBreakdown && Object.keys(d.triggerBreakdown).length > 0 163 + ? html` 164 + <div style="margin-bottom:0.75rem"> 165 + <strong style="font-size:0.75rem">Triggers:</strong> 166 + ${Object.entries(d.triggerBreakdown).map(([t, count]: [string, any]) => html`${triggerBadge(t)} <span style="font-size:0.7rem;color:var(--muted)">${count}</span> `)} 167 + </div> 168 + ` 169 + : ""} 170 + 171 + ${recentSyncs.length > 0 172 + ? html` 173 + <table class="inner-table"> 174 + <thead> 175 + <tr> 176 + <th>Time</th> 177 + <th>Trigger</th> 178 + <th>Duration</th> 179 + <th>Blocks</th> 180 + <th>Bytes</th> 181 + <th>Status</th> 182 + </tr> 183 + </thead> 184 + <tbody> 185 + ${recentSyncs.map( 186 + (sync: any) => html` 187 + <tr> 188 + <td>${timeAgo(sync.startedAt)}</td> 189 + <td>${triggerBadge(sync.trigger)}</td> 190 + <td>${sync.durationMs != null ? sync.durationMs + "ms" : "-"}</td> 191 + <td>${formatNumber(sync.blocksAdded)}</td> 192 + <td>${formatBytes((sync.carBytes || 0) + (sync.blobBytes || 0))}</td> 193 + <td>${statusDot(sync.status)}</td> 194 + </tr> 195 + `, 196 + )} 197 + </tbody> 198 + </table> 199 + ` 200 + : html`<p style="color:var(--muted);font-size:0.8rem">No recent syncs</p>`} 201 + 202 + ${canRevoke 203 + ? html` 204 + <button 205 + class="btn-danger" 206 + style="margin-top:0.75rem" 207 + @click=${(e: Event) => { e.stopPropagation(); handlers.onRevoke(s.did); }} 208 + > 209 + Revoke Replication 210 + </button> 211 + ` 212 + : ""} 213 + </div> 214 + `; 215 + }
+59
src/ui/components/replication-summary.ts
··· 1 + import { LitElement, html } from "lit"; 2 + import { customElement, property } from "lit/decorators.js"; 3 + import { formatBytes, formatNumber } from "../styles/shared.js"; 4 + 5 + @customElement("p2p-replication-summary") 6 + export class ReplicationSummary extends LitElement { 7 + createRenderRoot() { 8 + return this; 9 + } 10 + 11 + @property({ attribute: false }) data: any = null; 12 + 13 + render() { 14 + const repl = this.data?.replication; 15 + if (!repl?.enabled) return html`<section class="card"><h2>Replication Summary</h2><div>Replication disabled</div></section>`; 16 + const a = repl.aggregate || {}; 17 + return html` 18 + <section class="card"> 19 + <h2>Replication Summary</h2> 20 + <div class="metrics-grid"> 21 + <div class="metric-box"> 22 + <div class="value">${formatNumber(a.totalDids)}</div> 23 + <div class="label">Tracked DIDs</div> 24 + </div> 25 + <div class="metric-box"> 26 + <div class="value">${formatNumber(a.totalRecords)}</div> 27 + <div class="label">Records</div> 28 + </div> 29 + <div class="metric-box"> 30 + <div class="value">${formatNumber(a.totalBlocks)}</div> 31 + <div class="label">Blocks</div> 32 + </div> 33 + <div class="metric-box"> 34 + <div class="value">${formatNumber(a.totalBlobs)}</div> 35 + <div class="label">Blobs</div> 36 + </div> 37 + <div class="metric-box"> 38 + <div class="value">${formatBytes(a.totalBytesHeld)}</div> 39 + <div class="label">Total Held</div> 40 + </div> 41 + <div class="metric-box"> 42 + <div class="value">${formatBytes(a.recentTransferredBytes)}</div> 43 + <div class="label">Transferred (24h)</div> 44 + </div> 45 + <div class="metric-box"> 46 + <div class="value">${formatNumber(a.totalSyncs)}</div> 47 + <div class="label">Total Syncs</div> 48 + </div> 49 + </div> 50 + </section> 51 + `; 52 + } 53 + } 54 + 55 + declare global { 56 + interface HTMLElementTagNameMap { 57 + "p2p-replication-summary": ReplicationSummary; 58 + } 59 + }
+290
src/ui/components/replications-card.ts
··· 1 + import { LitElement, html } from "lit"; 2 + import { customElement, property, state } from "lit/decorators.js"; 3 + import { apiFetch, apiPost } from "../state/api.js"; 4 + import { fetchProfile, displayName, getCachedProfile } from "../state/profile-cache.js"; 5 + import { renderReplicationRows } from "./replication-row.js"; 6 + import type { RowHandlers } from "./replication-row.js"; 7 + import type { ConfirmDialog } from "./confirm-dialog.js"; 8 + import type { AccountSearch } from "./account-search.js"; 9 + import "./account-search.js"; 10 + 11 + @customElement("p2p-replications") 12 + export class ReplicationsCard extends LitElement { 13 + createRenderRoot() { 14 + return this; 15 + } 16 + 17 + @property({ attribute: false }) data: any = null; 18 + @property({ attribute: false }) liveProgress: Record<string, any> = {}; 19 + 20 + @state() expandedDid: string | null = null; 21 + @state() detailData: any = null; 22 + @state() profiles: Record<string, any> = {}; 23 + @state() perDidMetrics: Record<string, any> = {}; 24 + @state() msgClass = ""; 25 + @state() msgText = ""; 26 + @state() selectedDid: string | null = null; 27 + @state() selectedPolicy = "reciprocal"; 28 + @state() consentAvailable = false; 29 + @state() consentChecking = false; 30 + @state() gated = false; 31 + 32 + updated(changed: Map<string, unknown>) { 33 + if (changed.has("data") && this.data) { 34 + this._resolveProfilesAndMetrics(); 35 + this._checkGate(); 36 + } 37 + } 38 + 39 + private _checkGate() { 40 + const rep = this.data?.replication; 41 + if (!rep) return; 42 + const nodeDid = this.data.nodeDid ?? this.data.did; 43 + if (!nodeDid) { 44 + this.gated = false; 45 + return; 46 + } 47 + const states = rep.syncStates ?? []; 48 + const selfState = states.find((s: any) => s.did === nodeDid); 49 + this.gated = !selfState || selfState.status === "syncing" || selfState.status === "pending"; 50 + } 51 + 52 + private async _resolveProfilesAndMetrics() { 53 + const rep = this.data?.replication; 54 + if (!rep) return; 55 + const states = rep.syncStates ?? []; 56 + const offeredDids = this.data.offeredDids ?? []; 57 + const allDids = [ 58 + ...states.map((s: any) => s.did), 59 + ...offeredDids.map((o: any) => o.did), 60 + ]; 61 + 62 + for (const did of allDids) { 63 + if (!did || this.profiles[did]) continue; 64 + fetchProfile(did).then((p) => { 65 + if (p) { 66 + this.profiles = { ...this.profiles, [did]: p }; 67 + } 68 + }); 69 + } 70 + 71 + for (const s of states) { 72 + apiFetch("org.p2pds.app.getDidStatus", { did: s.did }).then((d) => { 73 + if (d) { 74 + this.perDidMetrics = { ...this.perDidMetrics, [s.did]: { 75 + records: d.recordCount ?? 0, 76 + blocks: d.blockCount ?? 0, 77 + blobs: d.blobCount ?? 0, 78 + totalBytes: d.bytesHeld ?? 0, 79 + }}; 80 + } 81 + }).catch(() => {}); 82 + } 83 + } 84 + 85 + private _onActorSelected(e: CustomEvent) { 86 + this.selectedDid = e.detail.did ?? null; 87 + this.consentAvailable = false; 88 + this.consentChecking = false; 89 + if (this.selectedDid) { 90 + this._checkConsent(this.selectedDid); 91 + } 92 + } 93 + 94 + private _onActorCleared() { 95 + this.selectedDid = null; 96 + this.consentAvailable = false; 97 + this.consentChecking = false; 98 + this.selectedPolicy = "reciprocal"; 99 + } 100 + 101 + private async _checkConsent(did: string) { 102 + this.consentChecking = true; 103 + try { 104 + const data = await apiFetch("org.p2pds.app.checkConsent", { did }); 105 + this.consentAvailable = data.hasConsent === true; 106 + } catch { 107 + this.consentAvailable = false; 108 + } finally { 109 + this.consentChecking = false; 110 + } 111 + } 112 + 113 + private async _submitAdd() { 114 + if (!this.selectedDid) return; 115 + this.msgText = ""; 116 + this.msgClass = ""; 117 + 118 + try { 119 + if (this.selectedPolicy === "reciprocal" || this.selectedPolicy === "consented") { 120 + await apiPost("org.p2pds.app.offerDid", { 121 + did: this.selectedDid, 122 + policy: this.selectedPolicy, 123 + }); 124 + } else { 125 + await apiPost("org.p2pds.app.addDid", { 126 + did: this.selectedDid, 127 + policy: this.selectedPolicy, 128 + }); 129 + } 130 + this.msgClass = "msg-ok"; 131 + this.msgText = `Added ${displayName(this.selectedDid)}`; 132 + this.selectedDid = null; 133 + this.selectedPolicy = "reciprocal"; 134 + const search = this.querySelector("p2p-account-search") as AccountSearch | null; 135 + if (search) search.clear(); 136 + this.dispatchEvent( 137 + new CustomEvent("refresh-requested", { bubbles: true, composed: true }), 138 + ); 139 + } catch (err: any) { 140 + this.msgClass = "msg-err"; 141 + this.msgText = err.message ?? "Failed to add"; 142 + } 143 + } 144 + 145 + private async _expandRow(did: string) { 146 + if (this.expandedDid === did) { 147 + this.expandedDid = null; 148 + this.detailData = null; 149 + return; 150 + } 151 + this.expandedDid = did; 152 + this.detailData = null; 153 + try { 154 + this.detailData = await apiFetch("org.p2pds.app.getDidStatus", { did }); 155 + } catch { 156 + this.detailData = { error: true }; 157 + } 158 + } 159 + 160 + private async _revokeReplication(did: string) { 161 + const dialog = document.querySelector("p2p-confirm-dialog") as ConfirmDialog | null; 162 + if (!dialog) return; 163 + const name = displayName(did); 164 + const confirmed = await dialog.confirm(`Revoke replication of ${name}? This will delete all local data for this account.`); 165 + if (!confirmed) return; 166 + try { 167 + await apiPost("org.p2pds.app.revokeReplication", { did }); 168 + this.dispatchEvent( 169 + new CustomEvent("refresh-requested", { bubbles: true, composed: true }), 170 + ); 171 + } catch { 172 + // ignore 173 + } 174 + } 175 + 176 + private async _removeOffer(did: string) { 177 + const dialog = document.querySelector("p2p-confirm-dialog") as ConfirmDialog | null; 178 + if (!dialog) return; 179 + const name = displayName(did); 180 + const confirmed = await dialog.confirm(`Remove offer for ${name}?`); 181 + if (!confirmed) return; 182 + try { 183 + await apiPost("org.p2pds.app.removeOffer", { did }); 184 + this.dispatchEvent( 185 + new CustomEvent("refresh-requested", { bubbles: true, composed: true }), 186 + ); 187 + } catch { 188 + // ignore 189 + } 190 + } 191 + 192 + render() { 193 + if (!this.data) return html``; 194 + const rep = this.data.replication; 195 + if (!rep) return html``; 196 + 197 + const states = rep.syncStates ?? []; 198 + const sources = rep.didSources ?? {}; 199 + const offeredDids = this.data.offeredDids ?? []; 200 + const nodeDid = this.data.nodeDid ?? this.data.did; 201 + 202 + const handlers: RowHandlers = { 203 + onExpand: (did) => this._expandRow(did), 204 + onRevoke: (did) => this._revokeReplication(did), 205 + onRemoveOffer: (did) => this._removeOffer(did), 206 + }; 207 + 208 + return html` 209 + <div class="card"> 210 + <h2>Replications</h2> 211 + 212 + <div class="add-did-form" style="margin-bottom:1rem"> 213 + ${this.gated 214 + ? html`<p style="color:var(--muted);font-size:0.85rem">Syncing your account... You can add others once your data is ready.</p>` 215 + : html` 216 + <div class="add-did-row"> 217 + <div class="add-did-search"> 218 + <p2p-account-search 219 + placeholder="Search for an account to replicate..." 220 + @actor-selected=${this._onActorSelected} 221 + @actor-cleared=${this._onActorCleared} 222 + ></p2p-account-search> 223 + </div> 224 + ${this.selectedDid 225 + ? html` 226 + <select 227 + class="policy-select" 228 + .value=${this.selectedPolicy} 229 + @change=${(e: Event) => { 230 + this.selectedPolicy = (e.target as HTMLSelectElement).value; 231 + }} 232 + > 233 + <option value="reciprocal">Reciprocal archive — Mutual consent, bidirectional replication</option> 234 + <option value="consented" .disabled=${!this.consentAvailable}>Consensual archive — Replicate with explicit permission${this.consentChecking ? " (checking...)" : !this.consentAvailable ? " (unavailable)" : ""}</option> 235 + <option value="archive">Non-consensual archive — Replicate without explicit permission</option> 236 + </select> 237 + <button 238 + class="btn-primary add-did-btn" 239 + @click=${this._submitAdd} 240 + > 241 + Add 242 + </button> 243 + ` 244 + : ""} 245 + </div> 246 + `} 247 + ${this.msgText 248 + ? html`<div class="form-msg ${this.msgClass}" style="margin-top:0.4rem;font-size:0.8rem">${this.msgText}</div>` 249 + : ""} 250 + </div> 251 + 252 + <div class="table-wrap"> 253 + <table class="replication-table"> 254 + <thead> 255 + <tr> 256 + <th>Account</th> 257 + <th>Status</th> 258 + <th>Source</th> 259 + <th>Records</th> 260 + <th>Blocks</th> 261 + <th>Stored</th> 262 + <th>Last Sync</th> 263 + </tr> 264 + </thead> 265 + <tbody> 266 + ${renderReplicationRows( 267 + states, 268 + sources, 269 + offeredDids, 270 + nodeDid, 271 + this.expandedDid, 272 + this.detailData, 273 + this.profiles, 274 + this.perDidMetrics, 275 + handlers, 276 + this.liveProgress, 277 + )} 278 + </tbody> 279 + </table> 280 + </div> 281 + </div> 282 + `; 283 + } 284 + } 285 + 286 + declare global { 287 + interface HTMLElementTagNameMap { 288 + "p2p-replications": ReplicationsCard; 289 + } 290 + }
+72
src/ui/components/sync-history.ts
··· 1 + import { LitElement, html } from "lit"; 2 + import { customElement, property } from "lit/decorators.js"; 3 + import { 4 + sourceBadge, 5 + triggerBadge, 6 + timeAgo, 7 + formatNumber, 8 + formatBytes, 9 + } from "../styles/shared.js"; 10 + 11 + @customElement("p2p-sync-history") 12 + export class SyncHistory extends LitElement { 13 + createRenderRoot() { 14 + return this; 15 + } 16 + 17 + @property({ attribute: false }) data: any = null; 18 + 19 + render() { 20 + const history = this.data?.history ?? []; 21 + 22 + return html` 23 + <section class="card"> 24 + <details> 25 + <summary>Sync History <span class="count-badge">${history.length > 0 ? "(" + history.length + ")" : ""}</span></summary> 26 + <div class="scroll-container"> 27 + ${history.length === 0 28 + ? html`<p>No sync events recorded</p>` 29 + : html` 30 + <table> 31 + <thead> 32 + <tr> 33 + <th>Time</th> 34 + <th>DID</th> 35 + <th>Source</th> 36 + <th>Trigger</th> 37 + <th>Status</th> 38 + <th>Blocks+</th> 39 + <th>Duration</th> 40 + <th>Bytes</th> 41 + </tr> 42 + </thead> 43 + <tbody> 44 + ${history.map( 45 + (s: any) => html` 46 + <tr> 47 + <td>${timeAgo(s.startedAt)}</td> 48 + <td>${s.did}</td> 49 + <td>${sourceBadge(s.sourceType)}</td> 50 + <td>${triggerBadge(s.trigger)}</td> 51 + <td>${s.status}</td> 52 + <td>${formatNumber(s.blocksAdded)}</td> 53 + <td>${s.durationMs != null ? s.durationMs + "ms" : "-"}</td> 54 + <td>${formatBytes((s.carBytes || 0) + (s.blobBytes || 0))}</td> 55 + </tr> 56 + `, 57 + )} 58 + </tbody> 59 + </table> 60 + `} 61 + </div> 62 + </details> 63 + </section> 64 + `; 65 + } 66 + } 67 + 68 + declare global { 69 + interface HTMLElementTagNameMap { 70 + "p2p-sync-history": SyncHistory; 71 + } 72 + }
+62
src/ui/components/system-card.ts
··· 1 + import { LitElement, html } from "lit"; 2 + import { customElement, property } from "lit/decorators.js"; 3 + 4 + @customElement("p2p-system-card") 5 + export class SystemCard extends LitElement { 6 + createRenderRoot() { 7 + return this; 8 + } 9 + 10 + @property({ attribute: false }) data: any = null; 11 + @property({ attribute: false }) network: any = null; 12 + 13 + render() { 14 + if (!this.data) return html`<section class="card"><h2>System</h2><div class="loading">Loading...</div></section>`; 15 + const net = this.data.network || {}; 16 + const dial = this.network?.dialability; 17 + return html` 18 + <section class="card"> 19 + <h2>System</h2> 20 + <dl class="kv"> 21 + <dt>Peer ID</dt> 22 + <dd>${net.peerId ?? "-"}</dd> 23 + <dt>Connections</dt> 24 + <dd>${net.peerId ? (net.connections ?? 0) : "-"}</dd> 25 + ${this.network?.multiaddrs?.length > 0 ? html` 26 + <dt>Listening</dt> 27 + <dd>${this.network.multiaddrs.join(", ")}</dd> 28 + ` : ""} 29 + ${dial ? html` 30 + <dt>NAT Status</dt> 31 + <dd>${this._natLabel(dial.natStatus)}</dd> 32 + ${dial.publicAddrs?.length > 0 ? html` 33 + <dt>Public Addrs</dt> 34 + <dd>${dial.publicAddrs.join(", ")}</dd> 35 + ` : ""} 36 + ${dial.httpReachable !== null ? html` 37 + <dt>HTTP Reachable</dt> 38 + <dd>${dial.httpReachable ? "yes" : "no"}</dd> 39 + ` : ""} 40 + ` : ""} 41 + </dl> 42 + </section> 43 + `; 44 + } 45 + 46 + private _natLabel(status: string): string { 47 + switch (status) { 48 + case "public": 49 + return "public (reachable)"; 50 + case "private": 51 + return "private (behind NAT)"; 52 + default: 53 + return "unknown"; 54 + } 55 + } 56 + } 57 + 58 + declare global { 59 + interface HTMLElementTagNameMap { 60 + "p2p-system-card": SystemCard; 61 + } 62 + }
+69
src/ui/components/verification-card.ts
··· 1 + import { LitElement, html } from "lit"; 2 + import { customElement, property } from "lit/decorators.js"; 3 + 4 + @customElement("p2p-verification") 5 + export class VerificationCard extends LitElement { 6 + createRenderRoot() { 7 + return this; 8 + } 9 + 10 + @property({ attribute: false }) data: any = null; 11 + 12 + private _passOrFail(layer: any) { 13 + if (!layer) return html`-`; 14 + return layer.passed 15 + ? html`<span class="verify-pass">PASS</span>` 16 + : html`<span class="verify-fail">FAIL</span>`; 17 + } 18 + 19 + private _findLayer(layers: any[], layerNum: number) { 20 + if (!Array.isArray(layers)) return null; 21 + return layers.find((l: any) => l.layer === layerNum) ?? null; 22 + } 23 + 24 + render() { 25 + const results = this.data?.verification?.results; 26 + if (!results || results.length === 0) { 27 + return html` 28 + <section class="card"> 29 + <h2>Verification</h2> 30 + <div>No verification results</div> 31 + </section> 32 + `; 33 + } 34 + 35 + return html` 36 + <section class="card"> 37 + <h2>Verification</h2> 38 + <table> 39 + <thead> 40 + <tr> 41 + <th>DID</th> 42 + <th>L0 Commit</th> 43 + <th>L1 Sampling</th> 44 + <th>Last Check</th> 45 + </tr> 46 + </thead> 47 + <tbody> 48 + ${results.map( 49 + (r: any) => html` 50 + <tr> 51 + <td>${r.did}</td> 52 + <td>${this._passOrFail(this._findLayer(r.layers, 0))}</td> 53 + <td>${this._passOrFail(this._findLayer(r.layers, 1))}</td> 54 + <td>${r.timestamp ?? "-"}</td> 55 + </tr> 56 + `, 57 + )} 58 + </tbody> 59 + </table> 60 + </section> 61 + `; 62 + } 63 + } 64 + 65 + declare global { 66 + interface HTMLElementTagNameMap { 67 + "p2p-verification": VerificationCard; 68 + } 69 + }
+76
src/ui/state/api.ts
··· 1 + declare global { 2 + interface Window { 3 + __P2PDS_TOKEN__: string; 4 + } 5 + } 6 + 7 + const TOKEN = window.__P2PDS_TOKEN__; 8 + const HEADERS = { Authorization: "Bearer " + TOKEN }; 9 + 10 + export async function apiFetch(endpoint: string, params?: Record<string, string>): Promise<any> { 11 + const url = new URL("/xrpc/" + endpoint, location.origin); 12 + if (params) { 13 + for (const [k, v] of Object.entries(params)) { 14 + url.searchParams.set(k, v); 15 + } 16 + } 17 + const res = await fetch(url.toString(), { headers: HEADERS }); 18 + return res.json(); 19 + } 20 + 21 + export async function apiPost(endpoint: string, body: unknown): Promise<any> { 22 + const url = new URL("/xrpc/" + endpoint, location.origin); 23 + const res = await fetch(url.toString(), { 24 + method: "POST", 25 + headers: { ...HEADERS, "Content-Type": "application/json" }, 26 + body: JSON.stringify(body), 27 + }); 28 + return res.json(); 29 + } 30 + 31 + export interface SyncProgressEvent { 32 + type: string; 33 + did: string; 34 + sourceType?: string; 35 + carBytes?: number; 36 + blocksStored?: number; 37 + blocksSizeKb?: number; 38 + blobsFetched?: number; 39 + blobsTotal?: number; 40 + blobBytes?: number; 41 + durationMs?: number; 42 + error?: string; 43 + missingBlocks?: number; 44 + } 45 + 46 + /** 47 + * Connect to the SSE sync progress stream. 48 + * Returns a cleanup function to close the connection. 49 + */ 50 + export function connectSyncProgress(onEvent: (event: SyncProgressEvent) => void): () => void { 51 + const url = new URL("/xrpc/org.p2pds.app.syncProgress", location.origin); 52 + url.searchParams.set("token", TOKEN); 53 + 54 + const es = new EventSource(url.toString()); 55 + 56 + const eventTypes = [ 57 + "sync:start", "sync:car-received", "sync:blocks-stored", 58 + "sync:verified", "sync:blob-progress", "sync:complete", "sync:error", 59 + "sync-cycle:start", "sync-cycle:complete", 60 + ]; 61 + 62 + for (const type of eventTypes) { 63 + es.addEventListener(type, (e: MessageEvent) => { 64 + try { 65 + const data = JSON.parse(e.data); 66 + onEvent(data); 67 + } catch { /* ignore parse errors */ } 68 + }); 69 + } 70 + 71 + es.onerror = () => { 72 + // EventSource auto-reconnects; no action needed 73 + }; 74 + 75 + return () => es.close(); 76 + }
+45
src/ui/state/profile-cache.ts
··· 1 + export interface BlueskyProfile { 2 + did: string; 3 + handle: string; 4 + displayName?: string; 5 + avatar?: string; 6 + } 7 + 8 + const cache = new Map<string, BlueskyProfile>(); 9 + const pending = new Map<string, Promise<BlueskyProfile | null>>(); 10 + 11 + export function getCachedProfile(did: string): BlueskyProfile | undefined { 12 + return cache.get(did); 13 + } 14 + 15 + export function fetchProfile(did: string): Promise<BlueskyProfile | null> { 16 + const cached = cache.get(did); 17 + if (cached) return Promise.resolve(cached); 18 + 19 + const inflight = pending.get(did); 20 + if (inflight) return inflight; 21 + 22 + const p = fetch( 23 + "https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=" + 24 + encodeURIComponent(did), 25 + ) 26 + .then((r) => (r.ok ? r.json() : null)) 27 + .then((profile: BlueskyProfile | null) => { 28 + if (profile) cache.set(did, profile); 29 + pending.delete(did); 30 + return profile; 31 + }) 32 + .catch(() => { 33 + pending.delete(did); 34 + return null; 35 + }); 36 + 37 + pending.set(did, p); 38 + return p; 39 + } 40 + 41 + export function displayName(did: string): string { 42 + const p = cache.get(did); 43 + if (p?.handle) return "@" + p.handle; 44 + return did; 45 + }
+14
src/ui/state/signals.ts
··· 1 + import { signal } from "@preact/signals-core"; 2 + 3 + export interface AuthStatus { 4 + authenticated: boolean; 5 + handle?: string; 6 + did?: string; 7 + displayName?: string; 8 + avatar?: string; 9 + } 10 + 11 + export const authStatus = signal<AuthStatus | null>(null); 12 + export const overviewData = signal<any>(null); 13 + export const isRefreshing = signal(false); 14 + export const autoRefresh = signal(true);
+70
src/ui/styles/shared.ts
··· 1 + import { html } from "lit"; 2 + import type { TemplateResult } from "lit"; 3 + 4 + export function esc(s: unknown): string { 5 + const d = document.createElement("div"); 6 + d.textContent = String(s ?? "-"); 7 + return d.innerHTML; 8 + } 9 + 10 + export function formatBytes(n: number | null | undefined): string { 11 + if (n == null || n === 0) return "0 B"; 12 + const units = ["B", "KB", "MB", "GB", "TB"]; 13 + let i = Math.floor(Math.log(n) / Math.log(1024)); 14 + if (i >= units.length) i = units.length - 1; 15 + return (n / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + " " + units[i]!; 16 + } 17 + 18 + export function formatNumber(n: number | null | undefined): string { 19 + if (n == null) return "-"; 20 + return Number(n).toLocaleString(); 21 + } 22 + 23 + export function timeAgo(iso: string | null | undefined): string { 24 + if (!iso) return "-"; 25 + let diff = Date.now() - new Date(iso + "Z").getTime(); 26 + if (diff < 0) diff = 0; 27 + if (diff < 60000) return Math.floor(diff / 1000) + "s ago"; 28 + if (diff < 3600000) return Math.floor(diff / 60000) + "m ago"; 29 + if (diff < 86400000) return Math.floor(diff / 3600000) + "h ago"; 30 + return Math.floor(diff / 86400000) + "d ago"; 31 + } 32 + 33 + export function statusDot(status: string): TemplateResult { 34 + const cls = 35 + status === "synced" ? "dot-synced" 36 + : status === "syncing" ? "dot-syncing" 37 + : status === "error" ? "dot-error" 38 + : status === "offered" ? "dot-offered" 39 + : "dot-pending"; 40 + return html`<span class="dot ${cls}"></span>${status}`; 41 + } 42 + 43 + export function sourceBadge(sourceType: string): TemplateResult { 44 + const cls = "source-" + (sourceType || "pds"); 45 + return html`<span class="source-badge ${cls}">${sourceType || "pds"}</span>`; 46 + } 47 + 48 + export function triggerBadge(trigger: string): TemplateResult { 49 + const t = trigger || "unknown"; 50 + const cls = "trigger-" + t; 51 + return html`<span class="trigger-badge ${cls}">${t}</span>`; 52 + } 53 + 54 + export function didSourceBadge(source: string): TemplateResult { 55 + const cls = "did-source did-source-" + (source || "unknown"); 56 + return html`<span class="${cls}">${source || "unknown"}</span>`; 57 + } 58 + 59 + export function renderAccountCell(did: string, profile: any): TemplateResult { 60 + if (!profile) return html`${did}`; 61 + const initial = ((profile.handle || did)[0] as string).toUpperCase(); 62 + return html` 63 + ${profile.avatar 64 + ? html`<img src="${profile.avatar}" alt="" style="width:20px;height:20px;border-radius:50%;vertical-align:middle;margin-right:0.3rem">` 65 + : html`<span style="display:inline-block;width:20px;height:20px;border-radius:50%;background:var(--acct-placeholder-bg);text-align:center;line-height:20px;font-size:0.6rem;vertical-align:middle;margin-right:0.3rem">${initial}</span>`} 66 + <strong style="font-size:0.78rem">${profile.displayName || profile.handle}</strong> 67 + <span style="color:var(--muted);font-size:0.72rem">@${profile.handle}</span> 68 + <div style="color:var(--faint);font-size:0.65rem;margin-top:1px">${did}</div> 69 + `; 70 + }
+311
src/ui/styles/theme.ts
··· 1 + import { css } from "lit"; 2 + 3 + export const themeStyles = css` 4 + :root { 5 + --bg: #f0f0f0; --fg: #000; --card-bg: #fff; --card-border: transparent; 6 + --muted: #666; --faint: #999; --border: #eee; --row-hover: #f8f8f8; 7 + --metric-bg: #f8f8f8; --input-bg: #fff; --input-border: #ccc; 8 + --badge-bg: #000; --badge-fg: #fff; 9 + --detail-bg: #fafafa; --shadow: rgba(0,0,0,0.08); 10 + --acct-placeholder-bg: #e5e7eb; --selected-bg: #f8f8f8; --selected-border: #ddd; 11 + --dropdown-bg: #fff; --dropdown-border: #ddd; --dropdown-shadow: rgba(0,0,0,0.12); 12 + } 13 + @media (prefers-color-scheme: dark) { 14 + :root { 15 + --bg: #111; --fg: #e0e0e0; --card-bg: #1a1a1a; --card-border: #2a2a2a; 16 + --muted: #999; --faint: #666; --border: #2a2a2a; --row-hover: #222; 17 + --metric-bg: #222; --input-bg: #222; --input-border: #444; 18 + --badge-bg: #e0e0e0; --badge-fg: #111; 19 + --detail-bg: #1e1e1e; --shadow: rgba(0,0,0,0.3); 20 + --acct-placeholder-bg: #333; --selected-bg: #222; --selected-border: #444; 21 + --dropdown-bg: #1a1a1a; --dropdown-border: #333; --dropdown-shadow: rgba(0,0,0,0.4); 22 + } 23 + } 24 + `; 25 + 26 + export const themeCSS = ` 27 + :root { 28 + --bg: #f0f0f0; --fg: #000; --card-bg: #fff; --card-border: transparent; 29 + --muted: #666; --faint: #999; --border: #eee; --row-hover: #f8f8f8; 30 + --metric-bg: #f8f8f8; --input-bg: #fff; --input-border: #ccc; 31 + --badge-bg: #000; --badge-fg: #fff; 32 + --detail-bg: #fafafa; --shadow: rgba(0,0,0,0.08); 33 + --acct-placeholder-bg: #e5e7eb; --selected-bg: #f8f8f8; --selected-border: #ddd; 34 + --dropdown-bg: #fff; --dropdown-border: #ddd; --dropdown-shadow: rgba(0,0,0,0.12); 35 + } 36 + @media (prefers-color-scheme: dark) { 37 + :root { 38 + --bg: #111; --fg: #e0e0e0; --card-bg: #1a1a1a; --card-border: #2a2a2a; 39 + --muted: #999; --faint: #666; --border: #2a2a2a; --row-hover: #222; 40 + --metric-bg: #222; --input-bg: #222; --input-border: #444; 41 + --badge-bg: #e0e0e0; --badge-fg: #111; 42 + --detail-bg: #1e1e1e; --shadow: rgba(0,0,0,0.3); 43 + --acct-placeholder-bg: #333; --selected-bg: #222; --selected-border: #444; 44 + --dropdown-bg: #1a1a1a; --dropdown-border: #333; --dropdown-shadow: rgba(0,0,0,0.4); 45 + } 46 + } 47 + * { margin: 0; padding: 0; box-sizing: border-box; } 48 + body { 49 + font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace; 50 + background: var(--bg); color: var(--fg); padding: 0.8rem; font-size: 13px; 51 + } 52 + header { 53 + display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; 54 + margin-bottom: 0.6rem; 55 + } 56 + header h1 { font-size: 1.1rem; letter-spacing: 0.1em; } 57 + .badge { font-size: 0.65rem; background: var(--badge-bg); color: var(--badge-fg); padding: 1px 6px; border-radius: 3px; } 58 + .meta { margin-left: auto; font-size: 0.7rem; color: var(--muted); display: flex; gap: 0.8rem; align-items: center; } 59 + .meta label { cursor: pointer; } 60 + .top-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; align-items: stretch; margin-bottom: 0.5rem; } 61 + select.policy-select, select.policy-select ::picker(select) { 62 + appearance: base-select; 63 + } 64 + select.policy-select { 65 + font-family: inherit; font-size: 0.78rem; border: 1px solid var(--input-border); 66 + border-radius: 4px; background: var(--input-bg); color: var(--fg); padding: 0.35rem 0.5rem; 67 + } 68 + select.policy-select option { 69 + padding: 0.4rem 0.5rem; font-size: 0.78rem; 70 + } 71 + select.policy-select option .policy-label { font-weight: 600; display: block; } 72 + select.policy-select option .policy-desc { color: var(--muted); font-size: 0.68rem; display: block; } 73 + .top-row > * > .card { height: 100%; box-sizing: border-box; margin-bottom: 0; } 74 + .card { 75 + background: var(--card-bg); border: 1px solid var(--card-border); border-radius: 4px; 76 + padding: 0.6rem 0.8rem; margin-bottom: 0.5rem; 77 + box-shadow: 0 1px 2px var(--shadow); 78 + } 79 + .card h2 { font-size: 0.95rem; margin-bottom: 0.4rem; } 80 + .card details { margin-bottom: 0; } 81 + .card details > summary { font-size: 0.85rem; font-weight: 700; cursor: pointer; user-select: none; border-bottom: 1px solid var(--border); padding-bottom: 0.2rem; margin-bottom: 0.4rem; list-style: none; display: flex; align-items: center; gap: 0.3rem; } 82 + .card details > summary::-webkit-details-marker { display: none; } 83 + .card details > summary::before { content: "\\25B6"; font-size: 0.55rem; transition: transform 0.15s; display: inline-block; } 84 + .card details[open] > summary::before { transform: rotate(90deg); } 85 + .card details > summary .count-badge { font-weight: 400; font-size: 0.7rem; color: var(--muted); margin-left: auto; } 86 + .scroll-container { max-height: 240px; overflow-y: auto; } 87 + .kv { display: grid; grid-template-columns: 100px 1fr; gap: 0.15rem 0.6rem; font-size: 0.8rem; } 88 + .kv dt { color: var(--muted); } 89 + .kv dd { word-break: break-all; } 90 + .metrics-grid { 91 + display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: 0.4rem; font-size: 0.8rem; 92 + } 93 + .metric-box { 94 + background: var(--metric-bg); border-radius: 3px; padding: 0.35rem 0.4rem; text-align: center; 95 + } 96 + .metric-box .value { font-size: 1rem; font-weight: 700; } 97 + .metric-box .label { color: var(--muted); font-size: 0.65rem; margin-top: 0.1rem; } 98 + table { width: 100%; border-collapse: collapse; font-size: 0.78rem; } 99 + th { text-align: left; padding: 0.25rem 0.4rem; border-bottom: 2px solid var(--border); color: var(--muted); font-weight: 600; } 100 + td { padding: 0.25rem 0.4rem; border-bottom: 1px solid var(--border); } 101 + tr.clickable { cursor: pointer; } 102 + tr.clickable:hover { background: var(--row-hover); } 103 + .dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 4px; vertical-align: middle; } 104 + .dot-synced { background: #22c55e; } 105 + .dot-syncing { background: #eab308; } 106 + .dot-pending { background: #9ca3af; } 107 + .dot-error { background: #ef4444; } 108 + .dot-offered { background: #8b5cf6; } 109 + .detail-row td { padding: 0.5rem; background: var(--detail-bg); font-size: 0.75rem; } 110 + .detail-inner table { margin-top: 0.3rem; } 111 + .source-badge { font-size: 0.65rem; padding: 1px 5px; border-radius: 3px; } 112 + .source-pds { background: #dbeafe; color: #1e40af; } 113 + .source-firehose { background: #fef3c7; color: #92400e; } 114 + .source-peer_fallback { background: #fce7f3; color: #9d174d; } 115 + .trigger-badge { font-size: 0.65rem; padding: 1px 5px; border-radius: 3px; } 116 + .trigger-firehose { background: #fef3c7; color: #92400e; } 117 + .trigger-firehose-resync { background: #ffedd5; color: #9a3412; } 118 + .trigger-gossipsub { background: #ede9fe; color: #5b21b6; } 119 + .trigger-periodic { background: #dbeafe; color: #1e40af; } 120 + .trigger-manual { background: #d1fae5; color: #065f46; } 121 + .trigger-tombstone-recovery { background: #fce7f3; color: #9d174d; } 122 + .trigger-gc { background: #f3f4f6; color: #374151; } 123 + .trigger-unknown { background: var(--metric-bg); color: var(--muted); } 124 + .policy-list { list-style: none; font-size: 0.8rem; } 125 + .policy-list li { padding: 0.25rem 0; border-bottom: 1px solid var(--border); } 126 + .verify-pass { color: #22c55e; font-weight: 600; } 127 + .verify-fail { color: #ef4444; font-weight: 600; } 128 + .loading { color: var(--faint); font-style: italic; } 129 + .add-did-form { 130 + display: flex; flex-direction: column; gap: 0.5rem; 131 + border: 1px solid var(--border); border-radius: 6px; padding: 0.75rem; 132 + margin-bottom: 0.75rem; background: var(--card-bg); 133 + } 134 + .add-did-row { 135 + display: flex; align-items: stretch; gap: 0.5rem; 136 + } 137 + .add-did-row .add-did-search { flex: 1; min-width: 180px; display: flex; } 138 + .add-did-row .add-did-search p2p-account-search { flex: 1; display: flex; } 139 + .add-did-row .add-did-search .account-search-wrap { flex: 1; } 140 + .add-did-row .add-did-search .account-selected { flex: 1; } 141 + .add-did-row .policy-select { flex-shrink: 0; } 142 + .add-did-row .add-did-btn { flex-shrink: 0; align-self: stretch; } 143 + .add-did-form .form-row { display: flex; align-items: center; gap: 0.4rem; } 144 + .add-did-form .form-row label { font-size: 0.75rem; font-weight: 600; min-width: 4rem; flex-shrink: 0; } 145 + .add-did-form input { 146 + flex: 1; padding: 0.3rem 0.5rem; font-family: inherit; font-size: 0.8rem; 147 + border: 1px solid var(--input-border); border-radius: 3px; outline: none; 148 + background: var(--input-bg); color: var(--fg); 149 + } 150 + .add-did-form input:focus { border-color: var(--fg); } 151 + .add-did-form select { 152 + flex: 1; padding: 0.3rem 0.5rem; font-family: inherit; font-size: 0.8rem; 153 + border: 1px solid var(--input-border); border-radius: 3px; outline: none; 154 + background: var(--input-bg); color: var(--fg); 155 + } 156 + .add-did-form select:focus { border-color: var(--fg); } 157 + button, .btn { 158 + padding: 0.35rem 0.7rem; font-family: inherit; font-size: 0.78rem; 159 + border: 1px solid var(--fg); border-radius: 4px; cursor: pointer; 160 + background: var(--card-bg); color: var(--fg); transition: background 0.15s, color 0.15s; 161 + } 162 + button:hover:not(:disabled) { background: var(--fg); color: var(--bg); } 163 + button:disabled { opacity: 0.4; cursor: not-allowed; } 164 + .btn-small { padding: 0.25rem 0.55rem; font-size: 0.75rem; } 165 + .btn-primary { 166 + border-color: #22c55e; color: #22c55e; 167 + } 168 + .btn-primary:hover:not(:disabled) { background: #22c55e; color: #fff; } 169 + .btn-danger { 170 + border-color: #ef4444; color: #ef4444; 171 + } 172 + .btn-danger:hover:not(:disabled) { background: #ef4444; color: #fff; } 173 + .add-did-form .form-submit-row { display: flex; justify-content: flex-end; } 174 + .add-did-form .form-submit-row button { 175 + padding: 0.4rem 1.1rem; font-size: 0.82rem; 176 + } 177 + .add-did-form input:disabled { opacity: 0.4; cursor: not-allowed; } 178 + .add-did-form select:disabled { opacity: 0.4; cursor: not-allowed; } 179 + .consent-note { font-size: 0.65rem; color: #eab308; font-style: italic; margin-left: 4.4rem; } 180 + .btn-remove { border-color: #ef4444; color: #ef4444; } 181 + .btn-remove:hover:not(:disabled) { background: #ef4444; color: #fff; } 182 + .did-source { font-size: 0.65rem; padding: 1px 5px; border-radius: 3px; } 183 + .did-source-config { background: #e0e7ff; color: #3730a3; } 184 + .did-source-user { background: #d1fae5; color: #065f46; } 185 + .did-source-policy { background: #fef3c7; color: #92400e; } 186 + .did-source-unknown { background: var(--metric-bg); color: var(--muted); } 187 + .did-source-offered { background: #ede9fe; color: #5b21b6; } 188 + .add-did-error { color: #ef4444; font-size: 0.78rem; margin-bottom: 0.3rem; min-height: 1em; } 189 + .add-did-success { color: #22c55e; font-size: 0.78rem; margin-bottom: 0.3rem; min-height: 1em; } 190 + .account-search-wrap { position: relative; } 191 + .account-search-wrap input { 192 + width: 100%; padding: 0.3rem 0.5rem 0.3rem 1.6rem; font-family: inherit; font-size: 0.8rem; 193 + border: 1px solid var(--input-border); border-radius: 3px; outline: none; 194 + background: var(--input-bg); color: var(--fg); 195 + } 196 + .account-search-wrap input:focus { border-color: var(--fg); box-shadow: 0 0 0 1px var(--border); } 197 + .account-results { 198 + position: absolute; top: calc(100% + 3px); left: 0; right: 0; z-index: 100; 199 + background: var(--dropdown-bg); border: 1px solid var(--dropdown-border); border-radius: 4px; 200 + box-shadow: 0 3px 12px var(--dropdown-shadow); max-height: 240px; overflow-y: auto; 201 + display: none; 202 + } 203 + .account-results.visible { display: block; } 204 + .account-result-item { 205 + display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.5rem; 206 + cursor: pointer; border-bottom: 1px solid var(--border); transition: background 0.1s; 207 + } 208 + .account-result-item:last-child { border-bottom: none; } 209 + .account-result-item:hover, .account-result-item.active { background: var(--row-hover); } 210 + .account-result-avatar { 211 + width: 26px; height: 26px; border-radius: 50%; background: var(--acct-placeholder-bg); 212 + flex-shrink: 0; object-fit: cover; 213 + } 214 + .account-result-avatar-placeholder { 215 + width: 26px; height: 26px; border-radius: 50%; background: var(--acct-placeholder-bg); 216 + flex-shrink: 0; display: flex; align-items: center; justify-content: center; 217 + color: var(--faint); font-size: 0.65rem; font-weight: 600; 218 + } 219 + .account-result-info { flex: 1; min-width: 0; } 220 + .account-result-name { font-size: 0.8rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 221 + .account-result-handle { font-size: 0.7rem; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 222 + .account-selected { 223 + display: flex; align-items: center; gap: 0.4rem; padding: 0.35rem 0.5rem; 224 + background: var(--selected-bg); border: 1px solid var(--selected-border); border-radius: 4px; 225 + height: 100%; box-sizing: border-box; position: relative; 226 + } 227 + .account-selected-avatar { width: 22px; height: 22px; border-radius: 50%; flex-shrink: 0; } 228 + .account-selected-placeholder { 229 + width: 22px; height: 22px; border-radius: 50%; background: var(--acct-placeholder-bg); 230 + display: inline-flex; align-items: center; justify-content: center; 231 + font-size: 0.65rem; font-weight: 600; flex-shrink: 0; 232 + } 233 + .account-selected-info { flex: 1; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 0.8rem; } 234 + .account-selected-x { 235 + flex-shrink: 0; width: 20px; height: 20px; padding: 0; border: none; 236 + background: transparent; color: var(--muted); font-size: 1rem; line-height: 1; 237 + cursor: pointer; display: flex; align-items: center; justify-content: center; 238 + border-radius: 3px; 239 + } 240 + .account-selected-x:hover { background: var(--border); color: var(--fg); } 241 + .account-connect-btn { 242 + padding: 0.35rem 0.8rem; font-family: inherit; font-size: 0.8rem; 243 + border: 1px solid var(--fg); border-radius: 3px; cursor: pointer; 244 + background: var(--fg); color: var(--bg); font-weight: 600; transition: opacity 0.15s; 245 + } 246 + .account-connect-btn:hover { opacity: 0.85; } 247 + .account-connect-btn:disabled { opacity: 0.35; cursor: not-allowed; } 248 + .account-no-results { padding: 0.5rem; text-align: center; color: var(--faint); font-size: 0.78rem; } 249 + .account-searching { padding: 0.5rem; text-align: center; color: var(--faint); font-size: 0.78rem; } 250 + .per-acct-metrics { display: inline-flex; gap: 0.6rem; font-size: 0.7rem; color: var(--muted); margin-top: 2px; } 251 + .per-acct-metrics span { white-space: nowrap; } 252 + @keyframes spin { to { transform: rotate(360deg); } } 253 + .activity-spinner { 254 + display: inline-block; width: 12px; height: 12px; border: 2px solid var(--border); 255 + border-top-color: var(--fg); border-radius: 50%; vertical-align: middle; 256 + animation: spin 0.8s linear infinite; opacity: 0; transition: opacity 0.2s; 257 + } 258 + .activity-spinner.active { opacity: 1; } 259 + .live-progress { font-size: 0.65rem; color: #eab308; margin-left: 4px; font-style: italic; } 260 + .sync-gate-msg { 261 + display: inline-flex; align-items: center; gap: 0.4rem; 262 + color: #eab308; font-size: 0.8rem; font-weight: 600; 263 + } 264 + .incoming-offer-row { 265 + display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0; 266 + border-bottom: 1px solid var(--border); font-size: 0.8rem; 267 + } 268 + .incoming-offer-row:last-child { border-bottom: none; } 269 + .incoming-offer-info { flex: 1; min-width: 0; } 270 + .incoming-offer-actions { display: flex; gap: 0.3rem; flex-shrink: 0; } 271 + .sync-gate-spinner { 272 + display: inline-block; width: 10px; height: 10px; border: 2px solid #eab30844; 273 + border-top-color: #eab308; border-radius: 50%; animation: spin 0.8s linear infinite; 274 + } 275 + .consent-toggle { display: flex; align-items: center; gap: 0.4rem; margin-top: 0.5rem; font-size: 0.78rem; cursor: pointer; } 276 + .consent-toggle input[type="checkbox"] { cursor: pointer; } 277 + @media (prefers-color-scheme: dark) { 278 + .source-pds { background: #1e3a5f; color: #93c5fd; } 279 + .source-firehose { background: #422006; color: #fcd34d; } 280 + .source-peer_fallback { background: #4a1035; color: #f9a8d4; } 281 + .did-source-config { background: #312e81; color: #a5b4fc; } 282 + .did-source-user { background: #064e3b; color: #6ee7b7; } 283 + .did-source-policy { background: #422006; color: #fcd34d; } 284 + .did-source-offered { background: #2e1065; color: #c4b5fd; } 285 + .trigger-firehose { background: #422006; color: #fcd34d; } 286 + .trigger-firehose-resync { background: #431407; color: #fdba74; } 287 + .trigger-gossipsub { background: #2e1065; color: #c4b5fd; } 288 + .trigger-periodic { background: #1e3a5f; color: #93c5fd; } 289 + .trigger-manual { background: #064e3b; color: #6ee7b7; } 290 + .trigger-tombstone-recovery { background: #4a1035; color: #f9a8d4; } 291 + .trigger-gc { background: #1f2937; color: #9ca3af; } 292 + } 293 + p2p-confirm-dialog dialog { 294 + font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace; 295 + background: var(--card-bg); color: var(--fg); border: 1px solid var(--card-border); 296 + border-radius: 6px; padding: 1.2rem; max-width: 420px; font-size: 0.85rem; 297 + box-shadow: 0 4px 24px var(--shadow); 298 + position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); margin: 0; 299 + } 300 + p2p-confirm-dialog dialog::backdrop { background: rgba(0,0,0,0.4); } 301 + p2p-confirm-dialog .dialog-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1rem; } 302 + p2p-confirm-dialog .dialog-actions button { 303 + padding: 0.4rem 1rem; font-family: inherit; font-size: 0.82rem; 304 + border: 1px solid var(--fg); border-radius: 4px; cursor: pointer; 305 + background: var(--card-bg); color: var(--fg); transition: background 0.15s, color 0.15s, opacity 0.15s; 306 + } 307 + p2p-confirm-dialog .dialog-actions button.confirm-btn { 308 + background: var(--fg); color: var(--bg); 309 + } 310 + p2p-confirm-dialog .dialog-actions button:hover { opacity: 0.85; } 311 + `;
+16
src/ui/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "ES2022", 4 + "module": "ESNext", 5 + "moduleResolution": "bundler", 6 + "lib": ["ES2022", "DOM", "DOM.Iterable"], 7 + "experimentalDecorators": true, 8 + "useDefineForClassFields": false, 9 + "strict": true, 10 + "esModuleInterop": true, 11 + "skipLibCheck": true, 12 + "forceConsistentCasingInFileNames": true, 13 + "noEmit": true 14 + }, 15 + "include": ["./**/*.ts"] 16 + }
+2 -5
src/xrpc/app-e2e.test.ts
··· 282 282 283 283 const html = await res.text(); 284 284 expect(html).toContain("P2PDS"); 285 - expect(html).toContain('id="section-overview"'); 286 - expect(html).toContain('id="section-metrics"'); 287 - expect(html).toContain('id="section-replication"'); 288 - expect(html).toContain('id="section-sync-history"'); 289 - expect(html).toContain('id="section-network"'); 285 + expect(html).toContain("<p2p-app>"); 286 + expect(html).toContain("__P2PDS_TOKEN__"); 290 287 expect(html).toContain("test-auth-token"); 291 288 }); 292 289
+3 -10
src/xrpc/app.test.ts
··· 621 621 622 622 const html = await res.text(); 623 623 expect(html).toContain("P2PDS"); 624 - expect(html).toContain('id="section-overview"'); 625 - expect(html).toContain('id="section-metrics"'); 626 - expect(html).toContain('id="section-replication"'); 627 - expect(html).toContain('id="section-sync-history"'); 628 - expect(html).toContain('id="section-network"'); 629 - expect(html).toContain('id="section-policies"'); 630 - expect(html).toContain('id="section-verification"'); 631 - expect(html).toContain('id="add-did-input"'); 632 - expect(html).toContain('id="add-did-btn"'); 633 - expect(html).toContain("<script>"); 624 + expect(html).toContain("<p2p-app>"); 625 + expect(html).toContain("__P2PDS_TOKEN__"); 634 626 expect(html).toContain("test-auth-token"); 627 + expect(html).toContain('/static/ui/app.js'); 635 628 }); 636 629 }); 637 630
+81 -1233
src/xrpc/app.ts
··· 1 1 import type { Context } from "hono"; 2 + import { streamSSE } from "hono/streaming"; 2 3 import type { AppEnv, AuthedAppEnv } from "../types.js"; 3 4 import type { ReplicationManager } from "../replication/replication-manager.js"; 4 - import type { NetworkService } from "../ipfs.js"; 5 + import type { SyncProgressEvent } from "../replication/replication-manager.js"; 6 + import type { NetworkService, IpfsService } from "../ipfs.js"; 5 7 import type { PdsClientRef } from "../oauth/routes.js"; 6 8 import { CONSENT_NSID } from "../replication/types.js"; 7 9 ··· 116 118 const policyEngine = replicationManager.getPolicyEngine(); 117 119 const effectivePolicy = policyEngine ? policyEngine.evaluate(did) : null; 118 120 121 + const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); 122 + const transferred24h = didMetrics.recentSyncs 123 + .filter((s) => s.status === "success" && s.startedAt >= cutoff) 124 + .reduce((sum, s) => sum + (s.carBytes ?? 0) + (s.blobBytes ?? 0), 0); 125 + 119 126 return c.json({ 120 127 did, 121 128 syncState, ··· 124 131 blobCount: didMetrics.blobs, 125 132 recordCount: didMetrics.records, 126 133 bytesHeld: didMetrics.bytesHeld, 134 + transferred24h, 127 135 recentSyncs: didMetrics.recentSyncs, 128 136 triggerBreakdown, 129 137 peerEndpoints, ··· 132 140 }); 133 141 } 134 142 135 - export function getNetworkStatus( 143 + export async function getNetworkStatus( 136 144 c: Context<AuthedAppEnv>, 137 145 networkService: NetworkService | undefined, 138 - ): Response { 146 + ipfsService?: IpfsService, 147 + publicUrl?: string, 148 + ): Promise<Response> { 139 149 if (!networkService) { 140 150 return c.json({ 141 151 peerId: null, 142 152 multiaddrs: [], 143 153 connections: 0, 154 + dialability: null, 144 155 }); 145 156 } 146 157 158 + let dialability: { 159 + listeningAddrs: string[]; 160 + publicAddrs: string[]; 161 + natStatus: "unknown" | "public" | "private"; 162 + httpReachable: boolean | null; 163 + } | null = null; 164 + 165 + if (ipfsService) { 166 + dialability = await ipfsService.getDialability(publicUrl); 167 + } 168 + 147 169 return c.json({ 148 170 peerId: networkService.getPeerId(), 149 171 multiaddrs: networkService.getMultiaddrs(), 150 172 connections: networkService.getConnectionCount(), 173 + dialability, 151 174 }); 152 175 } 153 176 154 177 export function getApp( 155 178 c: Context<AppEnv>, 156 - networkService: NetworkService | undefined, 157 - replicationManager: ReplicationManager | undefined, 179 + _networkService: NetworkService | undefined, 180 + _replicationManager: ReplicationManager | undefined, 158 181 ): Response { 159 182 const authHeader = c.req.header("Authorization") ?? ""; 160 183 const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : c.env.AUTH_TOKEN; ··· 165 188 <meta charset="utf-8"> 166 189 <meta name="viewport" content="width=device-width, initial-scale=1"> 167 190 <title>P2PDS</title> 168 - <style> 169 - :root { 170 - --bg: #f0f0f0; --fg: #000; --card-bg: #fff; --card-border: transparent; 171 - --muted: #666; --faint: #999; --border: #eee; --row-hover: #f8f8f8; 172 - --metric-bg: #f8f8f8; --input-bg: #fff; --input-border: #ccc; 173 - --badge-bg: #000; --badge-fg: #fff; 174 - --detail-bg: #fafafa; --shadow: rgba(0,0,0,0.08); 175 - --acct-placeholder-bg: #e5e7eb; --selected-bg: #f8f8f8; --selected-border: #ddd; 176 - --dropdown-bg: #fff; --dropdown-border: #ddd; --dropdown-shadow: rgba(0,0,0,0.12); 177 - } 178 - @media (prefers-color-scheme: dark) { 179 - :root { 180 - --bg: #111; --fg: #e0e0e0; --card-bg: #1a1a1a; --card-border: #2a2a2a; 181 - --muted: #999; --faint: #666; --border: #2a2a2a; --row-hover: #222; 182 - --metric-bg: #222; --input-bg: #222; --input-border: #444; 183 - --badge-bg: #e0e0e0; --badge-fg: #111; 184 - --detail-bg: #1e1e1e; --shadow: rgba(0,0,0,0.3); 185 - --acct-placeholder-bg: #333; --selected-bg: #222; --selected-border: #444; 186 - --dropdown-bg: #1a1a1a; --dropdown-border: #333; --dropdown-shadow: rgba(0,0,0,0.4); 187 - } 188 - } 189 - * { margin: 0; padding: 0; box-sizing: border-box; } 190 - body { 191 - font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace; 192 - background: var(--bg); color: var(--fg); padding: 0.8rem; font-size: 13px; 193 - } 194 - header { 195 - display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; 196 - margin-bottom: 0.6rem; 197 - } 198 - header h1 { font-size: 1.1rem; letter-spacing: 0.1em; } 199 - .badge { font-size: 0.65rem; background: var(--badge-bg); color: var(--badge-fg); padding: 1px 6px; border-radius: 3px; } 200 - .meta { margin-left: auto; font-size: 0.7rem; color: var(--muted); display: flex; gap: 0.8rem; align-items: center; } 201 - .meta label { cursor: pointer; } 202 - .top-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; } 203 - .card { 204 - background: var(--card-bg); border: 1px solid var(--card-border); border-radius: 4px; 205 - padding: 0.6rem 0.8rem; margin-bottom: 0.5rem; 206 - box-shadow: 0 1px 2px var(--shadow); 207 - } 208 - .card h2 { font-size: 0.85rem; margin-bottom: 0.4rem; border-bottom: 1px solid var(--border); padding-bottom: 0.2rem; } 209 - .card details { margin-bottom: 0; } 210 - .card details > summary { font-size: 0.85rem; font-weight: 700; cursor: pointer; user-select: none; border-bottom: 1px solid var(--border); padding-bottom: 0.2rem; margin-bottom: 0.4rem; list-style: none; display: flex; align-items: center; gap: 0.3rem; } 211 - .card details > summary::-webkit-details-marker { display: none; } 212 - .card details > summary::before { content: "\\25B6"; font-size: 0.55rem; transition: transform 0.15s; display: inline-block; } 213 - .card details[open] > summary::before { transform: rotate(90deg); } 214 - .card details > summary .count-badge { font-weight: 400; font-size: 0.7rem; color: var(--muted); margin-left: auto; } 215 - .scroll-container { max-height: 240px; overflow-y: auto; } 216 - .kv { display: grid; grid-template-columns: 100px 1fr; gap: 0.15rem 0.6rem; font-size: 0.8rem; } 217 - .kv dt { color: var(--muted); } 218 - .kv dd { word-break: break-all; } 219 - .metrics-grid { 220 - display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: 0.4rem; font-size: 0.8rem; 221 - } 222 - .metric-box { 223 - background: var(--metric-bg); border-radius: 3px; padding: 0.35rem 0.4rem; text-align: center; 224 - } 225 - .metric-box .value { font-size: 1rem; font-weight: 700; } 226 - .metric-box .label { color: var(--muted); font-size: 0.65rem; margin-top: 0.1rem; } 227 - table { width: 100%; border-collapse: collapse; font-size: 0.78rem; } 228 - th { text-align: left; padding: 0.25rem 0.4rem; border-bottom: 2px solid var(--border); color: var(--muted); font-weight: 600; } 229 - td { padding: 0.25rem 0.4rem; border-bottom: 1px solid var(--border); } 230 - tr.clickable { cursor: pointer; } 231 - tr.clickable:hover { background: var(--row-hover); } 232 - .dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 4px; vertical-align: middle; } 233 - .dot-synced { background: #22c55e; } 234 - .dot-syncing { background: #eab308; } 235 - .dot-pending { background: #9ca3af; } 236 - .dot-error { background: #ef4444; } 237 - .dot-offered { background: #8b5cf6; } 238 - .detail-row td { padding: 0.5rem; background: var(--detail-bg); font-size: 0.75rem; } 239 - .detail-inner table { margin-top: 0.3rem; } 240 - .source-badge { font-size: 0.65rem; padding: 1px 5px; border-radius: 3px; } 241 - .source-pds { background: #dbeafe; color: #1e40af; } 242 - .source-firehose { background: #fef3c7; color: #92400e; } 243 - .source-peer_fallback { background: #fce7f3; color: #9d174d; } 244 - .trigger-badge { font-size: 0.65rem; padding: 1px 5px; border-radius: 3px; } 245 - .trigger-firehose { background: #fef3c7; color: #92400e; } 246 - .trigger-firehose-resync { background: #ffedd5; color: #9a3412; } 247 - .trigger-gossipsub { background: #ede9fe; color: #5b21b6; } 248 - .trigger-periodic { background: #dbeafe; color: #1e40af; } 249 - .trigger-manual { background: #d1fae5; color: #065f46; } 250 - .trigger-tombstone-recovery { background: #fce7f3; color: #9d174d; } 251 - .trigger-gc { background: #f3f4f6; color: #374151; } 252 - .trigger-unknown { background: var(--metric-bg); color: var(--muted); } 253 - .policy-list { list-style: none; font-size: 0.8rem; } 254 - .policy-list li { padding: 0.25rem 0; border-bottom: 1px solid var(--border); } 255 - .verify-pass { color: #22c55e; font-weight: 600; } 256 - .verify-fail { color: #ef4444; font-weight: 600; } 257 - .loading { color: var(--faint); font-style: italic; } 258 - .add-did-form { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.4rem; } 259 - .add-did-form input { 260 - flex: 1; padding: 0.3rem 0.5rem; font-family: inherit; font-size: 0.8rem; 261 - border: 1px solid var(--input-border); border-radius: 3px; outline: none; 262 - background: var(--input-bg); color: var(--fg); 263 - } 264 - .add-did-form input:focus { border-color: var(--fg); } 265 - .add-did-form button, .btn-remove { 266 - padding: 0.15rem 0.4rem; font-family: inherit; font-size: 0.7rem; 267 - border: 1px solid var(--fg); border-radius: 3px; cursor: pointer; 268 - background: var(--card-bg); color: var(--fg); 269 - } 270 - .add-did-form input:disabled { opacity: 0.4; cursor: not-allowed; } 271 - .add-did-form button:disabled { opacity: 0.4; cursor: not-allowed; } 272 - .add-did-form button:hover:not(:disabled) { background: var(--fg); color: var(--bg); } 273 - .btn-remove { border-color: #ef4444; color: #ef4444; padding: 0.15rem 0.4rem; font-size: 0.7rem; } 274 - .btn-remove:hover { background: #ef4444; color: #fff; } 275 - .did-source { font-size: 0.65rem; padding: 1px 5px; border-radius: 3px; } 276 - .did-source-config { background: #e0e7ff; color: #3730a3; } 277 - .did-source-user { background: #d1fae5; color: #065f46; } 278 - .did-source-policy { background: #fef3c7; color: #92400e; } 279 - .did-source-unknown { background: var(--metric-bg); color: var(--muted); } 280 - .did-source-offered { background: #ede9fe; color: #5b21b6; } 281 - .add-did-error { color: #ef4444; font-size: 0.78rem; margin-bottom: 0.3rem; min-height: 1em; } 282 - .add-did-success { color: #22c55e; font-size: 0.78rem; margin-bottom: 0.3rem; min-height: 1em; } 283 - .account-search-wrap { position: relative; } 284 - .account-search-wrap input { 285 - width: 100%; padding: 0.3rem 0.5rem 0.3rem 1.6rem; font-family: inherit; font-size: 0.8rem; 286 - border: 1px solid var(--input-border); border-radius: 3px; outline: none; 287 - background: var(--input-bg); color: var(--fg); 288 - } 289 - .account-search-wrap input:focus { border-color: var(--fg); box-shadow: 0 0 0 1px var(--border); } 290 - .account-search-icon { 291 - position: absolute; left: 0.4rem; top: 50%; transform: translateY(-50%); 292 - color: var(--faint); font-size: 0.8rem; pointer-events: none; line-height: 1; 293 - } 294 - .account-results { 295 - position: absolute; top: calc(100% + 3px); left: 0; right: 0; z-index: 100; 296 - background: var(--dropdown-bg); border: 1px solid var(--dropdown-border); border-radius: 4px; 297 - box-shadow: 0 3px 12px var(--dropdown-shadow); max-height: 240px; overflow-y: auto; 298 - display: none; 299 - } 300 - .account-results.visible { display: block; } 301 - .account-result-item { 302 - display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.5rem; 303 - cursor: pointer; border-bottom: 1px solid var(--border); transition: background 0.1s; 304 - } 305 - .account-result-item:last-child { border-bottom: none; } 306 - .account-result-item:hover, .account-result-item.active { background: var(--row-hover); } 307 - .account-result-avatar { 308 - width: 26px; height: 26px; border-radius: 50%; background: var(--acct-placeholder-bg); 309 - flex-shrink: 0; object-fit: cover; 310 - } 311 - .account-result-avatar-placeholder { 312 - width: 26px; height: 26px; border-radius: 50%; background: var(--acct-placeholder-bg); 313 - flex-shrink: 0; display: flex; align-items: center; justify-content: center; 314 - color: var(--faint); font-size: 0.65rem; font-weight: 600; 315 - } 316 - .account-result-info { flex: 1; min-width: 0; } 317 - .account-result-name { font-size: 0.8rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 318 - .account-result-handle { font-size: 0.7rem; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 319 - .account-selected { 320 - display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.6rem; 321 - background: var(--selected-bg); border: 1px solid var(--selected-border); border-radius: 4px; 322 - } 323 - .account-selected-info { flex: 1; min-width: 0; } 324 - .account-selected-name { font-size: 0.8rem; font-weight: 600; } 325 - .account-selected-handle { font-size: 0.7rem; color: var(--muted); } 326 - .account-selected-clear { 327 - padding: 0.15rem 0.4rem; font-family: inherit; font-size: 0.7rem; 328 - border: 1px solid var(--input-border); border-radius: 3px; cursor: pointer; 329 - background: var(--card-bg); color: var(--muted); flex-shrink: 0; 330 - } 331 - .account-selected-clear:hover { border-color: var(--muted); color: var(--fg); } 332 - .account-connect-btn { 333 - padding: 0.35rem 0.8rem; font-family: inherit; font-size: 0.8rem; 334 - border: 1px solid var(--fg); border-radius: 3px; cursor: pointer; 335 - background: var(--fg); color: var(--bg); font-weight: 600; transition: opacity 0.15s; 336 - } 337 - .account-connect-btn:hover { opacity: 0.85; } 338 - .account-connect-btn:disabled { opacity: 0.35; cursor: not-allowed; } 339 - .account-no-results { padding: 0.5rem; text-align: center; color: var(--faint); font-size: 0.78rem; } 340 - .account-searching { padding: 0.5rem; text-align: center; color: var(--faint); font-size: 0.78rem; } 341 - .per-acct-metrics { display: inline-flex; gap: 0.6rem; font-size: 0.7rem; color: var(--muted); margin-top: 2px; } 342 - .per-acct-metrics span { white-space: nowrap; } 343 - @keyframes spin { to { transform: rotate(360deg); } } 344 - .activity-spinner { 345 - display: inline-block; width: 12px; height: 12px; border: 2px solid var(--border); 346 - border-top-color: var(--fg); border-radius: 50%; vertical-align: middle; 347 - animation: spin 0.8s linear infinite; opacity: 0; transition: opacity 0.2s; 348 - } 349 - .activity-spinner.active { opacity: 1; } 350 - .sync-gate-msg { 351 - display: inline-flex; align-items: center; gap: 0.4rem; 352 - color: #eab308; font-size: 0.8rem; font-weight: 600; 353 - } 354 - .btn-accept { 355 - padding: 0.2rem 0.5rem; font-family: inherit; font-size: 0.72rem; 356 - border: 1px solid #22c55e; border-radius: 3px; cursor: pointer; 357 - background: var(--card-bg); color: #22c55e; 358 - } 359 - .btn-accept:hover { background: #22c55e; color: #fff; } 360 - .btn-reject { 361 - padding: 0.2rem 0.5rem; font-family: inherit; font-size: 0.72rem; 362 - border: 1px solid #ef4444; border-radius: 3px; cursor: pointer; 363 - background: var(--card-bg); color: #ef4444; 364 - } 365 - .btn-reject:hover { background: #ef4444; color: #fff; } 366 - .incoming-offer-row { 367 - display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0; 368 - border-bottom: 1px solid var(--border); font-size: 0.8rem; 369 - } 370 - .incoming-offer-row:last-child { border-bottom: none; } 371 - .incoming-offer-info { flex: 1; min-width: 0; } 372 - .incoming-offer-actions { display: flex; gap: 0.3rem; flex-shrink: 0; } 373 - .sync-gate-spinner { 374 - display: inline-block; width: 10px; height: 10px; border: 2px solid #eab30844; 375 - border-top-color: #eab308; border-radius: 50%; animation: spin 0.8s linear infinite; 376 - } 377 - .policy-selector { margin: 0.5rem 0 0.3rem; display: flex; flex-direction: column; gap: 0; } 378 - .policy-option { 379 - display: flex; align-items: flex-start; gap: 0.4rem; padding: 0.4rem 0.5rem; 380 - cursor: pointer; font-size: 0.78rem; border: 1px solid var(--border); 381 - border-bottom: none; transition: background 0.1s; 382 - } 383 - .policy-option:first-child { border-radius: 4px 4px 0 0; } 384 - .policy-option:last-child { border-bottom: 1px solid var(--border); border-radius: 0 0 4px 4px; } 385 - .policy-option:hover { background: var(--row-hover); } 386 - .policy-option.selected { background: var(--selected-bg); } 387 - .policy-option.disabled { opacity: 0.45; cursor: not-allowed; } 388 - .policy-option.disabled:hover { background: transparent; } 389 - .policy-option input[type="radio"] { margin-top: 2px; flex-shrink: 0; } 390 - .policy-option .policy-label { font-weight: 600; } 391 - .policy-option .policy-desc { color: var(--muted); font-size: 0.7rem; } 392 - .policy-option .policy-note { color: #eab308; font-size: 0.65rem; font-style: italic; } 393 - .consent-toggle { display: flex; align-items: center; gap: 0.4rem; margin-top: 0.5rem; font-size: 0.78rem; cursor: pointer; } 394 - .consent-toggle input[type="checkbox"] { cursor: pointer; } 395 - @media (prefers-color-scheme: dark) { 396 - .source-pds { background: #1e3a5f; color: #93c5fd; } 397 - .source-firehose { background: #422006; color: #fcd34d; } 398 - .source-peer_fallback { background: #4a1035; color: #f9a8d4; } 399 - .did-source-config { background: #312e81; color: #a5b4fc; } 400 - .did-source-user { background: #064e3b; color: #6ee7b7; } 401 - .did-source-policy { background: #422006; color: #fcd34d; } 402 - .did-source-offered { background: #2e1065; color: #c4b5fd; } 403 - .trigger-firehose { background: #422006; color: #fcd34d; } 404 - .trigger-firehose-resync { background: #431407; color: #fdba74; } 405 - .trigger-gossipsub { background: #2e1065; color: #c4b5fd; } 406 - .trigger-periodic { background: #1e3a5f; color: #93c5fd; } 407 - .trigger-manual { background: #064e3b; color: #6ee7b7; } 408 - .trigger-tombstone-recovery { background: #4a1035; color: #f9a8d4; } 409 - .trigger-gc { background: #1f2937; color: #9ca3af; } 410 - } 411 - </style> 412 191 </head> 413 192 <body> 414 - <header> 415 - <h1>P2PDS</h1> 416 - <span class="badge" id="version-badge">v${VERSION}</span> 417 - <div class="meta"> 418 - <span class="activity-spinner" id="activity-spinner"></span> 419 - <span id="last-refresh">-</span> 420 - <label><input type="checkbox" id="auto-refresh" checked> auto-refresh</label> 421 - </div> 422 - </header> 423 - 424 - <div class="top-row"> 425 - <section class="card" id="section-overview"> 426 - <h2>System</h2> 427 - <div id="overview-content" class="loading">Loading...</div> 428 - </section> 429 - <section class="card" id="section-account"> 430 - <h2>Account</h2> 431 - <div id="account-content" class="loading">Loading...</div> 432 - </section> 433 - </div> 434 - 435 - <section class="card" id="section-incoming-offers" style="display:none"> 436 - <details open> 437 - <summary>Incoming Offers <span class="count-badge" id="incoming-offers-count"></span></summary> 438 - <div class="scroll-container" id="incoming-offers-content"></div> 439 - </details> 440 - </section> 441 - 442 - <section class="card" id="section-metrics"> 443 - <h2>Replication Summary</h2> 444 - <div id="metrics-content" class="loading">Loading...</div> 445 - </section> 446 - 447 - <section class="card" id="section-replication"> 448 - <h2>Replications</h2> 449 - <div class="add-did-form"> 450 - <div class="account-search-wrap" id="did-search-wrap" style="flex:1"> 451 - <span class="account-search-icon">&#128269;</span> 452 - <input type="text" id="add-did-input" placeholder="Search account or paste did:plc:..." autocomplete="off" spellcheck="false"> 453 - <div class="account-results" id="did-search-results"></div> 454 - </div> 455 - <div id="add-did-selected" style="display:none;flex:1"></div> 456 - <button id="add-did-btn">Add</button> 457 - </div> 458 - <div id="add-did-msg" class="add-did-error"></div> 459 - <div class="scroll-container" id="replication-content" class="loading">Loading...</div> 460 - </section> 461 - 462 - <section class="card" id="section-sync-history"> 463 - <details> 464 - <summary>Sync History <span class="count-badge" id="sync-history-count"></span></summary> 465 - <div class="scroll-container" id="sync-history-content" class="loading">Loading...</div> 466 - </details> 467 - </section> 468 - 469 - <section class="card" id="section-network"> 470 - <h2>Network</h2> 471 - <div id="network-content" class="loading">Loading...</div> 472 - </section> 473 - 474 - <section class="card" id="section-policies"> 475 - <h2>Policies</h2> 476 - <div id="policies-content" class="loading">Loading...</div> 477 - </section> 478 - 479 - <section class="card" id="section-verification"> 480 - <h2>Verification</h2> 481 - <div id="verification-content" class="loading">Loading...</div> 482 - </section> 483 - 484 - <script> 485 - const TOKEN = ${JSON.stringify(token)}; 486 - const HEADERS = { "Authorization": "Bearer " + TOKEN }; 487 - 488 - function esc(s) { const d = document.createElement("div"); d.textContent = String(s ?? "-"); return d.innerHTML; } 489 - 490 - function formatBytes(n) { 491 - if (n == null || n === 0) return "0 B"; 492 - var units = ["B", "KB", "MB", "GB", "TB"]; 493 - var i = Math.floor(Math.log(n) / Math.log(1024)); 494 - if (i >= units.length) i = units.length - 1; 495 - return (n / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + " " + units[i]; 496 - } 497 - 498 - function formatNumber(n) { 499 - if (n == null) return "-"; 500 - return Number(n).toLocaleString(); 501 - } 502 - 503 - function timeAgo(iso) { 504 - if (!iso) return "-"; 505 - var diff = Date.now() - new Date(iso + "Z").getTime(); 506 - if (diff < 0) diff = 0; 507 - if (diff < 60000) return Math.floor(diff / 1000) + "s ago"; 508 - if (diff < 3600000) return Math.floor(diff / 60000) + "m ago"; 509 - if (diff < 86400000) return Math.floor(diff / 3600000) + "h ago"; 510 - return Math.floor(diff / 86400000) + "d ago"; 511 - } 512 - 513 - async function apiFetch(endpoint, params) { 514 - const url = new URL("/xrpc/" + endpoint, location.origin); 515 - if (params) Object.entries(params).forEach(([k,v]) => url.searchParams.set(k, v)); 516 - const res = await fetch(url, { headers: HEADERS }); 517 - return res.json(); 518 - } 519 - 520 - function statusDot(status) { 521 - const cls = status === "synced" ? "dot-synced" 522 - : status === "syncing" ? "dot-syncing" 523 - : status === "error" ? "dot-error" 524 - : status === "offered" ? "dot-offered" 525 - : "dot-pending"; 526 - return '<span class="dot ' + cls + '"></span>' + esc(status); 527 - } 528 - 529 - function sourceBadge(sourceType) { 530 - var cls = "source-" + (sourceType || "pds"); 531 - return '<span class="source-badge ' + cls + '">' + esc(sourceType || "pds") + '</span>'; 532 - } 533 - 534 - function triggerBadge(trigger) { 535 - var t = trigger || "unknown"; 536 - var cls = "trigger-" + t; 537 - return '<span class="trigger-badge ' + cls + '">' + esc(t) + '</span>'; 538 - } 539 - 540 - function didSourceBadge(source) { 541 - var cls = "did-source did-source-" + (source || "unknown"); 542 - return '<span class="' + cls + '">' + esc(source || "unknown") + '</span>'; 543 - } 544 - 545 - async function apiPost(endpoint, body) { 546 - const url = new URL("/xrpc/" + endpoint, location.origin); 547 - const res = await fetch(url, { 548 - method: "POST", 549 - headers: { ...HEADERS, "Content-Type": "application/json" }, 550 - body: JSON.stringify(body), 551 - }); 552 - return res.json(); 553 - } 554 - 555 - var cachedAccountStatus = null; 556 - 557 - function renderOverview(data) { 558 - const el = document.getElementById("overview-content"); 559 - const net = data.network || {}; 560 - el.innerHTML = '<dl class="kv">' 561 - + "<dt>DID</dt><dd>" + esc(data.did) + "</dd>" 562 - + "<dt>Handle</dt><dd>" + esc(data.handle) + "</dd>" 563 - + "<dt>Peer ID</dt><dd>" + esc(net.peerId) + "</dd>" 564 - + "<dt>Connections</dt><dd>" + esc(net.peerId ? (net.connections ?? 0) : null) + "</dd>" 565 - + "</dl>"; 566 - } 567 - 568 - function renderMetrics(data) { 569 - const el = document.getElementById("metrics-content"); 570 - const repl = data.replication; 571 - if (!repl || !repl.enabled) { el.innerHTML = "Replication disabled"; return; } 572 - const a = repl.aggregate || {}; 573 - el.innerHTML = '<div class="metrics-grid">' 574 - + '<div class="metric-box"><div class="value">' + formatNumber(a.totalDids) + '</div><div class="label">Tracked DIDs</div></div>' 575 - + '<div class="metric-box"><div class="value">' + formatNumber(a.totalRecords) + '</div><div class="label">Records</div></div>' 576 - + '<div class="metric-box"><div class="value">' + formatNumber(a.totalBlocks) + '</div><div class="label">Blocks</div></div>' 577 - + '<div class="metric-box"><div class="value">' + formatNumber(a.totalBlobs) + '</div><div class="label">Blobs</div></div>' 578 - + '<div class="metric-box"><div class="value">' + formatBytes(a.totalBytesHeld) + '</div><div class="label">Total Held</div></div>' 579 - + '<div class="metric-box"><div class="value">' + formatBytes(a.recentTransferredBytes) + '</div><div class="label">Transferred (24h)</div></div>' 580 - + '<div class="metric-box"><div class="value">' + formatNumber(a.totalSyncs) + '</div><div class="label">Total Syncs</div></div>' 581 - + '</div>'; 582 - } 583 - 584 - var profileCache = {}; 585 - 586 - function displayName(did) { 587 - var p = profileCache[did]; 588 - if (p && p.handle) return "@" + p.handle; 589 - return did; 590 - } 591 - 592 - function fetchProfile(did) { 593 - if (profileCache[did]) return Promise.resolve(profileCache[did]); 594 - return fetch("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=" + encodeURIComponent(did)) 595 - .then(function(r) { return r.ok ? r.json() : null; }) 596 - .then(function(p) { if (p) profileCache[did] = p; return p; }) 597 - .catch(function() { return null; }); 598 - } 599 - 600 - function renderAccountCell(did, profile) { 601 - if (!profile) return esc(did); 602 - var av = profile.avatar 603 - ? '<img src="' + esc(profile.avatar) + '" alt="" style="width:20px;height:20px;border-radius:50%;vertical-align:middle;margin-right:0.3rem">' 604 - : '<span style="display:inline-block;width:20px;height:20px;border-radius:50%;background:var(--acct-placeholder-bg);text-align:center;line-height:20px;font-size:0.6rem;vertical-align:middle;margin-right:0.3rem">' 605 - + esc((profile.handle || did)[0].toUpperCase()) + '</span>'; 606 - return av 607 - + '<strong style="font-size:0.78rem">' + esc(profile.displayName || profile.handle) + '</strong>' 608 - + ' <span style="color:var(--muted);font-size:0.72rem">@' + esc(profile.handle) + '</span>' 609 - + '<div style="color:var(--faint);font-size:0.65rem;margin-top:1px">' + esc(did) + '</div>'; 610 - } 611 - 612 - function renderReplication(data) { 613 - const el = document.getElementById("replication-content"); 614 - const repl = data.replication; 615 - if (!repl || !repl.enabled) { el.innerHTML = "Replication disabled"; return; } 616 - const states = repl.syncStates || []; 617 - const sources = repl.didSources || {}; 618 - const offered = data.offeredDids || []; 619 - if (states.length === 0 && offered.length === 0 && !data.nodeDid) { el.innerHTML = "No tracked DIDs"; return; } 620 - if (states.length === 0 && offered.length === 0) { el.innerHTML = "Syncing…"; return; } 621 - let html = "<table><thead><tr><th>Account</th><th>Source</th><th>Status</th><th>Records</th><th>Blocks</th><th>Held</th><th>Last Sync</th><th></th></tr></thead><tbody>"; 622 - for (const s of states) { 623 - const st = s.status || "pending"; 624 - const src = sources[s.did] || "unknown"; 625 - const rid = "detail-" + s.did.replace(/[^a-zA-Z0-9]/g, "_"); 626 - var cellId = "acct-" + s.did.replace(/[^a-zA-Z0-9]/g, "_"); 627 - var metricsId = "metrics-" + s.did.replace(/[^a-zA-Z0-9]/g, "_"); 628 - html += '<tr class="clickable" data-did="' + esc(s.did) + '" data-rid="' + rid + '">' 629 - + '<td id="' + cellId + '" style="min-width:180px">' + esc(s.did) + '</td>' 630 - + "<td>" + didSourceBadge(src) + "</td>" 631 - + "<td>" + statusDot(st) + "</td>" 632 - + '<td id="' + metricsId + '-rec">-</td>' 633 - + '<td id="' + metricsId + '-blk">-</td>' 634 - + '<td id="' + metricsId + '-bytes">-</td>' 635 - + "<td>" + timeAgo(s.lastSyncAt) + "</td>" 636 - + "<td>" + (src === "user" && s.did !== data.did ? '<button class="btn-remove btn-revoke-replication" data-did="' + esc(s.did) + '">Revoke</button>' : "") + "</td>" 637 - + "</tr>"; 638 - html += '<tr class="detail-row" id="' + rid + '" style="display:none"><td colspan="8"><div class="detail-inner loading">Click to load...</div></td></tr>'; 639 - } 640 - // Render offered DIDs (awaiting mutual consent) 641 - for (const o of offered) { 642 - var oCellId = "acct-" + o.did.replace(/[^a-zA-Z0-9]/g, "_"); 643 - html += '<tr>' 644 - + '<td id="' + oCellId + '" style="min-width:180px">' + esc(o.did) + '</td>' 645 - + "<td>" + didSourceBadge("offered") + "</td>" 646 - + "<td>" + statusDot("offered") + "</td>" 647 - + "<td>-</td><td>-</td><td>-</td>" 648 - + "<td>" + timeAgo(o.offeredAt) + "</td>" 649 - + '<td><button class="btn-remove btn-remove-offer" data-did="' + esc(o.did) + '">Revoke</button></td>' 650 - + "</tr>"; 651 - } 652 - html += "</tbody></table>"; 653 - el.innerHTML = html; 654 - 655 - // Async profile + per-account metrics resolution 656 - for (const s of states) { 657 - (function(did) { 658 - var cellId = "acct-" + did.replace(/[^a-zA-Z0-9]/g, "_"); 659 - var metricsId = "metrics-" + did.replace(/[^a-zA-Z0-9]/g, "_"); 660 - fetchProfile(did).then(function(p) { 661 - var cell = document.getElementById(cellId); 662 - if (cell && p) cell.innerHTML = renderAccountCell(did, p); 663 - }); 664 - apiFetch("org.p2pds.app.getDidStatus", { did: did }).then(function(d) { 665 - var recEl = document.getElementById(metricsId + "-rec"); 666 - var blkEl = document.getElementById(metricsId + "-blk"); 667 - var bytesEl = document.getElementById(metricsId + "-bytes"); 668 - if (recEl) recEl.textContent = formatNumber(d.recordCount); 669 - if (blkEl) blkEl.textContent = formatNumber(d.blockCount); 670 - if (bytesEl) bytesEl.textContent = formatBytes(d.bytesHeld); 671 - }).catch(function() {}); 672 - })(s.did); 673 - } 674 - 675 - // Async profile resolution for offered DIDs 676 - for (const o of offered) { 677 - (function(did) { 678 - var cellId = "acct-" + did.replace(/[^a-zA-Z0-9]/g, "_"); 679 - fetchProfile(did).then(function(p) { 680 - var cell = document.getElementById(cellId); 681 - if (cell && p) cell.innerHTML = renderAccountCell(did, p); 682 - }); 683 - })(o.did); 684 - } 685 - 686 - el.querySelectorAll("tr.clickable").forEach(function(row) { 687 - row.addEventListener("click", async function() { 688 - const did = this.dataset.did; 689 - const detailRow = document.getElementById(this.dataset.rid); 690 - if (detailRow.style.display === "none") { 691 - detailRow.style.display = ""; 692 - const inner = detailRow.querySelector(".detail-inner"); 693 - inner.innerHTML = "Loading..."; 694 - try { 695 - const d = await apiFetch("org.p2pds.app.getDidStatus", { did: did }); 696 - let h = '<div class="metrics-grid" style="margin-bottom:0.6rem">' 697 - + '<div class="metric-box"><div class="value">' + formatNumber(d.recordCount) + '</div><div class="label">Records</div></div>' 698 - + '<div class="metric-box"><div class="value">' + formatNumber(d.blockCount) + '</div><div class="label">Blocks</div></div>' 699 - + '<div class="metric-box"><div class="value">' + formatNumber(d.blobCount) + '</div><div class="label">Blobs</div></div>' 700 - + '<div class="metric-box"><div class="value">' + formatBytes(d.bytesHeld) + '</div><div class="label">Held</div></div>' 701 - + '</div>'; 702 - var tb = d.triggerBreakdown || {}; 703 - var tbParts = []; 704 - for (var tk in tb) { if (tb.hasOwnProperty(tk)) tbParts.push(triggerBadge(tk) + ' \\u00d7' + tb[tk]); } 705 - if (tbParts.length > 0) { 706 - h += '<div style="margin-bottom:0.4rem;font-size:0.75rem;color:var(--muted)">Last 20 syncs: ' + tbParts.join(', ') + '</div>'; 707 - } 708 - var syncs = d.recentSyncs || []; 709 - if (syncs.length > 0) { 710 - h += '<table><thead><tr><th>Time</th><th>Source</th><th>Trigger</th><th>Status</th><th>Blocks+</th><th>Duration</th><th>Bytes</th></tr></thead><tbody>'; 711 - for (var i = 0; i < syncs.length; i++) { 712 - var sy = syncs[i]; 713 - h += '<tr><td>' + timeAgo(sy.startedAt) + '</td>' 714 - + '<td>' + sourceBadge(sy.sourceType) + '</td>' 715 - + '<td>' + triggerBadge(sy.trigger) + '</td>' 716 - + '<td>' + esc(sy.status) + '</td>' 717 - + '<td>' + formatNumber(sy.blocksAdded) + '</td>' 718 - + '<td>' + (sy.durationMs != null ? sy.durationMs + 'ms' : '-') + '</td>' 719 - + '<td>' + formatBytes((sy.carBytes || 0) + (sy.blobBytes || 0)) + '</td>' 720 - + '</tr>'; 721 - } 722 - h += '</tbody></table>'; 723 - } 724 - inner.innerHTML = h; 725 - inner.classList.remove("loading"); 726 - } catch (e) { inner.textContent = "Error: " + e.message; } 727 - } else { 728 - detailRow.style.display = "none"; 729 - } 730 - }); 731 - }); 732 - 733 - // Revoke replication button handlers (stop propagation to prevent row click) 734 - el.querySelectorAll(".btn-revoke-replication").forEach(function(btn) { 735 - btn.addEventListener("click", async function(e) { 736 - e.stopPropagation(); 737 - var did = this.dataset.did; 738 - if (!confirm("Revoke replication for " + displayName(did) + "?\\n\\nThis will revoke your offer, purge all stored data, and notify the peer.")) return; 739 - var msgEl = document.getElementById("add-did-msg"); 740 - try { 741 - var result = await apiPost("org.p2pds.app.revokeReplication", { did: did }); 742 - if (result.error) { msgEl.className = "add-did-error"; msgEl.textContent = result.message || result.error; } 743 - else { msgEl.className = "add-did-success"; msgEl.textContent = "Revoked replication for " + displayName(did); refresh(); } 744 - } catch (err) { msgEl.className = "add-did-error"; msgEl.textContent = "Error: " + err.message; } 745 - }); 746 - }); 747 - 748 - // Revoke offer button handlers 749 - el.querySelectorAll(".btn-remove-offer").forEach(function(btn) { 750 - btn.addEventListener("click", async function(e) { 751 - e.stopPropagation(); 752 - var did = this.dataset.did; 753 - if (!confirm("Revoke offer for " + displayName(did) + "?")) return; 754 - var msgEl = document.getElementById("add-did-msg"); 755 - try { 756 - var result = await apiPost("org.p2pds.app.removeOfferedDid", { did: did }); 757 - if (result.error) { msgEl.className = "add-did-error"; msgEl.textContent = result.message || result.error; } 758 - else { msgEl.className = "add-did-success"; msgEl.textContent = "Revoked offer for " + displayName(did); refresh(); } 759 - } catch (err) { msgEl.className = "add-did-error"; msgEl.textContent = "Error: " + err.message; } 760 - }); 761 - }); 762 - } 763 - 764 - function renderSyncHistory(history) { 765 - const el = document.getElementById("sync-history-content"); 766 - var countBadge = document.getElementById("sync-history-count"); 767 - var items = (history && history.history) || []; 768 - if (countBadge) countBadge.textContent = items.length > 0 ? "(" + items.length + ")" : ""; 769 - if (items.length === 0) { el.innerHTML = "No sync events recorded"; return; } 770 - let html = '<table><thead><tr><th>Time</th><th>DID</th><th>Source</th><th>Trigger</th><th>Status</th><th>Blocks+</th><th>Duration</th><th>Bytes</th></tr></thead><tbody>'; 771 - for (var i = 0; i < items.length; i++) { 772 - var s = items[i]; 773 - html += '<tr>' 774 - + '<td>' + timeAgo(s.startedAt) + '</td>' 775 - + '<td>' + esc(s.did) + '</td>' 776 - + '<td>' + sourceBadge(s.sourceType) + '</td>' 777 - + '<td>' + triggerBadge(s.trigger) + '</td>' 778 - + '<td>' + esc(s.status) + '</td>' 779 - + '<td>' + formatNumber(s.blocksAdded) + '</td>' 780 - + '<td>' + (s.durationMs != null ? s.durationMs + 'ms' : '-') + '</td>' 781 - + '<td>' + formatBytes((s.carBytes || 0) + (s.blobBytes || 0)) + '</td>' 782 - + '</tr>'; 783 - } 784 - html += '</tbody></table>'; 785 - el.innerHTML = html; 786 - } 787 - 788 - function renderNetwork(data) { 789 - const el = document.getElementById("network-content"); 790 - if (!data.peerId) { 791 - el.innerHTML = '<span style="color:var(--faint)">Networking disabled (local blockstore only)</span>'; 792 - return; 793 - } 794 - el.innerHTML = '<dl class="kv">' 795 - + "<dt>Peer ID</dt><dd>" + esc(data.peerId) + "</dd>" 796 - + "<dt>Connections</dt><dd>" + esc(data.connections) + "</dd>" 797 - + "<dt>Multiaddrs</dt><dd>" + (data.multiaddrs || []).map(function(m) { return esc(m); }).join("<br>") + "</dd>" 798 - + "</dl>"; 799 - } 800 - 801 - function renderPolicies(data) { 802 - const el = document.getElementById("policies-content"); 803 - if (!data.enabled) { el.innerHTML = "Policy engine disabled"; return; } 804 - const ps = data.policySet; 805 - let html = '<dl class="kv"><dt>Enabled</dt><dd>Yes</dd>' 806 - + "<dt>Explicit DIDs</dt><dd>" + esc((data.explicitDids || []).join(", ") || "none") + "</dd></dl>"; 807 - if (ps && ps.policies && ps.policies.length > 0) { 808 - html += '<ul class="policy-list">'; 809 - for (const p of ps.policies) { 810 - html += "<li><strong>" + esc(p.name || p.id) + "</strong>" 811 - + " &mdash; target: " + esc(p.target.type) 812 - + ", priority: " + esc(p.priority) 813 - + ", enabled: " + esc(p.enabled) + "</li>"; 814 - } 815 - html += "</ul>"; 816 - } 817 - el.innerHTML = html; 818 - } 819 - 820 - function renderVerification(data) { 821 - const el = document.getElementById("verification-content"); 822 - const results = (data.verification && data.verification.results) || []; 823 - if (results.length === 0) { el.innerHTML = "No verification results"; return; } 824 - let html = "<table><thead><tr><th>DID</th><th>L0 Commit</th><th>L1 Sampling</th><th>Last Check</th></tr></thead><tbody>"; 825 - for (const r of results) { 826 - const l0 = r.layers && r.layers.l0; 827 - const l1 = r.layers && r.layers.l1; 828 - html += "<tr>" 829 - + "<td>" + esc(r.did) + "</td>" 830 - + "<td>" + (l0 ? (l0.pass ? '<span class="verify-pass">PASS</span>' : '<span class="verify-fail">FAIL</span>') : "-") + "</td>" 831 - + "<td>" + (l1 ? (l1.pass ? '<span class="verify-pass">PASS</span>' : '<span class="verify-fail">FAIL</span>') : "-") + "</td>" 832 - + "<td>" + esc(r.lastCheck) + "</td>" 833 - + "</tr>"; 834 - } 835 - html += "</tbody></table>"; 836 - el.innerHTML = html; 837 - } 838 - 839 - var accountSearchState = { selectedHandle: null, selectedActor: null, activeIndex: -1 }; 840 - var accountSearchTimer = null; 841 - 842 - async function refreshAccount() { 843 - var el = document.getElementById("account-content"); 844 - try { 845 - var res = await fetch("/oauth/status"); 846 - if (!res.ok) { el.innerHTML = '<span style="color:#999">OAuth not enabled</span>'; return; } 847 - var data = await res.json(); 848 - cachedAccountStatus = data; 849 - if (data.authenticated) { 850 - var avatarHtml = data.avatar 851 - ? '<img src="' + esc(data.avatar) + '" alt="" style="width:28px;height:28px;border-radius:50%;margin-right:0.5rem;vertical-align:middle">' 852 - : ''; 853 - el.innerHTML = '<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.3rem">' 854 - + avatarHtml 855 - + '<strong style="font-size:0.85rem">' + esc(data.displayName || data.handle || "Connected") + '</strong>' 856 - + (data.handle ? ' <span style="color:var(--muted);font-size:0.8rem">@' + esc(data.handle) + '</span>' : '') 857 - + '</div>' 858 - + '<div style="display:flex;align-items:center;gap:0.6rem;font-size:0.75rem">' 859 - + '<span class="dot dot-synced"></span>Authenticated' 860 - + '<button class="btn-remove" id="disconnect-btn">Disconnect</button>' 861 - + '</div>' 862 - + '<label class="consent-toggle" id="consent-toggle">' 863 - + '<input type="checkbox" id="consent-checkbox">' 864 - + '<span>Allow anyone to archive my data</span>' 865 - + '</label>'; 866 - document.getElementById("disconnect-btn").addEventListener("click", function() { 867 - if (!confirm("Are you sure you want to disconnect?\\n\\nThis will delete all replicated data, revoke all offers, and remove your identity from this node. This cannot be undone.")) return; 868 - fetch("/oauth/logout?disconnect=true", { method: "POST" }).then(function() { location.href = "/"; }); 869 - }); 870 - // Load consent state 871 - var consentCb = document.getElementById("consent-checkbox"); 872 - apiFetch("org.p2pds.app.getMyConsent").then(function(res) { 873 - if (consentCb) consentCb.checked = !!res.consented; 874 - }).catch(function() {}); 875 - consentCb.addEventListener("change", function() { 876 - var cb = this; 877 - cb.disabled = true; 878 - apiPost("org.p2pds.app.setMyConsent", { enabled: cb.checked }).then(function() { 879 - cb.disabled = false; 880 - }).catch(function() { 881 - cb.checked = !cb.checked; 882 - cb.disabled = false; 883 - }); 884 - }); 885 - } else { 886 - accountSearchState = { selectedHandle: null, selectedActor: null, activeIndex: -1 }; 887 - el.innerHTML = '<div id="account-search-container">' 888 - + '<div id="account-selected-display" style="display:none"></div>' 889 - + '<div class="account-search-wrap" id="account-search-wrap">' 890 - + '<span class="account-search-icon">&#128269;</span>' 891 - + '<input type="text" id="account-search-input" placeholder="Search Bluesky account..." autocomplete="off" spellcheck="false">' 892 - + '<div class="account-results" id="account-results"></div>' 893 - + '</div>' 894 - + '<div style="margin-top:0.4rem">' 895 - + '<button class="account-connect-btn" id="account-connect-btn" disabled>Connect</button>' 896 - + '</div>' 897 - + '</div>'; 898 - setupAccountSearch(); 899 - } 900 - } catch (e) { 901 - el.innerHTML = '<span style="color:#999">OAuth not enabled</span>'; 902 - } 903 - } 904 - 905 - function setupAccountSearch() { 906 - var input = document.getElementById("account-search-input"); 907 - var results = document.getElementById("account-results"); 908 - var connectBtn = document.getElementById("account-connect-btn"); 909 - 910 - input.addEventListener("input", function() { 911 - var q = this.value.trim(); 912 - accountSearchState.activeIndex = -1; 913 - if (q.length < 2) { 914 - results.classList.remove("visible"); 915 - results.innerHTML = ""; 916 - return; 917 - } 918 - if (accountSearchTimer) clearTimeout(accountSearchTimer); 919 - accountSearchTimer = setTimeout(function() { searchAccounts(q); }, 250); 920 - }); 921 - 922 - input.addEventListener("keydown", function(e) { 923 - var items = results.querySelectorAll(".account-result-item"); 924 - if (e.key === "ArrowDown") { 925 - e.preventDefault(); 926 - accountSearchState.activeIndex = Math.min(accountSearchState.activeIndex + 1, items.length - 1); 927 - updateActiveResult(items); 928 - } else if (e.key === "ArrowUp") { 929 - e.preventDefault(); 930 - accountSearchState.activeIndex = Math.max(accountSearchState.activeIndex - 1, 0); 931 - updateActiveResult(items); 932 - } else if (e.key === "Enter") { 933 - e.preventDefault(); 934 - if (accountSearchState.activeIndex >= 0 && items[accountSearchState.activeIndex]) { 935 - items[accountSearchState.activeIndex].click(); 936 - } else if (accountSearchState.selectedHandle) { 937 - connectBtn.click(); 938 - } else { 939 - // If text looks like a handle, allow direct entry 940 - var val = input.value.trim(); 941 - if (val && val.includes(".")) { 942 - selectAccount({ handle: val, displayName: null, avatar: null, did: null }); 943 - } 944 - } 945 - } else if (e.key === "Escape") { 946 - results.classList.remove("visible"); 947 - accountSearchState.activeIndex = -1; 948 - } 949 - }); 950 - 951 - input.addEventListener("focus", function() { 952 - if (results.children.length > 0) results.classList.add("visible"); 953 - }); 954 - 955 - document.addEventListener("click", function(e) { 956 - if (!e.target.closest("#account-search-container")) { 957 - results.classList.remove("visible"); 958 - } 959 - }); 960 - 961 - connectBtn.addEventListener("click", function() { 962 - var handle = accountSearchState.selectedHandle; 963 - if (!handle) return; 964 - window.location.href = "/oauth/login?handle=" + encodeURIComponent(handle); 965 - }); 966 - } 967 - 968 - function updateActiveResult(items) { 969 - for (var i = 0; i < items.length; i++) { 970 - items[i].classList.toggle("active", i === accountSearchState.activeIndex); 971 - } 972 - if (accountSearchState.activeIndex >= 0 && items[accountSearchState.activeIndex]) { 973 - items[accountSearchState.activeIndex].scrollIntoView({ block: "nearest" }); 974 - } 975 - } 976 - 977 - async function searchAccounts(query) { 978 - var results = document.getElementById("account-results"); 979 - results.innerHTML = '<div class="account-searching">Searching...</div>'; 980 - results.classList.add("visible"); 981 - try { 982 - var url = "https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=" 983 - + encodeURIComponent(query) + "&limit=8"; 984 - var res = await fetch(url); 985 - if (!res.ok) throw new Error("Search failed"); 986 - var data = await res.json(); 987 - var actors = data.actors || []; 988 - if (actors.length === 0) { 989 - results.innerHTML = '<div class="account-no-results">No accounts found</div>'; 990 - return; 991 - } 992 - var html = ""; 993 - for (var i = 0; i < actors.length; i++) { 994 - var a = actors[i]; 995 - var avatarHtml = a.avatar 996 - ? '<img class="account-result-avatar" src="' + esc(a.avatar) + '" alt="" loading="lazy">' 997 - : '<div class="account-result-avatar-placeholder">' + esc((a.handle || "?")[0].toUpperCase()) + '</div>'; 998 - html += '<div class="account-result-item" data-index="' + i + '" ' 999 - + 'data-handle="' + esc(a.handle) + '" ' 1000 - + 'data-did="' + esc(a.did) + '" ' 1001 - + 'data-displayname="' + esc(a.displayName || "") + '" ' 1002 - + 'data-avatar="' + esc(a.avatar || "") + '">' 1003 - + avatarHtml 1004 - + '<div class="account-result-info">' 1005 - + '<div class="account-result-name">' + esc(a.displayName || a.handle) + '</div>' 1006 - + '<div class="account-result-handle">@' + esc(a.handle) + '</div>' 1007 - + '</div>' 1008 - + '</div>'; 1009 - } 1010 - results.innerHTML = html; 1011 - accountSearchState.activeIndex = -1; 1012 - results.querySelectorAll(".account-result-item").forEach(function(item) { 1013 - item.addEventListener("click", function() { 1014 - selectAccount({ 1015 - handle: this.dataset.handle, 1016 - did: this.dataset.did, 1017 - displayName: this.dataset.displayname || null, 1018 - avatar: this.dataset.avatar || null 1019 - }); 1020 - }); 1021 - item.addEventListener("mouseenter", function() { 1022 - var idx = parseInt(this.dataset.index, 10); 1023 - accountSearchState.activeIndex = idx; 1024 - updateActiveResult(results.querySelectorAll(".account-result-item")); 1025 - }); 1026 - }); 1027 - } catch (e) { 1028 - results.innerHTML = '<div class="account-no-results">Search error. You can type a handle directly and press Enter.</div>'; 1029 - } 1030 - } 1031 - 1032 - function selectAccount(actor) { 1033 - accountSearchState.selectedHandle = actor.handle; 1034 - accountSearchState.selectedActor = actor; 1035 - var results = document.getElementById("account-results"); 1036 - results.classList.remove("visible"); 1037 - results.innerHTML = ""; 1038 - var searchWrap = document.getElementById("account-search-wrap"); 1039 - searchWrap.style.display = "none"; 1040 - var display = document.getElementById("account-selected-display"); 1041 - var avatarHtml = actor.avatar 1042 - ? '<img class="account-result-avatar" src="' + esc(actor.avatar) + '" alt="">' 1043 - : '<div class="account-result-avatar-placeholder">' + esc((actor.handle || "?")[0].toUpperCase()) + '</div>'; 1044 - display.innerHTML = '<div class="account-selected">' 1045 - + avatarHtml 1046 - + '<div class="account-selected-info">' 1047 - + '<div class="account-selected-name">' + esc(actor.displayName || actor.handle) + '</div>' 1048 - + '<div class="account-selected-handle">@' + esc(actor.handle) + (actor.did ? ' &middot; ' + esc(actor.did) : '') + '</div>' 1049 - + '</div>' 1050 - + '<button class="account-selected-clear" id="account-clear-btn">Change</button>' 1051 - + '</div>'; 1052 - display.style.display = "block"; 1053 - document.getElementById("account-connect-btn").disabled = false; 1054 - document.getElementById("account-clear-btn").addEventListener("click", function() { 1055 - accountSearchState.selectedHandle = null; 1056 - accountSearchState.selectedActor = null; 1057 - display.style.display = "none"; 1058 - display.innerHTML = ""; 1059 - searchWrap.style.display = ""; 1060 - document.getElementById("account-connect-btn").disabled = true; 1061 - var input = document.getElementById("account-search-input"); 1062 - input.value = ""; 1063 - input.focus(); 1064 - }); 1065 - } 1066 - 1067 - function renderIncomingOffers(data) { 1068 - var section = document.getElementById("section-incoming-offers"); 1069 - var el = document.getElementById("incoming-offers-content"); 1070 - var offers = data.incomingOffers || []; 1071 - var countBadge = document.getElementById("incoming-offers-count"); 1072 - if (offers.length === 0) { section.style.display = "none"; return; } 1073 - section.style.display = ""; 1074 - if (countBadge) countBadge.textContent = "(" + offers.length + ")"; 1075 - var html = ""; 1076 - for (var i = 0; i < offers.length; i++) { 1077 - var o = offers[i]; 1078 - var rowId = "incoming-" + o.offererDid.replace(/[^a-zA-Z0-9]/g, "_"); 1079 - html += '<div class="incoming-offer-row" id="' + rowId + '">' 1080 - + '<div class="incoming-offer-info" id="' + rowId + '-info">' 1081 - + '<span>' + esc(o.offererDid) + '</span>' 1082 - + ' <span style="color:var(--muted)">offered to replicate your data</span>' 1083 - + '</div>' 1084 - + '<div class="incoming-offer-actions">' 1085 - + '<button class="btn-accept" data-did="' + esc(o.offererDid) + '">Accept &amp; Replicate</button>' 1086 - + '<button class="btn-reject" data-did="' + esc(o.offererDid) + '">Reject</button>' 1087 - + '</div></div>'; 1088 - } 1089 - el.innerHTML = html; 1090 - 1091 - // Async profile resolution for offerer DIDs 1092 - for (var j = 0; j < offers.length; j++) { 1093 - (function(o) { 1094 - var infoId = "incoming-" + o.offererDid.replace(/[^a-zA-Z0-9]/g, "_") + "-info"; 1095 - fetchProfile(o.offererDid).then(function(p) { 1096 - var infoEl = document.getElementById(infoId); 1097 - if (infoEl && p) { 1098 - var av = p.avatar 1099 - ? '<img src="' + esc(p.avatar) + '" alt="" style="width:20px;height:20px;border-radius:50%;vertical-align:middle;margin-right:0.3rem">' 1100 - : ''; 1101 - infoEl.innerHTML = av 1102 - + '<strong>' + esc(p.displayName || p.handle) + '</strong>' 1103 - + ' <span style="color:var(--muted)">@' + esc(p.handle) + '</span>' 1104 - + ' <span style="color:var(--muted)">offered to replicate your data</span>'; 1105 - } 1106 - }); 1107 - })(offers[j]); 1108 - } 1109 - 1110 - // Accept handlers 1111 - el.querySelectorAll(".btn-accept").forEach(function(btn) { 1112 - btn.addEventListener("click", async function() { 1113 - var did = this.dataset.did; 1114 - if (!confirm("Accept and replicate " + displayName(did) + "?\\n\\nThis will replicate their data on your node and share your data with them.")) return; 1115 - try { 1116 - var result = await apiPost("org.p2pds.app.acceptOffer", { offererDid: did }); 1117 - if (result.error) { alert("Error: " + (result.message || result.error)); } 1118 - else { refresh(); } 1119 - } catch (err) { alert("Error: " + err.message); } 1120 - }); 1121 - }); 1122 - 1123 - // Reject handlers 1124 - el.querySelectorAll(".btn-reject").forEach(function(btn) { 1125 - btn.addEventListener("click", async function() { 1126 - var did = this.dataset.did; 1127 - if (!confirm("Reject offer from " + displayName(did) + "?")) return; 1128 - try { 1129 - var result = await apiPost("org.p2pds.app.rejectOffer", { offererDid: did }); 1130 - if (result.error) { alert("Error: " + (result.message || result.error)); } 1131 - else { refresh(); } 1132 - } catch (err) { alert("Error: " + err.message); } 1133 - }); 1134 - }); 1135 - } 1136 - 1137 - function gateAddDid(overview) { 1138 - var input = document.getElementById("add-did-input"); 1139 - var btn = document.getElementById("add-did-btn"); 1140 - var msgEl = document.getElementById("add-did-msg"); 1141 - if (!input || !btn) return; 1142 - var nodeDid = overview.did; 1143 - if (!nodeDid) return; 1144 - var states = (overview.replication && overview.replication.syncStates) || []; 1145 - var selfState = null; 1146 - for (var i = 0; i < states.length; i++) { 1147 - if (states[i].did === nodeDid) { selfState = states[i]; break; } 1148 - } 1149 - // Gate when: self-DID not yet in sync states (entry not created yet) 1150 - // OR self-DID is pending/syncing 1151 - var selfSyncing = !selfState || selfState.status === "pending" || selfState.status === "syncing"; 1152 - if (selfSyncing) { 1153 - input.disabled = true; 1154 - btn.disabled = true; 1155 - if (msgEl) { 1156 - msgEl.className = ""; 1157 - msgEl.innerHTML = '<span class="sync-gate-msg"><span class="sync-gate-spinner"></span>Syncing your account\u2026</span>'; 1158 - } 1159 - } else { 1160 - input.disabled = false; 1161 - btn.disabled = false; 1162 - if (msgEl && msgEl.querySelector(".sync-gate-msg")) { 1163 - msgEl.className = ""; 1164 - msgEl.innerHTML = ""; 1165 - } 1166 - } 1167 - } 1168 - 1169 - function setActivity(active) { 1170 - var sp = document.getElementById("activity-spinner"); 1171 - if (sp) sp.classList.toggle("active", active); 1172 - } 1173 - 1174 - async function refresh() { 1175 - setActivity(true); 1176 - try { 1177 - // Check account status first — if OAuth is active but nobody is logged in, 1178 - // show empty sections (no stale data from a previous session) 1179 - await refreshAccount(); 1180 - if (cachedAccountStatus && cachedAccountStatus.authenticated === false) { 1181 - var empty = { did: "", handle: "", network: {}, replication: { enabled: false }, policy: {} }; 1182 - renderOverview(empty); 1183 - renderIncomingOffers(empty); 1184 - renderMetrics(empty); 1185 - renderReplication(empty); 1186 - renderSyncHistory({ history: [] }); 1187 - renderNetwork({ peerId: null, connections: 0, peers: [] }); 1188 - renderPolicies({ policies: [] }); 1189 - renderVerification(empty); 1190 - document.getElementById("last-refresh").textContent = "Updated: " + new Date().toLocaleTimeString(); 1191 - setActivity(false); 1192 - return; 1193 - } 1194 - const [overview, network, policies, syncHistory] = await Promise.all([ 1195 - apiFetch("org.p2pds.app.getOverview"), 1196 - apiFetch("org.p2pds.app.getNetworkStatus"), 1197 - apiFetch("org.p2pds.app.getPolicies"), 1198 - apiFetch("org.p2pds.app.getSyncHistory", { limit: "20" }), 1199 - ]); 1200 - renderOverview(overview); 1201 - renderIncomingOffers(overview); 1202 - renderMetrics(overview); 1203 - renderReplication(overview); 1204 - renderSyncHistory(syncHistory); 1205 - renderNetwork(network); 1206 - renderPolicies(policies); 1207 - renderVerification(overview); 1208 - gateAddDid(overview); 1209 - document.getElementById("last-refresh").textContent = "Updated: " + new Date().toLocaleTimeString(); 1210 - // Keep spinner active if any DID is still syncing 1211 - var anySyncing = ((overview.replication && overview.replication.syncStates) || []).some(function(s) { 1212 - return s.status === "syncing" || s.status === "pending"; 1213 - }); 1214 - setActivity(anySyncing); 1215 - } catch (e) { 1216 - setActivity(false); 1217 - console.error("App refresh error:", e); 1218 - } 1219 - } 1220 - 1221 - let intervalId = setInterval(refresh, 30000); 1222 - document.getElementById("auto-refresh").addEventListener("change", function() { 1223 - if (this.checked) { intervalId = setInterval(refresh, 30000); refresh(); } 1224 - else { clearInterval(intervalId); } 1225 - }); 1226 - 1227 - refresh(); 1228 - 1229 - // Add DID — account search widget 1230 - var didSearchState = { selectedDid: null, activeIndex: -1, selectedPolicy: "reciprocal" }; 1231 - var didSearchTimer = null; 1232 - 1233 - async function addDidSubmit() { 1234 - var input = document.getElementById("add-did-input"); 1235 - var msgEl = document.getElementById("add-did-msg"); 1236 - var selectedEl = document.getElementById("add-did-selected"); 1237 - var did = didSearchState.selectedDid || input.value.trim(); 1238 - if (!did) { msgEl.className = "add-did-error"; msgEl.textContent = "Search for an account or paste a DID"; return; } 1239 - msgEl.className = ""; msgEl.textContent = ""; 1240 - var policy = didSearchState.selectedPolicy || "reciprocal"; 1241 - var endpoint = policy === "reciprocal" ? "org.p2pds.app.offerDid" : "org.p2pds.app.addDid"; 1242 - try { 1243 - var result = await apiPost(endpoint, { did: did }); 1244 - if (result.error) { 1245 - msgEl.className = "add-did-error"; msgEl.textContent = result.message || result.error; 1246 - } else { 1247 - msgEl.className = "add-did-success"; 1248 - var dn = displayName(did); 1249 - if (result.status === "already_tracked") { 1250 - msgEl.textContent = dn + " already tracked (source: " + result.source + ")"; 1251 - } else if (policy === "reciprocal") { 1252 - msgEl.textContent = result.status === "already_offered" 1253 - ? dn + " already offered" 1254 - : "Offered to replicate " + dn; 1255 - } else if (policy === "consented") { 1256 - msgEl.textContent = "Archiving " + dn + " (with consent)"; 1257 - } else { 1258 - msgEl.textContent = "Archiving " + dn; 1259 - } 1260 - input.value = ""; 1261 - didSearchState.selectedDid = null; 1262 - didSearchState.selectedPolicy = "reciprocal"; 1263 - selectedEl.style.display = "none"; 1264 - selectedEl.innerHTML = ""; 1265 - document.getElementById("did-search-wrap").style.display = ""; 1266 - refresh(); 1267 - } 1268 - } catch (err) { msgEl.className = "add-did-error"; msgEl.textContent = "Error: " + err.message; } 1269 - } 1270 - 1271 - function didSelectActor(actor) { 1272 - didSearchState.selectedDid = actor.did; 1273 - didSearchState.activeIndex = -1; 1274 - var results = document.getElementById("did-search-results"); 1275 - results.classList.remove("visible"); 1276 - results.innerHTML = ""; 1277 - document.getElementById("did-search-wrap").style.display = "none"; 1278 - var selectedEl = document.getElementById("add-did-selected"); 1279 - var avatarHtml = actor.avatar 1280 - ? '<img class="account-result-avatar" src="' + esc(actor.avatar) + '" alt="">' 1281 - : '<div class="account-result-avatar-placeholder">' + esc((actor.handle || "?")[0].toUpperCase()) + '</div>'; 1282 - selectedEl.innerHTML = '<div class="account-selected">' 1283 - + avatarHtml 1284 - + '<div class="account-selected-info">' 1285 - + '<div class="account-selected-name">' + esc(actor.displayName || actor.handle) + '</div>' 1286 - + '<div class="account-selected-handle">@' + esc(actor.handle) + (actor.did ? ' &middot; ' + esc(actor.did) : '') + '</div>' 1287 - + '</div>' 1288 - + '<button class="account-selected-clear" id="did-clear-btn">Change</button>' 1289 - + '</div>'; 1290 - selectedEl.style.display = "block"; 1291 - // Append policy selector with 3 options 1292 - var policyHtml = '<div class="policy-selector">' 1293 - + '<label class="policy-option selected"><input type="radio" name="add-did-policy" value="reciprocal" checked>' 1294 - + '<span><span class="policy-label">Bidirectional archiving with mutual consent</span><br><span class="policy-desc">Both peers replicate data bidirectionally</span></span></label>' 1295 - + '<label class="policy-option disabled" id="policy-consented-option"><input type="radio" name="add-did-policy" value="consented" disabled>' 1296 - + '<span><span class="policy-label">Archive with consent</span><br><span class="policy-desc">Back up their data (they opted in)</span><br><span class="policy-note" id="consent-status-note">Checking consent...</span></span></label>' 1297 - + '<label class="policy-option"><input type="radio" name="add-did-policy" value="archive">' 1298 - + '<span><span class="policy-label">Archive without explicit consent</span><br><span class="policy-desc">Back up their data locally (one-way)</span></span></label>' 1299 - + '</div>'; 1300 - selectedEl.insertAdjacentHTML("beforeend", policyHtml); 1301 - didSearchState.selectedPolicy = "reciprocal"; 1302 - // Check consent async 1303 - apiFetch("org.p2pds.app.checkConsent", { did: actor.did }).then(function(res) { 1304 - var note = document.getElementById("consent-status-note"); 1305 - var option = document.getElementById("policy-consented-option"); 1306 - var radio = option ? option.querySelector("input") : null; 1307 - if (res.hasConsent) { 1308 - if (note) note.textContent = ""; 1309 - if (option) option.classList.remove("disabled"); 1310 - if (radio) radio.disabled = false; 1311 - } else { 1312 - if (note) note.textContent = "Account hasn't opted in"; 1313 - } 1314 - }).catch(function() { 1315 - var note = document.getElementById("consent-status-note"); 1316 - if (note) note.textContent = "Could not check consent"; 1317 - }); 1318 - selectedEl.querySelectorAll('input[name="add-did-policy"]').forEach(function(r) { 1319 - r.addEventListener("change", function() { 1320 - didSearchState.selectedPolicy = this.value; 1321 - selectedEl.querySelectorAll(".policy-option").forEach(function(opt) { opt.classList.remove("selected"); }); 1322 - this.closest(".policy-option").classList.add("selected"); 1323 - }); 1324 - }); 1325 - document.getElementById("did-clear-btn").addEventListener("click", function() { 1326 - didSearchState.selectedDid = null; 1327 - didSearchState.selectedPolicy = "reciprocal"; 1328 - selectedEl.style.display = "none"; 1329 - selectedEl.innerHTML = ""; 1330 - document.getElementById("did-search-wrap").style.display = ""; 1331 - document.getElementById("add-did-input").value = ""; 1332 - document.getElementById("add-did-input").focus(); 1333 - }); 1334 - } 1335 - 1336 - (function setupDidSearch() { 1337 - var input = document.getElementById("add-did-input"); 1338 - var results = document.getElementById("did-search-results"); 1339 - 1340 - input.addEventListener("input", function() { 1341 - var q = this.value.trim(); 1342 - didSearchState.activeIndex = -1; 1343 - if (q.length < 2 || q.startsWith("did:")) { 1344 - results.classList.remove("visible"); 1345 - results.innerHTML = ""; 1346 - return; 1347 - } 1348 - if (didSearchTimer) clearTimeout(didSearchTimer); 1349 - didSearchTimer = setTimeout(function() { 1350 - results.innerHTML = '<div class="account-searching">Searching...</div>'; 1351 - results.classList.add("visible"); 1352 - fetch("https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=" + encodeURIComponent(q) + "&limit=6") 1353 - .then(function(r) { return r.ok ? r.json() : { actors: [] }; }) 1354 - .then(function(data) { 1355 - var actors = data.actors || []; 1356 - if (actors.length === 0) { results.innerHTML = '<div class="account-no-results">No accounts found</div>'; return; } 1357 - var html = ""; 1358 - for (var i = 0; i < actors.length; i++) { 1359 - var a = actors[i]; 1360 - var avHtml = a.avatar 1361 - ? '<img class="account-result-avatar" src="' + esc(a.avatar) + '" alt="" loading="lazy">' 1362 - : '<div class="account-result-avatar-placeholder">' + esc((a.handle || "?")[0].toUpperCase()) + '</div>'; 1363 - html += '<div class="account-result-item" data-index="' + i + '" data-handle="' + esc(a.handle) + '" data-did="' + esc(a.did) + '" data-displayname="' + esc(a.displayName || "") + '" data-avatar="' + esc(a.avatar || "") + '">' 1364 - + avHtml + '<div class="account-result-info"><div class="account-result-name">' + esc(a.displayName || a.handle) + '</div><div class="account-result-handle">@' + esc(a.handle) + '</div></div></div>'; 1365 - } 1366 - results.innerHTML = html; 1367 - didSearchState.activeIndex = -1; 1368 - results.querySelectorAll(".account-result-item").forEach(function(item) { 1369 - item.addEventListener("click", function() { 1370 - didSelectActor({ handle: this.dataset.handle, did: this.dataset.did, displayName: this.dataset.displayname || null, avatar: this.dataset.avatar || null }); 1371 - }); 1372 - item.addEventListener("mouseenter", function() { 1373 - didSearchState.activeIndex = parseInt(this.dataset.index, 10); 1374 - var items = results.querySelectorAll(".account-result-item"); 1375 - for (var j = 0; j < items.length; j++) items[j].classList.toggle("active", j === didSearchState.activeIndex); 1376 - }); 1377 - }); 1378 - }) 1379 - .catch(function() { results.innerHTML = '<div class="account-no-results">Search error</div>'; }); 1380 - }, 250); 1381 - }); 1382 - 1383 - input.addEventListener("keydown", function(e) { 1384 - var items = results.querySelectorAll(".account-result-item"); 1385 - if (e.key === "ArrowDown") { e.preventDefault(); didSearchState.activeIndex = Math.min(didSearchState.activeIndex + 1, items.length - 1); for (var i = 0; i < items.length; i++) items[i].classList.toggle("active", i === didSearchState.activeIndex); if (items[didSearchState.activeIndex]) items[didSearchState.activeIndex].scrollIntoView({ block: "nearest" }); } 1386 - else if (e.key === "ArrowUp") { e.preventDefault(); didSearchState.activeIndex = Math.max(didSearchState.activeIndex - 1, 0); for (var i = 0; i < items.length; i++) items[i].classList.toggle("active", i === didSearchState.activeIndex); } 1387 - else if (e.key === "Enter") { e.preventDefault(); if (didSearchState.activeIndex >= 0 && items[didSearchState.activeIndex]) { items[didSearchState.activeIndex].click(); } else { addDidSubmit(); } } 1388 - else if (e.key === "Escape") { results.classList.remove("visible"); didSearchState.activeIndex = -1; } 1389 - }); 1390 - 1391 - input.addEventListener("focus", function() { if (results.children.length > 0 && !input.value.trim().startsWith("did:")) results.classList.add("visible"); }); 1392 - document.addEventListener("click", function(e) { if (!e.target.closest("#did-search-wrap") && !e.target.closest("#add-did-selected")) results.classList.remove("visible"); }); 1393 - })(); 1394 - 1395 - document.getElementById("add-did-btn").addEventListener("click", addDidSubmit); 1396 - </script> 193 + <script>window.__P2PDS_TOKEN__=${JSON.stringify(token)};</script> 194 + <p2p-app></p2p-app> 195 + <script type="module" src="/static/ui/app.js"></script> 1397 196 </body> 1398 197 </html>`; 1399 198 1400 - // Prevent browser from caching the page (auth token is embedded) 1401 199 c.header("Cache-Control", "no-store"); 1402 200 return c.html(html); 1403 201 } 202 + 1404 203 1405 204 export function getSyncHistory( 1406 205 c: Context<AuthedAppEnv>, ··· 1942 741 } catch { 1943 742 return c.json({ consented: false }); 1944 743 } 744 + } 745 + 746 + /** 747 + * Stream sync progress events via Server-Sent Events. 748 + * Supports auth via query param (?token=) since EventSource doesn't support headers. 749 + */ 750 + export function streamSyncProgress( 751 + c: Context<AppEnv>, 752 + replicationManager: ReplicationManager | undefined, 753 + ): Response { 754 + // Auth: check query param token (EventSource limitation) or Bearer header 755 + const token = c.req.query("token") ?? c.req.header("Authorization")?.replace("Bearer ", ""); 756 + if (!token || token !== c.env.AUTH_TOKEN) { 757 + return c.json({ error: "AuthenticationRequired", message: "Invalid token" }, 401); 758 + } 759 + 760 + if (!replicationManager) { 761 + return c.json({ error: "ReplicationNotEnabled", message: "Replication is not enabled" }, 400); 762 + } 763 + 764 + const rm = replicationManager; 765 + 766 + return streamSSE(c, async (stream) => { 767 + let eventId = 0; 768 + const cb = (event: SyncProgressEvent) => { 769 + stream.writeSSE({ 770 + data: JSON.stringify(event), 771 + event: event.type, 772 + id: String(++eventId), 773 + }).catch(() => { 774 + // Stream closed 775 + }); 776 + }; 777 + 778 + rm.onSyncProgress(cb); 779 + 780 + // Keep the stream alive until client disconnects 781 + let resolveWait: () => void; 782 + const waitPromise = new Promise<void>((r) => { resolveWait = r; }); 783 + c.req.raw.signal.addEventListener("abort", () => { 784 + rm.offSyncProgress(cb); 785 + resolveWait(); 786 + }); 787 + 788 + // Send initial keepalive 789 + await stream.writeSSE({ data: "connected", event: "connected", id: "0" }); 790 + 791 + await waitPromise; 792 + }); 1945 793 } 1946 794 1947 795 /**
+1 -1
tsconfig.json
··· 16 16 "sourceMap": true 17 17 }, 18 18 "include": ["src"], 19 - "exclude": ["node_modules", "dist", "apps"] 19 + "exclude": ["node_modules", "dist", "apps", "src/ui"] 20 20 }