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

This commit is contained in:
2025-10-14 23:08:51 +02:00
parent 46bd5622db
commit 0977a21641
7 changed files with 908 additions and 0 deletions

View File

@@ -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 }}

View File

@@ -0,0 +1,124 @@
# Git(tea) Actions workflow: Build and publish standalone binaries **plus** bundled `static/` assets
# ────────────────────────────────────────────────────────────────────
# ✧ Builds the Gobased 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 BootstrapAssets & 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 TagPush (vX.Y.Z) als ReleaseAssets.
#
# Secrets/variables:
# GITEA_TOKEN optional, falls default token keine ReleaseRechte 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 TagPushes
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

11
Dockerfile Normal file
View File

@@ -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"]

View File

@@ -1,2 +1,27 @@
# status-dashboard # 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).

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module git.send.nrw/sendnrw/status-dashboard
go 1.24.4

667
main.go Normal file
View 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 => ({'&':'&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();
})();
`

27
services.json Normal file
View File

@@ -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"
}
]
}