admin/monitoring: persistent sent/got

Massimo Melina committed May 22, 2024 at 00:47 UTC c3fe0ef39f9a7e64690603de9d7a01a0e0d0b622
8 files changed +78 -42
admin/src/MonitorPage.ts
+31 -19
@@ -6,14 +6,17 @@ import { apiCall, useApiEvents, useApiEx, useApiList } from "./api"
6 import { LinkOff, Lock, FolderZip, Upload, Download, ChevronRight, ChevronLeft } from '@mui/icons-material'
7 import { Box, Chip, ChipProps } from '@mui/material'
8 import { DataTable } from './DataTable'
9 -import { formatBytes, ipForUrl, CFG, formatSpeed, with_, createDurationFormatter, formatTimestamp,
10 - formatPerc, md } from "./misc"
9 +import {
10 + formatBytes, ipForUrl, CFG, formatSpeed, with_, createDurationFormatter, formatTimestamp, formatPerc, md, Callback,
11 + reactJoin,
12 +} from "./misc"
13 import { IconBtn, IconProgress, iconTooltip, usePauseButton, useBreakpoint, Country, hTooltip } from './mui'
14 import { Field, SelectField } from '@hfs/mui-grid-form'
15 import { StandardCSSProperties } from '@mui/system/styleFunctionSx/StandardCssProperties'
16 import { agentIcons } from './LogsPage'
17 import { state, useSnapState } from './state'
18 import { useBlockIp } from './useBlockIp'
19 +import { alertDialog } from './dialog'
20
21 export default function MonitorPage() {
22 return h(Fragment, {},
@@ -28,36 +31,45 @@ function MoreInfo() {
31 if (status && connections)
32 Object.assign(status, connections)
33 const [allInfo, setAllInfo] = useState(false)
31 - const xl = useBreakpoint('xl')
34 + const lg = useBreakpoint('lg')
35 const md = useBreakpoint('md')
36 const sm = useBreakpoint('sm')
37 const formatDuration = createDurationFormatter({ maxTokens: 2, skipZeroes: true })
35 - return element || h(Box, { display: 'flex', flexWrap: 'wrap', gap: '1em', mb: 2 },
38 + return element || h(Box, { display: 'flex', flexWrap: 'wrap', gap: { xs: .5, md: 1 }, mb: { xs: 1, sm: 2 } },
39 (allInfo || md) && pair('started', {
40 label: "Uptime",
41 render: x => formatDuration(Date.now() - +new Date(x)),
42 title: x => "Started: " + formatTimestamp(x),
43 }),
41 - (allInfo || xl) && pair('http', { label: "HTTP", render: port }),
42 - (allInfo || xl) && pair('https', { label: "HTTPS", render: port }),
43 - (allInfo || sm) && pair('connections', { title: () => `${status.ips} IP(s)` }),
44 - pair('sent', { render: formatBytes, minWidth: '4em' }),
45 - (allInfo || sm) && pair('got', { render: formatBytes, minWidth: '4em' }),
46 - pair('outSpeed', { label: "Output speed", render: formatSpeedK, minWidth: '5em' }),
47 - (allInfo || md) && pair('inSpeed', { label: "Input speed", render: formatSpeedK, minWidth: '5em' }),
48 - !xl && h(IconBtn, { size: 'small', icon: allInfo ? ChevronLeft : ChevronRight, title: "Show more", onClick: () => setAllInfo(x => !x) }),
44 + (allInfo || sm) && pair('sent_got', {
45 + render: x => ({ Sent: formatBytes(x[0]), Got: formatBytes(x[1]) }),
46 + onDelete: () => apiCall('clear_persistent', { k: ['totalSent', 'totalGot'] })
47 + .then(() => alertDialog("Done", 'success'), alertDialog)
48 + }),
49 + (allInfo || sm) && pair('ips', { label: "IPs" }),
50 + pair('outSpeed', { label: "Output", render: formatSpeedK, minWidth: '8.5em' }),
51 + pair('inSpeed', { label: "Input", render: formatSpeedK, minWidth: '8.5em' }),
52 + (md || allInfo && md || status?.http?.error) && pair('http', { label: "HTTP", render: port }),
53 + (md || allInfo && md || status?.https?.error) && pair('https', { label: "HTTPS", render: port }),
54 + !md && h(IconBtn, {
55 + size: 'small',
56 + icon: allInfo ? ChevronLeft : ChevronRight,
57 + title: "Show more",
58 + onClick: () => setAllInfo(x => !x)
59 + }),
60 )
61
62 type Color = ChipProps['color']
52 - type Render = (v: any) => [string, Color?] | string
63 + type Render = (v: any) => [string, Color?] | string | { [label: string]: string }
64 interface PairOptions {
65 label?: string
66 render?: Render
67 minWidth?: StandardCSSProperties['minWidth']
68 title?: (v: any) => string
69 + onDelete?: Callback
70 }
71
60 - function pair(k: string, { label, minWidth, render, title }: PairOptions={}) {
72 + function pair(k: string, { label, minWidth, render, title, onDelete }: PairOptions={}) {
73 let v = _.get(status, k)
74 if (v === undefined)
75 return null
@@ -73,11 +85,11 @@ function MoreInfo() {
85 return hTooltip(renderedTitle, undefined, h(Chip, {
86 variant: 'filled',
87 color,
76 - label: h(Fragment, {},
77 - h('b',{},label),
78 - ': ',
79 - h('span', { style:{ display: 'inline-block', minWidth } }, v),
80 - ),
88 + onDelete,
89 + label: reactJoin(' – ', _.map(_.isPlainObject(v) ? v : { [label]: v }, (v,label) =>
90 + h('span', { style:{ display: 'inline-block', minWidth } },
91 + h('b',{}, label), ': ', v,
92 + ))),
93 }) )
94 }
95
src/api.monitor.ts
+10 -3
@@ -2,12 +2,13 @@
2
3 import _ from 'lodash'
4 import { Connection, getConnections } from './connections'
5 -import { shortenAgent, try_, wait } from './misc'
5 +import { apiAssertTypes, shortenAgent, try_, wait, wantArray } from './misc'
6 import { ApiHandlers } from './apiMiddleware'
7 import Koa from 'koa'
8 import { totalGot, totalInSpeed, totalOutSpeed, totalSent } from './throttler'
9 import { getCurrentUsername } from './auth'
10 import { SendListReadable } from './SendList'
11 +import { storedMap } from './persistence'
12
13 export default {
14
@@ -86,14 +87,20 @@ export default {
87 yield {
88 outSpeed: totalOutSpeed,
89 inSpeed: totalInSpeed,
89 - got: totalGot,
90 - sent: totalSent,
90 + sent_got: [totalSent.get(), totalGot.get()],
91 connections: filtered.length,
92 ips: _.uniqBy(filtered, x => x.ip).length,
93 }
94 await wait(1000)
95 }
96 },
97 +
98 + async clear_persistent({ k }) {
99 + apiAssertTypes({ string_array: { k } })
100 + for (const x of wantArray(k))
101 + void storedMap.del(x)
102 + },
103 +
104 } satisfies ApiHandlers
105
106 function ignore(conn: Connection) {
src/index.ts
+3 -1
@@ -26,6 +26,7 @@ import { geoFilter } from './geo'
26 import { rootsMiddleware } from './roots'
27 import events from './events'
28 import { trackIpsMw } from './ip'
29 +import { storedMap } from './persistence'
30
31 ok(_.intersection(Object.keys(frontEndApis), Object.keys(adminApis)).length === 0) // they share same endpoints, don't clash
32
@@ -50,7 +51,8 @@ app.use(sessionMiddleware)
51 .use(mount(API_URI, apiMiddleware({ ...frontEndApis, ...adminApis })))
52 .use(serveGuiAndSharedFiles)
53 .on('error', errorHandler)
53 -events.emit('app', app)
54 +storedMap.once('open', () =>
55 + events.emit('app', app))
56
57 function errorHandler(err:Error & { code:string, path:string }) {
58 const { code } = err
src/log.ts
+1 -1
@@ -153,7 +153,7 @@ declare module "koa" {
153 }
154 }
155
156 -events.on('app', () => { // wait for app to be set
156 +events.once('app', () => { // wait for app to be set
157 app.context.logExtra = function(anything, params) { // no => as we need 'this'
158 _.merge((this as any).state, { logExtra: { ...anything, params } }) // params will be considered as parameters of the API
159 }
src/middlewares.ts
+1 -1
@@ -153,7 +153,7 @@ export const paramsDecoder: Koa.Middleware = async (ctx, next) => {
153 // But koa-session doesn't support 2 cookies, so I made this hacky solution: keep track of the options object, to modify the key at run-time.
154 let internalSessionMw: any
155 let options: any
156 -events.on('app', () => // wait for app to be defined
156 +events.once('app', () => // wait for app to be defined
157 internalSessionMw = session(options = { signed: true, rolling: true, sameSite: 'lax' } as const, app) )
158 export const sessionMiddleware: Koa.Middleware = (ctx, next) => {
159 options.key = 'hfs_' + ctx.protocol
src/misc.ts
+3 -4
@@ -120,8 +120,7 @@ export class AsapStream<T> extends Readable {
120
121 export function apiAssertTypes(paramsByType: { [type:string]: { [name:string]: any } }) {
122 for (const [types,params] of Object.entries(paramsByType))
123 - for (const type of types.split('_'))
124 - for (const [name,val] of Object.entries(params))
125 - if (type === 'array' ? !Array.isArray(val) : typeof val !== type)
126 - throw new ApiError(HTTP_BAD_REQUEST, 'bad ' + name)
123 + for (const [name,val] of Object.entries(params))
124 + if (! types.split('_').some(type => type === 'array' ? Array.isArray(val) : typeof val === type))
125 + throw new ApiError(HTTP_BAD_REQUEST, 'bad ' + name)
126 }
\ No newline at end of file
src/persistence.ts new
+11
@@ -0,0 +1,11 @@
1 +import { KvStorage } from '@rejetto/kvstorage'
2 +import { MINUTE, onProcessExit } from './misc'
3 +
4 +export const storedMap = new KvStorage({
5 + defaultPutDelay: 5000,
6 + maxPutDelay: MINUTE,
7 + maxPutDelayCreate: 1000,
8 + rewriteLater: true
9 +})
10 +storedMap.open('data.kv')
11 +onProcessExit(() => storedMap.flush())
src/throttler.ts
+18 -13
@@ -8,6 +8,7 @@ import { getOrSet, isLocalHost } from './misc'
8 import { Connection, getConnection, updateConnection } from './connections'
9 import _ from 'lodash'
10 import events from './events'
11 +import { storedMap } from './persistence'
12
13 const mainThrottleGroup = new ThrottleGroup(Infinity)
14
@@ -64,7 +65,7 @@ export const throttler: Koa.Middleware = async (ctx, next) => {
65 conn[SymTimeout] = setTimeout(update, DELAY)
66 }, DELAY, { leading: true, maxWait:DELAY })
67 ts.on('sent', (n: number) => {
67 - totalSent += n
68 + totalSent.set(x => (x || 0) + n)
69 update()
70 })
71
@@ -96,26 +97,30 @@ export function roundSpeed(n: number) {
97 return _.round(n, 1) || _.round(n, 3) // further precision if necessary
98 }
99
99 -export let totalSent = 0
100 -export let totalGot = 0
100 +export let totalSent = storedMap.singleSync<number>('totalSent', 0)
101 +export let totalGot = storedMap.singleSync<number>('totalGot', 0)
102 export let totalOutSpeed = 0
103 export let totalInSpeed = 0
104
104 -let lastSent = totalSent
105 -let lastGot = totalGot
105 +let lastSent: number | undefined
106 +let lastGot: number | undefined
107 let last = Date.now()
108 setInterval(() => {
109 const now = Date.now()
109 - const past = (now - last) / 1000 // seconds
110 + const past = now - last
111 last = now
111 - const deltaSentKb = (totalSent - lastSent) / 1000
112 - lastSent = totalSent
113 - const deltaGotKb = (totalGot - lastGot) / 1000
114 - lastGot = totalGot
115 - totalOutSpeed = roundSpeed(deltaSentKb / past)
116 - totalInSpeed = roundSpeed(deltaGotKb / past)
112 + {
113 + const v = totalSent.get()
114 + totalOutSpeed = roundSpeed((v - (lastSent ?? v)) / past)
115 + lastSent = v
116 + }
117 + {
118 + const v = totalGot.get()
119 + totalInSpeed = roundSpeed((v - (lastGot ?? v)) / past)
120 + lastGot = v
121 + }
122 }, 1000)
123
124 events.on('connection', (c: Connection) =>
125 c.socket.on('data', data =>
121 - totalGot += data.length ))
126 + totalGot.set(x => (x || 0) + data.length) ))