lightweight com.atproto.sync.listReposByCollection
45
fork

Configure Feed

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

fix up admin and resync cancellation

phil c7a1adf0 f3722049

+564 -199
+235 -34
src/server/admin/page.css
··· 1 - *{box-sizing:border-box;margin:0;padding:0} 2 - body{font-family:system-ui,-apple-system,sans-serif;background:#f8f8f8;color:#222;padding:1.5rem;max-width:1100px;margin:0 auto} 3 - header{margin-bottom:1.5rem;display:flex;justify-content:space-between;align-items:baseline;flex-wrap:wrap;gap:0.5rem} 4 - h1{font-size:1.4rem;font-weight:600;letter-spacing:-0.02em} 5 - .sub{color:#888;font-size:0.82rem} 6 - .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:1rem} 7 - .card{background:#fff;border-radius:6px;padding:1rem 1.25rem;border:1px solid #e8e8e8} 8 - .card h2{font-size:0.78rem;text-transform:uppercase;letter-spacing:0.06em;color:#999;margin-bottom:0.6rem;font-weight:600} 9 - .row{display:flex;justify-content:space-between;align-items:center;padding:0.25rem 0} 10 - .row .k{color:#666;font-size:0.88rem} 11 - .row .v{font-weight:600;font-variant-numeric:tabular-nums;font-size:0.88rem} 12 - .bar-row{margin:0.35rem 0} 13 - .bar-head{display:flex;justify-content:space-between;font-size:0.82rem;margin-bottom:3px} 14 - .bar-head .k{color:#666} 15 - .bar-head .v{font-weight:600;font-variant-numeric:tabular-nums} 16 - .track{height:5px;background:#eee;border-radius:3px;overflow:hidden} 17 - .fill{height:100%;border-radius:3px;transition:width 0.6s ease} 18 - table{width:100%;border-collapse:collapse;font-size:0.82rem;margin-top:0.25rem} 19 - th{text-align:left;font-weight:500;color:#999;padding:0.2rem 0.5rem 0.2rem 0;border-bottom:1px solid #eee;font-size:0.78rem} 20 - td{padding:0.2rem 0.5rem 0.2rem 0;border-bottom:1px solid #f5f5f5;font-family:ui-monospace,'SF Mono',monospace;font-size:0.8rem} 21 - td.num{text-align:right;font-family:system-ui,-apple-system,sans-serif;font-variant-numeric:tabular-nums} 22 - .badge{display:inline-block;padding:0.1rem 0.45rem;border-radius:3px;font-size:0.75rem;font-weight:600} 23 - .bg{background:#e8f5e9;color:#2e7d32} 24 - .by{background:#fff8e1;color:#f57f17} 25 - .dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:#ccc;margin-right:0.4rem;vertical-align:middle;transition:background 0.3s} 26 - .dot.on{background:#4caf50} 27 - .empty{color:#bbb;font-style:italic} 28 - .sec-head{margin-top:0.6rem;font-size:0.75rem;color:#999;text-transform:uppercase;letter-spacing:0.04em;padding-bottom:0.2rem;border-bottom:1px solid #eee} 29 - details summary{cursor:pointer;font-size:0.82rem;color:#555} 30 - details summary:hover{color:#222} 31 - details[open] summary{margin-bottom:0.3rem} 32 - details table{margin-top:0.15rem} 33 - .detail-paths{padding:0.2rem 0 0.2rem 1rem;font-size:0.78rem;color:#666} 34 - .detail-paths div{padding:0.1rem 0;font-family:ui-monospace,'SF Mono',monospace} 1 + /* ── Reset & page layout ──────────────────────────────────────────────── */ 2 + 3 + * { 4 + box-sizing: border-box; 5 + margin: 0; 6 + padding: 0; 7 + } 8 + 9 + body { 10 + font-family: system-ui, -apple-system, sans-serif; 11 + background: #f8f8f8; 12 + color: #222; 13 + padding: 1.5rem; 14 + max-width: 1100px; 15 + margin: 0 auto; 16 + } 17 + 18 + /* ── Header ───────────────────────────────────────────────────────────── */ 19 + 20 + header { 21 + margin-bottom: 1.5rem; 22 + display: flex; 23 + justify-content: space-between; 24 + align-items: baseline; 25 + flex-wrap: wrap; 26 + gap: 0.5rem; 27 + } 28 + 29 + h1 { 30 + font-size: 1.4rem; 31 + font-weight: 600; 32 + letter-spacing: -0.02em; 33 + } 34 + 35 + .subtitle { 36 + color: #888; 37 + font-size: 0.82rem; 38 + } 39 + 40 + /* ── Poll status indicator (green dot + timestamp) ────────────────────── */ 41 + 42 + .poll-dot { 43 + display: inline-block; 44 + width: 7px; 45 + height: 7px; 46 + border-radius: 50%; 47 + background: #ccc; 48 + margin-right: 0.4rem; 49 + vertical-align: middle; 50 + transition: background 0.3s; 51 + } 52 + 53 + .poll-dot.connected { 54 + background: #4caf50; 55 + } 56 + 57 + /* ── Card grid ────────────────────────────────────────────────────────── */ 58 + 59 + .grid { 60 + display: grid; 61 + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); 62 + gap: 1rem; 63 + } 64 + 65 + .card { 66 + background: #fff; 67 + border-radius: 6px; 68 + padding: 1rem 1.25rem; 69 + border: 1px solid #e8e8e8; 70 + } 71 + 72 + .card h2 { 73 + font-size: 0.78rem; 74 + text-transform: uppercase; 75 + letter-spacing: 0.06em; 76 + color: #999; 77 + margin-bottom: 0.6rem; 78 + font-weight: 600; 79 + } 80 + 81 + /* ── Key/value rows ───────────────────────────────────────────────────── */ 82 + 83 + .stat-row { 84 + display: flex; 85 + justify-content: space-between; 86 + align-items: center; 87 + padding: 0.25rem 0; 88 + } 89 + 90 + .stat-row .label { 91 + color: #666; 92 + font-size: 0.88rem; 93 + } 94 + 95 + .stat-row .value { 96 + font-weight: 600; 97 + font-variant-numeric: tabular-nums; 98 + font-size: 0.88rem; 99 + } 100 + 101 + /* ── Horizontal bar charts ────────────────────────────────────────────── */ 102 + 103 + .bar-row { 104 + margin: 0.35rem 0; 105 + } 106 + 107 + .bar-header { 108 + display: flex; 109 + justify-content: space-between; 110 + font-size: 0.82rem; 111 + margin-bottom: 3px; 112 + } 113 + 114 + .bar-header .label { 115 + color: #666; 116 + } 117 + 118 + .bar-header .value { 119 + font-weight: 600; 120 + font-variant-numeric: tabular-nums; 121 + } 122 + 123 + .bar-track { 124 + height: 5px; 125 + background: #eee; 126 + border-radius: 3px; 127 + overflow: hidden; 128 + } 129 + 130 + .bar-fill { 131 + height: 100%; 132 + border-radius: 3px; 133 + transition: width 0.6s ease; 134 + } 135 + 136 + /* ── Tables (dispatcher hosts, cooling, etc.) ─────────────────────────── */ 137 + 138 + table { 139 + width: 100%; 140 + border-collapse: collapse; 141 + font-size: 0.82rem; 142 + margin-top: 0.25rem; 143 + } 144 + 145 + th { 146 + text-align: left; 147 + font-weight: 500; 148 + color: #999; 149 + padding: 0.2rem 0.5rem 0.2rem 0; 150 + border-bottom: 1px solid #eee; 151 + font-size: 0.78rem; 152 + } 153 + 154 + td { 155 + padding: 0.2rem 0.5rem 0.2rem 0; 156 + border-bottom: 1px solid #f5f5f5; 157 + font-family: ui-monospace, 'SF Mono', monospace; 158 + font-size: 0.8rem; 159 + } 160 + 161 + td.numeric { 162 + text-align: right; 163 + font-family: system-ui, -apple-system, sans-serif; 164 + font-variant-numeric: tabular-nums; 165 + } 166 + 167 + /* ── Status badges (backfill complete / in progress) ──────────────────── */ 168 + 169 + .badge { 170 + display: inline-block; 171 + padding: 0.1rem 0.45rem; 172 + border-radius: 3px; 173 + font-size: 0.75rem; 174 + font-weight: 600; 175 + } 176 + 177 + .badge-success { 178 + background: #e8f5e9; 179 + color: #2e7d32; 180 + } 181 + 182 + .badge-warning { 183 + background: #fff8e1; 184 + color: #f57f17; 185 + } 186 + 187 + /* ── Section headings within cards ────────────────────────────────────── */ 188 + 189 + .section-heading { 190 + margin-top: 0.6rem; 191 + font-size: 0.75rem; 192 + color: #999; 193 + text-transform: uppercase; 194 + letter-spacing: 0.04em; 195 + padding-bottom: 0.2rem; 196 + border-bottom: 1px solid #eee; 197 + } 198 + 199 + /* ── Expandable details (busy DIDs, throttled hosts) ──────────────────── */ 200 + 201 + details summary { 202 + cursor: pointer; 203 + font-size: 0.82rem; 204 + color: #555; 205 + } 206 + 207 + details summary:hover { 208 + color: #222; 209 + } 210 + 211 + details[open] summary { 212 + margin-bottom: 0.3rem; 213 + } 214 + 215 + details table { 216 + margin-top: 0.15rem; 217 + } 218 + 219 + .detail-paths { 220 + padding: 0.2rem 0 0.2rem 1rem; 221 + font-size: 0.78rem; 222 + color: #666; 223 + } 224 + 225 + .detail-paths div { 226 + padding: 0.1rem 0; 227 + font-family: ui-monospace, 'SF Mono', monospace; 228 + } 229 + 230 + /* ── Misc ─────────────────────────────────────────────────────────────── */ 231 + 232 + .empty { 233 + color: #bbb; 234 + font-style: italic; 235 + }
+248 -122
src/server/admin/page.js
··· 1 - var $=function(s){return document.getElementById(s)}; 2 - var N=function(n){return typeof n==='number'?n.toLocaleString():'\u2014'}; 1 + // lightrail admin dashboard 2 + // 3 + // Polls /admin/status every 5 seconds and renders stats into cards. 4 + // No dependencies — vanilla JS, runs in any modern browser. 5 + 6 + const POLL_INTERVAL_MS = 5000; 7 + 8 + // ── Formatting helpers ─────────────────────────────────────────────────── 9 + 10 + /** Format a number with locale-appropriate grouping, or "—" for non-numbers. */ 11 + function formatNumber(n) { 12 + return typeof n === "number" ? n.toLocaleString() : "\u2014"; 13 + } 14 + 15 + /** Format a millisecond duration as a human-readable string like "3d 12h". */ 16 + function formatDuration(ms) { 17 + const totalSeconds = Math.floor(ms / 1000); 18 + const days = Math.floor(totalSeconds / 86400); 19 + const hours = Math.floor((totalSeconds % 86400) / 3600); 20 + const minutes = Math.floor((totalSeconds % 3600) / 60); 21 + 22 + if (days > 0) return `${days}d ${hours}h`; 23 + if (hours > 0) return `${hours}h ${minutes}m`; 24 + return `${minutes}m`; 25 + } 26 + 27 + /** Escape a string for safe insertion into innerHTML. */ 28 + function escapeHtml(str) { 29 + const el = document.createElement("span"); 30 + el.textContent = str; 31 + return el.innerHTML; 32 + } 33 + 34 + /** Compute a percentage (0–100) of value relative to max, clamped. */ 35 + function percent(value, max) { 36 + return max > 0 ? Math.min(100, (value / max) * 100) : 0; 37 + } 38 + 39 + // ── HTML fragment builders ─────────────────────────────────────────────── 40 + 41 + /** Wrap content in a card with a title. */ 42 + function card(title, body) { 43 + return `<div class="card"><h2>${title}</h2>${body}</div>`; 44 + } 45 + 46 + /** A key/value row. Value can contain HTML (badges, styled spans, etc.). */ 47 + function statRow(label, value) { 48 + return `<div class="stat-row"> 49 + <span class="label">${label}</span> 50 + <span class="value">${value}</span> 51 + </div>`; 52 + } 3 53 4 - function dur(ms){ 5 - var s=Math.floor(ms/1000),d=Math.floor(s/86400),h=Math.floor(s%86400/3600),m=Math.floor(s%3600/60); 6 - return d>0?d+'d '+h+'h':h>0?h+'h '+m+'m':m+'m'; 54 + /** A labelled horizontal bar with a numeric value. */ 55 + function barRow(label, value, pct, color) { 56 + return `<div class="bar-row"> 57 + <div class="bar-header"> 58 + <span class="label">${label}</span> 59 + <span class="value">${formatNumber(value)}</span> 60 + </div> 61 + <div class="bar-track"> 62 + <div class="bar-fill" style="width: ${pct.toFixed(1)}%; background: ${color}"></div> 63 + </div> 64 + </div>`; 7 65 } 8 66 9 - function esc(s){var e=document.createElement('span');e.textContent=s;return e.innerHTML} 67 + /** A section sub-heading within a card. */ 68 + function sectionHeading(text) { 69 + return `<div class="section-heading">${text}</div>`; 70 + } 10 71 11 - function card(title,body){return '<div class="card"><h2>'+title+'</h2>'+body+'</div>'} 72 + // ── Card renderers ─────────────────────────────────────────────────────── 73 + // Each function takes the full status object and returns an HTML string. 74 + 75 + function renderIndexingCard(status) { 76 + const badge = status.upstream_backfill_complete 77 + ? `<span class="badge badge-success">complete</span>` 78 + : `<span class="badge badge-warning">in progress</span>`; 79 + 80 + let html = 81 + statRow("Upstream crawl", badge) 82 + + statRow("Repos crawled", formatNumber(status.repos_queued_total)) 83 + + statRow("Resync queue depth", formatNumber(status.resync_queue_depth)) 84 + + statRow("Resyncs completed", formatNumber(status.resyncs_completed_total)) 85 + + statRow("Resync-buffered events", formatNumber(status.resync_buffer_count)); 12 86 13 - function row(k,v){return '<div class="row"><span class="k">'+k+'</span><span class="v">'+v+'</span></div>'} 87 + if (status.upstream_backfill_completed_at) { 88 + const when = new Date(status.upstream_backfill_completed_at).toLocaleString(); 89 + html += statRow("Backfill completed", when); 90 + } 14 91 15 - function barRow(k,v,pct,color){ 16 - return '<div class="bar-row"><div class="bar-head"><span class="k">'+k+'</span><span class="v">'+N(v)+'</span></div>' 17 - +'<div class="track"><div class="fill" style="width:'+pct.toFixed(1)+'%;background:'+color+'"></div></div></div>'; 92 + return card("Indexing", html); 18 93 } 19 94 20 - function render(d){ 21 - var now=Date.now(); 22 - $('info').textContent=d.first_startup_ms 23 - ?'first started '+dur(now-d.first_startup_ms)+' ago \u00b7 '+d.startup_count+' start'+(d.startup_count!==1?'s':'') 24 - :''; 95 + function renderCollectionsCard(status) { 96 + const html = 97 + statRow("Distinct", formatNumber(status.distinct_collections)) 25 98 26 - document.title='lightrail \u00b7 Q:'+N(d.resync_queue_depth); 99 + + sectionHeading("From resync") 100 + + statRow("Indexed by account", formatNumber(status.collection_births_resync)) 101 + + statRow("De-indexed by account", formatNumber(status.collection_deaths_resync)) 27 102 28 - var h=''; 103 + + sectionHeading("From firehose") 104 + + statRow("Indexed by account", formatNumber(status.collection_births_firehose)) 105 + + statRow("De-indexed by account", formatNumber(status.collection_deaths_firehose)) 29 106 30 - /* Indexing */ 31 - var bfBadge=d.upstream_backfill_complete 32 - ?'<span class="badge bg">complete</span>' 33 - :'<span class="badge by">in progress</span>'; 34 - var idx=row('Backfill',bfBadge) 35 - +row('Repos found',N(d.repos_queued_total)) 36 - +row('Resyncs completed',N(d.resyncs_completed_total)) 37 - +row('Resync queue depth',N(d.resync_queue_depth)) 38 - +row('Replay buffer',N(d.resync_buffer_count)); 39 - if(d.upstream_backfill_completed_at)idx+=row('Backfill completed',new Date(d.upstream_backfill_completed_at).toLocaleString()); 40 - h+=card('Indexing',idx); 107 + return card("Collections", html); 108 + } 41 109 42 - /* Collections */ 43 - var birthTotal=d.collection_births_firehose+d.collection_births_resync; 44 - var deathTotal=d.collection_deaths_firehose+d.collection_deaths_resync; 45 - h+=card('Collections', 46 - row('Distinct (estimate)',N(d.distinct_collections)) 47 - +row('Births',N(birthTotal) 48 - +' <span style="font-weight:400;color:#888;font-size:0.78rem">(firehose\u00a0'+N(d.collection_births_firehose)+' \u00b7 resync\u00a0'+N(d.collection_births_resync)+')</span>') 49 - +row('Deaths',N(deathTotal) 50 - +' <span style="font-weight:400;color:#888;font-size:0.78rem">(firehose\u00a0'+N(d.collection_deaths_firehose)+' \u00b7 resync\u00a0'+N(d.collection_deaths_resync)+')</span>') 51 - ); 110 + function renderAccountsCard(status) { 111 + // Synced group: bars normalized to "all synced accounts" 112 + const syncMax = Math.max(status.distinct_accounts_all, 1); 52 113 53 - /* Accounts — sync group */ 54 - var syncMax=Math.max(d.distinct_accounts_all,1); 55 - var syncPct=function(v){return Math.min(100,v/syncMax*100)}; 56 - h+=card('Accounts', 57 - '<div class="sec-head">Synced</div>' 58 - +barRow('All synced accounts',d.distinct_accounts_all,syncPct(d.distinct_accounts_all),'#64b5f6') 59 - +barRow('Resynced (full fetch)',d.distinct_accounts_resynced,syncPct(d.distinct_accounts_resynced),'#81c784') 60 - +'<div class="sec-head" style="margin-top:0.6rem">Firehose</div>' 61 - +(function(){ 62 - var fhMax=Math.max(d.distinct_accounts_commit_strict,d.distinct_accounts_commit_lenient,d.distinct_accounts_desynced,1); 63 - var fhPct=function(v){return Math.min(100,v/fhMax*100)}; 64 - return barRow('with events processed (strict)',d.distinct_accounts_commit_strict,fhPct(d.distinct_accounts_commit_strict),'#4a90d9') 65 - +barRow('with events processed (lenient)',d.distinct_accounts_commit_lenient,fhPct(d.distinct_accounts_commit_lenient),'#ffb74d') 66 - +barRow('became desynced',d.distinct_accounts_desynced,fhPct(d.distinct_accounts_desynced),'#e57373'); 67 - })() 68 - +row('PDS hosts seen (firehose)','~'+N(d.distinct_pds_hosts)) 114 + // Firehose group: bars normalized to the largest of the three 115 + const firehoseMax = Math.max( 116 + status.distinct_accounts_commit_strict, 117 + status.distinct_accounts_commit_lenient, 118 + status.distinct_accounts_desynced, 119 + 1, 69 120 ); 70 121 71 - /* Dispatcher */ 72 - if(d.dispatcher){ 73 - var dp=d.dispatcher; 74 - var body=row('Workers',N(dp.worker_count)) 75 - +row('Cooling',N(dp.cooling.length)); 122 + const html = 123 + sectionHeading("Synced") 124 + + barRow("Distinct synced", status.distinct_accounts_all, 125 + percent(status.distinct_accounts_all, syncMax), "#64b5f6") 126 + + barRow("Resynced", status.distinct_accounts_resynced, 127 + percent(status.distinct_accounts_resynced, syncMax), "#81c784") 76 128 77 - if(dp.busy.length>0){ 78 - body+='<details><summary>Busy accounts ('+dp.busy.length+')</summary>'; 79 - body+='<table><tr><th>DID</th><th style="text-align:right">Host</th></tr>'; 80 - dp.busy.forEach(function(e){ 81 - body+='<tr><td>'+esc(e.did)+'</td><td class="num" style="font-family:ui-monospace,monospace;font-size:0.78rem">'+esc(e.host)+'</td></tr>'; 82 - }); 83 - body+='</table></details>'; 84 - }else{ 85 - body+=row('Busy','0'); 86 - } 129 + + sectionHeading("Firehose synced accounts") 130 + + barRow("Strict-mode", status.distinct_accounts_commit_strict, 131 + percent(status.distinct_accounts_commit_strict, firehoseMax), "#4a90d9") 132 + + barRow("Lenient", status.distinct_accounts_commit_lenient, 133 + percent(status.distinct_accounts_commit_lenient, firehoseMax), "#ffb74d") 134 + + barRow("Desynced", status.distinct_accounts_desynced, 135 + percent(status.distinct_accounts_desynced, firehoseMax), "#e57373") 87 136 88 - if(dp.hosts.length>0){ 89 - body+='<div class="sec-head">Active hosts ('+dp.hosts.length+')</div>'; 90 - body+='<table><tr><th>Host</th><th style="text-align:right">Workers</th></tr>'; 91 - dp.hosts.slice(0,20).forEach(function(e){ 92 - body+='<tr><td>'+esc(e.host)+'</td><td class="num">'+e.workers+'</td></tr>'; 93 - }); 94 - if(dp.hosts.length>20)body+='<tr><td colspan="2" class="empty">+'+(dp.hosts.length-20)+' more</td></tr>'; 95 - body+='</table>'; 96 - } 137 + + statRow("PDS hosts seen (firehose??)", formatNumber(status.distinct_pds_hosts)); 138 + 139 + return card("Accounts", html); 140 + } 141 + 142 + function renderDispatcherCard(dispatcher) { 143 + let html = statRow("Workers", formatNumber(dispatcher.worker_count)) 144 + + statRow("Cooling", formatNumber(dispatcher.cooling.length)); 145 + 146 + // Busy accounts — expandable list of DIDs currently being resynced 147 + if (dispatcher.busy.length > 0) { 148 + html += `<details> 149 + <summary>Busy accounts (${dispatcher.busy.length})</summary> 150 + <table> 151 + <tr><th>DID</th><th style="text-align:right">Host</th></tr> 152 + ${dispatcher.busy.map(entry => 153 + `<tr> 154 + <td>${escapeHtml(entry.did)}</td> 155 + <td class="numeric" style="font-family:ui-monospace,monospace; font-size:0.78rem"> 156 + ${escapeHtml(entry.host)} 157 + </td> 158 + </tr>` 159 + ).join("")} 160 + </table> 161 + </details>`; 162 + } else { 163 + html += statRow("Busy", "0"); 164 + } 165 + 166 + // Per-host worker distribution 167 + if (dispatcher.hosts.length > 0) { 168 + const visible = dispatcher.hosts.slice(0, 20); 169 + const overflow = dispatcher.hosts.length - visible.length; 170 + 171 + html += sectionHeading(`Active hosts (${dispatcher.hosts.length})`); 172 + html += `<table> 173 + <tr><th>Host</th><th style="text-align:right">Workers</th></tr> 174 + ${visible.map(h => 175 + `<tr><td>${escapeHtml(h.host)}</td><td class="numeric">${h.workers}</td></tr>` 176 + ).join("")} 177 + ${overflow > 0 ? `<tr><td colspan="2" class="empty">+${overflow} more</td></tr>` : ""} 178 + </table>`; 179 + } 180 + 181 + // Hosts in rate-limit cooldown 182 + if (dispatcher.cooling.length > 0) { 183 + html += sectionHeading("Cooling down"); 184 + html += `<table> 185 + <tr><th>Host</th><th style="text-align:right">Remaining</th></tr> 186 + ${dispatcher.cooling.map(c => 187 + `<tr><td>${escapeHtml(c.host)}</td><td class="numeric">${c.remaining_secs.toFixed(0)}s</td></tr>` 188 + ).join("")} 189 + </table>`; 190 + } 191 + 192 + return card("Dispatcher", html); 193 + } 194 + 195 + function renderThrottledHostsCard(throttledHosts) { 196 + const entries = Object.entries(throttledHosts); 197 + if (entries.length === 0) return ""; 198 + 199 + // Sort by total blocked requests, descending 200 + const sorted = entries 201 + .map(([host, paths]) => ({ 202 + host, 203 + paths: Object.entries(paths).sort((a, b) => b[1] - a[1]), 204 + total: Object.values(paths).reduce((sum, n) => sum + n, 0), 205 + })) 206 + .sort((a, b) => b.total - a.total); 207 + 208 + const body = sorted.map(({ host, paths, total }) => ` 209 + <details> 210 + <summary>${escapeHtml(host)} \u2014 ${formatNumber(total)} blocked</summary> 211 + <div class="detail-paths"> 212 + ${paths.map(([path, count]) => 213 + `<div>${escapeHtml(path)}: ${formatNumber(count)}</div>` 214 + ).join("")} 215 + </div> 216 + </details> 217 + `).join(""); 218 + 219 + return card(`Throttled Hosts (${entries.length})`, body); 220 + } 221 + 222 + // ── Main render ────────────────────────────────────────────────────────── 223 + 224 + function render(status) { 225 + // Update header info 226 + const infoEl = document.getElementById("info"); 227 + if (status.first_startup_ms) { 228 + const age = formatDuration(Date.now() - status.first_startup_ms); 229 + const starts = status.startup_count; 230 + infoEl.textContent = `first started ${age} ago \u00b7 ${starts} start${starts !== 1 ? "s" : ""}`; 231 + } else { 232 + infoEl.textContent = ""; 233 + } 97 234 98 - if(dp.cooling.length>0){ 99 - body+='<div class="sec-head" style="margin-top:0.5rem">Cooling down</div>'; 100 - body+='<table><tr><th>Host</th><th style="text-align:right">Remaining</th></tr>'; 101 - dp.cooling.forEach(function(c){ 102 - body+='<tr><td>'+esc(c.host)+'</td><td class="num">'+c.remaining_secs.toFixed(0)+'s</td></tr>'; 103 - }); 104 - body+='</table>'; 105 - } 235 + // Update tab title with queue depth for at-a-glance monitoring 236 + document.title = `lightrail \u00b7 Q:${formatNumber(status.resync_queue_depth)}`; 106 237 107 - h+=card('Dispatcher',body); 238 + // Build all cards 239 + let html = renderIndexingCard(status) 240 + + renderCollectionsCard(status) 241 + + renderAccountsCard(status); 242 + 243 + if (status.dispatcher) { 244 + html += renderDispatcherCard(status.dispatcher); 108 245 } 109 246 110 - /* Throttled hosts */ 111 - if(d.throttled_hosts){ 112 - var hosts=Object.entries(d.throttled_hosts); 113 - if(hosts.length>0){ 114 - hosts.sort(function(a,b){ 115 - var ta=Object.values(a[1]).reduce(function(s,n){return s+n},0); 116 - var tb=Object.values(b[1]).reduce(function(s,n){return s+n},0); 117 - return tb-ta; 118 - }); 119 - var body=''; 120 - hosts.forEach(function(pair){ 121 - var paths=Object.entries(pair[1]).sort(function(a,b){return b[1]-a[1]}); 122 - var total=paths.reduce(function(s,p){return s+p[1]},0); 123 - body+='<details><summary>'+esc(pair[0])+' \u2014 '+N(total)+' blocked</summary>'; 124 - body+='<div class="detail-paths">'; 125 - paths.forEach(function(p){body+='<div>'+esc(p[0])+': '+N(p[1])+'</div>'}); 126 - body+='</div></details>'; 127 - }); 128 - h+=card('Throttled Hosts (' + Object.keys(d.throttled_hosts).length + ')',body); 129 - } 247 + if (status.throttled_hosts) { 248 + html += renderThrottledHostsCard(status.throttled_hosts); 130 249 } 131 250 132 - $('g').innerHTML=h; 251 + document.getElementById("g").innerHTML = html; 133 252 } 134 253 135 - function poll(){ 136 - fetch('/admin/status',{credentials:'same-origin'}) 137 - .then(function(r){if(!r.ok)throw new Error(r.status+' '+r.statusText);return r.json()}) 138 - .then(function(d){ 139 - render(d); 140 - $('dot').classList.add('on'); 141 - $('ts').textContent=new Date().toLocaleTimeString(); 142 - }) 143 - .catch(function(e){ 144 - $('dot').classList.remove('on'); 145 - $('ts').textContent='error: '+e.message; 146 - }); 254 + // ── Polling ────────────────────────────────────────────────────────────── 255 + 256 + async function poll() { 257 + const dot = document.getElementById("dot"); 258 + const timestamp = document.getElementById("ts"); 259 + 260 + try { 261 + const response = await fetch("/admin/status", { credentials: "same-origin" }); 262 + if (!response.ok) throw new Error(`${response.status} ${response.statusText}`); 263 + 264 + const status = await response.json(); 265 + render(status); 266 + 267 + dot.classList.add("connected"); 268 + timestamp.textContent = new Date().toLocaleTimeString(); 269 + } catch (err) { 270 + dot.classList.remove("connected"); 271 + timestamp.textContent = `error: ${err.message}`; 272 + } 147 273 } 148 274 149 275 poll(); 150 - setInterval(poll,5000); 276 + setInterval(poll, POLL_INTERVAL_MS);
+31 -19
src/server/admin/page.rs
··· 16 16 Ok(Html(PAGE)) 17 17 } 18 18 19 + /// The full HTML page, assembled at compile time from separate CSS/JS files. 19 20 const PAGE: &str = concat!( 20 - "<!DOCTYPE html>\n\ 21 - <html lang=\"en\">\n\ 22 - <head>\n\ 23 - <meta charset=\"utf-8\">\n\ 24 - <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\ 25 - <title>lightrail admin</title>\n\ 26 - <style>\n", 21 + r#"<!doctype html> 22 + <html lang="en"> 23 + <head> 24 + <meta charset="utf-8"> 25 + <meta name="viewport" content="width=device-width, initial-scale=1"> 26 + <title>lightrail admin</title> 27 + <style> 28 + "#, 27 29 include_str!("page.css"), 28 - "\n</style>\n\ 29 - </head>\n\ 30 - <body>\n\ 31 - <header>\n\ 32 - <div><h1>lightrail</h1><div class=\"sub\" id=\"info\"></div></div>\n\ 33 - <div class=\"sub\"><span class=\"dot\" id=\"dot\"></span><span id=\"ts\">loading&hellip;</span></div>\n\ 34 - </header>\n\ 35 - <div class=\"grid\" id=\"g\"></div>\n\ 36 - <script>\n", 30 + r#" 31 + </style> 32 + </head> 33 + <body> 34 + <header> 35 + <div> 36 + <h1>lightrail admin</h1> 37 + <div class="subtitle" id="info"></div> 38 + </div> 39 + <div class="subtitle"> 40 + <span class="poll-dot" id="dot"></span> 41 + <span id="ts">loading&hellip;</span> 42 + </div> 43 + </header> 44 + <div class="grid" id="g"></div> 45 + <script> 46 + "#, 37 47 include_str!("page.js"), 38 - "\n</script>\n\ 39 - </body>\n\ 40 - </html>\n", 48 + r#" 49 + </script> 50 + </body> 51 + </html> 52 + "#, 41 53 );
+4
src/sync/resync/dispatcher.rs
··· 185 185 let client = client.clone(); 186 186 let resolver = resolver.clone(); 187 187 let db = db.clone(); 188 + let token = token.clone(); 188 189 let handle = workers.spawn(async move { 189 190 run_worker( 190 191 item, 191 192 &resolver, 192 193 &client, 193 194 &db, 195 + token, 194 196 describe_timeout, 195 197 get_repo_timeout, 196 198 force_get_repo, ··· 372 374 resolver: &crate::identity::Resolver, 373 375 client: &crate::http::ThrottledClient, 374 376 db: &DbRef, 377 + token: tokio_util::sync::CancellationToken, 375 378 describe_timeout: std::time::Duration, 376 379 get_repo_timeout: std::time::Duration, 377 380 force_get_repo: bool, ··· 382 385 resolver, 383 386 item.did, 384 387 db, 388 + token, 385 389 describe_timeout, 386 390 get_repo_timeout, 387 391 force_get_repo,
+32 -23
src/sync/resync/mod.rs
··· 32 32 }; 33 33 use tracing::info; 34 34 35 - use crate::storage::{ 36 - DbRef, 37 - repo::{AccountStatus, RepoInfo, RepoPrev, RepoState}, 38 - }; 35 + use crate::storage::DbRef; 36 + use crate::storage::repo::{AccountStatus, RepoInfo, RepoPrev, RepoState}; 37 + use crate::util::TokenExt; 39 38 40 39 pub use dispatcher::DispatcherConfig; 41 40 ··· 98 97 /// The repo is likely tiny, intentionally fall through to sync.getRepo 99 98 #[error("should getRepo because it's likely tiny")] 100 99 GetSmallRepo, 100 + /// The request was externally cancelled 101 + #[error("externally cancelled")] 102 + Cancelled, 101 103 } 102 104 103 105 /// The specific reason a repository is inaccessible. ··· 123 125 resolver: &crate::identity::Resolver, 124 126 did: Did<'_>, 125 127 db: &DbRef, 128 + token: tokio_util::sync::CancellationToken, 126 129 describe_timeout: Duration, 127 130 get_repo_timeout: Duration, 128 131 force_get_repo: bool, ··· 141 144 client, 142 145 base, 143 146 did.clone(), 147 + token, 144 148 describe_timeout, 145 149 get_repo_timeout, 146 150 force_get_repo, ··· 265 269 client: &C, 266 270 base: &jacquard_common::url::Url, 267 271 did: Did<'_>, 272 + token: tokio_util::sync::CancellationToken, 268 273 describe_timeout: Duration, 269 274 get_repo_timeout: Duration, 270 275 force_get_repo: bool, ··· 273 278 C: HttpClient + HttpClientExt + Sync, 274 279 { 275 280 if !force_get_repo { 276 - let describe_result = tokio::time::timeout( 277 - describe_timeout, 278 - describe_repo::fetch_collections(client, base, did.clone()), 279 - ) 280 - .await; 281 + let Some(describe_result) = token 282 + .timeout( 283 + describe_timeout, 284 + describe_repo::fetch_collections(client, base, did.clone()), 285 + ) 286 + .await 287 + else { 288 + return Err(GetCollectionsError::Cancelled); 289 + }; 281 290 282 291 match describe_result { 283 292 Ok(Ok(snapshot)) => { ··· 297 306 } 298 307 } 299 308 300 - let result = tokio::time::timeout( 301 - get_repo_timeout, 302 - get_repo::fetch_collections(client, base, did), 303 - ) 304 - .await 305 - .unwrap_or_else(|_| { 306 - Err(GetCollectionsError::Request( 307 - "getRepo timed out".to_string(), 308 - )) 309 - }); 310 - if result.is_ok() { 311 - metrics::counter!("lightrail_resync_fetch_total", "source" => "get_repo").increment(1); 312 - } 313 - result 309 + let res = token 310 + .timeout( 311 + get_repo_timeout, 312 + get_repo::fetch_collections(client, base, did), 313 + ) 314 + .await 315 + .ok_or(GetCollectionsError::Cancelled)? 316 + .unwrap_or_else(|_| { 317 + Err(GetCollectionsError::Request( 318 + "getRepo timed out".to_string(), 319 + )) 320 + })?; 321 + metrics::counter!("lightrail_resync_fetch_total", "source" => "get_repo").increment(1); 322 + Ok(res) 314 323 }
+14 -1
src/util.rs
··· 1 1 use std::time::{Duration, SystemTime, UNIX_EPOCH}; 2 - use tokio::time::sleep; 2 + use tokio::time::{error::Elapsed, sleep, timeout}; 3 3 use tokio_util::sync::CancellationToken; 4 4 5 5 /// Convert a `SystemTime` to milliseconds since the Unix epoch. ··· 23 23 /// 24 24 /// returns `true` when completed after the full sleep completed 25 25 fn sleep(&self, d: Duration) -> impl Future<Output = bool>; 26 + /// runs the future wrapped in a timeout wrapped in a token canceller 27 + /// 28 + /// returns None if the token cancels before the timeout or future completes 29 + /// returns Some(Err(elapsed)) if the timeout completes before the future 30 + /// otherwise returns Some(Ok(F::Output)) if the future gets to finish 31 + fn timeout<F: Future>( 32 + &self, 33 + d: Duration, 34 + fut: F, 35 + ) -> impl Future<Output = Option<Result<F::Output, Elapsed>>>; 26 36 } 27 37 28 38 impl TokenExt for CancellationToken { ··· 31 41 } 32 42 async fn sleep(&self, d: Duration) -> bool { 33 43 self.run_until_cancelled(sleep(d)).await.is_some() 44 + } 45 + async fn timeout<F: Future>(&self, d: Duration, fut: F) -> Option<Result<F::Output, Elapsed>> { 46 + self.run_until_cancelled(timeout(d, fut)).await 34 47 } 35 48 }