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(() => {