@yuuki824/kanshi 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,372 @@
1
+ // Package server wires the poller, the storage scanner and the HTTP surface
2
+ // together.
3
+ package server
4
+
5
+ import (
6
+ "context"
7
+ "encoding/json"
8
+ "fmt"
9
+ "io/fs"
10
+ "net"
11
+ "net/http"
12
+ "strconv"
13
+ "sync"
14
+ "time"
15
+
16
+ "github.com/yuuki824/kanshi/internal/config"
17
+ "github.com/yuuki824/kanshi/internal/dockerstats"
18
+ "github.com/yuuki824/kanshi/internal/storage"
19
+ "github.com/yuuki824/kanshi/internal/vitals"
20
+ )
21
+
22
+ // Frame is one push to the browser. Vitals and Docker are explicit nulls
23
+ // rather than omitted, because app.js tests each for truthiness.
24
+ type Frame struct {
25
+ Vitals *vitals.Sample `json:"vitals"`
26
+ Docker *dockerstats.Result `json:"docker"`
27
+ Error string `json:"error,omitempty"`
28
+ }
29
+
30
+ // Server holds the shared state the poller writes and the handlers read.
31
+ type Server struct {
32
+ cfg config.Config
33
+ vitals *vitals.Reader
34
+ docker *dockerstats.Client
35
+ storage *storage.Scanner
36
+ web fs.FS
37
+
38
+ // base outlives any single request. Work that mutates shared state — a
39
+ // sample, a storage walk — is started under it rather than the request
40
+ // context, so a browser navigating away mid-walk cannot abort a 76-second
41
+ // scan and leave an error on the snapshot everyone else reads.
42
+ base context.Context
43
+
44
+ mu sync.RWMutex
45
+ latest Frame
46
+ subs map[chan []byte]struct{}
47
+ lastSee time.Time
48
+
49
+ // wake releases the poller from its idle sleep. Buffered by one so a
50
+ // signal is never lost and no sender ever blocks.
51
+ wake chan struct{}
52
+ }
53
+
54
+ func New(cfg config.Config, web fs.FS) *Server {
55
+ return &Server{
56
+ base: context.Background(),
57
+ cfg: cfg,
58
+ vitals: vitals.New(cfg),
59
+ docker: dockerstats.New(cfg),
60
+ storage: storage.New(cfg),
61
+ web: web,
62
+ subs: make(map[chan []byte]struct{}),
63
+ wake: make(chan struct{}, 1),
64
+ }
65
+ }
66
+
67
+ /* ── polling ────────────────────────────────────────────────────────────── */
68
+
69
+ // sampleOnce takes one reading of both halves at the same moment. The vitals
70
+ // read is pure /proc work, so it runs alongside the Docker round trips rather
71
+ // than adding its latency to them.
72
+ func (s *Server) sampleOnce(ctx context.Context) Frame {
73
+ var host vitals.Sample
74
+ var containers dockerstats.Result
75
+
76
+ var wg sync.WaitGroup
77
+ wg.Add(1)
78
+ go func() {
79
+ defer wg.Done()
80
+ host = s.vitals.Sample()
81
+ }()
82
+ containers = s.docker.Sample(ctx)
83
+ wg.Wait()
84
+
85
+ frame := Frame{Vitals: &host, Docker: &containers}
86
+ s.mu.Lock()
87
+ s.latest = frame
88
+ s.mu.Unlock()
89
+ return frame
90
+ }
91
+
92
+ // seed fills the delta baselines so the first frame shows real numbers.
93
+ //
94
+ // Both CPU readings are differences against a previous sample, so a cold poller
95
+ // would otherwise publish a screen of zeros. One throwaway pass plus a short
96
+ // gap costs ~1s and makes the first frame the user sees correct.
97
+ func (s *Server) seed(ctx context.Context) {
98
+ s.vitals.Prime()
99
+ s.docker.Sample(ctx)
100
+ select {
101
+ case <-ctx.Done():
102
+ return
103
+ case <-time.After(time.Second):
104
+ }
105
+ s.vitals.Prime()
106
+ }
107
+
108
+ // Poll is the live loop. It stops touching the Docker socket entirely once
109
+ // nobody has been connected for IdleTimeout.
110
+ func (s *Server) Poll(ctx context.Context) {
111
+ s.seed(ctx)
112
+ for {
113
+ if s.idle() {
114
+ // Nobody is watching: sleep until a new subscriber or a REST
115
+ // request wakes us, then re-seed because the baselines are stale.
116
+ select {
117
+ case <-ctx.Done():
118
+ return
119
+ case <-s.wake:
120
+ case <-time.After(time.Minute):
121
+ }
122
+ s.seed(ctx)
123
+ continue
124
+ }
125
+
126
+ frame := s.sampleOnce(ctx)
127
+ if ctx.Err() != nil {
128
+ return
129
+ }
130
+ s.broadcast(frame)
131
+
132
+ select {
133
+ case <-ctx.Done():
134
+ return
135
+ case <-time.After(s.cfg.PollInterval):
136
+ }
137
+ }
138
+ }
139
+
140
+ func (s *Server) idle() bool {
141
+ s.mu.RLock()
142
+ defer s.mu.RUnlock()
143
+ return len(s.subs) == 0 && time.Since(s.lastSee) > s.cfg.IdleTimeout
144
+ }
145
+
146
+ // touch records browser activity and wakes an idle poller.
147
+ func (s *Server) touch() {
148
+ s.mu.Lock()
149
+ s.lastSee = time.Now()
150
+ s.mu.Unlock()
151
+ select {
152
+ case s.wake <- struct{}{}:
153
+ default: // already pending
154
+ }
155
+ }
156
+
157
+ func (s *Server) broadcast(frame Frame) {
158
+ payload, err := json.Marshal(frame)
159
+ if err != nil {
160
+ payload, _ = json.Marshal(Frame{Error: err.Error()})
161
+ }
162
+ s.mu.RLock()
163
+ defer s.mu.RUnlock()
164
+ for ch := range s.subs {
165
+ select {
166
+ case ch <- payload:
167
+ default:
168
+ // The subscriber is behind. Drop its stale frame and hand it the
169
+ // fresh one — a slow phone must never stall the poller.
170
+ select {
171
+ case <-ch:
172
+ default:
173
+ }
174
+ select {
175
+ case ch <- payload:
176
+ default:
177
+ }
178
+ }
179
+ }
180
+ }
181
+
182
+ /* ── handlers ───────────────────────────────────────────────────────────── */
183
+
184
+ func (s *Server) Handler() http.Handler {
185
+ mux := http.NewServeMux()
186
+ mux.HandleFunc("/api/vitals", s.handleVitals)
187
+ mux.HandleFunc("/api/containers", s.handleContainers)
188
+ mux.HandleFunc("/api/storage", s.handleStorage)
189
+ mux.HandleFunc("/api/storage/rescan", s.handleRescan)
190
+ mux.HandleFunc("/api/config", s.handleConfig)
191
+ mux.HandleFunc("/api/stream", s.handleStream)
192
+ mux.HandleFunc("/healthz", s.handleHealth)
193
+ mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(s.web))))
194
+ mux.HandleFunc("/", s.handleIndex)
195
+ return mux
196
+ }
197
+
198
+ func writeJSON(w http.ResponseWriter, v any) {
199
+ w.Header().Set("Content-Type", "application/json")
200
+ w.Header().Set("Cache-Control", "no-cache")
201
+ _ = json.NewEncoder(w).Encode(v)
202
+ }
203
+
204
+ func (s *Server) handleVitals(w http.ResponseWriter, _ *http.Request) {
205
+ s.touch()
206
+ s.mu.RLock()
207
+ latest := s.latest
208
+ s.mu.RUnlock()
209
+ if latest.Vitals == nil {
210
+ latest = s.sampleOnce(s.base)
211
+ }
212
+ writeJSON(w, latest.Vitals)
213
+ }
214
+
215
+ func (s *Server) handleContainers(w http.ResponseWriter, _ *http.Request) {
216
+ s.touch()
217
+ s.mu.RLock()
218
+ latest := s.latest
219
+ s.mu.RUnlock()
220
+ if latest.Docker == nil {
221
+ latest = s.sampleOnce(s.base)
222
+ }
223
+ writeJSON(w, latest.Docker)
224
+ }
225
+
226
+ func (s *Server) handleStorage(w http.ResponseWriter, _ *http.Request) {
227
+ snap := s.storage.Snapshot()
228
+ // The very first request arrives before the background loop has finished
229
+ // its opening walk. Kick one off and return immediately rather than
230
+ // blocking the request for the walk's full length — the browser polls
231
+ // this same endpoint and renders the live percentage as it comes in.
232
+ if snap.ScannedAt == nil && !snap.Scanning {
233
+ snap = s.storage.ScanAsync(s.base, true)
234
+ }
235
+ writeJSON(w, snap)
236
+ }
237
+
238
+ func (s *Server) handleRescan(w http.ResponseWriter, _ *http.Request) {
239
+ writeJSON(w, s.storage.ScanAsync(s.base, false))
240
+ }
241
+
242
+ func (s *Server) handleConfig(w http.ResponseWriter, _ *http.Request) {
243
+ writeJSON(w, map[string]any{
244
+ "poll_interval": s.cfg.PollInterval.Seconds(),
245
+ "storage_interval": s.cfg.StorageInterval.Seconds(),
246
+ "tree_depth": s.cfg.TreeDepth,
247
+ })
248
+ }
249
+
250
+ func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
251
+ writeJSON(w, map[string]bool{"ok": true})
252
+ }
253
+
254
+ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
255
+ if r.URL.Path != "/" {
256
+ http.NotFound(w, r)
257
+ return
258
+ }
259
+ index, err := fs.ReadFile(s.web, "index.html")
260
+ if err != nil {
261
+ http.Error(w, "index.html missing", http.StatusInternalServerError)
262
+ return
263
+ }
264
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
265
+ w.Header().Set("Cache-Control", "no-cache")
266
+ _, _ = w.Write(index)
267
+ }
268
+
269
+ func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
270
+ flusher, ok := w.(http.Flusher)
271
+ if !ok {
272
+ http.Error(w, "streaming unsupported", http.StatusInternalServerError)
273
+ return
274
+ }
275
+
276
+ ch := make(chan []byte, 1)
277
+ s.mu.Lock()
278
+ s.subs[ch] = struct{}{}
279
+ s.mu.Unlock()
280
+ s.touch()
281
+ defer func() {
282
+ s.mu.Lock()
283
+ delete(s.subs, ch)
284
+ s.mu.Unlock()
285
+ }()
286
+
287
+ h := w.Header()
288
+ h.Set("Content-Type", "text/event-stream")
289
+ h.Set("Cache-Control", "no-cache, no-transform")
290
+ h.Set("X-Accel-Buffering", "no") // tell any reverse proxy not to buffer us
291
+ h.Set("Connection", "keep-alive")
292
+ w.WriteHeader(http.StatusOK)
293
+
294
+ // Hand the newcomer the last frame immediately, so a reconnecting phone
295
+ // paints real numbers instead of waiting out a poll interval.
296
+ s.mu.RLock()
297
+ latest := s.latest
298
+ s.mu.RUnlock()
299
+ if latest.Vitals != nil {
300
+ if payload, err := json.Marshal(latest); err == nil {
301
+ fmt.Fprintf(w, "data: %s\n\n", payload)
302
+ flusher.Flush()
303
+ }
304
+ }
305
+
306
+ keepalive := time.NewTicker(25 * time.Second)
307
+ defer keepalive.Stop()
308
+
309
+ for {
310
+ select {
311
+ case <-r.Context().Done():
312
+ return
313
+ case <-s.base.Done():
314
+ // A stream never ends on its own, so without this every shutdown
315
+ // would sit out the full Shutdown grace period waiting on it.
316
+ return
317
+ case payload := <-ch:
318
+ s.touch()
319
+ fmt.Fprintf(w, "data: %s\n\n", payload)
320
+ flusher.Flush()
321
+ case <-keepalive.C:
322
+ // Keeps mobile proxies from closing an idle stream.
323
+ fmt.Fprint(w, ": keepalive\n\n")
324
+ flusher.Flush()
325
+ }
326
+ }
327
+ }
328
+
329
+ /* ── lifecycle ──────────────────────────────────────────────────────────── */
330
+
331
+ // Run starts the background workers and serves until ctx is cancelled.
332
+ func (s *Server) Run(ctx context.Context) error {
333
+ // Assigned before the listener accepts anything, so no handler can observe
334
+ // the placeholder set in New.
335
+ s.base = ctx
336
+
337
+ var wg sync.WaitGroup
338
+ wg.Add(2)
339
+ go func() { defer wg.Done(); s.Poll(ctx) }()
340
+ go func() { defer wg.Done(); s.storage.Loop(ctx) }()
341
+
342
+ addr := net.JoinHostPort(s.cfg.Host, strconv.Itoa(s.cfg.Port))
343
+ srv := &http.Server{
344
+ Addr: addr,
345
+ Handler: s.Handler(),
346
+ // No write deadline: an SSE stream is meant to stay open indefinitely.
347
+ ReadHeaderTimeout: 10 * time.Second,
348
+ IdleTimeout: 65 * time.Second,
349
+ }
350
+
351
+ listener, err := net.Listen("tcp", addr)
352
+ if err != nil {
353
+ return err
354
+ }
355
+ fmt.Printf("kanshi listening on http://%s\n", addr)
356
+
357
+ errc := make(chan error, 1)
358
+ go func() { errc <- srv.Serve(listener) }()
359
+
360
+ select {
361
+ case err := <-errc:
362
+ return err
363
+ case <-ctx.Done():
364
+ }
365
+
366
+ shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
367
+ defer cancel()
368
+ _ = srv.Shutdown(shutdown)
369
+ s.docker.Close()
370
+ wg.Wait()
371
+ return nil
372
+ }