Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 26 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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

Expand Down Expand Up @@ -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.
Expand Down
12 changes: 8 additions & 4 deletions broker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
32 changes: 31 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package main
import (
"context"
"embed"
"os"
"time"

"github.com/jkaninda/logger"
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand All @@ -81,6 +89,7 @@ func main() {
},
OnShutdown: func() {
logger.Info("shutting down")
stopTicks()
_ = store.Close()
},
})
Expand All @@ -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++ {
Expand Down
39 changes: 39 additions & 0 deletions web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -190,6 +202,15 @@ <h1>The <span class="grad">Guestbook</span></h1>
</nav>
</header>

<!-- Live server clock (new in v2) -->
<div class="card clock" id="clock">
<div class="clock-left">
<div class="clock-label">Server time</div>
<div class="clock-time" id="clockTime">—</div>
</div>
<div class="clock-meta" id="clockMeta">waiting for server…</div>
</div>

<!-- Home view -->
<section id="view-home">
<form class="compose card" id="form">
Expand Down Expand Up @@ -249,6 +270,7 @@ <h2>All signatures</h2>

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' }),
Expand Down Expand Up @@ -378,12 +400,28 @@ <h2>All signatures</h2>
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(`<b>v${esc(version)}</b>`);
if (host) bits.push(`host <b>${esc(host)}</b>`);
$('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.
}

Expand Down Expand Up @@ -420,6 +458,7 @@ <h2>All signatures</h2>

// ---- 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();
</script>
Expand Down