From f01a26dae8d6ba207f9c1ae324e1ec7e889b5898 Mon Sep 17 00:00:00 2001 From: Jonas Kaninda Date: Mon, 6 Jul 2026 20:30:13 +0200 Subject: [PATCH] feat: add live server-time card with host --- README.md | 38 ++++++++++++++++++++++++++------------ broker.go | 12 ++++++++---- handlers.go | 20 ++++++++++++++++++++ main.go | 32 +++++++++++++++++++++++++++++++- web/index.html | 39 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 124 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 96454ed..2748d55 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,10 @@ 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 -- **Version badge** — the serving build's version is shown in the UI and - `/api/info`; ideal for watching a **canary rollout** decide which build answers +- **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 + is new in v2, and its time/host reveal exactly which build/replica served you - **[GORM](https://gorm.io) with PostgreSQL _or_ SQLite** — `uint` PKs and soft deletes, the same conventions Miabi uses. Attach a Miabi managed Postgres, or fall back to a zero-config SQLite file (pure-Go driver, no CGO). @@ -44,20 +46,23 @@ and keeps the container tiny. | Method | Path | Description | |----------|----------------------|------------------------------------| | `GET` | `/healthz` | Liveness/readiness (checks the DB), reports `version` | -| `GET` | `/api/info` | App name, serving `version`, connected-client count | +| `GET` | `/api/info` | App name, serving `version`, `host`, connected-client count | +| `GET` | `/api/time` | Current server time + `version` + `host` (v2 — canary probe) | | `GET` | `/api/entries` | List entries + total; paginated via `?limit=&offset=` | | `POST` | `/api/entries` | Create `{ "name", "message" }` (broadcast live) | | `DELETE` | `/api/entries/{id}` | Delete an entry (broadcast live) | -| `GET` | `/api/stream` | **SSE** stream: `welcome`, `created`, `deleted`, `presence` events | +| `GET` | `/api/stream` | **SSE** stream: `welcome`, `created`, `deleted`, `presence`, `tick` events | | `GET` | `/` · `/all` | The web UI (home · all-signatures page) | ### Live updates (SSE) `/api/stream` is a Server-Sent Events endpoint. On connect the client gets a -`welcome` event (serving version + online count); thereafter the server pushes -`created` / `deleted` events as the wall changes and `presence` events whenever -the number of connected clients changes. The UI uses these to update the wall in -real time and show a live **“N online”** indicator — no polling. +`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. ## Configuration @@ -130,10 +135,19 @@ docker build --build-arg VERSION=2.0.0 -t miabi/guestbook:2.0.0 . 1. Deploy `1.0.0` as the app; attach a **shared** managed Postgres. 2. Roll out `2.0.0` as a **canary** with a small weight (e.g. 10%). 3. Refresh the page a few times: ~1 in 10 loads shows the `v2.0.0` badge (a - different colour), the rest `v1.0.0`. Because both versions share the same - database, signatures created on either build appear on both — and stream - live to every open tab via SSE. -4. Shift the weight up and promote `2.0.0` when you're happy. + different colour) **and the new live server-time card**, the rest `v1.0.0` + without it. Because both versions share the same database, signatures created + on either build appear on both — and stream live to every open tab via SSE. +4. Watch routing from the shell — each request may hit a different build/replica: + ```bash + watch -n1 'curl -s https://your-domain/api/time' + # {"time":"…","version":"2.0.0","host":"…"} ← version/host flips under canary + ``` +5. Shift the weight up and promote `2.0.0` when you're happy. + +> The server-time card is the **v2 change**: v1 (built before this feature) has +> no clock, so its presence — and the version/host it shows — is an at-a-glance +> signal of which build a viewer landed on. > No build args? Set `APP_VERSION=1.0.0` / `APP_VERSION=2.0.0` in each app's > environment instead — same effect. diff --git a/broker.go b/broker.go index c110d4f..4fa1ffd 100644 --- a/broker.go +++ b/broker.go @@ -8,11 +8,15 @@ import "sync" // - "created" → Entry is set (a new signature) // - "deleted" → ID is set (a removed signature) // - "presence" → Online is set (connected-client count changed) +// - "tick" → Time/Version/Host are set (live server clock; new in v2) type Event struct { - Type string `json:"type"` - Entry *Entry `json:"entry,omitempty"` - ID uint `json:"id,omitempty"` - Online int `json:"online,omitempty"` + Type string `json:"type"` + Entry *Entry `json:"entry,omitempty"` + ID uint `json:"id,omitempty"` + Online int `json:"online,omitempty"` + Time string `json:"time,omitempty"` + Version string `json:"version,omitempty"` + Host string `json:"host,omitempty"` } // Broker is a tiny in-process pub/sub hub. Each connected SSE client holds one diff --git a/handlers.go b/handlers.go index d3c0e35..c0f1aec 100644 --- a/handlers.go +++ b/handlers.go @@ -16,6 +16,18 @@ type Handler struct { broker *Broker appName string version string + host string +} + +// serverTime is the shape of the live clock payload (v2 feature). It carries +// the version and host so a canary viewer can see exactly which build/replica +// answered. +func (h *Handler) serverTime() okapi.M { + return okapi.M{ + "time": time.Now().Format(time.RFC3339), + "version": h.version, + "host": h.host, + } } // createEntryRequest is the POST /api/entries body. @@ -50,9 +62,16 @@ func (h *Handler) Info(c *okapi.Context) error { "app": h.appName, "version": h.version, "online": h.broker.Online(), + "host": h.host, }) } +// Time returns the current server time (plus version + host). Handy for probing +// a canary rollout from the shell: `watch curl -s .../api/time`. +func (h *Handler) Time(c *okapi.Context) error { + return c.OK(h.serverTime()) +} + // ListEntries returns guestbook entries, newest first, with simple pagination. // Query params: limit (default 100, max 200) and offset (default 0). The home // page requests a small limit; the "All signatures" page pages through them. @@ -162,6 +181,7 @@ func (h *Handler) Stream(c *okapi.Context) error { welcome := okapi.Message{Event: "welcome", Data: okapi.M{ "version": h.version, "online": h.broker.Online(), + "host": h.host, }} select { case msgs <- welcome: diff --git a/main.go b/main.go index e934b45..c78353e 100644 --- a/main.go +++ b/main.go @@ -16,6 +16,7 @@ package main import ( "context" "embed" + "os" "time" "github.com/jkaninda/logger" @@ -51,8 +52,14 @@ func main() { } } + host, _ := os.Hostname() broker := NewBroker() - h := &Handler{store: store, broker: broker, appName: cfg.AppName, version: cfg.Version} + h := &Handler{store: store, broker: broker, appName: cfg.AppName, version: cfg.Version, host: host} + + // Broadcast a live server-time "tick" to all SSE clients once a second + // (the v2 clock card). Stopped on shutdown. + tickCtx, stopTicks := context.WithCancel(ctx) + go publishTicks(tickCtx, broker, cfg.Version, host) app := okapi.New(okapi.WithPort(cfg.Port)) app.WithDebug() @@ -61,6 +68,7 @@ func main() { app.Get("/healthz", h.Health) api := app.Group("/api") api.Get("/info", h.Info) + api.Get("/time", h.Time) // current server time (v2 clock card) api.Get("/entries", h.ListEntries) api.Post("/entries", h.CreateEntry) api.Delete("/entries/{id:int}", h.DeleteEntry) @@ -81,6 +89,7 @@ func main() { }, OnShutdown: func() { logger.Info("shutting down") + stopTicks() _ = store.Close() }, }) @@ -92,6 +101,27 @@ func main() { } } +// publishTicks broadcasts a live server-time "tick" event to all connected SSE +// clients once a second. Each replica sends its own time/host, so under a +// canary rollout the clock card reveals exactly which build served you. +func publishTicks(ctx context.Context, b *Broker, version, host string) { + t := time.NewTicker(time.Second) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case now := <-t.C: + b.Publish(Event{ + Type: "tick", + Time: now.Format(time.RFC3339), + Version: version, + Host: host, + }) + } + } +} + func migrateWithRetry(store *Store, attempts int, wait time.Duration) error { var err error for i := 0; i < attempts; i++ { diff --git a/web/index.html b/web/index.html index e3a3c04..08b2005 100644 --- a/web/index.html +++ b/web/index.html @@ -84,6 +84,18 @@ background: var(--card); border: 1px solid var(--card-border); border-radius: var(--radius); backdrop-filter: blur(12px); } + .clock { + display: flex; align-items: center; justify-content: space-between; gap: 14px; + padding: 16px 20px; margin-bottom: 18px; + } + .clock-left { display: flex; flex-direction: column; gap: 2px; } + .clock-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); } + .clock-time { font-size: 26px; font-weight: 800; font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; color: var(--text); } + .clock-meta { font-size: 12px; color: var(--muted); text-align: right; line-height: 1.6; } + .clock-meta b { color: var(--text); font-weight: 600; } + .clock.stale { opacity: .5; } + form.compose { padding: 20px; box-shadow: var(--shadow); margin-bottom: 14px; } .row { display: flex; gap: 12px; flex-wrap: wrap; } input, textarea { @@ -190,6 +202,15 @@ + +
+
+
Server time
+
—
+
+
waiting for server…
+
+
@@ -249,6 +270,7 @@ const api = { info: () => fetch('/api/info').then(r => r.json()), + time: () => fetch('/api/time').then(r => r.json()), list: (limit, offset) => fetch(`/api/entries?limit=${limit}&offset=${offset}`).then(r => r.json()), create: (body) => fetch('/api/entries', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }), remove: (id) => fetch('/api/entries/' + id, { method: 'DELETE' }), @@ -378,12 +400,28 @@ if (currentView() === 'all') loadAll(); } + // ---- live server clock (v2) -------------------------------------------- + let clockStaleTimer; + function showTime({ time, version, host }) { + const d = new Date(time); + $('clockTime').textContent = isNaN(d) ? time : d.toLocaleTimeString(); + const bits = []; + if (version) bits.push(`v${esc(version)}`); + if (host) bits.push(`host ${esc(host)}`); + $('clockMeta').innerHTML = bits.join(' · ') || 'live'; + $('clock').classList.remove('stale'); + // If ticks stop (server gone / tab backgrounded), dim the card. + clearTimeout(clockStaleTimer); + clockStaleTimer = setTimeout(() => $('clock').classList.add('stale'), 4000); + } + function connectSSE() { const es = new EventSource('/api/stream'); es.addEventListener('welcome', (e) => { const d = JSON.parse(e.data); setVersion(d.version); setOnline(d.online); }); es.addEventListener('presence', (e) => { const d = JSON.parse(e.data); setOnline(d.online); }); es.addEventListener('created', (e) => { const d = JSON.parse(e.data); if (d.entry) onCreated(d.entry); }); es.addEventListener('deleted', (e) => { const d = JSON.parse(e.data); onDeleted(d.id); }); + es.addEventListener('tick', (e) => { const d = JSON.parse(e.data); showTime(d); setVersion(d.version); }); // EventSource auto-reconnects on error; nothing to do here. } @@ -420,6 +458,7 @@ // ---- boot --------------------------------------------------------------- api.info().then((d) => { setVersion(d.version); setOnline(d.online); }).catch(() => {}); + api.time().then(showTime).catch(() => {}); // immediate clock; SSE ticks keep it live route(); connectSSE();