Some checks failed
release-tag / release-image (push) Has been cancelled
build-binaries / build (, amd64, linux) (push) Successful in 46s
build-binaries / build (, arm, 7, linux) (push) Successful in 44s
build-binaries / build (, arm64, linux) (push) Successful in 45s
build-binaries / build (.exe, amd64, windows) (push) Successful in 47s
build-binaries / release (push) Successful in 18s
668 lines
18 KiB
Go
668 lines
18 KiB
Go
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(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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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();
|
||
})();
|
||
`
|