feat: add admin console, Redis broker and chaos lab for training demo
This commit is contained in:
@@ -37,3 +37,21 @@ DATABASE_URL=postgres://postgres:postgres@db:5432/guestbook?sslmode=disable
|
||||
# DB_PASSWORD=postgres
|
||||
# DB_NAME=guestbook
|
||||
# DB_SSL_MODE=disable
|
||||
|
||||
# === Scaling / live updates ========
|
||||
# Redis for cross-replica live updates and presence. Without it each replica
|
||||
# only pushes to its own clients. On Miabi, attach a managed Redis with the
|
||||
# "REDIS" env prefix and REDIS_DATABASE_URL is injected (also accepted).
|
||||
# REDIS_URL=redis://redis:6379
|
||||
|
||||
# === Admin console =================
|
||||
# Enables /admin. Store as a secret. Generate with: openssl rand -hex 16
|
||||
# ADMIN_TOKEN=
|
||||
|
||||
# CIDRs whose X-Forwarded-For is trusted for the signer IP shown in /admin.
|
||||
# Unset trusts the header as-is (fine behind the Miabi gateway).
|
||||
# TRUSTED_PROXIES=10.0.0.0/8
|
||||
|
||||
# Expose /api/debug/* failure injection (crash, slow, 5xx, CPU, memory, logs).
|
||||
# Still requires admin auth. Demo environments only.
|
||||
# DEBUG_ENDPOINTS=false
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
# Guestbook demo runbook
|
||||
|
||||
A step-by-step script for showing Miabi with the guestbook. Each act builds on
|
||||
the previous one and takes 3–8 minutes. Skip acts freely: everything after
|
||||
act 2 only assumes the app is deployed.
|
||||
|
||||
Keep two windows open: the **wall** (`https://<domain>/`) and the **admin
|
||||
console** (`https://<domain>/admin`). A third tab on the Miabi app page is
|
||||
handy for logs, events and scaling.
|
||||
|
||||
| Act | Shows | Miabi features |
|
||||
|-----|-------|----------------|
|
||||
| 1 | Deploy from Git / image | pipeline, build, domain + TLS |
|
||||
| 2 | Managed Postgres | databases, env injection |
|
||||
| 3 | Secrets + admin console | secret env vars |
|
||||
| 4 | Scale out → broken live updates | replicas |
|
||||
| 5 | Attach Redis → fixed | managed Redis, env prefix |
|
||||
| 6 | Break it on purpose | restarts, health gating, alerts, logs, analytics |
|
||||
| 7 | Canary v1 → v2 | canary weights, promote |
|
||||
| 8 | Scheduled cleanup | cron jobs |
|
||||
|
||||
---
|
||||
|
||||
## 0. Prepare (before the session)
|
||||
|
||||
```bash
|
||||
docker build --build-arg VERSION=1.0.0 -t <registry>/guestbook:1.0.0 .
|
||||
docker build --build-arg VERSION=2.0.0 -t <registry>/guestbook:2.0.0 .
|
||||
docker push <registry>/guestbook:1.0.0 && docker push <registry>/guestbook:2.0.0
|
||||
```
|
||||
|
||||
- Pick an admin token: `openssl rand -hex 16`.
|
||||
- Create an empty Postgres and a Redis in the workspace ahead of time, since provisioning is slower than talking.
|
||||
- Open `scripts/hammer.sh` in a terminal (see act 7).
|
||||
|
||||
## 1. Deploy
|
||||
|
||||
1. **New application**, from the Git repo (the `.miabi/pipeline.yaml` builds, scans and deploys) or from image `guestbook:1.0.0`.
|
||||
2. Port `8080`. **Health check**: HTTP, path `/healthz`. (The default is *none*, and without it act 6 loses its punch.)
|
||||
3. Add a domain. TLS is issued automatically.
|
||||
4. Open the wall. It runs on **SQLite**, as the first seeded signature says.
|
||||
|
||||
> Talking point: zero config. With no database attached the app falls back
|
||||
> to a local SQLite file. Mount a volume at `/data` if you want it to survive
|
||||
> redeploys.
|
||||
|
||||
## 2. Attach Postgres
|
||||
|
||||
1. App → **Databases** → attach the Postgres, **no prefix**.
|
||||
2. Miabi injects `DATABASE_URL` (as a secret reference) plus `DB_*` and flags the app for redeploy.
|
||||
3. Redeploy. The app migrates the schema and seeds the wall on first boot. The welcome signature now says *PostgreSQL*.
|
||||
|
||||
## 3. Secrets and the admin console
|
||||
|
||||
1. App → **Environment** → add `ADMIN_TOKEN` = your token, marked **secret**. Add `DEBUG_ENDPOINTS=true` too (used in act 6).
|
||||
2. Redeploy, open `/admin` and sign in with the token.
|
||||
3. Tour it:
|
||||
- **Overview**: signatures per day, replicas, and *this replica*'s runtime (host, DB, uptime, memory).
|
||||
- **Moderation**: each signature shows the signer's IP, forwarded by the gateway. Click one to filter by it. Pin a signature. It jumps to the top of every open wall, live. Hide one and it disappears.
|
||||
- **Settings**: set a banner ("Welcome to the Miabi training 👋"), then toggle **Pause signing**. The wall goes read-only instantly in every tab.
|
||||
|
||||
> Talking point: the token never appears in the app's config in plain text.
|
||||
> Rotating it (edit + redeploy) signs every admin out, because sessions are
|
||||
> HMAC-signed with it.
|
||||
|
||||
Ask the audience to open the wall on their phones and sign it. The
|
||||
**online** counter climbs live.
|
||||
|
||||
## 4. Scale out, and watch it break
|
||||
|
||||
1. App → **Replicas** → scale to **3**.
|
||||
2. In the admin **Overview**, the *served by* chip changes as you refresh, but the **Replicas** table only ever lists one host. The hint says *in-process broker*.
|
||||
3. Open the wall in two browsers (or phones) until their clock cards show **different hosts**. Sign in one: **the other doesn't update.** Each replica only pushes to its own SSE clients.
|
||||
4. Also note the `via replica-x` tag on new signatures: each one records which replica accepted it.
|
||||
|
||||
> Talking point: this is the classic "works on one box" bug. Stateless apps
|
||||
> need shared state for fan-out. Scaling is a platform feature; being
|
||||
> scalable is an app property.
|
||||
|
||||
## 5. Attach Redis, and it's fixed
|
||||
|
||||
1. App → **Databases** → attach the Redis with prefix **`REDIS`**. Miabi injects `REDIS_DATABASE_URL`, which the app reads (so does `REDIS_URL`).
|
||||
2. Redeploy. Startup logs show `live updates ready broker=redis`.
|
||||
3. Repeat the two-browser test: signatures now appear everywhere. The **Replicas** table lists all 3 hosts with their client counts, and the wall header says *3 replicas*.
|
||||
4. **Chaos lab → Traffic probe** → *Run probe*: the bars show requests spread across the three hosts.
|
||||
|
||||
## 6. Break it on purpose
|
||||
|
||||
All buttons are in the admin **Chaos lab**. Each acts on *the replica that
|
||||
receives the request*, and the activity log shows which one.
|
||||
|
||||
| Button | What happens | Where to look in Miabi |
|
||||
|--------|--------------|------------------------|
|
||||
| 💥 Crash it | process exits 1; the restart policy restarts it; the page reports when it's back | app **Events**, **Notifications** (crash-loop if repeated) |
|
||||
| 🩺 Unhealthy | `/healthz` → 503 on that replica | `app_unhealthy` alert; a deploy made now fails the health gate |
|
||||
| 🔥 CPU burn | all cores busy for N s | app metrics |
|
||||
| 🧠 Hold memory | allocates N MiB. Set a **Memory limit** of 256 MB first, then hold 400 MiB → OOM kill → restart | `app_oom` alert |
|
||||
| 📜 Log burst | N error lines | **Logs** tab (Live) |
|
||||
| Probe `/api/debug/slow` | 400 ms latency | **Analytics → Performance** |
|
||||
| Probe `/api/debug/error` | 5xx responses | **Analytics → HTTP Traffic** |
|
||||
|
||||
The same endpoints work from a shell:
|
||||
|
||||
```bash
|
||||
T=<admin-token>; D=https://<domain>
|
||||
curl -XPOST -H "Authorization: Bearer $T" $D/api/debug/crash
|
||||
curl -XPOST -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
|
||||
-d '{"healthy":false}' $D/api/debug/health
|
||||
curl -XPOST -H "Authorization: Bearer $T" "$D/api/debug/memory?mb=400&seconds=30"
|
||||
```
|
||||
|
||||
Remember to click **Healthy** afterwards, or redeploy.
|
||||
|
||||
## 7. Canary v1 → v2
|
||||
|
||||
1. In a terminal: `scripts/hammer.sh https://<domain>`. It prints a live count and, on Ctrl-C, a tally of `status version host`.
|
||||
2. Set **deploy strategy** to *canary* (initial weight 10%), and deploy image `2.0.0`.
|
||||
3. On the wall, reload a few times: ~1 in 10 loads shows the **v2 badge** in a different colour. In the admin **Traffic probe**, the bars split by version.
|
||||
4. **Promote now.** The probe goes 100% v2, and `hammer.sh` should show `0 failed`.
|
||||
|
||||
> The v2 build is the same code with a different `VERSION`. Nothing needs to
|
||||
> differ for the demo to work.
|
||||
|
||||
## 8. Scheduled cleanup
|
||||
|
||||
The binary has a `cleanup` subcommand that permanently removes deleted
|
||||
signatures and optionally expires old ones:
|
||||
|
||||
```bash
|
||||
guestbook cleanup --purge-after 168h # default: purge deletes older than 7 days
|
||||
guestbook cleanup --max-age 720h --dry-run # preview expiring >30-day-old, unpinned entries
|
||||
```
|
||||
|
||||
1. **Jobs** → *New cronjob*, image blank (uses the app's image), command `/app/guestbook cleanup --purge-after 1h`, schedule `*/15 * * * *`.
|
||||
2. Delete a signature in **Moderation**, then **Run now**. The job log shows `cleanup finished purged=… remaining=…`.
|
||||
|
||||
`guestbook migrate` works the same way as a one-off **Job**, for teams that
|
||||
want migrations as an explicit step rather than on boot.
|
||||
|
||||
---
|
||||
|
||||
## Reset between sessions
|
||||
|
||||
- Admin → Settings: clear the banner, un-pause.
|
||||
- Chaos lab: **Healthy**.
|
||||
- To wipe the wall: detach/recreate the database, or `DELETE FROM entries;` and restart (the seed runs when the table is empty).
|
||||
@@ -1,5 +1,7 @@
|
||||
# Miabi Guestbook — example app
|
||||
|
||||
[](https://miabi.io?ref=badge)
|
||||
|
||||
A tiny, self-contained web app used as a **reference for deploying to
|
||||
[Miabi](https://github.com/miabi-io/miabi)**. It is a classic guestbook:
|
||||
visitors leave a name and a message, and the wall shows every signature.
|
||||
@@ -28,6 +30,19 @@ miniature:
|
||||
- **`AutoMigrate` on startup** with retry (tolerates the DB coming up a beat later)
|
||||
- **First-run seeder** — populates the wall when the table is empty (`SEED=false` to skip)
|
||||
- **`/healthz` readiness probe** that returns `503` until the database is reachable
|
||||
- **Scales horizontally** — attach a Redis and live updates + presence fan out
|
||||
across every replica via pub/sub; without it, the in-process broker makes the
|
||||
classic "works on one replica" bug easy to demonstrate
|
||||
- **Admin console** at `/admin`, guarded by a single `ADMIN_TOKEN` secret:
|
||||
overview (stats, replicas, runtime), moderation (pin / hide / delete, signer
|
||||
IP, CSV export), wall settings (pause signing, announcement banner) — all pushed live
|
||||
- **Chaos lab** (`DEBUG_ENDPOINTS=true`): crash, fail health checks, burn CPU,
|
||||
hold memory, burst logs, inject latency/5xx, plus an in-browser traffic probe
|
||||
that shows load-balancing and canary splits
|
||||
- **Scheduled-job friendly CLI** — `guestbook cleanup` (purge/expire) and
|
||||
`guestbook migrate` subcommands for Miabi cron jobs and one-off jobs
|
||||
|
||||
**Presenting it?** [`DEMO.md`](DEMO.md) is a step-by-step training runbook.
|
||||
- **Single static binary** — the whole UI is embedded with `go:embed`, so the
|
||||
image is one distroless layer with **no Node build step**
|
||||
|
||||
@@ -45,14 +60,44 @@ and keeps the container tiny.
|
||||
|
||||
| Method | Path | Description |
|
||||
|----------|----------------------|------------------------------------|
|
||||
| `GET` | `/healthz` | Liveness/readiness (checks the DB), reports `version` |
|
||||
| `GET` | `/api/info` | App name, serving `version`, `host`, connected-client count |
|
||||
| `GET` | `/healthz` | Liveness/readiness (checks the DB), reports `version`, `host`, `redis` |
|
||||
| `GET` | `/api/info` | App name, serving `version`, `host`, online count, `broker` mode, `replicas` |
|
||||
| `GET` | `/api/time` | Current server time + `version` + `host` (v2 — canary probe) |
|
||||
| `GET` | `/api/entries` | List entries + total; paginated via `?limit=&offset=` |
|
||||
| `POST` | `/api/entries` | Create `{ "name", "message" }` (broadcast live) |
|
||||
| `DELETE` | `/api/entries/{id}` | Delete an entry (broadcast live) |
|
||||
| `GET` | `/api/stream` | **SSE** stream: `welcome`, `created`, `deleted`, `presence`, `tick` events |
|
||||
| `GET` | `/` · `/all` | The web UI (home · all-signatures page) |
|
||||
| `GET` | `/api/settings` | Public wall settings: `signing_paused`, `banner` |
|
||||
| `GET` | `/api/entries` | Visible entries (pinned first) + total; paginated via `?limit=&offset=` |
|
||||
| `POST` | `/api/entries` | Create `{ "name", "message" }` (broadcast live; `503` while paused) |
|
||||
| `GET` | `/api/stream` | **SSE** stream: `welcome`, `created`, `updated`, `deleted`, `settings`, `presence`, `tick` events |
|
||||
| `GET` | `/` · `/all` · `/admin` | The web UI (home · all signatures · admin console) |
|
||||
|
||||
### Admin API
|
||||
|
||||
Authenticated with the session cookie from `POST /api/admin/login {"token"}`
|
||||
or an `Authorization: Bearer <ADMIN_TOKEN>` header. Disabled (`404`) when
|
||||
`ADMIN_TOKEN` is unset.
|
||||
|
||||
| Method | Path | Description |
|
||||
|----------|-------------------------------|-------------|
|
||||
| `GET` | `/api/admin/overview` | Stats (14-day histogram), replicas, runtime of the answering replica |
|
||||
| `GET` | `/api/admin/entries` | All entries; `?status=visible\|hidden\|pinned&q=&limit=&offset=` |
|
||||
| `PATCH` | `/api/admin/entries/{id}` | `{ "pinned"?: bool, "hidden"?: bool }` |
|
||||
| `DELETE` | `/api/admin/entries/{id}` | Soft-delete an entry |
|
||||
| `GET`·`PUT` | `/api/admin/settings` | `{ "signing_paused": bool, "banner": string }` |
|
||||
| `GET` | `/api/admin/export.csv` | Every entry as CSV |
|
||||
|
||||
### Debug API (chaos)
|
||||
|
||||
Requires admin auth **and** `DEBUG_ENDPOINTS=true`. Each call affects only the
|
||||
replica that receives it and echoes its `host`.
|
||||
|
||||
| Method | Path | Effect |
|
||||
|--------|------|--------|
|
||||
| `POST` | `/api/debug/crash` | Exit with status 1 |
|
||||
| `POST` | `/api/debug/health` | `{ "healthy": false }` makes `/healthz` return 503 |
|
||||
| `GET` | `/api/debug/slow?ms=2000` | Sleep before answering (max 30 s) |
|
||||
| `GET` | `/api/debug/error?status=500` | Answer with a 4xx/5xx |
|
||||
| `POST` | `/api/debug/cpu?seconds=30` | Saturate all cores (max 120 s) |
|
||||
| `POST` | `/api/debug/memory?mb=256&seconds=30` | Hold resident memory (max 2048 MiB) |
|
||||
| `POST` | `/api/debug/logs?count=20&level=error` | Write a burst of log lines |
|
||||
|
||||
### Live updates (SSE)
|
||||
|
||||
@@ -64,6 +109,11 @@ the live server time (with `version` + `host`). The UI uses these to update the
|
||||
wall in real time, show a live **“N online”** indicator, and drive the
|
||||
**server-time card** — all without polling.
|
||||
|
||||
With `REDIS_URL` set, `created`/`updated`/`deleted`/`settings` events go through
|
||||
a Redis pub/sub channel so every replica delivers them, and each replica
|
||||
advertises its client count under a short-TTL key so presence is cluster-wide.
|
||||
`tick` events stay per-replica on purpose: the clock shows who served you.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
@@ -81,6 +131,10 @@ wall in real time, show a live **“N online”** indicator, and drive the
|
||||
| `DB_PASSWORD` | `postgres` | Database password |
|
||||
| `DB_NAME` | `guestbook` | Database name |
|
||||
| `DB_SSL_MODE` | `disable` | Postgres `sslmode` |
|
||||
| `REDIS_URL` | — | Redis URL (`redis://:pass@host:6379`). Enables cross-replica live updates. Falls back to `REDIS_DATABASE_URL`, which is what Miabi injects when you attach a managed Redis with the `REDIS` prefix. |
|
||||
| `ADMIN_TOKEN` | — | Enables `/admin`. Store it as a **secret**. |
|
||||
| `TRUSTED_PROXIES` | — | Comma-separated CIDRs whose `X-Forwarded-For` is trusted when recording a signer's IP (e.g. `10.0.0.0/8`). Unset: the header is taken as-is — fine behind the Miabi gateway, spoofable if the app is exposed directly. |
|
||||
| `DEBUG_ENDPOINTS` | `false` | `true` enables the `/api/debug/*` chaos endpoints (still admin-only). |
|
||||
|
||||
> **On Miabi:** attach a managed database to the app and Miabi injects these
|
||||
> variables automatically — `DATABASE_URL` for the full DSN, plus the discrete
|
||||
@@ -102,6 +156,8 @@ SQLite file, so this just works:
|
||||
go run . # runs the default "server" command
|
||||
go run . server -p 9000 # okapicli flags: choose the port
|
||||
go run . --help # list commands and flags
|
||||
go run . migrate # apply migrations and exit
|
||||
go run . cleanup --purge-after 168h --max-age 720h --dry-run
|
||||
```
|
||||
|
||||
To point it at your own Postgres, set `DATABASE_URL` (or the `DB_*` vars) first:
|
||||
@@ -159,5 +215,7 @@ docker build --build-arg VERSION=2.0.0 -t miabi/guestbook:2.0.0 .
|
||||
3. **Attach the database** — Miabi injects the connection as `DATABASE_URL`.
|
||||
4. **Set the health check** to `GET /healthz` and the port to `8080`.
|
||||
5. **Connect a domain** — Miabi issues automatic SSL and routes traffic.
|
||||
6. **Optional:** add `ADMIN_TOKEN` as a secret, scale replicas, and attach a
|
||||
managed Redis with the `REDIS` prefix. See [`DEMO.md`](DEMO.md).
|
||||
|
||||
The app migrates its schema on first boot, so there is no separate release step.
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"errors"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jkaninda/okapi"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const maxBannerLen = 200
|
||||
|
||||
type loginRequest struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// adminEntry exposes the fields public responses omit.
|
||||
type adminEntry struct {
|
||||
Entry
|
||||
IP string `json:"ip,omitempty"`
|
||||
}
|
||||
|
||||
func toAdmin(e Entry) adminEntry { return adminEntry{Entry: e, IP: e.IP} }
|
||||
|
||||
type flagsRequest struct {
|
||||
Pinned *bool `json:"pinned"`
|
||||
Hidden *bool `json:"hidden"`
|
||||
}
|
||||
|
||||
// AdminSession reports whether the console is enabled and the caller signed in.
|
||||
func (h *Handler) AdminSession(c *okapi.Context) error {
|
||||
return c.OK(okapi.M{
|
||||
"enabled": h.auth.Enabled(),
|
||||
"authenticated": h.auth.Authenticated(c),
|
||||
})
|
||||
}
|
||||
|
||||
// AdminLogin exchanges ADMIN_TOKEN for a session cookie.
|
||||
func (h *Handler) AdminLogin(c *okapi.Context) error {
|
||||
if !h.auth.Enabled() {
|
||||
return c.JSON(http.StatusNotFound, okapi.M{"error": "admin console is disabled (set ADMIN_TOKEN)"})
|
||||
}
|
||||
var req loginRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, okapi.M{"error": "invalid JSON body"})
|
||||
}
|
||||
if !h.auth.CheckToken(strings.TrimSpace(req.Token)) {
|
||||
// Slows down guessing without needing shared rate-limit state.
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
return c.JSON(http.StatusUnauthorized, okapi.M{"error": "invalid admin token"})
|
||||
}
|
||||
h.auth.SetSession(c, h.auth.Issue(time.Now()))
|
||||
return c.OK(okapi.M{"authenticated": true})
|
||||
}
|
||||
|
||||
// AdminLogout clears the session cookie.
|
||||
func (h *Handler) AdminLogout(c *okapi.Context) error {
|
||||
h.auth.SetSession(c, "")
|
||||
return c.NoContent()
|
||||
}
|
||||
|
||||
// AdminOverview returns wall statistics plus runtime details of the replica
|
||||
// that answered, which is what makes scaling visible in the console.
|
||||
func (h *Handler) AdminOverview(c *okapi.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
stats, err := h.store.Stats(ctx, 14)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not compute stats"})
|
||||
}
|
||||
settings, _ := h.store.Settings(ctx)
|
||||
|
||||
var mem runtime.MemStats
|
||||
runtime.ReadMemStats(&mem)
|
||||
|
||||
return c.OK(okapi.M{
|
||||
"stats": stats,
|
||||
"settings": settings,
|
||||
"online": h.broker.Online(),
|
||||
"replicas": h.broker.Replicas(),
|
||||
"debug": h.debug,
|
||||
"runtime": okapi.M{
|
||||
"app": h.appName,
|
||||
"version": h.version,
|
||||
"host": h.host,
|
||||
"db": dbDisplayName(h.dbDriver),
|
||||
"broker": h.broker.Mode(),
|
||||
"redis": h.broker.RedisStatus(ctx),
|
||||
"healthy": !h.unhealthy.Load(),
|
||||
"started_at": h.started.UTC().Format(time.RFC3339),
|
||||
"uptime_s": int64(time.Since(h.started).Seconds()),
|
||||
"go": runtime.Version(),
|
||||
"cpus": runtime.NumCPU(),
|
||||
"goroutines": runtime.NumGoroutine(),
|
||||
"heap_mb": float64(mem.HeapAlloc) / (1 << 20),
|
||||
"sys_mb": float64(mem.Sys) / (1 << 20),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// AdminListEntries lists every entry, hidden ones included, with filters.
|
||||
func (h *Handler) AdminListEntries(c *okapi.Context) error {
|
||||
limit := atoiDefault(c.Query("limit"), 25)
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 25
|
||||
}
|
||||
offset := max(atoiDefault(c.Query("offset"), 0), 0)
|
||||
|
||||
entries, total, err := h.store.AdminList(c.Request().Context(), EntryFilter{
|
||||
Status: c.Query("status"),
|
||||
Query: c.Query("q"),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not load entries"})
|
||||
}
|
||||
out := make([]adminEntry, len(entries))
|
||||
for i, e := range entries {
|
||||
out[i] = toAdmin(e)
|
||||
}
|
||||
return c.OK(okapi.M{"entries": out, "total": total, "limit": limit, "offset": offset})
|
||||
}
|
||||
|
||||
// AdminUpdateEntry pins/unpins or hides/unhides an entry and broadcasts it.
|
||||
func (h *Handler) AdminUpdateEntry(c *okapi.Context) error {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, okapi.M{"error": "invalid id"})
|
||||
}
|
||||
var req flagsRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, okapi.M{"error": "invalid JSON body"})
|
||||
}
|
||||
entry, err := h.store.SetFlags(c.Request().Context(), id, req.Pinned, req.Hidden)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return c.JSON(http.StatusNotFound, okapi.M{"error": "entry not found"})
|
||||
}
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not update entry"})
|
||||
}
|
||||
h.broker.Publish(Event{Type: "updated", Entry: &entry})
|
||||
return c.OK(toAdmin(entry))
|
||||
}
|
||||
|
||||
// AdminSettings returns the shared wall settings.
|
||||
func (h *Handler) AdminSettings(c *okapi.Context) error {
|
||||
st, err := h.store.Settings(c.Request().Context())
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not load settings"})
|
||||
}
|
||||
return c.OK(st)
|
||||
}
|
||||
|
||||
// AdminSaveSettings persists the wall settings and broadcasts them so every
|
||||
// open tab on every replica reacts immediately.
|
||||
func (h *Handler) AdminSaveSettings(c *okapi.Context) error {
|
||||
var st Settings
|
||||
if err := c.Bind(&st); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, okapi.M{"error": "invalid JSON body"})
|
||||
}
|
||||
st.Banner = strings.TrimSpace(st.Banner)
|
||||
if len(st.Banner) > maxBannerLen {
|
||||
return c.JSON(http.StatusBadRequest, okapi.M{"error": "banner is too long (max 200 characters)"})
|
||||
}
|
||||
if err := h.store.SaveSettings(c.Request().Context(), st); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not save settings"})
|
||||
}
|
||||
h.broker.Publish(Event{Type: "settings", Settings: &st})
|
||||
return c.OK(st)
|
||||
}
|
||||
|
||||
// AdminExport streams every entry as CSV.
|
||||
func (h *Handler) AdminExport(c *okapi.Context) error {
|
||||
entries, err := h.store.All(c.Request().Context())
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not export entries"})
|
||||
}
|
||||
w := c.ResponseWriter()
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="guestbook-`+time.Now().UTC().Format("20060102-150405")+`.csv"`)
|
||||
cw := csv.NewWriter(w)
|
||||
_ = cw.Write([]string{"id", "name", "message", "pinned", "hidden", "replica", "ip", "created_at"})
|
||||
for _, e := range entries {
|
||||
_ = cw.Write([]string{
|
||||
strconv.FormatUint(uint64(e.ID), 10),
|
||||
csvSafe(e.Name),
|
||||
csvSafe(e.Message),
|
||||
strconv.FormatBool(e.Pinned),
|
||||
strconv.FormatBool(e.Hidden),
|
||||
e.Replica,
|
||||
e.IP,
|
||||
e.CreatedAt.UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
cw.Flush()
|
||||
return cw.Error()
|
||||
}
|
||||
|
||||
// csvSafe neutralises values a spreadsheet would evaluate as a formula.
|
||||
func csvSafe(s string) string {
|
||||
if s != "" && strings.ContainsRune("=+-@\t\r", rune(s[0])) {
|
||||
return "'" + s
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jkaninda/okapi"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookie = "gb_admin"
|
||||
sessionTTL = 12 * time.Hour
|
||||
)
|
||||
|
||||
// AdminAuth guards the admin console with a single shared ADMIN_TOKEN.
|
||||
//
|
||||
// Sessions are stateless HMAC-signed cookies keyed on the token, so any
|
||||
// replica can verify a session issued by another, and rotating ADMIN_TOKEN
|
||||
// logs everyone out.
|
||||
type AdminAuth struct {
|
||||
token string
|
||||
key []byte
|
||||
}
|
||||
|
||||
// NewAdminAuth returns an AdminAuth; an empty token disables the console.
|
||||
func NewAdminAuth(token string) *AdminAuth {
|
||||
sum := sha256.Sum256([]byte("guestbook-admin-session:" + token))
|
||||
return &AdminAuth{token: token, key: sum[:]}
|
||||
}
|
||||
|
||||
// Enabled reports whether an admin token is configured.
|
||||
func (a *AdminAuth) Enabled() bool { return a.token != "" }
|
||||
|
||||
// CheckToken compares a candidate against ADMIN_TOKEN in constant time.
|
||||
func (a *AdminAuth) CheckToken(candidate string) bool {
|
||||
return a.Enabled() && subtle.ConstantTimeCompare([]byte(candidate), []byte(a.token)) == 1
|
||||
}
|
||||
|
||||
// Issue returns a signed session value expiring after sessionTTL.
|
||||
func (a *AdminAuth) Issue(now time.Time) string {
|
||||
exp := strconv.FormatInt(now.Add(sessionTTL).Unix(), 10)
|
||||
return exp + "." + a.sign(exp)
|
||||
}
|
||||
|
||||
// Verify reports whether a session value is authentic and unexpired.
|
||||
func (a *AdminAuth) Verify(value string, now time.Time) bool {
|
||||
if !a.Enabled() {
|
||||
return false
|
||||
}
|
||||
exp, sig, ok := strings.Cut(value, ".")
|
||||
if !ok || !hmac.Equal([]byte(sig), []byte(a.sign(exp))) {
|
||||
return false
|
||||
}
|
||||
unix, err := strconv.ParseInt(exp, 10, 64)
|
||||
return err == nil && now.Unix() < unix
|
||||
}
|
||||
|
||||
func (a *AdminAuth) sign(payload string) string {
|
||||
m := hmac.New(sha256.New, a.key)
|
||||
m.Write([]byte(payload))
|
||||
return hex.EncodeToString(m.Sum(nil))
|
||||
}
|
||||
|
||||
// Authenticated accepts either a session cookie (the console) or an
|
||||
// "Authorization: Bearer <ADMIN_TOKEN>" header (curl during a demo).
|
||||
func (a *AdminAuth) Authenticated(c *okapi.Context) bool {
|
||||
if bearer, ok := strings.CutPrefix(c.Header("Authorization"), "Bearer "); ok {
|
||||
return a.CheckToken(strings.TrimSpace(bearer))
|
||||
}
|
||||
v, err := c.Cookie(sessionCookie)
|
||||
return err == nil && a.Verify(v, time.Now())
|
||||
}
|
||||
|
||||
// Require is middleware that rejects unauthenticated requests.
|
||||
func (a *AdminAuth) Require(c *okapi.Context) error {
|
||||
if !a.Enabled() {
|
||||
return c.JSON(http.StatusNotFound, okapi.M{"error": "admin console is disabled (set ADMIN_TOKEN)"})
|
||||
}
|
||||
if !a.Authenticated(c) {
|
||||
return c.JSON(http.StatusUnauthorized, okapi.M{"error": "authentication required"})
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// SetSession writes (or, with an empty value, clears) the session cookie.
|
||||
func (a *AdminAuth) SetSession(c *okapi.Context, value string) {
|
||||
maxAge := int(sessionTTL.Seconds())
|
||||
if value == "" {
|
||||
maxAge = -1
|
||||
}
|
||||
r := c.Request()
|
||||
http.SetCookie(c.ResponseWriter(), &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: value,
|
||||
Path: "/",
|
||||
MaxAge: maxAge,
|
||||
HttpOnly: true,
|
||||
Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https",
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAdminSession(t *testing.T) {
|
||||
a := NewAdminAuth("s3cret")
|
||||
now := time.Now()
|
||||
v := a.Issue(now)
|
||||
|
||||
if !a.Verify(v, now) {
|
||||
t.Fatal("fresh session should verify")
|
||||
}
|
||||
if a.Verify(v, now.Add(sessionTTL+time.Second)) {
|
||||
t.Fatal("expired session should not verify")
|
||||
}
|
||||
if NewAdminAuth("other").Verify(v, now) {
|
||||
t.Fatal("session signed with another token should not verify")
|
||||
}
|
||||
if a.Verify("9999999999."+v[len(v)-64:], now) {
|
||||
t.Fatal("tampered expiry should not verify")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminDisabled(t *testing.T) {
|
||||
a := NewAdminAuth("")
|
||||
if a.Enabled() || a.CheckToken("") || a.Verify(a.Issue(time.Now()), time.Now()) {
|
||||
t.Fatal("empty token must disable all admin access")
|
||||
}
|
||||
}
|
||||
@@ -1,53 +1,158 @@
|
||||
package main
|
||||
|
||||
import "sync"
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jkaninda/logger"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// Event is a server-to-client message pushed over SSE.
|
||||
//
|
||||
// Type selects the shape of the payload:
|
||||
// - "created" → Entry is set (a new signature)
|
||||
// - "updated" → Entry is set (pinned/hidden by a moderator)
|
||||
// - "deleted" → ID is set (a removed signature)
|
||||
// - "presence" → Online is set (connected-client count changed)
|
||||
// - "settings" → Settings is set (signing paused, banner changed)
|
||||
// - "presence" → Online/Replicas are set (connected clients changed)
|
||||
// - "tick" → Time/Version/Host are set (live server clock; new in v2)
|
||||
type Event struct {
|
||||
Type string `json:"type"`
|
||||
Entry *Entry `json:"entry,omitempty"`
|
||||
ID uint `json:"id,omitempty"`
|
||||
Online int `json:"online,omitempty"`
|
||||
Time string `json:"time,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Entry *Entry `json:"entry,omitempty"`
|
||||
ID uint `json:"id,omitempty"`
|
||||
Settings *Settings `json:"settings,omitempty"`
|
||||
Online int `json:"online,omitempty"`
|
||||
Replicas int `json:"replicas,omitempty"`
|
||||
Time string `json:"time,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
}
|
||||
|
||||
// Broker is a tiny in-process pub/sub hub. Each connected SSE client holds one
|
||||
// subscription; handlers Publish entry events, and the broker itself emits a
|
||||
// "presence" event whenever the number of connected clients changes.
|
||||
// Replica describes one running instance of the app and its SSE clients.
|
||||
type Replica struct {
|
||||
Host string `json:"host"`
|
||||
Version string `json:"version"`
|
||||
Online int `json:"online"`
|
||||
Seen time.Time `json:"seen"`
|
||||
}
|
||||
|
||||
const (
|
||||
eventsChannel = "guestbook:events"
|
||||
replicaKeyPrefix = "guestbook:replica:"
|
||||
replicaTTL = 15 * time.Second
|
||||
heartbeatEvery = 5 * time.Second
|
||||
syncEvent = "sync"
|
||||
)
|
||||
|
||||
// Broker fans events out to the SSE clients connected to this replica.
|
||||
//
|
||||
// Without Redis it is purely in-process, so a signature posted on replica A
|
||||
// never reaches viewers on replica B. With Redis, published events go through
|
||||
// a pub/sub channel that every replica listens to, and each replica advertises
|
||||
// its client count under a TTL'd key so presence is cluster-wide.
|
||||
type Broker struct {
|
||||
mu sync.RWMutex
|
||||
subs map[chan Event]struct{}
|
||||
|
||||
host, version string
|
||||
rdb *redis.Client
|
||||
dirty chan struct{}
|
||||
|
||||
cmu sync.RWMutex
|
||||
replicas []Replica
|
||||
online int
|
||||
}
|
||||
|
||||
// NewBroker creates an empty Broker.
|
||||
func NewBroker() *Broker {
|
||||
return &Broker{subs: make(map[chan Event]struct{})}
|
||||
// NewBroker creates an in-process Broker. Call UseRedis to make it cluster-wide.
|
||||
func NewBroker(host, version string) *Broker {
|
||||
return &Broker{
|
||||
subs: make(map[chan Event]struct{}),
|
||||
host: host,
|
||||
version: version,
|
||||
dirty: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe registers a new client and returns its event channel. It also
|
||||
// broadcasts the updated presence count to everyone.
|
||||
// UseRedis switches the broker to Redis pub/sub. The client reconnects on its
|
||||
// own, so an unreachable Redis at startup is not fatal.
|
||||
func (b *Broker) UseRedis(url string) error {
|
||||
opts, err := redis.ParseURL(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.rdb = redis.NewClient(opts)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Mode reports "redis" when events are shared across replicas, else "local".
|
||||
func (b *Broker) Mode() string {
|
||||
if b.rdb != nil {
|
||||
return "redis"
|
||||
}
|
||||
return "local"
|
||||
}
|
||||
|
||||
// RedisStatus reports "disabled", "up" or "down".
|
||||
func (b *Broker) RedisStatus(ctx context.Context) string {
|
||||
if b.rdb == nil {
|
||||
return "disabled"
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, time.Second)
|
||||
defer cancel()
|
||||
if err := b.rdb.Ping(ctx).Err(); err != nil {
|
||||
return "down"
|
||||
}
|
||||
return "up"
|
||||
}
|
||||
|
||||
// Run drives the Redis subscription and presence heartbeat until ctx is done.
|
||||
// It is a no-op in local mode.
|
||||
func (b *Broker) Run(ctx context.Context) {
|
||||
if b.rdb == nil {
|
||||
return
|
||||
}
|
||||
go b.listen(ctx)
|
||||
|
||||
t := time.NewTicker(heartbeatEvery)
|
||||
defer t.Stop()
|
||||
b.announce(ctx, true)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-b.dirty:
|
||||
b.announce(ctx, true)
|
||||
case <-t.C:
|
||||
b.announce(ctx, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close withdraws this replica from the presence set.
|
||||
func (b *Broker) Close(ctx context.Context) {
|
||||
if b.rdb == nil {
|
||||
return
|
||||
}
|
||||
_ = b.rdb.Del(ctx, replicaKeyPrefix+b.host).Err()
|
||||
_ = b.publishRaw(ctx, Event{Type: syncEvent})
|
||||
_ = b.rdb.Close()
|
||||
}
|
||||
|
||||
// Subscribe registers a new client and returns its event channel.
|
||||
func (b *Broker) Subscribe() chan Event {
|
||||
ch := make(chan Event, 16)
|
||||
|
||||
b.mu.Lock()
|
||||
b.subs[ch] = struct{}{}
|
||||
n := len(b.subs)
|
||||
b.mu.Unlock()
|
||||
|
||||
b.emit(Event{Type: "presence", Online: n})
|
||||
b.presenceChanged()
|
||||
return ch
|
||||
}
|
||||
|
||||
// Unsubscribe removes a client, closes its channel, and broadcasts the updated
|
||||
// presence count.
|
||||
// Unsubscribe removes a client and closes its channel.
|
||||
func (b *Broker) Unsubscribe(ch chan Event) {
|
||||
b.mu.Lock()
|
||||
if _, ok := b.subs[ch]; !ok {
|
||||
@@ -56,25 +161,158 @@ func (b *Broker) Unsubscribe(ch chan Event) {
|
||||
}
|
||||
delete(b.subs, ch)
|
||||
close(ch)
|
||||
n := len(b.subs)
|
||||
b.mu.Unlock()
|
||||
|
||||
b.emit(Event{Type: "presence", Online: n})
|
||||
b.presenceChanged()
|
||||
}
|
||||
|
||||
// Publish broadcasts an event to every connected client.
|
||||
func (b *Broker) Publish(ev Event) { b.emit(ev) }
|
||||
// Publish broadcasts an event to every client on every replica.
|
||||
func (b *Broker) Publish(ev Event) {
|
||||
if b.rdb == nil {
|
||||
b.emit(ev)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := b.publishRaw(ctx, ev); err != nil {
|
||||
logger.Warn("redis publish failed, delivering locally only", "type", ev.Type, "error", err)
|
||||
b.emit(ev)
|
||||
}
|
||||
}
|
||||
|
||||
// Online returns the current number of connected clients.
|
||||
// PublishLocal broadcasts an event to this replica's clients only.
|
||||
func (b *Broker) PublishLocal(ev Event) { b.emit(ev) }
|
||||
|
||||
// Online returns the number of connected clients across all replicas.
|
||||
func (b *Broker) Online() int {
|
||||
if b.rdb == nil {
|
||||
return b.localCount()
|
||||
}
|
||||
b.cmu.RLock()
|
||||
defer b.cmu.RUnlock()
|
||||
return b.online
|
||||
}
|
||||
|
||||
// Replicas lists the running replicas and their client counts.
|
||||
func (b *Broker) Replicas() []Replica {
|
||||
if b.rdb == nil {
|
||||
return []Replica{b.self()}
|
||||
}
|
||||
b.cmu.RLock()
|
||||
defer b.cmu.RUnlock()
|
||||
return append([]Replica(nil), b.replicas...)
|
||||
}
|
||||
|
||||
func (b *Broker) self() Replica {
|
||||
return Replica{Host: b.host, Version: b.version, Online: b.localCount(), Seen: time.Now().UTC()}
|
||||
}
|
||||
|
||||
func (b *Broker) localCount() int {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return len(b.subs)
|
||||
}
|
||||
|
||||
// emit fans an event out to all subscribers. Sends are non-blocking: a slow
|
||||
// client that has filled its buffer simply misses the event rather than
|
||||
// stalling the broker.
|
||||
func (b *Broker) presenceChanged() {
|
||||
if b.rdb == nil {
|
||||
b.emit(Event{Type: "presence", Online: b.localCount(), Replicas: 1})
|
||||
return
|
||||
}
|
||||
select {
|
||||
case b.dirty <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Broker) publishRaw(ctx context.Context, ev Event) error {
|
||||
payload, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.rdb.Publish(ctx, eventsChannel, payload).Err()
|
||||
}
|
||||
|
||||
func (b *Broker) listen(ctx context.Context) {
|
||||
ps := b.rdb.Subscribe(ctx, eventsChannel)
|
||||
defer func() { _ = ps.Close() }()
|
||||
ch := ps.Channel()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case msg, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var ev Event
|
||||
if err := json.Unmarshal([]byte(msg.Payload), &ev); err != nil {
|
||||
continue
|
||||
}
|
||||
if ev.Type == syncEvent {
|
||||
b.refresh(ctx)
|
||||
continue
|
||||
}
|
||||
b.emit(ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// announce writes this replica's heartbeat and, when notify is set, tells the
|
||||
// other replicas to recount presence.
|
||||
func (b *Broker) announce(ctx context.Context, notify bool) {
|
||||
payload, _ := json.Marshal(b.self())
|
||||
if err := b.rdb.Set(ctx, replicaKeyPrefix+b.host, payload, replicaTTL).Err(); err != nil {
|
||||
logger.Warn("redis heartbeat failed", "error", err)
|
||||
return
|
||||
}
|
||||
if notify {
|
||||
_ = b.publishRaw(ctx, Event{Type: syncEvent})
|
||||
}
|
||||
b.refresh(ctx)
|
||||
}
|
||||
|
||||
// refresh recomputes cluster presence from the replica keys and pushes a
|
||||
// presence event to local clients when it changed.
|
||||
func (b *Broker) refresh(ctx context.Context) {
|
||||
var keys []string
|
||||
iter := b.rdb.Scan(ctx, 0, replicaKeyPrefix+"*", 100).Iterator()
|
||||
for iter.Next(ctx) {
|
||||
keys = append(keys, iter.Val())
|
||||
}
|
||||
if iter.Err() != nil || len(keys) == 0 {
|
||||
return
|
||||
}
|
||||
vals, err := b.rdb.MGet(ctx, keys...).Result()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
replicas := make([]Replica, 0, len(vals))
|
||||
total := 0
|
||||
for _, v := range vals {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var r Replica
|
||||
if json.Unmarshal([]byte(s), &r) == nil {
|
||||
replicas = append(replicas, r)
|
||||
total += r.Online
|
||||
}
|
||||
}
|
||||
sort.Slice(replicas, func(i, j int) bool { return replicas[i].Host < replicas[j].Host })
|
||||
|
||||
b.cmu.Lock()
|
||||
changed := total != b.online || len(replicas) != len(b.replicas)
|
||||
b.online, b.replicas = total, replicas
|
||||
b.cmu.Unlock()
|
||||
|
||||
if changed {
|
||||
b.emit(Event{Type: "presence", Online: total, Replicas: len(replicas)})
|
||||
}
|
||||
}
|
||||
|
||||
// emit fans an event out to local subscribers. Sends are non-blocking: a slow
|
||||
// client that has filled its buffer misses the event rather than stalling
|
||||
// the broker.
|
||||
func (b *Broker) emit(ev Event) {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLocalBroker(t *testing.T) {
|
||||
b := NewBroker("host-a", "1.0.0")
|
||||
ch := b.Subscribe()
|
||||
defer b.Unsubscribe(ch)
|
||||
|
||||
if ev := <-ch; ev.Type != "presence" || ev.Online != 1 {
|
||||
t.Fatalf("want presence=1 on subscribe, got %+v", ev)
|
||||
}
|
||||
|
||||
b.Publish(Event{Type: "deleted", ID: 7})
|
||||
select {
|
||||
case ev := <-ch:
|
||||
if ev.Type != "deleted" || ev.ID != 7 {
|
||||
t.Fatalf("unexpected event %+v", ev)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("published event not delivered")
|
||||
}
|
||||
|
||||
if r := b.Replicas(); len(r) != 1 || r[0].Host != "host-a" || r[0].Online != 1 {
|
||||
t.Fatalf("unexpected replicas %+v", r)
|
||||
}
|
||||
if b.Mode() != "local" {
|
||||
t.Fatalf("mode = %s", b.Mode())
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -1,5 +1,5 @@
|
||||
# Local development: Postgres + the app. Mirrors what Miabi wires up for you
|
||||
# (a managed Postgres attached to the app via DATABASE_URL).
|
||||
# Local development: Postgres + Redis + the app. Mirrors what Miabi wires up
|
||||
# for you (managed databases attached to the app via env vars).
|
||||
services:
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
@@ -17,14 +17,22 @@ services:
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
|
||||
app:
|
||||
build: .
|
||||
environment:
|
||||
PORT: 8080
|
||||
DATABASE_URL: postgres://postgres:postgres@db:5432/guestbook?sslmode=disable
|
||||
REDIS_URL: redis://redis:6379
|
||||
ADMIN_TOKEN: ${ADMIN_TOKEN:-change-me}
|
||||
DEBUG_ENDPOINTS: "true"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
ports:
|
||||
- "8080:8080"
|
||||
|
||||
|
||||
@@ -18,6 +18,15 @@ type Config struct {
|
||||
AppName string
|
||||
Version string
|
||||
Seed bool
|
||||
|
||||
// RedisURL enables the cross-replica broker; empty keeps it in-process.
|
||||
RedisURL string
|
||||
// AdminToken enables the /admin console; empty disables it.
|
||||
AdminToken string
|
||||
// DebugEndpoints exposes the /api/debug failure-injection endpoints.
|
||||
DebugEndpoints bool
|
||||
// TrustedProxies limits whose X-Forwarded-For is believed (CIDRs).
|
||||
TrustedProxies []string
|
||||
}
|
||||
|
||||
// LoadConfig builds the Config from the environment.
|
||||
@@ -30,6 +39,11 @@ func LoadConfig() (Config, error) {
|
||||
Seed: goutils.Env("SEED", "true") == "true",
|
||||
DBDriver: driver,
|
||||
DatabaseDSN: dsn,
|
||||
|
||||
RedisURL: goutils.Env("REDIS_URL", goutils.Env("REDIS_DATABASE_URL", "")),
|
||||
AdminToken: goutils.Env("ADMIN_TOKEN", ""),
|
||||
DebugEndpoints: goutils.Env("DEBUG_ENDPOINTS", "false") == "true",
|
||||
TrustedProxies: splitList(goutils.Env("TRUSTED_PROXIES", "")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -80,3 +94,13 @@ func postgresDSN() string {
|
||||
goutils.Env("DB_SSL_MODE", "disable"),
|
||||
)
|
||||
}
|
||||
|
||||
func splitList(s string) []string {
|
||||
var out []string
|
||||
for _, p := range strings.Split(s, ",") {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/jkaninda/logger"
|
||||
"github.com/jkaninda/okapi"
|
||||
)
|
||||
|
||||
// Failure injection so restarts, health gating, analytics and alerts have
|
||||
// something to react to during a demo. See DEMO.md.
|
||||
|
||||
const (
|
||||
maxSlow = 30 * time.Second
|
||||
maxBurn = 120 * time.Second
|
||||
maxBallastMB = 2048
|
||||
maxLogBurst = 1000
|
||||
defaultBurnS = 30
|
||||
defaultMemMB = 256
|
||||
)
|
||||
|
||||
type chaosState struct {
|
||||
cpuBusy atomic.Bool
|
||||
ballastMB atomic.Int64
|
||||
}
|
||||
|
||||
// RequireDebug rejects requests unless DEBUG_ENDPOINTS=true.
|
||||
func (h *Handler) RequireDebug(c *okapi.Context) error {
|
||||
if !h.debug {
|
||||
return c.JSON(http.StatusNotFound, okapi.M{"error": "debug endpoints are disabled (set DEBUG_ENDPOINTS=true)"})
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// DebugCrash exits the process with status 1 after answering.
|
||||
func (h *Handler) DebugCrash(c *okapi.Context) error {
|
||||
logger.Error("crash requested via /api/debug/crash, exiting", "host", h.host)
|
||||
go func() {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
os.Exit(1)
|
||||
}()
|
||||
return c.JSON(http.StatusAccepted, okapi.M{"status": "crashing", "host": h.host})
|
||||
}
|
||||
|
||||
// DebugSlow sleeps for ?ms= milliseconds (default 2000) before answering.
|
||||
func (h *Handler) DebugSlow(c *okapi.Context) error {
|
||||
d := min(time.Duration(atoiDefault(c.Query("ms"), 2000))*time.Millisecond, maxSlow)
|
||||
select {
|
||||
case <-time.After(d):
|
||||
case <-c.Request().Context().Done():
|
||||
return nil
|
||||
}
|
||||
return c.OK(okapi.M{"slept_ms": d.Milliseconds(), "host": h.host})
|
||||
}
|
||||
|
||||
// DebugError answers with ?status= (default 500).
|
||||
func (h *Handler) DebugError(c *okapi.Context) error {
|
||||
status := atoiDefault(c.Query("status"), http.StatusInternalServerError)
|
||||
if status < 400 || status > 599 {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
logger.Error("injected error via /api/debug/error", "status", status, "host", h.host)
|
||||
return c.JSON(status, okapi.M{"error": "injected failure", "status": status, "host": h.host})
|
||||
}
|
||||
|
||||
// DebugHealth forces /healthz on this replica to fail (or recover).
|
||||
func (h *Handler) DebugHealth(c *okapi.Context) error {
|
||||
var req struct {
|
||||
Healthy bool `json:"healthy"`
|
||||
}
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, okapi.M{"error": "invalid JSON body"})
|
||||
}
|
||||
h.unhealthy.Store(!req.Healthy)
|
||||
logger.Warn("health overridden via /api/debug/health", "healthy", req.Healthy, "host", h.host)
|
||||
return c.OK(okapi.M{"healthy": req.Healthy, "host": h.host})
|
||||
}
|
||||
|
||||
// DebugCPU saturates every core for ?seconds= (default 30).
|
||||
func (h *Handler) DebugCPU(c *okapi.Context) error {
|
||||
d := min(time.Duration(atoiDefault(c.Query("seconds"), defaultBurnS))*time.Second, maxBurn)
|
||||
if !h.chaos.cpuBusy.CompareAndSwap(false, true) {
|
||||
return c.JSON(http.StatusConflict, okapi.M{"error": "a CPU burn is already running"})
|
||||
}
|
||||
workers := runtime.GOMAXPROCS(0)
|
||||
deadline := time.Now().Add(d)
|
||||
done := make(chan struct{}, workers)
|
||||
for range workers {
|
||||
go func() {
|
||||
x := 0
|
||||
for time.Now().Before(deadline) {
|
||||
for i := range 1_000_000 {
|
||||
x += i
|
||||
}
|
||||
}
|
||||
_ = x
|
||||
done <- struct{}{}
|
||||
}()
|
||||
}
|
||||
go func() {
|
||||
for range workers {
|
||||
<-done
|
||||
}
|
||||
h.chaos.cpuBusy.Store(false)
|
||||
logger.Info("cpu burn finished", "host", h.host)
|
||||
}()
|
||||
logger.Warn("cpu burn started", "seconds", d.Seconds(), "workers", workers, "host", h.host)
|
||||
return c.JSON(http.StatusAccepted, okapi.M{"seconds": d.Seconds(), "workers": workers, "host": h.host})
|
||||
}
|
||||
|
||||
// DebugMemory holds ?mb= MiB (default 256) of touched memory for ?seconds=
|
||||
// (default 30). Useful to show memory limits and OOM restarts.
|
||||
func (h *Handler) DebugMemory(c *okapi.Context) error {
|
||||
mb := min(atoiDefault(c.Query("mb"), defaultMemMB), maxBallastMB)
|
||||
d := min(time.Duration(atoiDefault(c.Query("seconds"), defaultBurnS))*time.Second, maxBurn)
|
||||
if mb <= 0 {
|
||||
return c.JSON(http.StatusBadRequest, okapi.M{"error": "mb must be positive"})
|
||||
}
|
||||
if !h.chaos.ballastMB.CompareAndSwap(0, int64(mb)) {
|
||||
return c.JSON(http.StatusConflict, okapi.M{"error": "memory is already being held"})
|
||||
}
|
||||
go func() {
|
||||
ballast := make([]byte, mb<<20)
|
||||
// Touch every page so the memory is actually resident, not just reserved.
|
||||
for i := 0; i < len(ballast); i += 4096 {
|
||||
ballast[i] = 1
|
||||
}
|
||||
time.Sleep(d)
|
||||
runtime.KeepAlive(ballast)
|
||||
h.chaos.ballastMB.Store(0)
|
||||
logger.Info("memory released", "mb", mb, "host", h.host)
|
||||
}()
|
||||
logger.Warn("holding memory", "mb", mb, "seconds", d.Seconds(), "host", h.host)
|
||||
return c.JSON(http.StatusAccepted, okapi.M{"mb": mb, "seconds": d.Seconds(), "host": h.host})
|
||||
}
|
||||
|
||||
// DebugLogs writes ?count= (default 20) log lines at ?level= (default error).
|
||||
func (h *Handler) DebugLogs(c *okapi.Context) error {
|
||||
count := min(max(atoiDefault(c.Query("count"), 20), 1), maxLogBurst)
|
||||
level := c.Query("level")
|
||||
for i := range count {
|
||||
n := strconv.Itoa(i + 1)
|
||||
switch level {
|
||||
case "info":
|
||||
logger.Info("demo log line", "n", n, "host", h.host)
|
||||
case "warn":
|
||||
logger.Warn("demo log line", "n", n, "host", h.host)
|
||||
default:
|
||||
level = "error"
|
||||
logger.Error("demo log line", "n", n, "host", h.host)
|
||||
}
|
||||
}
|
||||
return c.OK(okapi.M{"count": count, "level": level, "host": h.host})
|
||||
}
|
||||
@@ -7,11 +7,14 @@ require (
|
||||
github.com/jkaninda/go-utils v0.1.4
|
||||
github.com/jkaninda/logger v0.0.5
|
||||
github.com/jkaninda/okapi v1.0.0
|
||||
github.com/redis/go-redis/v9 v9.9.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/getkin/kin-openapi v0.145.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
@@ -54,6 +62,8 @@ github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hN
|
||||
github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw=
|
||||
github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.9.0 h1:URbPQ4xVQSQhZ27WMQVmZSo3uT3pL+4IdHVcYq2nVfM=
|
||||
github.com/redis/go-redis/v9 v9.9.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
|
||||
+77
-20
@@ -3,21 +3,31 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/jkaninda/okapi"
|
||||
)
|
||||
|
||||
// Handler holds the dependencies for the HTTP layer.
|
||||
type Handler struct {
|
||||
store *Store
|
||||
broker *Broker
|
||||
appName string
|
||||
version string
|
||||
host string
|
||||
store *Store
|
||||
broker *Broker
|
||||
auth *AdminAuth
|
||||
appName string
|
||||
version string
|
||||
host string
|
||||
dbDriver string
|
||||
debug bool
|
||||
started time.Time
|
||||
|
||||
unhealthy atomic.Bool
|
||||
chaos chaosState
|
||||
}
|
||||
|
||||
// serverTime is the shape of the live clock payload (v2 feature). It carries
|
||||
@@ -45,6 +55,14 @@ const (
|
||||
// Health reports liveness/readiness. Returns 503 when the database is
|
||||
// unreachable so Miabi's health checks hold traffic until the DB is up.
|
||||
func (h *Handler) Health(c *okapi.Context) error {
|
||||
if h.unhealthy.Load() {
|
||||
return c.JSON(http.StatusServiceUnavailable, okapi.M{
|
||||
"status": "unavailable",
|
||||
"reason": "forced unhealthy via /api/debug/health",
|
||||
"version": h.version,
|
||||
"host": h.host,
|
||||
})
|
||||
}
|
||||
if err := h.store.Ping(c.Request().Context()); err != nil {
|
||||
return c.JSON(http.StatusServiceUnavailable, okapi.M{
|
||||
"status": "unavailable",
|
||||
@@ -52,7 +70,14 @@ func (h *Handler) Health(c *okapi.Context) error {
|
||||
"version": h.version,
|
||||
})
|
||||
}
|
||||
return c.OK(okapi.M{"status": "ok", "db": "up", "app": h.appName, "version": h.version})
|
||||
return c.OK(okapi.M{
|
||||
"status": "ok",
|
||||
"db": "up",
|
||||
"redis": h.broker.RedisStatus(c.Request().Context()),
|
||||
"app": h.appName,
|
||||
"version": h.version,
|
||||
"host": h.host,
|
||||
})
|
||||
}
|
||||
|
||||
// Info returns app metadata for the UI: name, the serving version (useful for
|
||||
@@ -60,20 +85,32 @@ func (h *Handler) Health(c *okapi.Context) error {
|
||||
// are currently connected.
|
||||
func (h *Handler) Info(c *okapi.Context) error {
|
||||
return c.OK(okapi.M{
|
||||
"app": h.appName,
|
||||
"version": h.version,
|
||||
"online": h.broker.Online(),
|
||||
"host": h.host,
|
||||
"app": h.appName,
|
||||
"version": h.version,
|
||||
"online": h.broker.Online(),
|
||||
"host": h.host,
|
||||
"broker": h.broker.Mode(),
|
||||
"replicas": h.broker.Replicas(),
|
||||
"admin": h.auth.Enabled(),
|
||||
})
|
||||
}
|
||||
|
||||
// PublicSettings returns the wall state visitors need: paused flag and banner.
|
||||
func (h *Handler) PublicSettings(c *okapi.Context) error {
|
||||
st, err := h.store.Settings(c.Request().Context())
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not load settings"})
|
||||
}
|
||||
return c.OK(st)
|
||||
}
|
||||
|
||||
// Time returns the current server time (plus version + host). Handy for probing
|
||||
// a canary rollout from the shell: `watch curl -s .../api/time`.
|
||||
func (h *Handler) Time(c *okapi.Context) error {
|
||||
return c.OK(h.serverTime())
|
||||
}
|
||||
|
||||
// ListEntries returns guestbook entries, newest first, with simple pagination.
|
||||
// ListEntries returns visible entries, pinned first, with simple pagination.
|
||||
// Query params: limit (default 100, max 200) and offset (default 0). The home
|
||||
// page requests a small limit; the "All signatures" page pages through them.
|
||||
func (h *Handler) ListEntries(c *okapi.Context) error {
|
||||
@@ -92,7 +129,7 @@ func (h *Handler) ListEntries(c *okapi.Context) error {
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not load entries"})
|
||||
}
|
||||
count, _ := h.store.Count(ctx)
|
||||
count, _ := h.store.CountVisible(ctx)
|
||||
return c.OK(okapi.M{
|
||||
"entries": entries,
|
||||
"total": count,
|
||||
@@ -115,6 +152,12 @@ func atoiDefault(s string, def int) int {
|
||||
|
||||
// CreateEntry adds a new signature to the wall and broadcasts it live.
|
||||
func (h *Handler) CreateEntry(c *okapi.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
// Read from the DB rather than a cache so a pause applies on every replica.
|
||||
if st, err := h.store.Settings(ctx); err == nil && st.SigningPaused {
|
||||
return c.JSON(http.StatusServiceUnavailable, okapi.M{"error": "signing is paused by a moderator"})
|
||||
}
|
||||
|
||||
var req createEntryRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, okapi.M{"error": "invalid JSON body"})
|
||||
@@ -126,19 +169,19 @@ func (h *Handler) CreateEntry(c *okapi.Context) error {
|
||||
if name == "" {
|
||||
name = "Anonymous"
|
||||
}
|
||||
if len(name) > maxNameLen {
|
||||
name = name[:maxNameLen]
|
||||
if r := []rune(name); len(r) > maxNameLen {
|
||||
name = string(r[:maxNameLen])
|
||||
}
|
||||
if message == "" {
|
||||
return c.JSON(http.StatusBadRequest, okapi.M{"error": "message is required"})
|
||||
}
|
||||
if len(message) > maxMessageLen {
|
||||
if utf8.RuneCountInString(message) > maxMessageLen {
|
||||
return c.JSON(http.StatusBadRequest, okapi.M{
|
||||
"error": "message is too long (max 500 characters)",
|
||||
})
|
||||
}
|
||||
|
||||
entry, err := h.store.Create(c.Request().Context(), name, message)
|
||||
entry, err := h.store.Create(ctx, name, message, h.host, clientIP(c))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not save entry"})
|
||||
}
|
||||
@@ -147,7 +190,7 @@ func (h *Handler) CreateEntry(c *okapi.Context) error {
|
||||
return c.Created(entry)
|
||||
}
|
||||
|
||||
// DeleteEntry removes an entry by id and broadcasts the removal live.
|
||||
// DeleteEntry soft-deletes an entry by id and broadcasts the removal live.
|
||||
func (h *Handler) DeleteEntry(c *okapi.Context) error {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
@@ -180,9 +223,10 @@ func (h *Handler) Stream(c *okapi.Context) error {
|
||||
defer close(msgs)
|
||||
|
||||
welcome := okapi.Message{Event: "welcome", Data: okapi.M{
|
||||
"version": h.version,
|
||||
"online": h.broker.Online(),
|
||||
"host": h.host,
|
||||
"version": h.version,
|
||||
"online": h.broker.Online(),
|
||||
"replicas": len(h.broker.Replicas()),
|
||||
"host": h.host,
|
||||
}}
|
||||
select {
|
||||
case msgs <- welcome:
|
||||
@@ -217,3 +261,16 @@ func (h *Handler) Stream(c *okapi.Context) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// clientIP returns the caller's address as seen through the gateway. Without
|
||||
// TRUSTED_PROXIES the forwarded headers are taken at face value, which is
|
||||
// fine for display but spoofable.
|
||||
func clientIP(c *okapi.Context) string {
|
||||
if ip := c.RealIP(); net.ParseIP(ip) != nil {
|
||||
return ip
|
||||
}
|
||||
if host, _, err := net.SplitHostPort(c.Request().RemoteAddr); err == nil {
|
||||
return host
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -5,17 +5,19 @@
|
||||
// It is a classic "guestbook" — visitors leave a name and a message, and the
|
||||
// wall shows every signature. Everything a real Miabi deployment exercises is
|
||||
// here in miniature: env-based config (DATABASE_URL), an attached managed
|
||||
// database, a GORM AutoMigrate on startup, a first-run seeder, a /healthz
|
||||
// readiness probe, and a single static binary that ships the whole frontend
|
||||
// embedded — no Node build step.
|
||||
// database, an optional Redis for multi-replica live updates, a secret-guarded
|
||||
// admin console, failure-injection endpoints, a scheduled cleanup job, a
|
||||
// /healthz readiness probe, and a single static binary that ships the whole
|
||||
// frontend embedded — no Node build step.
|
||||
//
|
||||
// The HTTP server is driven by okapicli (a "server" subcommand with graceful
|
||||
// lifecycle hooks), and the embedded UI is served with app.WebFS.
|
||||
// Subcommands (okapicli): server (default), migrate, cleanup.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
@@ -33,77 +35,195 @@ func main() {
|
||||
logger.Fatal("invalid configuration", "error", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
opts := []okapi.OptionFunc{okapi.WithPort(cfg.Port)}
|
||||
if len(cfg.TrustedProxies) > 0 {
|
||||
opts = append(opts, okapi.WithTrustedProxies(cfg.TrustedProxies...))
|
||||
}
|
||||
app := okapi.New(opts...)
|
||||
cli := okapicli.New(app, "guestbook")
|
||||
|
||||
store, err := NewStore(cfg.DBDriver, cfg.DatabaseDSN)
|
||||
cli.Command("server", "Start the Guestbook HTTP server", func(cmd *okapicli.Command) error {
|
||||
return runServer(cmd, cfg)
|
||||
}).Int("port", "p", cfg.Port, "HTTP server port")
|
||||
|
||||
cli.Command("migrate", "Apply database migrations and exit (e.g. as a pre-deploy step)", func(cmd *okapicli.Command) error {
|
||||
store, err := openStore(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = store.Close() }()
|
||||
logger.Info("migrations applied", "db", dbDisplayName(cfg.DBDriver))
|
||||
return nil
|
||||
})
|
||||
|
||||
cli.Command("cleanup", "Purge deleted entries and optionally expire old ones (run as a scheduled job)", func(cmd *okapicli.Command) error {
|
||||
return runCleanup(cmd, cfg)
|
||||
}).
|
||||
Duration("purge-after", "", 7*24*time.Hour, "Permanently remove entries deleted longer ago than this").
|
||||
Duration("max-age", "", 0, "Also delete unpinned entries older than this (0 = keep forever)").
|
||||
Bool("dry-run", "", false, "Report what would be removed without changing anything")
|
||||
|
||||
cli.DefaultCommand("server")
|
||||
|
||||
if err := cli.Execute(); err != nil {
|
||||
logger.Fatal("command failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runServer(cmd *okapicli.Command, cfg Config) error {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
store, err := openStore(cfg)
|
||||
if err != nil {
|
||||
logger.Fatal("failed to open database", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := migrateWithRetry(store, 30, 2*time.Second); err != nil {
|
||||
logger.Fatal("failed to run migrations", "error", err)
|
||||
}
|
||||
logger.Info("schema ready", "app", cfg.AppName)
|
||||
|
||||
// Seed sample data on first run (empty table only).
|
||||
if cfg.Seed {
|
||||
if err := Seed(ctx, store, cfg.DBDriver); err != nil {
|
||||
logger.Fatal("failed to seed database", "error", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
host, _ := os.Hostname()
|
||||
broker := NewBroker()
|
||||
h := &Handler{store: store, broker: broker, appName: cfg.AppName, version: cfg.Version, host: host}
|
||||
broker := NewBroker(host, cfg.Version)
|
||||
if cfg.RedisURL != "" {
|
||||
if err := broker.UseRedis(cfg.RedisURL); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
go broker.Run(ctx)
|
||||
go publishTicks(ctx, broker, cfg.Version, host)
|
||||
logger.Info("live updates ready", "broker", broker.Mode())
|
||||
|
||||
// Broadcast a live server-time "tick" to all SSE clients once a second
|
||||
// (the v2 clock card). Stopped on shutdown.
|
||||
tickCtx, stopTicks := context.WithCancel(ctx)
|
||||
go publishTicks(tickCtx, broker, cfg.Version, host)
|
||||
h := &Handler{
|
||||
store: store,
|
||||
broker: broker,
|
||||
auth: NewAdminAuth(cfg.AdminToken),
|
||||
appName: cfg.AppName,
|
||||
version: cfg.Version,
|
||||
host: host,
|
||||
dbDriver: cfg.DBDriver,
|
||||
debug: cfg.DebugEndpoints,
|
||||
started: time.Now(),
|
||||
}
|
||||
if !h.auth.Enabled() {
|
||||
logger.Warn("admin console disabled: set ADMIN_TOKEN to enable /admin")
|
||||
}
|
||||
if h.debug {
|
||||
logger.Warn("debug endpoints enabled under /api/debug (admin auth required)")
|
||||
}
|
||||
|
||||
app := okapi.New(okapi.WithPort(cfg.Port))
|
||||
app := cmd.Okapi()
|
||||
app.WithPort(cmd.GetInt("port"))
|
||||
app.WithDebug()
|
||||
app.Use(okapi.LoggerMiddleware, okapi.RequestID())
|
||||
registerRoutes(app, h)
|
||||
|
||||
app.Get("/healthz", h.Health)
|
||||
api := app.Group("/api")
|
||||
api.Get("/info", h.Info)
|
||||
api.Get("/time", h.Time) // current server time (v2 clock card)
|
||||
api.Get("/entries", h.ListEntries)
|
||||
api.Post("/entries", h.CreateEntry)
|
||||
api.Delete("/entries/{id:int}", h.DeleteEntry)
|
||||
api.Get("/stream", h.Stream) // Server-Sent Events: live entries + presence
|
||||
|
||||
// Serve the embedded single-page UI. Okapi serves real files directly and
|
||||
// falls back to index.html for any other path so a client-side router can
|
||||
// take over.
|
||||
app.WebFS("/", webFS, okapi.WebConfig{Root: "web"})
|
||||
|
||||
cli := okapicli.New(app, "guestbook")
|
||||
cli.Command("server", "Start the Guestbook HTTP server", func(cmd *okapicli.Command) error {
|
||||
cmd.Okapi().WithPort(cmd.GetInt("port"))
|
||||
return cmd.CLI().RunServer(&okapicli.RunOptions{
|
||||
ShutdownTimeout: 10 * time.Second,
|
||||
OnStarted: func() {
|
||||
logger.Info("server started", "app", cfg.AppName, "version", cfg.Version, "port", cmd.GetInt("port"))
|
||||
},
|
||||
OnShutdown: func() {
|
||||
logger.Info("shutting down")
|
||||
stopTicks()
|
||||
_ = store.Close()
|
||||
},
|
||||
})
|
||||
}).Int("port", "p", cfg.Port, "HTTP server port")
|
||||
cli.DefaultCommand("server")
|
||||
|
||||
if err := cli.Execute(); err != nil {
|
||||
logger.Fatal("server error", "error", err)
|
||||
}
|
||||
return cmd.CLI().RunServer(&okapicli.RunOptions{
|
||||
ShutdownTimeout: 10 * time.Second,
|
||||
OnStarted: func() {
|
||||
logger.Info("server started", "app", cfg.AppName, "version", cfg.Version, "port", cmd.GetInt("port"))
|
||||
},
|
||||
OnShutdown: func() {
|
||||
logger.Info("shutting down")
|
||||
closeCtx, done := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer done()
|
||||
cancel()
|
||||
broker.Close(closeCtx)
|
||||
_ = store.Close()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// publishTicks broadcasts a live server-time "tick" event to all connected SSE
|
||||
// clients once a second. Each replica sends its own time/host, so under a
|
||||
// canary rollout the clock card reveals exactly which build served you.
|
||||
func registerRoutes(app *okapi.Okapi, h *Handler) {
|
||||
app.Get("/healthz", h.Health)
|
||||
|
||||
api := app.Group("/api")
|
||||
api.Get("/info", h.Info)
|
||||
api.Get("/time", h.Time)
|
||||
api.Get("/settings", h.PublicSettings)
|
||||
api.Get("/entries", h.ListEntries)
|
||||
api.Post("/entries", h.CreateEntry)
|
||||
api.Get("/stream", h.Stream)
|
||||
|
||||
api.Get("/admin/session", h.AdminSession)
|
||||
api.Post("/admin/login", h.AdminLogin)
|
||||
api.Post("/admin/logout", h.AdminLogout)
|
||||
|
||||
admin := app.Group("/api/admin", h.auth.Require)
|
||||
admin.Get("/overview", h.AdminOverview)
|
||||
admin.Get("/entries", h.AdminListEntries)
|
||||
admin.Patch("/entries/{id:int}", h.AdminUpdateEntry)
|
||||
admin.Delete("/entries/{id:int}", h.DeleteEntry)
|
||||
admin.Get("/settings", h.AdminSettings)
|
||||
admin.Put("/settings", h.AdminSaveSettings)
|
||||
admin.Get("/export.csv", h.AdminExport)
|
||||
|
||||
debug := app.Group("/api/debug", h.auth.Require, h.RequireDebug)
|
||||
debug.Post("/crash", h.DebugCrash)
|
||||
debug.Get("/slow", h.DebugSlow)
|
||||
debug.Get("/error", h.DebugError)
|
||||
debug.Post("/health", h.DebugHealth)
|
||||
debug.Post("/cpu", h.DebugCPU)
|
||||
debug.Post("/memory", h.DebugMemory)
|
||||
debug.Post("/logs", h.DebugLogs)
|
||||
|
||||
adminPage, err := fs.ReadFile(webFS, "web/admin.html")
|
||||
if err != nil {
|
||||
logger.Fatal("admin page missing from embedded assets", "error", err)
|
||||
}
|
||||
app.Get("/admin", func(c *okapi.Context) error {
|
||||
return c.Data(http.StatusOK, "text/html; charset=utf-8", adminPage)
|
||||
})
|
||||
|
||||
// Okapi serves real files directly and falls back to index.html for any
|
||||
// other path so the client-side router can take over.
|
||||
app.WebFS("/", webFS, okapi.WebConfig{Root: "web"})
|
||||
}
|
||||
|
||||
func runCleanup(cmd *okapicli.Command, cfg Config) error {
|
||||
ctx := context.Background()
|
||||
store, err := openStore(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = store.Close() }()
|
||||
|
||||
dryRun := cmd.GetBool("dry-run")
|
||||
now := time.Now()
|
||||
|
||||
purged, err := store.Purge(ctx, now.Add(-cmd.GetDuration("purge-after")), dryRun)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var expired int64
|
||||
if maxAge := cmd.GetDuration("max-age"); maxAge > 0 {
|
||||
if expired, err = store.Expire(ctx, now.Add(-maxAge), dryRun); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
remaining, _ := store.Count(ctx)
|
||||
logger.Info("cleanup finished", "purged", purged, "expired", expired, "remaining", remaining, "dry_run", dryRun)
|
||||
return nil
|
||||
}
|
||||
|
||||
// openStore connects to the database and applies migrations, retrying while
|
||||
// the database comes up.
|
||||
func openStore(cfg Config) (*Store, error) {
|
||||
store, err := NewStore(cfg.DBDriver, cfg.DatabaseDSN)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := migrateWithRetry(store, 30, 2*time.Second); err != nil {
|
||||
_ = store.Close()
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// publishTicks sends each replica's own clock to its local clients once a
|
||||
// second, so under a canary rollout the clock card reveals which build served
|
||||
// you. Ticks stay local on purpose, even with Redis.
|
||||
func publishTicks(ctx context.Context, b *Broker, version, host string) {
|
||||
t := time.NewTicker(time.Second)
|
||||
defer t.Stop()
|
||||
@@ -112,7 +232,7 @@ func publishTicks(ctx context.Context, b *Broker, version, host string) {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case now := <-t.C:
|
||||
b.Publish(Event{
|
||||
b.PublishLocal(Event{
|
||||
Type: "tick",
|
||||
Time: now.Format(time.RFC3339),
|
||||
Version: version,
|
||||
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hit an endpoint in a loop during a deploy and report any non-2xx responses,
|
||||
# plus which version/host answered. Proves zero-downtime rollouts.
|
||||
#
|
||||
# scripts/hammer.sh https://guestbook.example.com # /api/time, 10 req/s
|
||||
# scripts/hammer.sh https://guestbook.example.com 20 /healthz
|
||||
set -u
|
||||
|
||||
BASE="${1:?usage: hammer.sh <base-url> [req-per-sec] [path]}"
|
||||
RATE="${2:-10}"
|
||||
PATH_="${3:-/api/time}"
|
||||
URL="${BASE%/}${PATH_}"
|
||||
SLEEP=$(awk "BEGIN { print 1 / $RATE }")
|
||||
|
||||
ok=0 fail=0 total=0
|
||||
# A temp file instead of an associative array: macOS still ships bash 3.2.
|
||||
seen=$(mktemp)
|
||||
|
||||
summary() {
|
||||
echo
|
||||
echo "── $total requests · $ok ok · $fail failed ──"
|
||||
sort "$seen" | uniq -c | sort -rn
|
||||
rm -f "$seen"
|
||||
exit 0
|
||||
}
|
||||
trap summary INT TERM
|
||||
|
||||
echo "hammering $URL at ~${RATE} req/s — Ctrl-C for the summary"
|
||||
while true; do
|
||||
resp=$(curl -s -m 5 -w '\n%{http_code}' "$URL" 2>/dev/null)
|
||||
code="${resp##*$'\n'}"
|
||||
body="${resp%$'\n'*}"
|
||||
total=$((total + 1))
|
||||
version=$(printf '%s' "$body" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p')
|
||||
host=$(printf '%s' "$body" | sed -n 's/.*"host":"\([^"]*\)".*/\1/p')
|
||||
key="${code} v${version:-?} ${host:-?}"
|
||||
echo "$key" >> "$seen"
|
||||
if [[ "$code" =~ ^2 ]]; then
|
||||
ok=$((ok + 1))
|
||||
printf '\r%s ok=%d fail=%d last: %-40s' "$(date +%T)" "$ok" "$fail" "$key"
|
||||
else
|
||||
fail=$((fail + 1))
|
||||
printf '\n%s FAIL %s\n' "$(date +%T)" "${code:-000}"
|
||||
fi
|
||||
sleep "$SLEEP"
|
||||
done
|
||||
@@ -42,22 +42,24 @@ func seedEntries(driver string) []struct{ Name, Message string } {
|
||||
}
|
||||
}
|
||||
|
||||
// Seed populates an empty wall with sample signatures.
|
||||
func Seed(ctx context.Context, store *Store, driver string) error {
|
||||
count, err := store.Count(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
logger.Info("seed skipped, entries already present", "count", count)
|
||||
return nil
|
||||
}
|
||||
|
||||
entries := seedEntries(driver)
|
||||
for _, e := range entries {
|
||||
if _, err := store.Create(ctx, e.Name, e.Message); err != nil {
|
||||
return store.Exclusive(ctx, func(tx *Store) error {
|
||||
count, err := tx.Count(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
logger.Info("database seeded", "entries", len(entries))
|
||||
return nil
|
||||
if count > 0 {
|
||||
logger.Info("seed skipped, entries already present", "count", count)
|
||||
return nil
|
||||
}
|
||||
entries := seedEntries(driver)
|
||||
for _, e := range entries {
|
||||
if _, err := tx.Create(ctx, e.Name, e.Message, "seed", ""); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
logger.Info("database seeded", "entries", len(entries))
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -19,11 +20,54 @@ type Entry struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"type:varchar(60);not null" json:"name"`
|
||||
Message string `gorm:"type:varchar(500);not null" json:"message"`
|
||||
Pinned bool `gorm:"not null;default:false;index" json:"pinned"`
|
||||
Hidden bool `gorm:"not null;default:false;index" json:"hidden"`
|
||||
Replica string `gorm:"type:varchar(128)" json:"replica,omitempty"`
|
||||
IP string `gorm:"type:varchar(64)" json:"-"`
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
UpdatedAt time.Time `json:"-"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
// Setting is a key/value row holding app-wide state shared by all replicas.
|
||||
type Setting struct {
|
||||
Key string `gorm:"primaryKey;type:varchar(64)"`
|
||||
Value string `gorm:"type:text;not null"`
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Settings is the moderator-controlled state of the wall.
|
||||
type Settings struct {
|
||||
SigningPaused bool `json:"signing_paused"`
|
||||
Banner string `json:"banner"`
|
||||
}
|
||||
|
||||
// EntryFilter selects entries for the admin console.
|
||||
type EntryFilter struct {
|
||||
Status string // "", "visible", "hidden" or "pinned"
|
||||
Query string
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// DayCount is the number of signatures created on a given UTC day.
|
||||
type DayCount struct {
|
||||
Day string `json:"day"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// Stats summarises the wall for the admin overview.
|
||||
type Stats struct {
|
||||
Total int64 `json:"total"`
|
||||
Visible int64 `json:"visible"`
|
||||
Hidden int64 `json:"hidden"`
|
||||
Pinned int64 `json:"pinned"`
|
||||
Today int64 `json:"today"`
|
||||
Daily []DayCount `json:"daily"`
|
||||
}
|
||||
|
||||
const settingsKey = "settings"
|
||||
|
||||
type Store struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
@@ -92,11 +136,15 @@ func (s *Store) Ping(ctx context.Context) error {
|
||||
return sqlDB.PingContext(ctx)
|
||||
}
|
||||
|
||||
// Migrate applies the schema under the same lock as seeding, since
|
||||
// concurrent AutoMigrate calls race on Postgres catalog inserts.
|
||||
func (s *Store) Migrate() error {
|
||||
return s.db.AutoMigrate(&Entry{})
|
||||
return s.Exclusive(context.Background(), func(tx *Store) error {
|
||||
return tx.db.AutoMigrate(&Entry{}, &Setting{})
|
||||
})
|
||||
}
|
||||
|
||||
// List returns entries newest first, with limit/offset pagination.
|
||||
// List returns the public wall: visible entries, pinned first, then newest.
|
||||
func (s *Store) List(ctx context.Context, limit, offset int) ([]Entry, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 100
|
||||
@@ -106,6 +154,8 @@ func (s *Store) List(ctx context.Context, limit, offset int) ([]Entry, error) {
|
||||
}
|
||||
var entries []Entry
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("hidden = ?", false).
|
||||
Order("pinned DESC").
|
||||
Order("created_at DESC").
|
||||
Limit(limit).
|
||||
Offset(offset).
|
||||
@@ -114,8 +164,8 @@ func (s *Store) List(ctx context.Context, limit, offset int) ([]Entry, error) {
|
||||
}
|
||||
|
||||
// Create inserts a new entry and returns it with its generated id/timestamp.
|
||||
func (s *Store) Create(ctx context.Context, name, message string) (Entry, error) {
|
||||
e := Entry{Name: name, Message: message}
|
||||
func (s *Store) Create(ctx context.Context, name, message, replica, ip string) (Entry, error) {
|
||||
e := Entry{Name: name, Message: message, Replica: replica, IP: ip}
|
||||
err := s.db.WithContext(ctx).Create(&e).Error
|
||||
return e, err
|
||||
}
|
||||
@@ -126,9 +176,169 @@ func (s *Store) Delete(ctx context.Context, id uint64) (bool, error) {
|
||||
return res.RowsAffected > 0, res.Error
|
||||
}
|
||||
|
||||
// Count returns the total number of (non-deleted) entries.
|
||||
// schemaLockID is an arbitrary Postgres advisory-lock key.
|
||||
const schemaLockID = 7_406_381
|
||||
|
||||
// Exclusive runs fn in a transaction holding a Postgres advisory lock, so
|
||||
// replicas booting together migrate and seed one at a time.
|
||||
func (s *Store) Exclusive(ctx context.Context, fn func(tx *Store) error) error {
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if tx.Name() == "postgres" {
|
||||
if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", schemaLockID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return fn(&Store{db: tx})
|
||||
})
|
||||
}
|
||||
|
||||
// Count returns the total number of (non-deleted) entries, hidden included.
|
||||
func (s *Store) Count(ctx context.Context) (int64, error) {
|
||||
var n int64
|
||||
err := s.db.WithContext(ctx).Model(&Entry{}).Count(&n).Error
|
||||
return n, err
|
||||
}
|
||||
|
||||
// CountVisible returns the number of entries shown on the public wall.
|
||||
func (s *Store) CountVisible(ctx context.Context) (int64, error) {
|
||||
var n int64
|
||||
err := s.db.WithContext(ctx).Model(&Entry{}).Where("hidden = ?", false).Count(&n).Error
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Get returns a single entry by id.
|
||||
func (s *Store) Get(ctx context.Context, id uint64) (Entry, error) {
|
||||
var e Entry
|
||||
err := s.db.WithContext(ctx).First(&e, id).Error
|
||||
return e, err
|
||||
}
|
||||
|
||||
// AdminList returns entries matching f, newest first, and the match count.
|
||||
func (s *Store) AdminList(ctx context.Context, f EntryFilter) ([]Entry, int64, error) {
|
||||
q := s.db.WithContext(ctx).Model(&Entry{})
|
||||
switch f.Status {
|
||||
case "visible":
|
||||
q = q.Where("hidden = ?", false)
|
||||
case "hidden":
|
||||
q = q.Where("hidden = ?", true)
|
||||
case "pinned":
|
||||
q = q.Where("pinned = ?", true)
|
||||
}
|
||||
if term := strings.TrimSpace(f.Query); term != "" {
|
||||
like := "%" + strings.ToLower(term) + "%"
|
||||
q = q.Where("LOWER(name) LIKE ? OR LOWER(message) LIKE ? OR ip LIKE ?", like, like, like)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var entries []Entry
|
||||
err := q.Order("created_at DESC").Limit(f.Limit).Offset(f.Offset).Find(&entries).Error
|
||||
return entries, total, err
|
||||
}
|
||||
|
||||
// SetFlags updates the moderation flags that are non-nil and returns the entry.
|
||||
func (s *Store) SetFlags(ctx context.Context, id uint64, pinned, hidden *bool) (Entry, error) {
|
||||
updates := map[string]any{}
|
||||
if pinned != nil {
|
||||
updates["pinned"] = *pinned
|
||||
}
|
||||
if hidden != nil {
|
||||
updates["hidden"] = *hidden
|
||||
}
|
||||
e, err := s.Get(ctx, id)
|
||||
if err != nil || len(updates) == 0 {
|
||||
return e, err
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Model(&e).Updates(updates).Error; err != nil {
|
||||
return e, err
|
||||
}
|
||||
return s.Get(ctx, id)
|
||||
}
|
||||
|
||||
// All returns every entry, oldest first, for export.
|
||||
func (s *Store) All(ctx context.Context) ([]Entry, error) {
|
||||
var entries []Entry
|
||||
err := s.db.WithContext(ctx).Order("created_at ASC").Find(&entries).Error
|
||||
return entries, err
|
||||
}
|
||||
|
||||
// Stats computes the admin overview, including per-day counts for the last
|
||||
// `days` days. Bucketing happens in Go so it works the same on both drivers.
|
||||
func (s *Store) Stats(ctx context.Context, days int) (Stats, error) {
|
||||
var st Stats
|
||||
db := s.db.WithContext(ctx).Model(&Entry{})
|
||||
if err := db.Count(&st.Total).Error; err != nil {
|
||||
return st, err
|
||||
}
|
||||
s.db.WithContext(ctx).Model(&Entry{}).Where("hidden = ?", true).Count(&st.Hidden)
|
||||
s.db.WithContext(ctx).Model(&Entry{}).Where("pinned = ?", true).Count(&st.Pinned)
|
||||
st.Visible = st.Total - st.Hidden
|
||||
|
||||
now := time.Now().UTC()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
since := today.AddDate(0, 0, -(days - 1))
|
||||
|
||||
var stamps []time.Time
|
||||
if err := s.db.WithContext(ctx).Model(&Entry{}).
|
||||
Where("created_at >= ?", since).
|
||||
Pluck("created_at", &stamps).Error; err != nil {
|
||||
return st, err
|
||||
}
|
||||
buckets := make(map[string]int, days)
|
||||
for _, t := range stamps {
|
||||
buckets[t.UTC().Format("2006-01-02")]++
|
||||
}
|
||||
for d := since; !d.After(today); d = d.AddDate(0, 0, 1) {
|
||||
key := d.Format("2006-01-02")
|
||||
st.Daily = append(st.Daily, DayCount{Day: key, Count: buckets[key]})
|
||||
}
|
||||
st.Today = int64(buckets[today.Format("2006-01-02")])
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// Settings loads the shared wall settings, returning defaults when unset.
|
||||
func (s *Store) Settings(ctx context.Context) (Settings, error) {
|
||||
var row Setting
|
||||
var st Settings
|
||||
err := s.db.WithContext(ctx).Where(&Setting{Key: settingsKey}).Limit(1).Find(&row).Error
|
||||
if err != nil || row.Value == "" {
|
||||
return st, err
|
||||
}
|
||||
err = json.Unmarshal([]byte(row.Value), &st)
|
||||
return st, err
|
||||
}
|
||||
|
||||
// SaveSettings persists the shared wall settings.
|
||||
func (s *Store) SaveSettings(ctx context.Context, st Settings) error {
|
||||
payload, err := json.Marshal(st)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.db.WithContext(ctx).Save(&Setting{Key: settingsKey, Value: string(payload)}).Error
|
||||
}
|
||||
|
||||
// Purge permanently removes entries soft-deleted before cutoff.
|
||||
func (s *Store) Purge(ctx context.Context, cutoff time.Time, dryRun bool) (int64, error) {
|
||||
q := s.db.WithContext(ctx).Unscoped().Where("deleted_at IS NOT NULL AND deleted_at < ?", cutoff)
|
||||
if dryRun {
|
||||
var n int64
|
||||
err := q.Model(&Entry{}).Count(&n).Error
|
||||
return n, err
|
||||
}
|
||||
res := q.Delete(&Entry{})
|
||||
return res.RowsAffected, res.Error
|
||||
}
|
||||
|
||||
// Expire soft-deletes unpinned entries created before cutoff.
|
||||
func (s *Store) Expire(ctx context.Context, cutoff time.Time, dryRun bool) (int64, error) {
|
||||
q := s.db.WithContext(ctx).Where("pinned = ? AND created_at < ?", false, cutoff)
|
||||
if dryRun {
|
||||
var n int64
|
||||
err := q.Model(&Entry{}).Count(&n).Error
|
||||
return n, err
|
||||
}
|
||||
res := q.Delete(&Entry{})
|
||||
return res.RowsAffected, res.Error
|
||||
}
|
||||
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s, err := NewStore("sqlite", filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Migrate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
func TestModeration(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
a, _ := s.Create(ctx, "a", "first", "r1", "")
|
||||
b, _ := s.Create(ctx, "b", "second", "r2", "")
|
||||
c, _ := s.Create(ctx, "c", "third", "r1", "")
|
||||
|
||||
yes := true
|
||||
if _, err := s.SetFlags(ctx, uint64(a.ID), &yes, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.SetFlags(ctx, uint64(b.ID), nil, &yes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wall, err := s.List(ctx, 10, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(wall) != 2 || wall[0].ID != a.ID || wall[1].ID != c.ID {
|
||||
t.Fatalf("want pinned a then c (b hidden), got %+v", wall)
|
||||
}
|
||||
if n, _ := s.CountVisible(ctx); n != 2 {
|
||||
t.Fatalf("visible = %d, want 2", n)
|
||||
}
|
||||
|
||||
hidden, total, err := s.AdminList(ctx, EntryFilter{Status: "hidden", Limit: 10})
|
||||
if err != nil || total != 1 || hidden[0].ID != b.ID {
|
||||
t.Fatalf("hidden filter: total=%d err=%v", total, err)
|
||||
}
|
||||
found, total, _ := s.AdminList(ctx, EntryFilter{Query: "THIRD", Limit: 10})
|
||||
if total != 1 || found[0].ID != c.ID {
|
||||
t.Fatalf("search should be case-insensitive, got total=%d", total)
|
||||
}
|
||||
|
||||
st, err := s.Stats(ctx, 7)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Total != 3 || st.Hidden != 1 || st.Pinned != 1 || st.Today != 3 || len(st.Daily) != 7 {
|
||||
t.Fatalf("unexpected stats %+v", st)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
if st, err := s.Settings(ctx); err != nil || st.SigningPaused || st.Banner != "" {
|
||||
t.Fatalf("defaults: %+v %v", st, err)
|
||||
}
|
||||
want := Settings{SigningPaused: true, Banner: "hello"}
|
||||
if err := s.SaveSettings(ctx, want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.SaveSettings(ctx, want); err != nil {
|
||||
t.Fatal("saving twice should upsert:", err)
|
||||
}
|
||||
if got, _ := s.Settings(ctx); got != want {
|
||||
t.Fatalf("got %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanup(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
old, _ := s.Create(ctx, "old", "old", "r", "")
|
||||
pinned, _ := s.Create(ctx, "pin", "pinned", "r", "")
|
||||
gone, _ := s.Create(ctx, "gone", "deleted", "r", "")
|
||||
past := time.Now().Add(-48 * time.Hour)
|
||||
s.db.Model(&Entry{}).Where("id IN ?", []uint{old.ID, pinned.ID}).Update("created_at", past)
|
||||
yes := true
|
||||
_, _ = s.SetFlags(ctx, uint64(pinned.ID), &yes, nil)
|
||||
_, _ = s.Delete(ctx, uint64(gone.ID))
|
||||
|
||||
if n, _ := s.Expire(ctx, time.Now().Add(-24*time.Hour), true); n != 1 {
|
||||
t.Fatalf("dry-run expire = %d, want 1 (pinned is kept)", n)
|
||||
}
|
||||
if n, _ := s.Expire(ctx, time.Now().Add(-24*time.Hour), false); n != 1 {
|
||||
t.Fatalf("expire = %d, want 1", n)
|
||||
}
|
||||
if n, _ := s.Purge(ctx, time.Now().Add(time.Minute), false); n != 2 {
|
||||
t.Fatalf("purge = %d, want 2 (expired + deleted)", n)
|
||||
}
|
||||
if n, _ := s.Count(ctx); n != 1 {
|
||||
t.Fatalf("remaining = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPIsAdminOnly(t *testing.T) {
|
||||
e := Entry{ID: 1, Name: "a", Message: "m", IP: "203.0.113.7"}
|
||||
pub, _ := json.Marshal(e)
|
||||
if strings.Contains(string(pub), "203.0.113.7") {
|
||||
t.Fatalf("public JSON leaks IP: %s", pub)
|
||||
}
|
||||
adm, _ := json.Marshal(toAdmin(e))
|
||||
if !strings.Contains(string(adm), `"ip":"203.0.113.7"`) || !strings.Contains(string(adm), `"name":"a"`) {
|
||||
t.Fatalf("admin JSON missing fields: %s", adm)
|
||||
}
|
||||
}
|
||||
+707
@@ -0,0 +1,707 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Guestbook Admin</title>
|
||||
<meta name="robots" content="noindex" />
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0b0a12;
|
||||
--bg-2: #12101d;
|
||||
--card: rgba(255, 255, 255, 0.04);
|
||||
--card-border: rgba(255, 255, 255, 0.08);
|
||||
--text: #ece9f5;
|
||||
--muted: #9b95b3;
|
||||
--accent: #8b5cf6;
|
||||
--accent-2: #a855f7;
|
||||
--danger: #f43f5e;
|
||||
--warn: #f59e0b;
|
||||
--ok: #22c55e;
|
||||
--radius: 16px;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body {
|
||||
min-height: 100vh;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(900px 500px at 15% -10%, rgba(139, 92, 246, 0.16), transparent 60%),
|
||||
radial-gradient(700px 500px at 110% 10%, rgba(168, 85, 247, 0.12), transparent 55%),
|
||||
linear-gradient(180deg, var(--bg), var(--bg-2));
|
||||
background-attachment: fixed;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.wrap { max-width: 1080px; margin: 0 auto; padding: 28px 20px 80px; }
|
||||
[hidden] { display: none !important; }
|
||||
a { color: var(--accent-2); }
|
||||
|
||||
.topbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 22px; }
|
||||
.brand { display: flex; align-items: center; gap: 10px; font-weight: 750; font-size: 18px; letter-spacing: -0.01em; }
|
||||
.brand .dot { width: 10px; height: 10px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 12px 2px var(--accent); }
|
||||
.brand small { font-weight: 500; color: var(--muted); font-size: 13px; }
|
||||
.top-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--muted);
|
||||
padding: 5px 12px; border-radius: 999px; background: var(--card); border: 1px solid var(--card-border); white-space: nowrap;
|
||||
}
|
||||
.chip b { color: var(--text); font-weight: 600; font-family: var(--mono); font-size: 12px; }
|
||||
.ver { font-size: 12.5px; font-weight: 700; color: #fff; padding: 5px 12px; border-radius: 999px; background: var(--accent); }
|
||||
|
||||
.card { background: var(--card); border: 1px solid var(--card-border); border-radius: var(--radius); backdrop-filter: blur(12px); }
|
||||
.pad { padding: 20px; }
|
||||
h2 { font-size: 13px; font-weight: 650; color: var(--muted); margin: 0 0 14px; letter-spacing: 0.06em; text-transform: uppercase; }
|
||||
h3 { font-size: 15px; margin: 0 0 6px; }
|
||||
p.hint { color: var(--muted); font-size: 13.5px; line-height: 1.55; margin: 0 0 14px; }
|
||||
code { font-family: var(--mono); font-size: 12.5px; background: rgba(0,0,0,.3); padding: 1px 6px; border-radius: 6px; }
|
||||
|
||||
button, .btn {
|
||||
font: inherit; font-size: 13.5px; font-weight: 600; color: var(--text); cursor: pointer; text-decoration: none;
|
||||
background: rgba(255,255,255,.06); border: 1px solid var(--card-border); border-radius: 10px; padding: 8px 14px;
|
||||
transition: border-color .15s, background .15s, opacity .15s; display: inline-flex; align-items: center; gap: 6px;
|
||||
}
|
||||
button:hover:not(:disabled), .btn:hover { border-color: var(--accent); }
|
||||
button:disabled { opacity: .45; cursor: not-allowed; }
|
||||
button.primary { color: #fff; border: 0; background: linear-gradient(120deg, var(--accent), var(--accent-2)); }
|
||||
button.danger { color: #fecdd3; border-color: rgba(244, 63, 94, .4); background: rgba(244, 63, 94, .12); }
|
||||
button.danger:hover:not(:disabled) { border-color: var(--danger); }
|
||||
button.small { font-size: 12.5px; padding: 5px 10px; border-radius: 8px; }
|
||||
|
||||
input, select, textarea {
|
||||
font: inherit; font-size: 14px; color: var(--text); background: rgba(0, 0, 0, 0.28);
|
||||
border: 1px solid var(--card-border); border-radius: 10px; padding: 9px 12px; outline: none;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.22); }
|
||||
select option { background: var(--bg-2); }
|
||||
|
||||
.tabs { display: inline-flex; gap: 4px; padding: 4px; border-radius: 12px; background: var(--card); border: 1px solid var(--card-border); margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.tabs a { text-decoration: none; color: var(--muted); font-size: 14px; font-weight: 600; padding: 8px 16px; border-radius: 9px; }
|
||||
.tabs a.active { color: #fff; background: linear-gradient(120deg, var(--accent), var(--accent-2)); }
|
||||
.tabs a:not(.active):hover { color: var(--text); }
|
||||
|
||||
.center-card { max-width: 440px; margin: 60px auto; text-align: center; }
|
||||
.center-card input { width: 100%; margin: 14px 0 12px; text-align: center; }
|
||||
.center-card button { width: 100%; justify-content: center; padding: 11px; }
|
||||
|
||||
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-bottom: 16px; }
|
||||
.tile { padding: 16px 18px; }
|
||||
.tile .label { font-size: 11.5px; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); }
|
||||
.tile .value { font-size: 28px; font-weight: 800; margin-top: 4px; font-variant-numeric: tabular-nums; }
|
||||
.tile.live .value { color: var(--ok); }
|
||||
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
@media (max-width: 820px) { .grid-2 { grid-template-columns: 1fr; } }
|
||||
.stack { display: grid; gap: 16px; }
|
||||
|
||||
.chart { display: flex; align-items: flex-end; gap: 6px; height: 150px; padding-top: 8px; }
|
||||
.bar-col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 6px; height: 100%; justify-content: flex-end; min-width: 0; }
|
||||
.bar { width: 100%; max-width: 34px; border-radius: 6px 6px 3px 3px; background: linear-gradient(180deg, var(--accent-2), var(--accent)); min-height: 3px; position: relative; }
|
||||
.bar.zero { background: rgba(255,255,255,.08); }
|
||||
.bar-col .n { font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
.bar-col .d { font-size: 10.5px; color: var(--muted); white-space: nowrap; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
|
||||
th { text-align: left; font-weight: 600; color: var(--muted); font-size: 12px; padding: 0 8px 8px; border-bottom: 1px solid var(--card-border); }
|
||||
td { padding: 9px 8px; border-bottom: 1px solid rgba(255,255,255,.04); vertical-align: top; }
|
||||
td.mono { font-family: var(--mono); font-size: 12.5px; word-break: break-all; }
|
||||
tr.me td { color: #fff; }
|
||||
tr.me td:first-child::after { content: " ← you"; color: var(--accent-2); font-family: inherit; font-size: 11.5px; }
|
||||
dl.kv { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; margin: 0; font-size: 13.5px; }
|
||||
dl.kv dt { color: var(--muted); }
|
||||
dl.kv dd { margin: 0; font-family: var(--mono); font-size: 12.5px; word-break: break-all; }
|
||||
|
||||
.dot-s { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; vertical-align: 1px; }
|
||||
.ok { background: var(--ok); } .bad { background: var(--danger); } .off { background: var(--muted); }
|
||||
|
||||
.toolbar { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; margin-bottom: 14px; }
|
||||
.toolbar input[type=search] { flex: 1 1 220px; }
|
||||
.seg { display: inline-flex; gap: 2px; padding: 3px; border-radius: 10px; background: rgba(0,0,0,.25); border: 1px solid var(--card-border); }
|
||||
.seg button { border: 0; background: transparent; padding: 6px 12px; border-radius: 8px; font-size: 13px; color: var(--muted); }
|
||||
.seg button.on { background: rgba(139, 92, 246, .35); color: #fff; }
|
||||
|
||||
.rows { display: grid; gap: 10px; }
|
||||
.row { padding: 14px 16px; display: grid; grid-template-columns: 1fr auto; gap: 8px 14px; align-items: start; }
|
||||
.row.is-hidden { opacity: .6; border-style: dashed; }
|
||||
.row.is-pinned { border-color: rgba(139, 92, 246, 0.55); }
|
||||
.row .head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 13px; }
|
||||
.row .who { font-weight: 650; font-size: 14px; }
|
||||
.row .when, .row .id { color: var(--muted); }
|
||||
.row .msg { grid-column: 1 / -1; font-size: 14.5px; line-height: 1.55; white-space: pre-wrap; word-break: break-word; }
|
||||
.row .acts { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.badge { font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; padding: 2px 8px; border-radius: 999px; }
|
||||
.badge.pin { color: #ddd6fe; background: rgba(139, 92, 246, .3); }
|
||||
.badge.hid { color: #fde68a; background: rgba(245, 158, 11, .2); }
|
||||
.ip { font-family: var(--mono); font-size: 11.5px; color: #a5f3fc; background: rgba(34, 211, 238, .1); border: 1px solid rgba(34, 211, 238, .25); padding: 1px 8px; border-radius: 999px; cursor: pointer; }
|
||||
.rep { font-family: var(--mono); font-size: 11.5px; color: var(--muted); background: rgba(0,0,0,.25); border: 1px solid var(--card-border); padding: 1px 8px; border-radius: 999px; }
|
||||
@media (max-width: 620px) { .row { grid-template-columns: 1fr; } .row .acts { justify-content: flex-start; } }
|
||||
|
||||
.pager { display: flex; align-items: center; justify-content: center; gap: 14px; margin-top: 16px; }
|
||||
.pager .info { font-size: 13px; color: var(--muted); min-width: 110px; text-align: center; }
|
||||
.empty { text-align: center; color: var(--muted); padding: 36px 20px; }
|
||||
|
||||
.switch { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 4px 0 18px; border-bottom: 1px solid var(--card-border); margin-bottom: 18px; }
|
||||
.toggle { position: relative; width: 46px; height: 26px; flex: 0 0 auto; }
|
||||
.toggle input { opacity: 0; width: 0; height: 0; position: absolute; }
|
||||
.toggle span { position: absolute; inset: 0; border-radius: 999px; background: rgba(255,255,255,.12); cursor: pointer; transition: background .15s; }
|
||||
.toggle span::after { content: ""; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; border-radius: 50%; background: #fff; transition: transform .15s; }
|
||||
.toggle input:checked + span { background: var(--warn); }
|
||||
.toggle input:checked + span::after { transform: translateX(20px); }
|
||||
.toggle input:focus-visible + span { box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.4); }
|
||||
label.field { display: block; font-size: 13px; color: var(--muted); margin-bottom: 6px; }
|
||||
.field-row { display: flex; gap: 10px; align-items: center; }
|
||||
.field-row input { flex: 1; }
|
||||
|
||||
.chaos-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin-bottom: 16px; }
|
||||
.chaos { padding: 16px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.chaos p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.5; flex: 1; }
|
||||
.chaos .inputs { display: flex; gap: 8px; }
|
||||
.chaos .inputs input, .chaos .inputs select { width: 100%; min-width: 0; padding: 7px 10px; font-size: 13px; }
|
||||
.chaos .shows { font-size: 11.5px; color: var(--accent-2); }
|
||||
|
||||
.probe-bars { display: grid; gap: 8px; margin-top: 14px; }
|
||||
.pb { display: grid; grid-template-columns: minmax(120px, 260px) 1fr 96px; gap: 10px; align-items: center; font-size: 13px; }
|
||||
.pb .k { font-family: var(--mono); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pb .track { height: 12px; border-radius: 999px; background: rgba(255,255,255,.06); overflow: hidden; }
|
||||
.pb .fill { height: 100%; border-radius: 999px; }
|
||||
.pb .v { text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
@media (max-width: 620px) { .pb { grid-template-columns: 1fr 60px; } .pb .track { grid-column: 1 / -1; grid-row: 2; } }
|
||||
|
||||
.log { font-family: var(--mono); font-size: 12px; line-height: 1.7; max-height: 260px; overflow: auto; background: rgba(0,0,0,.3); border-radius: 10px; padding: 10px 12px; }
|
||||
.log div { white-space: pre-wrap; word-break: break-word; }
|
||||
.log .t { color: var(--muted); }
|
||||
.log .e { color: #fda4af; } .log .s { color: #86efac; } .log .w { color: #fcd34d; }
|
||||
|
||||
.toast { position: fixed; left: 50%; bottom: 26px; transform: translateX(-50%) translateY(20px); background: #1f1b2e; border: 1px solid var(--card-border);
|
||||
color: white; padding: 11px 18px; border-radius: 12px; font-size: 14px; opacity: 0; pointer-events: none; transition: opacity .2s, transform .2s; }
|
||||
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
.toast.err { background: var(--danger); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="topbar">
|
||||
<div class="brand"><span class="dot"></span> Guestbook Admin <small id="appName"></small></div>
|
||||
<div class="top-right">
|
||||
<span class="chip" title="Replica that answered the last request">served by <b id="servedBy">…</b></span>
|
||||
<span class="ver" id="ver">v…</span>
|
||||
<a class="btn" href="/">← Wall</a>
|
||||
<button id="logout" hidden>Sign out</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="view-disabled" class="card pad center-card" hidden>
|
||||
<h3>Admin console is disabled</h3>
|
||||
<p class="hint">Set an <code>ADMIN_TOKEN</code> secret on the app and redeploy. On Miabi, add it under the app's environment as a <b>secret</b>.</p>
|
||||
</section>
|
||||
|
||||
<section id="view-login" hidden>
|
||||
<form class="card pad center-card" id="loginForm">
|
||||
<h3>Sign in</h3>
|
||||
<p class="hint">Enter the value of the app's <code>ADMIN_TOKEN</code>.</p>
|
||||
<input id="token" type="password" placeholder="Admin token" autocomplete="current-password" required />
|
||||
<button class="primary" type="submit">Sign in</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="view-console" hidden>
|
||||
<nav class="tabs">
|
||||
<a href="#overview" data-tab="overview">Overview</a>
|
||||
<a href="#moderation" data-tab="moderation">Moderation</a>
|
||||
<a href="#settings" data-tab="settings">Settings</a>
|
||||
<a href="#chaos" data-tab="chaos">Chaos lab</a>
|
||||
</nav>
|
||||
|
||||
<div id="tab-overview" data-panel>
|
||||
<div class="tiles">
|
||||
<div class="card tile live"><div class="label">Online now</div><div class="value" id="tOnline">–</div></div>
|
||||
<div class="card tile"><div class="label">Replicas</div><div class="value" id="tReplicas">–</div></div>
|
||||
<div class="card tile"><div class="label">Signatures</div><div class="value" id="tTotal">–</div></div>
|
||||
<div class="card tile"><div class="label">Today</div><div class="value" id="tToday">–</div></div>
|
||||
<div class="card tile"><div class="label">Pinned</div><div class="value" id="tPinned">–</div></div>
|
||||
<div class="card tile"><div class="label">Hidden</div><div class="value" id="tHidden">–</div></div>
|
||||
</div>
|
||||
<div class="stack">
|
||||
<div class="card pad">
|
||||
<h2>Signatures · last 14 days</h2>
|
||||
<div class="chart" id="chart"></div>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="card pad">
|
||||
<h2>Replicas</h2>
|
||||
<p class="hint" id="replicaHint"></p>
|
||||
<table>
|
||||
<thead><tr><th>Host</th><th>Version</th><th>Clients</th><th>Seen</th></tr></thead>
|
||||
<tbody id="replicaRows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card pad">
|
||||
<h2>This replica</h2>
|
||||
<dl class="kv" id="runtime"></dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-moderation" data-panel hidden>
|
||||
<div class="toolbar">
|
||||
<input type="search" id="q" placeholder="Search name, message or IP…" />
|
||||
<div class="seg" id="statusSeg">
|
||||
<button data-status="" class="on">All</button>
|
||||
<button data-status="visible">Visible</button>
|
||||
<button data-status="hidden">Hidden</button>
|
||||
<button data-status="pinned">Pinned</button>
|
||||
</div>
|
||||
<a class="btn" href="/api/admin/export.csv" download>Export CSV</a>
|
||||
</div>
|
||||
<div class="rows" id="rows"></div>
|
||||
<div class="pager">
|
||||
<button id="prev">← Newer</button>
|
||||
<span class="info" id="pageInfo"></span>
|
||||
<button id="next">Older →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-settings" data-panel hidden>
|
||||
<form class="card pad" id="settingsForm" style="max-width:640px">
|
||||
<h2>Wall settings</h2>
|
||||
<div class="switch">
|
||||
<div>
|
||||
<h3>Pause signing</h3>
|
||||
<p class="hint" style="margin:0">Make the wall read-only on every replica. Stored in the database and pushed live to every open tab.</p>
|
||||
</div>
|
||||
<label class="toggle"><input type="checkbox" id="paused" /><span></span></label>
|
||||
</div>
|
||||
<label class="field" for="banner">Announcement banner <span id="bannerCount">0 / 200</span></label>
|
||||
<div class="field-row">
|
||||
<input id="banner" maxlength="200" placeholder="e.g. Welcome to the Miabi training 👋" />
|
||||
<button class="primary" type="submit">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="tab-chaos" data-panel hidden>
|
||||
<div class="card pad" id="chaosOff" hidden style="margin-bottom:16px">
|
||||
<h3>Chaos lab is disabled</h3>
|
||||
<p class="hint" style="margin:0">Set <code>DEBUG_ENDPOINTS=true</code> on the app to enable failure injection. The traffic probe below works either way.</p>
|
||||
</div>
|
||||
|
||||
<div class="chaos-grid" id="chaosGrid">
|
||||
<div class="card chaos">
|
||||
<h3>💥 Crash replica</h3>
|
||||
<p>Exit the process with status 1. Miabi's restart policy brings it back.</p>
|
||||
<span class="shows">Shows: restarts · alerts · events</span>
|
||||
<button class="danger" data-chaos="crash">Crash it</button>
|
||||
</div>
|
||||
<div class="card chaos">
|
||||
<h3>🩺 Fail health check</h3>
|
||||
<p>Make <code>/healthz</code> return 503 on the replica that receives this.</p>
|
||||
<span class="shows">Shows: health gating · rollout safety</span>
|
||||
<div class="inputs">
|
||||
<button class="danger" data-chaos="unhealthy" style="flex:1;justify-content:center">Unhealthy</button>
|
||||
<button data-chaos="healthy" style="flex:1;justify-content:center">Healthy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card chaos">
|
||||
<h3>🔥 CPU burn</h3>
|
||||
<p>Saturate every core for a while.</p>
|
||||
<span class="shows">Shows: metrics · resource alerts</span>
|
||||
<div class="inputs"><input id="cpuSec" type="number" min="1" max="120" value="30" title="Seconds" /><button data-chaos="cpu">Burn</button></div>
|
||||
</div>
|
||||
<div class="card chaos">
|
||||
<h3>🧠 Hold memory</h3>
|
||||
<p>Allocate MiB for N seconds. Exceed the memory limit to trigger an OOM restart.</p>
|
||||
<span class="shows">Shows: memory limits · OOM kill</span>
|
||||
<div class="inputs">
|
||||
<input id="memMb" type="number" min="1" max="2048" value="256" title="MiB" />
|
||||
<input id="memSec" type="number" min="1" max="120" value="30" title="Seconds" />
|
||||
<button data-chaos="memory">Hold</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card chaos">
|
||||
<h3>📜 Log burst</h3>
|
||||
<p>Write a burst of log lines.</p>
|
||||
<span class="shows">Shows: live logs · search</span>
|
||||
<div class="inputs">
|
||||
<input id="logCount" type="number" min="1" max="1000" value="20" title="Lines" />
|
||||
<select id="logLevel"><option>error</option><option>warn</option><option>info</option></select>
|
||||
<button data-chaos="logs">Write</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card pad" style="margin-bottom:16px">
|
||||
<h2>Traffic probe</h2>
|
||||
<p class="hint">Fire requests from this browser and tally who answered. With replicas you'll see the load spread; during a canary you'll see the traffic split by version.</p>
|
||||
<div class="toolbar" style="margin-bottom:0">
|
||||
<select id="probeTarget">
|
||||
<option value="/api/time">/api/time (fast)</option>
|
||||
<option value="/api/debug/slow?ms=400" data-debug>/api/debug/slow?ms=400 (latency)</option>
|
||||
<option value="/api/debug/error?status=500" data-debug>/api/debug/error (5xx)</option>
|
||||
</select>
|
||||
<input id="probeN" type="number" min="1" max="1000" value="100" style="width:90px" title="Requests" />
|
||||
<input id="probeC" type="number" min="1" max="20" value="6" style="width:70px" title="Concurrency" />
|
||||
<button class="primary" id="probeRun">Run probe</button>
|
||||
<span class="chip" id="probeStat" hidden></span>
|
||||
</div>
|
||||
<div class="probe-bars" id="probeBars"></div>
|
||||
</div>
|
||||
|
||||
<div class="card pad">
|
||||
<h2>Activity</h2>
|
||||
<div class="log" id="log"><div class="t">Actions and their responses appear here.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const PAGE = 15;
|
||||
|
||||
let toastTimer;
|
||||
function toast(msg, err) {
|
||||
const t = $('toast'); t.textContent = msg; t.classList.toggle('err', !!err); t.classList.add('show');
|
||||
clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove('show'), 3000);
|
||||
}
|
||||
|
||||
async function req(method, url, body) {
|
||||
const opts = { method, headers: {}, cache: 'no-store' };
|
||||
if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); }
|
||||
const res = await fetch(url, opts);
|
||||
const data = res.status === 204 ? null : await res.json().catch(() => null);
|
||||
if (res.status === 401) showView('login');
|
||||
return { ok: res.ok, status: res.status, data };
|
||||
}
|
||||
|
||||
function hueFor(s) { let h = 0; for (const c of String(s)) h = (h * 31 + c.charCodeAt(0)) >>> 0; return h % 360; }
|
||||
function setVersion(v) { const el = $('ver'); el.textContent = 'v' + v; el.style.background = `hsl(${hueFor(v)} 70% 52%)`; }
|
||||
function ago(iso) {
|
||||
const d = (Date.now() - new Date(iso).getTime()) / 1000;
|
||||
if (d < 5) return 'now';
|
||||
if (d < 60) return Math.floor(d) + 's ago';
|
||||
if (d < 3600) return Math.floor(d / 60) + 'm ago';
|
||||
if (d < 86400) return Math.floor(d / 3600) + 'h ago';
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
function uptime(s) {
|
||||
const d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60);
|
||||
return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : m ? `${m}m ${s % 60}s` : `${s}s`;
|
||||
}
|
||||
|
||||
// ---- views + tabs ------------------------------------------------------
|
||||
function showView(v) {
|
||||
for (const k of ['disabled', 'login', 'console']) $('view-' + k).hidden = k !== v;
|
||||
$('logout').hidden = v !== 'console';
|
||||
if (v === 'console') { route(); connectSSE(); } else { stopPolling(); }
|
||||
}
|
||||
function currentTab() { const t = location.hash.slice(1); return ['overview', 'moderation', 'settings', 'chaos'].includes(t) ? t : 'overview'; }
|
||||
function route() {
|
||||
const tab = currentTab();
|
||||
document.querySelectorAll('[data-panel]').forEach((p) => { p.hidden = p.id !== 'tab-' + tab; });
|
||||
document.querySelectorAll('.tabs a').forEach((a) => a.classList.toggle('active', a.dataset.tab === tab));
|
||||
stopPolling();
|
||||
if (tab === 'overview') { loadOverview(); pollTimer = setInterval(loadOverview, 5000); }
|
||||
if (tab === 'moderation') loadRows();
|
||||
if (tab === 'settings') loadSettings();
|
||||
if (tab === 'chaos') loadOverview();
|
||||
}
|
||||
let pollTimer;
|
||||
function stopPolling() { clearInterval(pollTimer); }
|
||||
window.addEventListener('hashchange', () => { if (!$('view-console').hidden) route(); });
|
||||
|
||||
// ---- overview ----------------------------------------------------------
|
||||
let overview = null;
|
||||
async function loadOverview() {
|
||||
const r = await req('GET', '/api/admin/overview');
|
||||
if (!r.ok || !r.data) return;
|
||||
overview = r.data;
|
||||
const { stats, runtime: rt, replicas, online, debug } = overview;
|
||||
$('servedBy').textContent = rt.host;
|
||||
$('appName').textContent = rt.app;
|
||||
setVersion(rt.version);
|
||||
$('tOnline').textContent = online;
|
||||
$('tReplicas').textContent = replicas.length;
|
||||
$('tTotal').textContent = stats.total;
|
||||
$('tToday').textContent = stats.today;
|
||||
$('tPinned').textContent = stats.pinned;
|
||||
$('tHidden').textContent = stats.hidden;
|
||||
renderChart(stats.daily || []);
|
||||
|
||||
$('replicaHint').innerHTML = rt.broker === 'redis'
|
||||
? 'Presence is shared through Redis, so every replica is listed.'
|
||||
: 'In-process broker: only this replica is visible. Attach Redis (<code>REDIS_URL</code>) to share live updates across replicas.';
|
||||
$('replicaRows').innerHTML = replicas.map((r) => `
|
||||
<tr class="${r.host === rt.host ? 'me' : ''}">
|
||||
<td class="mono">${esc(r.host)}</td>
|
||||
<td><span class="ver" style="background:hsl(${hueFor(r.version)} 70% 52%);font-size:11px;padding:2px 8px">v${esc(r.version)}</span></td>
|
||||
<td>${r.online}</td>
|
||||
<td>${ago(r.seen)}</td>
|
||||
</tr>`).join('');
|
||||
|
||||
const status = (v) => v === 'up' ? '<span class="dot-s ok"></span>up' : v === 'down' ? '<span class="dot-s bad"></span>down' : '<span class="dot-s off"></span>' + esc(v);
|
||||
const kv = [
|
||||
['Host', esc(rt.host)],
|
||||
['Version', esc(rt.version)],
|
||||
['Health', rt.healthy ? '<span class="dot-s ok"></span>healthy' : '<span class="dot-s bad"></span>forced unhealthy'],
|
||||
['Database', esc(rt.db)],
|
||||
['Live updates', rt.broker === 'redis' ? 'Redis pub/sub' : 'in-process'],
|
||||
['Redis', status(rt.redis)],
|
||||
['Uptime', uptime(rt.uptime_s)],
|
||||
['Go', `${esc(rt.go)} · ${rt.cpus} CPU`],
|
||||
['Goroutines', rt.goroutines],
|
||||
['Heap / Sys', `${rt.heap_mb.toFixed(1)} / ${rt.sys_mb.toFixed(1)} MiB`],
|
||||
['Debug endpoints', debug ? 'enabled' : 'disabled'],
|
||||
];
|
||||
$('runtime').innerHTML = kv.map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join('');
|
||||
|
||||
$('chaosOff').hidden = debug;
|
||||
document.querySelectorAll('[data-chaos]').forEach((b) => { b.disabled = !debug; });
|
||||
document.querySelectorAll('#probeTarget option[data-debug]').forEach((o) => { o.disabled = !debug; });
|
||||
}
|
||||
|
||||
function renderChart(daily) {
|
||||
const maxN = Math.max(1, ...daily.map((d) => d.count));
|
||||
$('chart').innerHTML = daily.map((d, i) => {
|
||||
const pct = Math.round((d.count / maxN) * 100);
|
||||
const day = new Date(d.day + 'T00:00:00Z');
|
||||
const label = day.toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' });
|
||||
const show = i % 2 === (daily.length - 1) % 2;
|
||||
return `<div class="bar-col" title="${label}: ${d.count}">
|
||||
<span class="n">${d.count || ''}</span>
|
||||
<div class="bar ${d.count ? '' : 'zero'}" style="height:${d.count ? pct : 0}%"></div>
|
||||
<span class="d">${show ? label : ' '}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ---- moderation --------------------------------------------------------
|
||||
const mod = { status: '', q: '', offset: 0 };
|
||||
async function loadRows() {
|
||||
const qs = new URLSearchParams({ status: mod.status, q: mod.q, limit: PAGE, offset: mod.offset });
|
||||
const r = await req('GET', '/api/admin/entries?' + qs);
|
||||
if (!r.ok) return;
|
||||
const { entries, total } = r.data;
|
||||
if (!entries.length && mod.offset > 0) { mod.offset = Math.max(0, mod.offset - PAGE); return loadRows(); }
|
||||
$('rows').innerHTML = entries.length ? entries.map(rowHTML).join('') : '<div class="empty card">No entries match.</div>';
|
||||
const from = total ? mod.offset + 1 : 0, to = Math.min(mod.offset + PAGE, total);
|
||||
$('pageInfo').textContent = `${from}–${to} of ${total}`;
|
||||
$('prev').disabled = mod.offset === 0;
|
||||
$('next').disabled = mod.offset + PAGE >= total;
|
||||
}
|
||||
function rowHTML(e) {
|
||||
const rep = e.replica ? `<span class="rep" title="Accepted by">${esc(e.replica)}</span>` : '';
|
||||
const ip = e.ip ? `<span class="ip" data-ip="${esc(e.ip)}" title="Submitter IP — click to filter">${esc(e.ip)}</span>` : '';
|
||||
return `<div class="card row ${e.hidden ? 'is-hidden' : ''} ${e.pinned ? 'is-pinned' : ''}" data-id="${e.id}">
|
||||
<div class="head">
|
||||
<span class="who">${esc(e.name)}</span>
|
||||
<span class="when" title="${esc(new Date(e.created_at).toLocaleString())}">${ago(e.created_at)}</span>
|
||||
<span class="id">#${e.id}</span>
|
||||
${e.pinned ? '<span class="badge pin">Pinned</span>' : ''}
|
||||
${e.hidden ? '<span class="badge hid">Hidden</span>' : ''}
|
||||
${ip}
|
||||
${rep}
|
||||
</div>
|
||||
<div class="acts">
|
||||
<button class="small" data-act="pin">${e.pinned ? 'Unpin' : '📌 Pin'}</button>
|
||||
<button class="small" data-act="hide">${e.hidden ? 'Show' : 'Hide'}</button>
|
||||
<button class="small danger" data-act="delete">Delete</button>
|
||||
</div>
|
||||
<div class="msg">${esc(e.message)}</div>
|
||||
</div>`;
|
||||
}
|
||||
$('rows').addEventListener('click', async (ev) => {
|
||||
const btn = ev.target.closest('button[data-act]');
|
||||
if (!btn) return;
|
||||
const row = btn.closest('.row');
|
||||
const id = row.dataset.id;
|
||||
const act = btn.dataset.act;
|
||||
let r;
|
||||
if (act === 'delete') {
|
||||
if (!confirm(`Delete entry #${id}? It will disappear from every open tab.`)) return;
|
||||
r = await req('DELETE', '/api/admin/entries/' + id);
|
||||
} else {
|
||||
const body = act === 'pin' ? { pinned: !row.classList.contains('is-pinned') } : { hidden: !row.classList.contains('is-hidden') };
|
||||
r = await req('PATCH', '/api/admin/entries/' + id, body);
|
||||
}
|
||||
if (!r.ok) toast((r.data && r.data.error) || 'Action failed', true);
|
||||
loadRows();
|
||||
});
|
||||
$('rows').addEventListener('click', (ev) => {
|
||||
const chip = ev.target.closest('.ip');
|
||||
if (!chip) return;
|
||||
$('q').value = chip.dataset.ip; mod.q = chip.dataset.ip; mod.offset = 0; loadRows();
|
||||
});
|
||||
let searchTimer;
|
||||
$('q').addEventListener('input', (e) => { clearTimeout(searchTimer); searchTimer = setTimeout(() => { mod.q = e.target.value; mod.offset = 0; loadRows(); }, 250); });
|
||||
$('statusSeg').addEventListener('click', (e) => {
|
||||
const b = e.target.closest('button'); if (!b) return;
|
||||
document.querySelectorAll('#statusSeg button').forEach((x) => x.classList.toggle('on', x === b));
|
||||
mod.status = b.dataset.status; mod.offset = 0; loadRows();
|
||||
});
|
||||
$('prev').addEventListener('click', () => { mod.offset = Math.max(0, mod.offset - PAGE); loadRows(); });
|
||||
$('next').addEventListener('click', () => { mod.offset += PAGE; loadRows(); });
|
||||
|
||||
// ---- settings ----------------------------------------------------------
|
||||
async function loadSettings() {
|
||||
const r = await req('GET', '/api/admin/settings');
|
||||
if (!r.ok) return;
|
||||
$('paused').checked = !!r.data.signing_paused;
|
||||
$('banner').value = r.data.banner || '';
|
||||
$('bannerCount').textContent = $('banner').value.length + ' / 200';
|
||||
}
|
||||
async function saveSettings() {
|
||||
const r = await req('PUT', '/api/admin/settings', { signing_paused: $('paused').checked, banner: $('banner').value });
|
||||
toast(r.ok ? 'Settings saved and broadcast' : ((r.data && r.data.error) || 'Could not save'), !r.ok);
|
||||
}
|
||||
$('banner').addEventListener('input', (e) => { $('bannerCount').textContent = e.target.value.length + ' / 200'; });
|
||||
$('paused').addEventListener('change', saveSettings);
|
||||
$('settingsForm').addEventListener('submit', (e) => { e.preventDefault(); saveSettings(); });
|
||||
|
||||
// ---- chaos lab ---------------------------------------------------------
|
||||
function log(msg, kind) {
|
||||
const el = $('log');
|
||||
if (el.querySelector('.t:only-child')) el.innerHTML = '';
|
||||
const line = document.createElement('div');
|
||||
line.innerHTML = `<span class="t">${new Date().toLocaleTimeString()}</span> <span class="${kind || ''}">${esc(msg)}</span>`;
|
||||
el.prepend(line);
|
||||
}
|
||||
const chaos = {
|
||||
crash: () => ['POST', '/api/debug/crash'],
|
||||
unhealthy: () => ['POST', '/api/debug/health', { healthy: false }],
|
||||
healthy: () => ['POST', '/api/debug/health', { healthy: true }],
|
||||
cpu: () => ['POST', `/api/debug/cpu?seconds=${$('cpuSec').value}`],
|
||||
memory: () => ['POST', `/api/debug/memory?mb=${$('memMb').value}&seconds=${$('memSec').value}`],
|
||||
logs: () => ['POST', `/api/debug/logs?count=${$('logCount').value}&level=${$('logLevel').value}`],
|
||||
};
|
||||
$('chaosGrid').addEventListener('click', async (e) => {
|
||||
const b = e.target.closest('button[data-chaos]'); if (!b) return;
|
||||
const kind = b.dataset.chaos;
|
||||
if (kind === 'crash' && !confirm('Crash the replica that receives this request?')) return;
|
||||
const [method, url, body] = chaos[kind]();
|
||||
b.disabled = true;
|
||||
try {
|
||||
const r = await req(method, url, body);
|
||||
const d = r.data || {};
|
||||
log(`${method} ${url} → ${r.status}${d.host ? ' from ' + d.host : ''}${d.error ? ' · ' + d.error : ''}`, r.ok ? 's' : 'e');
|
||||
if (kind === 'crash' && r.ok) watchRecovery(d.host);
|
||||
} catch (err) { log(`${method} ${url} → network error`, 'e'); }
|
||||
finally { setTimeout(() => { b.disabled = !(overview && overview.debug); }, 800); }
|
||||
if (kind === 'unhealthy' || kind === 'healthy') loadOverview();
|
||||
});
|
||||
|
||||
// Polls /healthz after a crash and reports when the crashed host (or a
|
||||
// replacement) answers again, which is the restart made visible.
|
||||
async function watchRecovery(crashed) {
|
||||
const start = Date.now();
|
||||
let sawDown = false;
|
||||
log(`watching /healthz for ${crashed} to come back…`, 'w');
|
||||
while (Date.now() - start < 120000) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
let status = 0, host = '';
|
||||
try {
|
||||
const res = await fetch('/healthz', { cache: 'no-store' });
|
||||
status = res.status; host = ((await res.json().catch(() => ({}))).host) || '';
|
||||
} catch { status = 0; }
|
||||
const secs = Math.round((Date.now() - start) / 1000);
|
||||
if (status !== 200) { if (!sawDown) log(`healthz → ${status || 'unreachable'} (${secs}s)`, 'e'); sawDown = true; continue; }
|
||||
if (host && host !== crashed) { log(`served by ${host} after ${secs}s — another replica took the traffic`, 's'); return; }
|
||||
if (sawDown || secs > 3) { log(`${host || 'app'} is back after ${secs}s`, 's'); loadOverview(); return; }
|
||||
}
|
||||
log('gave up watching after 120s', 'w');
|
||||
}
|
||||
|
||||
$('probeRun').addEventListener('click', async () => {
|
||||
const url = $('probeTarget').value;
|
||||
const n = Math.min(1000, Math.max(1, +$('probeN').value || 1));
|
||||
const c = Math.min(20, Math.max(1, +$('probeC').value || 1));
|
||||
const tally = new Map();
|
||||
const lat = [];
|
||||
let sent = 0, done = 0;
|
||||
$('probeRun').disabled = true;
|
||||
$('probeStat').hidden = false;
|
||||
const t0 = performance.now();
|
||||
const worker = async () => {
|
||||
while (sent < n) {
|
||||
sent++;
|
||||
const s = performance.now();
|
||||
let key;
|
||||
try {
|
||||
const res = await fetch(url, { cache: 'no-store' });
|
||||
const d = await res.json().catch(() => ({}));
|
||||
key = `${res.status} · v${d.version || '?'} · ${d.host || '?'}`;
|
||||
if (res.status >= 400 && !d.version) key = `${res.status} · ${d.host || '?'}`;
|
||||
} catch { key = 'network error'; }
|
||||
lat.push(performance.now() - s);
|
||||
tally.set(key, (tally.get(key) || 0) + 1);
|
||||
done++;
|
||||
if (done % 5 === 0 || done === n) renderProbe(tally, done, n, lat, t0);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: c }, worker));
|
||||
renderProbe(tally, done, n, lat, t0);
|
||||
log(`probe ${url}: ${n} requests, ${tally.size} distinct responders`, 's');
|
||||
$('probeRun').disabled = false;
|
||||
});
|
||||
function renderProbe(tally, done, n, lat, t0) {
|
||||
const sorted = [...lat].sort((a, b) => a - b);
|
||||
const p = (q) => sorted.length ? Math.round(sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]) : 0;
|
||||
const rps = done / ((performance.now() - t0) / 1000);
|
||||
$('probeStat').textContent = `${done}/${n} · p50 ${p(0.5)}ms · p95 ${p(0.95)}ms · ${rps.toFixed(0)} req/s`;
|
||||
const rows = [...tally.entries()].sort((a, b) => b[1] - a[1]);
|
||||
$('probeBars').innerHTML = rows.map(([k, v]) => {
|
||||
const pct = (v / done) * 100;
|
||||
const color = k.startsWith('2') ? `hsl(${hueFor(k.split(' · ')[1] || k)} 70% 55%)` : 'var(--danger)';
|
||||
return `<div class="pb"><span class="k" title="${esc(k)}">${esc(k)}</span>
|
||||
<div class="track"><div class="fill" style="width:${pct}%;background:${color}"></div></div>
|
||||
<span class="v">${v} · ${pct.toFixed(0)}%</span></div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ---- live updates ------------------------------------------------------
|
||||
let es;
|
||||
let refreshTimer;
|
||||
function refreshSoon() {
|
||||
clearTimeout(refreshTimer);
|
||||
refreshTimer = setTimeout(() => {
|
||||
const tab = currentTab();
|
||||
if (tab === 'moderation') loadRows();
|
||||
if (tab === 'overview') loadOverview();
|
||||
if (tab === 'settings') loadSettings();
|
||||
}, 250);
|
||||
}
|
||||
function connectSSE() {
|
||||
if (es) return;
|
||||
es = new EventSource('/api/stream');
|
||||
for (const t of ['created', 'updated', 'deleted', 'settings']) es.addEventListener(t, refreshSoon);
|
||||
es.addEventListener('presence', (e) => { const d = JSON.parse(e.data); $('tOnline').textContent = d.online || 0; if (d.replicas) $('tReplicas').textContent = d.replicas; });
|
||||
}
|
||||
|
||||
// ---- auth --------------------------------------------------------------
|
||||
$('loginForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const r = await req('POST', '/api/admin/login', { token: $('token').value });
|
||||
if (r.ok) { $('token').value = ''; showView('console'); }
|
||||
else toast((r.data && r.data.error) || 'Sign-in failed', true);
|
||||
});
|
||||
$('logout').addEventListener('click', async () => {
|
||||
await req('POST', '/api/admin/logout');
|
||||
if (es) { es.close(); es = null; }
|
||||
showView('login');
|
||||
});
|
||||
|
||||
(async () => {
|
||||
const r = await req('GET', '/api/admin/session');
|
||||
fetch('/api/info', { cache: 'no-store' }).then((x) => x.json()).then((d) => {
|
||||
setVersion(d.version); $('servedBy').textContent = d.host; $('appName').textContent = d.app;
|
||||
}).catch(() => {});
|
||||
if (!r.ok || !r.data) return showView('login');
|
||||
showView(!r.data.enabled ? 'disabled' : r.data.authenticated ? 'console' : 'login');
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="156" height="32" viewBox="0 0 156 32" role="img" aria-label="Running on Miabi">
|
||||
<title>Running on Miabi</title>
|
||||
<defs>
|
||||
<linearGradient id="mb-grad" x1="0" y1="0" x2="1024" y2="1024" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#c084fc"/>
|
||||
<stop offset=".33" stop-color="#a855f7"/>
|
||||
<stop offset=".68" stop-color="#9333ea"/>
|
||||
<stop offset="1" stop-color="#7e22ce"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x=".5" y=".5" width="155" height="31" rx="15.5" fill="#101A37" stroke="#ffffff" stroke-opacity=".08"/>
|
||||
<g transform="translate(8 4) scale(0.0234375)" fill="url(#mb-grad)">
|
||||
<path d="M 486 168 C 416 166 306 168 326 168 C 226 168 168 226 168 326 L 168 354 C 168 454 226 486 326 486 L 354 486 C 416 486 458 458 472 472 C 458 458 486 416 486 354 L 486 168 Z" transform="rotate(0 512 512)"/>
|
||||
<path d="M 486 168 C 416 166 306 168 326 168 C 226 168 168 226 168 326 L 168 354 C 168 454 226 486 326 486 L 354 486 C 416 486 458 458 472 472 C 458 458 486 416 486 354 L 486 168 Z" transform="rotate(90 512 512)"/>
|
||||
<path d="M 486 168 C 416 166 306 168 326 168 C 226 168 168 226 168 326 L 168 354 C 168 454 226 486 326 486 L 354 486 C 416 486 458 458 472 472 C 458 458 486 416 486 354 L 486 168 Z" transform="rotate(180 512 512)"/>
|
||||
<path d="M 486 168 C 416 166 306 168 326 168 C 226 168 168 226 168 326 L 168 354 C 168 454 226 486 326 486 L 354 486 C 416 486 458 458 472 472 C 458 458 486 416 486 354 L 486 168 Z" transform="rotate(270 512 512)"/>
|
||||
</g>
|
||||
<text x="34" y="20.8" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif" font-size="12.5">
|
||||
<tspan fill="#94a3b8" font-weight="500">Running on </tspan><tspan fill="#ffffff" font-weight="700">Miabi</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
+74
-29
@@ -143,13 +143,28 @@
|
||||
}
|
||||
.entry .who { font-weight: 600; }
|
||||
.entry .when { color: var(--muted); }
|
||||
.del {
|
||||
position: absolute; top: 14px; right: 14px; cursor: pointer;
|
||||
background: transparent; border: 0; color: var(--muted); font-size: 17px; line-height: 1;
|
||||
padding: 4px 7px; border-radius: 8px; opacity: 0; transition: opacity .15s, color .15s, background .15s;
|
||||
.entry .via {
|
||||
margin-left: auto; font-size: 11.5px; color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
background: rgba(0,0,0,.2); border: 1px solid var(--card-border); padding: 2px 8px; border-radius: 999px;
|
||||
max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
li.entry:hover .del { opacity: 1; }
|
||||
.del:hover { color: var(--danger); background: rgba(244, 63, 94, 0.12); }
|
||||
li.entry.pinned { border-color: rgba(139, 92, 246, 0.55); background: rgba(139, 92, 246, 0.08); }
|
||||
.pin {
|
||||
position: absolute; top: 14px; right: 16px; font-size: 11px; font-weight: 700; letter-spacing: .06em;
|
||||
text-transform: uppercase; color: var(--accent-2);
|
||||
}
|
||||
|
||||
.banner {
|
||||
display: flex; gap: 10px; align-items: flex-start; padding: 13px 18px; margin-bottom: 18px;
|
||||
border-color: rgba(245, 158, 11, 0.45); background: rgba(245, 158, 11, 0.1); font-size: 14.5px; line-height: 1.5;
|
||||
}
|
||||
.banner .icon { flex: 0 0 auto; }
|
||||
.paused {
|
||||
text-align: center; padding: 22px 20px; margin-bottom: 14px; color: var(--muted); font-size: 15px;
|
||||
}
|
||||
.paused b { color: var(--text); }
|
||||
.replicas { font-size: 12.5px; color: var(--muted); padding: 5px 12px; border-radius: 999px;
|
||||
background: var(--card); border: 1px solid var(--card-border); white-space: nowrap; }
|
||||
|
||||
.empty, .loading { text-align: center; color: var(--muted); padding: 40px 20px; font-size: 15px; }
|
||||
|
||||
@@ -170,6 +185,8 @@
|
||||
footer { text-align: center; margin-top: 46px; color: var(--muted); font-size: 13px; line-height: 1.8; }
|
||||
footer a { color: var(--accent-2); text-decoration: none; }
|
||||
footer a:hover { text-decoration: underline; }
|
||||
.miabi-badge { display: block; width: fit-content; margin: 0 auto 16px; line-height: 0; border-radius: 16px; transition: transform .15s, box-shadow .15s; }
|
||||
.miabi-badge:hover { transform: translateY(-1px); box-shadow: 0 8px 24px -8px rgba(139, 92, 246, 0.6); }
|
||||
.stack { display: inline-flex; gap: 6px; flex-wrap: wrap; justify-content: center; }
|
||||
.pill { background: var(--card); border: 1px solid var(--card-border); padding: 3px 10px; border-radius: 999px; }
|
||||
|
||||
@@ -189,6 +206,7 @@
|
||||
<div class="logo"><span class="dot"></span> Deployed on Miabi</div>
|
||||
<div class="status">
|
||||
<span class="ver" id="ver" title="Serving version">v…</span>
|
||||
<span class="replicas" id="replicas" title="Running replicas" hidden></span>
|
||||
<span class="online"><span class="pulse"></span> <span id="online">0</span> online</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -202,6 +220,8 @@
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="card banner" id="banner" hidden><span class="icon">📣</span><span id="bannerText"></span></div>
|
||||
|
||||
<!-- Live server clock (new in v2) -->
|
||||
<div class="card clock" id="clock">
|
||||
<div class="clock-left">
|
||||
@@ -213,6 +233,7 @@
|
||||
|
||||
<!-- Home view -->
|
||||
<section id="view-home">
|
||||
<div class="card paused" id="paused" hidden>✋ <b>Signing is paused</b> by a moderator. The wall is read-only for now.</div>
|
||||
<form class="compose card" id="form">
|
||||
<div class="row">
|
||||
<input class="name-field" id="name" type="text" placeholder="Your name (optional)" maxlength="60" autocomplete="off" />
|
||||
@@ -249,14 +270,19 @@
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<a class="miabi-badge" href="https://miabi.io?ref=badge" target="_blank" rel="noopener">
|
||||
<img src="/badges/running-on-miabi-dark.svg" alt="Running on Miabi" width="156" height="32" />
|
||||
</a>
|
||||
<div class="stack">
|
||||
<span class="pill">Okapi</span>
|
||||
<span class="pill">Go</span>
|
||||
<span class="pill">SSE live</span>
|
||||
<span class="pill">Single container</span>
|
||||
<span class="pill" id="brokerPill" hidden></span>
|
||||
</div>
|
||||
<div style="margin-top:12px">
|
||||
The open-source PaaS for Docker · <a href="https://github.com/miabi-io/miabi" target="_blank" rel="noopener">github.com/miabi-io/miabi</a>
|
||||
Ship apps, not infrastructure · <a href="https://github.com/miabi-io/miabi" target="_blank" rel="noopener">github.com/miabi-io/miabi</a>
|
||||
<span id="adminLink" hidden> · <a href="/admin">Admin</a></span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -271,9 +297,9 @@
|
||||
const api = {
|
||||
info: () => fetch('/api/info').then(r => r.json()),
|
||||
time: () => fetch('/api/time').then(r => r.json()),
|
||||
settings: () => fetch('/api/settings').then(r => r.json()),
|
||||
list: (limit, offset) => fetch(`/api/entries?limit=${limit}&offset=${offset}`).then(r => r.json()),
|
||||
create: (body) => fetch('/api/entries', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }),
|
||||
remove: (id) => fetch('/api/entries/' + id, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
let total = 0; // total signatures (kept in sync via SSE)
|
||||
@@ -300,13 +326,16 @@
|
||||
}
|
||||
|
||||
function cardHTML(e) {
|
||||
return `<li class="entry card" data-id="${e.id}">
|
||||
<button class="del" title="Delete" aria-label="Delete">✕</button>
|
||||
const via = e.replica && e.replica !== 'seed'
|
||||
? `<span class="via" title="Accepted by replica ${esc(e.replica)}">via ${esc(e.replica)}</span>` : '';
|
||||
return `<li class="entry card${e.pinned ? ' pinned' : ''}" data-id="${e.id}">
|
||||
${e.pinned ? '<span class="pin">📌 Pinned</span>' : ''}
|
||||
<div class="msg">${esc(e.message)}</div>
|
||||
<div class="foot">
|
||||
<span class="avatar" style="background:${colorFor(e.name)}">${esc(initials(e.name))}</span>
|
||||
<span class="who">${esc(e.name)}</span>
|
||||
<span class="when">· ${timeAgo(e.created_at)}</span>
|
||||
${via}
|
||||
</div>
|
||||
</li>`;
|
||||
}
|
||||
@@ -332,7 +361,20 @@
|
||||
let h = 0; for (const c of String(v)) h = (h * 31 + c.charCodeAt(0)) >>> 0;
|
||||
el.style.background = `hsl(${h % 360} 70% 52%)`;
|
||||
}
|
||||
function setOnline(n) { $('online').textContent = n; }
|
||||
function setOnline(n) { $('online').textContent = n || 0; }
|
||||
function setReplicas(n) {
|
||||
if (!n) return;
|
||||
$('replicas').hidden = false;
|
||||
$('replicas').textContent = n === 1 ? '1 replica' : n + ' replicas';
|
||||
}
|
||||
function applySettings(st) {
|
||||
const banner = (st && st.banner) || '';
|
||||
$('banner').hidden = !banner;
|
||||
$('bannerText').textContent = banner;
|
||||
const paused = !!(st && st.signing_paused);
|
||||
$('paused').hidden = !paused;
|
||||
$('form').hidden = paused;
|
||||
}
|
||||
|
||||
// ---- views --------------------------------------------------------------
|
||||
async function loadHome() {
|
||||
@@ -383,21 +425,25 @@
|
||||
window.addEventListener('popstate', route);
|
||||
|
||||
// ---- live SSE events ----------------------------------------------------
|
||||
// Pinned entries stay on top, so a new signature goes right after them.
|
||||
function onCreated(entry) {
|
||||
setTotals(total + 1);
|
||||
if (currentView() === 'home') {
|
||||
const ul = $('entries');
|
||||
if (ul.querySelector('.empty')) ul.innerHTML = '';
|
||||
ul.insertAdjacentHTML('afterbegin', cardHTML(entry));
|
||||
const pins = ul.querySelectorAll('.entry.pinned');
|
||||
const html = cardHTML(entry);
|
||||
if (pins.length) pins[pins.length - 1].insertAdjacentHTML('afterend', html);
|
||||
else ul.insertAdjacentHTML('afterbegin', html);
|
||||
while (ul.children.length > HOME_SIZE) ul.lastElementChild.remove();
|
||||
} else if (allOffset === 0) {
|
||||
loadAll();
|
||||
}
|
||||
}
|
||||
function reloadView() { currentView() === 'all' ? loadAll() : loadHome(); }
|
||||
function onDeleted(id) {
|
||||
setTotals(Math.max(0, total - 1));
|
||||
document.querySelectorAll(`.entry[data-id="${id}"]`).forEach((el) => el.remove());
|
||||
if (currentView() === 'all') loadAll();
|
||||
reloadView();
|
||||
}
|
||||
|
||||
// ---- live server clock (v2) --------------------------------------------
|
||||
@@ -415,12 +461,17 @@
|
||||
clockStaleTimer = setTimeout(() => $('clock').classList.add('stale'), 4000);
|
||||
}
|
||||
|
||||
let connectedOnce = false;
|
||||
function connectSSE() {
|
||||
const es = new EventSource('/api/stream');
|
||||
es.addEventListener('welcome', (e) => { const d = JSON.parse(e.data); setVersion(d.version); setOnline(d.online); });
|
||||
es.addEventListener('presence', (e) => { const d = JSON.parse(e.data); setOnline(d.online); });
|
||||
es.addEventListener('created', (e) => { const d = JSON.parse(e.data); if (d.entry) onCreated(d.entry); });
|
||||
es.addEventListener('welcome', (e) => { const d = JSON.parse(e.data); setVersion(d.version); setOnline(d.online); setReplicas(d.replicas); });
|
||||
es.addEventListener('presence', (e) => { const d = JSON.parse(e.data); setOnline(d.online); setReplicas(d.replicas); });
|
||||
es.addEventListener('created', (e) => { const d = JSON.parse(e.data); if (d.entry && !d.entry.hidden) onCreated(d.entry); });
|
||||
es.addEventListener('updated', () => reloadView());
|
||||
es.addEventListener('deleted', (e) => { const d = JSON.parse(e.data); onDeleted(d.id); });
|
||||
es.addEventListener('settings', (e) => { const d = JSON.parse(e.data); applySettings(d.settings); });
|
||||
// Resync after a reconnect: events sent while disconnected were missed.
|
||||
es.addEventListener('open', () => { if (connectedOnce) { reloadView(); api.settings().then(applySettings).catch(() => {}); } connectedOnce = true; });
|
||||
es.addEventListener('tick', (e) => { const d = JSON.parse(e.data); showTime(d); setVersion(d.version); });
|
||||
// EventSource auto-reconnects on error; nothing to do here.
|
||||
}
|
||||
@@ -442,22 +493,16 @@
|
||||
finally { btn.disabled = false; }
|
||||
});
|
||||
|
||||
document.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('.del');
|
||||
if (!btn) return;
|
||||
const li = btn.closest('.entry');
|
||||
const id = li.dataset.id;
|
||||
li.style.opacity = '.4';
|
||||
const res = await api.remove(id);
|
||||
if (!(res.ok || res.status === 404)) { li.style.opacity = '1'; toast('Could not delete'); }
|
||||
// Removal is reflected via SSE ("deleted").
|
||||
});
|
||||
|
||||
$('prev').addEventListener('click', () => { allOffset = Math.max(0, allOffset - PAGE_SIZE); loadAll(); });
|
||||
$('next').addEventListener('click', () => { allOffset += PAGE_SIZE; loadAll(); });
|
||||
|
||||
// ---- boot ---------------------------------------------------------------
|
||||
api.info().then((d) => { setVersion(d.version); setOnline(d.online); }).catch(() => {});
|
||||
api.info().then((d) => {
|
||||
setVersion(d.version); setOnline(d.online); setReplicas((d.replicas || []).length);
|
||||
$('adminLink').hidden = !d.admin;
|
||||
if (d.broker) { $('brokerPill').hidden = false; $('brokerPill').textContent = d.broker === 'redis' ? 'Redis pub/sub' : 'In-process broker'; }
|
||||
}).catch(() => {});
|
||||
api.settings().then(applySettings).catch(() => {});
|
||||
api.time().then(showTime).catch(() => {}); // immediate clock; SSE ticks keep it live
|
||||
route();
|
||||
connectSSE();
|
||||
|
||||
Reference in New Issue
Block a user