repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

13.5 KB · 462 lines · Go Raw History
  1package main
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"fmt"
  7	"log/slog"
  8	"sync"
  9	"time"
 10)
 11
 12// State is the whole page as data. Every panel is rebuilt into this and the
 13// same struct is what the template renders on a cold load and what goes down
 14// the SSE connection on every update, so there is one shape to get right rather
 15// than a server one and a browser one that drift.
 16type State struct {
 17	Market    Market       `json:"market"`
 18	Rates     Rates        `json:"rates"`
 19	Sectors   []SectorCell `json:"sectors"`
 20	Signal    Signal       `json:"signal"`
 21	Earnings  Earnings     `json:"earnings"`
 22	Wire      []Headline   `json:"wire"`
 23	HN        []Story      `json:"hn"`
 24	Lobsters  []Story      `json:"lobsters"`
 25	Weather   Weather      `json:"weather"`
 26	Air       Air          `json:"air"`
 27	Alerts    []Alert      `json:"alerts"`
 28	Outlook   Outlook      `json:"outlook"`
 29	Steam     []Game       `json:"steam"`
 30	Streaming []Title      `json:"streaming"`
 31	Systems   Systems      `json:"systems"`
 32	Feeds     []Feed       `json:"feeds"`
 33	Updated   string       `json:"updated"`
 34	Guarded   []string     `json:"guarded"`
 35}
 36
 37// Store holds the latest of everything and hands out snapshots. Each poller
 38// writes its own field, so the lock is only ever held for a struct copy.
 39type Store struct {
 40	mu    sync.RWMutex
 41	state State
 42
 43	// The daily series the conditions panel reads, kept here because the market
 44	// poll rebuilds that panel every thirty seconds and this only refreshes
 45	// hourly.
 46	history *history
 47
 48	hub *Hub
 49}
 50
 51func NewStore(hub *Hub) *Store { return &Store{hub: hub} }
 52
 53func (s *Store) Snapshot() State {
 54	s.mu.RLock()
 55	defer s.mu.RUnlock()
 56	return s.state
 57}
 58
 59// update applies one change and pushes the whole state out. Taking a mutator
 60// rather than a field keeps every write on one path, which is what makes the
 61// broadcast unconditional and impossible to forget.
 62func (s *Store) update(f func(*State)) {
 63	s.mu.Lock()
 64	f(&s.state)
 65	s.state.Updated = time.Now().UTC().Format(time.RFC3339)
 66	snapshot := s.state
 67	s.mu.Unlock()
 68
 69	b, err := json.Marshal(snapshot)
 70	if err != nil {
 71		slog.Error("state unmarshalable", slog.Any("err", err))
 72		return
 73	}
 74	s.hub.Broadcast(b)
 75}
 76
 77// Hub is the SSE fan-out. One poller writes and every open browser reads, so
 78// ten tabs on this page still cost one request upstream.
 79type Hub struct {
 80	mu      sync.Mutex
 81	clients map[chan []byte]struct{}
 82	last    []byte
 83
 84	// Closed on nothing; sent to when the first browser of a quiet spell
 85	// connects. The markets loop waits on it alongside its timer, so somebody
 86	// opening the page does not have to sit out the rest of an idle interval.
 87	// Buffered by one and sent without blocking, so an arrival while the loop
 88	// is mid-fetch is remembered rather than lost.
 89	wake chan struct{}
 90}
 91
 92func NewHub() *Hub {
 93	return &Hub{
 94		clients: map[chan []byte]struct{}{},
 95		wake:    make(chan struct{}, 1),
 96	}
 97}
 98
 99// Subscribe returns a channel of frames and the function that closes it. The
