@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,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
|
+
}
|