Skip to content

Commit c699648

Browse files
committed
feat: add disk usage per app (gauge + chart) from du -sb via background collector
Disk usage collected from du -sb on each app home directory in the 10s background collector alongside existing container stats. The value is stored in the container_stats table (disk_usage_bytes column) and served to the UI via two paths: - Page load: GetStatsOverview reads latest from DB - Realtime WS: diskTicker (10s) reads latest from DB, no extra SSH call Changes: - model.go: add DiskUsageBytes to ContainerStats + StatsOverview - schema.sql + db.go: add disk_usage_bytes column + ALTER TABLE migration - repository.go: update all queries + add ContainerStatsDiskHistory - stats.go: add collectDiskUsage helper, wire into collectors + LiveSnapshot - service.go: map DiskUsageBytes in GetStatsOverview - app_stats_ws.go: add diskTicker + disk_usage_bytes in WS JSON payload - apps_stats_card.html: add disk gauge + SVG chart - apps_detail.html: add disk to initLiveCharts metrics/lines/onData - mock.go: add du -sb mock response
1 parent e893c61 commit c699648

10 files changed

Lines changed: 154 additions & 24 deletions

File tree

‎internal/app/model.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ type ContainerStats struct {
5555
BlockIORead int64 `json:"block_io_read"`
5656
BlockIOWrite int64 `json:"block_io_write"`
5757
PIDs int `json:"pids"`
58+
DiskUsageBytes int64 `json:"disk_usage_bytes"`
5859
CreatedAt time.Time `json:"created_at"`
5960
}
6061

@@ -83,6 +84,7 @@ type StatsOverview struct {
8384
BlockIORead int64 `json:"block_io_read"`
8485
BlockIOWrite int64 `json:"block_io_write"`
8586
PIDs int `json:"pids"`
87+
DiskUsageBytes int64 `json:"disk_usage_bytes"`
8688
ContainerName string `json:"container_name"`
8789
}
8890

