Jelajahi Sumber

first commit

Ben Allen 1 Minggu lalu
melakukan
8fef5ff8ac
15 mengubah file dengan 1868 tambahan dan 0 penghapusan
  1. 16 0
      .gitignore
  2. 138 0
      DOCKER.md
  3. 30 0
      Dockerfile
  4. 251 0
      README.md
  5. 22 0
      config.yaml
  6. 36 0
      go.mod
  7. 91 0
      go.sum
  8. 186 0
      main.go
  9. 12 0
      publish.sh
  10. 335 0
      service_checks.go
  11. 261 0
      static/app.js
  12. 296 0
      static/styles.css
  13. 64 0
      status_store.go
  14. 29 0
      templates/status.html
  15. 101 0
      ws.go

+ 16 - 0
.gitignore

@@ -0,0 +1,16 @@
+# Go build artifacts
+/status
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+*.test
+*.out
+
+# Go workspace files
+go.work
+go.work.sum
+
+# Local tool metadata
+.codex

+ 138 - 0
DOCKER.md

@@ -0,0 +1,138 @@
+# `bmallenxs/status`
+
+Container image for a small live status page built with Go and Gin.
+
+The app reads `config.yaml`, serves a web UI, exposes `/api/status`, and pushes live service updates over WebSockets.
+
+## Quick Start
+
+Pull the image:
+
+```bash
+docker pull bmallenxs/status:latest
+```
+
+Run with the bundled config:
+
+```bash
+docker run --rm -p 8080:8080 bmallenxs/status:latest
+```
+
+Open:
+
+```text
+http://localhost:8080
+```
+
+## Use Your Own `config.yaml`
+
+Mount a config file into the container:
+
+```bash
+docker run --rm \
+  -p 8080:8080 \
+  -v "$(pwd)/config.yaml:/app/config.yaml:ro" \
+  bmallenxs/status:latest
+```
+
+## HTTPS / TLS
+
+If you provide a certificate and key, the container can serve HTTPS directly.
+
+```bash
+docker run --rm \
+  -p 8443:8443 \
+  -e ADDR=:8443 \
+  -e TLS_CERT_FILE=/certs/server.crt \
+  -e TLS_KEY_FILE=/certs/server.key \
+  -v "$(pwd)/config.yaml:/app/config.yaml:ro" \
+  -v "$(pwd)/certs:/certs:ro" \
+  bmallenxs/status:latest
+```
+
+Open:
+
+```text
+https://localhost:8443
+```
+
+## Environment Variables
+
+`ADDR`
+: Full listen address inside the container. Default: `:8080`
+
+`PORT`
+: Port-only fallback if `ADDR` is not set
+
+`CONFIG_PATH`
+: Config file path. Default: `/app/config.yaml`
+
+`THEME`
+: UI theme. Supported values: `dark` or `light`. Default: `dark`
+
+`TLS_CERT_FILE`
+: Optional PEM certificate file path for HTTPS
+
+`TLS_KEY_FILE`
+: Optional PEM private key file path for HTTPS
+
+Notes:
+
+- Set both `TLS_CERT_FILE` and `TLS_KEY_FILE` to enable HTTPS.
+- If only one is set, the container exits with an error.
+
+## Example `config.yaml`
+
+```yaml
+monitors:
+  - name: GitHub
+    url: https://github.com
+    category: Developer Tools
+
+  - name: Cloudflare DNS
+    url: dns://cloudflare.com
+    category: DNS
+
+  - name: Google TLS
+    url: tls://www.google.com:443
+    category: TLS
+
+  - name: Google Ping
+    url: ping://8.8.8.8
+    category: Network
+```
+
+## Supported Monitor Schemes
+
+`https://` and `http://`
+: HTTP GET check, healthy on `2xx` and `3xx`
+
+`dns://`
+: DNS resolution check
+
+`tls://`
+: TLS handshake plus certificate validation and expiry reporting
+
+`ping://`
+: TCP reachability check. This is not ICMP ping.
+
+## Exposed Port
+
+The image exposes:
+
+```text
+8080/tcp
+```
+
+If you change `ADDR`, publish the matching container port.
+
+## Endpoints
+
+`/`
+: Browser UI
+
+`/api/status`
+: JSON snapshot
+
+`/ws`
+: WebSocket stream of live `service_update` messages

+ 30 - 0
Dockerfile

@@ -0,0 +1,30 @@
+FROM golang:1.24.5-alpine AS build
+
+WORKDIR /src
+
+COPY go.mod go.sum ./
+RUN go mod download
+
+COPY . .
+
+RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/status .
+
+FROM alpine:3.22
+
+WORKDIR /app
+
+RUN adduser -D -H -u 10001 appuser
+
+COPY --from=build /out/status /app/status
+COPY config.yaml /app/config.yaml
+COPY templates /app/templates
+COPY static /app/static
+
+ENV ADDR=:8080
+ENV CONFIG_PATH=/app/config.yaml
+
+EXPOSE 8080
+
+USER appuser
+
+CMD ["/app/status"]

+ 251 - 0
README.md

