OverviewFilesCommitsIssuesDocsReleasesPeers

host-services / static / app.js

210 lines · javascript ref:
// 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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()