‎internal/app/repository.go‎

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -576,18 +576,18 @@ func nullInt64(i int64) interface{} {
576576

577577
func (r *Repository) InsertContainerStats(s *ContainerStats) error {
578578
_, err := r.db.Exec(`
579-
INSERT INTO container_stats (app_id, server_id, container_name, cpu_percent, mem_usage_bytes, mem_limit_bytes, mem_percent, net_io_rx_bytes, net_io_tx_bytes, block_io_read, block_io_write, pids)
580-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
581-
`, s.AppID, s.ServerID, s.ContainerName, s.CPUPercent, s.MemUsageBytes, s.MemLimitBytes, s.MemPercent, s.NetIORxBytes, s.NetIOTxBytes, s.BlockIORead, s.BlockIOWrite, s.PIDs)
579+
INSERT INTO container_stats (app_id, server_id, container_name, cpu_percent, mem_usage_bytes, mem_limit_bytes, mem_percent, net_io_rx_bytes, net_io_tx_bytes, block_io_read, block_io_write, pids, disk_usage_bytes)
580+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
581+
`, s.AppID, s.ServerID, s.ContainerName, s.CPUPercent, s.MemUsageBytes, s.MemLimitBytes, s.MemPercent, s.NetIORxBytes, s.NetIOTxBytes, s.BlockIORead, s.BlockIOWrite, s.PIDs, s.DiskUsageBytes)
582582
return err
583583
}
584584

585585
func (r *Repository) LatestContainerStats(appID int64) (*ContainerStats, error) {
586586
s := &ContainerStats{}
587587
err := r.db.QueryRow(`
588-
SELECT id, app_id, server_id, container_name, cpu_percent, mem_usage_bytes, mem_limit_bytes, mem_percent, net_io_rx_bytes, net_io_tx_bytes, block_io_read, block_io_write, pids, created_at
588+
SELECT id, app_id, server_id, container_name, cpu_percent, mem_usage_bytes, mem_limit_bytes, mem_percent, net_io_rx_bytes, net_io_tx_bytes, block_io_read, block_io_write, pids, disk_usage_bytes, created_at
589589
FROM container_stats WHERE app_id = ? ORDER BY created_at DESC LIMIT 1
590-
`, appID).Scan(&s.ID, &s.AppID, &s.ServerID, &s.ContainerName, &s.CPUPercent, &s.MemUsageBytes, &s.MemLimitBytes, &s.MemPercent, &s.NetIORxBytes, &s.NetIOTxBytes, &s.BlockIORead, &s.BlockIOWrite, &s.PIDs, &s.CreatedAt)
590+
`, appID).Scan(&s.ID, &s.AppID, &s.ServerID, &s.ContainerName, &s.CPUPercent, &s.MemUsageBytes, &s.MemLimitBytes, &s.MemPercent, &s.NetIORxBytes, &s.NetIOTxBytes, &s.BlockIORead, &s.BlockIOWrite, &s.PIDs, &s.DiskUsageBytes, &s.CreatedAt)
591591
if err == sql.ErrNoRows {
592592
return nil, nil
593593
}
@@ -609,7 +609,8 @@ func (r *Repository) LatestAggregatedStats(appID int64) (*ContainerStats, error)
609609
SUM(net_io_tx_bytes),
610610
SUM(block_io_read),
611611
SUM(block_io_write),
612-
SUM(pids)
612+
SUM(pids),
613+
MAX(disk_usage_bytes)
613614
FROM container_stats
614615
WHERE app_id = ? AND created_at = (
615616
SELECT MAX(created_at) FROM container_stats WHERE app_id = ?
@@ -620,7 +621,7 @@ func (r *Repository) LatestAggregatedStats(appID int64) (*ContainerStats, error)
620621
var pids sql.NullInt64
621622
if err := row.Scan(
622623
&s.CPUPercent, &s.MemUsageBytes, &s.MemLimitBytes, &s.MemPercent,
623-
&s.NetIORxBytes, &s.NetIOTxBytes, &s.BlockIORead, &s.BlockIOWrite, &pids,
624+
&s.NetIORxBytes, &s.NetIOTxBytes, &s.BlockIORead, &s.BlockIOWrite, &pids, &s.DiskUsageBytes,
624625
); err == sql.ErrNoRows {
625626
return nil, nil
626627
} else if err != nil {
@@ -669,6 +670,17 @@ func (r *Repository) ContainerStatsNetHistory(appID int64, since time.Time, buck
669670
return r.queryChartPoints(query, appID, since)
670671
}
671672

673+
func (r *Repository) ContainerStatsDiskHistory(appID int64, since time.Time, bucketMinutes int) ([]ChartPoint, error) {
674+
groupBy := fmt.Sprintf("(strftime('%%s', created_at) / %d) * %d", bucketMinutes*60, bucketMinutes*60)
675+
query := fmt.Sprintf(`
676+
SELECT datetime(%s, 'unixepoch') as bucket, MAX(disk_usage_bytes)
677+
FROM container_stats
678+
WHERE app_id = ? AND created_at >= ?
679+
GROUP BY bucket ORDER BY bucket ASC
680+
`, groupBy)
681+
return r.queryChartPoints(query, appID, since)
682+
}
683+
672684
func (r *Repository) queryChartPoints(query string, args ...interface{}) ([]ChartPoint, error) {
673685
rows, err := r.db.Query(query, args...)
674686
if err != nil {

‎internal/app/service.go‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -699,15 +699,16 @@ func (s *Service) GetStatsOverview(appID int64) *StatsOverview {
699699
return nil
700700
}
701701
return &StatsOverview{
702-
CPUPercent: cs.CPUPercent,
703-
MemPercent: cs.MemPercent,
704-
MemUsageBytes: cs.MemUsageBytes,
705-
MemLimitBytes: cs.MemLimitBytes,
706-
NetIORxBytes: cs.NetIORxBytes,
707-
NetIOTxBytes: cs.NetIOTxBytes,
708-
BlockIORead: cs.BlockIORead,
709-
BlockIOWrite: cs.BlockIOWrite,
710-
PIDs: cs.PIDs,
702+
CPUPercent: cs.CPUPercent,
703+
MemPercent: cs.MemPercent,
704+
MemUsageBytes: cs.MemUsageBytes,
705+
MemLimitBytes: cs.MemLimitBytes,
706+
NetIORxBytes: cs.NetIORxBytes,
707+
NetIOTxBytes: cs.NetIOTxBytes,
708+
BlockIORead: cs.BlockIORead,
709+
BlockIOWrite: cs.BlockIOWrite,
710+
PIDs: cs.PIDs,
711+
DiskUsageBytes: cs.DiskUsageBytes,
711712
}
712713
}
713714

‎internal/app/stats.go‎

Lines changed: 86 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ func (s *Service) collectAllContainerStats(client ssh.Connector, serverID int64,
156156
return
157157
}
158158

159+
var parsed []*ContainerStats
159160
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
160161
line = strings.TrimSpace(line)
161162
if line == "" {
@@ -170,15 +171,78 @@ func (s *Service) collectAllContainerStats(client ssh.Connector, serverID int64,
170171
if !ok {
171172
continue
172173
}
173-
cs := parseDockerStat(stat, appID, serverID, stat.Name)
174-
if cs != nil {
175-
if err := s.repo.InsertContainerStats(cs); err != nil {
176-
log.Printf("Stats collector: failed to insert stats for %q: %v", stat.Name, err)
177-
}
174+
if cs := parseDockerStat(stat, appID, serverID, stat.Name); cs != nil {
175+
parsed = append(parsed, cs)
176+
}
177+
}
178+
179+
if len(parsed) == 0 {
180+
return
181+
}
182+
183+
diskSizes := collectDiskUsage(client, apps, containerApp)
184+
for _, cs := range parsed {
185+
if du, ok := diskSizes[cs.AppID]; ok {
186+
cs.DiskUsageBytes = du
187+
}
188+
if err := s.repo.InsertContainerStats(cs); err != nil {
189+
log.Printf("Stats collector: failed to insert stats for %q: %v", cs.ContainerName, err)
178190
}
179191
}
180192
}
181193

194+
// collectDiskUsage runs du -sb on each app's home directory in a single SSH
195+
// round-trip and returns a map of appID -> total bytes.
196+
func collectDiskUsage(client ssh.Connector, apps []App, containerApp map[string]int64) map[int64]int64 {
197+
// Collect unique app IDs that have running containers.
198+
seen := make(map[int64]bool)
199+
var dirs []string
200+
for _, cs := range containerApp {
201+
if !seen[cs] {
202+
seen[cs] = true
203+
dirs = append(dirs, fmt.Sprintf("/opt/dockify/apps/app-%d", cs))
204+
}
205+
}
206+
if len(dirs) == 0 {
207+
return nil
208+
}
209+
210+
out, err := client.Exec(fmt.Sprintf("du -sb %s 2>/dev/null", strings.Join(dirs, " ")))
211+
if err != nil || strings.TrimSpace(out) == "" {
212+
return nil
213+
}
214+
215+
result := make(map[int64]int64, len(dirs))
216+
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
217+
line = strings.TrimSpace(line)
218+
if line == "" {
219+
continue
220+
}
221+
parts := strings.Fields(line)
222+
if len(parts) < 2 {
223+
continue
224+
}
225+
bytes, err := strconv.ParseInt(parts[0], 10, 64)
226+
if err != nil || bytes <= 0 {
227+
continue
228+
}
229+
// Path is /opt/dockify/apps/app-<id>
230+
path := parts[1]
231+
if !strings.HasPrefix(path, "/opt/dockify/apps/app-") {
232+
continue
233+
}
234+
appIDStr := strings.TrimPrefix(path, "/opt/dockify/apps/app-")
235+
appID, err := strconv.ParseInt(appIDStr, 10, 64)
236+
if err != nil {
237+
continue
238+
}
239+
if seen[appID] {
240+
result[appID] = bytes
241+
}
242+
}
243+
return result
244+
}
245+
182246
// parseComposeProjectAppID extracts the numeric app id from a compose project
183247
// name of the form "app-<id>" (the directory name Dockify uses for each app's
184248
// compose file). Returns 0 if it cannot parse.
@@ -303,7 +367,21 @@ func (s *Service) LiveSnapshot(client ssh.Connector, app *App) (*ContainerStats,
303367
parsed = append(parsed, cs)
304368
}
305369
}
306-
return sumStats(parsed), nil
370+
agg := sumStats(parsed)
371+
if agg == nil {
372+
return nil, nil
373+
}
374+
// Collect disk usage for the app directory.
375+
diskDir := fmt.Sprintf("/opt/dockify/apps/app-%d", app.ID)
376+
if out, err := client.Exec(fmt.Sprintf("du -sb %s 2>/dev/null", diskDir)); err == nil {
377+
fields := strings.Fields(strings.TrimSpace(out))
378+
if len(fields) >= 2 {
379+
if bytes, err := strconv.ParseInt(fields[0], 10, 64); err == nil && bytes > 0 {
380+
agg.DiskUsageBytes = bytes
381+
}
382+
}
383+
}
384+
return agg, nil
307385
}
308386

309387
// StreamStats continuously reads a streaming `docker stats` session for an app's
@@ -608,6 +686,7 @@ func (s *Service) GetStatsHistory(appID int64, duration string) map[string]inter
608686
cpu, _ := s.repo.ContainerStatsCPUHistory(appID, since, bucketMins)
609687
mem, _ := s.repo.ContainerStatsMemHistory(appID, since, bucketMins)
610688
net, _ := s.repo.ContainerStatsNetHistory(appID, since, bucketMins)
689+
disk, _ := s.repo.ContainerStatsDiskHistory(appID, since, bucketMins)
611690

612691
labels := make([]string, len(cpu))
613692
for i, p := range cpu {
@@ -618,6 +697,7 @@ func (s *Service) GetStatsHistory(appID int64, duration string) map[string]inter
618697
"cpu": cpu,
619698
"memory": mem,
620699
"network": net,
700+
"disk": disk,
621701
"labels": labels,
622702
}
623703
}

‎internal/db/db.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ func Open(path string) (*sql.DB, error) {
8383
block_io_read INTEGER,
8484
block_io_write INTEGER,
8585
pids INTEGER,
86+
disk_usage_bytes INTEGER DEFAULT 0,
8687
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
8788
)`)
8889
db.Exec(`CREATE INDEX IF NOT EXISTS idx_container_stats_app_time ON container_stats(app_id, created_at)`)
@@ -100,6 +101,9 @@ func Open(path string) (*sql.DB, error) {
100101
)`)
101102
db.Exec(`CREATE INDEX IF NOT EXISTS idx_route_stats_app_time ON route_stats(app_id, created_at)`)
102103