@@ -0,0 +1,251 @@
+# Status Page
+
+Small Gin-based status page that monitors a list of services from `config.yaml`, exposes a JSON API, and pushes live updates over WebSockets.
+
+## Run
+
+```bash
+go run .
+```
+
+The app serves the UI at `http://127.0.0.1:8080` by default.
+
+## Environment Variables
+
+The server reads these environment variables at startup:
+
+`ADDR`
+: Full listen address for Gin, such as `127.0.0.1:8080`, `0.0.0.0:8080`, or `:8080`.
+
+`PORT`
+: Port-only fallback. If `ADDR` is not set and `PORT=9090`, the server listens on `:9090`.
+
+`CONFIG_PATH`
+: Path to the monitor config file. Defaults to `config.yaml`.
+
+`THEME`
+: UI theme to render. Supported values: `dark` or `light`. Default: `dark`.
+
+`TLS_CERT_FILE`
+: Optional path to a PEM-encoded server certificate. When set with `TLS_KEY_FILE`, the app listens over HTTPS.
+
+`TLS_KEY_FILE`
+: Optional path to the PEM-encoded private key that matches `TLS_CERT_FILE`.
+
+## Address Precedence
+
+The listen address is chosen in this order:
+
+1. `ADDR`
+2. `PORT`
+3. Default `127.0.0.1:8080`
+
+HTTPS behavior:
+
+1. If both `TLS_CERT_FILE` and `TLS_KEY_FILE` are set, Gin starts with TLS.
+2. If neither is set, the app serves plain HTTP.
+3. If only one is set, startup fails fast.
+
+Examples:
+
+```bash
+ADDR=0.0.0.0:8080 go run .
+```
+
+```bash
+PORT=9090 go run .
+```
+
+```bash
+CONFIG_PATH=/etc/status/config.yaml go run .
+```
+
+```bash
+THEME=light go run .
+```
+
+```bash
+ADDR=:8443 TLS_CERT_FILE=server.crt TLS_KEY_FILE=server.key go run .
+```
+
+## Config File
+
+The app expects a YAML file with a top-level `monitors` list.
+
+Each monitor must include:
+
+- `name`: Friendly label shown in the UI
+- `url`: Target to check, including scheme
+- `category`: Group label shown on the tile
+
+Example:
+
+```yaml
+monitors:
+  - name: GitHub
+    url: https://github.com
+    category: Developer Tools
+
+  - name: Cloudflare DNS
+    url: dns://cloudflare.com
+    category: DNS
+
+  - name: Google TLS
+    url: tls://www.google.com:443
+    category: TLS
+
+  - name: OpenAI Reachability
+    url: ping://platform.openai.com:443
+    category: Network
+```
+
+## Supported URL Schemes
+
+### `http://` and `https://`
+
+Performs an HTTP GET and marks the monitor healthy for `2xx` and `3xx` responses.
+
+Example:
+
+```yaml
+- name: Example API
+  url: https://httpbin.org/status/200
+  category: API
+```
+
+### `dns://`
+
+Resolves host records with DNS lookup.
+
+Example:
+
+```yaml
+- name: Cloudflare DNS
+  url: dns://cloudflare.com
+  category: DNS
+```
+
+Notes:
+
+- Use a hostname only.
+- The UI will show the resolved addresses in the details area.
+
+### `tls://`
+
+Opens a TLS connection and inspects the presented certificate.
+
+Example:
+
+```yaml
+- name: Google TLS
+  url: tls://www.google.com:443
+  category: TLS
+```
+
+What it reports:
+
+- Whether the certificate chain verifies as signed/trusted
+- Whether hostname validation passes
+- Whether the certificate is currently time-valid
+- The certificate expiration date
+
+Notes:
+
+- Include a port when possible. If omitted, the app uses `443`.
+- A TLS monitor is only marked healthy when trust, hostname validation, and certificate dates all pass.
+
+### `ping://`
+
+Performs a TCP reachability check by opening a TCP connection to the target.
+
+Example:
+
+```yaml
+- name: Google Ping
+  url: ping://8.8.8.8
+  category: Network
+```
+
+You can also specify a port:
+
+```yaml
+- name: OpenAI Reachability
+  url: ping://platform.openai.com:443
+  category: Network
+```
+
+Notes:
+
+- This is a TCP connect check, not ICMP echo.
+- If no port is supplied, the app uses `443`.
+
+## Validation Rules
+
+At startup, the server fails fast when:
+
+- `config.yaml` cannot be read
+- the YAML is invalid
+- `monitors` is empty
+- a monitor is missing `name`, `url`, or `category`
+
+## API Endpoints
+
+`GET /`
+: Status page UI
+
+`GET /api/status`
+: Current status snapshot as JSON
+
+`GET /ws`
+: WebSocket endpoint for live `service_update` messages
+
+## Docker
+
+Build the image locally:
+
+```bash
+docker build -t bmallenxs/status:latest .
+```
+
+Run it with the bundled config:
+
+```bash
+docker run --rm -p 8080:8080 bmallenxs/status:latest
+```
+
+Run it with your own config:
+
+```bash
+docker run --rm \
+  -p 8080:8080 \
+  -v "$(pwd)/config.yaml:/app/config.yaml:ro" \
+  bmallenxs/status:latest
+```
+
+Run it with HTTPS enabled:
+
+```bash
+docker run --rm \
+  -p 8443:8443 \
+  -e ADDR=:8443 \
+  -e TLS_CERT_FILE=/certs/server.crt \
+  -e TLS_KEY_FILE=/certs/server.key \
+  -v "$(pwd)/config.yaml:/app/config.yaml:ro" \
+  -v "$(pwd)/certs:/certs:ro" \
+  bmallenxs/status:latest
+```
+
+The container defaults to:
+
+- `ADDR=:8080`
+- `CONFIG_PATH=/app/config.yaml`
+
+## Publish Script
+
+`publish.sh` builds and pushes the image as `bmallenxs/status:latest`.
+
+Usage:
+
+```bash
+./publish.sh
+```

+ 22 - 0
config.yaml

@@ -0,0 +1,22 @@
+monitors:
+  - name: GitHub
+    url: https://github.com
+    category: Developer Tools
+  - name: Cloudflare DNS
+    url: dns://cloudflare.com
+    category: DNS
+  - name: Google TLS
+    url: tls://www.google.com:443
+    category: Google
+  - name: Google Ping
+    url: ping://8.8.8.8
+    category: Google
+  - name: Google Search
+    url: https://www.google.com
+    category: Google
+  - name: OpenAI Reachability
+    url: ping://platform.openai.com:443
+    category: Network
+  - name: Example API
+    url: https://httpbin.org/status/200
+    category: API

+ 36 - 0
go.mod

@@ -0,0 +1,36 @@
+module status
+
+go 1.24.5
+
+require github.com/gin-gonic/gin v1.10.1
+
+require github.com/gorilla/websocket v1.5.3
+
+require (
+	github.com/bytedance/sonic v1.11.6 // indirect
+	github.com/bytedance/sonic/loader v0.1.1 // indirect
+	github.com/cloudwego/base64x v0.1.4 // indirect
+	github.com/cloudwego/iasm v0.2.0 // indirect
+	github.com/gabriel-vasile/mimetype v1.4.3 // indirect
+	github.com/gin-contrib/sse v0.1.0 // indirect
+	github.com/go-playground/locales v0.14.1 // indirect
+	github.com/go-playground/universal-translator v0.18.1 // indirect
+	github.com/go-playground/validator/v10 v10.20.0 // indirect
+	github.com/goccy/go-json v0.10.2 // indirect
+	github.com/json-iterator/go v1.1.12 // indirect
+	github.com/klauspost/cpuid/v2 v2.2.7 // indirect
+	github.com/leodido/go-urn v1.4.0 // indirect
+	github.com/mattn/go-isatty v0.0.20 // indirect
+	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+	github.com/modern-go/reflect2 v1.0.2 // indirect
+	github.com/pelletier/go-toml/v2 v2.2.2 // indirect
+	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
+	github.com/ugorji/go/codec v1.2.12 // indirect
+	golang.org/x/arch v0.8.0 // indirect
+	golang.org/x/crypto v0.23.0 // indirect
+	golang.org/x/net v0.25.0 // indirect
+	golang.org/x/sys v0.20.0 // indirect
+	golang.org/x/text v0.15.0 // indirect
+	google.golang.org/protobuf v1.34.1 // indirect
+	gopkg.in/yaml.v3 v3.0.1 // indirect
+)

