Skip to content

Commit bf6f9bf

Browse files
core: frontend: Add disk-usage page
Signed-off-by: Patrick José Pereira <patrickelectric@gmail.com>
1 parent b0213e5 commit bf6f9bf

File tree

7 files changed

+473
-0
lines changed

7 files changed

+473
-0
lines changed
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
<template>
2+
<div>
3+
<apexchart
4+
type="treemap"
5+
height="420"
6+
:options="chartOptions"
7+
:series="series"
8+
@dataPointSelection="onSelect"
9+
/>
10+
<div
11+
v-if="!loading && (!root || root.children.length === 0)"
12+
class="text-caption mt-2 grey--text text--darken-1"
13+
>
14+
No entries to display for this path.
15+
</div>
16+
</div>
17+
</template>
18+
19+
<script lang="ts">
20+
import Vue, { PropType } from 'vue'
21+
import { ApexOptions } from 'apexcharts'
22+
23+
import { DiskNode } from '@/types/disk'
24+
25+
interface TreemapDatum {
26+
x: string
27+
y: number
28+
path: string
29+
is_dir: boolean
30+
size_bytes: number
31+
}
32+
33+
export default Vue.extend({
34+
props: {
35+
root: {
36+
type: Object as PropType<DiskNode | null>,
37+
required: true,
38+
},
39+
loading: {
40+
type: Boolean,
41+
default: false,
42+
},
43+
},
44+
computed: {
45+
series(): { data: TreemapDatum[] }[] {
46+
if (!this.root) {
47+
return [{ data: [] }]
48+
}
49+
50+
const children = this.root.children || []
51+
return [{
52+
data: children.map((child) => ({
53+
x: child.name || '/',
54+
y: Math.max(child.size_bytes, 1),
55+
path: child.path,
56+
is_dir: child.is_dir,
57+
size_bytes: child.size_bytes,
58+
})),
59+
}]
60+
},
61+
chartOptions(): ApexOptions {
62+
return {
63+
chart: {
64+
toolbar: {
65+
show: false,
66+
},
67+
},
68+
legend: {
69+
show: false,
70+
},
71+
dataLabels: {
72+
enabled: true,
73+
formatter: (_: any, opts: any) => {
74+
const datum = (opts.w.config.series[0].data as TreemapDatum[])[opts.dataPointIndex]
75+
return `${datum.x}\n${this.formatBytes(datum.size_bytes)}`
76+
},
77+
style: {
78+
fontSize: '12px',
79+
},
80+
},
81+
tooltip: {
82+
y: {
83+
formatter: (value: number, opts: any) => {
84+
const datum = (opts.w.config.series[0].data as TreemapDatum[])[opts.dataPointIndex]
85+
return `${this.formatBytes(value)} (${datum.path})`
86+
},
87+
},
88+
},
89+
colors: ['#42a5f5', '#26c6da', '#7e57c2', '#66bb6a', '#ffa726', '#ef5350'],
90+
}
91+
},
92+
},
93+
methods: {
94+
onSelect(_event: MouseEvent, chartContext: any, config: any): void {
95+
const data = chartContext?.w?.config?.series?.[0]?.data as TreemapDatum[] | undefined
96+
if (!data || !data[config.dataPointIndex]) {
97+
return
98+
}
99+
const datum = data[config.dataPointIndex]
100+
this.$emit('select', datum.path, datum.is_dir)
101+
},
102+
formatBytes(bytes: number): string {
103+
if (bytes <= 0 || Number.isNaN(bytes)) {
104+
return '0 B'
105+
}
106+
const units = ['B', 'KB', 'MB', 'GB', 'TB']
107+
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
108+
const value = bytes / (1024 ** exponent)
109+
return `${value.toFixed(value >= 10 || exponent === 0 ? 0 : 1)} ${units[exponent]}`
110+
},
111+
},
112+
})
113+
</script>
114+
115+
<style scoped>
116+
.apexcharts-canvas {
117+
max-height: 430px;
118+
}
119+
</style>

