admin/fs: disk-spaces button
Massimo Melina committed
Mar 1, 2024 at 13:00 UTC
10b491bc48d0d9442b18684a47806bf77519fc33
4 files changed
+70
-22
admin/src/FilePicker.ts
+5
-1
@@ -172,9 +172,13 @@ export default function FilePicker({ onSelect, multiple=true, files=true, folder
172
icon: Storage,
173
progress: 1,
174
offset: (props.total - props.free) / props.total,
175
- title: `${formatBytes(props.free)} available (${formatPerc(props.free / props.total)}) of ${formatBytes(props.total)}`,
175
+ title: formatDiskSpace(props),
176
}),
177
),
178
)
179
)
180
}
181
+
182
+export function formatDiskSpace({ free, total }: { free: number, total: number }) {
183
+ return `${formatBytes(free)} available (${formatPerc(free / total)}) of ${formatBytes(total)}`
184
+}
\ No newline at end of file
admin/src/VfsMenuBar.ts
+21
-5
@@ -1,17 +1,19 @@
1
// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
import { createElement as h } from 'react'
4
-import { Alert, Box } from '@mui/material'
5
-import { Microsoft } from '@mui/icons-material'
4
+import { Alert, Box, List, ListItem, ListItemIcon, ListItemText } from '@mui/material'
5
+import { Microsoft, Storage } from '@mui/icons-material'
6
import { reloadVfs } from './VfsPage'
7
-import { CFG, newDialog } from './misc'
8
-import { Btn, Flex, reloadBtn } from './mui'
7
+import { CFG, newDialog, prefix } from './misc'
8
+import { Btn, Flex, IconBtn, reloadBtn } from './mui'
9
import { apiCall, ApiObject, useApi } from './api'
10
import { ConfigForm } from './ConfigForm'
11
import { ArrayField } from './ArrayField'
12
import { BoolField } from '@hfs/mui-grid-form'
13
import VfsPathField from './VfsPathField'
14
-import { promptDialog } from './dialog'
14
+import { alertDialog, promptDialog } from './dialog'
15
+import { formatDiskSpace } from './FilePicker'
16
+import { getDiskSpaces } from '../../src/util-os'
17
18
export default function VfsMenuBar({ statusApi }: { statusApi: ApiObject }) {
19
const isWindows = statusApi.data?.platform === 'win32'
@@ -26,6 +28,20 @@ export default function VfsMenuBar({ statusApi }: { statusApi: ApiObject }) {
28
},
29
h(Btn, { variant: 'outlined', onClick: roots }, "Roots"),
30
reloadBtn(() => reloadVfs()),
31
+ h(IconBtn, {
32
+ icon: Storage,
33
+ title: "Disk spaces",
34
+ onClick: () => apiCall<Awaited<ReturnType<typeof getDiskSpaces>>>('get_disk_spaces').then(res =>
35
+ alertDialog(h(List, { dense: true }, res.map(x => h(ListItem, { key: x.name },
36
+ h(ListItemIcon, {}, h(Storage)),
37
+ h(ListItemText, {
38
+ primary: x.name + prefix(' (', x.description, ')'),
39
+ secondary: formatDiskSpace(x)
40
+ }),
41
+ ))), { title: "Disk spaces" })
42
+ .then(() => false), // no success-animation for IconBtn
43
+ alertDialog)
44
+ }),
45
isWindows && h(Btn, {
46
icon: Microsoft,
47
variant: 'outlined',
src/api.vfs.ts
+3
-1
@@ -9,7 +9,7 @@ import { dirname, extname, join, resolve } from 'path'
9
import { dirStream, enforceFinal, isDirectory, isValidFileName, isWindowsDrive, makeMatcher, PERM_KEYS,
10
VfsNodeAdminSend } from './misc'
11
import { IS_WINDOWS, HTTP_BAD_REQUEST, HTTP_NOT_FOUND, HTTP_SERVER_ERROR, HTTP_CONFLICT, HTTP_NOT_ACCEPTABLE } from './const'
12
-import { getDiskSpaceSync, getDrives } from './util-os'
12
+import { getDiskSpaces, getDiskSpaceSync, getDrives } from './util-os'
13
import { getBaseUrlOrDefault, getServerStatus } from './listen'
14
import { promisify } from 'util'
15
import { execFile } from 'child_process'
@@ -179,6 +179,8 @@ const apis: ApiHandlers = {
179
return {}
180
},
181
182
+ get_disk_spaces: getDiskSpaces,
183
+
184
get_ls({ path, files=true, fileMask }, ctx) {
185
return new SendListReadable({
186
async doAtStart(list) {
src/util-os.ts
+41
-15
@@ -1,6 +1,6 @@
1
import { resolve } from 'path'
2
import { exec, execSync } from 'child_process'
3
-import { splitAt, try_ } from './misc'
3
+import { onlyTruthy, splitAt, try_ } from './misc'
4
import _ from 'lodash'
5
import { pid } from 'node:process'
6
import { promisify } from 'util'
@@ -9,20 +9,14 @@ import { IS_WINDOWS } from './const'
9
export function getDiskSpaceSync(path: string) {
10
if (IS_WINDOWS) {
11
const drive = resolve(path).slice(0, 2).toUpperCase()
12
- const out = execSync('wmic logicaldisk get size,FreeSpace,name /format:list').toString().replace(/\r/g, '')
13
- const one = out.split(/\n\n+/).find(x => x.includes('Name=' + drive))
12
+ const out = execSync('wmic logicaldisk get Size,FreeSpace,Name /format:list').toString().replace(/\r/g, '')
13
+ const one = parseKeyValueObjects(out).find(x => x.Name === drive)
14
if (!one)
15
throw Error('miss')
16
- const free = Number(/FreeSpace=(\d+)/.exec(one)?.[1])
17
- const total = Number(/Size=(\d+)/.exec(one)?.[1])
18
- return { free, total }
16
+ return { free: Number(one.FreeSpace), total: Number(one.Size) }
17
}
18
const out = try_(() => execSync(`df -k "${path}"`).toString(),
21
- err => {
22
- throw err.status === 1 ? Error('miss')
23
- : err.status === 127 ? Error('unsupported')
24
- : err
25
- })
19
+ err => { throw err.status === 1 ? Error('miss') : err.status === 127 ? Error('unsupported') : err })
20
if (!out?.startsWith('Filesystem'))
21
throw Error('unsupported')
22
const one = out.split('\n')[1] as string
@@ -30,6 +24,35 @@ export function getDiskSpaceSync(path: string) {
24
return { free, total: used + free }
25
}
26
27
+export async function getDiskSpaces(): Promise<{ name: string, free: number, total: number, description?: string }[]> {
28
+ if (IS_WINDOWS) {
29
+ const fields = ['Size','FreeSpace','Name','Description'] as const
30
+ const out = await runCmd(`wmic logicaldisk get ${fields.join()} /format:list`)
31
+ const objs = parseKeyValueObjects<typeof fields[number]>(out)
32
+ return onlyTruthy(objs.map(x => x.Size && {
33
+ total: Number(x.Size),
34
+ free: Number(x.FreeSpace),
35
+ name: x.Name,
36
+ description: x.Description
37
+ }))
38
+ }
39
+ const { stdout } = await promisify(exec)(`df -k`).catch(err => {
40
+ throw err.status === 1 ? Error('miss')
41
+ : err.status === 127 ? Error('unsupported')
42
+ : err
43
+ })
44
+ const out = stdout.split('\n')
45
+ if (!out.shift()?.startsWith('Filesystem'))
46
+ throw Error('unsupported')
47
+ return onlyTruthy(out.map(one => {
48
+ const bits = one.split(/\s+/)
49
+ const name = bits.shift() || ''
50
+ const [, used=0, free=0] = bits.map(x => Number(x) * 1024)
51
+ const total = used + free
52
+ return total && { free, total, name }
53
+ }))
54
+}
55
+
56
export async function getDrives() {
57
const stdout = await runCmd('wmic logicaldisk get name')
58
return stdout.split('\n').slice(1).map(x => x.trim()).filter(Boolean)
@@ -38,14 +61,17 @@ export async function getDrives() {
61
// execute win32 shell commands
62
export async function runCmd(cmd: string, args: string[] = []) {
63
const { stdout, stderr } = await promisify(exec)(`@chcp 65001 >nul & cmd /c ${cmd} ${args.join(' ')}`, { encoding: 'utf-8' })
41
- return stderr || stdout
64
+ return (stderr || stdout).replace(/\r/g, '')
65
}
66
67
function getWindowsServices() {
68
const fields = ['PathName', 'DisplayName', 'ProcessId'] as const
46
- const chunks = execSync(`wmic service get ${fields.join(',')} /value`).toString().replace(/\r/g, '').split(/\n\n+/)
47
- return chunks.map(chunk =>
48
- Object.fromEntries(chunk.split('\n').map(line => splitAt('=', line))) as { [k in typeof fields[number]]: string })
69
+ return parseKeyValueObjects<typeof fields[number]>(execSync(`wmic service get ${fields.join()} /value`).toString().replace(/\r/g, ''))
70
}
71
72
export const currentServiceName = IS_WINDOWS && _.find(getWindowsServices(), { ProcessId: String(pid) })?.DisplayName
73
+
74
+function parseKeyValueObjects<T extends string>(all: string, keySep='=', lineSep='\n', objectSep=/\n\n+/) {
75
+ return all.split(objectSep).map(obj =>
76
+ Object.fromEntries(obj.split(lineSep).map(kv => splitAt(keySep, kv))) ) as { [k in T]: string }[]
77
+}
\ No newline at end of file