+ 91 - 0
go.sum

@@ -0,0 +1,91 @@
+github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
+github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
+github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
+github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
+github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
+github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
+github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
+github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
+github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
+github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
+github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
+github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
+github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
+github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
+github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
+github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
+github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
+github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
+github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
+github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
+github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
+github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
+github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
+github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
+github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
+github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
+github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
+github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
+github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
+github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
+golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
+golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
+golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
+golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
+golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
+golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
+golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
+golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
+golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
+google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
+rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

+ 186 - 0
main.go

@@ -0,0 +1,186 @@
+package main
+
+import (
+	"fmt"
+	"log"
+	"net/http"
+	"os"
+	"strings"
+	"time"
+
+	"github.com/gin-gonic/gin"
+	"gopkg.in/yaml.v3"
+)
+
+// serviceMonitor defines one configured service endpoint to check.
+type serviceMonitor struct {
+	Name     string `yaml:"name"`
+	URL      string `yaml:"url"`
+	Category string `yaml:"category"`
+}
+
+// appConfig contains the monitor definitions loaded from the YAML config file.
+type appConfig struct {
+	Monitors []serviceMonitor `yaml:"monitors"`
+}
+
+// serviceStatus represents the latest observed health information for a monitor.
+type serviceStatus struct {
+	Name         string    `json:"name"`
+	URL          string    `json:"url"`
+	Category     string    `json:"category"`
+	Protocol     string    `json:"protocol"`
+	Healthy      bool      `json:"healthy"`
+	StatusCode   int       `json:"status_code"`
+	ResponseTime string    `json:"response_time"`
+	LastChecked  time.Time `json:"last_checked"`
+	Message      string    `json:"message"`
+	Details      string    `json:"details"`
+}
+
+// main starts the status server, schedules monitor refreshes, and wires routes.
+func main() {
+	store := &statusStore{}
+	hub := newWSHub()
+	client := &http.Client{Timeout: 5 * time.Second}
+	addr := serverAddr()
+	tlsCertFile, tlsKeyFile, err := serverTLSFiles()
+	if err != nil {
+		log.Fatal(err)
+	}
+	theme := serverTheme()
+	monitors, err := loadMonitors(configPath())
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	refreshStatuses(store, client, monitors, hub)
+
+	go func() {
+		ticker := time.NewTicker(30 * time.Second)
+		defer ticker.Stop()
+
+		for range ticker.C {
+			refreshStatuses(store, client, monitors, hub)
+		}
+	}()
+
+	router := gin.Default()
+	router.LoadHTMLGlob("templates/*.html")
+	router.Static("/static", "./static")
+
+	router.GET("/", func(c *gin.Context) {
+		c.HTML(http.StatusOK, "status.html", gin.H{
+			"title": "Status Board",
+			"theme": theme,
+		})
+	})
+
+	router.GET("/api/status", func(c *gin.Context) {
+		c.JSON(http.StatusOK, gin.H{
+			"generated_at": time.Now(),
+			"services":     store.list(),
+		})
+	})
+
+	router.GET("/ws", func(c *gin.Context) {
+		serveWebSocket(c, hub)
+	})
+
+	scheme := "http"
+	if tlsCertFile != "" {
+		scheme = "https"
+	}
+
+	log.Printf("status page listening on %s://%s", scheme, addr)
+	if err := runServer(router, addr, tlsCertFile, tlsKeyFile); err != nil {
+		log.Fatal(err)
+	}
+}
+
+// configPath returns the monitor configuration file path.
+func configPath() string {
+	if path := os.Getenv("CONFIG_PATH"); path != "" {
+		return path
+	}
+
+	return "config.yaml"
+}
+
+// serverAddr returns the HTTP listen address for the status server.
+func serverAddr() string {
+	if addr := os.Getenv("ADDR"); addr != "" {
+		return addr
+	}
+
+	if port := os.Getenv("PORT"); port != "" {
+		return ":" + port
+	}
+
+	return "127.0.0.1:8080"
+}
+
+// serverTLSFiles returns the configured TLS certificate and key file paths.
+func serverTLSFiles() (string, string, error) {
+	certFile := os.Getenv("TLS_CERT_FILE")
+	keyFile := os.Getenv("TLS_KEY_FILE")
+
+	if certFile == "" && keyFile == "" {
+		return "", "", nil
+	}
+
+	if certFile == "" || keyFile == "" {
+		return "", "", fmt.Errorf("both TLS_CERT_FILE and TLS_KEY_FILE must be set to enable HTTPS")
+	}
+
+	return certFile, keyFile, nil
+}
+
+// serverTheme returns the normalized UI theme name.
+func serverTheme() string {
+	switch strings.ToLower(os.Getenv("THEME")) {
+	case "light":
+		return "light"
+	case "dark", "":
+		return "dark"
+	default:
+		return "dark"
+	}
+}
+
+// runServer starts the Gin server with or without TLS based on configuration.
+func runServer(router *gin.Engine, addr, certFile, keyFile string) error {
+	if certFile != "" {
+		return router.RunTLS(addr, certFile, keyFile)
+	}
+
+	return router.Run(addr)
+}
+
+// loadMonitors reads, validates, and returns monitor definitions from disk.
+func loadMonitors(path string) ([]serviceMonitor, error) {
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return nil, fmt.Errorf("read config %q: %w", path, err)
+	}
+
+	var cfg appConfig
+	if err := yaml.Unmarshal(data, &cfg); err != nil {
+		return nil, fmt.Errorf("parse config %q: %w", path, err)
+	}
+
+	if len(cfg.Monitors) == 0 {
+		return nil, fmt.Errorf("config %q does not define any monitors", path)
+	}
+
+	for i, monitor := range cfg.Monitors {
+		if monitor.Name == "" || monitor.URL == "" || monitor.Category == "" {
+			return nil, fmt.Errorf("config %q has an incomplete monitor at index %d", path, i)
+		}
+		if _, err := parseMonitorURL(monitor.URL); err != nil {
+			return nil, fmt.Errorf("config %q has an invalid URL for monitor %q: %w", path, monitor.Name, err)
+		}
+	}
+
+	return cfg.Monitors, nil
+}