core/frontend/src/menus.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,13 @@ const menus = [
4343
text: 'Browse all the files in BlueOS. Useful for fetching logs,'
4444
+ ' tweaking configurations, and development.',
4545
},
46+
{
47+
title: 'Disk',
48+
icon: 'mdi-harddisk',
49+
route: '/tools/disk',
50+
advanced: true,
51+
text: 'Visualize disk usage and delete files/folders.',
52+
},
4653
{
4754
title: 'Log Browser',
4855
icon: 'mdi-math-log',

core/frontend/src/router/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ const routes: Array<RouteConfig> = [
4343
name: 'File Browser',
4444
component: defineAsyncComponent(() => import('../views/FileBrowserView.vue')),
4545
},
46+
{
47+
path: '/tools/disk',
48+
name: 'Disk',
49+
component: defineAsyncComponent(() => import('../views/Disk.vue')),
50+
},
4651
{
4752
path: '/tools/web-terminal',
4853
name: 'Terminal',

core/frontend/src/store/disk.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import {
2+
Action, Module, Mutation, VuexModule, getModule,
3+
} from 'vuex-module-decorators'
4+
5+
import store from '@/store'
6+
import { DiskUsageQuery, DiskUsageResponse } from '@/types/disk'
7+
import back_axios, { isBackendOffline } from '@/utils/api'
8+
9+
@Module({ dynamic: true, store, name: 'disk' })
10+
class DiskStore extends VuexModule {
11+
API_URL = '/disk-usage/v1.0/disk'
12+
13+
usage: DiskUsageResponse | null = null
14+
15+
loading = false
16+
17+
deleting = false
18+
19+
error: string | null = null
20+
21+
last_query: DiskUsageQuery = {
22+
path: '/',
23+
depth: 10,
24+
include_files: true,
25+
min_size_bytes: 0,
26+
}
27+
28+
@Mutation
29+
setUsage(value: DiskUsageResponse | null): void {
30+
this.usage = value
31+
}
32+
33+
@Mutation
34+
setLoading(value: boolean): void {
35+
this.loading = value
36+
}
37+
38+
@Mutation
39+
setDeleting(value: boolean): void {
40+
this.deleting = value
41+
}
42+
43+
@Mutation
44+
setError(message: string | null): void {
45+
this.error = message
46+
}
47+
48+
@Mutation
49+
setLastQuery(query: DiskUsageQuery): void {
50+
this.last_query = query
51+
}
52+
53+
@Action
54+
async fetchUsage(query: DiskUsageQuery): Promise<void> {
55+
const merged: DiskUsageQuery = {
56+
...this.last_query,
57+
...query,
58+
}
59+
60+
this.setLoading(true)
61+
this.setError(null)
62+
this.setLastQuery(merged)
63+
64+
await back_axios({
65+
method: 'get',
66+
url: `${this.API_URL}/usage`,
67+
params: merged,
68+
timeout: 120000,
69+
})
70+
.then((response) => {
71+
this.setUsage(response.data as DiskUsageResponse)
72+
})
73+
.catch((error) => {
74+
this.setUsage(null)
75+
if (isBackendOffline(error)) {
76+
return
77+
}
78+
this.setError(`Failed to fetch disk usage: ${error.message}`)
79+
})
80+
.finally(() => {
81+
this.setLoading(false)
82+
})
83+
}
84+
85+
@Action
86+
async deletePath(path: string): Promise<void> {
87+
this.setDeleting(true)
88+
this.setError(null)
89+
await back_axios({
90+
method: 'delete',
91+
url: `${this.API_URL}/paths/${encodeURIComponent(path)}`,
92+
timeout: 120000,
93+
})
94+
.then(() => {
95+
// Refresh after delete using the last query to reflect changes
96+
this.fetchUsage(this.last_query)
97+
})
98+
.catch((error) => {
99+
if (isBackendOffline(error)) {
100+
return
101+
}
102+
this.setError(`Failed to delete path: ${error.response?.data?.detail || error.message}`)
103+
})
104+
.finally(() => {
105+
this.setDeleting(false)
106+
})
107+
}
108+
}
109+
110+
const disk_store = getModule(DiskStore)
111+
112+
export { DiskStore }
113+
export default disk_store

core/frontend/src/types/disk.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export interface DiskNode {
2+
name: string
3+
path: string
4+
size_bytes: number
5+
is_dir: boolean
6+
children: DiskNode[]
7+
}
8+
9+
export interface DiskUsageResponse {
10+
root: DiskNode
11+
generated_at: number
12+
depth: number
13+
include_files: boolean
14+
min_size_bytes: number
15+
}
16+
17+
export interface DiskUsageQuery {
18+
path?: string
19+
depth?: number
20+
include_files?: boolean
21+
min_size_bytes?: number
22+
}

0 commit comments

Comments
 (0)