@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.
- package/.env.example +4 -4
- package/Dockerfile +24 -19
- package/README.md +139 -36
- package/docker-compose.yml +16 -7
- package/go.mod +3 -0
- package/internal/config/config.go +140 -0
- package/internal/dockerstats/dockerstats.go +501 -0
- package/internal/server/server.go +372 -0
- package/internal/storage/storage.go +586 -0
- package/internal/vitals/vitals.go +614 -0
- package/main.go +76 -0
- package/package.json +5 -3
- package/web/app.js +167 -8
- package/web/index.html +10 -0
- package/web/style.css +27 -0
- package/web/treemap.js +21 -63
- package/app/__init__.py +0 -0
- package/app/config.py +0 -72
- package/app/dockerstats.py +0 -207
- package/app/main.py +0 -174
- package/app/storage.py +0 -220
- package/app/vitals.py +0 -170
- package/requirements.txt +0 -4
|
@@ -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
|
+
}
|