feat(tui,status): add per-site pause, fix viewport, polish status page

Per-site pause: [p] key toggles pause for selected monitor in TUI.
Paused monitors skip checks, persist to DB, show on status page.

Status page: replace full-page reload with fetch-based DOM updates
to eliminate scroll-jump on refresh. Add summary bar (UP/DOWN/PAUSED
counts), stale-data indicator, and fix SSL EXP CSS class bug.

TUI: constrain tables to terminal width via lipgloss .Width() to
prevent row wrapping that pushed header off-screen. Add MaxHeight
safety net. Bump subtle style from #383838 to #565f89 for
readability on dark terminals.
This commit is contained in:
2026-05-14 18:46:17 -04:00
parent f17f8c9f93
commit d5ab3a18a4
10 changed files with 199 additions and 77 deletions
+92 -13
View File
@@ -148,7 +148,6 @@ func renderStatusPage(w http.ResponseWriter, title string) {
<html>
<head>
<title>{{.Title}}</title>
<meta http-equiv="refresh" content="5">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background: #1a1b26; color: #a9b1d6; padding: 20px; margin: 0; }
@@ -162,26 +161,106 @@ func renderStatusPage(w http.ResponseWriter, title string) {
.UP { background: #9ece6a; color: #1a1b26; }
.DOWN { background: #f7768e; color: #1a1b26; }
.PENDING { background: #e0af68; color: #1a1b26; }
.SSLEXP { background: #e0af68; color: #1a1b26; }
.SSL-EXP { background: #e0af68; color: #1a1b26; }
.PAUSED { background: #565f89; color: #c0caf5; }
.summary { display: flex; justify-content: center; gap: 16px; margin-bottom: 24px; font-size: 0.95em; font-weight: 600; }
.summary span { padding: 4px 12px; border-radius: 6px; }
.summary .s-up { color: #9ece6a; }
.summary .s-down { color: #f7768e; }
.summary .s-paused { color: #565f89; }
.summary .s-total { color: #7aa2f7; }
.stale-bar { text-align: center; font-size: 0.8em; color: #565f89; margin-bottom: 16px; transition: color 0.3s; }
.stale-bar.warn { color: #e0af68; }
.stale-bar.error { color: #f7768e; }
</style>
</head>
<body>
<div class="container">
<h1>{{.Title}}</h1>
{{range .Sites}}
<div class="card">
<div class="info">
<div class="name">{{.Name}}</div>
<div class="meta">{{.Type}} | {{if eq .Type "http"}}{{.URL}}{{else}}Heartbeat Monitor{{end}}</div>
<div class="meta" style="margin-top:4px;">Last Check: {{.LastCheck.Format "15:04:05"}}</div>
</div>
<div class="status {{.Status}}">{{.Status}}</div>
</div>
{{end}}
<div id="summary" class="summary"></div>
<div id="stale" class="stale-bar"></div>
<div id="cards"></div>
<div style="text-align: center; margin-top: 40px; color: #565f89; font-size: 0.8em;">Powered by Go-Upkeep</div>
</div>
<script>
setTimeout(function(){ window.location.reload(1); }, 5000);
var lastUpdate = null;
function cssClass(status) {
return status.replace(/\s+/g, '-');
}
function renderSummary(sites) {
var up = 0, down = 0, paused = 0, total = sites.length;
for (var i = 0; i < sites.length; i++) {
if (sites[i].Paused) { paused++; continue; }
if (sites[i].Status === 'UP') up++;
else if (sites[i].Status === 'DOWN') down++;
}
var el = document.getElementById('summary');
var parts = ['<span class="s-total">' + up + '/' + total + ' UP</span>'];
if (down > 0) parts.push('<span class="s-down">' + down + ' DOWN</span>');
if (paused > 0) parts.push('<span class="s-paused">' + paused + ' PAUSED</span>');
el.innerHTML = parts.join('<span style="color:#383838">·</span>');
}
function renderStale() {
var el = document.getElementById('stale');
if (!lastUpdate) { el.textContent = ''; return; }
var ago = Math.round((Date.now() - lastUpdate) / 1000);
el.className = 'stale-bar';
if (ago < 10) {
el.textContent = 'Updated just now';
} else if (ago < 30) {
el.textContent = 'Updated ' + ago + 's ago';
el.className = 'stale-bar warn';
} else {
el.textContent = 'Stale — last update ' + ago + 's ago';
el.className = 'stale-bar error';
}
}
function render(sites) {
var c = document.getElementById('cards');
var html = '';
sites.sort(function(a, b) {
if (a.Status !== b.Status) {
if (a.Status === 'DOWN') return -1;
if (b.Status === 'DOWN') return 1;
}
return a.Name < b.Name ? -1 : a.Name > b.Name ? 1 : 0;
});
renderSummary(sites);
for (var i = 0; i < sites.length; i++) {
var s = sites[i];
var st = s.Paused ? 'PAUSED' : s.Status;
var cls = cssClass(st);
var meta = s.Type + ' | ' + (s.Type === 'http' ? s.URL : 'Heartbeat Monitor');
var lc = s.LastCheck ? new Date(s.LastCheck).toLocaleTimeString('en-GB', {hour12: false}) : '—';
html += '<div class="card"><div class="info">' +
'<div class="name">' + s.Name + '</div>' +
'<div class="meta">' + meta + '</div>' +
'<div class="meta" style="margin-top:4px;">Last Check: ' + lc + '</div>' +
'</div><div class="status ' + cls + '">' + st + '</div></div>';
}
c.innerHTML = html;
}
function refresh() {
fetch('/status/json')
.then(function(r) { return r.json(); })
.then(function(data) {
var sites = [];
for (var k in data) sites.push(data[k]);
lastUpdate = Date.now();
render(sites);
})
.catch(function() {});
renderStale();
setTimeout(refresh, 5000);
}
setInterval(renderStale, 1000);
refresh();
</script>
</body>
</html>`