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