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