+ 12 - 0
publish.sh

@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+IMAGE="bmallenxs/status:latest"
+TIMESTAMP_TAG="v$(date +%Y.%m.%d.%H.%M)"
+VERSIONED_IMAGE="bmallenxs/status:${TIMESTAMP_TAG}"
+
+docker build -t "${IMAGE}" .
+docker tag "${IMAGE}" "${VERSIONED_IMAGE}"
+docker push "${IMAGE}"
+docker push "${VERSIONED_IMAGE}"

+ 335 - 0
service_checks.go

@@ -0,0 +1,335 @@
+package main
+
+import (
+	"crypto/tls"
+	"crypto/x509"
+	"fmt"
+	"net"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+)
+
+// refreshStatuses checks every monitor, updates the store, and broadcasts changes.
+func refreshStatuses(store *statusStore, client *http.Client, monitors []serviceMonitor, hub *wsHub) {
+	results := make([]serviceStatus, 0, len(monitors))
+
+	for _, monitor := range monitors {
+		result := checkService(client, monitor)
+		results = append(results, result)
+		store.upsert(result)
+		store.sort()
+		hub.broadcast(wsEnvelope{
+			Type:        "service_update",
+			GeneratedAt: time.Now(),
+			Service:     &result,
+		})
+	}
+
+	sortStatuses(results)
+	store.set(results)
+}
+
+// checkService dispatches a monitor to the appropriate protocol-specific checker.
+func checkService(client *http.Client, monitor serviceMonitor) serviceStatus {
+	parsed, err := parseMonitorURL(monitor.URL)
+	if err != nil {
+		return serviceStatus{
+			Name:         monitor.Name,
+			URL:          monitor.URL,
+			Category:     monitor.Category,
+			Protocol:     "invalid",
+			Healthy:      false,
+			ResponseTime: "0s",
+			LastChecked:  time.Now(),
+			Message:      "invalid monitor URL",
+			Details:      err.Error(),
+		}
+	}
+
+	switch parsed.Scheme {
+	case "http", "https":
+		return checkHTTPService(client, monitor, parsed)
+	case "dns":
+		return checkDNSService(monitor, parsed)
+	case "ping":
+		return checkPingService(monitor, parsed)
+	case "tls":
+		return checkTLSService(monitor, parsed)
+	default:
+		return serviceStatus{
+			Name:         monitor.Name,
+			URL:          monitor.URL,
+			Category:     monitor.Category,
+			Protocol:     parsed.Scheme,
+			Healthy:      false,
+			ResponseTime: "0s",
+			LastChecked:  time.Now(),
+			Message:      "unsupported monitor scheme",
+			Details:      "use http://, https://, dns://, ping://, or tls://",
+		}
+	}
+}
+
+// checkHTTPService issues an HTTP GET request and reports the response status.
+func checkHTTPService(client *http.Client, monitor serviceMonitor, parsed *url.URL) serviceStatus {
+	start := time.Now()
+	req, err := http.NewRequest(http.MethodGet, parsed.String(), nil)
+	if err != nil {
+		return serviceStatus{
+			Name:         monitor.Name,
+			URL:          monitor.URL,
+			Category:     monitor.Category,
+			Protocol:     parsed.Scheme,
+			Healthy:      false,
+			ResponseTime: time.Since(start).Round(time.Millisecond).String(),
+			LastChecked:  time.Now(),
+			Message:      "invalid HTTP request",
+			Details:      err.Error(),
+		}
+	}
+
+	req.Header.Set("User-Agent", "status-page-monitor/1.0")
+	resp, err := client.Do(req)
+	if err != nil {
+		return serviceStatus{
+			Name:         monitor.Name,
+			URL:          monitor.URL,
+			Category:     monitor.Category,
+			Protocol:     parsed.Scheme,
+			Healthy:      false,
+			ResponseTime: time.Since(start).Round(time.Millisecond).String(),
+			LastChecked:  time.Now(),
+			Message:      "HTTP request failed",
+			Details:      err.Error(),
+		}
+	}
+	defer resp.Body.Close()
+
+	healthy := resp.StatusCode >= 200 && resp.StatusCode < 400
+	message := "service responded normally"
+	if !healthy {
+		message = "service returned an unhealthy response"
+	}
+
+	return serviceStatus{
+		Name:         monitor.Name,
+		URL:          monitor.URL,
+		Category:     monitor.Category,
+		Protocol:     parsed.Scheme,
+		Healthy:      healthy,
+		StatusCode:   resp.StatusCode,
+		ResponseTime: time.Since(start).Round(time.Millisecond).String(),
+		LastChecked:  time.Now(),
+		Message:      message,
+		Details:      "HTTP " + resp.Status,
+	}
+}
+
+// checkDNSService resolves the monitor target and reports the lookup result.
+func checkDNSService(monitor serviceMonitor, parsed *url.URL) serviceStatus {
+	start := time.Now()
+	target, err := monitorTarget(parsed)
+	if err != nil {
+		return failedStatus(monitor, parsed.Scheme, start, "invalid DNS target", err.Error())
+	}
+
+	addrs, err := net.LookupHost(target)
+	if err != nil {
+		return failedStatus(monitor, parsed.Scheme, start, "DNS lookup failed", err.Error())
+	}
+
+	return serviceStatus{
+		Name:         monitor.Name,
+		URL:          monitor.URL,
+		Category:     monitor.Category,
+		Protocol:     parsed.Scheme,
+		Healthy:      len(addrs) > 0,
+		ResponseTime: time.Since(start).Round(time.Millisecond).String(),
+		LastChecked:  time.Now(),
+		Message:      fmt.Sprintf("resolved %d DNS record(s)", len(addrs)),
+		Details:      strings.Join(addrs, ", "),
+	}
+}
+
+// checkPingService performs a TCP dial to confirm the target is reachable.
+func checkPingService(monitor serviceMonitor, parsed *url.URL) serviceStatus {
+	start := time.Now()
+	address, err := targetAddress(parsed, "443")
+	if err != nil {
+		return failedStatus(monitor, parsed.Scheme, start, "invalid ping target", err.Error())
+	}
+
+	conn, err := net.DialTimeout("tcp", address, 5*time.Second)
+	if err != nil {
+		return failedStatus(monitor, parsed.Scheme, start, "TCP ping failed", err.Error())
+	}
+	_ = conn.Close()
+
+	return serviceStatus{
+		Name:         monitor.Name,
+		URL:          monitor.URL,
+		Category:     monitor.Category,
+		Protocol:     parsed.Scheme,
+		Healthy:      true,
+		ResponseTime: time.Since(start).Round(time.Millisecond).String(),
+		LastChecked:  time.Now(),
+		Message:      "TCP ping succeeded",
+		Details:      "connected to " + address,
+	}
+}
+
+// checkTLSService inspects the remote TLS certificate and validates its basics.
+func checkTLSService(monitor serviceMonitor, parsed *url.URL) serviceStatus {
+	start := time.Now()
+	address, err := targetAddress(parsed, "443")
+	if err != nil {
+		return failedStatus(monitor, parsed.Scheme, start, "invalid TLS target", err.Error())
+	}
+
+	host := addressHost(address)
+	dialer := &net.Dialer{Timeout: 5 * time.Second}
+	conn, err := tls.DialWithDialer(dialer, "tcp", address, &tls.Config{
+		ServerName:         host,
+		InsecureSkipVerify: true,
+		MinVersion:         tls.VersionTLS12,
+	})
+	if err != nil {
+		return failedStatus(monitor, parsed.Scheme, start, "TLS handshake failed", err.Error())
+	}
+	defer conn.Close()
+
+	state := conn.ConnectionState()
+	if len(state.PeerCertificates) == 0 {
+		return failedStatus(monitor, parsed.Scheme, start, "no TLS certificate presented", "")
+	}
+
+	leaf := state.PeerCertificates[0]
+	intermediates := x509.NewCertPool()
+	for _, cert := range state.PeerCertificates[1:] {
+		intermediates.AddCert(cert)
+	}
+
+	now := time.Now()
+	hostnameValid := leaf.VerifyHostname(host) == nil
+	timeValid := !now.Before(leaf.NotBefore) && !now.After(leaf.NotAfter)
+	_, verifyErr := leaf.Verify(x509.VerifyOptions{
+		Intermediates: intermediates,
+		CurrentTime:   now,
+	})
+	signed := verifyErr == nil
+	healthy := signed && hostnameValid && timeValid
+
+	details := []string{
+		"signed: " + yesNo(signed),
+		"valid: " + yesNo(healthy),
+		"expires: " + leaf.NotAfter.Format(time.RFC1123),
+	}
+	if !hostnameValid {
+		details = append(details, "hostname mismatch")
+	}
+	if verifyErr != nil {
+		details = append(details, "verify error: "+verifyErr.Error())
+	}
+
+	message := "TLS certificate is healthy"
+	if !healthy {
+		message = "TLS certificate check failed"
+	}
+
+	return serviceStatus{
+		Name:         monitor.Name,
+		URL:          monitor.URL,
+		Category:     monitor.Category,
+		Protocol:     parsed.Scheme,
+		Healthy:      healthy,
+		ResponseTime: time.Since(start).Round(time.Millisecond).String(),
+		LastChecked:  time.Now(),
+		Message:      message,
+		Details:      strings.Join(details, " | "),
+	}
+}
+
+// failedStatus builds a failed service status using a consistent shape.
+func failedStatus(monitor serviceMonitor, protocol string, start time.Time, message, details string) serviceStatus {
+	return serviceStatus{
+		Name:         monitor.Name,
+		URL:          monitor.URL,
+		Category:     monitor.Category,
+		Protocol:     protocol,
+		Healthy:      false,
+		ResponseTime: time.Since(start).Round(time.Millisecond).String(),
+		LastChecked:  time.Now(),
+		Message:      message,
+		Details:      details,
+	}
+}
+
+// parseMonitorURL parses and minimally validates a monitor URL.
+func parseMonitorURL(raw string) (*url.URL, error) {
+	parsed, err := url.Parse(raw)
+	if err != nil {
+		return nil, err
+	}
+	if parsed.Scheme == "" {
+		return nil, fmt.Errorf("missing scheme")
+	}
+
+	switch parsed.Scheme {
+	case "http", "https", "dns", "ping", "tls":
+		return parsed, nil
+	default:
+		return parsed, nil
+	}
+}
+
+// monitorTarget extracts the host-like target from a parsed monitor URL.
+func monitorTarget(parsed *url.URL) (string, error) {
+	switch {
+	case parsed.Host != "":
+		return parsed.Hostname(), nil
+	case parsed.Opaque != "":
+		return strings.TrimPrefix(parsed.Opaque, "//"), nil
+	case parsed.Path != "":
+		return strings.TrimPrefix(parsed.Path, "/"), nil
+	default:
+		return "", fmt.Errorf("missing target host")
+	}
+}
+
+// targetAddress returns a host:port address for monitors that require dialing.
+func targetAddress(parsed *url.URL, defaultPort string) (string, error) {
+	target, err := monitorTarget(parsed)
+	if err != nil {
+		return "", err
+	}
+
+	host, port, err := net.SplitHostPort(target)
+	if err == nil {
+		return net.JoinHostPort(host, port), nil
+	}
+
+	if strings.Contains(err.Error(), "missing port in address") {
+		return net.JoinHostPort(target, defaultPort), nil
+	}
+
+	return "", err
+}
+
+// addressHost strips the port from a host:port address when present.
+func addressHost(address string) string {
+	host, _, err := net.SplitHostPort(address)
+	if err != nil {
+		return address
+	}
+	return host
+}
+
+// yesNo converts a boolean into a stable yes or no string.
+func yesNo(value bool) string {
+	if value {
+		return "yes"
+	}
+	return "no"
+}

