Something went wrong. Try again.
A lightweight home network monitor with a live dashboard for latency, packet loss, jitter, DNS & bandwidth. Runs on Pi, Docker, or any mach…
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477<template> <div class="modal-backdrop" :class="{ open }" @click.self="close"> <div class="modal sm-root" role="dialog" aria-modal="true" aria-labelledby="sm-dialog-title" > <header class="sm-head"> <h1 id="sm-dialog-title" class="sm-title">Settings</h1> <Transition name="sm-fade"> <p v-if="autosaveLine" class="sm-autosave" :class="{ 'sm-autosave--muted': autosaveTone === 'muted', 'sm-autosave--ok': autosaveTone === 'ok', 'sm-autosave--err': autosaveTone === 'err', }" role="status" aria-live="polite" > {{ autosaveLine }} </p> </Transition> </header>
<div class="sm-main"> <aside class="sm-aside" aria-label="Settings categories"> <SettingsNav v-model="activeTab" :tabs="SETTINGS_TABS" /> </aside>
<div class="sm-panel-host" :aria-labelledby="'settings-tab-' + activeTab" > <div v-show="activeTab === 'monitor'" id="settings-panel-monitor" class="sm-panel" role="tabpanel" tabindex="0" aria-labelledby="settings-tab-monitor" > <SettingsMonitorTab v-model:ping-targets="pingTargets" v-model:dns-targets="dnsTargets" v-model:ping-interval="pingInterval" v-model:speed-interval="speedInterval" v-model:ping-count="pingCount" /> </div>
<div v-show="activeTab === 'about'" id="settings-panel-about" class="sm-panel" role="tabpanel" tabindex="0" aria-labelledby="settings-tab-about" > <SettingsAboutTab :version-info="versionInfo" :checking="checking" :updating="updating" :update-done="updateDone" :update-error="updateError" @check="checkUpdate" @update="doUpdate" @reload="reloadPage" /> </div> </div> </div>
</div> </div></template>
<script setup>import { ref, watch, onUnmounted } from 'vue'import SettingsNav from './settings/SettingsNav.vue'import SettingsMonitorTab from './settings/SettingsMonitorTab.vue'import SettingsAboutTab from './settings/SettingsAboutTab.vue'import { SETTINGS_TABS, DEFAULT_SETTINGS_TAB } from './settings/settingsTabMeta.js'
const AUTOSAVE_DEBOUNCE_MS = 550/** Minimum time "Saving..." stays visible before switching to "Saved" */const AUTOSAVE_SAVING_MIN_MS = 450/** How long "Saved" stays visible before fading out */const AUTOSAVE_SAVED_CLEAR_MS = 4000const AUTOSAVE_ERR_CLEAR_MS = 5000
const props = defineProps({ open: Boolean })const emit = defineEmits(['close', 'saved'])
const activeTab = ref(DEFAULT_SETTINGS_TAB)
const pingTargets = ref('')const dnsTargets = ref('')const pingInterval = ref(60)const speedInterval = ref(30)const pingCount = ref(5)
/** '', 'muted' (Saving…), 'ok', 'err' */const autosaveTone = ref('')const autosaveLine = ref('')
let lastSavedSerialized = ''let autosaveClearTimer = nulllet autosaveDebounceTimer = nulllet saveGeneration = 0
function serializeConfig() { const pt = pingTargets.value.split('\n').map(s => s.trim()).filter(Boolean) const dt = dnsTargets.value.split('\n').map(s => s.trim()).filter(Boolean) const pis = parseInt(pingInterval.value, 10) const sim = parseInt(speedInterval.value, 10) const pc = parseInt(pingCount.value, 10) return JSON.stringify({ pt, dt, pis, sim, pc })}
function scheduleAutosaveClear(ms) { clearTimeout(autosaveClearTimer) autosaveClearTimer = setTimeout(() => { autosaveLine.value = '' autosaveTone.value = '' }, ms)}
function showAutosaveError(msg) { autosaveLine.value = msg autosaveTone.value = 'err' scheduleAutosaveClear(AUTOSAVE_ERR_CLEAR_MS)}
async function load() { try { const cfg = await fetch('/api/config').then(r => r.json()) pingTargets.value = (cfg.ping_targets || []).join('\n') dnsTargets.value = (cfg.dns_targets || []).join('\n') pingInterval.value = cfg.ping_interval_s || 60 speedInterval.value = cfg.speed_interval_m || 60 pingCount.value = cfg.ping_count || 5 lastSavedSerialized = serializeConfig() } catch { showAutosaveError('Failed to load config') }}
async function persistConfig() { if (!props.open) return
const cur = serializeConfig() if (cur === lastSavedSerialized) return
const pt = pingTargets.value.split('\n').map(s => s.trim()).filter(Boolean) const dt = dnsTargets.value.split('\n').map(s => s.trim()).filter(Boolean) const pis = parseInt(pingInterval.value, 10) const sim = parseInt(speedInterval.value, 10) const pc = parseInt(pingCount.value, 10)
if (!pt.length) { showAutosaveError('Probe hosts cannot be empty') return } if (pis < 10) { showAutosaveError('Ping interval must be ≥ 10 s') return } if (sim < 5) { showAutosaveError('Speed interval must be ≥ 5 min') return } if (pc < 1 || pc > 20) { showAutosaveError('Ping count must be 1–20') return }
const gen = ++saveGeneration clearTimeout(autosaveClearTimer) autosaveLine.value = 'Saving...' autosaveTone.value = 'muted'
const savingStarted = Date.now()
try { const res = await fetch('/api/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ping_targets: pt, dns_targets: dt, ping_interval_s: pis, speed_interval_m: sim, ping_count: pc, }), }) const body = await res.json().catch(() => ({})) if (gen !== saveGeneration) return
if (res.ok) { const elapsed = Date.now() - savingStarted const pad = Math.max(0, AUTOSAVE_SAVING_MIN_MS - elapsed) if (pad > 0) { await new Promise(r => setTimeout(r, pad)) } if (gen !== saveGeneration) return
lastSavedSerialized = serializeConfig() autosaveLine.value = 'Saved' autosaveTone.value = 'ok' emit('saved') scheduleAutosaveClear(AUTOSAVE_SAVED_CLEAR_MS) } else { showAutosaveError(body.error || 'Save failed') } } catch { if (gen !== saveGeneration) return showAutosaveError('Network error') }}
function queueAutosave() { if (!props.open) return clearTimeout(autosaveDebounceTimer) autosaveDebounceTimer = setTimeout(persistConfig, AUTOSAVE_DEBOUNCE_MS)}
watch( [pingTargets, dnsTargets, pingInterval, speedInterval, pingCount], () => { if (!props.open) return const cur = serializeConfig() if (cur === lastSavedSerialized) return queueAutosave() })
const versionInfo = ref(null)const checking = ref(false)const updating = ref(false)const updateDone = ref(false)const updateError = ref('')
async function checkUpdate() { checking.value = true try { versionInfo.value = await fetch('/api/version').then(r => r.json()) } catch { /* ignore */ } checking.value = false}
async function doUpdate() { updating.value = true updateError.value = '' const targetVersion = versionInfo.value?.latest try { const res = await fetch('/api/update', { method: 'POST' }) const body = await res.json() if (!res.ok) { updating.value = false updateError.value = body.error || 'Update failed' return } if (body.status === 'up_to_date') { updating.value = false return } } catch { /* server may have restarted */ }
let attempts = 0 const poll = setInterval(async () => { if (++attempts > 30) { clearInterval(poll) updating.value = false updateError.value = 'Update timed out. Restart the server manually.' return } try { const res = await fetch('/api/version').then(r => r.json()) if (res.current === targetVersion) { clearInterval(poll) versionInfo.value = res updating.value = false updateDone.value = true } } catch { /* still restarting */ } }, 3000)}
function reloadPage() { window.location.reload()}
function runTabOpenHook(tabId) { if (tabId === 'about' && !versionInfo.value) checkUpdate()}
function onDocKeydown(e) { if (e.key === 'Escape' && props.open) { e.preventDefault() close() }}
watch( () => props.open, v => { if (v) { load() activeTab.value = DEFAULT_SETTINGS_TAB versionInfo.value = null updateDone.value = false updateError.value = '' document.addEventListener('keydown', onDocKeydown) } else { document.removeEventListener('keydown', onDocKeydown) } })
watch(activeTab, (tabId, prev) => { if (tabId === prev) return runTabOpenHook(tabId)})
onUnmounted(() => { document.removeEventListener('keydown', onDocKeydown)})
function close() { clearTimeout(autosaveDebounceTimer) clearTimeout(autosaveClearTimer) saveGeneration++ autosaveLine.value = '' autosaveTone.value = '' emit('close')}</script>
<style scoped>/* One height for all tabs — body scrolls inside .sm-panel-host */.sm-root { --sm-fixed-height: min(580px, calc(100vh - 2rem));
width: 100%; max-width: 760px !important; height: var(--sm-fixed-height) !important; min-height: var(--sm-fixed-height) !important; max-height: var(--sm-fixed-height) !important;
display: flex !important; flex-direction: column !important; overflow: hidden !important; border-radius: 14px !important; border: 1px solid var(--border) !important; background: var(--card) !important; box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.04) inset, 0 24px 56px rgba(0, 0, 0, 0.2) !important;}
.sm-head { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; flex-wrap: wrap; padding: 1rem 1.25rem; flex-shrink: 0; border-bottom: 1px solid var(--border);}
.sm-title { margin: 0; font-size: 1.0625rem; font-weight: 800; letter-spacing: -0.03em; color: var(--fg); line-height: 1.2;}
.sm-autosave { margin: 0; font-size: 0.75rem; font-weight: 600; letter-spacing: 0.01em; min-height: 1.2em;}
.sm-autosave--muted { color: var(--muted);}
.sm-autosave--ok { color: var(--green);}
.sm-autosave--err { color: var(--red);}
.sm-main { flex: 1; min-height: 0; display: flex; flex-direction: row; align-items: stretch;}
.sm-aside { width: 168px; flex-shrink: 0; border-right: 1px solid var(--border); background: color-mix(in srgb, var(--bg) 88%, var(--card)); overflow: hidden; display: flex; flex-direction: column;}
.sm-aside :deep(.sn) { flex: 1; overflow-y: auto; overflow-x: hidden; scrollbar-gutter: stable;}
.sm-panel-host { flex: 1; min-width: 0; min-height: 0; overflow-y: auto; overflow-x: hidden; scrollbar-gutter: stable;}
.sm-panel { padding: 1.125rem 1.25rem 1.25rem; outline: none;}
.sm-fade-enter-active,.sm-fade-leave-active { transition: opacity 0.15s ease;}
.sm-fade-enter-from,.sm-fade-leave-to { opacity: 0;}
@media (max-width: 560px) { .sm-main { flex-direction: column; }
.sm-aside { width: 100%; border-right: none; border-bottom: 1px solid var(--border); flex-shrink: 0; overflow: hidden; }
.sm-aside :deep(.sn) { overflow-y: hidden; }}</style>