Skip to content

Commit 6d1e36a

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

File tree

6 files changed

+362
-0
lines changed

6 files changed

+362
-0
lines changed

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: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
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+
@Mutation
22+
setUsage(value: DiskUsageResponse | null): void {
23+
this.usage = value
24+
}
25+
26+
@Mutation
27+
setLoading(value: boolean): void {
28+
this.loading = value
29+
}
30+
31+
@Mutation
32+
setDeleting(value: boolean): void {
33+
this.deleting = value
34+
}
35+
36+
@Mutation
37+
setError(message: string | null): void {
38+
this.error = message
39+
}
40+
41+
@Action
42+
async fetchUsage(query: DiskUsageQuery): Promise<void> {
43+
this.setLoading(true)
44+
this.setError(null)
45+
46+
await back_axios({
47+
method: 'get',
48+
url: `${this.API_URL}/usage`,
49+
params: query,
50+
timeout: 120000,
51+
})
52+
.then((response) => {
53+
this.setUsage(response.data as DiskUsageResponse)
54+
})
55+
.catch((error) => {
56+
this.setUsage(null)
57+
if (isBackendOffline(error)) {
58+
return
59+
}
60+
this.setError(`Failed to fetch disk usage: ${error.message}`)
61+
})
62+
.finally(() => {
63+
this.setLoading(false)
64+
})
65+
}
66+
67+
@Action
68+
async deletePath(path: string): Promise<void> {
69+
this.setDeleting(true)
70+
this.setError(null)
71+
await back_axios({
72+
method: 'delete',
73+
url: `${this.API_URL}/paths/${encodeURIComponent(path)}`,
74+
timeout: 120000,
75+
})
76+
.then(async () => {
77+
// Refresh after delete using the current usage query to reflect changes
78+
if (this.usage) {
79+
await this.fetchUsage({
80+
path: this.usage.root.path,
81+
depth: this.usage.depth,
82+
include_files: this.usage.include_files,
83+
min_size_bytes: this.usage.min_size_bytes,
84+
})
85+
}
86+
})
87+
.catch((error) => {
88+
if (isBackendOffline(error)) {
89+
return
90+
}
91+
this.setError(`Failed to delete path: ${error.response?.data?.detail || error.message}`)
92+
})
93+
.finally(() => {
94+
this.setDeleting(false)
95+
})
96+
}
97+
}
98+
99+
const disk_store = getModule(DiskStore)
100+
101+
export { DiskStore }
102+
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+
}

