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", "./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(p string) (*Config, error) { b, err := os.ReadFile(p) if err != nil { return nil, err } var cfg Config if err := json.Unmarshal(b, &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 = `