Skip to content

Commit 4e8ced4

Browse files
lan: read-only LAN dashboard over the podscan directory
Reads /private/net/hosts.jsonld (written by solid-tools/podscan) and shows devices, pods (relay at <pod>/relay), and verified pubkey<->WebID identities. Same-origin read only — no LAN access from the browser; works on https. Search + filter (all/pods/online), online/stale/new markers, per-device detail.
0 parents  commit 4e8ced4

8 files changed

Lines changed: 1023 additions & 0 deletions

File tree

‎.gitignore‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
node_modules/
2+
*.log
3+
.DS_Store

‎LICENSE‎

Lines changed: 661 additions & 0 deletions
Large diffs are not rendered by default.

‎README.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# lan
2+
3+
**Your local network at a glance — pod-native.** A read-only dashboard over the LAN
4+
directory that [`podscan`](https://github.com/solid-tools/podscan) writes into your pod at
5+
`/private/net/hosts.jsonld`: every device on your network, which ones are Solid pods running
6+
a Nostr relay, and the verified WebID identities behind them.
7+
8+
## How it works
9+
10+
A browser can't scan a LAN (no raw sockets). So `podscan` does the privileged work
11+
server-side and drops a **same-origin, owner-only** JSON-LD file; this app just `fetch`es and
12+
renders it — so it works even when served from `https://` GitHub Pages.
13+
14+
```
15+
podscan (CLI) ──writes──► /private/net/hosts.jsonld ──reads──► lan app
16+
```
17+
18+
## Shows
19+
20+
- **Devices** — ip · hostname · MAC · online/stale · first/last seen
21+
- **Pods** — hosts running a relay (`<pod>/relay`), flagged `✓ pod`
22+
- **Identities** — verified pubkey ⇄ WebID behind each host
23+
- Search + filter (all / pods / online), per-device detail, `new` markers
24+
25+
## Populate it
26+
27+
Run `podscan` on your pod's host:
28+
29+
```bash
30+
npx podscan scan # sweeps the LAN, writes /private/net/hosts.jsonld
31+
```
32+
33+
Schedule it (cron / systemd timer) to keep the view warm.
34+
35+
## License
36+
37+
AGPL-3.0-or-later

‎app.js‎

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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)

‎favicon.svg‎

Lines changed: 14 additions & 0 deletions
Loading

‎index.html‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6+
<title>lan — your local network</title>
7+
<link rel="icon" href="favicon.svg" type="image/svg+xml">
8+
<link rel="manifest" href="manifest.json">
9+
<link rel="stylesheet" href="style.css">
10+
</head>
11+
<body>
12+
<div class="topbar">
13+
<span class="brand">LAN</span>
14+
<a class="root" href="../../../" title="Go to your pod root">↑ root</a>
15+
</div>
16+
<div class="wrap"><div id="app"></div></div>
17+
<script src="https://unpkg.com/xlogin"></script>
18+
<script src="app.js"></script>
19+
</body>
20+
</html>

‎manifest.json‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"name": "lan — your local network",
3+
"short_name": "lan",
4+
"description": "Your local network at a glance — devices, pods, and verified identities, read from the podscan directory in your pod (/private/net/hosts.jsonld).",
5+
"start_url": "./",
6+
"scope": "./",
7+
"display": "standalone",
8+
"background_color": "#f0fdfa",
9+
"theme_color": "#0d9488",
10+
"icons": [ { "src": "favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" } ]
11+
}

