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

Log Guardian

Loki-backed log monitoring, alerting, and guarded remediation.

Loki diagnostics

Use authenticated endpoints: /loki/labels, /loki/label-values?name=service.

Add or replace rule

Alert channels{{range .AlertChannels}}{{else}}No channels configured.{{end}}

Alert channels

{{range .AlertChannels}}{{else}}{{end}}
NameTypeStatusSecretActions
{{.Name}}{{.Type}}{{if .Enabled}}enabled{{else}}disabled{{end}}{{secretState .Params}}
No alert channels configured.

Rules

{{range .Rules}}{{else}}{{end}}
NameLogQLCooldownLast countSuppressedStatus
{{.Name}}{{if not .Enabled}} disabled{{end}}{{.LogQL}}{{.Cooldown}}{{.LastMatchCount}}{{.SuppressedCount}}{{if .LastError}}{{.LastError}}{{else}}checked {{.LastCheckedAt}}{{end}}
No rules configured yet.

Recent incidents

{{range .Incidents}}{{else}}{{end}}
TimeRuleSeveritySummaryEvidence
{{.CreatedAt}}{{.RuleName}}{{.Severity}}{{.Summary}}{{range .AlertResults}}
{{.}}
{{end}}{{range .RemediationEvidence}}
{{.}}
{{end}}
No incidents recorded.
`)) 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) }