107107 .cta : disabled {opacity : .55 ;cursor : default}
108108 .link-status {width : 100% ;font-size : .88rem ;color : var (--muted );margin-top : .5rem ;line-height : 1.5 }
109109 .link-status .ok {color : # 1c8c6b } .link-status .err {color : # c0392b }
110+
111+ /* relay directory */
112+ .relays-head {display : flex;flex-wrap : wrap;align-items : baseline;gap : .6rem 1rem ;margin : .4rem 0 1rem }
113+ .relays-head h2 {font-family : var (--serif );font-size : 1.7rem ;font-weight : 600 }
114+ .relays-head .sub {color : var (--muted );font-size : .9rem }
115+ .relays-controls {display : flex;flex-wrap : wrap;gap : .5rem ;margin : .2rem 0 1rem }
116+ .toggle {font-size : .82rem ;font-weight : 500 ;border : 1px solid var (--line );background : # fff ;color : var (--muted );
117+ padding : .4rem .85rem ;border-radius : 999px ;cursor : pointer;transition : border-color .15s , color .15s , background .15s }
118+ .toggle : hover {border-color : var (--accent );color : var (--accent )}
119+ .toggle .on {background : var (--accent-soft );border-color : # dcd8f5 ;color : var (--accent )}
120+ .caveat {font-size : .8rem ;color : var (--faint );background : # fff ;border : 1px solid var (--line );border-radius : 10px ;padding : .55rem .8rem ;margin-bottom : 1rem }
121+ .rtable {width : 100% ;border-collapse : collapse;background : var (--surface );border : 1px solid var (--line );border-radius : 14px ;overflow : hidden;font-size : .86rem }
122+ .rtable thead th {text-align : left;font-size : .7rem ;font-weight : 700 ;letter-spacing : .06em ;text-transform : uppercase;color : var (--faint );
123+ padding : .7rem .8rem ;border-bottom : 1px solid var (--line );white-space : nowrap;cursor : pointer;user-select : none}
124+ .rtable thead th : hover {color : var (--accent )}
125+ .rtable thead th .num {text-align : right}
126+ .rtable th .arrow {color : var (--accent );font-size : .65rem }
127+ .rtable tbody td {padding : .6rem .8rem ;border-bottom : 1px solid var (--line );vertical-align : middle}
128+ .rtable tbody tr : last-child td {border-bottom : none}
129+ .rtable tbody tr : hover {background : # faf9ff }
130+ .rtable td .num {text-align : right;font-family : var (--mono );font-size : .8rem }
131+ .rtable .url {font-family : var (--mono );font-size : .82rem ;word-break : break-all}
132+ .rdot {display : inline-block;width : 8px ;height : 8px ;border-radius : 50% ;margin-right : .45rem ;vertical-align : middle}
133+ .rdot .up {background : # 1c8c6b ;box-shadow : 0 0 6px rgba (28 , 140 , 107 , .5 )} .rdot .down {background : # c0392b }
134+ .rbadge {display : inline-block;font-size : .68rem ;font-weight : 600 ;padding : .12rem .45rem ;border-radius : 6px ;margin-left : .3rem ;white-space : nowrap}
135+ .rbadge .ok {background : # e6f5ef ;color : # 1c8c6b } .rbadge .no {background : # f3eef0 ;color : # 9a6a76 }
136+ .rbadge .pay {background : # fff3e0 ;color : # b26a00 } .rbadge .auth {background : # fdeef0 ;color : # c0392b }
137+ .ubar {display : inline-block;width : 46px ;height : 6px ;border-radius : 3px ;background : var (--line );vertical-align : middle;margin-left : .5rem ;overflow : hidden}
138+ .ubar > i {display : block;height : 100% ;background : linear-gradient (90deg , var (--accent2 ), var (--accent ))}
110139</ style >
111140</ head >
112141< body >
113142< header class ="topbar ">
114143 < a class ="tb-brand " href ="/ "> < span class ="dot "> </ span > nostr.social</ a >
115144 < nav class ="tb-nav ">
145+ < a href ="/relays "> Relays</ a >
116146 < a href ="/link "> Link your pod</ a >
117147 </ nav >
118148</ header >
@@ -319,6 +349,84 @@ <h2>${esc(c.name || short(pk))}</h2>
319349 } ;
320350}
321351
352+ // ---- relay directory (firehose health data) ----
353+ const relaysState = { online : true , sortKey : 'uptime' , dir : - 1 , rows : [ ] , meta : { } } ;
354+ const RCOLS = [
355+ { key : 'relay' , label : 'Relay' , num : false } ,
356+ { key : 'online' , label : 'Status' , num : false } ,
357+ { key : 'uptime' , label : 'Uptime' , num : true } ,
358+ { key : 'responseTime' , label : 'Latency' , num : true } ,
359+ { key : 'acceptsEvents' , label : 'Writable' , num : false } ,
360+ { key : 'lastChecked' , label : 'Checked' , num : true } ,
361+ ] ;
362+
363+ function ageStr ( iso ) {
364+ if ( ! iso ) return '—' ;
365+ const t = new Date ( iso ) . getTime ( ) ; if ( isNaN ( t ) ) return '—' ;
366+ const s = ( Date . now ( ) - t ) / 1000 ;
367+ for ( const [ sec , lbl ] of [ [ 31536000 , 'y' ] , [ 2592000 , 'mo' ] , [ 86400 , 'd' ] , [ 3600 , 'h' ] , [ 60 , 'm' ] ] ) {
368+ if ( s >= sec ) return Math . floor ( s / sec ) + lbl ;
369+ }
370+ return 'now' ;
371+ }
372+
373+ function cmpRelay ( a , b , key , dir ) {
374+ let av = a [ key ] , bv = b [ key ] ;
375+ const an = av == null , bn = bv == null ; // unknown values always sort last
376+ if ( an && bn ) return 0 ; if ( an ) return 1 ; if ( bn ) return - 1 ;
377+ if ( typeof av === 'string' ) return dir * av . localeCompare ( bv ) ;
378+ if ( typeof av === 'boolean' ) { av = av ? 1 : 0 ; bv = bv ? 1 : 0 ; }
379+ return dir * ( av - bv ) ;
380+ }
381+
382+ function relayRow ( r ) {
383+ const flags = ( r . requiresPayment ? '<span class="rbadge pay">paid</span>' : '' )
384+ + ( r . requiresAuth ? '<span class="rbadge auth">auth</span>' : '' ) ;
385+ const up = r . uptime == null ? '—'
386+ : `${ Math . round ( r . uptime ) } %<span class="ubar"><i style="width:${ Math . max ( 0 , Math . min ( 100 , r . uptime ) ) } %"></i></span>` ;
387+ return `<tr>
388+ <td class="url">${ esc ( r . relay ) } ${ flags } </td>
389+ <td><span class="rdot ${ r . online ? 'up' : 'down' } "></span>${ r . online ? 'online' : 'offline' } </td>
390+ <td class="num">${ up } </td>
391+ <td class="num">${ r . responseTime == null ? '—' : r . responseTime + 'ms' } </td>
392+ <td>${ r . acceptsEvents === true ? '<span class="rbadge ok">yes</span>' : r . acceptsEvents === false ? '<span class="rbadge no">no</span>' : '—' } </td>
393+ <td class="num">${ ageStr ( r . lastChecked ) } </td>
394+ </tr>` ;
395+ }
396+
397+ function renderRelays ( ) {
398+ const { rows, meta, sortKey, dir, online } = relaysState ;
399+ const sorted = [ ...rows ] . sort ( ( a , b ) => cmpRelay ( a , b , sortKey , dir ) ) ;
400+ const head = RCOLS . map ( ( c ) => `<th class="${ c . num ? 'num' : '' } " data-key="${ c . key } ">${ c . label } ${ sortKey === c . key ? ` <span class="arrow">${ dir < 0 ? '▼' : '▲' } </span>` : '' } </th>` ) . join ( '' ) ;
401+ view . innerHTML = `<div class="detail">
402+ <div class="back" id="back">← all identities</div>
403+ <div class="relays-head"><h2>Relay directory</h2>
404+ <span class="sub"><strong>${ fmt ( meta . online ) } </strong> online of <strong>${ fmt ( meta . total ) } </strong> known relays</span></div>
405+ <div class="caveat">Health snapshot · relays last checked <strong>${ esc ( meta . lastChecked ? ageStr ( meta . lastChecked ) + ' ago' : 'unknown' ) } </strong>. Live monitoring is paused, so these figures reflect that snapshot.</div>
406+ <div class="relays-controls"><button class="toggle ${ online ? 'on' : '' } " id="r-online">Online only</button></div>
407+ <div style="overflow-x:auto"><table class="rtable"><thead><tr>${ head } </tr></thead><tbody>${ sorted . map ( relayRow ) . join ( '' ) } </tbody></table></div>
408+ <div class="caveat" style="margin-top:1rem">Showing ${ sorted . length } relays. Click a column to sort.</div>
409+ </div>` ;
410+ $ ( '#back' ) . onclick = ( ) => navigate ( '/' ) ;
411+ $ ( '#r-online' ) . onclick = ( ) => { relaysState . online = ! relaysState . online ; relaysView ( ) ; } ;
412+ view . querySelectorAll ( '.rtable thead th' ) . forEach ( ( th ) => { th . onclick = ( ) => {
413+ const k = th . dataset . key ;
414+ if ( relaysState . sortKey === k ) relaysState . dir = - relaysState . dir ;
415+ else { relaysState . sortKey = k ; relaysState . dir = k === 'relay' ? 1 : - 1 ; }
416+ renderRelays ( ) ;
417+ } ; } ) ;
418+ }
419+
420+ async function relaysView ( ) {
421+ if ( $ ( '#q' ) ) $ ( '#q' ) . value = '' ;
422+ $ ( '#stat' ) . innerHTML = '' ;
423+ view . innerHTML = '<div class="loading">loading relay directory…</div>' ;
424+ const data = await fetch ( `/api/relays-directory?limit=2000${ relaysState . online ? '&online=1' : '' } ` ) . then ( ( r ) => r . json ( ) ) . catch ( ( ) => null ) ;
425+ if ( ! data || ! Array . isArray ( data . relays ) ) { view . innerHTML = '<div class="loading">Could not load the relay directory.</div>' ; return ; }
426+ relaysState . rows = data . relays ; relaysState . meta = data ;
427+ renderRelays ( ) ;
428+ }
429+
322430function navigate ( path ) { if ( location . pathname !== path ) history . pushState ( { } , '' , path ) ; route ( ) ; }
323431
324432let searchTimer ;
@@ -330,6 +438,7 @@ <h2>${esc(c.name || short(pk))}</h2>
330438
331439function route ( ) {
332440 if ( location . pathname === '/link' ) return linkView ( ) ;
441+ if ( location . pathname === '/relays' ) return relaysView ( ) ;
333442 const m = location . pathname . match ( / ^ \/ ( [ 0 - 9 a - f ] { 64 } ) $ / i) ;
334443 if ( m ) return profile ( m [ 1 ] . toLowerCase ( ) ) ;
335444 const h = location . hash . slice ( 1 ) ; // legacy #<hex> links still resolve
0 commit comments