+ 261 - 0
static/app.js

@@ -0,0 +1,261 @@
+const grid = document.getElementById("service-grid");
+const modalBackdrop = document.getElementById("service-modal-backdrop");
+const modalClose = document.getElementById("service-modal-close");
+const modalTitle = document.getElementById("service-modal-title");
+const modalCategory = document.getElementById("service-modal-category");
+const modalGrid = document.getElementById("service-modal-grid");
+
+const services = new Map();
+const tiles = new Map();
+let openServiceName = "";
+
+function formatTimestamp(value) {
+  const date = new Date(value);
+  return date.toLocaleString(undefined, {
+    month: "short",
+    day: "numeric",
+    year: "numeric",
+    hour: "numeric",
+    minute: "2-digit",
+    second: "2-digit",
+    timeZoneName: "short"
+  });
+}
+
+function formatTime(value) {
+  const date = new Date(value);
+  return date.toLocaleTimeString(undefined, {
+    hour: "numeric",
+    minute: "2-digit",
+    second: "2-digit"
+  });
+}
+
+function escapeHTML(value) {
+  return String(value).replace(/[&<>"']/g, function(char) {
+    const entities = {
+      "&": "&amp;",
+      "<": "&lt;",
+      ">": "&gt;",
+      '"': "&quot;",
+      "'": "&#39;"
+    };
+    return entities[char];
+  });
+}
+
+function sortServices(items) {
+  return items.sort(function(a, b) {
+    if (a.healthy === b.healthy) {
+      return a.name.localeCompare(b.name);
+    }
+    return a.healthy ? -1 : 1;
+  });
+}
+
+function serviceTile(service) {
+  const tile = document.createElement("article");
+  tile.className = "tile tile-enter";
+  tile.dataset.serviceName = service.name;
+  tile.innerHTML =
+    '<div class="row">' +
+      '<span class="category"></span>' +
+      '<span class="pill"><span class="dot"></span><span class="pill-label"></span></span>' +
+    '</div>' +
+    '<h2 class="name"></h2>' +
+    '<div class="stats">' +
+      '<div class="stat">' +
+        '<span class="label">HTTP Status</span>' +
+        '<span class="value status-code"></span>' +
+      '</div>' +
+      '<div class="stat">' +
+        '<span class="label">Response Time</span>' +
+        '<span class="value response-time"></span>' +
+      '</div>' +
+    '</div>';
+
+  applyServiceToTile(tile, service);
+  tile.setAttribute("role", "button");
+  tile.setAttribute("tabindex", "0");
+  tile.addEventListener("click", function() {
+    openModal(service.name);
+  });
+  tile.addEventListener("keydown", function(event) {
+    if (event.key === "Enter" || event.key === " ") {
+      event.preventDefault();
+      openModal(service.name);
+    }
+  });
+  window.setTimeout(function() {
+    tile.classList.remove("tile-enter");
+  }, 500);
+  return tile;
+}
+
+function applyServiceToTile(tile, service) {
+  const pill = tile.querySelector(".pill");
+  const pillLabel = tile.querySelector(".pill-label");
+  const statusCode = service.status_code > 0 ? service.status_code : "n/a";
+
+  tile.querySelector(".category").textContent = service.category + " · " + String(service.protocol || "").toUpperCase();
+  tile.querySelector(".name").textContent = service.name;
+  tile.querySelector(".status-code").textContent = statusCode;
+  tile.querySelector(".response-time").textContent = service.response_time;
+
+  pill.classList.toggle("ok", service.healthy);
+  pill.classList.toggle("bad", !service.healthy);
+  pillLabel.textContent = service.healthy ? "Healthy" : "Down";
+}
+
+function modalField(label, value) {
+  return '<div class="modal-stat">' +
+    '<span class="label">' + escapeHTML(label) + '</span>' +
+    '<span class="value">' + escapeHTML(value) + '</span>' +
+  '</div>';
+}
+
+function renderModal(service) {
+  const statusCode = service.status_code > 0 ? String(service.status_code) : "n/a";
+  modalCategory.textContent = service.category + " · " + String(service.protocol || "").toUpperCase();
+  modalTitle.textContent = service.name;
+  modalGrid.innerHTML =
+    modalField("Status", service.healthy ? "Healthy" : "Down") +
+    modalField("URL", service.url || "n/a") +
+    modalField("HTTP Status", statusCode) +
+    modalField("Response Time", service.response_time || "n/a") +
+    modalField("Last Checked", formatTimestamp(service.last_checked)) +
+    modalField("Message", service.message || "n/a") +
+    modalField("Details", service.details || "n/a") +
+    modalField("Category", service.category || "n/a") +
+    modalField("Protocol", String(service.protocol || "").toUpperCase() || "n/a");
+}
+
+function openModal(serviceName) {
+  const service = services.get(serviceName);
+  if (!service) {
+    return;
+  }
+
+  openServiceName = serviceName;
+  renderModal(service);
+  modalBackdrop.classList.remove("hidden");
+  document.body.classList.add("modal-open");
+}
+
+function closeModal() {
+  openServiceName = "";
+  modalBackdrop.classList.add("hidden");
+  document.body.classList.remove("modal-open");
+}
+
+function syncGridOrder(items) {
+  items.forEach(function(service) {
+    let tile = tiles.get(service.name);
+    if (!tile) {
+      tile = serviceTile(service);
+      tiles.set(service.name, tile);
+    } else {
+      applyServiceToTile(tile, service);
+    }
+
+    grid.appendChild(tile);
+  });
+}
+
+function syncTiles(items) {
+  const liveNames = new Set(items.map(function(service) {
+    return service.name;
+  }));
+
+  Array.from(tiles.keys()).forEach(function(name) {
+    if (!liveNames.has(name)) {
+      const tile = tiles.get(name);
+      if (tile) {
+        tile.remove();
+      }
+      tiles.delete(name);
+    }
+  });
+}
+
+function renderSnapshot() {
+  const items = sortServices(Array.from(services.values()));
+  if (items.length === 0) {
+    grid.replaceChildren();
+    return;
+  }
+
+  syncTiles(items);
+  syncGridOrder(items);
+}
+
+function upsertService(service) {
+  services.set(service.name, service);
+
+  const items = sortServices(Array.from(services.values()));
+  syncGridOrder(items);
+
+  if (openServiceName === service.name) {
+    renderModal(service);
+  }
+}
+
+function connectWebSocket() {
+  const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
+  const socket = new WebSocket(protocol + "//" + window.location.host + "/ws");
+
+  socket.addEventListener("open", function() {
+  });
+
+  socket.addEventListener("message", function(event) {
+    const payload = JSON.parse(event.data);
+
+    if (payload.type === "service_update" && payload.service) {
+      upsertService(payload.service);
+    }
+  });
+
+  socket.addEventListener("close", function() {
+    window.setTimeout(connectWebSocket, 2000);
+  });
+
+  socket.addEventListener("error", function() {
+    socket.close();
+  });
+}
+
+modalClose.addEventListener("click", closeModal);
+modalBackdrop.addEventListener("click", function(event) {
+  if (event.target === modalBackdrop) {
+    closeModal();
+  }
+});
+document.addEventListener("keydown", function(event) {
+  if (event.key === "Escape" && !modalBackdrop.classList.contains("hidden")) {
+    closeModal();
+  }
+});
+
+function loadInitialStatus() {
+  return fetch("/api/status", {
+    headers: {
+      "Accept": "application/json"
+    }
+  }).then(function(response) {
+    if (!response.ok) {
+      throw new Error("Request failed with status " + response.status);
+    }
+
+    return response.json();
+  }).then(function(payload) {
+    services.clear();
+    (payload.services || []).forEach(function(service) {
+      services.set(service.name, service);
+    });
+    renderSnapshot();
+  });
+}
+
+loadInitialStatus().finally(function() {
+  connectWebSocket();
+});

