admin/monitor: show upload progress
Massimo Melina committed
Mar 7, 2023 at 10:25 UTC
10a359323a000696ed61244b038bfe5c54f14e4f
8 files changed
+68
-21
admin/src/MonitorPage.ts
+12
-7
@@ -3,10 +3,10 @@
3
import _ from "lodash"
4
import { createElement as h, useMemo, Fragment, useState } from "react"
5
import { apiCall, useApiEvents, useApiEx, useApiList } from "./api"
6
-import { PauseCircle, PlayCircle, Delete, Lock, Block, FolderZip } from '@mui/icons-material'
6
+import { PauseCircle, PlayCircle, Delete, Lock, Block, FolderZip, Upload } from '@mui/icons-material'
7
import { Alert, Box, Chip, ChipProps } from '@mui/material'
8
import { DataGrid } from "@mui/x-data-grid"
9
-import { formatBytes, IconBtn, iconTooltip, manipulateConfig, useBreakpoint } from "./misc"
9
+import { formatBytes, IconBtn, IconProgress, iconTooltip, manipulateConfig, useBreakpoint } from "./misc"
10
import { Field, SelectField } from '@hfs/mui-grid-form'
11
import { GridColumns } from '@mui/x-data-grid/models/colDef/gridColDef'
12
import { StandardCSSProperties } from '@mui/system/styleFunctionSx/StandardCssProperties'
@@ -33,6 +33,7 @@ function MoreInfo() {
33
md && pair('https', { label: "HTTPS", render: port }),
34
sm && pair('connections'),
35
pair('sent', { render: formatBytes, minWidth: '4em' }),
36
+ sm && pair('got', { render: formatBytes, minWidth: '4em' }),
37
pair('outSpeed', { label: "Output speed", render: formatSpeed }),
38
)
39
@@ -117,8 +118,12 @@ function Connections() {
118
h(Box, { ml: 2, color: 'text.secondary' }, value)
119
)
120
const i = value?.lastIndexOf('/')
120
- return h(Fragment, {}, value.slice(i + 1),
121
- i > 0 && h(Box, { ml: 2, color: 'text.secondary' }, value.slice(0, i)))
121
+ return h(Fragment, {},
122
+ row.uploadProgress !== undefined
123
+ && h(IconProgress, { icon: Upload, progress: row.uploadProgress, sx: { mr: 1 } }),
124
+ value.slice(i + 1),
125
+ i > 0 && h(Box, { ml: 2, color: 'text.secondary' }, value.slice(0, i))
126
+ )
127
}
128
},
129
{
@@ -135,13 +140,13 @@ function Connections() {
140
field: 'outSpeed',
141
headerName: "Speed",
142
type: 'number',
138
- valueFormatter: ({ value }) => formatSpeed(value)
143
+ renderCell: ({ value, row }) => formatSpeed(Math.max(value||0, row.inSpeed||0))
144
},
145
{
146
field: 'sent',
147
headerName: "Total",
148
type: 'number',
144
- valueFormatter: ({ value }) => formatBytes(value as number)
149
+ renderCell: ({ value, row}) => formatBytes(Math.max(value||0, row.got||0))
150
},
151
{
152
field: 'agent',
@@ -176,7 +181,7 @@ function Connections() {
181
fullWidth: false,
182
value: filtered,
183
onChange: setFiltered as any,
179
- options: { "Show only downloads": true, "Show all connections": false }
184
+ options: { "Show only files": true, "Show all connections": false }
185
}),
186
187
h(Box, { flex: 1 }),
admin/src/misc.ts
+16
-2
@@ -1,13 +1,13 @@
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, FC, ReactNode } from 'react'
3
+import { createElement as h, FC, Fragment, ReactNode } from 'react'
4
import { Box, Breakpoint, CircularProgress, IconButton, Link, Tooltip, useMediaQuery } from '@mui/material'
5
import { Link as RouterLink } from 'react-router-dom'
6
import { SxProps } from '@mui/system'
7
import { Refresh, SvgIconComponent } from '@mui/icons-material'
8
import { alertDialog, confirmDialog } from './dialog'
9
import { apiCall } from './api'
10
-import { findFirst, onlyTruthy, useStateMounted } from '@hfs/shared'
10
+import { findFirst, formatPerc, onlyTruthy, useStateMounted } from '@hfs/shared'
11
export * from '@hfs/shared'
12
13
export function spinner() {
@@ -140,3 +140,17 @@ export function isCtrlKey(ev: React.KeyboardEvent) {
140
return (ev.ctrlKey || isMac && ev.metaKey) && ev.key
141
}
142
143
+export function IconProgress({ icon, progress, sx }: { icon: SvgIconComponent, progress: number, sx?: SxProps }) {
144
+ return h(Fragment, {},
145
+ h(icon, { sx: { position:'absolute', ml: '4px' } }),
146
+ h(Tooltip, {
147
+ title: formatPerc(progress),
148
+ children: h(CircularProgress, {
149
+ value: progress*100,
150
+ variant: 'determinate',
151
+ size: 32,
152
+ sx,
153
+ }),
154
+ }),
155
+ )
156
+}
\ No newline at end of file
frontend/src/upload.ts
+1
-5
@@ -2,7 +2,7 @@
2
3
import { createElement as h, Fragment, useMemo, useState } from 'react'
4
import { Flex, FlexV } from './components'
5
-import { closeDialog, DialogCloser, formatBytes, hIcon, newDialog, prefix, selectFiles } from './misc'
5
+import { closeDialog, DialogCloser, formatBytes, formatPerc, hIcon, newDialog, prefix, selectFiles } from './misc'
6
import _ from 'lodash'
7
import { proxy, ref, subscribe, useSnapshot } from 'valtio'
8
import { alertDialog, confirmDialog, promptDialog } from './dialog'
@@ -183,10 +183,6 @@ function iconBtn(icon: string, onClick: () => any, { small=true, style={}, ...pr
183
)
184
}
185
186
-function formatPerc(p: number) {
187
- return (p*100).toFixed(1) + '%'
188
-}
189
-
186
function formatTime(time: number, decimals=0, length=Infinity) {
187
time /= 1000
188
const ret = [(time % 1).toFixed(decimals).slice(1)]
shared/index.ts
+5
-1
@@ -127,4 +127,8 @@ export function readFile(f: File | Blob) {
127
})
128
reader.readAsText(f)
129
})
130
-}
\ No newline at end of file
130
+}
131
+
132
+export function formatPerc(p: number) {
133
+ return (p*100).toFixed(1) + '%'
134
+}
src/api.monitor.ts
+3
-1
@@ -81,7 +81,9 @@ const apis: ApiHandlers = {
81
user: getCurrentUsername(ctx),
82
agent: getBrowser(ctx.get('user-agent')),
83
archive: ctx.state.archive,
84
- path: (ctx.fileSource || ctx.state.archive) && ctx.path // only for downloading files
84
+ upload: ctx.state.uploadProgress,
85
+ path: ctx.state.uploadPath
86
+ || (ctx.fileSource || ctx.state.archive) && ctx.path // only uploads and downloads
87
}
88
}
89
},
src/connections.ts
+4
@@ -8,7 +8,11 @@ import _ from 'lodash'
8
export class Connection {
9
readonly started = new Date()
10
sent = 0
11
+ got = 0
12
outSpeed?: number
13
+ inSpeed?: number
14
+ uploadProgress?: number
15
+ uploadPath?: string
16
ctx?: Koa.Context
17
private _cachedIp?: string
18
[rest:symbol]: any // let other modules add extra data, but using symbols to avoid name collision
src/throttler.ts
+4
-4
@@ -48,7 +48,7 @@ export const throttler: Koa.Middleware = async (ctx, next) => {
48
const DELAY = 1000
49
const update = _.debounce(() => {
50
const ts = conn[SymThrStr] as ThrottledStream
51
- const outSpeed = roundKb(ts.getSpeed())
51
+ const outSpeed = roundSpeed(ts.getSpeed())
52
updateConnection(conn, { outSpeed, sent: ts.getBytesSent() })
53
/* in case this stream stands still for a while (before the end), we'll have neither 'sent' or 'close' events,
54
* so who will take care to updateConnection? This artificial next-call will ensure just that */
@@ -77,7 +77,7 @@ export const throttler: Koa.Middleware = async (ctx, next) => {
77
ctx.response.length = bak
78
}
79
80
-function roundKb(n: number) {
80
+export function roundSpeed(n: number) {
81
return _.round(n, 1) || _.round(n, 3) // further precision if necessary
82
}
83
@@ -97,8 +97,8 @@ setInterval(() => {
97
lastSent = totalSent
98
const deltaGotKb = (totalGot - lastGot) / 1000
99
lastGot = totalGot
100
- totalOutSpeed = roundKb(deltaSentKb / past)
101
- totalInSpeed = roundKb(deltaGotKb / past)
100
+ totalOutSpeed = roundSpeed(deltaSentKb / past)
101
+ totalInSpeed = roundSpeed(deltaGotKb / past)
102
}, 1000)
103
104
events.on('connection', (c: Connection) =>
src/upload.ts
+23
-1
@@ -13,6 +13,9 @@ import { Callback, try_ } from './misc'
13
import { notifyClient } from './frontEndApis'
14
import { defineConfig } from './config'
15
import { getFreeDiskSync } from './util-os'
16
+import { socket2connection, updateConnection } from './connections'
17
+import { roundSpeed } from './throttler'
18
+import _ from 'lodash'
19
20
export const deleteUnfinishedUploadsAfter = defineConfig('delete_unfinished_uploads_after')
21
export const minAvailableMb = defineConfig('min_available_mb', 100)
@@ -67,7 +70,8 @@ export function uploadWriter(base: VfsNode, path: string, ctx: Koa.Context) {
70
tempName = resumable
71
}
72
cancelDeletion(tempName)
70
- ret.on('close', () => {
73
+ trackProgress()
74
+ ret.once('close', () => {
75
if (!ctx.req.aborted) {
76
let dest = fullPath
77
if (dontOverwriteUploading.get() && fs.existsSync(dest)) {
@@ -91,6 +95,24 @@ export function uploadWriter(base: VfsNode, path: string, ctx: Koa.Context) {
95
})
96
return ret
97
98
+ function trackProgress() {
99
+ let lastGot = 0
100
+ let lastGotTime = 0
101
+ const conn = socket2connection(ctx.socket)
102
+ if (!conn) return ()=>{}
103
+ ctx.state.uploadPath = ctx.path + path
104
+ updateConnection(conn, { ctx })
105
+ const h = setInterval(() => {
106
+ const now = Date.now()
107
+ const got = ret.bytesWritten
108
+ const inSpeed = roundSpeed((got - lastGot) / (now - lastGotTime))
109
+ lastGot = got
110
+ lastGotTime = now
111
+ updateConnection(conn, { inSpeed, got, uploadProgress: _.round(got / reqSize, 3) })
112
+ }, 1000)
113
+ ret.once('close', () => clearInterval(h) )
114
+ }
115
+
116
function delayedDelete(path: string, secs: number, cb?: Callback) {
117
clearTimeout(waitingToBeDeleted[path])
118
waitingToBeDeleted[path] = setTimeout(() => {