Files
status-dashboard/main.go
jbergner 0977a21641
All checks were successful
release-tag / release-image (push) Successful in 3m43s
build-binaries / build (, amd64, linux) (push) Has been skipped
build-binaries / build (, arm, 7, linux) (push) Has been skipped
build-binaries / build (, arm64, linux) (push) Has been skipped
build-binaries / build (.exe, amd64, windows) (push) Has been skipped
build-binaries / release (push) Has been skipped
init
2025-10-14 23:08:51 +02:00

668 lines
18 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>{{.Title}}</title>
<link rel="stylesheet" href="/static/style.css" />
<script>window.AppConfig = { title: {{printf "%q" .Title}}, pollSeconds: {{.PollSeconds}} };</script>
</head>
<body>
<header class="page-header">
<div class="container">
<h1 id="app-title">{{.Title}}</h1>
<div class="header-meta">
<span id="last-refresh" class="muted"></span>
<button id="refresh-btn" class="btn" aria-label="Jetzt aktualisieren">Aktualisieren</button>
</div>
</div>
</header>
<main class="container">
<div id="filters" class="filters">
<label class="sr-only" for="status-filter">Status-Filter</label>
<select id="status-filter" class="select">
<option value="">Alle Status</option>
<option>Online</option>
<option>Offline</option>
<option>Wartung</option>
<option>Beeinträchtigt</option>
<option>Unbekannt</option>
</select>
</div>
<div id="grid" class="grid" role="list"></div>
</main>
<footer class="container footer">
<span class="muted">Läuft lokal · Keine externen Ressourcen</span>
</footer>
<script src="/static/app.js" defer></script>
</body>
</html>`
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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 = '<span class="dot" aria-hidden="true"></span><span>'+esc(s.status || 'Unbekannt')+'</span>';
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();
})();
`