package server
import (
"context"
"html/template"
"net/http"
"strconv"
"strings"
"time"
"gitea.wayfinderak.com/wayfinderak/log-guardian/internal/alerts"
"gitea.wayfinderak.com/wayfinderak/log-guardian/internal/loki"
"gitea.wayfinderak.com/wayfinderak/log-guardian/internal/rules"
"gitea.wayfinderak.com/wayfinderak/log-guardian/internal/store"
)
type Server struct {
store *store.FileStore
engine *rules.Engine
loki *loki.Client
dispatcher *alerts.Dispatcher
authUsername string
authPassword string
}
func New(s *store.FileStore, engine *rules.Engine, lokiClient *loki.Client, dispatcher *alerts.Dispatcher, authUsername, authPassword string) *Server {
return &Server{store: s, engine: engine, loki: lokiClient, dispatcher: dispatcher, authUsername: authUsername, authPassword: authPassword}
}
func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", s.health)
mux.HandleFunc("GET /", s.index)
mux.HandleFunc("POST /rules", s.saveRule)
mux.HandleFunc("POST /check", s.checkNow)
mux.HandleFunc("POST /channels", s.saveChannel)
mux.HandleFunc("POST /channels/delete", s.deleteChannel)
mux.HandleFunc("POST /channels/test", s.testChannel)
mux.HandleFunc("GET /loki/labels", s.lokiLabels)
mux.HandleFunc("GET /loki/label-values", s.lokiLabelValues)
mux.HandleFunc("POST /loki/query-test", s.lokiQueryTest)
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static"))))
return securityHeaders(requestLog(basicAuth(mux, s.authUsername, s.authPassword)))
}
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]string{"status": "ok", "service": "log-guardian"})
}
func (s *Server) index(w http.ResponseWriter, r *http.Request) {
data, err := s.store.Load()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
render(w, data)
}
func (s *Server) saveRule(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
threshold, _ := strconv.Atoi(r.FormValue("threshold"))
rule := store.Rule{ID: strings.TrimSpace(r.FormValue("id")), Name: strings.TrimSpace(r.FormValue("name")), Enabled: r.FormValue("enabled") == "on", LogQL: strings.TrimSpace(r.FormValue("logql")), Severity: strings.TrimSpace(r.FormValue("severity")), Threshold: threshold, Window: strings.TrimSpace(r.FormValue("window")), Cooldown: strings.TrimSpace(r.FormValue("cooldown")), AnalysisEnabled: r.FormValue("analysis_enabled") == "on", AlertChannels: r.Form["alert_channels"]}
if rule.Name == "" || rule.LogQL == "" {
http.Error(w, "name and LogQL are required", http.StatusBadRequest)
return
}
if rule.Severity == "" {
rule.Severity = "warning"
}
if err := s.store.UpsertRule(rule); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) saveChannel(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
channel := store.AlertChannel{ID: strings.TrimSpace(r.FormValue("id")), Name: strings.TrimSpace(r.FormValue("name")), Type: strings.TrimSpace(r.FormValue("type")), Enabled: r.FormValue("enabled") == "on", Params: map[string]string{}}
for _, key := range []string{"server_url", "topic", "token", "priority", "tags", "url", "api_url", "email"} {
if value := strings.TrimSpace(r.FormValue(key)); value != "" {
channel.Params[key] = value
}
}
if channel.Name == "" || channel.Type == "" {
http.Error(w, "name and type are required", http.StatusBadRequest)
return
}
if err := s.store.UpsertAlertChannel(channel); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) deleteChannel(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := s.store.DeleteAlertChannel(r.FormValue("id")); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) testChannel(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
data, err := s.store.Load()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
for _, channel := range data.AlertChannels {
if channel.ID == r.FormValue("id") {
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
if err := s.dispatcher.Test(ctx, channel); err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
writeJSON(w, map[string]string{"status": "sent"})
return
}
}
http.Error(w, "channel not found", http.StatusNotFound)
}
func (s *Server) checkNow(w http.ResponseWriter, r *http.Request) {
s.engine.CheckAll(context.Background())
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) lokiLabels(w http.ResponseWriter, r *http.Request) {
labels, err := s.loki.Labels(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
writeJSON(w, map[string]any{"labels": labels})
}
func (s *Server) lokiLabelValues(w http.ResponseWriter, r *http.Request) {
values, err := s.loki.LabelValues(r.Context(), r.URL.Query().Get("name"))
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
writeJSON(w, map[string]any{"values": values})
}
func (s *Server) lokiQueryTest(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
window, err := time.ParseDuration(r.FormValue("window"))
if err != nil {
window = 5 * time.Minute
}
result, err := s.loki.QueryRange(r.Context(), strings.TrimSpace(r.FormValue("logql")), window, 20)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
if len(result.Matches) > 5 {
result.Matches = result.Matches[:5]
}
writeJSON(w, result)
}
var tmpl = template.Must(template.New("index").Funcs(template.FuncMap{"secretState": secretState}).Parse(`
Log Guardian
Loki diagnostics
Use authenticated endpoints: /loki/labels, /loki/label-values?name=service.
Alert channels
| Name | Type | Status | Secret | Actions |
{{range .AlertChannels}}| {{.Name}} | {{.Type}} | {{if .Enabled}}enabled{{else}}disabled{{end}} | {{secretState .Params}} | |
{{else}}| No alert channels configured. |
{{end}}
Rules
| Name | LogQL | Cooldown | Last count | Suppressed | Status |
{{range .Rules}}| {{.Name}}{{if not .Enabled}} disabled{{end}} | {{.LogQL}} | {{.Cooldown}} | {{.LastMatchCount}} | {{.SuppressedCount}} | {{if .LastError}}{{.LastError}}{{else}}checked {{.LastCheckedAt}}{{end}} |
{{else}}| No rules configured yet. |
{{end}}
Recent incidents
| Time | Rule | Severity | Summary | Evidence |
{{range .Incidents}}| {{.CreatedAt}} | {{.RuleName}} | {{.Severity}} | {{.Summary}} | {{range .AlertResults}} {{.}} {{end}}{{range .RemediationEvidence}}{{.}} {{end}} |
{{else}}| No incidents recorded. |
{{end}}
`))
func secretState(params map[string]string) string {
if params["token"] != "" || params["password"] != "" || params["secret"] != "" {
return "configured"
}
return "not configured"
}
func render(w http.ResponseWriter, data store.Data) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = tmpl.Execute(w, data)
}