Skip to content

Commit c80fffa

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

File tree

6 files changed

+354
-0
lines changed

6 files changed

+354
-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: 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+
}

core/frontend/src/views/Disk.vue

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