Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 08f2394715 | |||
| f01a26dae8 |
@@ -16,8 +16,10 @@ 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
|
||||||
- **Version badge** — the serving build's version is shown in the UI and
|
- **Version badge + live server-time card** — the serving build's version is
|
||||||
`/api/info`; ideal for watching a **canary rollout** decide which build answers
|
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
|
- **[GORM](https://gorm.io) with PostgreSQL _or_ SQLite** — `uint` PKs and soft
|
||||||
deletes, the same conventions Miabi uses. Attach a Miabi managed Postgres, or
|
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).
|
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 |
|
| Method | Path | Description |
|
||||||
|----------|----------------------|------------------------------------|
|
|----------|----------------------|------------------------------------|
|
||||||
| `GET` | `/healthz` | Liveness/readiness (checks the DB), reports `version` |
|
| `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=` |
|
| `GET` | `/api/entries` | List entries + total; paginated via `?limit=&offset=` |
|
||||||
| `POST` | `/api/entries` | Create `{ "name", "message" }` (broadcast live) |
|
| `POST` | `/api/entries` | Create `{ "name", "message" }` (broadcast live) |
|
||||||
| `DELETE` | `/api/entries/{id}` | Delete an entry (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) |
|
| `GET` | `/` · `/all` | The web UI (home · all-signatures page) |
|
||||||
|
|
||||||
### Live updates (SSE)
|
### Live updates (SSE)
|
||||||
|
|
||||||
`/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 + online count); thereafter the server pushes
|
`welcome` event (serving version + host + online count); thereafter the server
|
||||||
`created` / `deleted` events as the wall changes and `presence` events whenever
|
pushes `created` / `deleted` events as the wall changes, `presence` events when
|
||||||
the number of connected clients changes. The UI uses these to update the wall in
|
the connected-client count changes, and a `tick` event once a second carrying
|
||||||
real time and show a live **“N online”** indicator — no polling.
|
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
|
## 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.
|
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%).
|
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
|
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
|
different colour) **and the new live server-time card**, the rest `v1.0.0`
|
||||||
database, signatures created on either build appear on both — and stream
|
without it. Because both versions share the same database, signatures created
|
||||||
live to every open tab via SSE.
|
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.
|
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
|
> No build args? Set `APP_VERSION=1.0.0` / `APP_VERSION=2.0.0` in each app's
|
||||||
> environment instead — same effect.
|
> environment instead — same effect.
|
||||||
|
|||||||
@@ -8,11 +8,15 @@ import "sync"
|
|||||||
// - "created" → Entry is set (a new signature)
|
// - "created" → Entry is set (a new signature)
|
||||||
// - "deleted" → ID is set (a removed signature)
|
// - "deleted" → ID is set (a removed signature)
|
||||||
// - "presence" → Online is set (connected-client count changed)
|
// - "presence" → Online is set (connected-client count changed)
|
||||||
|
// - "tick" → Time/Version/Host are set (live server clock; new in v2)
|
||||||
type Event struct {
|
type Event struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Entry *Entry `json:"entry,omitempty"`
|
Entry *Entry `json:"entry,omitempty"`
|
||||||
ID uint `json:"id,omitempty"`
|
ID uint `json:"id,omitempty"`
|
||||||
Online int `json:"online,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
|
// Broker is a tiny in-process pub/sub hub. Each connected SSE client holds one
|
||||||
|
|||||||
+20
@@ -16,6 +16,18 @@ type Handler struct {
|
|||||||
broker *Broker
|
broker *Broker
|
||||||
appName string
|
appName string
|
||||||
version 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.
|
// createEntryRequest is the POST /api/entries body.
|
||||||
@@ -50,9 +62,16 @@ func (h *Handler) Info(c *okapi.Context) error {
|
|||||||
"app": h.appName,
|
"app": h.appName,
|
||||||
"version": h.version,
|
"version": h.version,
|
||||||
"online": h.broker.Online(),
|
"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.
|
// ListEntries returns guestbook entries, newest first, with simple pagination.
|
||||||
// Query params: limit (default 100, max 200) and offset (default 0). The home
|
// 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.
|
// 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{
|
welcome := okapi.Message{Event: "welcome", Data: okapi.M{
|
||||||
"version": h.version,
|
"version": h.version,
|
||||||
"online": h.broker.Online(),
|
"online": h.broker.Online(),
|
||||||
|
"host": h.host,
|
||||||
}}
|
}}
|
||||||
select {
|
select {
|
||||||
case msgs <- welcome:
|
case msgs <- welcome:
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"embed"
|
"embed"
|
||||||
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jkaninda/logger"
|
"github.com/jkaninda/logger"
|
||||||
@@ -51,8 +52,14 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
host, _ := os.Hostname()
|
||||||
broker := NewBroker()
|
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 := okapi.New(okapi.WithPort(cfg.Port))
|
||||||
app.WithDebug()
|
app.WithDebug()
|
||||||
@@ -61,6 +68,7 @@ func main() {
|
|||||||
app.Get("/healthz", h.Health)
|
app.Get("/healthz", h.Health)
|
||||||
api := app.Group("/api")
|
api := app.Group("/api")
|
||||||
api.Get("/info", h.Info)
|
api.Get("/info", h.Info)
|
||||||
|
api.Get("/time", h.Time) // current server time (v2 clock card)
|
||||||
api.Get("/entries", h.ListEntries)
|
api.Get("/entries", h.ListEntries)
|
||||||
api.Post("/entries", h.CreateEntry)
|
api.Post("/entries", h.CreateEntry)
|
||||||
api.Delete("/entries/{id:int}", h.DeleteEntry)
|
api.Delete("/entries/{id:int}", h.DeleteEntry)
|
||||||
@@ -81,6 +89,7 @@ func main() {
|
|||||||
},
|
},
|
||||||
OnShutdown: func() {
|
OnShutdown: func() {
|
||||||
logger.Info("shutting down")
|
logger.Info("shutting down")
|
||||||
|
stopTicks()
|
||||||
_ = store.Close()
|
_ = 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 {
|
func migrateWithRetry(store *Store, attempts int, wait time.Duration) error {
|
||||||
var err error
|
var err error
|
||||||
for i := 0; i < attempts; i++ {
|
for i := 0; i < attempts; i++ {
|
||||||
|
|||||||
@@ -84,6 +84,18 @@
|
|||||||
background: var(--card); border: 1px solid var(--card-border);
|
background: var(--card); border: 1px solid var(--card-border);
|
||||||
border-radius: var(--radius); backdrop-filter: blur(12px);
|
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; }
|
form.compose { padding: 20px; box-shadow: var(--shadow); margin-bottom: 14px; }
|
||||||
.row { display: flex; gap: 12px; flex-wrap: wrap; }
|
.row { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||||
input, textarea {
|
input, textarea {
|
||||||
@@ -190,6 +202,15 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</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 -->
|
<!-- Home view -->
|
||||||
<section id="view-home">
|
<section id="view-home">
|
||||||
<form class="compose card" id="form">
|
<form class="compose card" id="form">
|
||||||
@@ -249,6 +270,7 @@
|
|||||||
|
|
||||||
const api = {
|
const api = {
|
||||||
info: () => fetch('/api/info').then(r => r.json()),
|
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()),
|
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) }),
|
||||||
remove: (id) => fetch('/api/entries/' + id, { method: 'DELETE' }),
|
remove: (id) => fetch('/api/entries/' + id, { method: 'DELETE' }),
|
||||||
@@ -378,12 +400,28 @@
|
|||||||
if (currentView() === 'all') loadAll();
|
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() {
|
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); });
|
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('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('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('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.
|
// EventSource auto-reconnects on error; nothing to do here.
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,6 +458,7 @@
|
|||||||
|
|
||||||
// ---- boot ---------------------------------------------------------------
|
// ---- boot ---------------------------------------------------------------
|
||||||
api.info().then((d) => { setVersion(d.version); setOnline(d.online); }).catch(() => {});
|
api.info().then((d) => { setVersion(d.version); setOnline(d.online); }).catch(() => {});
|
||||||
|
api.time().then(showTime).catch(() => {}); // immediate clock; SSE ticks keep it live
|
||||||
route();
|
route();
|
||||||
connectSSE();
|
connectSSE();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user