From 3a68f64eb6a25a3f0aa4195f9d98c485e0d66b38 Mon Sep 17 00:00:00 2001 From: Akili Date: Mon, 5 Oct 2026 21:36:23 +0200 Subject: [PATCH] feat: add emoji reactions, light/dark themes and rollout observability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a set of guest-facing and operator-facing features that make the deployment-strategy demos (recreate, rolling, canary) more visual: - Emoji reactions (heart/tada/thumbsup/smile): visitors react without an account, a first-party cookie keeps counts honest, the tally streams live to every tab over SSE. Backed by a new reactions table with an idempotent upsert; whitelisted emoji so the endpoint can't be abused. - Light & dark themes: the wall follows the OS preference, visitors can toggle it (persisted locally), and a moderator can force a theme for everyone from the admin console, pushed live over SSE. - Per-replica request counter: a middleware tallies requests answered by each replica; surfaced in /healthz, /api/info and the admin replica table. Under load-balancing the counts diverge, and a rolling update resets them one container at a time — the rollout made visible. - Rolling-update wave in the Chaos lab: bounces replicas one at a time (via the crash endpoint) while polling the replica table, so a training session can watch each replica reset and rejoin — what a rolling update looks like from the inside. - Admin console: reactions tile, per-replica Requests column, reaction tallies in moderation, and a wall-theme setting. - Favicon + /favicon.ico redirect. Tests cover reactions (idempotency, tallies, whitelist) and the theme setting round-trip. Akili-Task: tsk_xgg6sfuzkzymyc6gstr4 Akili-Agent: goldfish --- .env.example | 10 ++- DEMO.md | 41 ++++++++---- README.md | 61 +++++++++++------ admin.go | 9 +++ broker.go | 37 +++++++---- broker_test.go | 2 +- handlers.go | 32 +++++---- main.go | 13 +++- reactions.go | 111 +++++++++++++++++++++++++++++++ reactions_test.go | 99 ++++++++++++++++++++++++++++ store.go | 157 +++++++++++++++++++++++++++++++++++++++++--- web/admin.html | 93 +++++++++++++++++++++++++- web/badges/icon.svg | 11 ++++ web/index.html | 132 +++++++++++++++++++++++++++++++++++-- 14 files changed, 726 insertions(+), 82 deletions(-) create mode 100644 reactions.go create mode 100644 reactions_test.go create mode 100644 web/badges/icon.svg diff --git a/.env.example b/.env.example index eeb865a..a2284d6 100644 --- a/.env.example +++ b/.env.example @@ -45,13 +45,17 @@ DATABASE_URL=postgres://postgres:postgres@db:5432/guestbook?sslmode=disable # REDIS_URL=redis://redis:6379 # === Admin console ================= -# Enables /admin. Store as a secret. Generate with: openssl rand -hex 16 +# Enables /admin: overview (stats, replicas with per-replica request counters), +# moderation (pin / hide / delete, reaction tallies, CSV export) and wall +# settings (pause signing, banner, forced light/dark theme). +# Store the token 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. +# Expose /api/debug/* failure injection (crash, slow, 5xx, CPU, memory, logs) +# and the admin "rolling-update wave" tool. Still requires admin auth. +# Demo environments only. # DEBUG_ENDPOINTS=false diff --git a/DEMO.md b/DEMO.md index 926d4f9..8e2a545 100644 --- a/DEMO.md +++ b/DEMO.md @@ -15,7 +15,7 @@ handy for logs, events and scaling. | 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 | +| 6 | Break it on purpose (+ rolling wave) | restarts, health gating, alerts, logs, analytics | | 7 | Canary v1 → v2 | canary weights, promote | | 8 | Scheduled cleanup | cron jobs | @@ -55,23 +55,24 @@ docker push /guestbook:1.0.0 && docker push /guestbook:2.0.0 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. + - **Overview**: signatures per day, total reactions, replicas (with each one's **request counter**), and *this replica*'s runtime (host, DB, uptime, memory). + - **Moderation**: each signature shows the signer's IP, forwarded by the gateway, plus its reaction tally. Click the IP 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 👋"), pick a **wall theme** (force light or dark — every open tab flips instantly), 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. +**online** counter climbs live. Have them tap the ❤️ / 🎉 reactions — the +tallies move on every screen at once, with no refresh. ## 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. +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. Reactions are the same: a ❤️ on one host never shows on the other. +4. Also note the `via replica-x` tag on new signatures: each one records which replica accepted it. And `curl -s https:///healthz` a few times — the `requests` counter climbs on whichever replica answered, so you can watch the load-balancer spread traffic even though live updates stay broken. > 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 @@ -81,8 +82,8 @@ Ask the audience to open the wall on their phones and sign it. The 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. +3. Repeat the two-browser test: signatures **and reactions** now appear everywhere. The **Replicas** table lists all 3 hosts with their client counts **and request counters**, and the wall header says *3 replicas*. +4. **Chaos lab → Traffic probe** → *Run probe*: the bars show requests spread across the three hosts. Compare with the per-replica `requests` counters in the table — they tell the same story server-side. ## 6. Break it on purpose @@ -111,15 +112,29 @@ curl -XPOST -H "Authorization: Bearer $T" "$D/api/debug/memory?mb=400&seconds=30 Remember to click **Healthy** afterwards, or redeploy. +### The rolling-update wave + +Still in the Chaos lab, **Rolling-update wave → Bounce each replica, one at a +time** restarts the fleet exactly like a rolling update does, but driven by the +crash endpoint. Keep the **Replicas** table in view: each replica disappears, +comes back, and its **request counter restarts from ~0** while the others keep +climbing. The wall never goes down — which is the whole point of rolling +updates, shown from the inside. + +> Talking point: recreate would drop every replica at once (all counters would +> reset together, with a gap of downtime). Rolling keeps capacity by replacing +> one at a time. Same mechanism, two strategies. + ## 7. Canary v1 → v2 1. In a terminal: `scripts/hammer.sh https://`. 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. +3. On the wall, reload a few times: ~1 in 10 loads shows the **v2 badge** in a different colour **and the new live server-time card**. In the admin **Traffic probe**, the bars split by version; in the **Replicas** table, the two versions sit side by side with their own request counters. 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. +> differ for the demo to work — but the clock card (absent in v1) makes the +> split visible to the audience without a terminal. ## 8. Scheduled cleanup @@ -141,6 +156,6 @@ want migrations as an explicit step rather than on boot. ## Reset between sessions -- Admin → Settings: clear the banner, un-pause. +- Admin → Settings: clear the banner, set the theme back to *System*, 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). +- To wipe the wall: detach/recreate the database, or `DELETE FROM entries; DELETE FROM reactions;` and restart (the seed runs when the table is empty). diff --git a/README.md b/README.md index 52837b6..1c05b31 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,17 @@ miniature: routes keep precedence - **Live updates over SSE** — new/removed signatures stream to every open tab, with a real-time **connected-clients** ("N online") indicator +- **Emoji reactions** — visitors cheer a signature with ❤️ 🎉 👍 😄 (no + account; a cookie keeps counts honest) and the tally streams live to every + tab. Each reaction counts against a whitelist, so the endpoint can't be + used to spam arbitrary emoji +- **Light & dark themes** — the wall follows the OS preference, visitors can + toggle it, and a moderator can **force a theme for everyone** from the admin + console (pushed live over SSE) +- **Per-replica request counter** — `/healthz`, `/api/info` and the admin + replica table report how many requests *this* replica has answered since + boot. Under load-balancing the counts diverge; a rolling update resets them + one container at a time, which makes the rollout visible from the outside - **Version badge + live server-time card** — the serving build's version is shown in the UI and `/api/info`, and a **live clock** (streamed over SSE with the server's `version` + `host`) makes a **canary rollout** obvious: the card @@ -34,11 +45,14 @@ miniature: 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 + overview (stats, replicas with per-replica request counters, runtime), + moderation (pin / hide / delete, signer IP, reaction tallies, CSV export), + wall settings (pause signing, announcement banner, forced wall theme) — 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 + hold memory, burst logs, inject latency/5xx, a **rolling-update wave** that + bounces replicas one at a time while the replica table shows each one reset, + 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 @@ -60,13 +74,15 @@ and keeps the container tiny. | Method | Path | Description | |----------|----------------------|------------------------------------| -| `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` | `/healthz` | Liveness/readiness (checks the DB), reports `version`, `host`, `redis`, `requests` | +| `GET` | `/api/info` | App name, serving `version`, `host`, online count, `broker` mode, `replicas` (with per-replica request counts), `requests`, reaction whitelist | | `GET` | `/api/time` | Current server time + `version` + `host` (v2 — canary probe) | -| `GET` | `/api/settings` | Public wall settings: `signing_paused`, `banner` | -| `GET` | `/api/entries` | Visible entries (pinned first) + total; paginated via `?limit=&offset=` | +| `GET` | `/api/settings` | Public wall settings: `signing_paused`, `banner`, `theme` | +| `GET` | `/api/entries` | Visible entries (pinned first) with reaction tallies + 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 | +| `POST` | `/api/entries/{id}/reactions?emoji=` | Add this browser's reaction (`heart`·`tada`·`thumbsup`·`smile`); idempotent, broadcasts the new tally | +| `DELETE` | `/api/entries/{id}/reactions?emoji=` | Remove this browser's reaction | +| `GET` | `/api/stream` | **SSE** stream: `welcome`, `created`, `updated`, `deleted`, `reaction`, `settings`, `presence`, `tick` events | | `GET` | `/` · `/all` · `/admin` | The web UI (home · all signatures · admin console) | ### Admin API @@ -77,11 +93,11 @@ or an `Authorization: Bearer ` header. Disabled (`404`) when | 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=` | +| `GET` | `/api/admin/overview` | Stats (14-day histogram, reaction totals), replicas (with per-replica request counts), runtime of the answering replica | +| `GET` | `/api/admin/entries` | All entries with reaction tallies; `?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`·`PUT` | `/api/admin/settings` | `{ "signing_paused": bool, "banner": string, "theme": "system\|light\|dark" }` | | `GET` | `/api/admin/export.csv` | Every entry as CSV | ### Debug API (chaos) @@ -103,16 +119,19 @@ replica that receives it and echoes its `host`. `/api/stream` is a Server-Sent Events endpoint. On connect the client gets a `welcome` event (serving version + host + online count); thereafter the server -pushes `created` / `deleted` events as the wall changes, `presence` events when -the connected-client count changes, and a `tick` event once a second carrying -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. +pushes `created` / `updated` / `deleted` events as the wall changes, `reaction` +events as tallies move, `settings` events when a moderator pauses signing, +changes the banner or forces a theme, `presence` events when the +connected-client count changes, and a `tick` event once a second carrying the +live server time (with `version` + `host`). The UI uses these to update the +wall in real time, show a live **“N online”** indicator, stream emoji-reaction +tallies, 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. +With `REDIS_URL` set, `created`/`updated`/`deleted`/`reaction`/`settings` +events go through a Redis pub/sub channel so every replica delivers them, and +each replica advertises its client count **and request 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 diff --git a/admin.go b/admin.go index 9d30dc4..26eec64 100644 --- a/admin.go +++ b/admin.go @@ -93,6 +93,7 @@ func (h *Handler) AdminOverview(c *okapi.Context) error { "healthy": !h.unhealthy.Load(), "started_at": h.started.UTC().Format(time.RFC3339), "uptime_s": int64(time.Since(h.started).Seconds()), + "requests": h.requests.Load(), "go": runtime.Version(), "cpus": runtime.NumCPU(), "goroutines": runtime.NumGoroutine(), @@ -167,6 +168,14 @@ func (h *Handler) AdminSaveSettings(c *okapi.Context) error { if len(st.Banner) > maxBannerLen { return c.JSON(http.StatusBadRequest, okapi.M{"error": "banner is too long (max 200 characters)"}) } + switch st.Theme { + case "light", "dark": + // forced appearance for every visitor + case "", "system": + st.Theme = "" // follow each visitor's preference + default: + return c.JSON(http.StatusBadRequest, okapi.M{"error": "theme must be system, light or dark"}) + } if err := h.store.SaveSettings(c.Request().Context(), st); err != nil { return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not save settings"}) } diff --git a/broker.go b/broker.go index a10285f..503d731 100644 --- a/broker.go +++ b/broker.go @@ -17,13 +17,16 @@ import ( // - "created" → Entry is set (a new signature) // - "updated" → Entry is set (pinned/hidden by a moderator) // - "deleted" → ID is set (a removed signature) -// - "settings" → Settings is set (signing paused, banner changed) +// - "reaction" → ID/Emoji/Count are set (an entry's tally changed) +// - "settings" → Settings is set (signing paused, banner changed, theme forced) // - "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"` + Emoji string `json:"emoji,omitempty"` + Count int `json:"count,omitempty"` Settings *Settings `json:"settings,omitempty"` Online int `json:"online,omitempty"` Replicas int `json:"replicas,omitempty"` @@ -33,11 +36,15 @@ type Event struct { } // Replica describes one running instance of the app and its SSE clients. +// Requests is the number of HTTP requests it has answered since boot: under +// load-balancing the counts diverge, and a rollout resets them one replica at +// a time — the rolling update made visible. type Replica struct { - Host string `json:"host"` - Version string `json:"version"` - Online int `json:"online"` - Seen time.Time `json:"seen"` + Host string `json:"host"` + Version string `json:"version"` + Online int `json:"online"` + Requests int64 `json:"requests"` + Seen time.Time `json:"seen"` } const ( @@ -61,6 +68,7 @@ type Broker struct { host, version string rdb *redis.Client dirty chan struct{} + requests func() int64 cmu sync.RWMutex replicas []Replica @@ -68,12 +76,15 @@ type Broker struct { } // NewBroker creates an in-process Broker. Call UseRedis to make it cluster-wide. -func NewBroker(host, version string) *Broker { +// requests reports how many HTTP requests this replica has served so far (may +// be nil); it is advertised with the presence heartbeat. +func NewBroker(host, version string, requests func() int64) *Broker { return &Broker{ - subs: make(map[chan Event]struct{}), - host: host, - version: version, - dirty: make(chan struct{}, 1), + subs: make(map[chan Event]struct{}), + host: host, + version: version, + dirty: make(chan struct{}, 1), + requests: requests, } } @@ -203,7 +214,11 @@ func (b *Broker) Replicas() []Replica { } func (b *Broker) self() Replica { - return Replica{Host: b.host, Version: b.version, Online: b.localCount(), Seen: time.Now().UTC()} + var reqs int64 + if b.requests != nil { + reqs = b.requests() + } + return Replica{Host: b.host, Version: b.version, Online: b.localCount(), Requests: reqs, Seen: time.Now().UTC()} } func (b *Broker) localCount() int { diff --git a/broker_test.go b/broker_test.go index 103dc11..59416a6 100644 --- a/broker_test.go +++ b/broker_test.go @@ -6,7 +6,7 @@ import ( ) func TestLocalBroker(t *testing.T) { - b := NewBroker("host-a", "1.0.0") + b := NewBroker("host-a", "1.0.0", nil) ch := b.Subscribe() defer b.Unsubscribe(ch) diff --git a/handlers.go b/handlers.go index e98f967..87b7c03 100644 --- a/handlers.go +++ b/handlers.go @@ -28,6 +28,9 @@ type Handler struct { unhealthy atomic.Bool chaos chaosState + // requests counts HTTP requests answered by this replica since boot. A + // rolling update resets it one container at a time; see countRequests. + requests atomic.Int64 } // serverTime is the shape of the live clock payload (v2 feature). It carries @@ -71,12 +74,13 @@ func (h *Handler) Health(c *okapi.Context) error { }) } 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, + "status": "ok", + "db": "up", + "redis": h.broker.RedisStatus(c.Request().Context()), + "app": h.appName, + "version": h.version, + "host": h.host, + "requests": h.requests.Load(), }) } @@ -85,13 +89,15 @@ 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, - "broker": h.broker.Mode(), - "replicas": h.broker.Replicas(), - "admin": h.auth.Enabled(), + "app": h.appName, + "version": h.version, + "online": h.broker.Online(), + "host": h.host, + "broker": h.broker.Mode(), + "replicas": h.broker.Replicas(), + "requests": h.requests.Load(), + "admin": h.auth.Enabled(), + "reactions": ReactionEmojis(), }) } diff --git a/main.go b/main.go index 531ec2b..0eb1abd 100644 --- a/main.go +++ b/main.go @@ -85,7 +85,7 @@ func runServer(cmd *okapicli.Command, cfg Config) error { } host, _ := os.Hostname() - broker := NewBroker(host, cfg.Version) + broker := NewBroker(host, cfg.Version, nil) if cfg.RedisURL != "" { if err := broker.UseRedis(cfg.RedisURL); err != nil { return err @@ -106,6 +106,7 @@ func runServer(cmd *okapicli.Command, cfg Config) error { debug: cfg.DebugEndpoints, started: time.Now(), } + broker.requests = h.requests.Load if !h.auth.Enabled() { logger.Warn("admin console disabled: set ADMIN_TOKEN to enable /admin") } @@ -116,7 +117,7 @@ func runServer(cmd *okapicli.Command, cfg Config) error { app := cmd.Okapi() app.WithPort(cmd.GetInt("port")) app.WithDebug() - app.Use(okapi.LoggerMiddleware, okapi.RequestID()) + app.Use(okapi.LoggerMiddleware, okapi.RequestID(), h.countRequests) registerRoutes(app, h) return cmd.CLI().RunServer(&okapicli.RunOptions{ @@ -144,6 +145,8 @@ func registerRoutes(app *okapi.Okapi, h *Handler) { api.Get("/settings", h.PublicSettings) api.Get("/entries", h.ListEntries) api.Post("/entries", h.CreateEntry) + api.Post("/entries/{id:int}/reactions", h.React) + api.Delete("/entries/{id:int}/reactions", h.Unreact) api.Get("/stream", h.Stream) api.Get("/admin/session", h.AdminSession) @@ -176,6 +179,12 @@ func registerRoutes(app *okapi.Okapi, h *Handler) { return c.Data(http.StatusOK, "text/html; charset=utf-8", adminPage) }) + // Browsers ask for /favicon.ico on their own; the logo serves as the icon. + app.Get("/favicon.ico", func(c *okapi.Context) error { + c.Redirect(http.StatusFound, "/badges/icon.svg") + return nil + }) + // 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"}) diff --git a/reactions.go b/reactions.go new file mode 100644 index 0000000..743b417 --- /dev/null +++ b/reactions.go @@ -0,0 +1,111 @@ +package main + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "net/http" + "strconv" + "time" + + "github.com/jkaninda/okapi" + "gorm.io/gorm" +) + +// Reactions let visitors cheer a signature without an account: one tap adds, +// tapping again removes, and the tally streams to every open tab over SSE. +// A first-party cookie identifies the browser so a count can't be inflated by +// hammering the endpoint. + +const ( + reactionCookie = "gb_reactor" + reactionCookieTTL = 365 * 24 * time.Hour +) + +// reactorID returns the stable, anonymous id of this browser, setting the +// cookie on first sight. +func reactorID(c *okapi.Context) string { + if id, err := c.Cookie(reactionCookie); err == nil && len(id) >= 8 && len(id) <= 64 { + return id + } + buf := make([]byte, 16) + if _, err := rand.Read(buf); err != nil { + return "" + } + id := hex.EncodeToString(buf) + r := c.Request() + http.SetCookie(c.ResponseWriter(), &http.Cookie{ + Name: reactionCookie, + Value: id, + Path: "/", + MaxAge: int(reactionCookieTTL.Seconds()), + HttpOnly: true, + Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https", + SameSite: http.SameSiteLaxMode, + }) + return id +} + +// parseReactionRequest validates the {id} path param and the ?emoji= query. +func parseReactionRequest(c *okapi.Context) (uint64, string, error) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + return 0, "", errors.New("invalid id") + } + emoji := c.Query("emoji") + if !validReaction(emoji) { + return 0, "", errors.New("unknown emoji (want one of: heart, tada, thumbsup, smile)") + } + return id, emoji, nil +} + +// React adds a reaction to an entry and broadcasts the new tally live. +func (h *Handler) React(c *okapi.Context) error { + ctx := c.Request().Context() + id, emoji, err := parseReactionRequest(c) + if err != nil { + return c.JSON(http.StatusBadRequest, okapi.M{"error": err.Error()}) + } + if _, err := h.store.Get(ctx, id); errors.Is(err, gorm.ErrRecordNotFound) { + return c.JSON(http.StatusNotFound, okapi.M{"error": "entry not found"}) + } + who := reactorID(c) + if who == "" { + return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not identify the browser"}) + } + count, err := h.store.React(ctx, id, emoji, who) + if err != nil { + return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not save the reaction"}) + } + h.broker.Publish(Event{Type: "reaction", ID: uint(id), Emoji: emoji, Count: count}) + return c.OK(okapi.M{"entry_id": id, "emoji": emoji, "count": count}) +} + +// Unreact removes the browser's reaction and broadcasts the new tally live. +func (h *Handler) Unreact(c *okapi.Context) error { + ctx := c.Request().Context() + id, emoji, err := parseReactionRequest(c) + if err != nil { + return c.JSON(http.StatusBadRequest, okapi.M{"error": err.Error()}) + } + who := reactorID(c) + if who == "" { + return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not identify the browser"}) + } + count, err := h.store.Unreact(ctx, id, emoji, who) + if err != nil { + return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not remove the reaction"}) + } + h.broker.Publish(Event{Type: "reaction", ID: uint(id), Emoji: emoji, Count: count}) + return c.OK(okapi.M{"entry_id": id, "emoji": emoji, "count": count}) +} + +// countRequests is middleware that tallies the HTTP requests this replica has +// answered since it started. The number lands in /healthz, /api/info and the +// admin replica table: it grows per replica under load-balancing and drops +// back to ~0 when a rollout replaces a container, which makes recreate and +// rolling updates visible from the outside. +func (h *Handler) countRequests(c *okapi.Context) error { + h.requests.Add(1) + return c.Next() +} diff --git a/reactions_test.go b/reactions_test.go new file mode 100644 index 0000000..d587244 --- /dev/null +++ b/reactions_test.go @@ -0,0 +1,99 @@ +package main + +import ( + "context" + "testing" +) + +func TestReactions(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + + e, err := s.Create(ctx, "ada", "hello miabi", "r1", "") + if err != nil { + t.Fatal(err) + } + + // Two visitors react, one of them twice (idempotent). + for _, tc := range []struct { + who, emoji string + want int + }{ + {"alice", "heart", 1}, + {"bob", "heart", 2}, + {"bob", "heart", 2}, + {"alice", "tada", 1}, + } { + got, err := s.React(ctx, uint64(e.ID), tc.emoji, tc.who) + if err != nil { + t.Fatal(err) + } + if got != tc.want { + t.Fatalf("React(%s, %s) = %d, want %d", tc.who, tc.emoji, got, tc.want) + } + } + + counts, err := s.ReactionCounts(ctx, uint64(e.ID)) + if err != nil { + t.Fatal(err) + } + if counts["heart"] != 2 || counts["tada"] != 1 { + t.Fatalf("counts = %+v, want heart=2 tada=1", counts) + } + + // Unreacting twice is a no-op after the first removal. + if got, _ := s.Unreact(ctx, uint64(e.ID), "heart", "bob"); got != 1 { + t.Fatalf("unreact = %d, want 1", got) + } + if got, _ := s.Unreact(ctx, uint64(e.ID), "heart", "bob"); got != 1 { + t.Fatalf("second unreact = %d, want 1", got) + } + + // List and Get attach the tally; totals count every reaction. + wall, err := s.List(ctx, 10, 0) + if err != nil || len(wall) != 1 { + t.Fatalf("list: %v (%d entries)", err, len(wall)) + } + if wall[0].Reactions["heart"] != 1 || wall[0].Reactions["tada"] != 1 { + t.Fatalf("list reactions = %+v", wall[0].Reactions) + } + one, err := s.Get(ctx, uint64(e.ID)) + if err != nil || one.Reactions["heart"] != 1 { + t.Fatalf("get reactions = %+v, err=%v", one.Reactions, err) + } + if n, _ := s.ReactionTotals(ctx); n != 2 { + t.Fatalf("totals = %d, want 2", n) + } + + // Deleting a reaction must not touch the entry itself. + if n, _ := s.Count(ctx); n != 1 { + t.Fatalf("entries = %d, want 1", n) + } +} + +func TestReactionWhitelist(t *testing.T) { + for _, emoji := range ReactionEmojis() { + if !validReaction(emoji) { + t.Fatalf("whitelist rejects its own emoji %q", emoji) + } + } + if validReaction("poop") || validReaction("") { + t.Fatal("whitelist accepted a non-whitelisted emoji") + } +} + +func TestSettingsTheme(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + + if st, _ := s.Settings(ctx); st.Theme != "" { + t.Fatalf("default theme = %q, want empty (system)", st.Theme) + } + want := Settings{SigningPaused: false, Banner: "hi", Theme: "light"} + if err := s.SaveSettings(ctx, want); err != nil { + t.Fatal(err) + } + if got, _ := s.Settings(ctx); got != want { + t.Fatalf("got %+v, want %+v", got, want) + } +} diff --git a/store.go b/store.go index b6f35f2..0e54e54 100644 --- a/store.go +++ b/store.go @@ -13,6 +13,7 @@ import ( "github.com/jkaninda/logger" "gorm.io/driver/postgres" "gorm.io/gorm" + "gorm.io/gorm/clause" ) // Entry is a single signature on the guestbook wall. @@ -27,6 +28,21 @@ type Entry struct { CreatedAt time.Time `gorm:"index" json:"created_at"` UpdatedAt time.Time `json:"-"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + // Reactions carries the per-emoji tally (e.g. {"heart":3,"tada":1}); it is + // filled by list endpoints, never stored on the entries table itself. + Reactions map[string]int `gorm:"-" json:"reactions,omitempty"` +} + +// Reaction is one emoji reaction to an entry. Visitors react and un-react +// without an account, so a short-lived cookie ("who") prevents the same +// browser from inflating the count. +type Reaction struct { + EntryID uint `gorm:"primaryKey;autoIncrement:false" json:"-"` + Emoji string `gorm:"primaryKey;type:varchar(16)" json:"-"` + Who string `gorm:"primaryKey;type:varchar(64)" json:"-"` + CreatedAt time.Time `json:"-"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` } // Setting is a key/value row holding app-wide state shared by all replicas. @@ -40,6 +56,9 @@ type Setting struct { type Settings struct { SigningPaused bool `json:"signing_paused"` Banner string `json:"banner"` + // Theme is the wall appearance forced by a moderator: "" (or "system"), + // "light" or "dark". Visitors can still override it locally. + Theme string `json:"theme"` } // EntryFilter selects entries for the admin console. @@ -56,14 +75,31 @@ type DayCount struct { Count int `json:"count"` } +// Emojis a visitor can react with; also the whitelist for the API. +var reactionEmojis = []string{"heart", "tada", "thumbsup", "smile"} + +// ReactionEmojis returns the whitelist, for the info endpoint. +func ReactionEmojis() []string { return reactionEmojis } + +// validReaction reports whether emoji is on the whitelist. +func validReaction(emoji string) bool { + for _, e := range reactionEmojis { + if e == emoji { + return true + } + } + return false +} + // 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"` + Total int64 `json:"total"` + Visible int64 `json:"visible"` + Hidden int64 `json:"hidden"` + Pinned int64 `json:"pinned"` + Today int64 `json:"today"` + Reactions int64 `json:"reactions"` + Daily []DayCount `json:"daily"` } const settingsKey = "settings" @@ -140,7 +176,7 @@ func (s *Store) Ping(ctx context.Context) error { // concurrent AutoMigrate calls race on Postgres catalog inserts. func (s *Store) Migrate() error { return s.Exclusive(context.Background(), func(tx *Store) error { - return tx.db.AutoMigrate(&Entry{}, &Setting{}) + return tx.db.AutoMigrate(&Entry{}, &Setting{}, &Reaction{}) }) } @@ -160,7 +196,10 @@ func (s *Store) List(ctx context.Context, limit, offset int) ([]Entry, error) { Limit(limit). Offset(offset). Find(&entries).Error - return entries, err + if err != nil { + return nil, err + } + return entries, s.loadReactions(ctx, entries) } // Create inserts a new entry and returns it with its generated id/timestamp. @@ -170,6 +209,96 @@ func (s *Store) Create(ctx context.Context, name, message, replica, ip string) ( return e, err } +// loadReactions fills the Reactions map of every entry in one grouped query. +func (s *Store) loadReactions(ctx context.Context, entries []Entry) error { + ids := make([]uint, len(entries)) + for i, e := range entries { + ids[i] = e.ID + entries[i].Reactions = map[string]int{} + } + if len(ids) == 0 { + return nil + } + var rows []struct { + EntryID uint + Emoji string + N int + } + if err := s.db.WithContext(ctx).Model(&Reaction{}). + Select("entry_id, emoji, count(*) AS n"). + Where("entry_id IN ?", ids). + Group("entry_id, emoji"). + Scan(&rows).Error; err != nil { + return err + } + byID := make(map[uint]int, len(entries)) + for i, e := range entries { + byID[e.ID] = i + } + for _, r := range rows { + if i, ok := byID[r.EntryID]; ok { + entries[i].Reactions[r.Emoji] = r.N + } + } + return nil +} + +// React registers a reaction and returns the emoji's new total for the entry. +// It is idempotent for a given (entry, emoji, who): reacting twice is a no-op. +func (s *Store) React(ctx context.Context, entryID uint64, emoji, who string) (int, error) { + err := s.db.WithContext(ctx). + Clauses(clause.OnConflict{DoNothing: true}). + Create(&Reaction{EntryID: uint(entryID), Emoji: emoji, Who: who}).Error + if err != nil { + return 0, err + } + return s.reactionTotal(ctx, entryID, emoji) +} + +// Unreact removes a reaction (idempotent) and returns the emoji's new total. +func (s *Store) Unreact(ctx context.Context, entryID uint64, emoji, who string) (int, error) { + err := s.db.WithContext(ctx). + Where("entry_id = ? AND emoji = ? AND who = ?", entryID, emoji, who). + Delete(&Reaction{}).Error + if err != nil { + return 0, err + } + return s.reactionTotal(ctx, entryID, emoji) +} + +func (s *Store) reactionTotal(ctx context.Context, entryID uint64, emoji string) (int, error) { + var n int64 + err := s.db.WithContext(ctx).Model(&Reaction{}). + Where("entry_id = ? AND emoji = ?", entryID, emoji). + Count(&n).Error + return int(n), err +} + +// ReactionCounts returns the per-emoji tally for one entry. +func (s *Store) ReactionCounts(ctx context.Context, entryID uint64) (map[string]int, error) { + out := map[string]int{} + var rows []struct { + Emoji string + N int + } + err := s.db.WithContext(ctx).Model(&Reaction{}). + Select("emoji, count(*) AS n"). + Where("entry_id = ?", entryID). + Group("emoji"). + Scan(&rows).Error + for _, r := range rows { + out[r.Emoji] = r.N + } + return out, err +} + +// ReactionTotals returns the total number of reactions across the wall. +func (s *Store) ReactionTotals(ctx context.Context) (int64, error) { + var n int64 + err := s.db.WithContext(ctx).Model(&Reaction{}).Count(&n).Error + return n, err +} + // Delete soft-deletes an entry by id. Returns true when a row was affected. func (s *Store) Delete(ctx context.Context, id uint64) (bool, error) { res := s.db.WithContext(ctx).Delete(&Entry{}, id) @@ -206,10 +335,14 @@ func (s *Store) CountVisible(ctx context.Context) (int64, error) { return n, err } -// Get returns a single entry by id. +// Get returns a single entry by id, with its reaction counts. func (s *Store) Get(ctx context.Context, id uint64) (Entry, error) { var e Entry err := s.db.WithContext(ctx).First(&e, id).Error + if err != nil { + return e, err + } + e.Reactions, err = s.ReactionCounts(ctx, id) return e, err } @@ -235,7 +368,10 @@ func (s *Store) AdminList(ctx context.Context, f EntryFilter) ([]Entry, int64, e } var entries []Entry err := q.Order("created_at DESC").Limit(f.Limit).Offset(f.Offset).Find(&entries).Error - return entries, total, err + if err != nil { + return nil, 0, err + } + return entries, total, s.loadReactions(ctx, entries) } // SetFlags updates the moderation flags that are non-nil and returns the entry. @@ -274,6 +410,7 @@ func (s *Store) Stats(ctx context.Context, days int) (Stats, error) { } s.db.WithContext(ctx).Model(&Entry{}).Where("hidden = ?", true).Count(&st.Hidden) s.db.WithContext(ctx).Model(&Entry{}).Where("pinned = ?", true).Count(&st.Pinned) + s.db.WithContext(ctx).Model(&Reaction{}).Count(&st.Reactions) st.Visible = st.Total - st.Hidden now := time.Now().UTC() diff --git a/web/admin.html b/web/admin.html index f06d4db..c6bfef2 100644 --- a/web/admin.html +++ b/web/admin.html @@ -219,6 +219,7 @@
Replicas
–
Signatures
–
Today
–
+
Reactions
–
Pinned
–
Hidden
–
@@ -232,7 +233,7 @@