core/frontend/src/views/Disk.vue

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
<template>
2+
<v-container fluid>
3+
<v-row v-if="error">
4+
<v-col cols="12">
5+
<v-alert type="error" dense outlined>
6+
{{ error }}
7+
</v-alert>
8+
</v-col>
9+
</v-row>
10+
<v-row v-if="loading">
11+
<v-col cols="12">
12+
<v-progress-linear
13+
color="primary"
14+
indeterminate
15+
height="6"
16+
rounded
17+
class="mb-2"
18+
/>
19+
</v-col>
20+
</v-row>
21+
<v-row v-else>
22+
<v-col cols="12">
23+
<v-card outlined>
24+
<v-card-title class="py-2 d-flex justify-space-between align-center">
25+
<span>{{ path }} {{ totalSize ? ` (${totalSize})` : '' }}</span>
26+
<v-btn
27+
icon
28+
small
29+
:disabled="!canGoUp"
30+
@click="goUp"
31+
>
32+
<v-icon>mdi-arrow-up</v-icon>
33+
</v-btn>
34+
</v-card-title>
35+
<v-divider />
36+
<v-card-text class="pa-0">
37+
<v-simple-table dense>
38+
<thead>
39+
<tr>
40+
<th class="text-left">
41+
Name
42+
</th>
43+
<th class="text-center" style="width: 150px;">
44+
Usage
45+
</th>
46+
<th class="text-right">
47+
Size
48+
</th>
49+
<th class="text-center">
50+
Type
51+
</th>
52+
<th class="text-center">
53+
Actions
54+
</th>
55+
</tr>
56+
</thead>
57+
<tbody>
58+
<tr v-if="!usage || usage.root.children.length === 0">
59+
<td colspan="5" class="text-center grey--text text--darken-1">
60+
No entries.
61+
</td>
62+
</tr>
63+
<tr
64+
v-for="child in sortedChildren"
65+
:key="child.path"
66+
:class="{ 'clickable-row': child.is_dir }"
67+
@click="navigate(child)"
68+
>
69+
<td>
70+
<v-icon left small>
71+
{{ child.is_dir ? 'mdi-folder' : 'mdi-file' }}
72+
</v-icon>
73+
{{ child.name }}
74+
</td>
75+
<td>
76+
<v-progress-linear
77+
:value="getPercentage(child.size_bytes)"
78+
height="16"
79+
rounded
80+
color="primary"
81+
>
82+
<template #default>
83+
<span class="text-caption white--text">
84+
{{ getPercentage(child.size_bytes).toFixed(1) }}%
85+
</span>
86+
</template>
87+
</v-progress-linear>
88+
</td>
89+
<td class="text-right">
90+
{{ prettifySize(child.size_bytes / 1024) }}
91+
</td>
92+
<td class="text-center">
93+
{{ child.is_dir ? 'Dir' : 'File' }}
94+
</td>
95+
<td class="text-center">
96+
<v-btn
97+
icon
98+
small
99+
color="red"
100+
:loading="deleting"
101+
@click.stop="confirmDelete(child)"
102+
>
103+
<v-icon small>
104+
mdi-delete
105+
</v-icon>
106+
</v-btn>
107+
</td>
108+
</tr>
109+
</tbody>
110+
</v-simple-table>
111+
</v-card-text>
112+
</v-card>
113+
</v-col>
114+
</v-row>
115+
</v-container>
116+
</template>
117+
118+
<script lang="ts">
119+
import Vue from 'vue'
120+
121+
import disk_store from '@/store/disk'
122+
import { DiskNode } from '@/types/disk'
123+
import { prettifySize } from '@/utils/helper_functions'
124+
125+
export default Vue.extend({
126+
data() {
127+
return {
128+
path: '/',
129+
depth: 2,
130+
includeFiles: true,
131+
minSizeKb: 0,
132+
}
133+
},
134+
computed: {
135+
usage() {
136+
return disk_store.usage
137+
},
138+
loading(): boolean {
139+
return disk_store.loading
140+
},
141+
deleting(): boolean {
142+
return disk_store.deleting
143+
},
144+
error(): string | null {
145+
return disk_store.error
146+
},
147+
sortedChildren(): DiskNode[] {
148+
if (!this.usage) {
149+
return []
150+
}
151+
return [...this.usage.root.children].sort((a, b) => b.size_bytes - a.size_bytes)
152+
},
153+
canGoUp(): boolean {
154+
const currentPath = this.path as string
155+
return currentPath !== '/'
156+
},
157+
totalSize(): string {
158+
if (!this.usage?.root?.size_bytes) {
159+
return ''
160+
}
161+
return prettifySize(this.usage.root.size_bytes / 1024)
162+
},
163+
},
164+
mounted(): void {
165+
this.fetchUsage()
166+
},
167+
methods: {
168+
async fetchUsage(): Promise<void> {
169+
const currentPath = this.path as string
170+
const currentDepth = this.depth as number
171+
const currentIncludeFiles = this.includeFiles as boolean
172+
const currentMinSizeKb = this.minSizeKb as number
173+
await disk_store.fetchUsage({
174+
path: currentPath || '/',
175+
depth: Math.max(currentDepth, 0),
176+
include_files: currentIncludeFiles,
177+
min_size_bytes: Math.max(0, currentMinSizeKb * 1024),
178+
})
179+
},
180+
goUp(): void {
181+
if (!this.canGoUp) {
182+
return
183+
}
184+
const currentPath = this.path as string
185+
const trimmed = currentPath.endsWith('/') ? currentPath.slice(0, -1) : currentPath
186+
const parent = trimmed.substring(0, trimmed.lastIndexOf('/')) || '/'
187+
this.path = parent
188+
this.fetchUsage()
189+
},
190+
navigate(node: DiskNode): void {
191+
if (!node.is_dir) {
192+
return
193+
}
194+
this.path = node.path
195+
this.fetchUsage()
196+
},
197+
async confirmDelete(node: DiskNode): Promise<void> {
198+
const confirmed = window.confirm(`Delete ${node.path}? This cannot be undone.`)
199+
if (!confirmed) {
200+
return
201+
}
202+
await disk_store.deletePath(node.path)
203+
},
204+
getPercentage(sizeBytes: number): number {
205+
const parentSize = this.usage?.root?.size_bytes ?? 0
206+
if (parentSize <= 0) {
207+
return 0
208+
}
209+
return sizeBytes / parentSize * 100
210+
},
211+
prettifySize,
212+
},
213+
})
214+
</script>
215+
216+
<style scoped>
217+
.clickable-row {
218+
cursor: pointer;
219+
}
220+
.clickable-row:hover {
221+
background-color: rgba(0, 0, 0, 0.05);
222+
}
223+
</style>

core/frontend/vite.config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,9 @@ export default defineConfig(({ command, mode }) => {
145145
'^/commander': {
146146
target: SERVER_ADDRESS,
147147
},
148+
'^/disk-usage': {
149+
target: SERVER_ADDRESS,
150+
},
148151
'^/docker': {
149152
target: SERVER_ADDRESS,
150153
},

0 commit comments

Comments
 (0)