‎style.css‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/* lan — local network dashboard. Slate topbar + teal accent. */
2+
3+
:root {
4+
--bg: #ffffff;
5+
--surface: #f0fdfa;
6+
--surface-2: #e6fbf6;
7+
--border: #d4f0ea;
8+
--text: #14201d;
9+
--muted: #5a716b;
10+
--dim: #8aa39c;
11+
--accent: #0d9488;
12+
--accent-soft: #d9f5ef;
13+
--danger: #dc2626;
14+
--ok: #059669;
15+
--warn: #b45309;
16+
--shadow: 0 4px 14px rgba(13, 148, 136, 0.08);
17+
}
18+
19+
* { box-sizing: border-box; }
20+
html, body { margin: 0; padding: 0; }
21+
body {
22+
background: linear-gradient(180deg, #eafaf6, var(--bg) 36%);
23+
color: var(--text);
24+
font: 15px/1.55 -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif;
25+
min-height: 100dvh;
26+
}
27+
button { font: inherit; cursor: pointer; }
28+
code { font-family: 'SF Mono', Menlo, Consolas, monospace; }
29+
30+
.topbar { display: flex; align-items: center; gap: 14px; padding: 11px 18px; background: #1c2b27; color: #fff; }
31+
.brand { font-weight: 800; letter-spacing: .06em; font-size: 13px; }
32+
.topbar .root { margin-left: auto; color: rgba(255,255,255,.6); font-size: 12px; font-weight: 600; text-decoration: none; }
33+
.topbar .root:hover { color: #fff; }
34+
35+
.wrap { max-width: 760px; margin: 0 auto; padding: 16px 16px 70px; }
36+
.muted { color: var(--muted); }
37+
.sub { margin: 2px 0 16px; font-size: .9rem; }
38+
h2 { font-size: 1.2rem; font-weight: 800; margin: 4px 0; }
39+
.signin-note { background: #fff; border: 1px solid var(--border); color: var(--accent); padding: 14px 16px; border-radius: 14px; box-shadow: var(--shadow); }
40+
.mono { font-size: .82em; word-break: break-all; }
41+
.src code { background: var(--surface); border: 1px solid var(--border); padding: 0 5px; border-radius: 5px; }
42+
43+
.head .refresh { margin-left: 6px; }
44+
45+
/* stat tiles */
46+
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin: 4px 0 16px; }
47+
.stat { background: #fff; border: 1px solid var(--border); border-radius: 14px; padding: 12px; text-align: center; box-shadow: var(--shadow); }
48+
.stat b { display: block; font-size: 1.5rem; font-weight: 800; color: var(--accent); line-height: 1.1; }
49+
.stat span { font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; color: var(--dim); }
50+
51+
/* controls */
52+
.controls { display: flex; gap: 10px; align-items: center; margin-bottom: 14px; flex-wrap: wrap; }
53+
.search { flex: 1; min-width: 180px; font: inherit; color: var(--text); background: #fff; border: 1px solid var(--border); border-radius: 10px; padding: 9px 12px; }
54+
.search:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
55+
.chips { display: flex; gap: 6px; }
56+
.chip { background: var(--surface); border: 1px solid var(--border); border-radius: 999px; padding: 6px 13px; font-size: .8rem; font-weight: 600; color: var(--muted); text-transform: capitalize; }
57+
.chip:hover { border-color: var(--accent); color: var(--accent); }
58+
.chip.on { background: var(--accent); border-color: var(--accent); color: #fff; }
59+
60+
/* device list */
61+
.devlist { display: flex; flex-direction: column; gap: 9px; }
62+
.card { background: #fff; border: 1px solid var(--border); border-radius: 14px; padding: 12px 14px; box-shadow: var(--shadow); }
63+
.dev.pod { border-color: var(--accent-soft); }
64+
.dev-h { display: flex; align-items: center; gap: 8px; cursor: pointer; }
65+
.dot { width: 9px; height: 9px; border-radius: 50%; flex: 0 0 auto; }
66+
.dot.on { background: var(--ok); box-shadow: 0 0 0 3px rgba(5,150,105,.15); }
67+
.dot.off { background: var(--dim); }
68+
.dev-h .name { font-size: .96rem; }
69+
.dev-h .when { margin-left: auto; font-size: .76rem; color: var(--dim); white-space: nowrap; }
70+
.badge { font-size: .66rem; font-weight: 700; padding: 1px 7px; border-radius: 999px; }
71+
.badge.pod { background: var(--accent-soft); color: var(--accent); }
72+
.badge.relay { background: #eef6ff; color: #2563eb; }
73+
.badge.new { background: #fff4e5; color: var(--warn); }
74+
.badge.wv { background: #e6f7ee; color: var(--ok); }
75+
.dev-sub { display: flex; align-items: center; gap: 10px; margin-top: 7px; flex-wrap: wrap; }
76+
.dev-sub code { font-size: .78rem; color: var(--muted); }
77+
.dev-sub .ip { font-weight: 600; color: var(--text); }
78+
.idcount { font-size: .74rem; color: var(--accent); font-weight: 600; }
79+
80+
/* detail */
81+
.dev-detail.open { margin-top: 11px; padding-top: 11px; border-top: 1px solid var(--border); }
82+
.kvs { display: flex; flex-direction: column; gap: 2px; margin-bottom: 8px; }
83+
.kv { display: flex; align-items: center; gap: 10px; padding: 4px 0; }
84+
.kv .kl { font-size: .68rem; text-transform: uppercase; letter-spacing: .04em; color: var(--dim); min-width: 86px; flex: 0 0 auto; }
85+
.kv .kvv { flex: 1; font-size: .82rem; word-break: break-all; }
86+
.mini { background: var(--surface-2); border: 0; color: var(--muted); border-radius: 8px; padding: 4px 9px; font-size: .76rem; font-weight: 600; }
87+
.mini:hover { background: var(--accent); color: #fff; }
88+
.ids { display: flex; flex-direction: column; gap: 8px; }
89+
.idrow { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 9px 11px; }
90+
.idn { display: flex; align-items: center; gap: 7px; }
91+
.idrow .mono { display: block; color: var(--dim); font-size: .74rem; margin-top: 2px; }
92+
.widlink { display: block; color: var(--accent); font-size: .76rem; text-decoration: none; margin-top: 3px; word-break: break-all; }
93+
.widlink:hover { text-decoration: underline; }
94+
95+
/* empty / loading */
96+
.empty { background: var(--surface); border: 1px dashed var(--border); border-radius: 16px; padding: 26px; text-align: center; color: var(--muted); }
97+
.empty.small { padding: 18px; }
98+
.empty h2 { color: var(--text); }
99+
.empty pre { background: #1c2b27; color: #d9f5ef; border-radius: 10px; padding: 12px 14px; display: inline-block; margin: 8px 0; }
100+
.empty code { font-size: .9em; }
101+
102+
/* toast */
103+
.toast { position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%) translateY(20px); background: var(--text); color: #fff; padding: 10px 18px; border-radius: 999px; font-size: .85rem; box-shadow: 0 6px 20px rgba(13,148,136,.25); opacity: 0; transition: .18s; pointer-events: none; z-index: 50; }
104+
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
105+
.toast.error { background: var(--danger); }
106+
107+
@media (max-width: 520px) { .stats { grid-template-columns: repeat(2, 1fr); } }

0 commit comments

Comments
 (0)