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:
51
.gitea/workflows/registry.yml
Normal file
51
.gitea/workflows/registry.yml
Normal 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 }}
|
||||
124
.gitea/workflows/release.yml
Normal file
124
.gitea/workflows/release.yml
Normal file
@@ -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
|
||||
11
Dockerfile
Normal file
11
Dockerfile
Normal 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"]
|
||||
25
README.md
25
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).
|
||||
3
go.mod
Normal file
3
go.mod
Normal file
@@ -0,0 +1,3 @@
|
||||
module git.send.nrw/sendnrw/status-dashboard
|
||||
|
||||
go 1.24.4
|
||||
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();
|
||||
})();
|
||||
`
|
||||
27
services.json
Normal file
27
services.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user