// host-services page: polls /api/host and /api/lan and renders them.
const $ = (id) => document.getElementById(id)
const STATES = ['active', 'degraded', 'failed', 'dormant']
const STATE_LABEL = { active: 'Active', degraded: 'Degraded', failed: 'Failed', dormant: 'Dormant' }
const KIND_LABEL = { pgh: 'pgh app', unit: 'systemd', 'user-unit': 'user unit', process: 'process', port: 'port' }
const ORDER = { failed: 0, degraded: 1, active: 2, dormant: 3 }
let host = null
let lan = null
let filter = 'all'
let query = ''
function esc (s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
}
function ago (iso) {
if (!iso) return ''
const t = Date.parse(iso)
if (isNaN(t)) return iso
const s = Math.round((Date.now() - t) / 1000)
if (s < 60) return s + 's ago'
if (s < 3600) return Math.round(s / 60) + 'm ago'
if (s < 86400) return Math.round(s / 3600) + 'h ago'
return Math.round(s / 86400) + 'd ago'
}
// Where a browser on the LAN reaches a port of host `h`.
function portUrl (h, p) {
const tls = !p.http && (p.port === 443 || p.port === 8443 || p.port === 8920)
return (tls ? 'https://' : 'http://') + h + (p.port === (tls ? 443 : 80) ? '' : ':' + p.port) + '/'
}
function portChip (p, hostname) {
const label = p.label && p.label.name
const title = p.http && p.http.title
const what = [title, label && label !== title ? label : null].filter(Boolean).join(' · ')
const status = p.http ? p.http.code : (p.banner ? 'tcp' : '')
const reachable = p.scope !== 'local'
const head = reachable && (p.http || p.open)
? '<a href="' + esc(portUrl(hostname, p)) + '" target="_blank" rel="noopener">:' + p.port + '</a>'
: '<span class="mono">:' + p.port + '</span>'
const notes = [
p.scope === 'local' ? 'localhost only' : null,
p.scope === 'addr' ? p.addr : null,
p.open === false ? 'not answering' : null,
status ? String(status) : null
].filter(Boolean).join(' · ')
return '<span class="port' + (p.open === false ? ' closed' : '') + '" title="' + esc([what, notes, p.banner].filter(Boolean).join('\n')) + '">' +
head + (what ? '<span class="t">' + esc(what) + '</span>' : '') +
(notes ? '<span class="t">' + esc(notes) + '</span>' : '') + '</span>'
}
// "Sonarr :8989 · Login - Radarr :7878": the web pages this service serves,
// by title, so the port list isn't the only clue to what's where.
function webLine (s, hostname) {
const pages = s.ports.filter((p) => p.http && p.http.title && p.open !== false)
if (!pages.length) return ''
return '<div class="web">' + pages.map((p) => {
const port = '<span class="mono">:' + p.port + '</span>'
return p.scope === 'local'
? '<span>' + esc(p.http.title) + ' ' + port + ' <span class="sub">localhost only</span></span>'
: '<a href="' + esc(portUrl(hostname, p)) + '" target="_blank" rel="noopener">' + esc(p.http.title) + ' ' + port + '</a>'
}).join('') + '</div>'
}
function displayName (s) {
return (s.label && s.label.name) || s.name
}
function matches (s) {
if (filter === 'active' && s.state !== 'active') return false
if (filter === 'dormant' && s.state !== 'dormant') return false
if (filter === 'problems' && s.state !== 'degraded' && s.state !== 'failed') return false
if (!query) return true
const hay = [displayName(s), s.name, s.description, s.user, s.kind, (s.label && s.label.description),
...s.ports.map((p) => p.port + ' ' + ((p.http && p.http.title) || '') + ' ' + ((p.label && p.label.name) || ''))].join(' ').toLowerCase()
return hay.includes(query)
}
function renderHost () {
if (!host) return
$('host').textContent = host.host
document.title = host.host + ' · services'
$('updated').textContent = 'updated ' + ago(host.at)
const n = Object.fromEntries(STATES.map((k) => [k, 0]))
for (const s of host.services) n[s.state]++
$('counts').innerHTML = STATES.map((k) => '<span class="count"><span class="dot ' + k + '"></span><b>' + n[k] + '</b> ' + STATE_LABEL[k] + '</span>').join('')
const errs = host.errors || []
$('errors').hidden = errs.length === 0
$('errors').innerHTML = errs.map(esc).join('<br>')
const hostname = location.hostname
const list = host.services.filter(matches).sort((a, b) =>
(ORDER[a.state] - ORDER[b.state]) || displayName(a).localeCompare(displayName(b)))
$('services').innerHTML = list.length
? list.map((s) => {
const meta = [
s.description || (s.label && s.label.description),
s.version ? 'v' + s.version : null,
s.kind === 'pgh' ? 'autostart ' + (s.autostart ? 'on' : 'off') : null,
s.enabled && s.kind !== 'pgh' ? s.enabled : null,
s.user ? 'user ' + s.user : null,
s.owner && s.owner.user ? 'user ' + s.owner.user : null,
s.owner && s.owner.cmd ? s.owner.cmd : null,
s.since ? (s.running ? 'up since ' : 'stopped ') + ago(s.since) : null,
s.kind === 'pgh' && !s.running && s.lastExitCode != null ? 'last exit ' + s.lastExitCode : null,
s.activeState && s.state !== 'active' && s.state !== 'dormant' ? s.activeState + '/' + s.subState : null
].filter(Boolean)
return '<div class="card ' + s.state + '">' +
'<div class="row1"><span class="state ' + s.state + '"><span class="dot ' + s.state + '"></span>' + STATE_LABEL[s.state] + '</span>' +
'<span class="name">' + esc(displayName(s)) + '</span>' +
(displayName(s) !== s.name ? '<span class="mono sub">' + esc(s.name) + '</span>' : '') +
'<span class="badge">' + esc(KIND_LABEL[s.kind] || s.kind) + '</span></div>' +
webLine(s, hostname) +
(meta.length ? '<div class="meta">' + meta.map((m) => '<span>' + esc(m) + '</span>').join('') + '</div>' : '') +
(s.ports.length ? '<div class="ports">' + s.ports.map((p) => portChip(p, hostname)).join('') + '</div>' : '') +
'</div>'
}).join('')
: '<div class="empty">Nothing matches.</div>'
const sys = host.system || []
$('system-sum').textContent = 'Operating-system units (' + sys.length + ')'
$('system').innerHTML = sys.sort((a, b) => a.name.localeCompare(b.name)).map((u) => {
const st = u.state === 'active' ? 'active' : u.state === 'failed' ? 'failed' : 'dormant'
return '<span title="' + esc(u.state + '/' + u.sub + ' · ' + u.enabled) + '"><span class="dot ' + st + '"></span>' + esc(u.name) + '</span>'
}).join('')
}
function renderLan () {
if (!lan) return
const r = lan.result
$('scan').disabled = lan.scanning
$('scan').textContent = lan.scanning ? 'Scanning…' : 'Scan now'
$('lan-meta').textContent = lan.scanning
? 'Scanning: ' + lan.progress
: r ? r.hosts.length + ' devices on ' + r.subnets.map((s) => s + '.0/24').join(', ') + ' · ' + r.ports + ' ports checked · scanned ' + ago(r.at) + ' in ' + Math.round(r.durationMs / 1000) + 's'
: 'No scan yet.'
if (!r) {
$('lan').innerHTML = '<div class="empty">' + (lan.scanning ? 'First scan running…' : 'No scan yet.') + '</div>'
return
}
$('lan').innerHTML = r.hosts.map((h) => {
const name = h.names[0] || h.ip
const linkHost = h.names[0] || h.ip
const ports = h.ports.map((p) => portChip({ ...p, scope: 'all', open: true }, linkHost)).join('')
const mdns = h.mdns.map((m) => m.name + ' (' + m.type.replace(/^_|\._tcp$|\._udp$/g, '') + (m.port ? ':' + m.port : '') + ')')
return '<div class="card">' +
'<div class="row1"><span class="name">' + esc(name) + '</span>' +
(name !== h.ip ? '<span class="mono sub">' + esc(h.ip) + '</span>' : '') +
(h.self ? '<span class="badge">this host</span>' : '') +
(h.ports.length ? '' : '<span class="badge">no open ports found</span>') + '</div>' +
'<div class="meta">' + [h.mac ? '<span class="mono">' + esc(h.mac) + '</span>' : null, ...h.names.slice(1).map(esc)].filter(Boolean).map((m) => '<span>' + m + '</span>').join('') + '</div>' +
(ports ? '<div class="ports">' + ports + '</div>' : '') +
(mdns.length ? '<div class="meta"><span>mDNS: ' + esc(mdns.join(', ')) + '</span></div>' : '') +
'</div>'
}).join('')
}
async function loadHost () {
try {
const r = await fetch('/api/host')
host = await r.json()
renderHost()
} catch (err) {
$('errors').hidden = false
$('errors').textContent = 'Could not load /api/host: ' + err.message
}
}
async function loadLan () {
try {
lan = await (await fetch('/api/lan')).json()
renderLan()
} catch {}
setTimeout(loadLan, lan && lan.scanning ? 3000 : 30000)
}
$('filters').addEventListener('click', (e) => {
const f = e.target.dataset.f
if (!f) return
filter = f
for (const b of $('filters').children) b.classList.toggle('on', b.dataset.f === f)
renderHost()
})
$('q').addEventListener('input', (e) => {
query = e.target.value.trim().toLowerCase()
renderHost()
})
document.querySelector('.tabs').addEventListener('click', (e) => {
const t = e.target.dataset.tab
if (!t) return
for (const b of document.querySelectorAll('.tabs button')) b.classList.toggle('on', b.dataset.tab === t)
$('tab-services').hidden = t !== 'services'
$('tab-lan').hidden = t !== 'lan'
})
$('scan').addEventListener('click', async () => {
await fetch('/api/lan/scan', { method: 'POST', headers: { 'x-host-services': '1' } })
lan = { ...(lan || {}), scanning: true, progress: 'starting' }
renderLan()
setTimeout(loadLan, 1500)
})
loadHost()
setInterval(loadHost, 20000)
setInterval(renderHost, 10000)
loadLan()