100// buffer is small: a browser that cannot keep up with a state this size is
101// gone, and dropping the frame is better than holding the broadcast for it.
102func (h *Hub) Subscribe() (<-chan []byte, func()) {
103	ch := make(chan []byte, 4)
104
105	h.mu.Lock()
106	first := len(h.clients) == 0
107	h.clients[ch] = struct{}{}
108	last := h.last
109	h.mu.Unlock()
110
111	if first {
112		select {
113		case h.wake <- struct{}{}:
114		default:
115		}
116	}
117
118	// A new connection gets the current state at once rather than waiting out
119	// the poll interval, which is what makes a reconnect invisible.
120	if last != nil {
121		ch <- last
122	}
123
124	return ch, func() {
125		h.mu.Lock()
126		if _, ok := h.clients[ch]; ok {
127			delete(h.clients, ch)
128			close(ch)
129		}
130		h.mu.Unlock()
131	}
132}
133
134func (h *Hub) Broadcast(b []byte) {
135	h.mu.Lock()
136	defer h.mu.Unlock()
137
138	h.last = b
139	for ch := range h.clients {
140		select {
141		case ch <- b:
142		default:
143		}
144	}
145}
146
147func (h *Hub) Watching() int {
148	h.mu.Lock()
149	defer h.mu.Unlock()
150	return len(h.clients)
151}
152
153// Poll intervals. Markets move and the rest do not, so only markets gets a fast
154// one, and it only gets it while somebody is looking.
155const (
156	marketWatched = 30 * time.Second
157
158	// Yahoo's edge sends cache-control: max-age=10 on these responses, so
159	// anything under about ten seconds is asking for bytes it already has.
160	// Thirty is comfortably past that and still live enough to watch.
161
162	marketIdle = 5 * time.Minute
163	newsEvery  = 5 * time.Minute
164)
165
166// Prime fetches what the first page render needs, synchronously, before the
167// server starts listening.
168//
169// The daily history comes first because the conditions panel is built by the
170// market poll out of it, and priming them the other way round renders a panel
171// with one of its four rows for the first thirty seconds after every deploy.
172func (s *Store) Prime(ctx context.Context, g *Guard) {
173	s.refreshSignal(ctx, g)
174	s.refreshMarket(ctx, g)
175}
176
177// Run starts one goroutine per source and blocks until ctx is done. It must not
178// start before the listener is up: the health strip probes this process over
179// loopback, and a first round against a socket nobody is listening on reports
180// the site the dashboard is running on as unknown until the next round a minute
181// later, which is every deploy.
182func (s *Store) Run(ctx context.Context, g *Guard) {
183	go s.loop(ctx, "market", func() time.Duration {
184		if s.hub.Watching() > 0 {
185			return marketWatched
186		}
187		// Nobody is on the page, so this drops to a keep-warm poll. It does not
188		// stop outright because the first visitor after a quiet night should
189		// open onto real numbers, not a spinner.
190		return marketIdle
191	}, func() { s.refreshMarket(ctx, g) })
192
193	go s.loop(ctx, "news", nil, func() { s.refreshNews(ctx, g) })
194	go s.loop(ctx, "wire", nil, func() { s.refreshWire(ctx, g) })
195	go s.loop(ctx, "signal", nil, func() { s.refreshSignal(ctx, g) })
196	go s.loop(ctx, "board", nil, func() { s.refreshBoard(ctx, g) })
197	go s.loop(ctx, "earnings", nil, func() { s.refreshEarnings(ctx, g) })
198	go s.loop(ctx, "alerts", nil, func() { s.refreshAlerts(ctx, g) })
199	go s.loop(ctx, "air", nil, func() { s.refreshAir(ctx, g) })
200	go s.loop(ctx, "outdoors", nil, func() { s.refreshOutlook(ctx, g) })
201	go s.loop(ctx, "steam", nil, func() { s.refreshSteam(ctx, g) })
202	go s.loop(ctx, "streaming", nil, func() { s.refreshStreaming(ctx, g) })
203	go s.loop(ctx, "weather", nil, func() { s.refreshWeather(ctx, g) })
204	go s.loop(ctx, "systems", nil, func() { s.refreshSystems(ctx, g) })
205
206	// The guard writes itself out on a timer rather than on every call, so a
207	// thirty second poll does not mean a file write per tick.
208	go func() {
209		t := time.NewTicker(time.Minute)
210		defer t.Stop()
211		for {
212			select {
213			case <-ctx.Done():
214				g.Flush()
215				return
216			case <-t.C:
217				g.Flush()
218				s.update(func(st *State) {
219					st.Guarded = g.Status()
220					st.Feeds = g.Feeds(time.Now())
221				})
222			}
223		}
224	}()
225
226	<-ctx.Done()
227}
228
229// loop runs work on an interval. every is a function so the markets loop can
230// change its mind about the cadence between ticks; a nil one means use the
231// fixed interval the source was registered with.
232//
233// The markets loop also wakes when the first browser of a quiet spell connects.
234// Without it a visitor arriving a second after an idle timer was set would wait
235// out the whole five minutes watching a page that says it is live.
236func (s *Store) loop(ctx context.Context, name string, every func() time.Duration, work func()) {
237	fixed := map[string]time.Duration{
238		"news":      newsEvery,
239		"wire":      wireEvery,
240		"signal":    signalEvery,
241		"board":     boardEvery,
242		"earnings":  earningsEvery,
243		"alerts":    alertsEvery,
244		"air":       airEvery,
245		"outdoors":  outdoorsEvery,
246		"steam":     steamEvery,
247		"streaming": streamingEvery,
248		"weather":   weatherEvery,
249		"systems":   probeEvery,
250	}[name]
251
252	if every == nil {
253		every = func() time.Duration { return fixed }
254	}
255
256	var wake <-chan struct{}
257	if name == "market" {
258		wake = s.hub.wake
259	}
260
261	// The first run is immediate for everything Prime did not already do.
262	last := time.Now()
263	if name != "market" && name != "signal" {
264		work()
265	}
266
267	for {
268		t := time.NewTimer(every())
269		select {
270		case <-ctx.Done():
271			t.Stop()
272			return
273
274		case <-t.C:
275			work()
276			last = time.Now()
277
278		case <-wake:
279			t.Stop()
280			// A viewer arriving right after a fetch does not need another one,
281			// and the hub has already replayed the current state to them.
282			if time.Since(last) >= marketWatched {
283				work()
284				last = time.Now()
285			}
286		}
287	}
288}
289
290func (s *Store) refreshMarket(ctx context.Context, g *Guard) {
291	quotes, err := fetchStrip(ctx, g)
292	if err != nil {
293		slog.Warn("market poll failed", slog.String("component", "market"), slog.Any("err", err))
294		// The previous cards stay up rather than being blanked, and the panel
295		// marks itself stale so the page says so instead of quietly lying.
296		s.update(func(st *State) { st.Market.Stale = true })
297		return
298	}
299	m := buildMarket(quotes, time.Now())
300	m.Updated = time.Now().UTC().Format("15:04:05")
301	m = carrySparks(m, s.Snapshot().Market)
302
303	s.mu.RLock()
304	h := s.history
305	s.mu.RUnlock()
306
307	sig := buildSignal(h, quotes)
308
309	s.update(func(st *State) {
310		st.Market = m
311		st.Signal = sig
312	})
313}
314
315// refreshBoard is the rates and sectors poll. Neither moves on the scale the
316// market strip does, and riding the fast poll cost a third of every Yahoo
317// request this site makes for numbers that had not changed.
318func (s *Store) refreshBoard(ctx context.Context, g *Guard) {
319	quotes, err := fetchQuotes(ctx, g, rateAndSectorSymbols(), sessionRange)
320	if err != nil {
321		slog.Warn("board poll failed", slog.String("component", "board"), slog.Any("err", err))
322		return
323	}
324
325	rates := buildRates(quotes)
326	sectors := buildSectors(quotes)
327	s.update(func(st *State) {
328		st.Rates = rates
329		st.Sectors = sectors
330	})
331}
332
333func (s *Store) refreshSignal(ctx context.Context, g *Guard) {
334	h, err := fetchHistory(ctx, g, signalSymbol)
335	if err != nil {
336		slog.Warn("history poll failed", slog.String("component", "signal"), slog.Any("err", err))
337		return
338	}
339	s.mu.Lock()
340	s.history = h
341	s.mu.Unlock()
342}
343
344func (s *Store) refreshEarnings(ctx context.Context, g *Guard) {
345	rows, err := fetchEarnings(ctx, g, time.Now())
346	if err != nil {
347		slog.Warn("earnings poll failed", slog.String("component", "earnings"), slog.Any("err", err))
348		return
349	}
350	s.update(func(st *State) { st.Earnings = rows })
351}
352
353// An empty alert list is a result, not a failure, so this writes it: the panel
354// has to be able to say all clear rather than showing yesterday's warning.
355func (s *Store) refreshAlerts(ctx context.Context, g *Guard) {
356	alerts, err := fetchAlerts(ctx, g)
357	if err != nil {
358		slog.Warn("alerts poll failed", slog.String("component", "local"), slog.Any("err", err))
359		return
360	}
361	s.update(func(st *State) { st.Alerts = alerts })
362}
363
364func (s *Store) refreshAir(ctx context.Context, g *Guard) {
365	air, err := fetchAir(ctx, g)
366	if err != nil {
367		slog.Warn("air poll failed", slog.String("component", "local"), slog.Any("err", err))
368		return
369	}
370
371	// Pollen is the unofficial source here, so it losing its footing costs its
372	// own row and not the air quality beside it.
373	if index, top, err := fetchPollen(ctx, g); err != nil {
374		slog.Warn("pollen poll failed", slog.String("component", "local"), slog.Any("err", err))
375	} else {
376		air.Pollen = fmt.Sprintf("%.1f", index)
377		air.PollenState = pollenBand(index)
378		air.PollenTop = top
379		air.PollenKnown = true
380		air.PollenFill, air.PollenLevel = gauge(index, 9.7, 2.5, 4.9, 7.3)
381	}
382
383	s.update(func(st *State) { st.Air = air })
384}
385
386func (s *Store) refreshStreaming(ctx context.Context, g *Guard) {
387	titles, err := fetchStreaming(ctx, g)
388	if err != nil {
389		slog.Warn("streaming poll failed", slog.String("component", "streaming"), slog.Any("err", err))
390		return
391	}
392	s.update(func(st *State) { st.Streaming = titles })
393}
394
395func (s *Store) refreshOutlook(ctx context.Context, g *Guard) {
396	outlook, err := fetchOutlook(ctx, g, time.Now())
397	if err != nil {
398		slog.Warn("outlook poll failed", slog.String("component", "local"), slog.Any("err", err))
399		return
400	}
401	s.update(func(st *State) { st.Outlook = outlook })
402}
403
404func (s *Store) refreshSteam(ctx context.Context, g *Guard) {
405	games, err := fetchSteam(ctx, g)
406	if err != nil {
407		slog.Warn("steam poll failed", slog.String("component", "steam"), slog.Any("err", err))
408		return
409	}
410	s.update(func(st *State) {
411		if keepSteam(games, st.Steam) {
412			st.Steam = games
413		}
414	})
415}
416
417func (s *Store) refreshWire(ctx context.Context, g *Guard) {
418	wire, err := fetchWire(ctx, g, time.Now())
419	if err != nil {
420		slog.Warn("wire poll failed", slog.String("component", "wire"), slog.Any("err", err))
421		return
422	}
423	s.update(func(st *State) { st.Wire = wire })
424}
425
426func (s *Store) refreshNews(ctx context.Context, g *Guard) {
427	now := time.Now()
428
429	if stories, err := fetchHackerNews(ctx, g, now); err != nil {
430		slog.Warn("hacker news poll failed", slog.String("component", "news"), slog.Any("err", err))
431	} else {
432		s.update(func(st *State) { st.HN = stories })
433	}
434
435	if stories, err := fetchLobsters(ctx, g, now); err != nil {
436		slog.Warn("lobsters poll failed", slog.String("component", "news"), slog.Any("err", err))
437	} else {
438		s.update(func(st *State) { st.Lobsters = stories })
439	}
440}
441
442func (s *Store) refreshWeather(ctx context.Context, g *Guard) {
443	w, err := fetchWeather(ctx, g)
444	if err != nil {
445		slog.Warn("weather poll failed", slog.String("component", "weather"), slog.Any("err", err))
446		return
447	}
448	s.update(func(st *State) { st.Weather = w })
449}
450
451func (s *Store) refreshSystems(ctx context.Context, g *Guard) {
452	sys := buildSystems(ctx, g, time.Now())
453	// Guarded and Feeds are the same fact told two ways, in the status line and
454	// in the SIGNAL panel, so they are written together or the page contradicts
455	// itself for up to a minute.
456	s.update(func(st *State) {
457		st.Systems = sys
458		st.Feeds = g.Feeds(time.Now())
459		st.Guarded = g.Status()
460	})
461}