NTP Stratum Visualizer

let currentFormat = 'ntpq'; const EXAMPLES = { ntpq: ` remote refid st t when poll reach delay offset jitter ============================================================================== *216.239.35.8 .GPS. 1 u 150 256 377 0.789 -0.243 0.187 +162.159.200.1 216.239.35.0 2 u 55 256 377 5.032 0.112 0.543 +192.168.1.10 10.0.0.1 2 u 120 256 345 1.500 0.031 0.200 -192.168.1.11 10.0.0.1 2 u 90 256 255 2.100 0.500 1.200 10.0.0.99 .INIT. 16 u - 64 0 0.000 0.000 0.000`, chrony: `MS Name/IP address Stratum Poll Reach LastRx Last sample =============================================================================== ^* ntp1.example.com 1 6 377 23 +12us[ +13us] +/- 476us ^+ 192.168.1.1 2 6 377 23 -135us[-134us] +/- 856us ^- ntp2.example.net 2 7 377 128 +2345us[+2346us] +/- 999us ^? bad-server.example.com 0 8 0 10y +0ns[ +0ns] +/- 0ns`, cisco: ` address ref clock st when poll reach delay offset disp *~216.239.35.8 .GPS. 1 30 64 377 1.00 -0.500 0.700 ~192.168.1.10 216.239.35.0 2 45 64 377 2.50 0.100 0.800 ~10.0.0.1 .INIT. 16 - 64 0 0.000 0.000 16.00` }; function setFormat(fmt) { currentFormat = fmt; document.querySelectorAll('.format-btn').forEach(b => b.classList.remove('active')); document.getElementById('btn-' + fmt).classList.add('active'); document.getElementById('inputText').placeholder = fmt === 'ntpq' ? 'Paste ntpq -p output here...' : fmt === 'chrony' ? 'Paste chronyc sources output here...' : 'Paste "show ntp associations" output here...'; } window.setFormat = setFormat; function loadExample() { document.getElementById('inputText').value = EXAMPLES[currentFormat]; } window.loadExample = loadExample; function clearAll() { document.getElementById('inputText').value = ''; document.getElementById('resultsDiv').style.display = 'none'; } window.clearAll = clearAll; // ── Parsers ─────────────────────────────────────────────────────────────────── function parseNtpq(text) { const servers = []; for (const line of text.split('\n')) { const m = line.match(/^([*+\-ox? ])(\S+)\s+(\S+)\s+(\d+)\s+(\w)\s+(\S+)\s+(\d+)\s+([0-7]+)\s+([\d.]+)\s+([-\d.]+)\s+([\d.]+)/); if (!m) continue; servers.push({ status: m[1].trim() || ' ', remote: m[2], refid: m[3], stratum: parseInt(m[4], 10), type: m[5], when: m[6], poll: parseInt(m[7], 10), reach: parseInt(m[8], 8), delay: parseFloat(m[9]), offset: parseFloat(m[10]), jitter: parseFloat(m[11]), }); } return servers; } function parseChrony(text) { const servers = []; for (const line of text.split('\n')) { // chronyc format: mode char (^=NTP server, =peer, #=refclock) then status char (*+- x~#?) const m = line.match(/^([*=#^]?)([*+\-x~#?])\s+(\S+)\s+(\d+)\s+(\d+)\s+([0-7]+)\s+(\S+)/); if (!m) continue; servers.push({ status: m[2], remote: m[3], refid: '', stratum: parseInt(m[4], 10), type: m[1] === '^' ? 'NTS' : 'u', when: m[7], poll: parseInt(m[5], 10), reach: parseInt(m[6], 8), delay: 0, offset: 0, jitter: 0, }); } return servers; } function parseCisco(text) { const servers = []; for (const line of text.split('\n')) { const m = line.match(/^([* ])\s*[~]?\s*(\S+)\s+(\S+)\s+(\d+)\s+(\S+)\s+(\d+)\s+([0-7]+)\s+([\d.]+)\s+([-\d.]+)/); if (!m) continue; servers.push({ status: m[1].trim() || ' ', remote: m[2], refid: m[3], stratum: parseInt(m[4], 10), type: 'u', when: m[5], poll: parseInt(m[6], 10), reach: parseInt(m[7], 8), delay: parseFloat(m[8]), offset: parseFloat(m[9]), jitter: 0, }); } return servers; } function parseInput(text, fmt) { if (fmt === 'chrony') return parseChrony(text); if (fmt === 'cisco') return parseCisco(text); return parseNtpq(text); } // ── Status helpers ───────────────────────────────────────────────────────── const STATUS_META = { '*': { label: '*', cls: 's-synced', rowCls: 'synced', title: 'Current sync source' }, 'o': { label: 'o', cls: 's-synced', rowCls: 'synced', title: 'PPS sync source' }, '+': { label: '+', cls: 's-candidate', rowCls: 'candidate', title: 'Selected candidate' }, '#': { label: '#', cls: 's-candidate', rowCls: 'candidate', title: 'Selected (excess)' }, '-': { label: '-', cls: 's-rejected', rowCls: 'rejected', title: 'Discarded (cluster algorithm)' }, 'x': { label: 'x', cls: 's-rejected', rowCls: 'rejected', title: 'Rejected (falseticker)' }, '~': { label: '~', cls: 's-unknown', rowCls: 'unreachable', title: 'Too variable' }, '?': { label: '?', cls: 's-unknown', rowCls: 'unreachable', title: 'Unreachable' }, ' ': { label: ' ', cls: 's-unknown', rowCls: 'unreachable', title: 'Not selected' }, }; function getMeta(s) { return STATUS_META[s] || STATUS_META[' ']; } function reachDots(reach) { const bits = reach.toString(2).padStart(8, '0'); return bits.split('').map(b => `` ).join(''); } // ── Render table ─────────────────────────────────────────────────────────── function renderTable(servers) { const tbody = document.getElementById('serverTableBody'); tbody.innerHTML = ''; for (const s of servers) { const meta = getMeta(s.status); const tr = document.createElement('tr'); tr.className = meta.rowCls; tr.innerHTML = ` ${meta.label || '?'} ${s.remote} ${s.refid || '—'} ${s.stratum === 16 ? '16 (unsync)' : s.stratum} ${s.poll}
${reachDots(s.reach)}
${s.delay.toFixed(3)} ${s.offset.toFixed(3)} ${s.jitter.toFixed(3)}`; tbody.appendChild(tr); } } // ── SVG stratum diagram ─────────────────────────────────────────────────── function renderDiagram(servers) { const svg = document.getElementById('diagram'); svg.innerHTML = ''; if (servers.length === 0) return; const W = 700, NODE_H = 40, PAD = 20, ROW_H = 60; const validServers = servers.filter(s => s.stratum < 16); if (validServers.length === 0) { svg.setAttribute('width', W); svg.setAttribute('height', 60); const t = document.createElementNS('http://www.w3.org/2000/svg', 'text'); t.setAttribute('x', W/2); t.setAttribute('y', 35); t.setAttribute('text-anchor', 'middle'); t.setAttribute('fill', '#9ca3af'); t.setAttribute('font-size', '14'); t.textContent = 'No reachable servers to diagram.'; svg.appendChild(t); return; } const refclocks = [...new Set( validServers.map(s => s.refid).filter(r => r.startsWith('.') && r.endsWith('.')) )]; const strata = [...new Set(validServers.map(s => s.stratum))].sort((a,b) => a-b); const rows = refclocks.length > 0 ? ['refclk', ...strata] : strata; const height = PAD + rows.length * ROW_H + ROW_H + PAD; svg.setAttribute('width', W); svg.setAttribute('height', height); svg.style.width = '100%'; svg.style.maxWidth = W + 'px'; function rowY(rowIdx) { return PAD + rowIdx * ROW_H + ROW_H / 2; } const ns = 'http://www.w3.org/2000/svg'; function el(tag, attrs, text) { const e = document.createElementNS(ns, tag); for (const [k,v] of Object.entries(attrs)) e.setAttribute(k, v); if (text) e.textContent = text; return e; } rows.forEach((row, i) => { const y = rowY(i); svg.appendChild(el('text', { x: 8, y: y + 5, 'font-size': '12', fill: '#6b7280' }, row === 'refclk' ? 'Stratum 0 (Ref Clock)' : `Stratum ${row}`)); svg.appendChild(el('line', { x1: 0, y1: y + ROW_H/2, x2: W, y2: y + ROW_H/2, stroke: '#e5e7eb', 'stroke-width': '1' })); }); const thisY = rowY(rows.length); svg.appendChild(el('text', { x: 8, y: thisY + 5, 'font-size': '12', fill: '#6b7280' }, 'This Host')); const stratumNodes = {}; strata.forEach(st => { const inStratum = validServers.filter(s => s.stratum === st); const rowIdx = rows.indexOf(st); const totalW = W - 120; const spacing = inStratum.length > 1 ? totalW / (inStratum.length - 1) : 0; inStratum.forEach((s, i) => { const x = 120 + (inStratum.length === 1 ? totalW / 2 : spacing * i); const y = rowY(rowIdx); stratumNodes[s.remote] = { x, y, server: s }; }); }); if (refclocks.length > 0) { const rowIdx = rows.indexOf('refclk'); const totalW = W - 120; refclocks.forEach((rc, i) => { const x = 120 + (refclocks.length === 1 ? totalW / 2 : (totalW / (refclocks.length - 1)) * i); const y = rowY(rowIdx); stratumNodes['rc:' + rc] = { x, y, isRefclk: true, label: rc }; }); } for (const { x, y, server, isRefclk } of Object.values(stratumNodes)) { if (isRefclk) continue; const refKey = 'rc:' + server.refid; if (stratumNodes[refKey]) { const { x: rx, y: ry } = stratumNodes[refKey]; svg.appendChild(el('line', { x1: rx, y1: ry, x2: x, y2: y, stroke: '#d1d5db', 'stroke-width': '1.5', 'stroke-dasharray': '4,3' })); } if (server.status === '*' || server.status === 'o') { svg.appendChild(el('line', { x1: x, y1: y, x2: W/2, y2: thisY, stroke: '#22c55e', 'stroke-width': '2' })); } else if (server.status === '+' || server.status === '#') { svg.appendChild(el('line', { x1: x, y1: y, x2: W/2, y2: thisY, stroke: '#d1d5db', 'stroke-width': '1', 'stroke-dasharray': '4,3' })); } } for (const { x, y, server, isRefclk, label } of Object.values(stratumNodes)) { const meta = isRefclk ? null : getMeta(server ? server.status : ' '); const fill = isRefclk ? '#8b5cf6' : meta.rowCls === 'synced' ? '#22c55e' : meta.rowCls === 'candidate' ? '#eab308' : meta.rowCls === 'rejected' ? '#ef4444' : '#9ca3af'; svg.appendChild(el('circle', { cx: x, cy: y, r: 16, fill, opacity: '0.9' })); const lbl = isRefclk ? label : (server.remote.length > 14 ? server.remote.slice(0,13)+'…' : server.remote); svg.appendChild(el('text', { x, y: y + 28, 'text-anchor': 'middle', 'font-size': '10', fill: '#374151' }, lbl)); if (!isRefclk && server) { svg.appendChild(el('text', { x, y: y + 5, 'text-anchor': 'middle', 'font-size': '10', fill: 'white', 'font-weight': 'bold' }, server.status === ' ' ? '?' : server.status)); } } svg.appendChild(el('rect', { x: W/2 - 40, y: thisY - 16, width: 80, height: 32, rx: 8, fill: '#3b82f6', opacity: '0.9' })); svg.appendChild(el('text', { x: W/2, y: thisY + 5, 'text-anchor': 'middle', 'font-size': '11', fill: 'white', 'font-weight': 'bold' }, 'This Host')); } // ── Summary ─────────────────────────────────────────────────────────────── function renderSummary(servers) { const div = document.getElementById('summaryDiv'); const synced = servers.find(s => s.status === '*' || s.status === 'o'); const candidates = servers.filter(s => s.status === '+' || s.status === '#').length; const unreachable = servers.filter(s => s.reach === 0 || s.stratum === 16).length; let html = '
    '; if (synced) { html += `
  • Sync source: ${synced.remote} (Stratum ${synced.stratum}`; if (synced.refid) html += `, RefID: ${synced.refid}`; html += `)`; if (synced.offset !== undefined) html += ` — Offset: ${synced.offset.toFixed(3)} ms, Jitter: ${synced.jitter.toFixed(3)} ms`; html += '
  • '; } else { html += `
  • No sync source found. The host is not synchronized.
  • `; } html += `
  • Candidate servers: ${candidates}
  • `; html += `
  • Unreachable / unsynchronized: ${unreachable}
  • `; if (synced && Math.abs(synced.offset) > 128) html += `
  • Warning: Offset ${synced.offset.toFixed(1)} ms exceeds 128 ms — NTP may step-adjust or fail.
  • `; html += '
'; html += `

References: RFC 5905 (NTPv4)RFC 8915 (NTS)chrony docs

`; div.innerHTML = html; } // ── Main ────────────────────────────────────────────────────────────────── function visualize() { const text = document.getElementById('inputText').value.trim(); if (!text) return; const servers = parseInput(text, currentFormat); if (servers.length === 0) { alert('No server rows recognized. Check that the input format matches the selected format button.'); return; } renderTable(servers); renderDiagram(servers); renderSummary(servers); document.getElementById('resultsDiv').style.display = 'block'; } window.visualize = visualize; })();