init
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
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
This commit is contained in:
667
main.go
Normal file
667
main.go
Normal file
@@ -0,0 +1,667 @@
|
||||
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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();
|
||||
})();
|
||||
`
|
||||
Reference in New Issue
Block a user