Skip to content

Commit 069917a

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

File tree

6 files changed

+338
-0
lines changed

6 files changed

+338
-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: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
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-right">
44+
Size
45+
</th>
46+
<th class="text-center">
47+
Type
48+
</th>
49+
<th class="text-center">
50+
Actions
51+
</th>
52+
</tr>
53+
</thead>
54+
<tbody>
55+
<tr v-if="!usage || usage.root.children.length === 0">
56+
<td colspan="4" class="text-center grey--text text--darken-1">
57+
No entries.
58+
</td>
59+
</tr>
60+
<tr
61+
v-for="child in sortedChildren"
62+
:key="child.path"
63+
:class="{ 'clickable-row': child.is_dir }"
64+
@click="navigate(child)"
65+
>
66+
<td>
67+
<v-icon left small>
68+
{{ child.is_dir ? 'mdi-folder' : 'mdi-file' }}
69+
</v-icon>
70+
{{ child.name }}
71+
</td>
72+
<td class="text-right">
73+
{{ prettifySize(child.size_bytes / 1024) }}
74+
</td>
75+
<td class="text-center">
76+
{{ child.is_dir ? 'Dir' : 'File' }}
77+
</td>
78+
<td class="text-center">
79+
<v-btn
80+
icon
81+
small
82+
color="red"
83+
:loading="deleting"
84+
@click.stop="confirmDelete(child)"
85+
>
86+
<v-icon small>
87+
mdi-delete
88+
</v-icon>
89+
</v-btn>
90+
</td>
91+
</tr>
92+
</tbody>
93+
</v-simple-table>
94+
</v-card-text>
95+
</v-card>
96+
</v-col>
97+
</v-row>
98+
</v-container>
99+
</template>
100+
101+
<script lang="ts">
102+
import Vue from 'vue'
103+
104+
import disk_store from '@/store/disk'
105+
import { DiskNode } from '@/types/disk'
106+
import { prettifySize } from '@/utils/helper_functions'
107+
108+
export default Vue.extend({
109+
data() {
110+
return {
111+
path: '/',
112+
depth: 2,
113+
includeFiles: true,
114+
minSizeKb: 0,
115+
}
116+
},
117+
computed: {
118+
usage() {
119+
return disk_store.usage
120+
},
121+
loading(): boolean {
122+
return disk_store.loading
123+
},
124+
deleting(): boolean {
125+
return disk_store.deleting
126+
},
127+
error(): string | null {
128+
return disk_store.error
129+
},
130+
sortedChildren(): DiskNode[] {
131+
if (!this.usage) {
132+
return []
133+
}
134+
return [...this.usage.root.children].sort((a, b) => b.size_bytes - a.size_bytes)
135+
},
136+
canGoUp(): boolean {
137+
const currentPath = this.path as string
138+
return currentPath !== '/'
139+
},
140+
totalSize(): string {
141+
if (!this.usage?.root?.size_bytes) {
142+
return ''
143+
}
144+
return prettifySize(this.usage.root.size_bytes / 1024)
145+
},
146+
},
147+
mounted(): void {
148+
this.fetchUsage()
149+
},
150+
methods: {
151+
async fetchUsage(): Promise<void> {
152+
const currentPath = this.path as string
153+
const currentDepth = this.depth as number
154+
const currentIncludeFiles = this.includeFiles as boolean
155+
const currentMinSizeKb = this.minSizeKb as number
156+
await disk_store.fetchUsage({
157+
path: currentPath || '/',
158+
depth: Math.max(currentDepth, 0),
159+
include_files: currentIncludeFiles,
160+
min_size_bytes: Math.max(0, currentMinSizeKb * 1024),
161+
})
162+
},
163+
goUp(): void {
164+
if (!this.canGoUp) {
165+
return
166+
}
167+
const currentPath = this.path as string
168+
const trimmed = currentPath.endsWith('/') ? currentPath.slice(0, -1) : currentPath
169+
const parent = trimmed.substring(0, trimmed.lastIndexOf('/')) || '/'
170+
this.path = parent
171+
this.fetchUsage()
172+
},
173+
navigate(node: DiskNode): void {
174+
if (!node.is_dir) {
175+
return
176+
}
177+
this.path = node.path
178+
this.fetchUsage()
179+
},
180+
async confirmDelete(node: DiskNode): Promise<void> {
181+
const confirmed = window.confirm(`Delete ${node.path}? This cannot be undone.`)
182+
if (!confirmed) {
183+
return
184+
}
185+
await disk_store.deletePath(node.path)
186+
},
187+
prettifySize,
188+
},
189+
})
190+
</script>
191+
192+
<style scoped>
193+
.clickable-row {
194+
cursor: pointer;
195+
}
196+
.clickable-row:hover {
197+
background-color: rgba(0, 0, 0, 0.05);
198+
}
199+
</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)