From 0977a21641f561d306f712887cf314003b8edf3f Mon Sep 17 00:00:00 2001 From: jbergner Date: Tue, 14 Oct 2025 23:08:51 +0200 Subject: [PATCH] init --- .gitea/workflows/registry.yml | 51 +++ .gitea/workflows/release.yml | 124 +++++++ Dockerfile | 11 + README.md | 25 ++ go.mod | 3 + main.go | 667 ++++++++++++++++++++++++++++++++++ services.json | 27 ++ 7 files changed, 908 insertions(+) create mode 100644 .gitea/workflows/registry.yml create mode 100644 .gitea/workflows/release.yml create mode 100644 Dockerfile create mode 100644 go.mod create mode 100644 main.go create mode 100644 services.json diff --git a/.gitea/workflows/registry.yml b/.gitea/workflows/registry.yml new file mode 100644 index 0000000..20912ac --- /dev/null +++ b/.gitea/workflows/registry.yml @@ -0,0 +1,51 @@ +name: release-tag +on: + push: + branches: + - 'main' +jobs: + release-image: + runs-on: ubuntu-fast + env: + DOCKER_ORG: ${{ vars.DOCKER_ORG }} + DOCKER_LATEST: latest + RUNNER_TOOL_CACHE: /toolcache + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker BuildX + uses: docker/setup-buildx-action@v2 + with: # replace it with your local IP + config-inline: | + [registry."${{ vars.DOCKER_REGISTRY }}"] + http = true + insecure = true + + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + registry: ${{ vars.DOCKER_REGISTRY }} # replace it with your local IP + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Get Meta + id: meta + run: | + echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT + echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT + + - name: Build and push + uses: docker/build-push-action@v4 + with: + context: . + file: ./Dockerfile + platforms: | + linux/amd64 + push: true + tags: | # replace it with your local IP and tags + ${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }} + ${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }} \ No newline at end of file diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..361ca4f --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,124 @@ +# Git(tea) Actions workflow: Build and publish standalone binaries **plus** bundled `static/` assets +# ──────────────────────────────────────────────────────────────────── +# ✧ Builds the Go‑based WoL server for four targets **and** packt das Verzeichnis +# `static` zusammen mit der Binary, sodass es relativ zur ausführbaren Datei +# liegt (wichtig für die eingebauten Bootstrap‑Assets & favicon). +# +# • linux/amd64 → wol-server-linux-amd64.tar.gz +# • linux/arm64 → wol-server-linux-arm64.tar.gz +# • linux/arm/v7 → wol-server-linux-armv7.tar.gz +# • windows/amd64 → wol-server-windows-amd64.zip +# +# ✧ Artefakte landen im Workflow und – bei Tag‑Push (vX.Y.Z) – als Release‑Assets. +# +# Secrets/variables: +# GITEA_TOKEN – optional, falls default token keine Release‑Rechte hat. +# ──────────────────────────────────────────────────────────────────── + +name: build-binaries + +on: + push: + branches: [ "main" ] + tags: [ "v*" ] + +jobs: + build: + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-fast + + strategy: + matrix: + include: + - goos: linux + goarch: amd64 + ext: "" + - goos: linux + goarch: arm64 + ext: "" + - goos: linux + goarch: arm + goarm: "7" + ext: "" + - goos: windows + goarch: amd64 + ext: ".exe" + + env: + GO_VERSION: "1.24" + BINARY_NAME: advocacy-watchlist + + steps: + - name: Checkout source + uses: actions/checkout@v3 + + - name: Set up Go ${{ env.GO_VERSION }} + uses: actions/setup-go@v4 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + + - name: Build ${{ matrix.goos }}/${{ matrix.goarch }}${{ matrix.goarm && format('/v{0}', matrix.goarm) || '' }} + shell: bash + run: | + set -e + mkdir -p dist/package + if [ -n "${{ matrix.goarm }}" ]; then export GOARM=${{ matrix.goarm }}; fi + CGO_ENABLED=0 GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} go build -trimpath -ldflags "-s -w" \ + -o "dist/package/${BINARY_NAME}${{ matrix.ext }}" . + # Assets: statisches Verzeichnis beilegen + # cp -r static dist/package/ + + - name: Package archive with static assets + shell: bash + run: | + set -e + cd dist + if [ "${{ matrix.goos }}" == "windows" ]; then + ZIP_NAME="${BINARY_NAME}-windows-amd64.zip" + (cd package && zip -r "../$ZIP_NAME" .) + else + ARCH_SUFFIX="${{ matrix.goarch }}" + if [ "${{ matrix.goarch }}" == "arm" ]; then ARCH_SUFFIX="armv${{ matrix.goarm }}"; fi + TAR_NAME="${BINARY_NAME}-${{ matrix.goos }}-${ARCH_SUFFIX}.tar.gz" + tar -czf "$TAR_NAME" -C package . + fi + + - name: Upload workflow artifact + uses: actions/upload-artifact@v3 + with: + name: ${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm && format('v{0}', matrix.goarm) || '' }} + path: dist/*.tar.gz + if-no-files-found: ignore + - uses: actions/upload-artifact@v3 + with: + name: windows-amd64 + path: dist/*.zip + if-no-files-found: ignore + + # Release Schritt für Tag‑Pushes + release: + if: startsWith(github.ref, 'refs/tags/') + needs: build + runs-on: ubuntu-fast + permissions: + contents: write + + steps: + - name: Download artifacts + uses: actions/download-artifact@v3 + with: + path: ./dist + + - name: Create / Update release + uses: softprops/action-gh-release@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN || github.token }} + with: + name: "Release ${{ github.ref_name }}" + tag_name: ${{ github.ref_name }} + draft: false + prerelease: false + files: | + dist/**/advocacy-watchlist-*.tar.gz + dist/**/advocacy-watchlist-*.zip diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..74139b6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.24.4 +WORKDIR /app +COPY go.mod ./ +RUN go mod download +COPY *.go ./ +RUN CGO_ENABLED=0 GOOS=linux go build -o /goprg +RUN mkdir /data +COPY services.json /data/services.json +VOLUME ["/data"] +EXPOSE 8080 +CMD ["/goprg"] \ No newline at end of file diff --git a/README.md b/README.md index 46ee0b8..2863c0f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,27 @@ # status-dashboard +GET http://localhost:8080/api/services + +POST http://localhost:8080/api/services +Content-Type: application/json + +[ + {"name":"Search","description":"Elasticsearch Cluster","status":"Online"}, + {"name":"CDN","description":"Edge Delivery","status":"Unbekannt"} +] + +POST http://localhost:8080/api/service/api-gateway +Content-Type: application/json + +{"status":"Offline"} + + +Hinweis: Die id eines Dienstes wird aus dem Namen gebildet (z. B. "API-Gateway" → api-gateway). Bei Namens-Dubletten wird -2, -3, … angehängt. + +Statusfarben sind vorkonfiguriert für: Online, Offline, Wartung, Beeinträchtigt, Unbekannt. Andere freie Status werden neutral gestylt (grau). + +Zeiten werden serverseitig als RFC3339 ausgegeben; im Frontend siehst du relative Zeiten („vor 2 Minuten“) plus Tooltip mit exakter Zeit. + +Dark/Light-Mode per OS-Einstellung. + +Fonts: System-Font-Stack (keine externen Downloads → vollständig lokal). \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..71dd1fd --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.send.nrw/sendnrw/status-dashboard + +go 1.24.4 diff --git a/main.go b/main.go new file mode 100644 index 0000000..4dd2fe4 --- /dev/null +++ b/main.go @@ -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 = ` + + + + + {{.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(); +})(); +` diff --git a/services.json b/services.json new file mode 100644 index 0000000..f83dbb2 --- /dev/null +++ b/services.json @@ -0,0 +1,27 @@ +{ + "title": "Team Status", + "poll_seconds": 10, + "services": [ + { + "name": "API-Gateway1", + "description": "Eingangspunkt für alle Backend-APIs", + "status": "Online", + "last_change": "2025-10-14T07:50:00Z" + }, + { + "name": "Datenbank1", + "description": "Primärer PostgreSQL-Cluster", + "status": "Wartung" + }, + { + "name": "Benachrichtigungen1", + "description": "E-Mail/Push-Service", + "status": "Beeinträchtigt" + }, + { + "name": "Website1", + "description": "Öffentliche Landingpage", + "status": "Offline" + } + ] +} \ No newline at end of file