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

Add a set of guest-facing and operator-facing features that make the
deployment-strategy demos (recreate, rolling, canary) more visual:

- Emoji reactions (heart/tada/thumbsup/smile): visitors react without an
  account, a first-party cookie keeps counts honest, the tally streams
  live to every tab over SSE. Backed by a new reactions table with an
  idempotent upsert; whitelisted emoji so the endpoint can't be abused.
- Light & dark themes: the wall follows the OS preference, visitors can
  toggle it (persisted locally), and a moderator can force a theme for
  everyone from the admin console, pushed live over SSE.
- Per-replica request counter: a middleware tallies requests answered by
  each replica; surfaced in /healthz, /api/info and the admin replica
  table. Under load-balancing the counts diverge, and a rolling update
  resets them one container at a time — the rollout made visible.
- Rolling-update wave in the Chaos lab: bounces replicas one at a time
  (via the crash endpoint) while polling the replica table, so a training
  session can watch each replica reset and rejoin — what a rolling update
  looks like from the inside.
- Admin console: reactions tile, per-replica Requests column, reaction
  tallies in moderation, and a wall-theme setting.
- Favicon + /favicon.ico redirect.

Tests cover reactions (idempotency, tallies, whitelist) and the theme
setting round-trip.

Akili-Task: tsk_xgg6sfuzkzymyc6gstr4
Akili-Agent: goldfish
This commit is contained in:
Akili
2026-10-05 21:36:23 +02:00
parent 886f7cdc14
commit 3a68f64eb6
14 changed files with 726 additions and 82 deletions
+7 -3
View File
@@ -45,13 +45,17 @@ DATABASE_URL=postgres://postgres:postgres@db:5432/guestbook?sslmode=disable
# REDIS_URL=redis://redis:6379
# === Admin console =================
# Enables /admin. Store as a secret. Generate with: openssl rand -hex 16
# Enables /admin: overview (stats, replicas with per-replica request counters),
# moderation (pin / hide / delete, reaction tallies, CSV export) and wall
# settings (pause signing, banner, forced light/dark theme).
# Store the token as a secret. Generate with: openssl rand -hex 16
# ADMIN_TOKEN=
# CIDRs whose X-Forwarded-For is trusted for the signer IP shown in /admin.
# Unset trusts the header as-is (fine behind the Miabi gateway).
# TRUSTED_PROXIES=10.0.0.0/8
# Expose /api/debug/* failure injection (crash, slow, 5xx, CPU, memory, logs).
# Still requires admin auth. Demo environments only.
# Expose /api/debug/* failure injection (crash, slow, 5xx, CPU, memory, logs)
# and the admin "rolling-update wave" tool. Still requires admin auth.
# Demo environments only.
# DEBUG_ENDPOINTS=false
+28 -13
View File
@@ -15,7 +15,7 @@ handy for logs, events and scaling.
| 3 | Secrets + admin console | secret env vars |
| 4 | Scale out → broken live updates | replicas |
| 5 | Attach Redis → fixed | managed Redis, env prefix |
| 6 | Break it on purpose | restarts, health gating, alerts, logs, analytics |
| 6 | Break it on purpose (+ rolling wave) | restarts, health gating, alerts, logs, analytics |
| 7 | Canary v1 → v2 | canary weights, promote |
| 8 | Scheduled cleanup | cron jobs |
@@ -55,23 +55,24 @@ docker push <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).
2. Redeploy, open `/admin` and sign in with the token.
3. Tour it:
- **Overview**: signatures per day, replicas, and *this replica*'s runtime (host, DB, uptime, memory).
- **Moderation**: each signature shows the signer's IP, forwarded by the gateway. Click one to filter by it. Pin a signature. It jumps to the top of every open wall, live. Hide one and it disappears.
- **Settings**: set a banner ("Welcome to the Miabi training 👋"), then toggle **Pause signing**. The wall goes read-only instantly in every tab.
- **Overview**: signatures per day, total reactions, replicas (with each one's **request counter**), and *this replica*'s runtime (host, DB, uptime, memory).
- **Moderation**: each signature shows the signer's IP, forwarded by the gateway, plus its reaction tally. Click the IP to filter by it. Pin a signature. It jumps to the top of every open wall, live. Hide one and it disappears.
- **Settings**: set a banner ("Welcome to the Miabi training 👋"), pick a **wall theme** (force light or dark — every open tab flips instantly), then toggle **Pause signing**. The wall goes read-only instantly in every tab.
> Talking point: the token never appears in the app's config in plain text.
> Rotating it (edit + redeploy) signs every admin out, because sessions are
> HMAC-signed with it.
Ask the audience to open the wall on their phones and sign it. The
**online** counter climbs live.
**online** counter climbs live. Have them tap the ❤️ / 🎉 reactions — the
tallies move on every screen at once, with no refresh.
## 4. Scale out, and watch it break
1. App → **Replicas** → scale to **3**.
2. In the admin **Overview**, the *served by* chip changes as you refresh, but the **Replicas** table only ever lists one host. The hint says *in-process broker*.
3. Open the wall in two browsers (or phones) until their clock cards show **different hosts**. Sign in one: **the other doesn't update.** Each replica only pushes to its own SSE clients.
4. Also note the `via replica-x` tag on new signatures: each one records which replica accepted it.
3. Open the wall in two browsers (or phones) until their clock cards show **different hosts**. Sign in one: **the other doesn't update.** Each replica only pushes to its own SSE clients. Reactions are the same: a ❤️ on one host never shows on the other.
4. Also note the `via replica-x` tag on new signatures: each one records which replica accepted it. And `curl -s https://<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
> need shared state for fan-out. Scaling is a platform feature; being
@@ -81,8 +82,8 @@ Ask the audience to open the wall on their phones and sign it. The
1. App → **Databases** → attach the Redis with prefix **`REDIS`**. Miabi injects `REDIS_DATABASE_URL`, which the app reads (so does `REDIS_URL`).
2. Redeploy. Startup logs show `live updates ready broker=redis`.
3. Repeat the two-browser test: signatures now appear everywhere. The **Replicas** table lists all 3 hosts with their client counts, and the wall header says *3 replicas*.
4. **Chaos lab → Traffic probe** → *Run probe*: the bars show requests spread across the three hosts.
3. Repeat the two-browser test: signatures **and reactions** now appear everywhere. The **Replicas** table lists all 3 hosts with their client counts **and request counters**, and the wall header says *3 replicas*.
4. **Chaos lab → Traffic probe** → *Run probe*: the bars show requests spread across the three hosts. Compare with the per-replica `requests` counters in the table — they tell the same story server-side.
## 6. Break it on purpose
@@ -111,15 +112,29 @@ curl -XPOST -H "Authorization: Bearer $T" "$D/api/debug/memory?mb=400&seconds=30
Remember to click **Healthy** afterwards, or redeploy.
### The rolling-update wave
Still in the Chaos lab, **Rolling-update wave → Bounce each replica, one at a
time** restarts the fleet exactly like a rolling update does, but driven by the
crash endpoint. Keep the **Replicas** table in view: each replica disappears,
comes back, and its **request counter restarts from ~0** while the others keep
climbing. The wall never goes down — which is the whole point of rolling
updates, shown from the inside.
> Talking point: recreate would drop every replica at once (all counters would
> reset together, with a gap of downtime). Rolling keeps capacity by replacing
> one at a time. Same mechanism, two strategies.
## 7. Canary v1 → v2
1. In a terminal: `scripts/hammer.sh https://<domain>`. It prints a live count and, on Ctrl-C, a tally of `status version host`.
2. Set **deploy strategy** to *canary* (initial weight 10%), and deploy image `2.0.0`.
3. On the wall, reload a few times: ~1 in 10 loads shows the **v2 badge** in a different colour. In the admin **Traffic probe**, the bars split by version.
3. On the wall, reload a few times: ~1 in 10 loads shows the **v2 badge** in a different colour **and the new live server-time card**. In the admin **Traffic probe**, the bars split by version; in the **Replicas** table, the two versions sit side by side with their own request counters.
4. **Promote now.** The probe goes 100% v2, and `hammer.sh` should show `0 failed`.
> The v2 build is the same code with a different `VERSION`. Nothing needs to
> differ for the demo to work.
> differ for the demo to work — but the clock card (absent in v1) makes the
> split visible to the audience without a terminal.
## 8. Scheduled cleanup
@@ -141,6 +156,6 @@ want migrations as an explicit step rather than on boot.
## Reset between sessions
- Admin → Settings: clear the banner, un-pause.
- Admin → Settings: clear the banner, set the theme back to *System*, un-pause.
- Chaos lab: **Healthy**.
- To wipe the wall: detach/recreate the database, or `DELETE FROM entries;` and restart (the seed runs when the table is empty).
- To wipe the wall: detach/recreate the database, or `DELETE FROM entries; DELETE FROM reactions;` and restart (the seed runs when the table is empty).
+40 -21
View File
@@ -18,6 +18,17 @@ miniature:
routes keep precedence
- **Live updates over SSE** — new/removed signatures stream to every open tab,
with a real-time **connected-clients** ("N online") indicator
- **Emoji reactions** — visitors cheer a signature with ❤️ 🎉 👍 😄 (no
account; a cookie keeps counts honest) and the tally streams live to every
tab. Each reaction counts against a whitelist, so the endpoint can't be
used to spam arbitrary emoji
- **Light & dark themes** — the wall follows the OS preference, visitors can
toggle it, and a moderator can **force a theme for everyone** from the admin
console (pushed live over SSE)
- **Per-replica request counter** — `/healthz`, `/api/info` and the admin
replica table report how many requests *this* replica has answered since
boot. Under load-balancing the counts diverge; a rolling update resets them
one container at a time, which makes the rollout visible from the outside
- **Version badge + live server-time card** — the serving build's version is
shown in the UI and `/api/info`, and a **live clock** (streamed over SSE with
the server's `version` + `host`) makes a **canary rollout** obvious: the card
@@ -34,11 +45,14 @@ miniature:
across every replica via pub/sub; without it, the in-process broker makes the
classic "works on one replica" bug easy to demonstrate
- **Admin console** at `/admin`, guarded by a single `ADMIN_TOKEN` secret:
overview (stats, replicas, runtime), moderation (pin / hide / delete, signer
IP, CSV export), wall settings (pause signing, announcement banner) — all pushed live
overview (stats, replicas with per-replica request counters, runtime),
moderation (pin / hide / delete, signer IP, reaction tallies, CSV export),
wall settings (pause signing, announcement banner, forced wall theme) — all
pushed live
- **Chaos lab** (`DEBUG_ENDPOINTS=true`): crash, fail health checks, burn CPU,
hold memory, burst logs, inject latency/5xx, plus an in-browser traffic probe
that shows load-balancing and canary splits
hold memory, burst logs, inject latency/5xx, a **rolling-update wave** that
bounces replicas one at a time while the replica table shows each one reset,
plus an in-browser traffic probe that shows load-balancing and canary splits
- **Scheduled-job friendly CLI** — `guestbook cleanup` (purge/expire) and
`guestbook migrate` subcommands for Miabi cron jobs and one-off jobs
@@ -60,13 +74,15 @@ and keeps the container tiny.
| Method | Path | Description |
|----------|----------------------|------------------------------------|
| `GET` | `/healthz` | Liveness/readiness (checks the DB), reports `version`, `host`, `redis` |
| `GET` | `/api/info` | App name, serving `version`, `host`, online count, `broker` mode, `replicas` |
| `GET` | `/healthz` | Liveness/readiness (checks the DB), reports `version`, `host`, `redis`, `requests` |
| `GET` | `/api/info` | App name, serving `version`, `host`, online count, `broker` mode, `replicas` (with per-replica request counts), `requests`, reaction whitelist |
| `GET` | `/api/time` | Current server time + `version` + `host` (v2 — canary probe) |
| `GET` | `/api/settings` | Public wall settings: `signing_paused`, `banner` |
| `GET` | `/api/entries` | Visible entries (pinned first) + total; paginated via `?limit=&offset=` |
| `GET` | `/api/settings` | Public wall settings: `signing_paused`, `banner`, `theme` |
| `GET` | `/api/entries` | Visible entries (pinned first) with reaction tallies + total; paginated via `?limit=&offset=` |
| `POST` | `/api/entries` | Create `{ "name", "message" }` (broadcast live; `503` while paused) |
| `GET` | `/api/stream` | **SSE** stream: `welcome`, `created`, `updated`, `deleted`, `settings`, `presence`, `tick` events |
| `POST` | `/api/entries/{id}/reactions?emoji=` | Add this browser's reaction (`heart`·`tada`·`thumbsup`·`smile`); idempotent, broadcasts the new tally |
| `DELETE` | `/api/entries/{id}/reactions?emoji=` | Remove this browser's reaction |
| `GET` | `/api/stream` | **SSE** stream: `welcome`, `created`, `updated`, `deleted`, `reaction`, `settings`, `presence`, `tick` events |
| `GET` | `/` · `/all` · `/admin` | The web UI (home · all signatures · admin console) |
### Admin API
@@ -77,11 +93,11 @@ or an `Authorization: Bearer <ADMIN_TOKEN>` header. Disabled (`404`) when
| Method | Path | Description |
|----------|-------------------------------|-------------|
| `GET` | `/api/admin/overview` | Stats (14-day histogram), replicas, runtime of the answering replica |
| `GET` | `/api/admin/entries` | All entries; `?status=visible\|hidden\|pinned&q=&limit=&offset=` |
| `GET` | `/api/admin/overview` | Stats (14-day histogram, reaction totals), replicas (with per-replica request counts), runtime of the answering replica |
| `GET` | `/api/admin/entries` | All entries with reaction tallies; `?status=visible\|hidden\|pinned&q=&limit=&offset=` |
| `PATCH` | `/api/admin/entries/{id}` | `{ "pinned"?: bool, "hidden"?: bool }` |
| `DELETE` | `/api/admin/entries/{id}` | Soft-delete an entry |
| `GET`·`PUT` | `/api/admin/settings` | `{ "signing_paused": bool, "banner": string }` |
| `GET`·`PUT` | `/api/admin/settings` | `{ "signing_paused": bool, "banner": string, "theme": "system\|light\|dark" }` |
| `GET` | `/api/admin/export.csv` | Every entry as CSV |
### Debug API (chaos)
@@ -103,16 +119,19 @@ replica that receives it and echoes its `host`.
`/api/stream` is a Server-Sent Events endpoint. On connect the client gets a
`welcome` event (serving version + host + online count); thereafter the server
pushes `created` / `deleted` events as the wall changes, `presence` events when
the connected-client count changes, and a `tick` event once a second carrying
the live server time (with `version` + `host`). The UI uses these to update the
wall in real time, show a live **“N online”** indicator, and drive the
**server-time card** — all without polling.
pushes `created` / `updated` / `deleted` events as the wall changes, `reaction`
events as tallies move, `settings` events when a moderator pauses signing,
changes the banner or forces a theme, `presence` events when the
connected-client count changes, and a `tick` event once a second carrying the
live server time (with `version` + `host`). The UI uses these to update the
wall in real time, show a live **“N online”** indicator, stream emoji-reaction
tallies, and drive the **server-time card** — all without polling.
With `REDIS_URL` set, `created`/`updated`/`deleted`/`settings` events go through
a Redis pub/sub channel so every replica delivers them, and each replica
advertises its client count under a short-TTL key so presence is cluster-wide.
`tick` events stay per-replica on purpose: the clock shows who served you.
With `REDIS_URL` set, `created`/`updated`/`deleted`/`reaction`/`settings`
events go through a Redis pub/sub channel so every replica delivers them, and
each replica advertises its client count **and request count** under a
short-TTL key so presence is cluster-wide. `tick` events stay per-replica on
purpose: the clock shows who served you.
## Configuration
+9
View File
@@ -93,6 +93,7 @@ func (h *Handler) AdminOverview(c *okapi.Context) error {
"healthy": !h.unhealthy.Load(),
"started_at": h.started.UTC().Format(time.RFC3339),
"uptime_s": int64(time.Since(h.started).Seconds()),
"requests": h.requests.Load(),
"go": runtime.Version(),
"cpus": runtime.NumCPU(),
"goroutines": runtime.NumGoroutine(),
@@ -167,6 +168,14 @@ func (h *Handler) AdminSaveSettings(c *okapi.Context) error {
if len(st.Banner) > maxBannerLen {
return c.JSON(http.StatusBadRequest, okapi.M{"error": "banner is too long (max 200 characters)"})
}
switch st.Theme {
case "light", "dark":
// forced appearance for every visitor
case "", "system":
st.Theme = "" // follow each visitor's preference
default:
return c.JSON(http.StatusBadRequest, okapi.M{"error": "theme must be system, light or dark"})
}
if err := h.store.SaveSettings(c.Request().Context(), st); err != nil {
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not save settings"})
}
+26 -11
View File
@@ -17,13 +17,16 @@ import (
// - "created" → Entry is set (a new signature)
// - "updated" → Entry is set (pinned/hidden by a moderator)
// - "deleted" → ID is set (a removed signature)
// - "settings" → Settings is set (signing paused, banner changed)
// - "reaction" → ID/Emoji/Count are set (an entry's tally changed)
// - "settings" → Settings is set (signing paused, banner changed, theme forced)
// - "presence" → Online/Replicas are set (connected clients changed)
// - "tick" → Time/Version/Host are set (live server clock; new in v2)
type Event struct {
Type string `json:"type"`
Entry *Entry `json:"entry,omitempty"`
ID uint `json:"id,omitempty"`
Emoji string `json:"emoji,omitempty"`
Count int `json:"count,omitempty"`
Settings *Settings `json:"settings,omitempty"`
Online int `json:"online,omitempty"`
Replicas int `json:"replicas,omitempty"`
@@ -33,11 +36,15 @@ type Event struct {
}
// Replica describes one running instance of the app and its SSE clients.
// Requests is the number of HTTP requests it has answered since boot: under
// load-balancing the counts diverge, and a rollout resets them one replica at
// a time — the rolling update made visible.
type Replica struct {
Host string `json:"host"`
Version string `json:"version"`
Online int `json:"online"`
Seen time.Time `json:"seen"`
Host string `json:"host"`
Version string `json:"version"`
Online int `json:"online"`
Requests int64 `json:"requests"`
Seen time.Time `json:"seen"`
}
const (
@@ -61,6 +68,7 @@ type Broker struct {
host, version string
rdb *redis.Client
dirty chan struct{}
requests func() int64
cmu sync.RWMutex
replicas []Replica
@@ -68,12 +76,15 @@ type Broker struct {
}
// NewBroker creates an in-process Broker. Call UseRedis to make it cluster-wide.
func NewBroker(host, version string) *Broker {
// requests reports how many HTTP requests this replica has served so far (may
// be nil); it is advertised with the presence heartbeat.
func NewBroker(host, version string, requests func() int64) *Broker {
return &Broker{
subs: make(map[chan Event]struct{}),
host: host,
version: version,
dirty: make(chan struct{}, 1),
subs: make(map[chan Event]struct{}),
host: host,
version: version,
dirty: make(chan struct{}, 1),
requests: requests,
}
}
@@ -203,7 +214,11 @@ func (b *Broker) Replicas() []Replica {
}
func (b *Broker) self() Replica {
return Replica{Host: b.host, Version: b.version, Online: b.localCount(), Seen: time.Now().UTC()}
var reqs int64
if b.requests != nil {
reqs = b.requests()
}
return Replica{Host: b.host, Version: b.version, Online: b.localCount(), Requests: reqs, Seen: time.Now().UTC()}
}
func (b *Broker) localCount() int {
+1 -1
View File
@@ -6,7 +6,7 @@ import (
)
func TestLocalBroker(t *testing.T) {
b := NewBroker("host-a", "1.0.0")
b := NewBroker("host-a", "1.0.0", nil)
ch := b.Subscribe()
defer b.Unsubscribe(ch)
+19 -13
View File
@@ -28,6 +28,9 @@ type Handler struct {
unhealthy atomic.Bool
chaos chaosState
// requests counts HTTP requests answered by this replica since boot. A
// rolling update resets it one container at a time; see countRequests.
requests atomic.Int64
}
// serverTime is the shape of the live clock payload (v2 feature). It carries
@@ -71,12 +74,13 @@ func (h *Handler) Health(c *okapi.Context) error {
})
}
return c.OK(okapi.M{
"status": "ok",
"db": "up",
"redis": h.broker.RedisStatus(c.Request().Context()),
"app": h.appName,
"version": h.version,
"host": h.host,
"status": "ok",
"db": "up",
"redis": h.broker.RedisStatus(c.Request().Context()),
"app": h.appName,
"version": h.version,
"host": h.host,
"requests": h.requests.Load(),
})
}
@@ -85,13 +89,15 @@ func (h *Handler) Health(c *okapi.Context) error {
// are currently connected.
func (h *Handler) Info(c *okapi.Context) error {
return c.OK(okapi.M{
"app": h.appName,
"version": h.version,
"online": h.broker.Online(),
"host": h.host,
"broker": h.broker.Mode(),
"replicas": h.broker.Replicas(),
"admin": h.auth.Enabled(),
"app": h.appName,
"version": h.version,
"online": h.broker.Online(),
"host": h.host,
"broker": h.broker.Mode(),
"replicas": h.broker.Replicas(),
"requests": h.requests.Load(),
"admin": h.auth.Enabled(),
"reactions": ReactionEmojis(),
})
}
+11 -2
View File
@@ -85,7 +85,7 @@ func runServer(cmd *okapicli.Command, cfg Config) error {
}
host, _ := os.Hostname()
broker := NewBroker(host, cfg.Version)
broker := NewBroker(host, cfg.Version, nil)
if cfg.RedisURL != "" {
if err := broker.UseRedis(cfg.RedisURL); err != nil {
return err
@@ -106,6 +106,7 @@ func runServer(cmd *okapicli.Command, cfg Config) error {
debug: cfg.DebugEndpoints,
started: time.Now(),
}
broker.requests = h.requests.Load
if !h.auth.Enabled() {
logger.Warn("admin console disabled: set ADMIN_TOKEN to enable /admin")
}
@@ -116,7 +117,7 @@ func runServer(cmd *okapicli.Command, cfg Config) error {
app := cmd.Okapi()
app.WithPort(cmd.GetInt("port"))
app.WithDebug()
app.Use(okapi.LoggerMiddleware, okapi.RequestID())
app.Use(okapi.LoggerMiddleware, okapi.RequestID(), h.countRequests)
registerRoutes(app, h)
return cmd.CLI().RunServer(&okapicli.RunOptions{
@@ -144,6 +145,8 @@ func registerRoutes(app *okapi.Okapi, h *Handler) {
api.Get("/settings", h.PublicSettings)
api.Get("/entries", h.ListEntries)
api.Post("/entries", h.CreateEntry)
api.Post("/entries/{id:int}/reactions", h.React)
api.Delete("/entries/{id:int}/reactions", h.Unreact)
api.Get("/stream", h.Stream)
api.Get("/admin/session", h.AdminSession)
@@ -176,6 +179,12 @@ func registerRoutes(app *okapi.Okapi, h *Handler) {
return c.Data(http.StatusOK, "text/html; charset=utf-8", adminPage)
})
// Browsers ask for /favicon.ico on their own; the logo serves as the icon.
app.Get("/favicon.ico", func(c *okapi.Context) error {
c.Redirect(http.StatusFound, "/badges/icon.svg")
return nil
})
// Okapi serves real files directly and falls back to index.html for any
// other path so the client-side router can take over.
app.WebFS("/", webFS, okapi.WebConfig{Root: "web"})
+111
View File
@@ -0,0 +1,111 @@
package main
import (
"crypto/rand"
"encoding/hex"
"errors"
"net/http"
"strconv"
"time"
"github.com/jkaninda/okapi"
"gorm.io/gorm"
)
// Reactions let visitors cheer a signature without an account: one tap adds,
// tapping again removes, and the tally streams to every open tab over SSE.
// A first-party cookie identifies the browser so a count can't be inflated by
// hammering the endpoint.
const (
reactionCookie = "gb_reactor"
reactionCookieTTL = 365 * 24 * time.Hour
)
// reactorID returns the stable, anonymous id of this browser, setting the
// cookie on first sight.
func reactorID(c *okapi.Context) string {
if id, err := c.Cookie(reactionCookie); err == nil && len(id) >= 8 && len(id) <= 64 {
return id
}
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return ""
}
id := hex.EncodeToString(buf)
r := c.Request()
http.SetCookie(c.ResponseWriter(), &http.Cookie{
Name: reactionCookie,
Value: id,
Path: "/",
MaxAge: int(reactionCookieTTL.Seconds()),
HttpOnly: true,
Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https",
SameSite: http.SameSiteLaxMode,
})
return id
}
// parseReactionRequest validates the {id} path param and the ?emoji= query.
func parseReactionRequest(c *okapi.Context) (uint64, string, error) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
return 0, "", errors.New("invalid id")
}
emoji := c.Query("emoji")
if !validReaction(emoji) {
return 0, "", errors.New("unknown emoji (want one of: heart, tada, thumbsup, smile)")
}
return id, emoji, nil
}
// React adds a reaction to an entry and broadcasts the new tally live.
func (h *Handler) React(c *okapi.Context) error {
ctx := c.Request().Context()
id, emoji, err := parseReactionRequest(c)
if err != nil {
return c.JSON(http.StatusBadRequest, okapi.M{"error": err.Error()})
}
if _, err := h.store.Get(ctx, id); errors.Is(err, gorm.ErrRecordNotFound) {
return c.JSON(http.StatusNotFound, okapi.M{"error": "entry not found"})
}
who := reactorID(c)
if who == "" {
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not identify the browser"})
}
count, err := h.store.React(ctx, id, emoji, who)
if err != nil {
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not save the reaction"})
}
h.broker.Publish(Event{Type: "reaction", ID: uint(id), Emoji: emoji, Count: count})
return c.OK(okapi.M{"entry_id": id, "emoji": emoji, "count": count})
}
// Unreact removes the browser's reaction and broadcasts the new tally live.
func (h *Handler) Unreact(c *okapi.Context) error {
ctx := c.Request().Context()
id, emoji, err := parseReactionRequest(c)
if err != nil {
return c.JSON(http.StatusBadRequest, okapi.M{"error": err.Error()})
}
who := reactorID(c)
if who == "" {
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not identify the browser"})
}
count, err := h.store.Unreact(ctx, id, emoji, who)
if err != nil {
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not remove the reaction"})
}
h.broker.Publish(Event{Type: "reaction", ID: uint(id), Emoji: emoji, Count: count})
return c.OK(okapi.M{"entry_id": id, "emoji": emoji, "count": count})
}
// countRequests is middleware that tallies the HTTP requests this replica has
// answered since it started. The number lands in /healthz, /api/info and the
// admin replica table: it grows per replica under load-balancing and drops
// back to ~0 when a rollout replaces a container, which makes recreate and
// rolling updates visible from the outside.
func (h *Handler) countRequests(c *okapi.Context) error {
h.requests.Add(1)
return c.Next()
}
+99
View File
@@ -0,0 +1,99 @@
package main
import (
"context"
"testing"
)
func TestReactions(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
e, err := s.Create(ctx, "ada", "hello miabi", "r1", "")
if err != nil {
t.Fatal(err)
}
// Two visitors react, one of them twice (idempotent).
for _, tc := range []struct {
who, emoji string
want int
}{
{"alice", "heart", 1},
{"bob", "heart", 2},
{"bob", "heart", 2},
{"alice", "tada", 1},
} {
got, err := s.React(ctx, uint64(e.ID), tc.emoji, tc.who)
if err != nil {
t.Fatal(err)
}
if got != tc.want {
t.Fatalf("React(%s, %s) = %d, want %d", tc.who, tc.emoji, got, tc.want)
}
}
counts, err := s.ReactionCounts(ctx, uint64(e.ID))
if err != nil {
t.Fatal(err)
}
if counts["heart"] != 2 || counts["tada"] != 1 {
t.Fatalf("counts = %+v, want heart=2 tada=1", counts)
}
// Unreacting twice is a no-op after the first removal.
if got, _ := s.Unreact(ctx, uint64(e.ID), "heart", "bob"); got != 1 {
t.Fatalf("unreact = %d, want 1", got)
}
if got, _ := s.Unreact(ctx, uint64(e.ID), "heart", "bob"); got != 1 {
t.Fatalf("second unreact = %d, want 1", got)
}
// List and Get attach the tally; totals count every reaction.
wall, err := s.List(ctx, 10, 0)
if err != nil || len(wall) != 1 {
t.Fatalf("list: %v (%d entries)", err, len(wall))
}
if wall[0].Reactions["heart"] != 1 || wall[0].Reactions["tada"] != 1 {
t.Fatalf("list reactions = %+v", wall[0].Reactions)
}
one, err := s.Get(ctx, uint64(e.ID))
if err != nil || one.Reactions["heart"] != 1 {
t.Fatalf("get reactions = %+v, err=%v", one.Reactions, err)
}
if n, _ := s.ReactionTotals(ctx); n != 2 {
t.Fatalf("totals = %d, want 2", n)
}
// Deleting a reaction must not touch the entry itself.
if n, _ := s.Count(ctx); n != 1 {
t.Fatalf("entries = %d, want 1", n)
}
}
func TestReactionWhitelist(t *testing.T) {
for _, emoji := range ReactionEmojis() {
if !validReaction(emoji) {
t.Fatalf("whitelist rejects its own emoji %q", emoji)
}
}
if validReaction("poop") || validReaction("") {
t.Fatal("whitelist accepted a non-whitelisted emoji")
}
}
func TestSettingsTheme(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
if st, _ := s.Settings(ctx); st.Theme != "" {
t.Fatalf("default theme = %q, want empty (system)", st.Theme)
}
want := Settings{SigningPaused: false, Banner: "hi", Theme: "light"}
if err := s.SaveSettings(ctx, want); err != nil {
t.Fatal(err)
}
if got, _ := s.Settings(ctx); got != want {
t.Fatalf("got %+v, want %+v", got, want)
}
}
+147 -10
View File
@@ -13,6 +13,7 @@ import (
"github.com/jkaninda/logger"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// Entry is a single signature on the guestbook wall.
@@ -27,6 +28,21 @@ type Entry struct {
CreatedAt time.Time `gorm:"index" json:"created_at"`
UpdatedAt time.Time `json:"-"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
// Reactions carries the per-emoji tally (e.g. {"heart":3,"tada":1}); it is
// filled by list endpoints, never stored on the entries table itself.
Reactions map[string]int `gorm:"-" json:"reactions,omitempty"`
}
// Reaction is one emoji reaction to an entry. Visitors react and un-react
// without an account, so a short-lived cookie ("who") prevents the same
// browser from inflating the count.
type Reaction struct {
EntryID uint `gorm:"primaryKey;autoIncrement:false" json:"-"`
Emoji string `gorm:"primaryKey;type:varchar(16)" json:"-"`
Who string `gorm:"primaryKey;type:varchar(64)" json:"-"`
CreatedAt time.Time `json:"-"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// Setting is a key/value row holding app-wide state shared by all replicas.
@@ -40,6 +56,9 @@ type Setting struct {
type Settings struct {
SigningPaused bool `json:"signing_paused"`
Banner string `json:"banner"`
// Theme is the wall appearance forced by a moderator: "" (or "system"),
// "light" or "dark". Visitors can still override it locally.
Theme string `json:"theme"`
}
// EntryFilter selects entries for the admin console.
@@ -56,14 +75,31 @@ type DayCount struct {
Count int `json:"count"`
}
// Emojis a visitor can react with; also the whitelist for the API.
var reactionEmojis = []string{"heart", "tada", "thumbsup", "smile"}
// ReactionEmojis returns the whitelist, for the info endpoint.
func ReactionEmojis() []string { return reactionEmojis }
// validReaction reports whether emoji is on the whitelist.
func validReaction(emoji string) bool {
for _, e := range reactionEmojis {
if e == emoji {
return true
}
}
return false
}
// Stats summarises the wall for the admin overview.
type Stats struct {
Total int64 `json:"total"`
Visible int64 `json:"visible"`
Hidden int64 `json:"hidden"`
Pinned int64 `json:"pinned"`
Today int64 `json:"today"`
Daily []DayCount `json:"daily"`
Total int64 `json:"total"`
Visible int64 `json:"visible"`
Hidden int64 `json:"hidden"`
Pinned int64 `json:"pinned"`
Today int64 `json:"today"`
Reactions int64 `json:"reactions"`
Daily []DayCount `json:"daily"`
}
const settingsKey = "settings"
@@ -140,7 +176,7 @@ func (s *Store) Ping(ctx context.Context) error {
// concurrent AutoMigrate calls race on Postgres catalog inserts.
func (s *Store) Migrate() error {
return s.Exclusive(context.Background(), func(tx *Store) error {
return tx.db.AutoMigrate(&Entry{}, &Setting{})
return tx.db.AutoMigrate(&Entry{}, &Setting{}, &Reaction{})
})
}
@@ -160,7 +196,10 @@ func (s *Store) List(ctx context.Context, limit, offset int) ([]Entry, error) {
Limit(limit).
Offset(offset).
Find(&entries).Error
return entries, err
if err != nil {
return nil, err
}
return entries, s.loadReactions(ctx, entries)
}
// Create inserts a new entry and returns it with its generated id/timestamp.
@@ -170,6 +209,96 @@ func (s *Store) Create(ctx context.Context, name, message, replica, ip string) (
return e, err
}
// loadReactions fills the Reactions map of every entry in one grouped query.
func (s *Store) loadReactions(ctx context.Context, entries []Entry) error {
ids := make([]uint, len(entries))
for i, e := range entries {
ids[i] = e.ID
entries[i].Reactions = map[string]int{}
}
if len(ids) == 0 {
return nil
}
var rows []struct {
EntryID uint
Emoji string
N int
}
if err := s.db.WithContext(ctx).Model(&Reaction{}).
Select("entry_id, emoji, count(*) AS n").
Where("entry_id IN ?", ids).
Group("entry_id, emoji").
Scan(&rows).Error; err != nil {
return err
}
byID := make(map[uint]int, len(entries))
for i, e := range entries {
byID[e.ID] = i
}
for _, r := range rows {
if i, ok := byID[r.EntryID]; ok {
entries[i].Reactions[r.Emoji] = r.N
}
}
return nil
}
// React registers a reaction and returns the emoji's new total for the entry.
// It is idempotent for a given (entry, emoji, who): reacting twice is a no-op.
func (s *Store) React(ctx context.Context, entryID uint64, emoji, who string) (int, error) {
err := s.db.WithContext(ctx).
Clauses(clause.OnConflict{DoNothing: true}).
Create(&Reaction{EntryID: uint(entryID), Emoji: emoji, Who: who}).Error
if err != nil {
return 0, err
}
return s.reactionTotal(ctx, entryID, emoji)
}
// Unreact removes a reaction (idempotent) and returns the emoji's new total.
func (s *Store) Unreact(ctx context.Context, entryID uint64, emoji, who string) (int, error) {
err := s.db.WithContext(ctx).
Where("entry_id = ? AND emoji = ? AND who = ?", entryID, emoji, who).
Delete(&Reaction{}).Error
if err != nil {
return 0, err
}
return s.reactionTotal(ctx, entryID, emoji)
}
func (s *Store) reactionTotal(ctx context.Context, entryID uint64, emoji string) (int, error) {
var n int64
err := s.db.WithContext(ctx).Model(&Reaction{}).
Where("entry_id = ? AND emoji = ?", entryID, emoji).
Count(&n).Error
return int(n), err
}
// ReactionCounts returns the per-emoji tally for one entry.
func (s *Store) ReactionCounts(ctx context.Context, entryID uint64) (map[string]int, error) {
out := map[string]int{}
var rows []struct {
Emoji string
N int
}
err := s.db.WithContext(ctx).Model(&Reaction{}).
Select("emoji, count(*) AS n").
Where("entry_id = ?", entryID).
Group("emoji").
Scan(&rows).Error
for _, r := range rows {
out[r.Emoji] = r.N
}
return out, err
}
// ReactionTotals returns the total number of reactions across the wall.
func (s *Store) ReactionTotals(ctx context.Context) (int64, error) {
var n int64
err := s.db.WithContext(ctx).Model(&Reaction{}).Count(&n).Error
return n, err
}
// Delete soft-deletes an entry by id. Returns true when a row was affected.
func (s *Store) Delete(ctx context.Context, id uint64) (bool, error) {
res := s.db.WithContext(ctx).Delete(&Entry{}, id)
@@ -206,10 +335,14 @@ func (s *Store) CountVisible(ctx context.Context) (int64, error) {
return n, err
}
// Get returns a single entry by id.
// Get returns a single entry by id, with its reaction counts.
func (s *Store) Get(ctx context.Context, id uint64) (Entry, error) {
var e Entry
err := s.db.WithContext(ctx).First(&e, id).Error
if err != nil {
return e, err
}
e.Reactions, err = s.ReactionCounts(ctx, id)
return e, err
}
@@ -235,7 +368,10 @@ func (s *Store) AdminList(ctx context.Context, f EntryFilter) ([]Entry, int64, e
}
var entries []Entry
err := q.Order("created_at DESC").Limit(f.Limit).Offset(f.Offset).Find(&entries).Error
return entries, total, err
if err != nil {
return nil, 0, err
}
return entries, total, s.loadReactions(ctx, entries)
}
// SetFlags updates the moderation flags that are non-nil and returns the entry.
@@ -274,6 +410,7 @@ func (s *Store) Stats(ctx context.Context, days int) (Stats, error) {
}
s.db.WithContext(ctx).Model(&Entry{}).Where("hidden = ?", true).Count(&st.Hidden)
s.db.WithContext(ctx).Model(&Entry{}).Where("pinned = ?", true).Count(&st.Pinned)
s.db.WithContext(ctx).Model(&Reaction{}).Count(&st.Reactions)
st.Visible = st.Total - st.Hidden
now := time.Now().UTC()
+90 -3
View File
@@ -219,6 +219,7 @@
<div class="card tile"><div class="label">Replicas</div><div class="value" id="tReplicas">–</div></div>
<div class="card tile"><div class="label">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">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">Hidden</div><div class="value" id="tHidden">–</div></div>
</div>
@@ -232,7 +233,7 @@
<h2>Replicas</h2>
<p class="hint" id="replicaHint"></p>
<table>
<thead><tr><th>Host</th><th>Version</th><th>Clients</th><th>Seen</th></tr></thead>
<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>
</table>
</div>
@@ -278,6 +279,15 @@
<input id="banner" maxlength="200" placeholder="e.g. Welcome to the Miabi training 👋" />
<button class="primary" type="submit">Save</button>
</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>
</div>
@@ -348,6 +358,15 @@
<div class="probe-bars" id="probeBars"></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">
<h2>Activity</h2>
<div class="log" id="log"><div class="t">Actions and their responses appear here.</div></div>
@@ -421,6 +440,7 @@
if (!r.ok || !r.data) return;
overview = r.data;
const { stats, runtime: rt, replicas, online, debug } = overview;
$('waveRun').disabled = !debug;
$('servedBy').textContent = rt.host;
$('appName').textContent = rt.app;
setVersion(rt.version);
@@ -428,18 +448,20 @@
$('tReplicas').textContent = replicas.length;
$('tTotal').textContent = stats.total;
$('tToday').textContent = stats.today;
$('tReacts').textContent = stats.reactions ?? 0;
$('tPinned').textContent = stats.pinned;
$('tHidden').textContent = stats.hidden;
renderChart(stats.daily || []);
$('replicaHint').innerHTML = rt.broker === 'redis'
? 'Presence is shared through Redis, so every replica is listed.'
? 'Presence is shared through Redis, so every replica is listed. The requests counter grows per replica and resets when a rollout replaces it.'
: 'In-process broker: only this replica is visible. Attach Redis (<code>REDIS_URL</code>) to share live updates across replicas.';
$('replicaRows').innerHTML = replicas.map((r) => `
<tr class="${r.host === rt.host ? 'me' : ''}">
<td class="mono">${esc(r.host)}</td>
<td><span class="ver" style="background:hsl(${hueFor(r.version)} 70% 52%);font-size:11px;padding:2px 8px">v${esc(r.version)}</span></td>
<td>${r.online}</td>
<td>${r.requests ?? '–'}</td>
<td>${ago(r.seen)}</td>
</tr>`).join('');
@@ -452,6 +474,7 @@
['Live updates', rt.broker === 'redis' ? 'Redis pub/sub' : 'in-process'],
['Redis', status(rt.redis)],
['Uptime', uptime(rt.uptime_s)],
['Requests served', rt.requests ?? 0],
['Go', `${esc(rt.go)} · ${rt.cpus} CPU`],
['Goroutines', rt.goroutines],
['Heap / Sys', `${rt.heap_mb.toFixed(1)} / ${rt.sys_mb.toFixed(1)} MiB`],
@@ -496,6 +519,8 @@
function rowHTML(e) {
const rep = e.replica ? `<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 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}">
<div class="head">
<span class="who">${esc(e.name)}</span>
@@ -503,6 +528,7 @@
<span class="id">#${e.id}</span>
${e.pinned ? '<span class="badge pin">Pinned</span>' : ''}
${e.hidden ? '<span class="badge hid">Hidden</span>' : ''}
${reactsChip}
${ip}
${rep}
</div>
@@ -552,14 +578,16 @@
if (!r.ok) return;
$('paused').checked = !!r.data.signing_paused;
$('banner').value = r.data.banner || '';
$('theme').value = r.data.theme || 'system';
$('bannerCount').textContent = $('banner').value.length + ' / 200';
}
async function saveSettings() {
const r = await req('PUT', '/api/admin/settings', { signing_paused: $('paused').checked, banner: $('banner').value });
const r = await req('PUT', '/api/admin/settings', { signing_paused: $('paused').checked, banner: $('banner').value, theme: $('theme').value });
toast(r.ok ? 'Settings saved and broadcast' : ((r.data && r.data.error) || 'Could not save'), !r.ok);
}
$('banner').addEventListener('input', (e) => { $('bannerCount').textContent = e.target.value.length + ' / 200'; });
$('paused').addEventListener('change', saveSettings);
$('theme').addEventListener('change', saveSettings);
$('settingsForm').addEventListener('submit', (e) => { e.preventDefault(); saveSettings(); });
// ---- chaos lab ---------------------------------------------------------
@@ -615,6 +643,65 @@
log('gave up watching after 120s', 'w');
}
// ---- rolling-update wave -----------------------------------------------
// Crashes replicas one at a time and waits for each to rejoin (its request
// counter restarts from ~0), mimicking — and explaining — what the platform
// does during a rolling update. Self-healing does the rest.
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
async function fetchOverviewQuiet() {
try {
const res = await fetch('/api/admin/overview', { cache: 'no-store' });
return res.ok ? await res.json() : null;
} catch { return null; }
}
$('waveRun').addEventListener('click', async () => {
if (!overview || !overview.debug) return;
if (!confirm('Restart every replica, one at a time, using the crash endpoint? The app self-heals — this is what a rolling update looks like from the inside.')) return;
$('waveRun').disabled = true;
$('waveStat').hidden = false;
try {
// Snapshot the fleet (in local mode this is just the answering replica).
const first = await fetchOverviewQuiet();
const targets = [...new Set((first ? first.replicas : []).map((r) => r.host))];
if (!targets.length) targets.push((first && first.runtime.host) || '');
let bounced = 0, hops = 0;
while (bounced < targets.length && hops < targets.length * 3 + 3) {
hops++;
const ov = await fetchOverviewQuiet();
if (!ov) { await sleep(1500); continue; }
const host = ov.runtime.host;
const idx = targets.indexOf(host);
if (idx < 0) { // already-bounced or unknown replica answered: keep hunting
$('waveStat').textContent = `looking for ${targets[bounced]}… (at ${host})`;
await req('POST', '/api/debug/crash'); // bounce it too, then move on
await sleep(4000);
continue;
}
targets.splice(idx, 1);
$('waveStat').textContent = `wave ${bounced + 1} → bouncing ${host}`;
log(`wave ${bounced + 1}: crashing ${host} (served ${ov.runtime.requests ?? '?'} requests since boot)`, 'w');
await req('POST', '/api/debug/crash');
// Wait until a *different* replica (or the same name with a fresh
// counter) answers again — that is the restart made visible.
const deadline = Date.now() + 90000;
let back = false;
while (Date.now() < deadline) {
await sleep(1500);
const cur = await fetchOverviewQuiet();
if (!cur) continue;
if (cur.runtime.host !== host || (cur.runtime.requests ?? 0) < (ov.runtime.requests ?? 0)) { back = true; break; }
}
log(back ? `${host} is back (request counter reset)` : `${host} did not come back within 90s`, back ? 's' : 'e');
bounced++;
}
$('waveStat').textContent = `wave done — ${bounced} replica${bounced === 1 ? '' : 's'} bounced`;
log(`rolling wave finished: ${bounced} replica(s) bounced, app stayed up`, 's');
} finally {
$('waveRun').disabled = !(overview && overview.debug);
loadOverview();
}
});
$('probeRun').addEventListener('click', async () => {
const url = $('probeTarget').value;
const n = Math.min(1000, Math.max(1, +$('probeN').value || 1));
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#8b5cf6"/>
<stop offset="1" stop-color="#a855f7"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="url(#g)"/>
<path d="M20 34h24v4a8 8 0 0 1-8 8h-8a8 8 0 0 1-8-8v-4z" fill="#fff" opacity=".92"/>
<path d="M25 22c0-3 3-3 3-6m5 6c0-3 3-3 3-6" stroke="#fff" stroke-width="3.4" stroke-linecap="round" fill="none" opacity=".92"/>
</svg>

After

Width:  |  Height:  |  Size: 531 B

+127 -5
View File
@@ -5,8 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Miabi Guestbook</title>
<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>
: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-2: #12101d;
--card: rgba(255, 255, 255, 0.04);
@@ -17,9 +22,31 @@
--accent-2: #a855f7;
--danger: #f43f5e;
--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;
--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; }
html, body { margin: 0; padding: 0; }
body {
@@ -27,8 +54,8 @@
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: var(--text);
background:
radial-gradient(900px 500px at 15% -10%, rgba(139, 92, 246, 0.18), transparent 60%),
radial-gradient(700px 500px at 110% 10%, rgba(168, 85, 247, 0.14), transparent 55%),
radial-gradient(900px 500px at 15% -10%, var(--glow-a), transparent 60%),
radial-gradient(700px 500px at 110% 10%, var(--glow-b), transparent 55%),
linear-gradient(180deg, var(--bg), var(--bg-2));
background-attachment: fixed;
-webkit-font-smoothing: antialiased;
@@ -59,6 +86,14 @@
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; } }
.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; }
h1 {
@@ -100,7 +135,7 @@
.row { display: flex; gap: 12px; flex-wrap: wrap; }
input, textarea {
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;
transition: border-color .15s, box-shadow .15s;
}
@@ -145,7 +180,7 @@
.entry .when { color: var(--muted); }
.entry .via {
margin-left: auto; font-size: 11.5px; color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
background: rgba(0,0,0,.2); border: 1px solid var(--card-border); padding: 2px 8px; border-radius: 999px;
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;
}
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);
}
.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 {
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;
@@ -208,6 +258,7 @@
<span class="ver" id="ver" title="Serving version">v…</span>
<span class="replicas" id="replicas" title="Running replicas" hidden></span>
<span class="online"><span class="pulse"></span> <span id="online">0</span> online</span>
<button class="icon-btn" id="themeBtn" title="Toggle light / dark theme" aria-label="Toggle light / dark theme">🌓</button>
</div>
</div>
@@ -300,8 +351,12 @@
settings: () => fetch('/api/settings').then(r => r.json()),
list: (limit, offset) => fetch(`/api/entries?limit=${limit}&offset=${offset}`).then(r => r.json()),
create: (body) => fetch('/api/entries', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }),
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 allOffset = 0; // current offset on the "All" page
@@ -325,6 +380,23 @@
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) {
const via = e.replica && e.replica !== 'seed'
? `<span class="via" title="Accepted by replica ${esc(e.replica)}">via ${esc(e.replica)}</span>` : '';
@@ -337,9 +409,56 @@
<span class="when">· ${timeAgo(e.created_at)}</span>
${via}
</div>
${reactsHTML(e)}
</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) {
loading.style.display = 'none';
if (!entries.length) { ul.innerHTML = `<div class="empty">${emptyMsg}</div>`; return; }
@@ -374,6 +493,8 @@
const paused = !!(st && st.signing_paused);
$('paused').hidden = !paused;
$('form').hidden = paused;
serverTheme = (st && st.theme) || '';
applyTheme();
}
// ---- views --------------------------------------------------------------
@@ -469,6 +590,7 @@
es.addEventListener('created', (e) => { const d = JSON.parse(e.data); if (d.entry && !d.entry.hidden) onCreated(d.entry); });
es.addEventListener('updated', () => reloadView());
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); });
// Resync after a reconnect: events sent while disconnected were missed.
es.addEventListener('open', () => { if (connectedOnce) { reloadView(); api.settings().then(applySettings).catch(() => {}); } connectedOnce = true; });