Show whether the status on / and /status is fresh (closes #152)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
Each page's script turns a one-character braille spinner next to the status whenever a stats answer counts more messages than the one before. Its colour cycles as rtnetmon's does. A separate check runs every second. Once no such answer has come for three refresh periods, 15 seconds on / and 6 on /status, it shows "stale" and the seconds since, in the page's disconnected colour. A failed or hung request counts as no fresh answer. On / the card has no room beside "Connected", so the indicator sits on the line under it. A test renders both pages and checks the indicator follows the status element. Model: opus-5-5
This commit was merged in pull request #180.
This commit is contained in:
@@ -33,6 +33,10 @@ tested.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-27: on `/` and `/status`, a spinner beside the status turns one frame
|
||||||
|
each time the stats show more messages than the time before. A page that has
|
||||||
|
not seen more messages for three refreshes shows "stale" and the time since
|
||||||
|
then instead, in red (closes #152)
|
||||||
- 2026-10-27: the WHOIS details of a Brazilian AS keep their accented letters.
|
- 2026-10-27: the WHOIS details of a Brazilian AS keep their accented letters.
|
||||||
LACNIC's answer for such an AS comes from NIC.br, Brazil's registry, with each
|
LACNIC's answer for such an AS comes from NIC.br, Brazil's registry, with each
|
||||||
accented letter replaced. The details now come from NIC.br's own server,
|
accented letter replaced. The details now come from NIC.br's own server,
|
||||||
|
|||||||
@@ -107,9 +107,15 @@
|
|||||||
.stat-card.connected .stat-value {
|
.stat-card.connected .stat-value {
|
||||||
color: #27ae60;
|
color: #27ae60;
|
||||||
}
|
}
|
||||||
.stat-card.disconnected .stat-value {
|
.stat-card.disconnected .stat-value,
|
||||||
|
.stale {
|
||||||
color: #e74c3c;
|
color: #e74c3c;
|
||||||
}
|
}
|
||||||
|
/* The card has no room beside "Connected", so the spinner and "stale" go under it */
|
||||||
|
#freshness {
|
||||||
|
display: block;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Search section */
|
/* Search section */
|
||||||
.search-section {
|
.search-section {
|
||||||
@@ -297,7 +303,10 @@
|
|||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<div class="stats-grid">
|
<div class="stats-grid">
|
||||||
<div class="stat-card" id="status-card">
|
<div class="stat-card" id="status-card">
|
||||||
<div class="stat-value" id="stat-status">-</div>
|
<div class="stat-value">
|
||||||
|
<span id="stat-status">-</span>
|
||||||
|
<span id="freshness"></span>
|
||||||
|
</div>
|
||||||
<div class="stat-label">Status</div>
|
<div class="stat-label">Status</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
@@ -369,6 +378,44 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script nonce="{{.CSPNonce}}">
|
<script nonce="{{.CSPNonce}}">
|
||||||
|
// The stats are fetched every REFRESH_PERIOD_MS. Next to the status, a
|
||||||
|
// spinner turns one frame each time an answer counts more messages
|
||||||
|
// than the one before. After STALE_AFTER_REFRESHES refreshes without
|
||||||
|
// such an answer, the spinner gives way to "stale" and the time since.
|
||||||
|
const REFRESH_PERIOD_MS = 5000;
|
||||||
|
const STALE_AFTER_REFRESHES = 3;
|
||||||
|
const MS_PER_SECOND = 1000;
|
||||||
|
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||||
|
const SPINNER_COLOURS = ['#e74c3c', '#e67e22', '#f1c40f', '#27ae60', '#3498db', '#9b59b6'];
|
||||||
|
|
||||||
|
let spinnerFrame = 0;
|
||||||
|
let lastMessageCount = null;
|
||||||
|
let lastFreshAt = Date.now();
|
||||||
|
|
||||||
|
// Turn the spinner when the message count has risen since the last answer.
|
||||||
|
function noteMessageCount(count) {
|
||||||
|
if (lastMessageCount !== null && count > lastMessageCount) {
|
||||||
|
spinnerFrame = (spinnerFrame + 1) % SPINNER_FRAMES.length;
|
||||||
|
lastFreshAt = Date.now();
|
||||||
|
}
|
||||||
|
lastMessageCount = count;
|
||||||
|
showFreshness();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showFreshness() {
|
||||||
|
const freshness = document.getElementById('freshness');
|
||||||
|
const sinceFreshMs = Date.now() - lastFreshAt;
|
||||||
|
const stale = sinceFreshMs >= STALE_AFTER_REFRESHES * REFRESH_PERIOD_MS;
|
||||||
|
freshness.classList.toggle('stale', stale);
|
||||||
|
if (stale) {
|
||||||
|
freshness.textContent = 'stale for ' + Math.floor(sinceFreshMs / MS_PER_SECOND) + 's';
|
||||||
|
freshness.style.color = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
freshness.textContent = SPINNER_FRAMES[spinnerFrame];
|
||||||
|
freshness.style.color = SPINNER_COLOURS[spinnerFrame % SPINNER_COLOURS.length];
|
||||||
|
}
|
||||||
|
|
||||||
function formatNumber(num) {
|
function formatNumber(num) {
|
||||||
if (num >= 1000000) {
|
if (num >= 1000000) {
|
||||||
return (num / 1000000).toFixed(1) + 'M';
|
return (num / 1000000).toFixed(1) + 'M';
|
||||||
@@ -390,6 +437,7 @@
|
|||||||
const statusEl = document.getElementById('stat-status');
|
const statusEl = document.getElementById('stat-status');
|
||||||
statusEl.textContent = data.connected ? 'Connected' : 'Disconnected';
|
statusEl.textContent = data.connected ? 'Connected' : 'Disconnected';
|
||||||
statusCard.className = 'stat-card ' + (data.connected ? 'connected' : 'disconnected');
|
statusCard.className = 'stat-card ' + (data.connected ? 'connected' : 'disconnected');
|
||||||
|
noteMessageCount(data.total_messages);
|
||||||
|
|
||||||
document.getElementById('stat-routes').textContent = formatNumber(data.live_routes);
|
document.getElementById('stat-routes').textContent = formatNumber(data.live_routes);
|
||||||
document.getElementById('stat-asns').textContent = formatNumber(data.asns);
|
document.getElementById('stat-asns').textContent = formatNumber(data.asns);
|
||||||
@@ -536,9 +584,12 @@
|
|||||||
document.getElementById('ip-input').value = '';
|
document.getElementById('ip-input').value = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initial load and refresh stats every 5 seconds
|
// Initial load and refresh stats every REFRESH_PERIOD_MS. The freshness
|
||||||
|
// check runs every second on its own, so a request that never returns
|
||||||
|
// still turns the spinner into "stale".
|
||||||
updateStats();
|
updateStats();
|
||||||
setInterval(updateStats, 5000);
|
setInterval(updateStats, REFRESH_PERIOD_MS);
|
||||||
|
setInterval(showFreshness, MS_PER_SECOND);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -126,7 +126,8 @@
|
|||||||
.connected {
|
.connected {
|
||||||
color: #22c55e;
|
color: #22c55e;
|
||||||
}
|
}
|
||||||
.disconnected {
|
.disconnected,
|
||||||
|
.stale {
|
||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
}
|
}
|
||||||
.error {
|
.error {
|
||||||
@@ -180,7 +181,10 @@
|
|||||||
<h2>RouteWatch</h2>
|
<h2>RouteWatch</h2>
|
||||||
<div class="metric">
|
<div class="metric">
|
||||||
<span class="metric-label">Status</span>
|
<span class="metric-label">Status</span>
|
||||||
|
<span>
|
||||||
<span class="metric-value" id="connected">-</span>
|
<span class="metric-value" id="connected">-</span>
|
||||||
|
<span class="metric-value" id="freshness"></span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric">
|
<div class="metric">
|
||||||
<span class="metric-label">Uptime</span>
|
<span class="metric-label">Uptime</span>
|
||||||
@@ -386,6 +390,44 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script nonce="{{.CSPNonce}}">
|
<script nonce="{{.CSPNonce}}">
|
||||||
|
// The stats are fetched every REFRESH_PERIOD_MS. Next to the status, a
|
||||||
|
// spinner turns one frame each time an answer counts more messages
|
||||||
|
// than the one before. After STALE_AFTER_REFRESHES refreshes without
|
||||||
|
// such an answer, the spinner gives way to "stale" and the time since.
|
||||||
|
const REFRESH_PERIOD_MS = 2000;
|
||||||
|
const STALE_AFTER_REFRESHES = 3;
|
||||||
|
const MS_PER_SECOND = 1000;
|
||||||
|
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||||
|
const SPINNER_COLOURS = ['#e74c3c', '#e67e22', '#f1c40f', '#27ae60', '#3498db', '#9b59b6'];
|
||||||
|
|
||||||
|
let spinnerFrame = 0;
|
||||||
|
let lastMessageCount = null;
|
||||||
|
let lastFreshAt = Date.now();
|
||||||
|
|
||||||
|
// Turn the spinner when the message count has risen since the last answer.
|
||||||
|
function noteMessageCount(count) {
|
||||||
|
if (lastMessageCount !== null && count > lastMessageCount) {
|
||||||
|
spinnerFrame = (spinnerFrame + 1) % SPINNER_FRAMES.length;
|
||||||
|
lastFreshAt = Date.now();
|
||||||
|
}
|
||||||
|
lastMessageCount = count;
|
||||||
|
showFreshness();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showFreshness() {
|
||||||
|
const freshness = document.getElementById('freshness');
|
||||||
|
const sinceFreshMs = Date.now() - lastFreshAt;
|
||||||
|
const stale = sinceFreshMs >= STALE_AFTER_REFRESHES * REFRESH_PERIOD_MS;
|
||||||
|
freshness.classList.toggle('stale', stale);
|
||||||
|
if (stale) {
|
||||||
|
freshness.textContent = 'stale for ' + Math.floor(sinceFreshMs / MS_PER_SECOND) + 's';
|
||||||
|
freshness.style.color = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
freshness.textContent = SPINNER_FRAMES[spinnerFrame];
|
||||||
|
freshness.style.color = SPINNER_COLOURS[spinnerFrame % SPINNER_COLOURS.length];
|
||||||
|
}
|
||||||
|
|
||||||
function formatBytes(bytes) {
|
function formatBytes(bytes) {
|
||||||
if (bytes === 0) return '0 B';
|
if (bytes === 0) return '0 B';
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
@@ -569,6 +611,7 @@
|
|||||||
const connectedEl = document.getElementById('connected');
|
const connectedEl = document.getElementById('connected');
|
||||||
connectedEl.textContent = data.connected ? 'Connected' : 'Disconnected';
|
connectedEl.textContent = data.connected ? 'Connected' : 'Disconnected';
|
||||||
connectedEl.className = 'metric-value ' + (data.connected ? 'connected' : 'disconnected');
|
connectedEl.className = 'metric-value ' + (data.connected ? 'connected' : 'disconnected');
|
||||||
|
noteMessageCount(data.total_messages);
|
||||||
|
|
||||||
// Update all metrics
|
// Update all metrics
|
||||||
document.getElementById('uptime').textContent = data.uptime;
|
document.getElementById('uptime').textContent = data.uptime;
|
||||||
@@ -643,9 +686,12 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update immediately and then every 2 seconds
|
// Update immediately and then every REFRESH_PERIOD_MS. The freshness
|
||||||
|
// check runs every second on its own, so a request that never returns
|
||||||
|
// still turns the spinner into "stale".
|
||||||
updateStatus();
|
updateStatus();
|
||||||
setInterval(updateStatus, 2000);
|
setInterval(updateStatus, REFRESH_PERIOD_MS);
|
||||||
|
setInterval(showFreshness, MS_PER_SECOND);
|
||||||
</script>
|
</script>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,20 @@ import (
|
|||||||
"git.eeqj.de/sneak/routewatch/internal/templates"
|
"git.eeqj.de/sneak/routewatch/internal/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// renderPage renders a page with the data every page needs.
|
||||||
|
func renderPage(t *testing.T, tmpl *template.Template) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var page bytes.Buffer
|
||||||
|
|
||||||
|
err := tmpl.Execute(&page, struct{ CSPNonce string }{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rendering the page: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return page.String()
|
||||||
|
}
|
||||||
|
|
||||||
// TestFooterLicense renders the home and status pages and expects each footer
|
// TestFooterLicense renders the home and status pages and expects each footer
|
||||||
// to name the licence in LICENSE, MIT, and not WTFPL.
|
// to name the licence in LICENSE, MIT, and not WTFPL.
|
||||||
func TestFooterLicense(t *testing.T) {
|
func TestFooterLicense(t *testing.T) {
|
||||||
@@ -28,14 +42,7 @@ func TestFooterLicense(t *testing.T) {
|
|||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
var page bytes.Buffer
|
_, footer, found := strings.Cut(renderPage(t, tmpl), "<footer")
|
||||||
|
|
||||||
err := tmpl.Execute(&page, struct{ CSPNonce string }{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("rendering the page: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, footer, found := strings.Cut(page.String(), "<footer")
|
|
||||||
if !found {
|
if !found {
|
||||||
t.Fatal("the page has no footer")
|
t.Fatal("the page has no footer")
|
||||||
}
|
}
|
||||||
@@ -52,3 +59,42 @@ func TestFooterLicense(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFreshnessIndicator renders the home and status pages and expects each to
|
||||||
|
// have, right after the status element, the element where the page's script
|
||||||
|
// shows its spinner or "stale".
|
||||||
|
func TestFreshnessIndicator(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
pages := map[string]struct {
|
||||||
|
tmpl *template.Template
|
||||||
|
status string
|
||||||
|
indicator string
|
||||||
|
}{
|
||||||
|
"index": {
|
||||||
|
tmpl: templates.IndexTemplate(),
|
||||||
|
status: `<span id="stat-status">-</span>`,
|
||||||
|
indicator: `<span id="freshness"></span>`,
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
tmpl: templates.StatusTemplate(),
|
||||||
|
status: `<span class="metric-value" id="connected">-</span>`,
|
||||||
|
indicator: `<span class="metric-value" id="freshness"></span>`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, page := range pages {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, afterStatus, found := strings.Cut(renderPage(t, page.tmpl), page.status)
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("the page has no status element %s", page.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(strings.TrimSpace(afterStatus), page.indicator) {
|
||||||
|
t.Errorf("the status element is not followed by %s", page.indicator)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user