4 Commits

Author SHA1 Message Date
Jonas Kaninda cddbf2809a Merge pull request #6 from miabi-io/akili/tsk_ptdi5ufndrzwlljk4v5g
feat: visitor identity cookie + database reset job
2026-10-07 22:06:45 +02:00
Akili 86215f1eb1 feat: add visitor identity cookie and database reset job
Three connected features for the deployment-strategy demo:

1. Visitor identity cookie (v3). A new visitors table backs a first-party
   gb_visitor cookie: middleware on /api checks the cookie's UUID against
   the database on every request and regenerates + persists it when the id
   is missing, malformed, or unknown (fresh browser, pruned identity, or a
   reset database). The identity remembers the name a visitor signed with
   (pre-filled next time, used for unsigned posts), replaces the standalone
   reaction cookie for reaction de-dupe, and feeds a live "N visiting"
   counter (identities seen in the last 5 minutes) shown on the wall, in
   /api/info, the admin overview, and the presence/tick SSE events. The
   admin console gains Visitors/Visiting tiles and a visitors table, plus a
   GET /api/me endpoint and GET /api/admin/visitors.

2. guestbook reset command. Wipes entries (including soft-deleted rows),
   reactions and visitor identities under the schema advisory lock, with
   --dry-run, --hard (also reset wall settings) and --reseed flags; meant
   to run as a one-off Miabi Job. After a reset every outstanding cookie is
   invalidated, so browsers get a fresh identity on their next request.

3. cleanup learns --prune-visitors (default 30 days): the cron job now also
   forgets idle identities together with their reactions.

Docs (README, DEMO) updated: act 8 covers reset, the visitor-identity
talking point is in act 3, and the API tables list the new endpoints.