+ 296 - 0
static/styles.css

@@ -0,0 +1,296 @@
+:root {
+  color-scheme: dark;
+  --bg: #0d1318;
+  --bg-mid: #101921;
+  --bg-end: #18232d;
+  --panel: rgba(19, 28, 36, 0.9);
+  --panel-strong: rgba(24, 35, 45, 0.98);
+  --ink: #edf4f8;
+  --muted: #9fb0bc;
+  --line: rgba(203, 221, 233, 0.1);
+  --ok: #72e2a6;
+  --ok-soft: rgba(50, 132, 86, 0.26);
+  --bad: #ff9f93;
+  --bad-soft: rgba(170, 61, 54, 0.28);
+  --accent-a: rgba(61, 110, 170, 0.32);
+  --accent-b: rgba(26, 167, 122, 0.18);
+  --accent-c: rgba(255, 174, 102, 0.12);
+  --shadow: 0 24px 60px rgba(0, 0, 0, 0.35);
+  --button-bg: rgba(237, 244, 248, 0.08);
+  --button-hover: rgba(237, 244, 248, 0.14);
+  --backdrop: rgba(6, 10, 13, 0.72);
+}
+
+[data-theme="light"] {
+  color-scheme: light;
+  --bg: #f4efe7;
+  --bg-mid: #fbf8f3;
+  --bg-end: #eef4ea;
+  --panel: rgba(255, 252, 247, 0.92);
+  --panel-strong: rgba(255, 252, 247, 0.98);
+  --ink: #1f2933;
+  --muted: #52606d;
+  --line: rgba(31, 41, 51, 0.08);
+  --ok: #1f7a4c;
+  --ok-soft: #d7f5e5;
+  --bad: #b42318;
+  --bad-soft: #fee4e2;
+  --accent-a: rgba(213, 231, 247, 0.9);
+  --accent-b: rgba(220, 232, 200, 0.9);
+  --accent-c: rgba(248, 217, 182, 0.9);
+  --shadow: 0 24px 60px rgba(31, 41, 51, 0.12);
+  --button-bg: rgba(31, 41, 51, 0.04);
+  --button-hover: rgba(31, 41, 51, 0.08);
+  --backdrop: rgba(31, 41, 51, 0.42);
+}
+
+* {
+  box-sizing: border-box;
+}
+
+body {
+  margin: 0;
+  font-family: "Avenir Next", "Segoe UI", sans-serif;
+  color: var(--ink);
+  background:
+    radial-gradient(circle at top left, var(--accent-c), transparent 28%),
+    radial-gradient(circle at top right, var(--accent-a), transparent 24%),
+    linear-gradient(160deg, var(--bg), var(--bg-mid) 48%, var(--bg-end));
+  min-height: 100vh;
+}
+
+body.modal-open {
+  overflow: hidden;
+}
+
+.shell {
+  min-height: 100vh;
+  padding: 20px;
+  display: flex;
+  flex-direction: column;
+}
+
+.modal-close:focus-visible {
+  outline: 2px solid var(--muted);
+  outline-offset: 3px;
+}
+
+.grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+  gap: 20px;
+  flex: 1;
+  align-content: start;
+}
+
+.tile {
+  background: var(--panel);
+  border: 1px solid var(--line);
+  border-radius: 24px;
+  padding: 22px;
+  box-shadow: 0 10px 30px rgba(31, 41, 51, 0.08);
+  backdrop-filter: blur(10px);
+  transform: translateY(0);
+  opacity: 1;
+  display: flex;
+  flex-direction: column;
+  cursor: pointer;
+  transition: transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease;
+}
+
+.tile-enter {
+  transform: translateY(10px);
+  opacity: 0;
+  animation: rise 500ms ease forwards;
+}
+
+.tile:hover {
+  transform: translateY(-2px);
+  box-shadow: 0 16px 36px rgba(31, 41, 51, 0.14);
+}
+
+.tile:focus-visible {
+  outline: 2px solid var(--muted);
+  outline-offset: 4px;
+}
+
+@keyframes rise {
+  to {
+    transform: translateY(0);
+    opacity: 1;
+  }
+}
+
+.row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+}
+
+.name {
+  margin: 14px 0 8px;
+  font-size: 1.3rem;
+  letter-spacing: -0.03em;
+}
+
+.tile:hover .name,
+.tile:focus-visible .name {
+  text-decoration: underline;
+}
+
+.category,
+.modal-kicker {
+  color: var(--muted);
+}
+
+.pill {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  padding: 8px 12px;
+  border-radius: 999px;
+  font-weight: 700;
+  font-size: 0.9rem;
+}
+
+.pill.ok {
+  background: var(--ok-soft);
+  color: var(--ok);
+}
+
+.pill.bad {
+  background: var(--bad-soft);
+  color: var(--bad);
+}
+
+.dot {
+  width: 10px;
+  height: 10px;
+  border-radius: 50%;
+  background: currentColor;
+  box-shadow: 0 0 0 6px rgba(255,255,255,0.4);
+}
+
+.stats {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 12px;
+  margin-top: 18px;
+}
+
+.stat {
+  padding: 12px;
+  border-radius: 16px;
+  background: var(--button-bg);
+}
+
+.label {
+  display: block;
+  font-size: 0.8rem;
+  text-transform: uppercase;
+  letter-spacing: 0.08em;
+  color: var(--muted);
+  margin-bottom: 6px;
+}
+
+.value {
+  font-size: 1rem;
+  font-weight: 700;
+}
+
+.modal-backdrop {
+  position: fixed;
+  inset: 0;
+  background: var(--backdrop);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 20px;
+  z-index: 20;
+}
+
+.modal-backdrop.hidden {
+  display: none;
+}
+
+.modal {
+  width: min(760px, 100%);
+  max-height: min(85vh, 900px);
+  overflow: auto;
+  background: var(--panel-strong);
+  border: 1px solid var(--line);
+  border-radius: 28px;
+  box-shadow: var(--shadow);
+  padding: 24px;
+}
+
+.modal-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 16px;
+  margin-bottom: 20px;
+}
+
+.modal-title {
+  margin: 6px 0 0;
+  font-size: clamp(1.6rem, 4vw, 2.4rem);
+  letter-spacing: -0.04em;
+}
+
+.modal-close {
+  border: 1px solid var(--line);
+  background: var(--button-bg);
+  color: var(--ink);
+  border-radius: 999px;
+  padding: 10px 14px;
+  cursor: pointer;
+  font: inherit;
+}
+
+.modal-close:hover {
+  background: var(--button-hover);
+}
+
+.modal-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+  gap: 14px;
+}
+
+.modal-stat {
+  padding: 14px;
+  border-radius: 18px;
+  background: var(--button-bg);
+}
+
+.modal-stat .value {
+  display: block;
+  font-size: 0.98rem;
+  line-height: 1.5;
+  word-break: break-word;
+}
+
+@media (max-width: 640px) {
+  .shell {
+    padding: 14px 14px 24px;
+  }
+
+  .tile {
+    border-radius: 22px;
+  }
+
+  .grid {
+    grid-template-columns: 1fr;
+  }
+
+  .modal {
+    padding: 18px;
+    border-radius: 22px;
+  }
+
+  .modal-header {
+    flex-direction: column;
+  }
+}

