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