feat: add emoji reactions, light/dark themes and rollout observability

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
This commit is contained in:
Akili
2026-10-05 21:36:23 +02:00
parent 886f7cdc14
commit 3a68f64eb6
14 changed files with 726 additions and 82 deletions
+7 -3
View File
@@ -45,13 +45,17 @@ DATABASE_URL=postgres://postgres:postgres@db:5432/guestbook?sslmode=disable
# REDIS_URL=redis://redis:6379 # REDIS_URL=redis://redis:6379
# === Admin console ================= # === 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= # ADMIN_TOKEN=
# CIDRs whose X-Forwarded-For is trusted for the signer IP shown in /admin. # 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). # Unset trusts the header as-is (fine behind the Miabi gateway).
# TRUSTED_PROXIES=10.0.0.0/8 # TRUSTED_PROXIES=10.0.0.0/8
# Expose /api/debug/* failure injection (crash, slow, 5xx, CPU, memory, logs). # Expose /api/debug/* failure injection (crash, slow, 5xx, CPU, memory, logs)
# Still requires admin auth. Demo environments only. # and the admin "rolling-update wave" tool. Still requires admin auth.
# Demo environments only.
# DEBUG_ENDPOINTS=false # DEBUG_ENDPOINTS=false
+28 -13
View File
@@ -15,7 +15,7 @@ handy for logs, events and scaling.
| 3 | Secrets + admin console | secret env vars | | 3 | Secrets + admin console | secret env vars |
| 4 | Scale out → broken live updates | replicas | | 4 | Scale out → broken live updates | replicas |
| 5 | Attach Redis → fixed | managed Redis, env prefix | | 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 | | 7 | Canary v1 → v2 | canary weights, promote |
| 8 | Scheduled cleanup | cron jobs | | 8 | Scheduled cleanup | cron jobs |
@@ -55,23 +55,24 @@ docker push <registry>/guestbook:1.0.0 && docker push <registry>/guestbook:2.0.0
1. App → **Environment** → add `ADMIN_TOKEN` = your token, marked **secret**. Add `DEBUG_ENDPOINTS=true` too (used in act 6). 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. 2. Redeploy, open `/admin` and sign in with the token.
3. Tour it: 3. Tour it:
- **Overview**: signatures per day, replicas, and *this replica*'s runtime (host, DB, uptime, memory). - **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. Click one to filter by it. Pin a signature. It jumps to the top of every open wall, live. Hide one and it disappears. - **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 👋"), then toggle **Pause signing**. The wall goes read-only instantly in every tab. - **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. > 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 > Rotating it (edit + redeploy) signs every admin out, because sessions are
> HMAC-signed with it. > HMAC-signed with it.
Ask the audience to open the wall on their phones and sign it. The 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 ## 4. Scale out, and watch it break
1. App → **Replicas** → scale to **3**. 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*. 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. 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. 4. Also note the `via replica-x` tag on new signatures: each one records which replica accepted it. And `curl -s https://<domain>/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 > 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 > 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`). 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`. 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*. 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. 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 ## 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. 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 ## 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`. 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`. 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`. 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 > 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 ## 8. Scheduled cleanup
@@ -141,6 +156,6 @@ want migrations as an explicit step rather than on boot.
## Reset between sessions ## 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**. - 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).
+40 -21
View File
@@ -18,6 +18,17 @@ miniature:
routes keep precedence routes keep precedence
- **Live updates over SSE** — new/removed signatures stream to every open tab, - **Live updates over SSE** — new/removed signatures stream to every open tab,
with a real-time **connected-clients** ("N online") indicator 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 - **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 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 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 across every replica via pub/sub; without it, the in-process broker makes the
classic "works on one replica" bug easy to demonstrate classic "works on one replica" bug easy to demonstrate
- **Admin console** at `/admin`, guarded by a single `ADMIN_TOKEN` secret: - **Admin console** at `/admin`, guarded by a single `ADMIN_TOKEN` secret:
overview (stats, replicas, runtime), moderation (pin / hide / delete, signer overview (stats, replicas with per-replica request counters, runtime),
IP, CSV export), wall settings (pause signing, announcement banner) — all pushed live 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, - **Chaos lab** (`DEBUG_ENDPOINTS=true`): crash, fail health checks, burn CPU,
hold memory, burst logs, inject latency/5xx, plus an in-browser traffic probe hold memory, burst logs, inject latency/5xx, a **rolling-update wave** that
that shows load-balancing and canary splits 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 - **Scheduled-job friendly CLI** — `guestbook cleanup` (purge/expire) and
`guestbook migrate` subcommands for Miabi cron jobs and one-off jobs `guestbook migrate` subcommands for Miabi cron jobs and one-off jobs
@@ -60,13 +74,15 @@ and keeps the container tiny.
| Method | Path | Description | | Method | Path | Description |
|----------|----------------------|------------------------------------| |----------|----------------------|------------------------------------|
| `GET` | `/healthz` | Liveness/readiness (checks the DB), reports `version`, `host`, `redis` | | `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` | | `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/time` | Current server time + `version` + `host` (v2 — canary probe) |
| `GET` | `/api/settings` | Public wall settings: `signing_paused`, `banner` | | `GET` | `/api/settings` | Public wall settings: `signing_paused`, `banner`, `theme` |
| `GET` | `/api/entries` | Visible entries (pinned first) + total; paginated via `?limit=&offset=` | | `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) | | `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) | | `GET` | `/` · `/all` · `/admin` | The web UI (home · all signatures · admin console) |
### Admin API ### Admin API
@@ -77,11 +93,11 @@ or an `Authorization: Bearer <ADMIN_TOKEN>` header. Disabled (`404`) when
| Method | Path | Description | | Method | Path | Description |
|----------|-------------------------------|-------------| |----------|-------------------------------|-------------|
| `GET` | `/api/admin/overview` | Stats (14-day histogram), replicas, runtime of the answering replica | | `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; `?status=visible\|hidden\|pinned&q=&limit=&offset=` | | `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 }` | | `PATCH` | `/api/admin/entries/{id}` | `{ "pinned"?: bool, "hidden"?: bool }` |
| `DELETE` | `/api/admin/entries/{id}` | Soft-delete an entry | | `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 | | `GET` | `/api/admin/export.csv` | Every entry as CSV |
### Debug API (chaos) ### 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 `/api/stream` is a Server-Sent Events endpoint. On connect the client gets a
`welcome` event (serving version + host + online count); thereafter the server `welcome` event (serving version + host + online count); thereafter the server
pushes `created` / `deleted` events as the wall changes, `presence` events when pushes `created` / `updated` / `deleted` events as the wall changes, `reaction`
the connected-client count changes, and a `tick` event once a second carrying events as tallies move, `settings` events when a moderator pauses signing,
the live server time (with `version` + `host`). The UI uses these to update the changes the banner or forces a theme, `presence` events when the
wall in real time, show a live **“N online”** indicator, and drive the connected-client count changes, and a `tick` event once a second carrying the
**server-time card** — all without polling. 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 With `REDIS_URL` set, `created`/`updated`/`deleted`/`reaction`/`settings`
a Redis pub/sub channel so every replica delivers them, and each replica events go through a Redis pub/sub channel so every replica delivers them, and
advertises its client count under a short-TTL key so presence is cluster-wide. each replica advertises its client count **and request count** under a
`tick` events stay per-replica on purpose: the clock shows who served you. short-TTL key so presence is cluster-wide. `tick` events stay per-replica on
purpose: the clock shows who served you.
## Configuration ## Configuration
+9
View File
@@ -93,6 +93,7 @@ func (h *Handler) AdminOverview(c *okapi.Context) error {
"healthy": !h.unhealthy.Load(), "healthy": !h.unhealthy.Load(),
"started_at": h.started.UTC().Format(time.RFC3339), "started_at": h.started.UTC().Format(time.RFC3339),
"uptime_s": int64(time.Since(h.started).Seconds()), "uptime_s": int64(time.Since(h.started).Seconds()),
"requests": h.requests.Load(),
"go": runtime.Version(), "go": runtime.Version(),
"cpus": runtime.NumCPU(), "cpus": runtime.NumCPU(),
"goroutines": runtime.NumGoroutine(), "goroutines": runtime.NumGoroutine(),
@@ -167,6 +168,14 @@ func (h *Handler) AdminSaveSettings(c *okapi.Context) error {
if len(st.Banner) > maxBannerLen { if len(st.Banner) > maxBannerLen {
return c.JSON(http.StatusBadRequest, okapi.M{"error": "banner is too long (max 200 characters)"}) 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 { if err := h.store.SaveSettings(c.Request().Context(), st); err != nil {
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not save settings"}) return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not save settings"})
} }
+26 -11
View File
@@ -17,13 +17,16 @@ import (
// - "created" → Entry is set (a new signature) // - "created" → Entry is set (a new signature)
// - "updated" → Entry is set (pinned/hidden by a moderator) // - "updated" → Entry is set (pinned/hidden by a moderator)
// - "deleted" → ID is set (a removed signature) // - "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) // - "presence" → Online/Replicas are set (connected clients changed)
// - "tick" → Time/Version/Host are set (live server clock; new in v2) // - "tick" → Time/Version/Host are set (live server clock; new in v2)
type Event struct { type Event struct {
Type string `json:"type"` Type string `json:"type"`
Entry *Entry `json:"entry,omitempty"` Entry *Entry `json:"entry,omitempty"`
ID uint `json:"id,omitempty"` ID uint `json:"id,omitempty"`
Emoji string `json:"emoji,omitempty"`
Count int `json:"count,omitempty"`
Settings *Settings `json:"settings,omitempty"` Settings *Settings `json:"settings,omitempty"`
Online int `json:"online,omitempty"` Online int `json:"online,omitempty"`
Replicas int `json:"replicas,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. // 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 { type Replica struct {
Host string `json:"host"` Host string `json:"host"`
Version string `json:"version"` Version string `json:"version"`
Online int `json:"online"` Online int `json:"online"`
Seen time.Time `json:"seen"` Requests int64 `json:"requests"`
Seen time.Time `json:"seen"`
} }
const ( const (
@@ -61,6 +68,7 @@ type Broker struct {
host, version string host, version string
rdb *redis.Client rdb *redis.Client
dirty chan struct{} dirty chan struct{}
requests func() int64
cmu sync.RWMutex cmu sync.RWMutex
replicas []Replica replicas []Replica
@@ -68,12 +76,15 @@ type Broker struct {
} }
// NewBroker creates an in-process Broker. Call UseRedis to make it cluster-wide. // 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{ return &Broker{
subs: make(map[chan Event]struct{}), subs: make(map[chan Event]struct{}),
host: host, host: host,
version: version, version: version,
dirty: make(chan struct{}, 1), dirty: make(chan struct{}, 1),
requests: requests,
} }
} }
@@ -203,7 +214,11 @@ func (b *Broker) Replicas() []Replica {
} }
func (b *Broker) self() 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 { func (b *Broker) localCount() int {
+1 -1
View File
@@ -6,7 +6,7 @@ import (
) )
func TestLocalBroker(t *testing.T) { func TestLocalBroker(t *testing.T) {
b := NewBroker("host-a", "1.0.0") b := NewBroker("host-a", "1.0.0", nil)
ch := b.Subscribe() ch := b.Subscribe()
defer b.Unsubscribe(ch) defer b.Unsubscribe(ch)
+19 -13
View File
@@ -28,6 +28,9 @@ type Handler struct {
unhealthy atomic.Bool unhealthy atomic.Bool
chaos chaosState 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 // 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{ return c.OK(okapi.M{
"status": "ok", "status": "ok",
"db": "up", "db": "up",
"redis": h.broker.RedisStatus(c.Request().Context()), "redis": h.broker.RedisStatus(c.Request().Context()),
"app": h.appName, "app": h.appName,
"version": h.version, "version": h.version,
"host": h.host, "host": h.host,
"requests": h.requests.Load(),
}) })
} }
@@ -85,13 +89,15 @@ func (h *Handler) Health(c *okapi.Context) error {
// are currently connected. // are currently connected.
func (h *Handler) Info(c *okapi.Context) error { func (h *Handler) Info(c *okapi.Context) error {
return c.OK(okapi.M{ return c.OK(okapi.M{
"app": h.appName, "app": h.appName,
"version": h.version, "version": h.version,
"online": h.broker.Online(), "online": h.broker.Online(),
"host": h.host, "host": h.host,
"broker": h.broker.Mode(), "broker": h.broker.Mode(),
"replicas": h.broker.Replicas(), "replicas": h.broker.Replicas(),
"admin": h.auth.Enabled(), "requests": h.requests.Load(),
"admin": h.auth.Enabled(),
"reactions": ReactionEmojis(),
}) })
} }
+11 -2
View File
@@ -85,7 +85,7 @@ func runServer(cmd *okapicli.Command, cfg Config) error {
} }
host, _ := os.Hostname() host, _ := os.Hostname()
broker := NewBroker(host, cfg.Version) broker := NewBroker(host, cfg.Version, nil)
if cfg.RedisURL != "" { if cfg.RedisURL != "" {
if err := broker.UseRedis(cfg.RedisURL); err != nil { if err := broker.UseRedis(cfg.RedisURL); err != nil {
return err return err
@@ -106,6 +106,7 @@ func runServer(cmd *okapicli.Command, cfg Config) error {
debug: cfg.DebugEndpoints, debug: cfg.DebugEndpoints,
started: time.Now(), started: time.Now(),
} }
broker.requests = h.requests.Load
if !h.auth.Enabled() { if !h.auth.Enabled() {
logger.Warn("admin console disabled: set ADMIN_TOKEN to enable /admin") 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 := cmd.Okapi()
app.WithPort(cmd.GetInt("port")) app.WithPort(cmd.GetInt("port"))
app.WithDebug() app.WithDebug()
app.Use(okapi.LoggerMiddleware, okapi.RequestID()) app.Use(okapi.LoggerMiddleware, okapi.RequestID(), h.countRequests)
registerRoutes(app, h) registerRoutes(app, h)
return cmd.CLI().RunServer(&okapicli.RunOptions{ return cmd.CLI().RunServer(&okapicli.RunOptions{
@@ -144,6 +145,8 @@ func registerRoutes(app *okapi.Okapi, h *Handler) {
api.Get("/settings", h.PublicSettings) api.Get("/settings", h.PublicSettings)
api.Get("/entries", h.ListEntries) api.Get("/entries", h.ListEntries)
api.Post("/entries", h.CreateEntry) 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("/stream", h.Stream)
api.Get("/admin/session", h.AdminSession) 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) 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 // Okapi serves real files directly and falls back to index.html for any
// other path so the client-side router can take over. // other path so the client-side router can take over.
app.WebFS("/", webFS, okapi.WebConfig{Root: "web"}) app.WebFS("/", webFS, okapi.WebConfig{Root: "web"})
+111
View File
@@ -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()
}
+99
View File
@@ -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)
}
}
+147 -10
View File
@@ -13,6 +13,7 @@ import (
"github.com/jkaninda/logger" "github.com/jkaninda/logger"
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
// Entry is a single signature on the guestbook wall. // Entry is a single signature on the guestbook wall.
@@ -27,6 +28,21 @@ type Entry struct {
CreatedAt time.Time `gorm:"index" json:"created_at"` CreatedAt time.Time `gorm:"index" json:"created_at"`
UpdatedAt time.Time `json:"-"` UpdatedAt time.Time `json:"-"`
DeletedAt gorm.DeletedAt `gorm:"index" 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. // Setting is a key/value row holding app-wide state shared by all replicas.
@@ -40,6 +56,9 @@ type Setting struct {
type Settings struct { type Settings struct {
SigningPaused bool `json:"signing_paused"` SigningPaused bool `json:"signing_paused"`
Banner string `json:"banner"` 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. // EntryFilter selects entries for the admin console.
@@ -56,14 +75,31 @@ type DayCount struct {
Count int `json:"count"` 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. // Stats summarises the wall for the admin overview.
type Stats struct { type Stats struct {
Total int64 `json:"total"` Total int64 `json:"total"`
Visible int64 `json:"visible"` Visible int64 `json:"visible"`
Hidden int64 `json:"hidden"` Hidden int64 `json:"hidden"`
Pinned int64 `json:"pinned"` Pinned int64 `json:"pinned"`
Today int64 `json:"today"` Today int64 `json:"today"`
Daily []DayCount `json:"daily"` Reactions int64 `json:"reactions"`
Daily []DayCount `json:"daily"`
} }
const settingsKey = "settings" const settingsKey = "settings"
@@ -140,7 +176,7 @@ func (s *Store) Ping(ctx context.Context) error {
// concurrent AutoMigrate calls race on Postgres catalog inserts. // concurrent AutoMigrate calls race on Postgres catalog inserts.
func (s *Store) Migrate() error { func (s *Store) Migrate() error {
return s.Exclusive(context.Background(), func(tx *Store) 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). Limit(limit).
Offset(offset). Offset(offset).
Find(&entries).Error 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. // 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 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. // 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) { func (s *Store) Delete(ctx context.Context, id uint64) (bool, error) {
res := s.db.WithContext(ctx).Delete(&Entry{}, id) res := s.db.WithContext(ctx).Delete(&Entry{}, id)
@@ -206,10 +335,14 @@ func (s *Store) CountVisible(ctx context.Context) (int64, error) {
return n, err 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) { func (s *Store) Get(ctx context.Context, id uint64) (Entry, error) {
var e Entry var e Entry
err := s.db.WithContext(ctx).First(&e, id).Error 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 return e, err
} }
@@ -235,7 +368,10 @@ func (s *Store) AdminList(ctx context.Context, f EntryFilter) ([]Entry, int64, e
} }
var entries []Entry var entries []Entry
err := q.Order("created_at DESC").Limit(f.Limit).Offset(f.Offset).Find(&entries).Error 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. // 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("hidden = ?", true).Count(&st.Hidden)
s.db.WithContext(ctx).Model(&Entry{}).Where("pinned = ?", true).Count(&st.Pinned) 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 st.Visible = st.Total - st.Hidden
now := time.Now().UTC() now := time.Now().UTC()
+90 -3
View File
@@ -219,6 +219,7 @@
<div class="card tile"><div class="label">Replicas</div><div class="value" id="tReplicas">–</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">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">Today</div><div class="value" id="tToday">–</div></div>
<div class="card tile"><div class="label">Reactions</div><div class="value" id="tReacts">–</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">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 class="card tile"><div class="label">Hidden</div><div class="value" id="tHidden">–</div></div>
</div> </div>
@@ -232,7 +233,7 @@
<h2>Replicas</h2> <h2>Replicas</h2>
<p class="hint" id="replicaHint"></p> <p class="hint" id="replicaHint"></p>
<table> <table>
<thead><tr><th>Host</th><th>Version</th><th>Clients</th><th>Seen</th></tr></thead> <thead><tr><th>Host</th><th>Version</th><th>Clients</th><th title="HTTP requests answered since this replica started — a rollout resets them one at a time">Requests</th><th>Seen</th></tr></thead>
<tbody id="replicaRows"></tbody> <tbody id="replicaRows"></tbody>
</table> </table>
</div> </div>
@@ -278,6 +279,15 @@
<input id="banner" maxlength="200" placeholder="e.g. Welcome to the Miabi training 👋" /> <input id="banner" maxlength="200" placeholder="e.g. Welcome to the Miabi training 👋" />
<button class="primary" type="submit">Save</button> <button class="primary" type="submit">Save</button>
</div> </div>
<label class="field" for="theme" style="margin-top:16px">Wall theme</label>
<div class="field-row">
<select id="theme">
<option value="system">System — follow each visitor's preference</option>
<option value="light">Force light</option>
<option value="dark">Force dark</option>
</select>
</div>
<p class="hint" style="margin:8px 0 0">Pushed live to every open tab. Visitors can toggle light/dark themselves unless you force one here.</p>
</form> </form>
</div> </div>
@@ -348,6 +358,15 @@
<div class="probe-bars" id="probeBars"></div> <div class="probe-bars" id="probeBars"></div>
</div> </div>
<div class="card pad" style="margin-bottom:16px">
<h2>Rolling-update wave</h2>
<p class="hint">Watch what Miabi does during a <b>rolling update</b>: this tool bounces <i>one replica at a time</i> 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.</p>
<div class="toolbar" style="margin-bottom:0">
<button class="primary" id="waveRun">Bounce each replica, one at a time</button>
<span class="chip" id="waveStat" hidden></span>
</div>
</div>
<div class="card pad"> <div class="card pad">
<h2>Activity</h2> <h2>Activity</h2>
<div class="log" id="log"><div class="t">Actions and their responses appear here.</div></div> <div class="log" id="log"><div class="t">Actions and their responses appear here.</div></div>
@@ -421,6 +440,7 @@
if (!r.ok || !r.data) return; if (!r.ok || !r.data) return;
overview = r.data; overview = r.data;
const { stats, runtime: rt, replicas, online, debug } = overview; const { stats, runtime: rt, replicas, online, debug } = overview;
$('waveRun').disabled = !debug;
$('servedBy').textContent = rt.host; $('servedBy').textContent = rt.host;
$('appName').textContent = rt.app; $('appName').textContent = rt.app;
setVersion(rt.version); setVersion(rt.version);
@@ -428,18 +448,20 @@
$('tReplicas').textContent = replicas.length; $('tReplicas').textContent = replicas.length;
$('tTotal').textContent = stats.total; $('tTotal').textContent = stats.total;
$('tToday').textContent = stats.today; $('tToday').textContent = stats.today;
$('tReacts').textContent = stats.reactions ?? 0;
$('tPinned').textContent = stats.pinned; $('tPinned').textContent = stats.pinned;
$('tHidden').textContent = stats.hidden; $('tHidden').textContent = stats.hidden;
renderChart(stats.daily || []); renderChart(stats.daily || []);
$('replicaHint').innerHTML = rt.broker === 'redis' $('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 (<code>REDIS_URL</code>) to share live updates across replicas.'; : '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) => ` $('replicaRows').innerHTML = replicas.map((r) => `
<tr class="${r.host === rt.host ? 'me' : ''}"> <tr class="${r.host === rt.host ? 'me' : ''}">
<td class="mono">${esc(r.host)}</td> <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><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>${r.online}</td>
<td>${r.requests ?? '–'}</td>
<td>${ago(r.seen)}</td> <td>${ago(r.seen)}</td>
</tr>`).join(''); </tr>`).join('');
@@ -452,6 +474,7 @@
['Live updates', rt.broker === 'redis' ? 'Redis pub/sub' : 'in-process'], ['Live updates', rt.broker === 'redis' ? 'Redis pub/sub' : 'in-process'],
['Redis', status(rt.redis)], ['Redis', status(rt.redis)],
['Uptime', uptime(rt.uptime_s)], ['Uptime', uptime(rt.uptime_s)],
['Requests served', rt.requests ?? 0],
['Go', `${esc(rt.go)} · ${rt.cpus} CPU`], ['Go', `${esc(rt.go)} · ${rt.cpus} CPU`],
['Goroutines', rt.goroutines], ['Goroutines', rt.goroutines],
['Heap / Sys', `${rt.heap_mb.toFixed(1)} / ${rt.sys_mb.toFixed(1)} MiB`], ['Heap / Sys', `${rt.heap_mb.toFixed(1)} / ${rt.sys_mb.toFixed(1)} MiB`],
@@ -496,6 +519,8 @@
function rowHTML(e) { function rowHTML(e) {
const rep = e.replica ? `<span class="rep" title="Accepted by">${esc(e.replica)}</span>` : ''; 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>` : ''; const ip = e.ip ? `<span class="ip" data-ip="${esc(e.ip)}" title="Submitter IP — click to filter">${esc(e.ip)}</span>` : '';
const reacts = Object.entries(e.reactions || {}).map(([k, n]) => `${({ heart: '❤️', tada: '🎉', thumbsup: '👍', smile: '😄' })[k] || k} ${n}`).join(' · ');
const reactsChip = reacts ? `<span class="rep" title="Reactions">${esc(reacts)}</span>` : '';
return `<div class="card row ${e.hidden ? 'is-hidden' : ''} ${e.pinned ? 'is-pinned' : ''}" data-id="${e.id}"> return `<div class="card row ${e.hidden ? 'is-hidden' : ''} ${e.pinned ? 'is-pinned' : ''}" data-id="${e.id}">
<div class="head"> <div class="head">
<span class="who">${esc(e.name)}</span> <span class="who">${esc(e.name)}</span>
@@ -503,6 +528,7 @@
<span class="id">#${e.id}</span> <span class="id">#${e.id}</span>
${e.pinned ? '<span class="badge pin">Pinned</span>' : ''} ${e.pinned ? '<span class="badge pin">Pinned</span>' : ''}
${e.hidden ? '<span class="badge hid">Hidden</span>' : ''} ${e.hidden ? '<span class="badge hid">Hidden</span>' : ''}
${reactsChip}
${ip} ${ip}
${rep} ${rep}
</div> </div>
@@ -552,14 +578,16 @@
if (!r.ok) return; if (!r.ok) return;
$('paused').checked = !!r.data.signing_paused; $('paused').checked = !!r.data.signing_paused;
$('banner').value = r.data.banner || ''; $('banner').value = r.data.banner || '';
$('theme').value = r.data.theme || 'system';
$('bannerCount').textContent = $('banner').value.length + ' / 200'; $('bannerCount').textContent = $('banner').value.length + ' / 200';
} }
async function saveSettings() { 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); 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'; }); $('banner').addEventListener('input', (e) => { $('bannerCount').textContent = e.target.value.length + ' / 200'; });
$('paused').addEventListener('change', saveSettings); $('paused').addEventListener('change', saveSettings);
$('theme').addEventListener('change', saveSettings);
$('settingsForm').addEventListener('submit', (e) => { e.preventDefault(); saveSettings(); }); $('settingsForm').addEventListener('submit', (e) => { e.preventDefault(); saveSettings(); });
// ---- chaos lab --------------------------------------------------------- // ---- chaos lab ---------------------------------------------------------
@@ -615,6 +643,65 @@
log('gave up watching after 120s', 'w'); 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 () => { $('probeRun').addEventListener('click', async () => {
const url = $('probeTarget').value; const url = $('probeTarget').value;
const n = Math.min(1000, Math.max(1, +$('probeN').value || 1)); const n = Math.min(1000, Math.max(1, +$('probeN').value || 1));
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#8b5cf6"/>
<stop offset="1" stop-color="#a855f7"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="url(#g)"/>
<path d="M20 34h24v4a8 8 0 0 1-8 8h-8a8 8 0 0 1-8-8v-4z" fill="#fff" opacity=".92"/>
<path d="M25 22c0-3 3-3 3-6m5 6c0-3 3-3 3-6" stroke="#fff" stroke-width="3.4" stroke-linecap="round" fill="none" opacity=".92"/>
</svg>

After

Width:  |  Height:  |  Size: 531 B

+127 -5
View File
@@ -5,8 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Miabi Guestbook</title> <title>Miabi Guestbook</title>
<meta name="description" content="A tiny Okapi + Postgres example app, deployed on Miabi." /> <meta name="description" content="A tiny Okapi + Postgres example app, deployed on Miabi." />
<meta name="theme-color" content="#0b0a12" />
<link rel="icon" href="/badges/icon.svg" type="image/svg+xml" />
<style> <style>
:root { /* Dark is the default look; body.light switches to the light palette.
The moderator can also force one for everyone (wall setting "theme"). */
:root, body:not(.light) {
color-scheme: dark;
--bg: #0b0a12; --bg: #0b0a12;
--bg-2: #12101d; --bg-2: #12101d;
--card: rgba(255, 255, 255, 0.04); --card: rgba(255, 255, 255, 0.04);
@@ -17,9 +22,31 @@
--accent-2: #a855f7; --accent-2: #a855f7;
--danger: #f43f5e; --danger: #f43f5e;
--online: #22c55e; --online: #22c55e;
--input-bg: rgba(0, 0, 0, 0.25);
--via-bg: rgba(0, 0, 0, 0.2);
--glow-a: rgba(139, 92, 246, 0.18);
--glow-b: rgba(168, 85, 247, 0.14);
--radius: 16px; --radius: 16px;
--shadow: 0 20px 60px -20px rgba(139, 92, 246, 0.35); --shadow: 0 20px 60px -20px rgba(139, 92, 246, 0.35);
} }
body.light {
color-scheme: light;
--bg: #f4f2fb;
--bg-2: #e9e5f7;
--card: rgba(255, 255, 255, 0.72);
--card-border: rgba(15, 10, 40, 0.1);
--text: #221c3d;
--muted: #645e7d;
--accent: #7c3aed;
--accent-2: #9333ea;
--danger: #e11d48;
--online: #16a34a;
--input-bg: rgba(255, 255, 255, 0.8);
--via-bg: rgba(124, 58, 237, 0.08);
--glow-a: rgba(139, 92, 246, 0.22);
--glow-b: rgba(168, 85, 247, 0.16);
--shadow: 0 20px 50px -24px rgba(124, 58, 237, 0.35);
}
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body { margin: 0; padding: 0; } html, body { margin: 0; padding: 0; }
body { body {
@@ -27,8 +54,8 @@
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: var(--text); color: var(--text);
background: background:
radial-gradient(900px 500px at 15% -10%, rgba(139, 92, 246, 0.18), transparent 60%), radial-gradient(900px 500px at 15% -10%, var(--glow-a), transparent 60%),
radial-gradient(700px 500px at 110% 10%, rgba(168, 85, 247, 0.14), transparent 55%), radial-gradient(700px 500px at 110% 10%, var(--glow-b), transparent 55%),
linear-gradient(180deg, var(--bg), var(--bg-2)); linear-gradient(180deg, var(--bg), var(--bg-2));
background-attachment: fixed; background-attachment: fixed;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
@@ -59,6 +86,14 @@
background: var(--online); opacity: .5; animation: ping 1.6s cubic-bezier(0,0,.2,1) infinite; background: var(--online); opacity: .5; animation: ping 1.6s cubic-bezier(0,0,.2,1) infinite;
} }
@keyframes ping { 0% { transform: scale(1); opacity: .5; } 75%, 100% { transform: scale(2.4); opacity: 0; } } @keyframes ping { 0% { transform: scale(1); opacity: .5; } 75%, 100% { transform: scale(2.4); opacity: 0; } }
.icon-btn {
font: inherit; font-size: 14px; line-height: 1; cursor: pointer; color: var(--text);
display: inline-flex; align-items: center; justify-content: center;
width: 30px; height: 30px; border-radius: 999px;
background: var(--card); border: 1px solid var(--card-border);
transition: border-color .15s, transform .15s;
}
.icon-btn:hover { border-color: var(--accent); transform: translateY(-1px); }
header { text-align: center; margin-bottom: 26px; } header { text-align: center; margin-bottom: 26px; }
h1 { h1 {
@@ -100,7 +135,7 @@
.row { display: flex; gap: 12px; flex-wrap: wrap; } .row { display: flex; gap: 12px; flex-wrap: wrap; }
input, textarea { input, textarea {
width: 100%; font: inherit; color: var(--text); width: 100%; font: inherit; color: var(--text);
background: rgba(0, 0, 0, 0.25); border: 1px solid var(--card-border); background: var(--input-bg); border: 1px solid var(--card-border);
border-radius: 12px; padding: 13px 15px; outline: none; resize: vertical; border-radius: 12px; padding: 13px 15px; outline: none; resize: vertical;
transition: border-color .15s, box-shadow .15s; transition: border-color .15s, box-shadow .15s;
} }
@@ -145,7 +180,7 @@
.entry .when { color: var(--muted); } .entry .when { color: var(--muted); }
.entry .via { .entry .via {
margin-left: auto; font-size: 11.5px; color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; 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; background: var(--via-bg); border: 1px solid var(--card-border); padding: 2px 8px; border-radius: 999px;
max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
} }
li.entry.pinned { border-color: rgba(139, 92, 246, 0.55); background: rgba(139, 92, 246, 0.08); } li.entry.pinned { border-color: rgba(139, 92, 246, 0.55); background: rgba(139, 92, 246, 0.08); }
@@ -154,6 +189,21 @@
text-transform: uppercase; color: var(--accent-2); text-transform: uppercase; color: var(--accent-2);
} }
.entry .reacts { display: flex; gap: 6px; margin-top: 12px; flex-wrap: wrap; }
.react {
font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer; color: var(--muted);
display: inline-flex; align-items: center; gap: 5px; padding: 4px 10px; border-radius: 999px;
background: var(--card); border: 1px solid var(--card-border);
transition: border-color .15s, color .15s, transform .1s;
}
.react:hover:not(:disabled) { border-color: var(--accent); color: var(--text); }
.react:active:not(:disabled) { transform: scale(.93); }
.react.on { color: var(--accent-2); border-color: rgba(139, 92, 246, .55); background: rgba(139, 92, 246, .1); }
.react:disabled { opacity: .45; cursor: not-allowed; }
.react .n { font-variant-numeric: tabular-nums; }
.react.bump .n { animation: bump .3s ease; }
@keyframes bump { 40% { transform: scale(1.4); } }
.banner { .banner {
display: flex; gap: 10px; align-items: flex-start; padding: 13px 18px; margin-bottom: 18px; 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; border-color: rgba(245, 158, 11, 0.45); background: rgba(245, 158, 11, 0.1); font-size: 14.5px; line-height: 1.5;
@@ -208,6 +258,7 @@
<span class="ver" id="ver" title="Serving version">v…</span> <span class="ver" id="ver" title="Serving version">v…</span>
<span class="replicas" id="replicas" title="Running replicas" hidden></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> <span class="online"><span class="pulse"></span> <span id="online">0</span> online</span>
<button class="icon-btn" id="themeBtn" title="Toggle light / dark theme" aria-label="Toggle light / dark theme">🌓</button>
</div> </div>
</div> </div>
@@ -300,8 +351,12 @@
settings: () => fetch('/api/settings').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()), 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) }), create: (body) => fetch('/api/entries', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }),
react: (id, emoji) => fetch(`/api/entries/${id}/reactions?emoji=${emoji}`, { method: 'POST' }),
unreact:(id, emoji) => fetch(`/api/entries/${id}/reactions?emoji=${emoji}`, { method: 'DELETE' }),
}; };
const EMOJI = { heart: '❤️', tada: '🎉', thumbsup: '👍', smile: '😄' };
let total = 0; // total signatures (kept in sync via SSE) let total = 0; // total signatures (kept in sync via SSE)
let allOffset = 0; // current offset on the "All" page let allOffset = 0; // current offset on the "All" page
@@ -325,6 +380,23 @@
clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove('show'), 3200); clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove('show'), 3200);
} }
// ---- appearance (light / dark) -----------------------------------------
// Priority: moderator-forced theme > visitor's own choice > OS preference.
let serverTheme = '';
function applyTheme() {
const local = localStorage.getItem('gb-theme');
const mode = serverTheme || local || (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
document.body.classList.toggle('light', mode === 'light');
}
$('themeBtn').addEventListener('click', () => {
const next = document.body.classList.contains('light') ? 'dark' : 'light';
localStorage.setItem('gb-theme', next);
if (!serverTheme) applyTheme();
else toast(`The moderator forced the ${serverTheme} theme — your choice will apply when it's released`);
});
matchMedia('(prefers-color-scheme: light)').addEventListener('change', applyTheme);
applyTheme();
function cardHTML(e) { function cardHTML(e) {
const via = e.replica && e.replica !== 'seed' const via = e.replica && e.replica !== 'seed'
? `<span class="via" title="Accepted by replica ${esc(e.replica)}">via ${esc(e.replica)}</span>` : ''; ? `<span class="via" title="Accepted by replica ${esc(e.replica)}">via ${esc(e.replica)}</span>` : '';
@@ -337,9 +409,56 @@
<span class="when">· ${timeAgo(e.created_at)}</span> <span class="when">· ${timeAgo(e.created_at)}</span>
${via} ${via}
</div> </div>
${reactsHTML(e)}
</li>`; </li>`;
} }
// ---- reactions ----------------------------------------------------------
// One tap adds the browser's reaction, tapping again removes it. The own
// choices are kept in localStorage; the authoritative tally comes from the
// server (list payloads + live "reaction" SSE events).
function myReactions() { try { return JSON.parse(localStorage.getItem('gb-reacts') || '{}'); } catch { return {}; } }
function saveMyReaction(id, emoji, on) {
const m = myReactions();
if (on) m[id + ':' + emoji] = 1; else delete m[id + ':' + emoji];
localStorage.setItem('gb-reacts', JSON.stringify(m));
}
function reactsHTML(e) {
const mine = myReactions();
const counts = e.reactions || {};
const chips = Object.keys(EMOJI).map((k) => {
const n = counts[k] || 0;
const on = mine[e.id + ':' + k] ? ' on' : '';
return `<button class="react${on}" data-emoji="${k}" title="React ${EMOJI[k]}">${EMOJI[k]} <span class="n">${n}</span></button>`;
}).join('');
return `<div class="reacts">${chips}</div>`;
}
function updateReaction(id, emoji, count) {
document.querySelectorAll(`.entry[data-id="${id}"]`).forEach((li) => {
const btn = li.querySelector(`.react[data-emoji="${emoji}"]`);
if (!btn) return;
btn.querySelector('.n').textContent = count;
btn.classList.toggle('on', !!myReactions()[id + ':' + emoji]);
btn.classList.remove('bump'); void btn.offsetWidth; btn.classList.add('bump');
});
}
document.addEventListener('click', async (e) => {
const btn = e.target.closest('.react');
if (!btn || btn.disabled) return;
const li = btn.closest('.entry'); if (!li) return;
const id = li.dataset.id, emoji = btn.dataset.emoji;
const adding = !btn.classList.contains('on');
saveMyReaction(id, emoji, adding);
btn.classList.toggle('on', adding); // instant feedback; SSE brings the tally
btn.disabled = true;
try {
const res = adding ? await api.react(id, emoji) : await api.unreact(id, emoji);
if (!res.ok) saveMyReaction(id, emoji, !adding); // revert on failure
} catch { saveMyReaction(id, emoji, !adding); }
btn.disabled = false;
btn.classList.toggle('on', !!myReactions()[id + ':' + emoji]);
});
function renderList(ul, loading, entries, emptyMsg) { function renderList(ul, loading, entries, emptyMsg) {
loading.style.display = 'none'; loading.style.display = 'none';
if (!entries.length) { ul.innerHTML = `<div class="empty">${emptyMsg}</div>`; return; } if (!entries.length) { ul.innerHTML = `<div class="empty">${emptyMsg}</div>`; return; }
@@ -374,6 +493,8 @@
const paused = !!(st && st.signing_paused); const paused = !!(st && st.signing_paused);
$('paused').hidden = !paused; $('paused').hidden = !paused;
$('form').hidden = paused; $('form').hidden = paused;
serverTheme = (st && st.theme) || '';
applyTheme();
} }
// ---- views -------------------------------------------------------------- // ---- views --------------------------------------------------------------
@@ -469,6 +590,7 @@
es.addEventListener('created', (e) => { const d = JSON.parse(e.data); if (d.entry && !d.entry.hidden) onCreated(d.entry); }); 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('updated', () => reloadView());
es.addEventListener('deleted', (e) => { const d = JSON.parse(e.data); onDeleted(d.id); }); es.addEventListener('deleted', (e) => { const d = JSON.parse(e.data); onDeleted(d.id); });
es.addEventListener('reaction', (e) => { const d = JSON.parse(e.data); updateReaction(d.id, d.emoji, d.count); });
es.addEventListener('settings', (e) => { const d = JSON.parse(e.data); applySettings(d.settings); }); es.addEventListener('settings', (e) => { const d = JSON.parse(e.data); applySettings(d.settings); });
// Resync after a reconnect: events sent while disconnected were missed. // 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('open', () => { if (connectedOnce) { reloadView(); api.settings().then(applySettings).catch(() => {}); } connectedOnce = true; });