admin/monitor: show speed and total
Massimo Melina committed
Aug 5, 2022 at 19:06 UTC
71da6e654b581bc83e33c560529aaa47e41326d4
10 files changed
+140
-37
admin/src/MonitorPage.ts
+49
-21
@@ -2,14 +2,15 @@
2
3
import _ from "lodash"
4
import { createElement as h, useMemo, Fragment, useState } from "react"
5
-import { apiCall, useApiEx, useApiList } from "./api"
5
+import { apiCall, useApiEvents, useApiEx, useApiList } from "./api"
6
import { PauseCircle, PlayCircle, Delete, Lock, Block, FolderZip } from '@mui/icons-material'
7
-import { Box, Chip } from '@mui/material'
7
+import { Box, Chip, ChipProps } from '@mui/material'
8
import { DataGrid } from "@mui/x-data-grid"
9
import { Alert } from '@mui/material'
10
import { formatBytes, IconBtn, iconTooltip, manipulateConfig, useBreakpoint } from "./misc"
11
import { Field, SelectField } from '@hfs/mui-grid-form'
12
import { GridColumns } from '@mui/x-data-grid/models/colDef/gridColDef'
13
+import { StandardCSSProperties } from '@mui/system/styleFunctionSx/StandardCssProperties'
14
15
export default function MonitorPage() {
16
return h(Fragment, {},
@@ -21,39 +22,58 @@ export default function MonitorPage() {
22
const isoDateRe = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
23
24
function MoreInfo() {
24
- const { data, element } = useApiEx('get_status')
25
- return !useBreakpoint('md') ? null
26
- : element || h(Box, { display: 'flex', flexWrap: 'wrap', gap: '1em', mb: 2 },
27
- pair('started'),
28
- pair('http', "HTTP", port),
29
- pair('https', "HTTPS", port),
30
- )
25
+ const { data: status, element } = useApiEx('get_status')
26
+ const { data: connections } = useApiEvents('get_connection_stats')
27
+ if (status && connections)
28
+ Object.assign(status, connections)
29
+ const md = useBreakpoint('md')
30
+ const sm = useBreakpoint('sm')
31
+ return element || h(Box, { display: 'flex', flexWrap: 'wrap', gap: '1em', mb: 2 },
32
+ md && pair('started'),
33
+ md && pair('http', { label: "HTTP", render: port }),
34
+ md && pair('https', { label: "HTTPS", render: port }),
35
+ sm && pair('connections'),
36
+ pair('sent', { render: formatBytes, minWidth: '4em' }),
37
+ pair('outSpeed', { label: "Output speed", render: formatSpeed }),
38
+ )
39
32
- type Color = Parameters<typeof Chip>[0]['color']
33
- type Render = (v:any) => [string, Color?]
40
+ type Color = ChipProps['color']
41
+ type Render = (v: any) => [string, Color?] | string
42
+ interface PairOptions {
43
+ label?: string
44
+ render?: Render
45
+ minWidth?: StandardCSSProperties['minWidth']
46
+ }
47
35
- function pair(k: string, label: string='', render?:Render) {
36
- let v = _.get(data, k)
48
+ function pair(k: string, { label, minWidth, render }: PairOptions={}) {
49
+ let v = _.get(status, k)
50
if (v === undefined)
51
return null
52
if (typeof v === 'string' && isoDateRe.test(v))
53
v = new Date(v).toLocaleString()
54
let color: Color = undefined
42
- if (render)
43
- [v, color] = render(v)
55
+ if (render) {
56
+ v = render(v)
57
+ if (Array.isArray(v))
58
+ [v, color] = v
59
+ }
60
if (!label)
61
label = _.capitalize(k.replaceAll('_', ' '))
62
return h(Chip, {
63
variant: 'filled',
64
color,
49
- label: h(Fragment, {}, h('b',{},label), ': ', v),
65
+ label: h(Fragment, {},
66
+ h('b',{},label),
67
+ ': ',
68
+ h('span', { style:{ display: 'inline-block', minWidth } }, v),
69
+ ),
70
})
71
}
72
73
function port(v: any): ReturnType<Render> {
74
return v.listening ? ["port " + v.port, 'success']
75
: v.error ? [v.error, 'error']
56
- : ["off"]
76
+ : "off"
77
}
78
79
}
@@ -62,8 +82,8 @@ function Connections() {
82
const { list, error } = useApiList('get_connections')
83
const [filtered, setFiltered] = useState(true)
84
const [paused, setPaused] = useState(false)
65
- const rows = useMemo(()=>
66
- list?.filter((x:any) => !filtered || x.path).map((x:any,id:number) => ({ id, ...x })),
85
+ const rows = useMemo(() =>
86
+ list?.filter((x: any) => !filtered || x.path).map((x: any, id: number) => ({ id, ...x })),
87
[!paused && list, filtered]) //eslint-disable-line
88
// if I don't memo 'columns', it won't keep hiding status
89
const columns = useMemo<GridColumns<any>>(() => [
@@ -88,7 +108,11 @@ function Connections() {
108
renderCell({ value, row }) {
109
if (!value) return
110
if (row.archive)
91
- return h(Fragment, {}, h(FolderZip, { sx: { mr: 1 } }), row.archive, h(Box, { ml: 2, color: 'text.secondary' }, value))
111
+ return h(Fragment, {},
112
+ h(FolderZip, { sx: { mr: 1 } }),
113
+ row.archive,
114
+ h(Box, { ml: 2, color: 'text.secondary' }, value)
115
+ )
116
const i = value?.lastIndexOf('/')
117
return h(Fragment, {}, value.slice(i + 1),
118
i > 0 && h(Box, { ml: 2, color: 'text.secondary' }, value.slice(0, i)))
@@ -108,7 +132,7 @@ function Connections() {
132
field: 'outSpeed',
133
headerName: "Speed",
134
type: 'number',
111
- valueFormatter: ({ value }) => value ? formatBytes(value as number * 1000, "B/s", 1000) : ''
135
+ valueFormatter: ({ value }) => formatSpeed(value)
136
},
137
{
138
field: 'sent',
@@ -165,3 +189,7 @@ function Connections() {
189
function blockIp(ip: string) {
190
return manipulateConfig('block', data => [...data, { ip }])
191
}
192
+
193
+function formatSpeed(value: number) {
194
+ return !value ? '' : formatBytes(value * 1000, { post: "B/s", k: 1000, digits: 1 })
195
+}
admin/src/api.ts
+30
@@ -113,6 +113,36 @@ function getCsrf() {
113
return getCookie('csrf')
114
}
115
116
+export function useApiEvents(cmd: string, params: Dict={}) {
117
+ const [data, setData] = useStateMounted<any>(undefined)
118
+ const [error, setError] = useStateMounted<any>(undefined)
119
+ const [loading, setLoading] = useStateMounted(false)
120
+ useEffect(() => {
121
+ const src = apiEvents(cmd, params, (type, data) => {
122
+ switch (type) {
123
+ case 'error':
124
+ setError("Connection error")
125
+ return stop()
126
+ case 'closed':
127
+ return stop()
128
+ case 'msg':
129
+ if (src?.readyState === src?.CLOSED)
130
+ return stop()
131
+ return setData(data)
132
+ }
133
+ })
134
+ return () => {
135
+ src.close()
136
+ stop()
137
+ }
138
+
139
+ function stop() {
140
+ setLoading(false)
141
+ }
142
+ }, [cmd, JSON.stringify(params)]) //eslint-disable-line
143
+ return { data, loading, error }
144
+}
145
+
146
export function useApiList<T=any>(cmd:string|Falsy, params: Dict={}, { addId=false, map=((x:any)=>x) }={}) {
147
const [list, setList] = useStateMounted<T[]>([])
148
const [error, setError] = useStateMounted<any>(undefined)
server/src/ThrottledStream.ts
+2
-2
@@ -9,7 +9,7 @@ export class ThrottledStream extends Transform {
9
private sent: number = 0
10
private lastSpeed: number = 0
11
private lastSpeedTime = Date.now()
12
- private totalSent: number = 0
12
+ private totalSent: number = 0 // total sent over connection, since connection can be re-used for multiple requests
13
14
constructor(private group: ThrottleGroup, copyStats?: ThrottledStream) {
15
super()
@@ -34,7 +34,7 @@ export class ThrottledStream extends Transform {
34
this.sent += n
35
this.totalSent += n
36
pos += n
37
- this.emit('sent')
37
+ this.emit('sent', n)
38
} catch (e) {
39
done(e as Error)
40
return
server/src/api.file_list.ts
+1
-1
@@ -17,7 +17,7 @@ export const file_list: ApiHandler = async ({ path, offset, limit, search, omit,
17
if (dirTraversal(search))
18
return fail(418)
19
if (node.default)
20
- return (sse ? list.custom : _.identity)({ redirect: path })
20
+ return (sse ? list.custom : _.identity)({ redirect: path }) // sse will wrap the object in a 'custom' message, otherwise we plainly return the object
21
if (!await nodeIsDirectory(node))
22
return fail(405) // method not allowed on target
23
offset = Number(offset)
server/src/api.monitor.ts
+14
-1
@@ -1,8 +1,9 @@
1
import _ from 'lodash'
2
import { Connection, getConnections } from './connections'
3
-import { pendingPromise } from './misc'
3
+import { pendingPromise, wait } from './misc'
4
import { ApiHandlers, SendListReadable } from './apiMiddleware'
5
import Koa from 'koa'
6
+import { totalGot, totalInSpeed, totalOutSpeed, totalSent } from './throttler'
7
8
const apis: ApiHandlers = {
9
@@ -51,6 +52,18 @@ const apis: ApiHandlers = {
52
}
53
},
54
55
+ async *get_connection_stats() {
56
+ while (1) {
57
+ yield {
58
+ outSpeed: totalOutSpeed,
59
+ inSpeed: totalInSpeed,
60
+ got: totalGot,
61
+ sent: totalSent,
62
+ connections: getConnections().length
63
+ }
64
+ await wait(1000)
65
+ }
66
+ },
67
}
68
69
export default apis
server/src/connections.ts
+4
-6
@@ -3,6 +3,7 @@
3
import { Socket } from 'net'
4
import events from './events'
5
import Koa from 'koa'
6
+import _ from 'lodash'
7
8
export class Connection {
9
readonly started = new Date()
@@ -14,7 +15,7 @@ export class Connection {
15
private _cachedIp?: string
16
[rest:symbol]: any // let other modules add extra data, but using symbols to avoid name collision
17
17
- constructor(readonly socket: Socket,readonly secure: boolean) {
18
+ constructor(readonly socket: Socket, readonly secure: boolean) {
19
all.push(this)
20
socket.on('data', data =>
21
this.got += data.length )
@@ -22,6 +23,7 @@ export class Connection {
23
all.splice(all.indexOf(this), 1)
24
events.emit('connectionClosed', this)
25
})
26
+ events.emit('socket', socket)
27
}
28
29
get ip() {
@@ -51,13 +53,9 @@ export function socket2connection(socket: Socket) {
53
54
export function updateConnection(conn: Connection, change: Partial<Connection>) {
55
// if no change is detected, skip update. ctx is a special case
54
- if (!change.ctx && Object.entries(change).every(([k,v]) => eq(v, conn[k as keyof Connection]) ))
56
+ if (!change.ctx && Object.entries(change).every(([k,v]) => _.isEqual(v, conn[k as keyof Connection]) ))
57
return
58
Object.assign(conn, change)
59
events.emit(conn.alreadyEmitted ? 'connectionUpdated' : 'connection', conn, change)
60
conn.alreadyEmitted = true
59
-
60
- function eq(a: any, b: any) {
61
- return JSON.stringify(a) === JSON.stringify(b)
62
- }
61
}
server/src/log.ts
+1
@@ -91,6 +91,7 @@ export function log(): Koa.Middleware {
91
const date = a[2]+'/'+a[1]+'/'+a[3]+':'+a[4]+' '+a[5].slice(3)
92
const user = getCurrentUsername(ctx)
93
events.emit(logger.name, Object.assign(_.pick(ctx, ['ip', 'method','status','length']), { user, ts: now, uri: ctx.path }))
94
+ console.debug(ctx.status, ctx.method, ctx.path)
95
stream.write(util.format( format,
96
ctx.ip,
97
user || '-',
server/src/throttler.ts
+35
-3
@@ -7,6 +7,8 @@ import { defineConfig } from './config'
7
import { getOrSet, isLocalHost } from './misc'
8
import { Connection, updateConnection } from './connections'
9
import _ from 'lodash'
10
+import events from './events'
11
+import { Socket } from 'net'
12
13
const mainThrottleGroup = new ThrottleGroup(Infinity)
14
@@ -47,8 +49,7 @@ export const throttler: Koa.Middleware = async (ctx, next) => {
49
const DELAY = 1000
50
const update = _.debounce(() => {
51
const ts = conn[SymThrStr] as ThrottledStream
50
- const speed = ts.getSpeed()
51
- const outSpeed = _.round(speed, 1) || _.round(speed, 3) // further precision if necessary
52
+ const outSpeed = roundKb(ts.getSpeed())
53
updateConnection(conn, { outSpeed, sent: ts.getBytesSent() })
54
/* in case this stream stands still for a while (before the end), we'll have neither 'sent' or 'close' events,
55
* so who will take care to updateConnection? This artificial next-call will ensure just that */
@@ -56,7 +57,10 @@ export const throttler: Koa.Middleware = async (ctx, next) => {
57
if (outSpeed || !closed)
58
conn[SymTimeout] = setTimeout(update, DELAY)
59
}, DELAY, { maxWait:DELAY })
59
- ts.on('sent', update)
60
+ ts.on('sent', (n: number) => {
61
+ totalSent += n
62
+ update()
63
+ })
64
65
++ipGroup.count
66
ts.on('close', ()=> {
@@ -73,3 +77,31 @@ export const throttler: Koa.Middleware = async (ctx, next) => {
77
if (bak)
78
ctx.response.length = bak
79
}
80
+
81
+function roundKb(n: number) {
82
+ return _.round(n, 1) || _.round(n, 3) // further precision if necessary
83
+}
84
+
85
+export let totalSent = 0
86
+export let totalGot = 0
87
+export let totalOutSpeed = 0
88
+export let totalInSpeed = 0
89
+
90
+let lastSent = totalSent
91
+let lastGot = totalGot
92
+let last = Date.now()
93
+setInterval(() => {
94
+ const now = Date.now()
95
+ const past = (now - last) / 1000 // seconds
96
+ last = now
97
+ const deltaSentKb = (totalSent - lastSent) / 1000
98
+ lastSent = totalSent
99
+ const deltaGotKb = (totalGot - lastGot) / 1000
100
+ lastGot = totalGot
101
+ totalOutSpeed = roundKb(deltaSentKb / past)
102
+ totalInSpeed = roundKb(deltaGotKb / past)
103
+}, 1000)
104
+
105
+events.on('socket', (socket: Socket) =>
106
+ socket.on('data', data =>
107
+ totalGot += data.length ))
shared/src/index.ts
+4
-2
@@ -9,7 +9,7 @@ export type Dict<T=any> = Record<string, T>
9
export type Falsy = false | null | undefined | '' | 0
10
type Truthy<T> = T extends false | '' | 0 | null | undefined ? never : T
11
12
-export function formatBytes(n: number, post: string = 'B', k=1024) {
12
+export function formatBytes(n: number, { post='B', k=1024, digits=NaN }={}) {
13
if (isNaN(Number(n)) || n < 0)
14
return ''
15
let x = ['', 'K', 'M', 'G', 'T']
@@ -22,7 +22,9 @@ export function formatBytes(n: number, post: string = 'B', k=1024) {
22
++i
23
}
24
n /= prevMul
25
- return _.round(n, 1) + ' ' + (x[i]||'') + post
25
+ const ns = !i || isNaN(digits) ? _.round(n, isNaN(digits) ? 1 : digits) // _.round will avoid useless fractional zeros when `digits is unspecified or no multiplier was used
26
+ : n.toFixed(digits)
27
+ return ns + ' ' + (x[i]||'') + post
28
} // formatBytes
29
30
export function prefix(pre:string, v:string|number|undefined|null|false, post:string='') {
todo.md
-1
@@ -2,7 +2,6 @@
2
- fix: chrome is prompting to save credentials without username because of login's double-form
3
- admin: check + update
4
- admin/monitor: account column
5
-- admin/monitor: show total throughput
5
- frontend: hide closer button on login dialog accessing a protected resource, as it's no use
6
- easier nat life
7
- show public ip use, https://github.com/sindresorhus/public-ip