Replicas

- +
HostVersionClientsSeen
HostVersionClientsRequestsSeen
@@ -278,6 +279,15 @@ + +
+ +
+

Pushed live to every open tab. Visitors can toggle light/dark themselves unless you force one here.

@@ -348,6 +358,15 @@
+
+

Rolling-update wave

+

Watch what Miabi does during a rolling update: this tool bounces one replica at a time with the chaos endpoint while polling the replica table. Each restarted replica comes back with its request counter reset and, during a canary, a new version. Needs debug endpoints; run it with Redis attached so every replica is listed.

+
+ + +
+
+

Activity

Actions and their responses appear here.
@@ -421,6 +440,7 @@ if (!r.ok || !r.data) return; overview = r.data; const { stats, runtime: rt, replicas, online, debug } = overview; + $('waveRun').disabled = !debug; $('servedBy').textContent = rt.host; $('appName').textContent = rt.app; setVersion(rt.version); @@ -428,18 +448,20 @@ $('tReplicas').textContent = replicas.length; $('tTotal').textContent = stats.total; $('tToday').textContent = stats.today; + $('tReacts').textContent = stats.reactions ?? 0; $('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.' + ? 'Presence is shared through Redis, so every replica is listed. The requests counter grows per replica and resets when a rollout replaces it.' : 'In-process broker: only this replica is visible. Attach Redis (REDIS_URL) to share live updates across replicas.'; $('replicaRows').innerHTML = replicas.map((r) => ` ${esc(r.host)} v${esc(r.version)} ${r.online} + ${r.requests ?? '–'} ${ago(r.seen)} `).join(''); @@ -452,6 +474,7 @@ ['Live updates', rt.broker === 'redis' ? 'Redis pub/sub' : 'in-process'], ['Redis', status(rt.redis)], ['Uptime', uptime(rt.uptime_s)], + ['Requests served', rt.requests ?? 0], ['Go', `${esc(rt.go)} · ${rt.cpus} CPU`], ['Goroutines', rt.goroutines], ['Heap / Sys', `${rt.heap_mb.toFixed(1)} / ${rt.sys_mb.toFixed(1)} MiB`], @@ -496,6 +519,8 @@ function rowHTML(e) { const rep = e.replica ? `${esc(e.replica)}` : ''; const ip = e.ip ? `${esc(e.ip)}` : ''; + const reacts = Object.entries(e.reactions || {}).map(([k, n]) => `${({ heart: '❤️', tada: '🎉', thumbsup: '👍', smile: '😄' })[k] || k} ${n}`).join(' · '); + const reactsChip = reacts ? `${esc(reacts)}` : ''; return `
${esc(e.name)} @@ -503,6 +528,7 @@ #${e.id} ${e.pinned ? 'Pinned' : ''} ${e.hidden ? 'Hidden' : ''} + ${reactsChip} ${ip} ${rep}
@@ -552,14 +578,16 @@ if (!r.ok) return; $('paused').checked = !!r.data.signing_paused; $('banner').value = r.data.banner || ''; + $('theme').value = r.data.theme || 'system'; $('bannerCount').textContent = $('banner').value.length + ' / 200'; } async function saveSettings() { - const r = await req('PUT', '/api/admin/settings', { signing_paused: $('paused').checked, banner: $('banner').value }); + const r = await req('PUT', '/api/admin/settings', { signing_paused: $('paused').checked, banner: $('banner').value, theme: $('theme').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); + $('theme').addEventListener('change', saveSettings); $('settingsForm').addEventListener('submit', (e) => { e.preventDefault(); saveSettings(); }); // ---- chaos lab --------------------------------------------------------- @@ -615,6 +643,65 @@ log('gave up watching after 120s', 'w'); } + // ---- rolling-update wave ----------------------------------------------- + // Crashes replicas one at a time and waits for each to rejoin (its request + // counter restarts from ~0), mimicking — and explaining — what the platform + // does during a rolling update. Self-healing does the rest. + function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } + async function fetchOverviewQuiet() { + try { + const res = await fetch('/api/admin/overview', { cache: 'no-store' }); + return res.ok ? await res.json() : null; + } catch { return null; } + } + $('waveRun').addEventListener('click', async () => { + if (!overview || !overview.debug) return; + if (!confirm('Restart every replica, one at a time, using the crash endpoint? The app self-heals — this is what a rolling update looks like from the inside.')) return; + $('waveRun').disabled = true; + $('waveStat').hidden = false; + try { + // Snapshot the fleet (in local mode this is just the answering replica). + const first = await fetchOverviewQuiet(); + const targets = [...new Set((first ? first.replicas : []).map((r) => r.host))]; + if (!targets.length) targets.push((first && first.runtime.host) || ''); + let bounced = 0, hops = 0; + while (bounced < targets.length && hops < targets.length * 3 + 3) { + hops++; + const ov = await fetchOverviewQuiet(); + if (!ov) { await sleep(1500); continue; } + const host = ov.runtime.host; + const idx = targets.indexOf(host); + if (idx < 0) { // already-bounced or unknown replica answered: keep hunting + $('waveStat').textContent = `looking for ${targets[bounced]}… (at ${host})`; + await req('POST', '/api/debug/crash'); // bounce it too, then move on + await sleep(4000); + continue; + } + targets.splice(idx, 1); + $('waveStat').textContent = `wave ${bounced + 1} → bouncing ${host}`; + log(`wave ${bounced + 1}: crashing ${host} (served ${ov.runtime.requests ?? '?'} requests since boot)`, 'w'); + await req('POST', '/api/debug/crash'); + // Wait until a *different* replica (or the same name with a fresh + // counter) answers again — that is the restart made visible. + const deadline = Date.now() + 90000; + let back = false; + while (Date.now() < deadline) { + await sleep(1500); + const cur = await fetchOverviewQuiet(); + if (!cur) continue; + if (cur.runtime.host !== host || (cur.runtime.requests ?? 0) < (ov.runtime.requests ?? 0)) { back = true; break; } + } + log(back ? `${host} is back (request counter reset)` : `${host} did not come back within 90s`, back ? 's' : 'e'); + bounced++; + } + $('waveStat').textContent = `wave done — ${bounced} replica${bounced === 1 ? '' : 's'} bounced`; + log(`rolling wave finished: ${bounced} replica(s) bounced, app stayed up`, 's'); + } finally { + $('waveRun').disabled = !(overview && overview.debug); + loadOverview(); + } + }); + $('probeRun').addEventListener('click', async () => { const url = $('probeTarget').value; const n = Math.min(1000, Math.max(1, +$('probeN').value || 1)); diff --git a/web/badges/icon.svg b/web/badges/icon.svg new file mode 100644 index 0000000..2e062b5 --- /dev/null +++ b/web/badges/icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/web/index.html b/web/index.html index ecee4b4..da5213d 100644 --- a/web/index.html +++ b/web/index.html @@ -5,8 +5,13 @@ Miabi Guestbook + +