@@ -0,0 +1,228 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/example/glpi-ai-agent/internal/config"
|
||||
"github.com/example/glpi-ai-agent/internal/metrics"
|
||||
"github.com/example/glpi-ai-agent/internal/queue"
|
||||
"github.com/example/glpi-ai-agent/internal/state"
|
||||
)
|
||||
|
||||
//go:embed templates/dashboard.html
|
||||
var files embed.FS
|
||||
|
||||
type Server struct {
|
||||
cfg config.Config
|
||||
metrics *metrics.Metrics
|
||||
state *state.Store
|
||||
q *queue.Queue
|
||||
tpl *template.Template
|
||||
}
|
||||
|
||||
func New(cfg config.Config, m *metrics.Metrics, s *state.Store, q *queue.Queue) (*Server, error) {
|
||||
t, err := template.ParseFS(files, "templates/dashboard.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Server{cfg: cfg, metrics: m, state: s, q: q, tpl: t}, nil
|
||||
}
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", s.health)
|
||||
mux.HandleFunc("GET /readyz", s.ready)
|
||||
mux.HandleFunc("GET /metrics", s.prom)
|
||||
mux.Handle("GET /", s.auth(http.HandlerFunc(s.dashboard)))
|
||||
mux.Handle("GET /api/status", s.auth(http.HandlerFunc(s.status)))
|
||||
mux.Handle("GET /api/runs", s.auth(http.HandlerFunc(s.runs)))
|
||||
mux.HandleFunc("POST /webhook/glpi", s.webhook)
|
||||
return securityHeaders(requestLog(mux))
|
||||
}
|
||||
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
io.WriteString(w, `{"status":"ok"}`)
|
||||
}
|
||||
func (s *Server) ready(w http.ResponseWriter, r *http.Request) {
|
||||
g, o := s.metrics.Health()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if !g || !o {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"glpi": g, "ollama": o})
|
||||
}
|
||||
func (s *Server) prom(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
|
||||
s.metrics.WritePrometheus(w)
|
||||
}
|
||||
func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_ = s.tpl.ExecuteTemplate(w, "dashboard.html", map[string]any{"DryRun": s.cfg.DryRun, "AutoReply": s.cfg.AutoReply, "AutoCategory": s.cfg.AutoCategory, "CommunicationLanguage": s.cfg.CommunicationLanguage, "CommunicationStyle": s.cfg.CommunicationStyle})
|
||||
}
|
||||
func (s *Server) status(w http.ResponseWriter, r *http.Request) {
|
||||
g, o := s.metrics.Health()
|
||||
respondJSON(w, map[string]any{
|
||||
"uptime_seconds": int(time.Since(s.metrics.Started).Seconds()), "dry_run": s.cfg.DryRun, "auto_reply": s.cfg.AutoReply, "auto_category": s.cfg.AutoCategory,
|
||||
"processed": s.metrics.Processed.Load(), "skipped": s.metrics.Skipped.Load(), "errors": s.metrics.Errors.Load(), "category_changes": s.metrics.CategoryChanged.Load(), "replies": s.metrics.Replies.Load(), "queue_depth": s.q.Len(),
|
||||
"glpi_ok": g, "ollama_ok": o, "knowledge_docs": s.metrics.KnowledgeDocs(), "last_poll": s.metrics.LastPoll(),
|
||||
"communication_language": s.cfg.CommunicationLanguage, "communication_style": s.cfg.CommunicationStyle, "knowledge_allowed_sources": s.cfg.KnowledgeAllowedSources, "knowledge_auto_reply_sources": s.cfg.KnowledgeAutoReplySources,
|
||||
"context_enabled": s.cfg.ContextEnabled, "context_fetches": s.metrics.ContextFetches.Load(), "context_errors": s.metrics.ContextErrors.Load(),
|
||||
"change_calendar_enabled": s.cfg.ChangeCalendarEnabled, "major_incidents_enabled": s.cfg.MajorIncidentsEnabled, "user_device_context_enabled": s.cfg.UserDeviceContextEnabled,
|
||||
"uptime_kuma_enabled": s.cfg.UptimeKumaEnabled, "uptime_kuma_mode": s.cfg.UptimeKumaMode, "uptime_kuma_status_pages": s.cfg.UptimeKumaStatusPages, "context_fail_closed": s.cfg.ContextBlockReplyOnError, "context_incident_block": s.cfg.ContextBlockReplyOnIncident,
|
||||
})
|
||||
}
|
||||
func (s *Server) runs(w http.ResponseWriter, r *http.Request) {
|
||||
limit := 50
|
||||
if v := r.URL.Query().Get("limit"); v != "" {
|
||||
if n, e := strconv.Atoi(v); e == nil && n > 0 && n <= 200 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
respondJSON(w, s.state.Recent(limit))
|
||||
}
|
||||
|
||||
var ticketRE = regexp.MustCompile(`(?i)/Ticket/(\d+)`)
|
||||
|
||||
func (s *Server) webhook(w http.ResponseWriter, r *http.Request) {
|
||||
if s.cfg.WebhookSecret == "" {
|
||||
http.Error(w, "webhook disabled", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
got := r.Header.Get("X-Webhook-Secret")
|
||||
if subtle.ConstantTimeCompare([]byte(got), []byte(s.cfg.WebhookSecret)) != 1 {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
if err != nil {
|
||||
http.Error(w, "bad request", 400)
|
||||
return
|
||||
}
|
||||
id := extractTicketID(body)
|
||||
if id <= 0 {
|
||||
http.Error(w, "no ticket id found", 422)
|
||||
return
|
||||
}
|
||||
s.metrics.WebhookEvents.Add(1)
|
||||
if !s.q.Enqueue(id) {
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
return
|
||||
}
|
||||
s.metrics.QueueDepth.Store(int64(s.q.Len()))
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}
|
||||
func extractTicketID(body []byte) int64 {
|
||||
var v any
|
||||
if json.Unmarshal(body, &v) == nil {
|
||||
if id := walkID(v); id > 0 {
|
||||
return id
|
||||
}
|
||||
}
|
||||
if m := ticketRE.FindSubmatch(body); len(m) == 2 {
|
||||
id, _ := strconv.ParseInt(string(m[1]), 10, 64)
|
||||
return id
|
||||
}
|
||||
return 0
|
||||
}
|
||||
func walkID(v any) int64 {
|
||||
m, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
for _, k := range []string{"ticket_id", "ticketId"} {
|
||||
if n := num(m[k]); n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
if typ, ok := m["itemtype"].(string); ok && strings.EqualFold(typ, "Ticket") {
|
||||
if n := num(m["id"]); n > 0 {
|
||||
return n
|
||||
}
|
||||
if n := num(m["items_id"]); n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
// A nested object explicitly named "ticket" may legitimately only carry an id.
|
||||
if child, ok := m["ticket"].(map[string]any); ok {
|
||||
if n := num(child["id"]); n > 0 {
|
||||
return n
|
||||
}
|
||||
if n := walkID(child); n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
// Other generic wrapper objects are searched only for explicit ticket markers;
|
||||
// their own generic "id" must never be mistaken for a ticket id.
|
||||
for _, k := range []string{"item", "data", "object"} {
|
||||
if child, ok := m[k]; ok {
|
||||
if n := walkID(child); n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
func num(v any) int64 {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return int64(x)
|
||||
case string:
|
||||
n, _ := strconv.ParseInt(x, 10, 64)
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
func respondJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
func (s *Server) auth(next http.Handler) http.Handler {
|
||||
if s.cfg.WebAllowAnonymous {
|
||||
return next
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u, p, ok := r.BasicAuth()
|
||||
if !ok || subtle.ConstantTimeCompare([]byte(u), []byte(s.cfg.WebUsername)) != 1 || subtle.ConstantTimeCompare([]byte(p), []byte(s.cfg.WebPassword)) != 1 {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="GLPI AI Agent"`)
|
||||
http.Error(w, "unauthorized", 401)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
func securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
func requestLog(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
next.ServeHTTP(w, r)
|
||||
if r.URL.Path != "/healthz" {
|
||||
slog.Debug("http request", "method", r.Method, "path", r.URL.Path, "duration", time.Since(start).String())
|
||||
}
|
||||
})
|
||||
}
|
||||
func Listen(addr string, h http.Handler) *http.Server {
|
||||
return &http.Server{Addr: addr, Handler: h, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 1 << 20}
|
||||
}
|
||||
func (s *Server) String() string { return fmt.Sprintf("web(%s)", s.cfg.HTTPAddr) }
|
||||
Reference in New Issue
Block a user