+ 64 - 0
status_store.go

@@ -0,0 +1,64 @@
+package main
+
+import (
+	"sort"
+	"sync"
+)
+
+// statusStore provides synchronized access to the current set of service statuses.
+type statusStore struct {
+	mu      sync.RWMutex
+	records []serviceStatus
+}
+
+// set replaces the store contents with a copy of the provided status records.
+func (s *statusStore) set(records []serviceStatus) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	s.records = make([]serviceStatus, len(records))
+	copy(s.records, records)
+}
+
+// upsert inserts a new status record or replaces the existing one with the same name.
+func (s *statusStore) upsert(record serviceStatus) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	for i := range s.records {
+		if s.records[i].Name == record.Name {
+			s.records[i] = record
+			return
+		}
+	}
+
+	s.records = append(s.records, record)
+}
+
+// list returns a copy of the current status records.
+func (s *statusStore) list() []serviceStatus {
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+
+	records := make([]serviceStatus, len(s.records))
+	copy(records, s.records)
+	return records
+}
+
+// sort orders the stored status records by health and name.
+func (s *statusStore) sort() {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	sortStatuses(s.records)
+}
+
+// sortStatuses orders healthy services first and uses the service name as a tiebreaker.
+func sortStatuses(records []serviceStatus) {
+	sort.Slice(records, func(i, j int) bool {
+		if records[i].Healthy == records[j].Healthy {
+			return records[i].Name < records[j].Name
+		}
+		return records[i].Healthy && !records[j].Healthy
+	})
+}

