fix(core): correctness and robustness fixes across all subsystems
- Move status page template to package-level template.Must (panic on parse error at init instead of nil deref at runtime) - Fix XSS in import error responses (log detail server-side, return generic message to client) - Handle ListenAndServe errors in HTTP and SSH servers - Use defer resp.Body.Close() in all alert providers, check json.Marshal errors - Share HTTP clients across checks instead of creating per-request - Use http.NewRequestWithContext for per-site timeout control - Support HTTP method field (was always GET despite DB storing method) - Implement AcceptedCodes validation (was hardcoded >= 400 despite DB storing accepted code ranges) - Add defer tx.Rollback() to ImportData for transaction safety
This commit is contained in:
+139
-134
@@ -8,10 +8,139 @@ import (
|
||||
"go-upkeep/internal/monitor"
|
||||
"go-upkeep/internal/store"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"sort"
|
||||
)
|
||||
|
||||
var statusTpl = template.Must(template.New("status").Parse(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{{.Title}}</title>
|
||||
<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; }
|
||||
h1 { text-align: center; color: #7aa2f7; margin-bottom: 30px; }
|
||||
.container { max-width: 800px; margin: 0 auto; }
|
||||
.card { background: #24283b; padding: 20px; margin-bottom: 15px; border-radius: 8px; display: flex; align-items: center; justify-content: space-between; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
|
||||
.info { display: flex; flex-direction: column; }
|
||||
.name { font-size: 1.2em; font-weight: bold; color: #c0caf5; margin-bottom: 5px; }
|
||||
.meta { font-size: 0.85em; color: #565f89; }
|
||||
.status { font-weight: bold; padding: 6px 12px; border-radius: 6px; min-width: 60px; text-align: center; }
|
||||
.UP { background: #9ece6a; color: #1a1b26; }
|
||||
.DOWN { background: #f7768e; color: #1a1b26; }
|
||||
.PENDING { 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>
|
||||
<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>
|
||||
var lastUpdate = null;
|
||||
|
||||
function esc(s) {
|
||||
var d = document.createElement('div');
|
||||
d.appendChild(document.createTextNode(s));
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
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 = esc(s.Type) + ' | ' + (s.Type === 'http' ? esc(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">' + esc(s.Name) + '</div>' +
|
||||
'<div class="meta">' + meta + '</div>' +
|
||||
'<div class="meta" style="margin-top:4px;">Last Check: ' + lc + '</div>' +
|
||||
'</div><div class="status ' + cls + '">' + esc(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>`))
|
||||
|
||||
type ServerConfig struct {
|
||||
Port int
|
||||
EnableStatus bool
|
||||
@@ -76,7 +205,8 @@ func Start(cfg ServerConfig) {
|
||||
return
|
||||
}
|
||||
if err := store.Get().ImportData(data); err != nil {
|
||||
http.Error(w, "Import Failed: "+err.Error(), 500)
|
||||
log.Printf("Import failed: %v", err)
|
||||
http.Error(w, "Import failed", 500)
|
||||
return
|
||||
}
|
||||
w.Write([]byte("Import Successful"))
|
||||
@@ -94,12 +224,14 @@ func Start(cfg ServerConfig) {
|
||||
}
|
||||
var kb importer.KumaBackup
|
||||
if err := json.NewDecoder(r.Body).Decode(&kb); err != nil {
|
||||
http.Error(w, "Invalid Kuma JSON: "+err.Error(), 400)
|
||||
log.Printf("Invalid Kuma JSON: %v", err)
|
||||
http.Error(w, "Invalid Kuma JSON", 400)
|
||||
return
|
||||
}
|
||||
backup := importer.ConvertKuma(&kb)
|
||||
if err := store.Get().ImportData(backup); err != nil {
|
||||
http.Error(w, "Import Failed: "+err.Error(), 500)
|
||||
log.Printf("Kuma import failed: %v", err)
|
||||
http.Error(w, "Import failed", 500)
|
||||
return
|
||||
}
|
||||
w.Write([]byte(fmt.Sprintf("Imported %d monitors, %d alerts from Kuma v%s", len(backup.Sites), len(backup.Alerts), kb.Version)))
|
||||
@@ -119,7 +251,9 @@ func Start(cfg ServerConfig) {
|
||||
go func() {
|
||||
addr := fmt.Sprintf(":%d", cfg.Port)
|
||||
fmt.Printf("HTTP Server listening on %s\n", addr)
|
||||
http.ListenAndServe(addr, mux)
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
log.Fatalf("HTTP server failed: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -143,138 +277,9 @@ func renderStatusPage(w http.ResponseWriter, title string) {
|
||||
return sites[i].Name < sites[j].Name
|
||||
})
|
||||
|
||||
const tpl = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{{.Title}}</title>
|
||||
<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; }
|
||||
h1 { text-align: center; color: #7aa2f7; margin-bottom: 30px; }
|
||||
.container { max-width: 800px; margin: 0 auto; }
|
||||
.card { background: #24283b; padding: 20px; margin-bottom: 15px; border-radius: 8px; display: flex; align-items: center; justify-content: space-between; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
|
||||
.info { display: flex; flex-direction: column; }
|
||||
.name { font-size: 1.2em; font-weight: bold; color: #c0caf5; margin-bottom: 5px; }
|
||||
.meta { font-size: 0.85em; color: #565f89; }
|
||||
.status { font-weight: bold; padding: 6px 12px; border-radius: 6px; min-width: 60px; text-align: center; }
|
||||
.UP { background: #9ece6a; color: #1a1b26; }
|
||||
.DOWN { background: #f7768e; color: #1a1b26; }
|
||||
.PENDING { 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>
|
||||
<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>
|
||||
var lastUpdate = null;
|
||||
|
||||
function esc(s) {
|
||||
var d = document.createElement('div');
|
||||
d.appendChild(document.createTextNode(s));
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
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 = esc(s.Type) + ' | ' + (s.Type === 'http' ? esc(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">' + esc(s.Name) + '</div>' +
|
||||
'<div class="meta">' + meta + '</div>' +
|
||||
'<div class="meta" style="margin-top:4px;">Last Check: ' + lc + '</div>' +
|
||||
'</div><div class="status ' + cls + '">' + esc(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>`
|
||||
|
||||
t, _ := template.New("status").Parse(tpl)
|
||||
data := struct {
|
||||
Title string
|
||||
Sites []models.Site
|
||||
}{Title: title, Sites: sites}
|
||||
t.Execute(w, data)
|
||||
statusTpl.Execute(w, data)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user