feat/next: alert providers, prometheus metrics, core refactors #6

Merged
lerko merged 10 commits from feat/next into develop 2026-05-15 19:35:12 +00:00
7 changed files with 218 additions and 153 deletions
Showing only changes of commit 4d5116644f - Show all commits
+1
View File
@@ -39,3 +39,4 @@ tmp
/go-upkeep/ /go-upkeep/
*.local.json *.local.json
*.local.md
+5 -1
View File
@@ -161,7 +161,11 @@ func startSSHServer(port int) {
fmt.Printf("SSH server error: %v\n", err) fmt.Printf("SSH server error: %v\n", err)
return return
} }
go func() { s.ListenAndServe() }() go func() {
if err := s.ListenAndServe(); err != nil {
log.Fatalf("SSH server failed: %v", err)
}
}()
} }
func seedDemoData(s store.Store) { func seedDemoData(s store.Store) {
+16 -7
View File
@@ -61,12 +61,15 @@ type DiscordProvider struct{ URL string }
func (d *DiscordProvider) Send(title, message string) error { func (d *DiscordProvider) Send(title, message string) error {
payload := map[string]string{"content": fmt.Sprintf("**%s**\n%s", title, message)} payload := map[string]string{"content": fmt.Sprintf("**%s**\n%s", title, message)}
jsonValue, _ := json.Marshal(payload) jsonValue, err := json.Marshal(payload)
if err != nil {
return err
}
resp, err := alertClient.Post(d.URL, "application/json", bytes.NewBuffer(jsonValue)) resp, err := alertClient.Post(d.URL, "application/json", bytes.NewBuffer(jsonValue))
if err != nil { if err != nil {
return err return err
} }
resp.Body.Close() defer resp.Body.Close()
return nil return nil
} }
@@ -75,12 +78,15 @@ type SlackProvider struct{ URL string }
func (s *SlackProvider) Send(title, message string) error { func (s *SlackProvider) Send(title, message string) error {
payload := map[string]string{"text": fmt.Sprintf("*%s*\n%s", title, message)} payload := map[string]string{"text": fmt.Sprintf("*%s*\n%s", title, message)}
jsonValue, _ := json.Marshal(payload) jsonValue, err := json.Marshal(payload)
if err != nil {
return err
}
resp, err := alertClient.Post(s.URL, "application/json", bytes.NewBuffer(jsonValue)) resp, err := alertClient.Post(s.URL, "application/json", bytes.NewBuffer(jsonValue))
if err != nil { if err != nil {
return err return err
} }
resp.Body.Close() defer resp.Body.Close()
return nil return nil
} }
@@ -93,12 +99,15 @@ func (w *WebhookProvider) Send(title, message string) error {
"message": message, "message": message,
"status": "alert", "status": "alert",
} }
jsonValue, _ := json.Marshal(payload) jsonValue, err := json.Marshal(payload)
if err != nil {
return err
}
resp, err := alertClient.Post(w.URL, "application/json", bytes.NewBuffer(jsonValue)) resp, err := alertClient.Post(w.URL, "application/json", bytes.NewBuffer(jsonValue))
if err != nil { if err != nil {
return err return err
} }
resp.Body.Close() defer resp.Body.Close()
return nil return nil
} }
@@ -139,6 +148,6 @@ func (n *NtfyProvider) Send(title, message string) error {
if err != nil { if err != nil {
return err return err
} }
resp.Body.Close() defer resp.Body.Close()
return nil return nil
} }
+54 -9
View File
@@ -1,6 +1,7 @@
package monitor package monitor
import ( import (
"context"
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"go-upkeep/internal/alert" "go-upkeep/internal/alert"
@@ -9,6 +10,7 @@ import (
"net" "net"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"sync" "sync"
"time" "time"
@@ -52,6 +54,13 @@ var (
activeMutex sync.RWMutex activeMutex sync.RWMutex
insecureSkipVerify bool insecureSkipVerify bool
strictClient = &http.Client{
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: false}},
}
insecureClient = &http.Client{
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}},
}
) )
func SetInsecureSkipVerify(skip bool) { func SetInsecureSkipVerify(skip bool) {
@@ -258,15 +267,51 @@ func checkPush(site models.Site) {
} }
} }
func checkHTTP(site models.Site) { func isCodeAccepted(code int, accepted string) bool {
start := time.Now() if accepted == "" {
timeout := time.Duration(site.Timeout) * time.Second return code >= 200 && code < 300
if timeout <= 0 {
timeout = 5 * time.Second
} }
skipTLS := insecureSkipVerify || site.IgnoreTLS for _, part := range strings.Split(accepted, ",") {
client := &http.Client{Timeout: timeout, Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: skipTLS}}} part = strings.TrimSpace(part)
resp, err := client.Get(site.URL) if strings.Contains(part, "-") {
bounds := strings.SplitN(part, "-", 2)
lo, err1 := strconv.Atoi(strings.TrimSpace(bounds[0]))
hi, err2 := strconv.Atoi(strings.TrimSpace(bounds[1]))
if err1 == nil && err2 == nil && code >= lo && code <= hi {
return true
}
} else {
if v, err := strconv.Atoi(part); err == nil && code == v {
return true
}
}
}
return false
}
func checkHTTP(site models.Site) {
method := site.Method
if method == "" {
method = "GET"
}
timeout := siteTimeout(site)
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, method, site.URL, nil)
if err != nil {
handleStatusChange(site, "DOWN", 0, 0)
return
}
client := strictClient
if insecureSkipVerify || site.IgnoreTLS {
client = insecureClient
}
start := time.Now()
resp, err := client.Do(req)
latency := time.Since(start) latency := time.Since(start)
rawStatus := "UP" rawStatus := "UP"
@@ -279,7 +324,7 @@ func checkHTTP(site models.Site) {
} else { } else {
defer resp.Body.Close() defer resp.Body.Close()
rawCode = resp.StatusCode rawCode = resp.StatusCode
if resp.StatusCode >= 400 { if !isCodeAccepted(rawCode, site.AcceptedCodes) {
rawStatus = "DOWN" rawStatus = "DOWN"
} }
if site.CheckSSL && resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 { if site.CheckSSL && resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 {
+139 -134
View File
@@ -8,10 +8,139 @@ import (
"go-upkeep/internal/monitor" "go-upkeep/internal/monitor"
"go-upkeep/internal/store" "go-upkeep/internal/store"
"html/template" "html/template"
"log"
"net/http" "net/http"
"sort" "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 { type ServerConfig struct {
Port int Port int
EnableStatus bool EnableStatus bool
@@ -76,7 +205,8 @@ func Start(cfg ServerConfig) {
return return
} }
if err := store.Get().ImportData(data); err != nil { 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 return
} }
w.Write([]byte("Import Successful")) w.Write([]byte("Import Successful"))
@@ -94,12 +224,14 @@ func Start(cfg ServerConfig) {
} }
var kb importer.KumaBackup var kb importer.KumaBackup
if err := json.NewDecoder(r.Body).Decode(&kb); err != nil { 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 return
} }
backup := importer.ConvertKuma(&kb) backup := importer.ConvertKuma(&kb)
if err := store.Get().ImportData(backup); err != nil { 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 return
} }
w.Write([]byte(fmt.Sprintf("Imported %d monitors, %d alerts from Kuma v%s", len(backup.Sites), len(backup.Alerts), kb.Version))) 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() { go func() {
addr := fmt.Sprintf(":%d", cfg.Port) addr := fmt.Sprintf(":%d", cfg.Port)
fmt.Printf("HTTP Server listening on %s\n", addr) 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 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 { data := struct {
Title string Title string
Sites []models.Site Sites []models.Site
}{Title: title, Sites: sites} }{Title: title, Sites: sites}
t.Execute(w, data) statusTpl.Execute(w, data)
} }
+1
View File
@@ -239,6 +239,7 @@ func (p *PostgresStore) ImportData(data models.Backup) error {
if err != nil { if err != nil {
return err return err
} }
defer tx.Rollback()
tx.Exec("TRUNCATE TABLE sites RESTART IDENTITY CASCADE") tx.Exec("TRUNCATE TABLE sites RESTART IDENTITY CASCADE")
tx.Exec("TRUNCATE TABLE alerts RESTART IDENTITY CASCADE") tx.Exec("TRUNCATE TABLE alerts RESTART IDENTITY CASCADE")
+1 -1
View File
@@ -258,8 +258,8 @@ func (s *SQLiteStore) ImportData(data models.Backup) error {
if err != nil { if err != nil {
return err return err
} }
defer tx.Rollback()
// Wipe Existing
tx.Exec("DELETE FROM sites") tx.Exec("DELETE FROM sites")
tx.Exec("DELETE FROM sqlite_sequence WHERE name='sites'") tx.Exec("DELETE FROM sqlite_sequence WHERE name='sites'")
tx.Exec("DELETE FROM alerts") tx.Exec("DELETE FROM alerts")