package main import ( "encoding/json" "html/template" "log" "net/http" "os" "path" "strings" "sync" "time" ) // ====== Datenmodelle ====== type Service struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` Status string `json:"status"` LastChange time.Time `json:"last_change"` } type Config struct { Title string `json:"title"` PollSeconds int `json:"poll_seconds"` Services []ServiceSeed `json:"services"` } type ServiceSeed struct { Name string `json:"name"` Description string `json:"description"` Status string `json:"status"` // Optional: RFC3339 (z.B. "2025-10-14T08:31:00Z") LastChange string `json:"last_change,omitempty"` } type ServicesResponse struct { Title string `json:"title"` PollSeconds int `json:"poll_seconds"` Services []Service `json:"services"` UpdatedAt time.Time `json:"updated_at"` } // ====== Zustand (mit Mutex) ====== type State struct { mu sync.RWMutex title string pollSeconds int services map[string]*Service order []string // Beibehaltung der Anzeige-Reihenfolge } func newState() *State { return &State{ title: "Status-Dashboard", pollSeconds: 15, services: make(map[string]*Service), order: []string{}, } } func (s *State) loadConfig(cfg *Config) { s.mu.Lock() defer s.mu.Unlock() if cfg.Title != "" { s.title = cfg.Title } if cfg.PollSeconds > 0 { s.pollSeconds = cfg.PollSeconds } s.services = make(map[string]*Service) s.order = s.order[:0] idCount := map[string]int{} for _, seed := range cfg.Services { id := slugify(seed.Name) idCount[id]++ if idCount[id] > 1 { id = id + "-" + itoa(idCount[id]) } t := time.Now().UTC() if seed.LastChange != "" { if parsed, err := time.Parse(time.RFC3339, seed.LastChange); err == nil { t = parsed } } srv := &Service{ ID: id, Name: seed.Name, Description: seed.Description, Status: seed.Status, LastChange: t, } s.services[id] = srv s.order = append(s.order, id) } } func (s *State) listServices() ServicesResponse { s.mu.RLock() defer s.mu.RUnlock() list := make([]Service, 0, len(s.order)) for _, id := range s.order { if srv, ok := s.services[id]; ok { // Kopie (keine Zeiger nach außen) list = append(list, *srv) } } return ServicesResponse{ Title: s.title, PollSeconds: s.pollSeconds, Services: list, UpdatedAt: time.Now().UTC(), } } func (s *State) updateService(id string, update map[string]string) (*Service, bool) { s.mu.Lock() defer s.mu.Unlock() srv, ok := s.services[id] if !ok { return nil, false } changed := false if name, ok := update["name"]; ok && name != "" && name != srv.Name { srv.Name = name changed = true } if desc, ok := update["description"]; ok && desc != srv.Description { srv.Description = desc changed = true } if st, ok := update["status"]; ok && st != "" && st != srv.Status { srv.Status = st changed = true } // Wenn irgendein Feld geändert wurde, Zeitstempel setzen if changed { srv.LastChange = time.Now().UTC() } return srv, true } func (s *State) addService(seed ServiceSeed) *Service { s.mu.Lock() defer s.mu.Unlock() id := slugify(seed.Name) dup := 1 for { if _, exists := s.services[id]; !exists { break } dup++ id = slugify(seed.Name) + "-" + itoa(dup) } t := time.Now().UTC() if seed.LastChange != "" { if parsed, err := time.Parse(time.RFC3339, seed.LastChange); err == nil { t = parsed } } srv := &Service{ ID: id, Name: seed.Name, Description: seed.Description, Status: seed.Status, LastChange: t, } s.services[id] = srv s.order = append(s.order, id) return srv } // ====== HTTP Handlers ====== func envOr(k, def string) string { if v := os.Getenv(k); v != "" { return v } return def } func main() { addr := envOr("addr", ":8080") cfgPath := envOr("config", "/data/services.json") state := newState() // Demo-Daten, falls keine Config angegeben ist if cfgPath == "" { state.loadConfig(&Config{ Title: "Mein Status-Dashboard", PollSeconds: 15, Services: []ServiceSeed{ {Name: "API-Gateway", Description: "Eingangspunkt für alle Backend-APIs", Status: "Online"}, {Name: "Datenbank", Description: "Primärer PostgreSQL-Cluster", Status: "Wartung"}, {Name: "Benachrichtigungen", Description: "E-Mail/Push-Service", Status: "Beeinträchtigt"}, {Name: "Website", Description: "Öffentliche Landingpage", Status: "Offline"}, }, }) } else { cfg, err := readConfig(cfgPath) if err != nil { log.Fatalf("Konfiguration laden fehlgeschlagen: %v", err) } state.loadConfig(cfg) } // Routen http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } renderIndex(w, state) }) http.HandleFunc("/static/style.css", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/css; charset=utf-8") http.ServeContent(w, r, "style.css", buildTime, strings.NewReader(styleCSS)) }) http.HandleFunc("/static/app.js", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/javascript; charset=utf-8") http.ServeContent(w, r, "app.js", buildTime, strings.NewReader(appJS)) }) http.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) }) // API http.HandleFunc("/api/services", func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: writeJSON(w, state.listServices()) case http.MethodPost: // Bulk-Add: Array von ServiceSeed var seeds []ServiceSeed if err := json.NewDecoder(r.Body).Decode(&seeds); err != nil { http.Error(w, "invalid JSON body", http.StatusBadRequest) return } created := make([]*Service, 0, len(seeds)) for _, sd := range seeds { if strings.TrimSpace(sd.Name) == "" { continue } created = append(created, state.addService(sd)) } writeJSON(w, created) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } }) http.HandleFunc("/api/service/", func(w http.ResponseWriter, r *http.Request) { // /api/service/{id} (nur Update per POST) id := strings.TrimPrefix(r.URL.Path, "/api/service/") id = path.Clean("/" + id)[1:] // simple sanitize if id == "" { http.NotFound(w, r) return } switch r.Method { case http.MethodPost: var payload map[string]string if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { http.Error(w, "invalid JSON body", http.StatusBadRequest) return } srv, ok := state.updateService(id, payload) if !ok { http.Error(w, "service not found", http.StatusNotFound) return } writeJSON(w, srv) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } }) log.Printf("🚀 Server läuft auf %s", addr) log.Fatal(http.ListenAndServe(addr, nil)) } // ====== Hilfsfunktionen ====== func renderIndex(w http.ResponseWriter, st *State) { st.mu.RLock() defer st.mu.RUnlock() data := struct { Title string PollSeconds int }{ Title: st.title, PollSeconds: st.pollSeconds, } tpl := template.Must(template.New("index").Parse(indexHTML)) w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := tpl.Execute(w, data); err != nil { http.Error(w, "template error", http.StatusInternalServerError) } } func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") enc := json.NewEncoder(w) enc.SetIndent("", " ") _ = enc.Encode(v) } func readConfig(path string) (*Config, error) { var cfg Config f, err := http.Dir(".").Open(path) // schlichtes Lesen aus CWD if err != nil { return nil, err } defer f.Close() if err := json.NewDecoder(f).Decode(&cfg); err != nil { return nil, err } return &cfg, nil } var buildTime = time.Now() func slugify(s string) string { s = strings.ToLower(strings.TrimSpace(s)) // sehr einfache Slug-Funktion r := make([]rune, 0, len(s)) for _, ch := range s { if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') { r = append(r, ch) } else if ch == ' ' || ch == '-' || ch == '_' { if len(r) == 0 || r[len(r)-1] == '-' { continue } r = append(r, '-') } } out := strings.Trim(rmDashes(string(r)), "-") if out == "" { out = "srv" } return out } func rmDashes(s string) string { return strings.ReplaceAll(strings.ReplaceAll(s, "--", "-"), "---", "-") } func itoa(i int) string { // kleine, schnelle itoa ohne strconv if i == 0 { return "0" } var buf [32]byte pos := len(buf) for i > 0 { pos-- buf[pos] = byte('0' + i%10) i /= 10 } return string(buf[pos:]) } // ====== Assets (lokal bereitgestellt) ====== const indexHTML = ` {{.Title}}
` const styleCSS = `:root{ --bg: #0b0c10; --bg-elev: #111218; --card: #14151d; --fg: #e6e7ea; --muted: #9aa0aa; --border: #262838; --shadow: 0 10px 20px rgba(0,0,0,.35); --green: #33d17a; --red: #e01b24; --yellow: #f5c211; --orange: #ff8800; --blue: #3b82f6; --gray: #6b7280; } @media (prefers-color-scheme: light){ :root{ --bg: #f6f7fb; --bg-elev: #fff; --card: #fff; --fg: #0f1219; --muted: #5e6573; --border: #e6e8ef; --shadow: 0 10px 20px rgba(0,0,0,.08); } } *{box-sizing:border-box} html,body{height:100%} body{ margin:0; color:var(--fg); background:linear-gradient(180deg,var(--bg), var(--bg-elev)); font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, Cantarell, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji","Segoe UI Emoji", "Noto Color Emoji", sans-serif; } .container{max-width:1100px;margin:0 auto;padding:0 1rem} .page-header{ position:sticky;top:0;z-index:10; backdrop-filter:saturate(120%) blur(6px); background: color-mix(in lab, var(--bg-elev) 70%, transparent); border-bottom:1px solid var(--border); } .page-header .container{ display:flex;align-items:center;justify-content:space-between;gap:.75rem;padding:1rem 1rem; } h1{font-size:1.4rem;margin:0} .header-meta{display:flex;align-items:center;gap:.75rem} .muted{color:var(--muted)} .btn{ border:1px solid var(--border); background:var(--card); color:var(--fg); border-radius:12px; padding:.45rem .8rem; box-shadow:var(--shadow); cursor:pointer; transition:transform .05s ease, background .2s ease, border-color .2s ease; } .btn:hover{transform:translateY(-1px)} .btn:active{transform:translateY(0)} .select{ border:1px solid var(--border); background:var(--card); color:var(--fg); border-radius:12px; padding:.45rem .8rem; } .filters{display:flex;justify-content:flex-end;padding:1rem 0} .grid{ display:grid; grid-template-columns:repeat(auto-fill, minmax(280px, 1fr)); gap:1rem; padding:0 0 2rem 0; } .card{ border:1px solid var(--border); background:var(--card); border-radius:16px; box-shadow:var(--shadow); padding:1rem; display:flex; flex-direction:column; gap:.65rem; transition: translate .15s ease, box-shadow .2s ease, border-color .2s ease; } .card:hover{translate:0 -2px} .card-title{ display:flex;align-items:center;justify-content:space-between;gap:.5rem; } .card-title h2{ font-size:1.05rem; margin:0; line-height:1.2; font-weight:650; } .desc{color:var(--muted);margin:0} .row{display:flex; align-items:center; justify-content:space-between; gap:.75rem} .status-badge{ font-size:.8rem; border-radius:999px; padding:.25rem .6rem; border:1px solid var(--border); background:var(--bg-elev); display:inline-flex; align-items:center; gap:.4rem; } .dot{width:.55rem;height:.55rem;border-radius:999px;display:inline-block;border:1px solid var(--border)} .st-online .status-badge{border-color: color-mix(in srgb, var(--green) 45%, var(--border))} .st-online .dot{background:var(--green)} .st-offline .status-badge{border-color: color-mix(in srgb, var(--red) 45%, var(--border))} .st-offline .dot{background:var(--red)} .st-wartung .status-badge{border-color: color-mix(in srgb, var(--yellow) 50%, var(--border))} .st-wartung .dot{background:var(--yellow)} .st-beeinträchtigt .status-badge{border-color: color-mix(in srgb, var(--orange) 50%, var(--border))} .st-beeinträchtigt .dot{background:var(--orange)} .st-unbekannt .status-badge{border-color: color-mix(in srgb, var(--gray) 45%, var(--border))} .st-unbekannt .dot{background:var(--gray)} .meta{font-size:.85rem;color:var(--muted)} .footer{padding:2rem 0 3rem 0;text-align:center} .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0} ` const appJS = `"use strict"; (function(){ const cfg = window.AppConfig || { title: 'Status-Dashboard', pollSeconds: 15 }; const grid = document.getElementById('grid'); const lastRefreshEl = document.getElementById('last-refresh'); const btn = document.getElementById('refresh-btn'); const filterEl = document.getElementById('status-filter'); let timer = null; let cache = []; function statusKey(s){ if(!s) return 'unbekannt'; const n = s.toLowerCase(); if(n === 'online') return 'online'; if(n === 'offline') return 'offline'; if(n === 'wartung' || n === 'wartend' ) return 'wartung'; if(n === 'beeinträchtigt' || n === 'degraded') return 'beeinträchtigt'; if(n === 'unknown' || n === 'unbekannt') return 'unbekannt'; return n; // frei definierter Status -> eigener Key } function classForStatus(s){ const k = statusKey(s); const allowed = ['online','offline','wartung','beeinträchtigt','unbekannt']; return 'st-' + (allowed.includes(k) ? k : 'unbekannt'); } function esc(s){ return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } function timeAgo(ts){ const d = new Date(ts); const now = new Date(); const diff = (d - now) / 1000; // Sekunden (negativ, wenn in der Vergangenheit) const rtf = new Intl.RelativeTimeFormat('de', { numeric: 'auto' }); const units = [ ['year', 60*60*24*365], ['month', 60*60*24*30], ['week', 60*60*24*7], ['day', 60*60*24], ['hour', 60*60], ['minute', 60], ['second', 1], ]; for(const [unit, sec] of units){ const val = Math.round(diff / sec); if(Math.abs(val) >= 1){ return rtf.format(val, unit); } } return 'gerade eben'; } function render(services){ const f = (filterEl.value || '').toLowerCase(); grid.innerHTML = ''; for(const s of services){ if(f && statusKey(s.status) !== statusKey(f)) continue; const card = document.createElement('article'); card.className = 'card ' + classForStatus(s.status); card.setAttribute('role','listitem'); const title = document.createElement('div'); title.className = 'card-title'; const h2 = document.createElement('h2'); h2.textContent = s.name; const badge = document.createElement('span'); badge.className = 'status-badge'; badge.innerHTML = ''+esc(s.status || 'Unbekannt')+''; title.appendChild(h2); title.appendChild(badge); const desc = document.createElement('p'); desc.className = 'desc'; desc.textContent = s.description || '—'; const meta = document.createElement('div'); meta.className = 'meta row'; const lc = new Date(s.last_change); const timeSpan = document.createElement('span'); timeSpan.textContent = 'Letzte Änderung: ' + timeAgo(lc.toISOString()); timeSpan.title = lc.toLocaleString('de-DE'); meta.appendChild(timeSpan); card.appendChild(title); card.appendChild(desc); card.appendChild(meta); grid.appendChild(card); } } async function fetchServices(){ const res = await fetch('/api/services', { cache: 'no-store' }); if(!res.ok) throw new Error('HTTP '+res.status); const data = await res.json(); document.getElementById('app-title').textContent = data.title || cfg.title; cache = data.services || []; render(cache); const upd = new Date(data.updated_at).toLocaleTimeString('de-DE', {hour:'2-digit', minute:'2-digit', second:'2-digit'}); lastRefreshEl.textContent = 'Zuletzt aktualisiert: '+ upd; } function startTimer(){ clearTimer(); const ms = Math.max(3, Number(cfg.pollSeconds || 15)) * 1000; timer = setInterval(fetchServices, ms); } function clearTimer(){ if(timer){ clearInterval(timer); timer = null; } } // init btn.addEventListener('click', fetchServices); filterEl.addEventListener('change', ()=> render(cache)); fetchServices().catch(console.error); startTimer(); })(); `