@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,586 @@
1
+ // Package storage builds a Filelight-style directory size tree.
2
+ //
3
+ // The walk is a single getdents+lstat pass per root, run on a slow timer and
4
+ // cached — never recomputed per refresh. Sizes come from st_blocks so they
5
+ // match `du` (actual blocks on disk) rather than apparent size, and hardlinked
6
+ // files are counted once.
7
+ package storage
8
+
9
+ import (
10
+ "context"
11
+ "fmt"
12
+ "os"
13
+ "runtime"
14
+ "sort"
15
+ "sync"
16
+ "sync/atomic"
17
+ "syscall"
18
+ "time"
19
+
20
+ "github.com/yuuki824/kanshi/internal/config"
21
+ )
22
+
23
+ // Never ship a tile smaller than this, regardless of the fraction threshold.
24
+ const minAbsolute = 4 * 1024 * 1024
25
+
26
+ // Largest individual files kept per directory; the rest are aggregated.
27
+ const topFiles = 12
28
+
29
+ /* ── wire payload ───────────────────────────────────────────────────────── */
30
+
31
+ // Node is one tile in the treemap. Root, Unreadable and WalkSeconds are
32
+ // pointers so they appear only on the per-root nodes that actually have them,
33
+ // rather than on every one of the few thousand tiles below.
34
+ type Node struct {
35
+ Name string `json:"name"`
36
+ Path string `json:"path,omitempty"`
37
+ Size int64 `json:"size"`
38
+ Kind string `json:"kind"`
39
+ Children []*Node `json:"children,omitempty"`
40
+
41
+ Root *string `json:"root,omitempty"`
42
+ Unreadable *int `json:"unreadable,omitempty"`
43
+ WalkSeconds *float64 `json:"walk_seconds,omitempty"`
44
+ }
45
+
46
+ // Snapshot is what /api/storage returns and what the treemap renders from.
47
+ type Snapshot struct {
48
+ Roots []*Node `json:"roots"`
49
+ ScannedAt *float64 `json:"scanned_at"`
50
+ Duration *float64 `json:"duration"`
51
+ Scanning bool `json:"scanning"`
52
+ Progress *Progress `json:"progress,omitempty"`
53
+ Error *string `json:"error"`
54
+ }
55
+
56
+ // Progress is a live estimate of how far the in-flight walk has gotten,
57
+ // measured against the byte totals from the previous completed scan. There is
58
+ // nothing to compare against on the very first walk ever, so Progress is
59
+ // omitted rather than shipping a meaningless 0%.
60
+ type Progress struct {
61
+ Root string `json:"root"`
62
+ Percent float64 `json:"percent"`
63
+ BytesDone int64 `json:"bytes_done"`
64
+ BytesTotal int64 `json:"bytes_total"`
65
+ }
66
+
67
+ /* ── scanner ────────────────────────────────────────────────────────────── */
68
+
69
+ // Scanner owns the cached tree. Reads are served from the snapshot under a
70
+ // read lock; only one walk runs at a time.
71
+ type Scanner struct {
72
+ cfg config.Config
73
+
74
+ mu sync.RWMutex
75
+ state Snapshot
76
+ currentRoot string // label of the root the in-flight walk is on
77
+
78
+ // progressDone and progressTotal are updated far more often than state (once
79
+ // per file, not once per scan), so they are plain atomics rather than
80
+ // going through mu — a walk of a few hundred thousand files would
81
+ // otherwise contend the same lock the SSE poller and every REST handler
82
+ // read from.
83
+ progressDone atomic.Int64
84
+ progressTotal atomic.Int64
85
+
86
+ // scanMu serialises walks. A second request while one is in flight gets
87
+ // the current snapshot rather than queueing a duplicate walk.
88
+ scanMu sync.Mutex
89
+ lastScan time.Time
90
+ }
91
+
92
+ func New(cfg config.Config) *Scanner {
93
+ return &Scanner{cfg: cfg, state: Snapshot{Roots: []*Node{}}}
94
+ }
95
+
96
+ // Snapshot returns the cached tree, plus a live progress estimate while a
97
+ // walk is running. The returned nodes are never mutated after publication, so
98
+ // callers can marshal them without holding the lock.
99
+ func (s *Scanner) Snapshot() Snapshot {
100
+ s.mu.RLock()
101
+ defer s.mu.RUnlock()
102
+ out := s.state
103
+ if out.Scanning {
104
+ if total := s.progressTotal.Load(); total > 0 {
105
+ done := s.progressDone.Load()
106
+ pct := round1(min(100, float64(done)/float64(total)*100))
107
+ out.Progress = &Progress{Root: s.currentRoot, Percent: pct, BytesDone: done, BytesTotal: total}
108
+ }
109
+ }
110
+ return out
111
+ }
112
+
113
+ // previousTotal sums the byte sizes from the last completed scan, the
114
+ // baseline a new walk's progress is measured against. Zero on the very first
115
+ // scan, when there is nothing yet to compare to.
116
+ func (s *Scanner) previousTotal() int64 {
117
+ s.mu.RLock()
118
+ defer s.mu.RUnlock()
119
+ var total int64
120
+ for _, r := range s.state.Roots {
121
+ total += r.Size
122
+ }
123
+ return total
124
+ }
125
+
126
+ // beginScan applies the rate limit and flips Scanning on before returning, so
127
+ // a caller that immediately reads Snapshot() sees the walk has started even
128
+ // though the walk itself hasn't produced anything yet. It reports whether a
129
+ // scan was actually started; when it was, the caller must arrange for
130
+ // runScan to be called exactly once to release scanMu.
131
+ func (s *Scanner) beginScan(force bool) bool {
132
+ if !s.scanMu.TryLock() {
133
+ return false
134
+ }
135
+ if !force && !s.lastScan.IsZero() && time.Since(s.lastScan) < s.cfg.StorageMinRescan {
136
+ s.scanMu.Unlock()
137
+ return false
138
+ }
139
+ s.progressTotal.Store(s.previousTotal())
140
+ s.progressDone.Store(0)
141
+ s.setScanning(true)
142
+ return true
143
+ }
144
+
145
+ func (s *Scanner) runScan(ctx context.Context) Snapshot {
146
+ defer s.scanMu.Unlock()
147
+ started := time.Now()
148
+
149
+ roots, err := s.walkAll(ctx)
150
+
151
+ s.mu.Lock()
152
+ s.state.Scanning = false
153
+ if err != nil {
154
+ msg := err.Error()
155
+ s.state.Error = &msg
156
+ } else {
157
+ elapsed := round2(time.Since(started).Seconds())
158
+ at := float64(started.UnixNano()) / 1e9
159
+ s.state.Roots, s.state.Error = roots, nil
160
+ s.state.ScannedAt, s.state.Duration = &at, &elapsed
161
+ }
162
+ out := s.state
163
+ s.mu.Unlock()
164
+
165
+ s.lastScan = time.Now()
166
+ return out
167
+ }
168
+
169
+ // Scan rebuilds the tree, blocking until the walk finishes. Rate-limited
170
+ // unless force is set by the timer.
171
+ func (s *Scanner) Scan(ctx context.Context, force bool) Snapshot {
172
+ if !s.beginScan(force) {
173
+ return s.Snapshot()
174
+ }
175
+ return s.runScan(ctx)
176
+ }
177
+
178
+ // ScanAsync starts a rescan in the background and returns immediately with
179
+ // Scanning already true, so a REST handler can hand the browser something to
180
+ // poll instead of blocking the request for the length of the walk (on this
181
+ // host, well over a minute).
182
+ func (s *Scanner) ScanAsync(ctx context.Context, force bool) Snapshot {
183
+ if !s.beginScan(force) {
184
+ return s.Snapshot()
185
+ }
186
+ go s.runScan(ctx)
187
+ return s.Snapshot()
188
+ }
189
+
190
+ func (s *Scanner) setScanning(v bool) {
191
+ s.mu.Lock()
192
+ s.state.Scanning = v
193
+ s.mu.Unlock()
194
+ }
195
+
196
+ func (s *Scanner) setCurrentRoot(label string) {
197
+ s.mu.Lock()
198
+ s.currentRoot = label
199
+ s.mu.Unlock()
200
+ }
201
+
202
+ // Loop is the background refresher — slow by default (30 min).
203
+ func (s *Scanner) Loop(ctx context.Context) {
204
+ interval := s.cfg.StorageInterval
205
+ if interval < time.Minute {
206
+ interval = time.Minute
207
+ }
208
+ for {
209
+ s.Scan(ctx, true)
210
+ select {
211
+ case <-ctx.Done():
212
+ return
213
+ case <-time.After(interval):
214
+ }
215
+ }
216
+ }
217
+
218
+ func (s *Scanner) walkAll(ctx context.Context) ([]*Node, error) {
219
+ // The walk is a long burst of syscalls competing with the live poller for
220
+ // this container's CPU quota. Deprioritise it so a rescan never makes the
221
+ // at-a-glance numbers stutter — the walk finishing a few seconds later is
222
+ // invisible, a frozen dashboard is not.
223
+ //
224
+ // Linux applies setpriority(PRIO_PROCESS) to the calling thread, so the
225
+ // goroutine is pinned first. It never unlocks: the runtime then retires
226
+ // the niced thread when the walk returns, instead of handing it back to
227
+ // the scheduler for the poller to land on.
228
+ done := make(chan struct{})
229
+ var roots []*Node
230
+ var err error
231
+ go func() {
232
+ defer close(done)
233
+ runtime.LockOSThread()
234
+ _ = syscall.Setpriority(syscall.PRIO_PROCESS, 0, 10)
235
+ roots, err = s.walkRoots(ctx)
236
+ }()
237
+
238
+ select {
239
+ case <-done:
240
+ return roots, err
241
+ case <-ctx.Done():
242
+ return nil, ctx.Err()
243
+ }
244
+ }
245
+
246
+ func (s *Scanner) walkRoots(ctx context.Context) ([]*Node, error) {
247
+ var trees []*Node
248
+ for _, root := range s.cfg.Roots() {
249
+ if info, err := os.Stat(root.Path); err != nil || !info.IsDir() {
250
+ continue
251
+ }
252
+ s.setCurrentRoot(root.Label)
253
+ started := time.Now()
254
+ w, err := s.walk(ctx, root.Path)
255
+ if err != nil {
256
+ return nil, fmt.Errorf("%T: %w", err, err)
257
+ }
258
+ tree := s.toTree(w, root.Path, s.cfg.TreeDepth)
259
+ elapsed := round2(time.Since(started).Seconds())
260
+ path, unreadable := root.Path, w.unreadable
261
+ tree.Name = root.Label
262
+ tree.Root, tree.Unreadable, tree.WalkSeconds = &path, &unreadable, &elapsed
263
+ trees = append(trees, tree)
264
+ }
265
+ if trees == nil {
266
+ trees = []*Node{}
267
+ }
268
+ return trees, nil
269
+ }
270
+
271
+ /* ── the walk ───────────────────────────────────────────────────────────── */
272
+
273
+ // dirNode is the intermediate form: one per directory retained at or above the
274
+ // tree depth. Deeper directories have their bytes rolled into the nearest
275
+ // retained ancestor rather than getting a node of their own.
276
+ type dirNode struct {
277
+ name string
278
+ self int64 // bytes of files directly inside this dir
279
+ deep int64 // bytes below the retained depth
280
+ size int64 // self + deep + children
281
+ children []string
282
+ files fileHeap // largest files, capped at topFiles
283
+ }
284
+
285
+ type fileEntry struct {
286
+ size int64
287
+ name string
288
+ }
289
+
290
+ type walkResult struct {
291
+ nodes map[string]*dirNode
292
+ unreadable int
293
+ }
294
+
295
+ type frame struct {
296
+ path string
297
+ depth int
298
+ anchor string // nearest retained ancestor
299
+ }
300
+
301
+ // walk is an iterative DFS. Only directories within TreeDepth of the root get
302
+ // a node of their own; anything deeper is still fully traversed and counted,
303
+ // but its bytes roll up into the nearest retained ancestor. The tree is pruned
304
+ // to this depth before it is served anyway, so materialising a node per
305
+ // directory just burns memory. (On this host that is ~2.3k retained nodes
306
+ // instead of ~96k.)
307
+ func (s *Scanner) walk(ctx context.Context, rootPath string) (*walkResult, error) {
308
+ var rootStat syscall.Stat_t
309
+ if err := syscall.Lstat(rootPath, &rootStat); err != nil {
310
+ return nil, err
311
+ }
312
+ rootDev := rootStat.Dev
313
+ maxDepth := s.cfg.TreeDepth
314
+
315
+ excluded := make(map[string]bool, len(s.cfg.StorageExclude))
316
+ for _, p := range s.cfg.StorageExclude {
317
+ excluded[p] = true
318
+ }
319
+
320
+ res := &walkResult{nodes: make(map[string]*dirNode, 4096)}
321
+ // Packed into one int rather than a (dev, ino) pair: overlay2 hardlinks
322
+ // everything, so this set reaches six figures and a struct key costs
323
+ // several times the bytes of a bare uint64.
324
+ seenInodes := make(map[uint64]struct{})
325
+ order := make([]string, 0, 4096)
326
+ stack := []frame{{path: rootPath, depth: 0}}
327
+
328
+ var st syscall.Stat_t
329
+ ticks := 0
330
+
331
+ for len(stack) > 0 {
332
+ f := stack[len(stack)-1]
333
+ stack = stack[:len(stack)-1]
334
+ if excluded[f.path] {
335
+ continue
336
+ }
337
+ // Checking the context per directory rather than per entry keeps this
338
+ // off the hot path while still aborting a huge walk promptly.
339
+ if ticks++; ticks%256 == 0 {
340
+ select {
341
+ case <-ctx.Done():
342
+ return nil, ctx.Err()
343
+ default:
344
+ }
345
+ }
346
+
347
+ var node *dirNode
348
+ anchor := f.anchor
349
+ if f.depth <= maxDepth {
350
+ node = &dirNode{name: baseName(f.path)}
351
+ res.nodes[f.path] = node
352
+ order = append(order, f.path)
353
+ anchor = f.path
354
+ } else {
355
+ node = res.nodes[anchor]
356
+ }
357
+
358
+ dir, err := os.Open(f.path)
359
+ if err != nil {
360
+ // A directory we cannot read would otherwise silently vanish from
361
+ // the totals — count it so the UI can say the tree is incomplete
362
+ // rather than quietly under-reporting.
363
+ res.unreadable++
364
+ continue
365
+ }
366
+
367
+ for {
368
+ // Read in batches so a directory with a million entries does not
369
+ // materialise a million DirEntry values at once.
370
+ entries, err := dir.ReadDir(512)
371
+ for i := range entries {
372
+ name := entries[i].Name()
373
+ // d_type comes back with the directory entry, so symlinks are
374
+ // rejected without a stat at all.
375
+ if entries[i].Type()&os.ModeSymlink != 0 {
376
+ continue
377
+ }
378
+ child := join(f.path, name)
379
+ if syscall.Lstat(child, &st) != nil {
380
+ continue
381
+ }
382
+ switch st.Mode & syscall.S_IFMT {
383
+ case syscall.S_IFDIR:
384
+ // Don't cross into other filesystems — each root is walked
385
+ // separately, so we'd otherwise double-count.
386
+ if st.Dev != rootDev {
387
+ continue
388
+ }
389
+ stack = append(stack, frame{path: child, depth: f.depth + 1, anchor: anchor})
390
+ if f.depth+1 <= maxDepth {
391
+ node.children = append(node.children, child)
392
+ }
393
+ case syscall.S_IFREG:
394
+ if st.Nlink > 1 {
395
+ key := uint64(st.Dev)<<48 | uint64(st.Ino)
396
+ if _, dup := seenInodes[key]; dup {
397
+ continue
398
+ }
399
+ seenInodes[key] = struct{}{}
400
+ }
401
+ size := st.Blocks * 512
402
+ if f.depth <= maxDepth {
403
+ node.self += size
404
+ node.files.offer(fileEntry{size: size, name: name})
405
+ } else {
406
+ node.deep += size
407
+ }
408
+ // Every byte is counted here exactly once regardless of
409
+ // depth, the same set the previous scan's cached totals
410
+ // cover — so this sum lines up with progressTotal.
411
+ s.progressDone.Add(size)
412
+ }
413
+ }
414
+ if err != nil || len(entries) == 0 {
415
+ break // io.EOF, or a read error mid-directory
416
+ }
417
+ }
418
+ dir.Close()
419
+ }
420
+
421
+ // Children always follow their parent in a DFS pre-order, so walking the
422
+ // order backwards guarantees every child is totalled before its parent.
423
+ for i := len(order) - 1; i >= 0; i-- {
424
+ node := res.nodes[order[i]]
425
+ total := node.self + node.deep
426
+ for _, child := range node.children {
427
+ if c, ok := res.nodes[child]; ok {
428
+ total += c.size
429
+ }
430
+ }
431
+ node.size = total
432
+ }
433
+ return res, nil
434
+ }
435
+
436
+ /* ── pruning ────────────────────────────────────────────────────────────── */
437
+
438
+ type candidate struct {
439
+ path string // set for directories only
440
+ name string
441
+ size int64
442
+ kind string
443
+ }
444
+
445
+ // toTree prunes the walk down to what is worth downloading to a phone: the
446
+ // biggest children at each level, with everything else folded into one
447
+ // aggregate tile so the areas still add up.
448
+ func (s *Scanner) toTree(w *walkResult, path string, depth int) *Node {
449
+ node := w.nodes[path]
450
+ if node == nil {
451
+ return &Node{Name: baseName(path), Path: path, Kind: "dir"}
452
+ }
453
+ out := &Node{Name: node.name, Path: path, Size: node.size, Kind: "dir"}
454
+ if depth <= 0 || node.size <= 0 {
455
+ return out
456
+ }
457
+
458
+ entries := make([]candidate, 0, len(node.children)+topFiles+1)
459
+ for _, child := range node.children {
460
+ if c, ok := w.nodes[child]; ok {
461
+ entries = append(entries, candidate{path: child, size: c.size, kind: "dir"})
462
+ }
463
+ }
464
+ var fileBytesKept int64
465
+ for _, f := range node.files.sorted() {
466
+ entries = append(entries, candidate{name: f.name, size: f.size, kind: "file"})
467
+ fileBytesKept += f.size
468
+ }
469
+ if remainder := node.self + node.deep - fileBytesKept; remainder > 0 {
470
+ entries = append(entries, candidate{name: "other files", size: remainder, kind: "rest"})
471
+ }
472
+
473
+ sort.Slice(entries, func(i, j int) bool { return entries[i].size > entries[j].size })
474
+
475
+ threshold := int64(float64(node.size) * s.cfg.TreeMinFraction)
476
+ if threshold < minAbsolute {
477
+ threshold = minAbsolute
478
+ }
479
+
480
+ var children []*Node
481
+ var folded int64
482
+ var foldedCount int
483
+ for _, e := range entries {
484
+ if e.size < threshold || len(children) >= s.cfg.TreeMaxChildren {
485
+ folded += e.size
486
+ foldedCount++
487
+ continue
488
+ }
489
+ if e.kind == "dir" {
490
+ children = append(children, s.toTree(w, e.path, depth-1))
491
+ } else {
492
+ children = append(children, &Node{Name: e.name, Size: e.size, Kind: e.kind})
493
+ }
494
+ }
495
+ if folded > 0 {
496
+ // Keep the aggregate so child sizes still sum to the parent — otherwise
497
+ // the treemap silently under-reports and the areas lie.
498
+ children = append(children, &Node{
499
+ Name: fmt.Sprintf("%d smaller items", foldedCount),
500
+ Size: folded,
501
+ Kind: "rest",
502
+ })
503
+ }
504
+ out.Children = children
505
+ return out
506
+ }
507
+
508
+ /* ── bounded top-N of files ─────────────────────────────────────────────── */
509
+
510
+ // fileHeap keeps the topFiles largest entries seen. At this size a linear scan
511
+ // for the minimum beats the bookkeeping a real heap would need, and it keeps
512
+ // the whole thing in one flat array with no allocation after the first few.
513
+ type fileHeap struct {
514
+ items [topFiles]fileEntry
515
+ n int
516
+ minAt int
517
+ }
518
+
519
+ func (h *fileHeap) offer(e fileEntry) {
520
+ if h.n < topFiles {
521
+ h.items[h.n] = e
522
+ h.n++
523
+ if h.n == topFiles {
524
+ h.recomputeMin()
525
+ } else if h.n == 1 || e.size < h.items[h.minAt].size {
526
+ h.minAt = h.n - 1
527
+ }
528
+ return
529
+ }
530
+ if e.size <= h.items[h.minAt].size {
531
+ return
532
+ }
533
+ h.items[h.minAt] = e
534
+ h.recomputeMin()
535
+ }
536
+
537
+ func (h *fileHeap) recomputeMin() {
538
+ h.minAt = 0
539
+ for i := 1; i < h.n; i++ {
540
+ if h.items[i].size < h.items[h.minAt].size {
541
+ h.minAt = i
542
+ }
543
+ }
544
+ }
545
+
546
+ func (h *fileHeap) sorted() []fileEntry {
547
+ out := make([]fileEntry, h.n)
548
+ copy(out, h.items[:h.n])
549
+ sort.Slice(out, func(i, j int) bool { return out[i].size > out[j].size })
550
+ return out
551
+ }
552
+
553
+ /* ── path helpers ───────────────────────────────────────────────────────── */
554
+
555
+ // join and baseName avoid path/filepath's Clean pass: every path here is built
556
+ // from an already-clean parent plus one getdents name, so there is nothing to
557
+ // normalise and the walk calls these once per directory entry.
558
+ func join(dir, name string) string {
559
+ if dir == "/" {
560
+ return "/" + name
561
+ }
562
+ return dir + "/" + name
563
+ }
564
+
565
+ func baseName(path string) string {
566
+ for len(path) > 1 && path[len(path)-1] == '/' {
567
+ path = path[:len(path)-1]
568
+ }
569
+ for i := len(path) - 1; i >= 0; i-- {
570
+ if path[i] == '/' {
571
+ if i == len(path)-1 {
572
+ break
573
+ }
574
+ return path[i+1:]
575
+ }
576
+ }
577
+ return path
578
+ }
579
+
580
+ func round1(v float64) float64 {
581
+ return float64(int64(v*10+0.5)) / 10
582
+ }
583
+
584
+ func round2(v float64) float64 {
585
+ return float64(int64(v*100+0.5)) / 100
586
+ }