|
| 1 | +// lan — a read-only view of your local network, pod-native. It reads the |
| 2 | +// directory written by `podscan` (solid-tools/podscan) into your pod: |
| 3 | +// /private/net/hosts.jsonld |
| 4 | +// …and shows the devices on your LAN, which ones are Solid pods running a |
| 5 | +// Nostr relay, and the verified WebID identities behind them. The browser |
| 6 | +// can't scan a LAN itself; podscan does that server-side and drops this |
| 7 | +// same-origin, owner-only JSON-LD file the app simply fetches. |
| 8 | + |
| 9 | +const appEl = document.getElementById('app') |
| 10 | +const authFetch = (url, opts) => ((window.xlogin && window.xlogin.authFetch) || fetch)(url, opts) |
| 11 | +const loggedIn = () => !!(window.xlogin && window.xlogin.id) |
| 12 | +const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])) |
| 13 | +const toArr = (v) => v == null ? [] : Array.isArray(v) ? v : [v] |
| 14 | + |
| 15 | +const LAN_HOSTS = new URL('../../../private/net/hosts.jsonld', location.href) |
| 16 | +const FRESH_MS = 120000 // a host counts as "online" if seen within ~2 min of this scan |
| 17 | + |
| 18 | +// npub display sugar (lazy bech32) — the hex pubkey is canonical. |
| 19 | +let _base = null |
| 20 | +const base = async () => (_base || (_base = await import('https://esm.sh/@scure/base@1.1.6'))) |
| 21 | +const hexToBytes = (h) => Uint8Array.from(h.match(/.{1,2}/g).map((b) => parseInt(b, 16))) |
| 22 | +async function npub(hex) { try { const { bech32 } = await base(); return bech32.encode('npub', bech32.toWords(hexToBytes(hex))) } catch { return '' } } |
| 23 | + |
| 24 | +const toast = (m, err) => { let t = document.querySelector('.toast'); if (!t) { t = document.createElement('div'); t.className = 'toast'; document.body.appendChild(t) } t.className = 'toast' + (err ? ' error' : ''); t.textContent = m; requestAnimationFrame(() => t.classList.add('show')); setTimeout(() => t.classList.remove('show'), 2200) } |
| 25 | +async function copy(t) { try { await navigator.clipboard.writeText(t); toast('Copied') } catch { toast('Copy failed', true) } } |
| 26 | + |
| 27 | +function ago(iso) { |
| 28 | + const t = Date.parse(iso); if (isNaN(t)) return '' |
| 29 | + const s = Math.max(0, (Date.now() - t) / 1000) |
| 30 | + if (s < 60) return Math.floor(s) + 's ago' |
| 31 | + if (s < 3600) return Math.floor(s / 60) + 'm ago' |
| 32 | + if (s < 86400) return Math.floor(s / 3600) + 'h ago' |
| 33 | + return Math.floor(s / 86400) + 'd ago' |
| 34 | +} |
| 35 | +const isPod = (h) => toArr(h.services).some((s) => s && s.relay) || toArr(h.identities).length > 0 |
| 36 | +const relayOf = (h) => (toArr(h.services).find((s) => s && s.relay) || {}).relay || null |
| 37 | +function isOnline(h, scannedAt) { const l = Date.parse(h.lastSeen), s = Date.parse(scannedAt); return !isNaN(l) && !isNaN(s) && (s - l) < FRESH_MS } |
| 38 | +function isNew(h, scannedAt) { const f = Date.parse(h.firstSeen), s = Date.parse(scannedAt); return !isNaN(f) && !isNaN(s) && Math.abs(s - f) < FRESH_MS } |
| 39 | + |
| 40 | +let DOC = null |
| 41 | +let FILTER = 'all' // all | pods | online |
| 42 | +let Q = '' |
| 43 | + |
| 44 | +async function loadDoc() { try { const r = await authFetch(LAN_HOSTS, { headers: { Accept: 'application/ld+json' } }); return r.ok ? await r.json() : null } catch { return null } } |
| 45 | + |
| 46 | +async function render() { |
| 47 | + if (!loggedIn()) { appEl.innerHTML = '<div class="signin-note">Sign in (the login pill, bottom-right) to see your local network.</div>'; return } |
| 48 | + appEl.innerHTML = '<p class="muted">Loading…</p>' |
| 49 | + DOC = await loadDoc() |
| 50 | + paint() |
| 51 | +} |
| 52 | + |
| 53 | +function emptyState() { |
| 54 | + return `<div class="empty"> |
| 55 | + <h2>No LAN directory yet</h2> |
| 56 | + <p>This app reads <code>/private/net/hosts.jsonld</code> — written by <b>podscan</b>. Run it on your pod's host to populate it:</p> |
| 57 | + <pre><code>npx podscan scan</code></pre> |
| 58 | + <p class="muted">podscan sweeps your LAN, finds pods running a Nostr relay, resolves the verified WebID identities behind them, and writes the directory this app shows.</p> |
| 59 | + </div>` |
| 60 | +} |
| 61 | + |
| 62 | +function paint() { |
| 63 | + if (!DOC) { appEl.innerHTML = emptyState(); return } |
| 64 | + const hosts = toArr(DOC.hosts) |
| 65 | + const scannedAt = DOC.scannedAt |
| 66 | + const pods = hosts.filter(isPod).length |
| 67 | + const online = hosts.filter((h) => isOnline(h, scannedAt)).length |
| 68 | + const ids = hosts.reduce((n, h) => n + toArr(h.identities).length, 0) |
| 69 | + |
| 70 | + appEl.innerHTML = ` |
| 71 | + <div class="head"> |
| 72 | + <h2>Local network</h2> |
| 73 | + <p class="sub muted">${esc(DOC.subnet || '')} · scanned ${esc(ago(scannedAt))} · <span class="src">from <code>podscan</code></span> <button class="mini refresh">↻ Refresh</button></p> |
| 74 | + </div> |
| 75 | + <div class="stats"> |
| 76 | + <div class="stat"><b>${hosts.length}</b><span>devices</span></div> |
| 77 | + <div class="stat"><b>${online}</b><span>online</span></div> |
| 78 | + <div class="stat"><b>${pods}</b><span>pods</span></div> |
| 79 | + <div class="stat"><b>${ids}</b><span>identities</span></div> |
| 80 | + </div> |
| 81 | + <div class="controls"> |
| 82 | + <input class="search" placeholder="Search ip · hostname · mac…" value="${esc(Q)}"> |
| 83 | + <div class="chips"> |
| 84 | + ${['all', 'pods', 'online'].map((f) => `<button class="chip ${f === FILTER ? 'on' : ''}" data-f="${f}">${f}</button>`).join('')} |
| 85 | + </div> |
| 86 | + </div> |
| 87 | + <div class="devlist"></div>` |
| 88 | + |
| 89 | + appEl.querySelector('.refresh').onclick = () => render() |
| 90 | + const search = appEl.querySelector('.search') |
| 91 | + search.oninput = () => { Q = search.value; renderList() } |
| 92 | + appEl.querySelectorAll('.chip').forEach((c) => { c.onclick = () => { FILTER = c.dataset.f; paint() } }) |
| 93 | + renderList() |
| 94 | +} |
| 95 | + |
| 96 | +function renderList() { |
| 97 | + const list = appEl.querySelector('.devlist'); if (!list) return |
| 98 | + const scannedAt = DOC.scannedAt |
| 99 | + const q = Q.trim().toLowerCase() |
| 100 | + let hosts = toArr(DOC.hosts) |
| 101 | + if (FILTER === 'pods') hosts = hosts.filter(isPod) |
| 102 | + if (FILTER === 'online') hosts = hosts.filter((h) => isOnline(h, scannedAt)) |
| 103 | + if (q) hosts = hosts.filter((h) => [h.ip, h.hostname, h.mac].some((v) => String(v || '').toLowerCase().includes(q))) |
| 104 | + hosts = hosts.slice().sort((a, b) => String(a.ip).localeCompare(String(b.ip), undefined, { numeric: true })) |
| 105 | + |
| 106 | + list.innerHTML = '' |
| 107 | + if (!hosts.length) { list.innerHTML = '<div class="empty small">No devices match.</div>'; return } |
| 108 | + hosts.forEach((h) => list.appendChild(deviceRow(h, scannedAt))) |
| 109 | +} |
| 110 | + |
| 111 | +function deviceRow(h, scannedAt) { |
| 112 | + const el = document.createElement('div'); el.className = 'card dev' + (isPod(h) ? ' pod' : '') |
| 113 | + const online = isOnline(h, scannedAt) |
| 114 | + const idn = toArr(h.identities).length |
| 115 | + const relay = relayOf(h) |
| 116 | + el.innerHTML = ` |
| 117 | + <div class="dev-h"> |
| 118 | + <span class="dot ${online ? 'on' : 'off'}" title="${online ? 'online' : 'last seen ' + esc(ago(h.lastSeen))}"></span> |
| 119 | + <b class="name">${esc(h.hostname || h.ip)}</b> |
| 120 | + ${isPod(h) ? '<span class="badge pod">✓ pod</span>' : ''} |
| 121 | + ${isNew(h, scannedAt) ? '<span class="badge new">new</span>' : ''} |
| 122 | + ${relay ? '<span class="badge relay">relay</span>' : ''} |
| 123 | + <span class="when">${online ? 'online' : esc(ago(h.lastSeen))}</span> |
| 124 | + </div> |
| 125 | + <div class="dev-sub"> |
| 126 | + <code class="ip">${esc(h.ip)}</code> |
| 127 | + ${h.mac ? `<code class="mac">${esc(h.mac)}</code>` : ''} |
| 128 | + ${idn ? `<span class="idcount">${idn} identit${idn === 1 ? 'y' : 'ies'}</span>` : ''} |
| 129 | + </div> |
| 130 | + <div class="dev-detail"></div>` |
| 131 | + |
| 132 | + const detail = el.querySelector('.dev-detail') |
| 133 | + el.querySelector('.dev-h').onclick = () => { |
| 134 | + if (detail.classList.contains('open')) { detail.classList.remove('open'); detail.innerHTML = ''; return } |
| 135 | + detail.classList.add('open'); fillDetail(detail, h) |
| 136 | + } |
| 137 | + return el |
| 138 | +} |
| 139 | + |
| 140 | +function fillDetail(detail, h) { |
| 141 | + const relay = relayOf(h) |
| 142 | + const rows = [] |
| 143 | + rows.push(kv('IP', h.ip, true)) |
| 144 | + if (h.hostname) rows.push(kv('Hostname', h.hostname)) |
| 145 | + if (h.mac) rows.push(kv('MAC', h.mac, true)) |
| 146 | + rows.push(kv('First seen', ago(h.firstSeen))) |
| 147 | + rows.push(kv('Last seen', ago(h.lastSeen))) |
| 148 | + if (relay) rows.push(kv('Relay', relay, true)) |
| 149 | + detail.innerHTML = `<div class="kvs">${rows.join('')}</div>` + |
| 150 | + (toArr(h.identities).length ? `<div class="ids">${toArr(h.identities).map(identityRow).join('')}</div>` : '') |
| 151 | + detail.querySelectorAll('.cp').forEach((b) => { b.onclick = (e) => { e.stopPropagation(); copy(b.dataset.v) } }) |
| 152 | + detail.querySelectorAll('.np').forEach((el) => { npub(el.dataset.hex).then((v) => { el.textContent = v || el.dataset.hex }) }) |
| 153 | +} |
| 154 | + |
| 155 | +function kv(label, value, copyable) { |
| 156 | + return `<div class="kv"><span class="kl">${esc(label)}</span><code class="kvv">${esc(value)}</code>${copyable ? `<button class="mini cp" data-v="${esc(value)}">⧉</button>` : ''}</div>` |
| 157 | +} |
| 158 | + |
| 159 | +function identityRow(id) { |
| 160 | + const name = esc(id.name || (String(id.pubkey).slice(0, 10) + '…')) |
| 161 | + return `<div class="idrow"> |
| 162 | + <div class="idn"><b>${name}</b>${id.verified ? '<span class="badge wv">✓ WebID</span>' : ''}</div> |
| 163 | + <code class="mono np" data-hex="${esc(id.pubkey)}">…</code> |
| 164 | + ${id.webid ? `<a class="widlink" href="${esc(id.webid)}" target="_blank" rel="noopener">${esc(id.webid)}</a>` : ''} |
| 165 | + </div>` |
| 166 | +} |
| 167 | + |
| 168 | +render() |
| 169 | +document.addEventListener('xlogin', render) |
| 170 | +document.addEventListener('xlogout', render) |
0 commit comments