104+
// Migration: add disk_usage_bytes to container_stats (v0.5.5)
105+
db.Exec("ALTER TABLE container_stats ADD COLUMN disk_usage_bytes INTEGER DEFAULT 0")
106+
103107
// Migration: add server_stats table (v0.13.0)
104108
db.Exec(`CREATE TABLE IF NOT EXISTS server_stats (
105109
id INTEGER PRIMARY KEY AUTOINCREMENT,

‎internal/db/schema.sql‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ CREATE TABLE IF NOT EXISTS container_stats (
112112
block_io_read INTEGER,
113113
block_io_write INTEGER,
114114
pids INTEGER,
115+
disk_usage_bytes INTEGER DEFAULT 0,
115116
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
116117
);
117118
CREATE INDEX IF NOT EXISTS idx_container_stats_app_time ON container_stats(app_id, created_at);

‎internal/http/app_stats_ws.go‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,11 @@ func (h *StatsHandler) ServeLiveAppStats(w http.ResponseWriter, r *http.Request)
180180
chartTicker := time.NewTicker(30 * time.Second)
181181
defer chartTicker.Stop()
182182

183+
// Disk usage is collected by the background collector every 10s and stored
184+
// in the DB. We read it from the DB at the same cadence — no extra SSH call.
185+
diskTicker := time.NewTicker(10 * time.Second)
186+
defer diskTicker.Stop()
187+
183188
// Throttle live emission to ~1/sec (matching the server live stats cadence
184189
// and the "per detik" requirement). For multi-container apps, docker stats
185190
// streams several lines per interval; we keep only the latest aggregate and
@@ -190,6 +195,7 @@ func (h *StatsHandler) ServeLiveAppStats(w http.ResponseWriter, r *http.Request)
190195
var latestSnap *app.ContainerStats
191196
var prevNetBytes int64
192197
var prevNetTime time.Time
198+
var diskUsageBytes int64
193199

194200
for {
195201
select {
@@ -232,6 +238,7 @@ func (h *StatsHandler) ServeLiveAppStats(w http.ResponseWriter, r *http.Request)
232238
"net_tx": cs.NetIOTxBytes,
233239
"block_r": cs.BlockIORead,
234240
"block_w": cs.BlockIOWrite,
241+
"disk_usage_bytes": diskUsageBytes,
235242
"timestamp": now.UTC().Format(time.RFC3339),
236243
}
237244
jsonStr, _ := json.Marshal(data)
@@ -244,6 +251,11 @@ func (h *StatsHandler) ServeLiveAppStats(w http.ResponseWriter, r *http.Request)
244251
return
245252
}
246253

254+
case <-diskTicker.C:
255+
if stats := h.appSvc.GetStatsOverview(id); stats != nil {
256+
diskUsageBytes = stats.DiskUsageBytes
257+
}
258+
247259
case r, ok := <-rangeCh:
248260
if !ok {
249261
return

‎internal/http/templates/apps_detail.html‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,21 +26,22 @@ <h3>Resource Usage</h3>
2626
<script>
2727
var appCharts = initLiveCharts({
2828
wsUrl: 'api/apps/{{.App.ID}}/stats/live',
29-
metrics: ['cpu', 'memory', 'network'],
29+
metrics: ['cpu', 'memory', 'network', 'disk'],
3030
gauges: [
3131
{metric:'cpu', field:'cpu', pctId:'cpu-gauge-pct', fillId:'cpu-gauge-fill'},
3232
{metric:'memory', field:'memory', pctId:'mem-gauge-pct', fillId:'mem-gauge-fill',
3333
detailId:'mem-gauge-detail',
3434
detailFn:function(d){return formatBytes(d.mem_usage_bytes)+' / '+formatBytes(d.mem_limit_bytes);}}
3535
],
36-
lines: [{metric:'network', field:'network'}],
36+
lines: [{metric:'network', field:'network'}, {metric:'disk', field:'disk_usage_bytes'}],
3737
targetId: 'app-stats-cards',
3838
liveIndicatorId: 'app-live-indicator',
3939
onData: function(d) {
4040
if (d.net_rx !== undefined) { var e=document.getElementById('net-rx'); if(e) e.textContent=formatBytes(d.net_rx)+' RX'; }
4141
if (d.net_tx !== undefined) { var e=document.getElementById('net-tx'); if(e) e.textContent=formatBytes(d.net_tx)+' TX'; }
4242
if (d.block_r !== undefined) { var e=document.getElementById('block-r'); if(e) e.textContent=formatBytes(d.block_r)+' R'; }
4343
if (d.block_w !== undefined) { var e=document.getElementById('block-w'); if(e) e.textContent=formatBytes(d.block_w)+' W'; }
44+
if (d.disk_usage_bytes !== undefined) { var e=document.getElementById('disk-usage'); if(e) e.textContent=formatBytes(d.disk_usage_bytes); }
4445
}
4546
});
4647
window.addEventListener('beforeunload', appCharts.disconnect);

‎internal/http/templates/apps_stats_card.html‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ <h3>Resource Usage <span id="app-live-indicator" class="live-badge" style="displ
4343
<div class="stat-gauge-label">Block I/O</div>
4444
<div class="stat-gauge-net"><span id="block-r">{{formatBytes .Stats.BlockIORead}} R</span> &middot; <span id="block-w">{{formatBytes .Stats.BlockIOWrite}} W</span></div>
4545
</div>
46+
<div class="stat-gauge">
47+
<div class="stat-gauge-label">Disk</div>
48+
<div class="stat-gauge-net"><span id="disk-usage">{{formatBytes .Stats.DiskUsageBytes}}</span></div>
49+
</div>
4650
</div>
4751

4852
{{if .ChartData.cpu}}
@@ -82,6 +86,18 @@ <h3>Resource Usage <span id="app-live-indicator" class="live-badge" style="displ
8286
</svg>
8387
</div>
8488
{{end}}
89+
90+
{{if .ChartData.disk}}
91+
{{$diskMax := chartMax .ChartData.disk}}
92+
{{if eq $diskMax 0.0}}{{$diskMax = 1.0}}{{end}}
93+
<div class="chart-section">
94+
<div class="chart-title">Disk Usage</div>
95+
<svg class="chart-svg" viewBox="0 0 {{$width}} {{$height}}" preserveAspectRatio="none">
96+
<polyline id="disk-chart-line" data-max="{{$diskMax}}" data-points='{{chartPointsJSON .ChartData.disk}}' fill="none" stroke="var(--secondary)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"
97+
points="{{chartPoints .ChartData.disk $width $height $diskMax}}" />
98+
</svg>
99+
</div>
100+
{{end}}
85101
</div>
86102
{{else}}
87103
<div class="card" style="margin-top:1em">

‎internal/ssh/mock.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ func NewMockClient() *MockClient {
3030
{"NR==2 {gsub(/G/,\"\"); print $2}", "100"},
3131
{"NR==2 {gsub(/%/,\"\"); print $5}", "67"},
3232
{"head -1 /proc/stat", "23.5"},
33+
{"du -sb", "123456789 /opt/dockify/apps/app-16\n987654321 /opt/dockify/apps/app-27"},
3334
{"caddy_http_requests_total", mockMetrics()},
3435
},
3536
}

0 commit comments

Comments
 (0)