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 = ` + +
+ + +