+ 29 - 0
templates/status.html

@@ -0,0 +1,29 @@
+<!doctype html>
+<html lang="en" data-theme="{{ .theme }}">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>{{ .title }}</title>
+  <link rel="stylesheet" href="/static/styles.css">
+</head>
+<body>
+  <main class="shell">
+    <section class="grid" id="service-grid"></section>
+  </main>
+
+  <div class="modal-backdrop hidden" id="service-modal-backdrop">
+    <section class="modal" id="service-modal" aria-modal="true" role="dialog" aria-labelledby="service-modal-title">
+      <div class="modal-header">
+        <div>
+          <div class="modal-kicker" id="service-modal-category"></div>
+          <h2 class="modal-title" id="service-modal-title"></h2>
+        </div>
+        <button class="modal-close" id="service-modal-close" type="button" aria-label="Close details">Close</button>
+      </div>
+      <div class="modal-grid" id="service-modal-grid"></div>
+    </section>
+  </div>
+
+  <script src="/static/app.js"></script>
+</body>
+</html>

+ 101 - 0
ws.go

@@ -0,0 +1,101 @@
+package main
+
+import (
+	"net/http"
+	"sync"
+	"time"
+
+	"github.com/gin-gonic/gin"
+	"github.com/gorilla/websocket"
+)
+
+// wsClient wraps a websocket connection with synchronized writes.
+type wsClient struct {
+	conn *websocket.Conn
+	mu   sync.Mutex
+}
+
+// wsHub tracks active websocket clients and broadcasts updates to them.
+type wsHub struct {
+	mu      sync.Mutex
+	clients map[*wsClient]struct{}
+}
+
+// wsEnvelope is the websocket payload shape used for live service updates.
+type wsEnvelope struct {
+	Type        string         `json:"type"`
+	GeneratedAt time.Time      `json:"generated_at"`
+	Service     *serviceStatus `json:"service,omitempty"`
+}
+
+var upgrader = websocket.Upgrader{
+	CheckOrigin: func(r *http.Request) bool { return true },
+}
+
+// writeJSON writes a JSON payload to the websocket connection safely.
+func (c *wsClient) writeJSON(payload any) error {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+
+	return c.conn.WriteJSON(payload)
+}
+
+// newWSHub creates a websocket hub for tracking connected clients.
+func newWSHub() *wsHub {
+	return &wsHub{clients: make(map[*wsClient]struct{})}
+}
+
+// add registers a websocket client with the hub.
+func (h *wsHub) add(client *wsClient) {
+	h.mu.Lock()
+	defer h.mu.Unlock()
+
+	h.clients[client] = struct{}{}
+}
+
+// remove unregisters a websocket client and closes its connection.
+func (h *wsHub) remove(client *wsClient) {
+	h.mu.Lock()
+	defer h.mu.Unlock()
+
+	if _, ok := h.clients[client]; ok {
+		delete(h.clients, client)
+		_ = client.conn.Close()
+	}
+}
+
+// broadcast sends a payload to all connected websocket clients.
+func (h *wsHub) broadcast(payload any) {
+	h.mu.Lock()
+	clients := make([]*wsClient, 0, len(h.clients))
+	for client := range h.clients {
+		clients = append(clients, client)
+	}
+	h.mu.Unlock()
+
+	for _, client := range clients {
+		if err := client.writeJSON(payload); err != nil {
+			h.remove(client)
+		}
+	}
+}
+
+// serveWebSocket upgrades an HTTP request to a websocket and tracks the client.
+func serveWebSocket(c *gin.Context, hub *wsHub) {
+	conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
+	if err != nil {
+		return
+	}
+
+	client := &wsClient{conn: conn}
+	hub.add(client)
+
+	go func() {
+		defer hub.remove(client)
+		for {
+			if _, _, err := conn.ReadMessage(); err != nil {
+				return
+			}
+		}
+	}()
+}