Akili-Task: tsk_ptdi5ufndrzwlljk4v5g
Akili-Agent: goldfish
2026-10-07 19:06:07 +02:00
Jonas Kaninda a12c27b6bc Merge pull request #5 from miabi-io/akili/tsk_xgg6sfuzkzymyc6gstr4
feat: emoji reactions, light/dark themes and rollout observability
2026-10-05 22:12:34 +02:00
Akili 3a68f64eb6 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
2026-10-05 21:36:23 +02:00
17 changed files with 1455 additions and 110 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
+59 -17
View File
@@ -15,9 +15,9 @@ 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 + reset | cron jobs, one-off jobs |
--- ---
@@ -55,23 +55,31 @@ 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.
> Talking point — visitor identity: each browser also gets a `gb_visitor`
> cookie, and the wall greets it ("you are guest-… · N signatures"). The
> backend only accepts the cookie while its id exists in the database — in
> the admin console, the **Visitors** tile and table show every identity
> issued, and **Visiting** counts the ones seen in the last 5 minutes.
> Reactions are keyed on the same identity.
## 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 +89,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,28 +119,59 @@ 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 and reset
The binary has a `cleanup` subcommand that permanently removes deleted The binary has a `cleanup` subcommand that permanently removes deleted
signatures and optionally expires old ones: signatures, optionally expires old ones, and prunes idle visitor identities:
```bash ```bash
guestbook cleanup --purge-after 168h # default: purge deletes older than 7 days guestbook cleanup --purge-after 168h # default: purge deletes older than 7 days
guestbook cleanup --max-age 720h --dry-run # preview expiring >30-day-old, unpinned entries guestbook cleanup --max-age 720h --dry-run # preview expiring >30-day-old, unpinned entries
guestbook cleanup --prune-visitors 720h # forget identities idle for 30 days (and their reactions)
``` ```
1. **Jobs** → *New cronjob*, image blank (uses the app's image), command `/app/guestbook cleanup --purge-after 1h`, schedule `*/15 * * * *`. 1. **Jobs** → *New cronjob*, image blank (uses the app's image), command `/app/guestbook cleanup --purge-after 1h`, schedule `*/15 * * * *`.
2. Delete a signature in **Moderation**, then **Run now**. The job log shows `cleanup finished purged=… remaining=…`. 2. Delete a signature in **Moderation**, then **Run now**. The job log shows `cleanup finished purged=… visitors_pruned=… remaining=…`.
There is also a `reset` subcommand to **clean the database** between sessions
— entries, reactions and visitor identities go (soft-deleted rows are wiped
for good), wall settings are kept unless `--hard`:
```bash
guestbook reset --dry-run # report what would be removed
guestbook reset --reseed # wipe, then insert the sample signatures
guestbook reset --hard --reseed # also reset banner/pause/theme
```
3. **Jobs** → *New job*, command `/app/guestbook reset --reseed`. The wall
empties immediately; every browser's `gb_visitor` cookie stops matching a
database row, so on the next request each visitor is handed a **fresh
identity** — the "you are …" card on the wall changes, and the admin
Visitors table starts refilling from zero.
`guestbook migrate` works the same way as a one-off **Job**, for teams that `guestbook migrate` works the same way as a one-off **Job**, for teams that
want migrations as an explicit step rather than on boot. want migrations as an explicit step rather than on boot.
@@ -141,6 +180,9 @@ 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: run `/app/guestbook reset --reseed` as a one-off **Job**
(entries, reactions and visitor identities go; the wall is re-seeded), or
detach/recreate the database. Browsers pick up a fresh identity cookie on
their next request either way.
+74 -24
View File
@@ -18,6 +18,23 @@ 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; the visitor identity 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
- **Visitor identity cookie** (new in v3) — every browser gets a `gb_visitor`
cookie whose UUID the backend checks **against the database** on each API
request: unknown ids (first visit, pruned identity, or a wiped database) are
discarded and regenerated. The identity remembers the name you signed with,
drives the reaction de-dupe, and feeds a live **“N visiting”** counter
(distinct from connected SSE clients)
- **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,13 +51,18 @@ 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,
- **Scheduled-job friendly CLI** — `guestbook cleanup` (purge/expire) and plus an in-browser traffic probe that shows load-balancing and canary splits
`guestbook migrate` subcommands for Miabi cron jobs and one-off jobs - **Scheduled-job friendly CLI** — `guestbook cleanup` (purge/expire/prune
visitors) and `guestbook migrate` for Miabi cron jobs, plus
**`guestbook reset`** to wipe the wall (entries, reactions, identities;
`--reseed`, `--hard`) as a one-off job
**Presenting it?** [`DEMO.md`](DEMO.md) is a step-by-step training runbook. **Presenting it?** [`DEMO.md`](DEMO.md) is a step-by-step training runbook.
- **Single static binary** — the whole UI is embedded with `go:embed`, so the - **Single static binary** — the whole UI is embedded with `go:embed`, so the
@@ -60,13 +82,16 @@ 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, active `visitors`, `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/me` | The caller's visitor identity: id, remembered name, signature count, first seen |
| `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 +102,12 @@ 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 + visitor 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/visitors` | Recent visitor identities (name remembered, signature count, last seen; `?limit=`) |
| `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 +129,39 @@ 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.
### Visitor identity
Every API request passes through middleware that resolves the caller's
**visitor identity**: if the `gb_visitor` cookie is missing, malformed, or its
id is **not found in the `visitors` table**, the backend mints a new UUID,
persists it and rewrites the cookie. Known ids are kept and stamped
(`last_seen`). The identity:
- remembers the **name** you signed with (pre-filled next time, used for
unsigned posts),
- keys **reaction** de-duplication (replacing the old standalone reaction
cookie),
- feeds the **“N visiting”** counter on the wall, in `/api/info`, the admin
overview and the `tick`/`presence` SSE events (identities seen in the last
5 minutes — read from the database, so it is cluster-wide with no Redis).
Because validity lives in the database, a `guestbook reset` (or pruning by the
`cleanup` job) invalidates every outstanding cookie: browsers silently get a
fresh identity on their next request.
## Configuration ## Configuration
@@ -157,7 +206,8 @@ go run . # runs the default "server" command
go run . server -p 9000 # okapicli flags: choose the port go run . server -p 9000 # okapicli flags: choose the port
go run . --help # list commands and flags go run . --help # list commands and flags
go run . migrate # apply migrations and exit go run . migrate # apply migrations and exit
go run . cleanup --purge-after 168h --max-age 720h --dry-run go run . cleanup --purge-after 168h --max-age 720h --prune-visitors 720h --dry-run
go run . reset --dry-run # preview a full wipe; add --reseed to refill the wall
``` ```
To point it at your own Postgres, set `DATABASE_URL` (or the `DB_*` vars) first: To point it at your own Postgres, set `DATABASE_URL` (or the `DB_*` vars) first:
+22
View File
@@ -81,6 +81,7 @@ func (h *Handler) AdminOverview(c *okapi.Context) error {
"stats": stats, "stats": stats,
"settings": settings, "settings": settings,
"online": h.broker.Online(), "online": h.broker.Online(),
"visitors": h.visitorsActive(ctx),
"replicas": h.broker.Replicas(), "replicas": h.broker.Replicas(),
"debug": h.debug, "debug": h.debug,
"runtime": okapi.M{ "runtime": okapi.M{
@@ -93,6 +94,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(),
@@ -126,6 +128,18 @@ func (h *Handler) AdminListEntries(c *okapi.Context) error {
return c.OK(okapi.M{"entries": out, "total": total, "limit": limit, "offset": offset}) return c.OK(okapi.M{"entries": out, "total": total, "limit": limit, "offset": offset})
} }
// AdminVisitors lists the most recently active visitor identities, so the
// console can show the cookies the app has issued and who is around now.
func (h *Handler) AdminVisitors(c *okapi.Context) error {
limit := atoiDefault(c.Query("limit"), 50)
visitors, err := h.store.AdminVisitors(c.Request().Context(), limit)
if err != nil {
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not load visitors"})
}
fresh := time.Now().Add(-visitorFreshWindow)
return c.OK(okapi.M{"visitors": visitors, "active_since": fresh.UTC().Format(time.RFC3339)})
}
// AdminUpdateEntry pins/unpins or hides/unhides an entry and broadcasts it. // AdminUpdateEntry pins/unpins or hides/unhides an entry and broadcasts it.
func (h *Handler) AdminUpdateEntry(c *okapi.Context) error { func (h *Handler) AdminUpdateEntry(c *okapi.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
@@ -167,6 +181,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"})
} }
+45 -15
View File
@@ -17,27 +17,37 @@ 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)
// - "presence" → Online/Replicas are set (connected clients changed) // - "settings" → Settings is set (signing paused, banner changed, theme forced)
// - "tick" → Time/Version/Host are set (live server clock; new in v2) // - "presence" → Online/Replicas/Visitors are set (connected clients or active
// visitor identities changed)
// - "tick" → Time/Version/Host are set (live server clock; new in v2),
// plus Visitors (active identities; new in v3)
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"`
Visitors int64 `json:"visitors,omitempty"`
Time string `json:"time,omitempty"` Time string `json:"time,omitempty"`
Version string `json:"version,omitempty"` Version string `json:"version,omitempty"`
Host string `json:"host,omitempty"` Host string `json:"host,omitempty"`
} }
// 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 +71,10 @@ type Broker struct {
host, version string host, version string
rdb *redis.Client rdb *redis.Client
dirty chan struct{} dirty chan struct{}
requests func() int64
// visitors reports how many visitor identities were recently active (from
// the database, so it is cluster-wide by construction); may be nil.
visitors func() int64
cmu sync.RWMutex cmu sync.RWMutex
replicas []Replica replicas []Replica
@@ -68,12 +82,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 +220,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 {
@@ -214,7 +235,7 @@ func (b *Broker) localCount() int {
func (b *Broker) presenceChanged() { func (b *Broker) presenceChanged() {
if b.rdb == nil { if b.rdb == nil {
b.emit(Event{Type: "presence", Online: b.localCount(), Replicas: 1}) b.emit(Event{Type: "presence", Online: b.localCount(), Replicas: 1, Visitors: b.visitorsActive()})
return return
} }
select { select {
@@ -223,6 +244,15 @@ func (b *Broker) presenceChanged() {
} }
} }
// visitorsActive returns the recent-visitor count, or 0 when no reporter is
// registered.
func (b *Broker) visitorsActive() int64 {
if b.visitors == nil {
return 0
}
return b.visitors()
}
func (b *Broker) publishRaw(ctx context.Context, ev Event) error { func (b *Broker) publishRaw(ctx context.Context, ev Event) error {
payload, err := json.Marshal(ev) payload, err := json.Marshal(ev)
if err != nil { if err != nil {
@@ -306,7 +336,7 @@ func (b *Broker) refresh(ctx context.Context) {
b.cmu.Unlock() b.cmu.Unlock()
if changed { if changed {
b.emit(Event{Type: "presence", Online: total, Replicas: len(replicas)}) b.emit(Event{Type: "presence", Online: total, Replicas: len(replicas), Visitors: b.visitorsActive()})
} }
} }
+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)
+1 -1
View File
@@ -4,6 +4,7 @@ go 1.26.3
require ( require (
github.com/glebarez/sqlite v1.11.0 github.com/glebarez/sqlite v1.11.0
github.com/google/uuid v1.6.0
github.com/jkaninda/go-utils v0.1.4 github.com/jkaninda/go-utils v0.1.4
github.com/jkaninda/logger v0.0.5 github.com/jkaninda/logger v0.0.5
github.com/jkaninda/okapi v1.0.0 github.com/jkaninda/okapi v1.0.0
@@ -21,7 +22,6 @@ require (
github.com/go-openapi/jsonpointer v0.22.5 // indirect github.com/go-openapi/jsonpointer v0.22.5 // indirect
github.com/go-openapi/swag/jsonname v0.25.5 // indirect github.com/go-openapi/swag/jsonname v0.25.5 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect github.com/jackc/pgx/v5 v5.6.0 // indirect
+46 -16
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,16 +89,30 @@ 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(),
"visitors": h.visitorsActive(c.Request().Context()),
}) })
} }
// visitorsActive counts identities seen in the last few minutes — the
// "currently visiting" number. It degrades to 0 on a database hiccup; the
// SSE presence event simply carries whatever the database last said.
func (h *Handler) visitorsActive(ctx context.Context) int64 {
n, err := h.store.VisitorsSeenAfter(ctx, time.Now().Add(-visitorFreshWindow))
if err != nil {
return 0
}
return n
}
// PublicSettings returns the wall state visitors need: paused flag and banner. // PublicSettings returns the wall state visitors need: paused flag and banner.
func (h *Handler) PublicSettings(c *okapi.Context) error { func (h *Handler) PublicSettings(c *okapi.Context) error {
st, err := h.store.Settings(c.Request().Context()) st, err := h.store.Settings(c.Request().Context())
@@ -166,9 +184,6 @@ func (h *Handler) CreateEntry(c *okapi.Context) error {
name := strings.TrimSpace(req.Name) name := strings.TrimSpace(req.Name)
message := strings.TrimSpace(req.Message) message := strings.TrimSpace(req.Message)
if name == "" {
name = "Anonymous"
}
if r := []rune(name); len(r) > maxNameLen { if r := []rune(name); len(r) > maxNameLen {
name = string(r[:maxNameLen]) name = string(r[:maxNameLen])
} }
@@ -181,10 +196,24 @@ func (h *Handler) CreateEntry(c *okapi.Context) error {
}) })
} }
// The visitor identity ties the signature to a browser: an unsigned post
// keeps the name the visitor used before, and a signed post is remembered
// for next time.
visitor := visitorFromContext(c)
if name == "" {
name = visitorName(visitor)
}
if name == "" {
name = "Anonymous"
}
entry, err := h.store.Create(ctx, name, message, h.host, clientIP(c)) entry, err := h.store.Create(ctx, name, message, h.host, clientIP(c))
if err != nil { if err != nil {
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not save entry"}) return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not save entry"})
} }
if !visitor.IsZero() {
h.store.VisitorSigned(ctx, visitor.ID, name)
}
h.broker.Publish(Event{Type: "created", Entry: &entry}) h.broker.Publish(Event{Type: "created", Entry: &entry})
return c.Created(entry) return c.Created(entry)
@@ -227,6 +256,7 @@ func (h *Handler) Stream(c *okapi.Context) error {
"online": h.broker.Online(), "online": h.broker.Online(),
"replicas": len(h.broker.Replicas()), "replicas": len(h.broker.Replicas()),
"host": h.host, "host": h.host,
"visitors": h.visitorsActive(ctx),
}} }}
select { select {
case msgs <- welcome: case msgs <- welcome:
+82 -10
View File
@@ -10,7 +10,7 @@
// /healthz readiness probe, and a single static binary that ships the whole // /healthz readiness probe, and a single static binary that ships the whole
// frontend embedded — no Node build step. // frontend embedded — no Node build step.
// //
// Subcommands (okapicli): server (default), migrate, cleanup. // Subcommands (okapicli): server (default), migrate, cleanup, reset.
package main package main
import ( import (
@@ -61,6 +61,14 @@ func main() {
}). }).
Duration("purge-after", "", 7*24*time.Hour, "Permanently remove entries deleted longer ago than this"). Duration("purge-after", "", 7*24*time.Hour, "Permanently remove entries deleted longer ago than this").
Duration("max-age", "", 0, "Also delete unpinned entries older than this (0 = keep forever)"). Duration("max-age", "", 0, "Also delete unpinned entries older than this (0 = keep forever)").
Duration("prune-visitors", "", 30*24*time.Hour, "Forget visitor identities idle longer than this (0 = keep forever)").
Bool("dry-run", "", false, "Report what would be removed without changing anything")
cli.Command("reset", "Wipe the database: entries, reactions and visitor identities (run as a one-off job)", func(cmd *okapicli.Command) error {
return runReset(cmd, cfg)
}).
Bool("hard", "", false, "Also reset the wall settings (pause, banner, theme)").
Bool("reseed", "", false, "Insert the sample signatures after the wipe").
Bool("dry-run", "", false, "Report what would be removed without changing anything") Bool("dry-run", "", false, "Report what would be removed without changing anything")
cli.DefaultCommand("server") cli.DefaultCommand("server")
@@ -85,7 +93,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 +114,8 @@ 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
broker.visitors = func() int64 { return h.visitorsActive(context.Background()) }
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 +126,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{
@@ -138,25 +148,32 @@ func runServer(cmd *okapicli.Command, cfg Config) error {
func registerRoutes(app *okapi.Okapi, h *Handler) { func registerRoutes(app *okapi.Okapi, h *Handler) {
app.Get("/healthz", h.Health) app.Get("/healthz", h.Health)
api := app.Group("/api") // Every API request resolves the visitor identity: the cookie is checked
// against the database and regenerated when its id is unknown (fresh
// browser, pruned identity, or a reset database).
api := app.Group("/api", h.identifyVisitor)
api.Get("/info", h.Info) api.Get("/info", h.Info)
api.Get("/time", h.Time) api.Get("/time", h.Time)
api.Get("/settings", h.PublicSettings) api.Get("/settings", h.PublicSettings)
api.Get("/me", h.VisitorMe)
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)
api.Post("/admin/login", h.AdminLogin) api.Post("/admin/login", h.AdminLogin)
api.Post("/admin/logout", h.AdminLogout) api.Post("/admin/logout", h.AdminLogout)
admin := app.Group("/api/admin", h.auth.Require) admin := app.Group("/api/admin", h.identifyVisitor, h.auth.Require)
admin.Get("/overview", h.AdminOverview) admin.Get("/overview", h.AdminOverview)
admin.Get("/entries", h.AdminListEntries) admin.Get("/entries", h.AdminListEntries)
admin.Patch("/entries/{id:int}", h.AdminUpdateEntry) admin.Patch("/entries/{id:int}", h.AdminUpdateEntry)
admin.Delete("/entries/{id:int}", h.DeleteEntry) admin.Delete("/entries/{id:int}", h.DeleteEntry)
admin.Get("/settings", h.AdminSettings) admin.Get("/settings", h.AdminSettings)
admin.Put("/settings", h.AdminSaveSettings) admin.Put("/settings", h.AdminSaveSettings)
admin.Get("/visitors", h.AdminVisitors)
admin.Get("/export.csv", h.AdminExport) admin.Get("/export.csv", h.AdminExport)
debug := app.Group("/api/debug", h.auth.Require, h.RequireDebug) debug := app.Group("/api/debug", h.auth.Require, h.RequireDebug)
@@ -176,6 +193,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"})
@@ -202,8 +225,56 @@ func runCleanup(cmd *okapicli.Command, cfg Config) error {
return err return err
} }
} }
var pruned int64
if prune := cmd.GetDuration("prune-visitors"); prune > 0 {
if pruned, err = store.PurgeVisitors(ctx, now.Add(-prune), dryRun); err != nil {
return err
}
}
remaining, _ := store.Count(ctx) remaining, _ := store.Count(ctx)
logger.Info("cleanup finished", "purged", purged, "expired", expired, "remaining", remaining, "dry_run", dryRun) logger.Info("cleanup finished",
"purged", purged, "expired", expired, "visitors_pruned", pruned,
"remaining", remaining, "dry_run", dryRun)
return nil
}
// runReset wipes the database (the "clean slate" job): entries, reactions and
// visitor identities go, settings stay unless --hard. With --reseed the sample
// signatures are inserted right away; otherwise restart the app so its first
// boot seeds the empty wall. The wall's cookies stop matching any visitor row
// after a reset, so browsers get a fresh identity on their next request.
func runReset(cmd *okapicli.Command, cfg Config) error {
ctx := context.Background()
store, err := openStore(cfg)
if err != nil {
return err
}
defer func() { _ = store.Close() }()
dryRun := cmd.GetBool("dry-run")
hard := cmd.GetBool("hard")
stats, err := store.Reset(ctx, hard, dryRun)
if err != nil {
return err
}
if dryRun {
logger.Info("reset dry run, nothing removed",
"entries", stats.Entries, "reactions", stats.Reactions,
"visitors", stats.Visitors, "settings", stats.Settings)
return nil
}
logger.Warn("database reset",
"entries", stats.Entries, "reactions", stats.Reactions,
"visitors", stats.Visitors, "settings", stats.Settings, "hard", hard)
if cmd.GetBool("reseed") {
if err := Seed(ctx, store, cfg.DBDriver); err != nil {
return err
}
} else {
logger.Info("wall is empty; restart the app to re-seed it")
}
return nil return nil
} }
@@ -233,10 +304,11 @@ func publishTicks(ctx context.Context, b *Broker, version, host string) {
return return
case now := <-t.C: case now := <-t.C:
b.PublishLocal(Event{ b.PublishLocal(Event{
Type: "tick", Type: "tick",
Time: now.Format(time.RFC3339), Time: now.Format(time.RFC3339),
Version: version, Version: version,
Host: host, Host: host,
Visitors: b.visitorsActive(),
}) })
} }
} }
+79
View File
@@ -0,0 +1,79 @@
package main
import (
"errors"
"net/http"
"strconv"
"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.
// The visitor identity cookie (see visitors.go) identifies the browser, so a
// count can't be inflated by hammering the endpoint.
// 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 := visitorFromContext(c).ID
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 := visitorFromContext(c).ID
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 boot. 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)
}
}
+315 -10
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -10,9 +11,11 @@ import (
"time" "time"
"github.com/glebarez/sqlite" // pure-Go SQLite (no CGO — keeps the static build) "github.com/glebarez/sqlite" // pure-Go SQLite (no CGO — keeps the static build)
"github.com/google/uuid" // visitor identity ids (v4 UUIDs)
"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,8 +30,40 @@ 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; the "who" is their visitor id (see Visitor), so the
// same browser cannot inflate 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:"-"`
}
// Visitor is a browser the app knows about. The identity cookie carries the
// UUID; the row in this table is what makes it valid — a cookie whose id is
// not in the database is discarded and regenerated. That keeps identities
// honest across database resets and lets stale identities be pruned by the
// cleanup job.
type Visitor struct {
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
// Name is the last name the visitor signed with; the form pre-fills it.
Name string `gorm:"type:varchar(60)" json:"name,omitempty"`
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `gorm:"index" json:"last_seen"`
Signatures int64 `gorm:"not null;default:0" json:"signatures"`
}
// IsZero reports whether v is an unidentified visitor (no valid cookie).
func (v *Visitor) IsZero() bool { return v == nil || v.ID == "" }
// 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.
type Setting struct { type Setting struct {
Key string `gorm:"primaryKey;type:varchar(64)"` Key string `gorm:"primaryKey;type:varchar(64)"`
@@ -40,6 +75,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 +94,32 @@ 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"`
Visitors int64 `json:"visitors"`
Daily []DayCount `json:"daily"`
} }
const settingsKey = "settings" const settingsKey = "settings"
@@ -140,7 +196,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{}, &Visitor{})
}) })
} }
@@ -160,7 +216,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 +229,243 @@ 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
}
// FindVisitor loads a visitor by id. Returns false when the id is unknown —
// the caller then discards the cookie and issues a fresh identity.
func (s *Store) FindVisitor(ctx context.Context, id string) (Visitor, bool, error) {
var v Visitor
err := s.db.WithContext(ctx).First(&v, "id = ?", id).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return v, false, nil
}
return v, err == nil, err
}
// CreateVisitor inserts a new identity with the given UUID.
func (s *Store) CreateVisitor(ctx context.Context, id string) (Visitor, error) {
now := time.Now().UTC()
v := Visitor{ID: id, FirstSeen: now, LastSeen: now}
err := s.db.WithContext(ctx).Create(&v).Error
return v, err
}
// TouchVisitor stamps the visitor's last-seen time.
func (s *Store) TouchVisitor(ctx context.Context, id string, at time.Time) {
s.db.WithContext(ctx).Model(&Visitor{}).Where("id = ?", id).Update("last_seen", at)
}
// VisitorSigned records a new signature: it bumps the counter and remembers
// the name, so the form can be pre-filled on the next visit.
func (s *Store) VisitorSigned(ctx context.Context, id, name string) {
s.db.WithContext(ctx).Model(&Visitor{}).
Where("id = ?", id).
Updates(map[string]any{"name": name, "signatures": gorm.Expr("signatures + 1")})
}
// VisitorCount returns the number of identities the app has issued.
func (s *Store) VisitorCount(ctx context.Context) (int64, error) {
var n int64
err := s.db.WithContext(ctx).Model(&Visitor{}).Count(&n).Error
return n, err
}
// VisitorsSeenAfter counts visitors active since the given time — the
// "currently visiting" number, distinct from connected SSE clients.
func (s *Store) VisitorsSeenAfter(ctx context.Context, since time.Time) (int64, error) {
var n int64
err := s.db.WithContext(ctx).Model(&Visitor{}).Where("last_seen >= ?", since).Count(&n).Error
return n, err
}
// AdminVisitors lists the most recently active visitors.
func (s *Store) AdminVisitors(ctx context.Context, limit int) ([]Visitor, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
var visitors []Visitor
err := s.db.WithContext(ctx).Order("last_seen DESC").Limit(limit).Find(&visitors).Error
return visitors, err
}
// PurgeVisitors permanently removes visitors idle since cutoff, together with
// their reactions, so a later signup with a fresh cookie starts clean. It
// returns the number of visitors removed.
func (s *Store) PurgeVisitors(ctx context.Context, cutoff time.Time, dryRun bool) (int64, error) {
scope := s.db.WithContext(ctx).Where("last_seen < ?", cutoff)
if dryRun {
var n int64
err := scope.Model(&Visitor{}).Count(&n).Error
return n, err
}
var ids []string
if err := scope.Model(&Visitor{}).Pluck("id", &ids).Error; err != nil || len(ids) == 0 {
return 0, err
}
if err := s.db.WithContext(ctx).Where("who IN ?", ids).Delete(&Reaction{}).Error; err != nil {
return 0, err
}
res := s.db.WithContext(ctx).Where("id IN ?", ids).Delete(&Visitor{})
return res.RowsAffected, res.Error
}
// ResetStats reports what Reset removed (or would remove, when DryRun).
type ResetStats struct {
Entries int64 `json:"entries"`
Reactions int64 `json:"reactions"`
Visitors int64 `json:"visitors"`
Settings int64 `json:"settings"`
DryRun bool `json:"dry_run"`
}
// Reset wipes the wall so a demo can start from scratch (typically run as a
// one-off Job, followed by a restart to re-seed). Entries and reactions
// always go; settings are kept unless hard is set. With dryRun it only
// reports what would be removed.
func (s *Store) Reset(ctx context.Context, hard, dryRun bool) (ResetStats, error) {
var out ResetStats
out.DryRun = dryRun
err := s.Exclusive(ctx, func(tx *Store) error {
count := func(model any, dst *int64, unscoped bool) error {
q := tx.db.WithContext(ctx).Model(model)
if unscoped {
q = q.Unscoped()
}
return q.Count(dst).Error
}
// Soft-deleted entries are gone for good too: after a reset there is
// nothing left to purge.
if err := count(&Entry{}, &out.Entries, true); err != nil {
return err
}
if err := count(&Reaction{}, &out.Reactions, false); err != nil {
return err
}
if err := count(&Visitor{}, &out.Visitors, false); err != nil {
return err
}
if hard {
if err := count(&Setting{}, &out.Settings, false); err != nil {
return err
}
}
if dryRun {
return nil
}
wipe := func(model any) error {
// "WHERE 1=1" satisfies GORM's global-delete guard.
return tx.db.WithContext(ctx).Unscoped().Where("1 = 1").Delete(model).Error
}
for _, model := range []any{&Entry{}, &Reaction{}, &Visitor{}} {
if err := wipe(model); err != nil {
return err
}
}
if hard {
return wipe(&Setting{})
}
return nil
})
return out, err
}
// NewVisitorID returns a random UUID for a fresh identity.
func NewVisitorID() string { return uuid.NewString() }
// ValidVisitorID reports whether id is a syntactically valid visitor id.
// A malformed cookie is discarded without hitting the database.
func ValidVisitorID(id string) bool {
return len(id) == 36 && uuid.Validate(id) == nil
}
// 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 +502,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 +535,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 +577,8 @@ 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)
s.db.WithContext(ctx).Model(&Visitor{}).Count(&st.Visitors)
st.Visible = st.Total - st.Hidden st.Visible = st.Total - st.Hidden
now := time.Now().UTC() now := time.Now().UTC()
+120
View File
@@ -0,0 +1,120 @@
package main
import (
"net/http"
"strings"
"time"
"github.com/jkaninda/okapi"
)
// Visitor identity. Until now the only cookie was the reaction de-dupe token:
// accepted blindly, never checked against anything. The wall now issues a
// first-party identity cookie and keeps the matching row in the database, so
// the backend can tell a browser it has seen before from one it has not.
//
// The flow, on every API request (see identifyVisitor):
//
// 1. no cookie / malformed id → mint a new UUID, persist it, set the cookie
// 2. cookie with a known id → the visitor is recognised (last_seen stamped)
// 3. cookie with an unknown id → the database was reset, the identity was
// pruned by the cleanup job, or the value was forged: discard it and
// generate a fresh identity as in (1)
//
// Identities are anonymous (a random UUID), recognised — never authenticated.
const (
visitorCookie = "gb_visitor"
visitorCookieTTL = 365 * 24 * time.Hour
// visitorFreshWindow is how long an identity counts as "visiting now".
visitorFreshWindow = 5 * time.Minute
)
// visitorCtxKey is where identifyVisitor stashes the resolved *Visitor.
const visitorCtxKey = "gb.visitor"
// identifyVisitor is middleware that resolves (or creates) the caller's
// visitor identity for every API request and stores it on the context; see
// the package comment above for the flow.
func (h *Handler) identifyVisitor(c *okapi.Context) error {
visitor, fresh, err := h.resolveVisitor(c)
if err != nil {
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not resolve visitor identity"})
}
if fresh {
h.setVisitorCookie(c, visitor.ID)
}
c.Set(visitorCtxKey, &visitor)
return c.Next()
}
// resolveVisitor returns the caller's identity; fresh reports whether a new
// visitor row was created and the cookie must be (re)set.
func (h *Handler) resolveVisitor(c *okapi.Context) (visitor Visitor, fresh bool, err error) {
ctx := c.Request().Context()
if id, cerr := c.Cookie(visitorCookie); cerr == nil && ValidVisitorID(id) {
visitor, found, err := h.store.FindVisitor(ctx, id)
if err != nil {
return Visitor{}, false, err
}
if found {
// Refresh the cookie's lifetime while the visitor stays active, and
// stamp the visit so presence and pruning have something to work with.
h.setVisitorCookie(c, id)
h.store.TouchVisitor(ctx, id, time.Now().UTC())
return visitor, false, nil
}
// Known shape, unknown id: fall through and mint a new identity.
}
visitor, err = h.store.CreateVisitor(ctx, NewVisitorID())
if err != nil {
return Visitor{}, false, err
}
return visitor, true, nil
}
// setVisitorCookie writes (or refreshes) the identity cookie.
func (h *Handler) setVisitorCookie(c *okapi.Context, id string) {
r := c.Request()
http.SetCookie(c.ResponseWriter(), &http.Cookie{
Name: visitorCookie,
Value: id,
Path: "/",
MaxAge: int(visitorCookieTTL.Seconds()),
HttpOnly: true,
Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https",
SameSite: http.SameSiteLaxMode,
})
}
// visitorFromContext returns the identity resolved by the middleware; the
// zero value means "unidentified" (e.g. a request that bypassed the group).
func visitorFromContext(c *okapi.Context) *Visitor {
if v, ok := c.Get(visitorCtxKey); ok {
if visitor, ok := v.(*Visitor); ok {
return visitor
}
}
return &Visitor{}
}
// VisitorMe lets the UI learn its own identity: whether the cookie was just
// (re)generated and the remembered name to pre-fill the form with.
func (h *Handler) VisitorMe(c *okapi.Context) error {
v := visitorFromContext(c)
return c.OK(okapi.M{
"id": v.ID,
"name": v.Name,
"first_seen": v.FirstSeen.UTC().Format(time.RFC3339),
"signatures": v.Signatures,
})
}
// visitorName returns the name under which the visitor last signed, so the
// server can attach it to an unsigned signature.
func visitorName(v *Visitor) string {
if v == nil {
return ""
}
return strings.TrimSpace(v.Name)
}
+210
View File
@@ -0,0 +1,210 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/jkaninda/okapi"
)
func TestVisitorLifecycle(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
if !ValidVisitorID(NewVisitorID()) {
t.Fatal("a freshly minted id should validate")
}
for _, bad := range []string{"", "not-a-uuid", "550e8400-e29b-41d4-a716-44665544000z"} {
if ValidVisitorID(bad) {
t.Fatalf("invalid id accepted: %q", bad)
}
}
// A cookie the database does not know about must be regenerated.
if _, found, err := s.FindVisitor(ctx, NewVisitorID()); err != nil || found {
t.Fatalf("unknown visitor: found=%v err=%v", found, err)
}
id := NewVisitorID()
v, err := s.CreateVisitor(ctx, id)
if err != nil {
t.Fatal(err)
}
if v.ID != id || v.Name != "" || v.Signatures != 0 {
t.Fatalf("new visitor = %+v", v)
}
if _, found, err := s.FindVisitor(ctx, id); err != nil || !found {
t.Fatalf("known visitor: found=%v err=%v", found, err)
}
s.VisitorSigned(ctx, id, "Ada")
got, found, _ := s.FindVisitor(ctx, id)
if !found || got.Name != "Ada" || got.Signatures != 1 {
t.Fatalf("after signing: %+v", got)
}
// Freshness drives the "visiting now" number (2 minutes ago is still fresh).
s.db.Model(&Visitor{}).Where("id = ?", id).Update("last_seen", time.Now().Add(-2*time.Minute))
if n, _ := s.VisitorsSeenAfter(ctx, time.Now().Add(-visitorFreshWindow)); n != 1 {
t.Fatalf("active visitors = %d, want 1", n)
}
stale := time.Now().Add(-48 * time.Hour)
s.db.Model(&Visitor{}).Where("id = ?", id).Update("last_seen", stale)
if n, _ := s.VisitorsSeenAfter(ctx, time.Now().Add(-visitorFreshWindow)); n != 0 {
t.Fatalf("active visitors after going stale = %d, want 0", n)
}
// Pruning removes the identity and its reactions.
e, _ := s.Create(ctx, "ada", "hello", "r1", "")
if _, err := s.React(ctx, uint64(e.ID), "heart", id); err != nil {
t.Fatal(err)
}
if n, _ := s.PurgeVisitors(ctx, time.Now().Add(-24*time.Hour), true); n != 1 {
t.Fatalf("dry-run prune = %d, want 1", n)
}
if n, _ := s.PurgeVisitors(ctx, time.Now().Add(-24*time.Hour), false); n != 1 {
t.Fatalf("prune = %d, want 1", n)
}
if _, found, _ := s.FindVisitor(ctx, id); found {
t.Fatal("pruned visitor still found")
}
if n, _ := s.ReactionTotals(ctx); n != 0 {
t.Fatalf("reactions of the pruned visitor = %d, want 0", n)
}
}
// TestVisitorCookieFlow exercises the middleware over HTTP: no cookie → one is
// issued and persisted; a known cookie is kept; a cookie the database no
// longer knows about is regenerated.
func TestVisitorCookieFlow(t *testing.T) {
s := newTestStore(t)
h := &Handler{store: s, broker: NewBroker("test", "dev", nil), auth: NewAdminAuth("")}
app := okapi.New()
api := app.Group("/api", h.identifyVisitor)
api.Get("/me", h.VisitorMe)
call := func(cookie string) (*httptest.ResponseRecorder, map[string]any) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
if cookie != "" {
req.Header.Set("Cookie", visitorCookie+"="+cookie)
}
app.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
return rec, body
}
// First visit: the backend mints an identity, persists it and sets the cookie.
rec, first := call("")
issued := ""
for _, c := range rec.Result().Cookies() {
if c.Name == visitorCookie {
issued = c.Value
}
}
if issued == "" || first["id"] != issued {
t.Fatalf("first visit: cookie %q, body %v", issued, first)
}
if _, found, _ := s.FindVisitor(context.Background(), issued); !found {
t.Fatal("issued identity was not persisted")
}
// Second visit with the cookie: recognised, identity unchanged.
_, second := call(issued)
if second["id"] != issued {
t.Fatalf("known cookie should keep its identity: %v vs %v", second["id"], issued)
}
// A cookie the database does not know (e.g. after a reset) is regenerated.
rec, third := call(NewVisitorID())
regenerated := third["id"].(string)
if !ValidVisitorID(regenerated) || regenerated == issued {
t.Fatalf("unknown cookie should be regenerated, got %v", third)
}
if !cookieSetTo(rec, visitorCookie, regenerated) {
t.Fatalf("regenerated identity was not written back to the cookie: %v", rec.Header())
}
// A malformed cookie never reaches the database and gets a fresh identity.
_, fourth := call("not-a-uuid")
if !ValidVisitorID(fourth["id"].(string)) {
t.Fatalf("malformed cookie should yield a fresh identity, got %v", fourth)
}
}
func cookieSetTo(rec *httptest.ResponseRecorder, name, value string) bool {
for _, c := range rec.Result().Cookies() {
if c.Name == name {
return c.Value == value
}
}
return false
}
func TestReset(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
e, _ := s.Create(ctx, "ada", "hello", "r1", "")
gone, _ := s.Create(ctx, "gone", "deleted", "r1", "")
_, _ = s.Delete(ctx, uint64(gone.ID))
who, _ := s.CreateVisitor(ctx, NewVisitorID())
if _, err := s.React(ctx, uint64(e.ID), "tada", who.ID); err != nil {
t.Fatal(err)
}
_ = s.SaveSettings(ctx, Settings{SigningPaused: true, Banner: "hi"})
// Dry run reports but changes nothing.
dry, err := s.Reset(ctx, false, true)
if err != nil {
t.Fatal(err)
}
if dry.Entries != 2 || dry.Reactions != 1 || dry.Visitors != 1 || dry.Settings != 0 {
t.Fatalf("dry run = %+v", dry)
}
if n, _ := s.Count(ctx); n != 1 {
t.Fatalf("dry run removed entries: %d left", n)
}
// A soft reset keeps the settings, and soft-deleted rows are gone for good.
stats, err := s.Reset(ctx, false, false)
if err != nil {
t.Fatal(err)
}
if stats.Entries != 2 || stats.Reactions != 1 || stats.Visitors != 1 {
t.Fatalf("reset = %+v", stats)
}
if n, _ := s.Count(ctx); n != 0 {
t.Fatalf("entries after reset = %d, want 0", n)
}
if n, _ := s.ReactionTotals(ctx); n != 0 {
t.Fatalf("reactions after reset = %d, want 0", n)
}
if n, _ := s.VisitorCount(ctx); n != 0 {
t.Fatalf("visitors after reset = %d, want 0", n)
}
if n, _ := s.Purge(ctx, time.Now().Add(time.Hour), false); n != 0 {
t.Fatalf("purged after reset = %d, want 0 (soft-deleted rows wiped too)", n)
}
if st, _ := s.Settings(ctx); !st.SigningPaused || st.Banner != "hi" {
t.Fatalf("settings should survive a soft reset, got %+v", st)
}
// Hard reset drops the settings as well.
if _, err := s.Reset(ctx, true, false); err != nil {
t.Fatal(err)
}
if st, _ := s.Settings(ctx); st.SigningPaused || st.Banner != "" {
t.Fatalf("settings should be cleared by a hard reset, got %+v", st)
}
}
+121 -3
View File
@@ -108,6 +108,8 @@
td.mono { font-family: var(--mono); font-size: 12.5px; word-break: break-all; } td.mono { font-family: var(--mono); font-size: 12.5px; word-break: break-all; }
tr.me td { color: #fff; } tr.me td { color: #fff; }
tr.me td:first-child::after { content: " ← you"; color: var(--accent-2); font-family: inherit; font-size: 11.5px; } tr.me td:first-child::after { content: " ← you"; color: var(--accent-2); font-family: inherit; font-size: 11.5px; }
td.fresh { color: var(--ok); }
td.fresh::before { content: "● "; font-size: 10px; }
dl.kv { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; margin: 0; font-size: 13.5px; } dl.kv { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; margin: 0; font-size: 13.5px; }
dl.kv dt { color: var(--muted); } dl.kv dt { color: var(--muted); }
dl.kv dd { margin: 0; font-family: var(--mono); font-size: 12.5px; word-break: break-all; } dl.kv dd { margin: 0; font-family: var(--mono); font-size: 12.5px; word-break: break-all; }
@@ -216,9 +218,12 @@
<div id="tab-overview" data-panel> <div id="tab-overview" data-panel>
<div class="tiles"> <div class="tiles">
<div class="card tile live"><div class="label">Online now</div><div class="value" id="tOnline">–</div></div> <div class="card tile live"><div class="label">Online now</div><div class="value" id="tOnline">–</div></div>
<div class="card tile"><div class="label">Visiting</div><div class="value" id="tVisiting" title="Visitor identities seen in the last 5 minutes">–</div></div>
<div class="card tile"><div class="label">Visitors</div><div class="value" id="tVisitors" title="Identity cookies the app has issued (rows in the visitors table)">–</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">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 +237,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>
@@ -241,6 +246,14 @@
<dl class="kv" id="runtime"></dl> <dl class="kv" id="runtime"></dl>
</div> </div>
</div> </div>
<div class="card pad">
<h2>Visitors</h2>
<p class="hint">Every browser gets an identity cookie (<code>gb_visitor</code>); the backend accepts it only while its id exists in the database — after a <code>guestbook reset</code> every cookie is regenerated on the next request. Idle identities are pruned by the <code>cleanup</code> job (<code>--prune-visitors</code>).</p>
<table>
<thead><tr><th>Visitor</th><th>Name</th><th>Signatures</th><th>First seen</th><th>Last seen</th></tr></thead>
<tbody id="visitorRows"></tbody>
</table>
</div>
</div> </div>
</div> </div>
@@ -278,6 +291,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 +370,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,25 +452,31 @@
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);
$('tOnline').textContent = online; $('tOnline').textContent = online;
$('tVisiting').textContent = overview.visitors ?? 0;
$('tVisitors').textContent = stats.visitors ?? 0;
$('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 || []);
loadVisitors();
$('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 +489,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`],
@@ -479,6 +517,22 @@
}).join(''); }).join('');
} }
// ---- visitors ----------------------------------------------------------
async function loadVisitors() {
const r = await req('GET', '/api/admin/visitors?limit=25');
if (!r.ok || !r.data) return;
const activeSince = new Date(r.data.active_since || 0).getTime();
const rows = r.data.visitors || [];
$('visitorRows').innerHTML = rows.length ? rows.map((v) => `
<tr>
<td class="mono">${esc(String(v.id).slice(0, 8))}…</td>
<td>${esc(v.name || '–')}</td>
<td>${v.signatures}</td>
<td title="${esc(new Date(v.first_seen).toLocaleString())}">${ago(v.first_seen)}</td>
<td class="${new Date(v.last_seen).getTime() >= activeSince ? 'fresh' : ''}" title="${esc(new Date(v.last_seen).toLocaleString())}">${ago(v.last_seen)}</td>
</tr>`).join('') : '<tr><td colspan="5" class="empty">No visitor identities yet — they are created on the first request.</td></tr>';
}
// ---- moderation -------------------------------------------------------- // ---- moderation --------------------------------------------------------
const mod = { status: '', q: '', offset: 0 }; const mod = { status: '', q: '', offset: 0 };
async function loadRows() { async function loadRows() {
@@ -496,6 +550,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 +559,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 +609,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 +674,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

+163 -10
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;
@@ -165,6 +215,10 @@
.paused b { color: var(--text); } .paused b { color: var(--text); }
.replicas { font-size: 12.5px; color: var(--muted); padding: 5px 12px; border-radius: 999px; .replicas { font-size: 12.5px; color: var(--muted); padding: 5px 12px; border-radius: 999px;
background: var(--card); border: 1px solid var(--card-border); white-space: nowrap; } background: var(--card); border: 1px solid var(--card-border); white-space: nowrap; }
.clock-meta .you { color: var(--text); font-weight: 600; }
.visitors { display: inline-flex; align-items: center; gap: 7px; font-size: 12.5px; color: var(--muted);
padding: 5px 12px; border-radius: 999px; background: var(--card); border: 1px solid var(--card-border);
white-space: nowrap; }
.empty, .loading { text-align: center; color: var(--muted); padding: 40px 20px; font-size: 15px; } .empty, .loading { text-align: center; color: var(--muted); padding: 40px 20px; font-size: 15px; }
@@ -207,7 +261,9 @@
<div class="status"> <div class="status">
<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="visitors" id="visitors" title="Visitor identities active in the last 5 minutes (issued by the identity cookie)" hidden>👤 <span id="visitorCount">0</span></span>
<span class="online" title="Browsers connected to the live stream right now"><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>
@@ -229,6 +285,8 @@
<div class="clock-time" id="clockTime">—</div> <div class="clock-time" id="clockTime">—</div>
</div> </div>
<div class="clock-meta" id="clockMeta">waiting for server…</div> <div class="clock-meta" id="clockMeta">waiting for server…</div>
<!-- Visitor identity (new in v3): the server recognises this browser -->
<div class="clock-meta" id="visitorMeta" hidden></div>
</div> </div>
<!-- Home view --> <!-- Home view -->
@@ -296,12 +354,17 @@
const api = { const api = {
info: () => fetch('/api/info').then(r => r.json()), info: () => fetch('/api/info').then(r => r.json()),
me: () => fetch('/api/me').then(r => r.json()),
time: () => fetch('/api/time').then(r => r.json()), time: () => fetch('/api/time').then(r => r.json()),
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 +388,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 +417,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; }
@@ -367,6 +494,28 @@
$('replicas').hidden = false; $('replicas').hidden = false;
$('replicas').textContent = n === 1 ? '1 replica' : n + ' replicas'; $('replicas').textContent = n === 1 ? '1 replica' : n + ' replicas';
} }
function setVisitors(n) {
if (!n) return;
$('visitors').hidden = false;
$('visitorCount').textContent = n;
}
// ---- visitor identity ---------------------------------------------------
// The server recognises this browser by the gb_visitor cookie: it checks
// the cookie's id against the database and hands out a new one when it is
// unknown (first visit, pruned identity, or a wiped database). /api/me
// reports who the server thinks we are; the remembered name pre-fills the
// form.
function showVisitor(v) {
if (!v || !v.id) return;
const el = $('visitorMeta');
el.hidden = false;
const bits = [`you are <span class="you">${esc(v.name || 'guest-' + String(v.id).slice(0, 8))}</span>`];
if (v.signatures > 0) bits.push(`${v.signatures} signature${v.signatures === 1 ? '' : 's'}`);
if (v.first_seen) bits.push(`since ${timeAgo(v.first_seen)}`);
el.innerHTML = bits.join(' · ');
if (v.name && !$('name').value) $('name').value = v.name;
}
function applySettings(st) { function applySettings(st) {
const banner = (st && st.banner) || ''; const banner = (st && st.banner) || '';
$('banner').hidden = !banner; $('banner').hidden = !banner;
@@ -374,6 +523,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 --------------------------------------------------------------
@@ -464,15 +615,16 @@
let connectedOnce = false; let connectedOnce = false;
function connectSSE() { function connectSSE() {
const es = new EventSource('/api/stream'); const es = new EventSource('/api/stream');
es.addEventListener('welcome', (e) => { const d = JSON.parse(e.data); setVersion(d.version); setOnline(d.online); setReplicas(d.replicas); }); es.addEventListener('welcome', (e) => { const d = JSON.parse(e.data); setVersion(d.version); setOnline(d.online); setReplicas(d.replicas); setVisitors(d.visitors); });
es.addEventListener('presence', (e) => { const d = JSON.parse(e.data); setOnline(d.online); setReplicas(d.replicas); }); es.addEventListener('presence', (e) => { const d = JSON.parse(e.data); setOnline(d.online); setReplicas(d.replicas); setVisitors(d.visitors); });
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; });
es.addEventListener('tick', (e) => { const d = JSON.parse(e.data); showTime(d); setVersion(d.version); }); es.addEventListener('tick', (e) => { const d = JSON.parse(e.data); showTime(d); setVersion(d.version); setVisitors(d.visitors); });
// EventSource auto-reconnects on error; nothing to do here. // EventSource auto-reconnects on error; nothing to do here.
} }
@@ -498,10 +650,11 @@
// ---- boot --------------------------------------------------------------- // ---- boot ---------------------------------------------------------------
api.info().then((d) => { api.info().then((d) => {
setVersion(d.version); setOnline(d.online); setReplicas((d.replicas || []).length); setVersion(d.version); setOnline(d.online); setReplicas((d.replicas || []).length); setVisitors(d.visitors);
$('adminLink').hidden = !d.admin; $('adminLink').hidden = !d.admin;
if (d.broker) { $('brokerPill').hidden = false; $('brokerPill').textContent = d.broker === 'redis' ? 'Redis pub/sub' : 'In-process broker'; } if (d.broker) { $('brokerPill').hidden = false; $('brokerPill').textContent = d.broker === 'redis' ? 'Redis pub/sub' : 'In-process broker'; }
}).catch(() => {}); }).catch(() => {});
api.me().then(showVisitor).catch(() => {});
api.settings().then(applySettings).catch(() => {}); api.settings().then(applySettings).catch(() => {});
api.time().then(showTime).catch(() => {}); // immediate clock; SSE ticks keep it live api.time().then(showTime).catch(() => {}); // immediate clock; SSE ticks keep it live
route(); route();