init
release-tag / release-image (push) Successful in 1m33s

This commit is contained in:
2026-07-27 17:32:35 +02:00
parent 205dbfd877
commit 9b3227348d
39 changed files with 4647 additions and 1 deletions
+228
View File
@@ -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) }
+26
View File
@@ -0,0 +1,26 @@
package web
import "testing"
func TestExtractTicketID(t *testing.T) {
tests := []struct {
name string
body string
want int64
}{
{name: "explicit ticket id", body: `{"ticket_id":42}`, want: 42},
{name: "camel case ticket id", body: `{"ticketId":"43"}`, want: 43},
{name: "typed ticket", body: `{"itemtype":"Ticket","id":44}`, want: 44},
{name: "nested ticket", body: `{"ticket":{"id":45}}`, want: 45},
{name: "ticket url", body: `{"url":"https://glpi.example/api.php/v2.3/Assistance/Ticket/46"}`, want: 46},
{name: "unrelated generic id", body: `{"itemtype":"User","id":99}`, want: 0},
{name: "wrapped unrelated generic id", body: `{"data":{"id":100}}`, want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := extractTicketID([]byte(tt.body)); got != tt.want {
t.Fatalf("extractTicketID() = %d, want %d", got, tt.want)
}
})
}
}
+6
View File
@@ -0,0 +1,6 @@
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>GLPI AI Agent</title><style>
:root{font-family:Inter,system-ui,sans-serif;color-scheme:dark;background:#0b1020;color:#e5e7eb}body{margin:0}.wrap{max-width:1180px;margin:auto;padding:28px}.top{display:flex;justify-content:space-between;align-items:center;gap:20px}.badge{padding:6px 10px;border-radius:999px;background:#1f2937;font-size:12px}.warn{background:#713f12}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:14px;margin:24px 0}.card{background:#111827;border:1px solid #273244;border-radius:14px;padding:18px}.k{color:#9ca3af;font-size:12px;text-transform:uppercase;letter-spacing:.08em}.v{font-size:28px;font-weight:700;margin-top:8px}.ok{color:#86efac}.bad{color:#fca5a5}table{width:100%;border-collapse:collapse;background:#111827;border-radius:14px;overflow:hidden}th,td{text-align:left;padding:12px;border-bottom:1px solid #273244;font-size:13px}th{color:#9ca3af}.muted{color:#9ca3af}.pill{padding:3px 7px;border-radius:999px;background:#1f2937}h1{margin:0;font-size:25px}@media(max-width:700px){.wrap{padding:16px}.hide-sm{display:none}}
</style></head><body><div class="wrap"><div class="top"><div><h1>GLPI AI Agent</h1><div class="muted">Status & Audit Dashboard</div></div><div>{{if .DryRun}}<span class="badge warn">DRY RUN</span>{{else}}<span class="badge">LIVE</span>{{end}} {{if .AutoReply}}<span class="badge">Auto-Reply an</span>{{else}}<span class="badge">Auto-Reply aus</span>{{end}}</div></div><div id="cards" class="grid"></div><h2>Letzte Verarbeitungen</h2><table><thead><tr><th>Zeit</th><th>Ticket</th><th>Ergebnis</th><th class="hide-sm">Kategorie</th><th>Antwort</th><th class="hide-sm">Kontext</th><th class="hide-sm">Grund</th></tr></thead><tbody id="runs"><tr><td colspan="7">Lade…</td></tr></tbody></table></div><script>
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
async function refresh(){try{const [s,r]=await Promise.all([fetch('/api/status').then(x=>x.json()),fetch('/api/runs?limit=50').then(x=>x.json())]);const cards=[['GLPI',s.glpi_ok?'OK':'Fehler',s.glpi_ok],['Ollama',s.ollama_ok?'OK':'Fehler',s.ollama_ok],['Verarbeitet',s.processed,true],['Übersprungen',s.skipped,true],['Fehler',s.errors,s.errors===0],['Antworten',s.replies,true],['Kategorien',s.category_changes,true],['Queue',s.queue_depth,s.queue_depth<20],['Knowledge',s.knowledge_docs,true],['Sprache',s.communication_language,true],['Stil',s.communication_style,true],['Quellen',s.knowledge_allowed_sources.join(', '),true],['Reply-Quellen',s.knowledge_auto_reply_sources.join(', ')||'keine',true],['Context',s.context_enabled?'aktiv':'aus',true],['Context-Fehler',s.context_errors,s.context_errors===0],['Changes',s.change_calendar_enabled?'an':'aus',true],['Major Incidents',s.major_incidents_enabled?'an':'aus',true],['Benutzer/Geräte',s.user_device_context_enabled?'an':'aus',true],['Uptime Kuma',s.uptime_kuma_enabled?(s.uptime_kuma_status_pages.join(', ')||'an'):'aus',true]];document.querySelector('#cards').innerHTML=cards.map(c=>`<div class="card"><div class="k">${esc(c[0])}</div><div class="v ${c[2]?'ok':'bad'}">${esc(c[1])}</div></div>`).join('');document.querySelector('#runs').innerHTML=r.length?r.map(x=>`<tr><td>${esc(new Date(x.finished_at).toLocaleString('de-DE'))}</td><td>#${esc(x.ticket_id)} ${esc(x.ticket_name)}</td><td><span class="pill">${esc(x.outcome)}</span></td><td class="hide-sm">${esc(x.category_before)} → ${esc(x.category_proposed||x.category_before)}</td><td>${x.reply_written?'geschrieben':x.reply_proposed?'vorgeschlagen':'–'}</td><td class="hide-sm">C:${esc(x.context_changes||0)} I:${esc(x.context_incidents||0)} U:${esc(x.context_issues||0)} D:${esc(x.context_devices||0)}${(x.context_warnings||[]).length?' ⚠':''}</td><td class="hide-sm">${esc(x.reason||x.error)}</td></tr>`).join(''):'<tr><td colspan="7">Noch keine Verarbeitung.</td></tr>'}catch(e){console.error(e)}}refresh();setInterval(refresh,5000);
</script></body></html>