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
|
# 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