@samitouri / QOSami-HFS / commits / 6a73e3f0

admin/monitoring: log #763

Massimo Melina committed Oct 6, 2024 at 12:14 UTC 6a73e3f057c2e3635c4d8310e538552a208fa821
12 files changed +273 -166
admin/src/ArrayField.ts
-1
@@ -35,7 +35,6 @@ export function ArrayField<T extends object>({ label, helperText, fields, value,
35 '.MuiDataGrid-virtualScroller': { minHeight: '3em' },
36 ...autoRowHeight && { '.MuiDataGrid-cell': { minHeight: '52px !important' } }
37 },
38 - style: undefined, // override style making it fill the flex
38 hideFooterSelectedRowCount: true,
39 hideFooter: true,
40 slots: {
admin/src/DataTable.ts
+33 -24
@@ -1,11 +1,10 @@
1 import { DataGrid, DataGridProps, enUS, getGridStringOperators, GridColDef, GridFooter, GridFooterContainer,
2 - GridValidRowModel, useGridApiRef } from '@mui/x-data-grid'
2 + GridValidRowModel, useGridApiRef, GridRenderCellParams } from '@mui/x-data-grid'
3 import { Alert, Box, BoxProps, Breakpoint, LinearProgress, useTheme } from '@mui/material'
4 -import { useWindowSize } from 'usehooks-ts'
5 -import { createElement as h, Fragment, ReactNode, useEffect, useMemo, useRef, useState } from 'react'
6 -import { newDialog, onlyTruthy } from '@hfs/shared'
4 +import { createElement as h, Fragment, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
5 +import { callable, Callback, newDialog, onlyTruthy, useGetSize } from '@hfs/shared'
6 import _ from 'lodash'
8 -import { Center, Flex, useBreakpoint } from './mui'
7 +import { Center, Flex } from './mui'
8 import { SxProps } from '@mui/system'
9
10 const ACTIONS = 'Actions'
@@ -14,23 +13,22 @@ export type DataTableColumn<R extends GridValidRowModel=any> = GridColDef<R> & {
13 hidden?: boolean
14 hideUnder?: Breakpoint | number
15 dialogHidden?: boolean
17 - sx?: SxProps
16 + sx?: SxProps | Callback<GridRenderCellParams, SxProps>
17 mergeRender?: { [other: string]: false | { override?: Partial<GridColDef<R>> } & BoxProps }
18 mergeRenderSx?: SxProps
19 }
21 -interface DataTableProps<R extends GridValidRowModel=any> extends Omit<DataGridProps<R>, 'columns'> {
20 +export interface DataTableProps<R extends GridValidRowModel=any> extends Omit<DataGridProps<R>, 'columns'> {
21 columns: Array<DataTableColumn<R>>
22 actions?: ({ row, id }: any) => ReactNode[]
23 actionsProps?: Partial<GridColDef<R>> & { hideUnder?: Breakpoint | number }
24 initializing?: boolean
25 noRows?: ReactNode
26 error?: ReactNode
28 - compact?: true
29 - addToFooter?: ReactNode
27 + compact?: boolean
28 + footerSide?: (width: number) => ReactNode
29 + fillFlex?: boolean
30 }
31 -export function DataTable({ columns, initialState={}, actions, actionsProps, initializing, noRows, error, compact, addToFooter, ...rest }: DataTableProps) {
32 - let { width } = useWindowSize()
33 - width = Math.min(width, screen.availWidth) // workaround: width returned by useWindowSize is not good when toggling mobile-mode in chrome
31 +export function DataTable({ columns, initialState={}, actions, actionsProps, initializing, noRows, error, compact, footerSide, fillFlex, ...rest }: DataTableProps) {
32 const theme = useTheme()
33 const apiRef = useGridApiRef()
34 const [actionsLength, setActionsLength] = useState(0)
@@ -63,7 +61,7 @@ export function DataTable({ columns, initialState={}, actions, actionsProps, ini
61 originalRenderCell: col.renderCell || true,
62 renderCell(params: any) {
63 const { columns } = params.api.store.getSnapshot()
66 - return h(Box, { maxHeight: '100%', sx: { textWrap: 'wrap', ...sx } }, // wrap if necessary, but stay within the row
64 + return h(Box, { maxHeight: '100%', sx: { textWrap: 'wrap', ...callable(sx as any, params) } }, // wrap if necessary, but stay within the row
65 col.renderCell ? col.renderCell(params) : params.formattedValue,
66 h(Flex, { fontSize: 'smaller', flexWrap: 'wrap', mt: '2px', ...col.mergeRenderSx }, // wrap, normally causing overflow/hiding, if it doesn't fit
67 ...onlyTruthy(_.map(col.mergeRender, (props, other) => {
@@ -94,8 +92,9 @@ export function DataTable({ columns, initialState={}, actions, actionsProps, ini
92 })
93 return ret
94 }, [columns, actions, actionsLength])
95 + const sizeGrid = useGetSize()
96 + const width = sizeGrid.w || 0
97 const hideCols = useMemo(() => {
98 - if (!width) return
98 const fields = onlyTruthy(manipulatedColumns.map(({ field, hideUnder, hidden }) =>
99 (hidden || hideUnder && width < (typeof hideUnder === 'number' ? hideUnder : theme.breakpoints.values[hideUnder]))
100 && field))
@@ -112,10 +111,14 @@ export function DataTable({ columns, initialState={}, actions, actionsProps, ini
111 const { current: { id, setCurRow } } = displayingDetails
112 setCurRow?.(_.find(rest.rows, { id }))
113 })
115 - const sm = useBreakpoint('sm')
116 -
117 - if (!hideCols) // only first time we render, initialState is considered, so wait
118 - return null
114 + const sizeFooterSide = useGetSize()
115 + const wrappedFooterSide = h(Box, { ...sizeFooterSide.props, className: 'footerSide', sx: { whiteSpace: 'nowrap' } }, footerSide?.(width))
116 + const [causingScrolling, setCausingScrolling] = useState(false)
117 + useEffect(useCallback(_.debounce(() => {
118 + const el = sizeGrid.ref.current?.querySelector('.MuiTablePagination-root')
119 + setCausingScrolling(el && (el.scrollWidth > el.clientWidth) || false)
120 + }, 500), [sizeGrid]),
121 + [sizeGrid, width, sizeFooterSide.w]) // recalculate in case the footerSide changes
122
123 return h(Fragment, {},
124 error && h(Alert, { severity: 'error' }, error),
@@ -126,23 +129,29 @@ export function DataTable({ columns, initialState={}, actions, actionsProps, ini
129 h(DataGrid, {
130 key: width,
131 initialState,
129 - style: { height: 0, flex: 'auto' }, // limit table to available screen space
132 density: compact ? 'compact' : 'standard',
133 columns: manipulatedColumns,
134 apiRef,
135 + ...sizeGrid.props,
136 ...rest,
137 sx: {
135 - '& .MuiDataGrid-virtualScroller': { minHeight: '3em' } // without this, no-entries gets just 1px
138 + ...fillFlex && { height: 0, flex: 'auto' }, // limit table to available screen space, if parent is flex
139 + '& .MuiDataGrid-virtualScroller': { minHeight: '3em' }, // without this, no-entries gets just 1px
140 + '& .MuiTablePagination-root': { scrollbarWidth: 'none'},
141 + ...rest.sx,
142 },
143 slots: {
144 noRowsOverlay: () => initializing ? null : h(Center, {}, noRows || "No entries"),
145 footer: CustomFooter,
146 },
147 slotProps: {
142 - footer: { add: addToFooter } as any,
143 - pagination: !sm && addToFooter ? undefined : {
144 - showFirstButton: true,
145 - showLastButton: true,
148 + footer: { add: wrappedFooterSide } as any, // 'add' is introduced by CustomFooter
149 + pagination: {
150 + labelRowsPerPage: "Rows",
151 + ...!causingScrolling && {
152 + showFirstButton: true,
153 + showLastButton: true,
154 + }
155 },
156 },
157 onCellClick({ field, row }) {
admin/src/InstalledPlugins.ts
+1
@@ -36,6 +36,7 @@ export default function InstalledPlugins({ updates }: { updates?: true }) {
36 return h(DataTable, {
37 error: xlate(error, PLUGIN_ERRORS),
38 rows: list.length ? list : [], // workaround for DataGrid bug causing 'no rows' message to be not displayed after 'loading' was also used
39 + fillFlex: true,
40 initializing,
41 disableColumnSelector: true,
42 noRows: updates && `No updates available. Only plugins available on "search online" are checked.`,
admin/src/LangPage.ts
+1 -1
@@ -28,7 +28,7 @@ export default function LangPage() {
28 loading: connecting,
29 rows: useMemo(() => _.sortBy(list, x => (x.embedded ? 2 : 1) + x.code), [list.length]), // multi-sorting is only in pro version of DataGrid
30 hideFooter: true,
31 - sx: { flex: 1 },
31 + fillFlex: true,
32 columns: [
33 {
34 field: 'code',
admin/src/LogsPage.ts
+11 -9
@@ -3,10 +3,11 @@
3 import { createElement as h, Fragment, ReactNode, useEffect, useMemo, useState } from 'react';
4 import { Box, Tab, Tabs } from '@mui/material'
5 import { API_URL, apiCall, useApi, useApiList } from './api'
6 -import { DataTable } from './DataTable'
6 +import { DataTable, DataTableProps } from './DataTable'
7 import {
8 CFG, Dict, formatBytes, HTTP_UNAUTHORIZED, newDialog, prefix, shortenAgent, splitAt, tryJson, md,
9 - typedKeys, NBSP, _dbg, mapFilter, safeDecodeURIComponent, stringAfter
9 + typedKeys, NBSP, _dbg, mapFilter, safeDecodeURIComponent
10 +, stringAfter
11 } from '@hfs/shared'
12 import {
13 NetmaskField, Flex, IconBtn, useBreakpoint, usePauseButton, useToggleButton, WildcardsSupported, Country,
@@ -54,7 +55,7 @@ export default function LogsPage() {
55 h(IconBtn, { icon: Settings, title: "Options", onClick: showLogOptions })
56 ),
57 files.map(f =>
57 - h(LogFile, { hidden: file !== f, file: f, key: f }) ),
58 + h(LogFile, { hidden: file !== f, file: f, key: f, fillFlex: true }) ),
59 )
60
61 function showLogOptions() {
@@ -99,7 +100,7 @@ export default function LogsPage() {
100
101 const LOGS_ON_FILE: string[] = [CFG.log, CFG.error_log]
102
102 -function LogFile({ file, addToFooter, hidden }: { hidden?: boolean, file: string, addToFooter?: ReactNode }) {
103 +export function LogFile({ file, footerSide, hidden, limit, ...rest }: { limit?: number, hidden?: boolean, file: string, footerSide?: ReactNode } & Partial<DataTableProps>) {
104 const [showCountry, setShowCountry] = useState(false)
105 const [showAgent, setShowAgent] = useState(false)
106 const { pause, pauseButton } = usePauseButton()
@@ -111,7 +112,7 @@ function LogFile({ file, addToFooter, hidden }: { hidden?: boolean, file: string
112 const [totalSize, setTotalSize] = useState(NaN)
113 const [limited, setLimited] = useState(true)
114 const [skipped, setSkipped] = useState(0)
114 - const MAX = 2**20
115 + const MAX = 2**20 // 1MB
116 const invert = true
117 const [firstSight, setFirstSight] = useState(!hidden)
118 useEffect(() => setFirstSight(x => x || !hidden), [hidden])
@@ -136,7 +137,7 @@ function LogFile({ file, addToFooter, hidden }: { hidden?: boolean, file: string
137 setList(x => [...x, ...treated])
138 }
139 })
139 - const { list, setList, error, connecting, reload } = useApiList(firstSight && 'get_log', { file }, { invert, pause, map: enhanceLogLine })
140 + const { list, setList, error, connecting, reload } = useApiList(firstSight && 'get_log', { file }, { limit, invert, pause, map: enhanceLogLine })
141 if (file === 'ips')
142 reloadIps = reload
143 const tsColumn: GridColDef = {
@@ -157,19 +158,20 @@ function LogFile({ file, addToFooter, hidden }: { hidden?: boolean, file: string
158 compact: true,
159 actionsProps: { hideUnder: 'md' },
160 actions: ({ row }) => [ !isConsole && blockIp.iconBtn(row.ip, "From log") ],
160 - addToFooter: h(Box, {}, // 4 icons don't fit the tabs row on mobile
161 + ...rest,
162 + footerSide: width => h(Box, {}, // 4 icons don't fit the tabs row on mobile
163 pauseButton,
164 showApiButton,
165 !connecting && skipped > 0 && h(Btn, {
166 icon: ClearAll,
167 variant: 'outlined',
168 sx: { ml: { sm: 1 } },
167 - labelFrom: 'md',
169 + labelIf: width > 700,
170 title: `Only ${formatBytes(MAX)} was loaded, for speed. Total size is ${formatBytes(totalSize)}`,
171 loading: !limited,
172 onClick: () => setLimited(false)
173 }, "Load whole log"),
172 - addToFooter,
174 + footerSide,
175 ),
176 columns: isConsole ? [
177 tsColumn,
admin/src/MonitorPage.ts
+159 -118
@@ -3,17 +3,19 @@
3 import _ from "lodash"
4 import { createElement as h, useMemo, Fragment, useState } from "react"
5 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'
6 +import { LinkOff, Lock, FolderZip, Upload, Download, ChevronRight, ChevronLeft, History } from '@mui/icons-material'
7 +import { Box, Chip, ChipProps, Grid } from '@mui/material'
8 import { DataTable } from './DataTable'
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'
13 +import {
14 + IconBtn, IconProgress, iconTooltip, usePauseButton, useBreakpoint, Country, hTooltip, useToggleButton, Flex
15 +} from './mui'
16 import { Field, SelectField } from '@hfs/mui-grid-form'
17 import { StandardCSSProperties } from '@mui/system/styleFunctionSx/StandardCssProperties'
16 -import { agentIcons } from './LogsPage'
18 +import { agentIcons, LogFile } from './LogsPage'
19 import { state, useSnapState } from './state'
20 import { useBlockIp } from './useBlockIp'
21 import { alertDialog, confirmDialog } from './dialog'
@@ -46,7 +48,7 @@ function MoreInfo() {
48 title: x => "Since: " + formatTimestamp(x[2]),
49 onDelete: () => confirmDialog("Reset stats?")
50 .then(yes => yes && apiCall('clear_persistent', { k: ['totalSent', 'totalGot'] })
49 - .then(() => alertDialog("Done", 'success'), alertDialog) )
51 + .then(() => alertDialog("Done", 'success'), alertDialog))
52 }),
53 pair('outSpeed', { label: "Output", render: formatSpeedK, minWidth: '8.5em' }),
54 pair('inSpeed', { label: "Input", render: formatSpeedK, minWidth: '8.5em' }),
@@ -64,6 +66,7 @@ function MoreInfo() {
66
67 type Color = ChipProps['color']
68 type Render = (v: any) => [string, Color?] | string | { [label: string]: string }
69 +
70 interface PairOptions {
71 label?: string
72 render?: Render
@@ -72,7 +75,7 @@ function MoreInfo() {
75 onDelete?: Callback
76 }
77
75 - function pair(k: string, { label, minWidth, render, title, onDelete }: PairOptions={}) {
78 + function pair(k: string, { label, minWidth, render, title, onDelete }: PairOptions = {}) {
79 let v = _.get(connections, k) ?? _.get(status, k)
80 if (v === undefined)
81 return null
@@ -89,11 +92,11 @@ function MoreInfo() {
92 variant: 'filled',
93 color,
94 onDelete,
92 - label: reactJoin(' – ', _.map(_.isPlainObject(v) ? v : { [label]: v }, (v,label) =>
93 - h('span', { style:{ display: 'inline-block', minWidth } },
94 - h('b',{}, label), ': ', v,
95 + label: reactJoin(' – ', _.map(_.isPlainObject(v) ? v : { [label]: v }, (v, label) =>
96 + h('span', { style: { display: 'inline-block', minWidth } },
97 + h('b', {}, label), ': ', v,
98 ))),
96 - }) )
99 + }))
100 }
101
102 function port(v: any): ReturnType<Render> {
@@ -113,119 +116,157 @@ function Connections() {
116 list?.filter((x: any) => !monitorOnlyFiles || x.op).map((x: any, id: number) => ({ id, ...x })),
117 [!pause && list, monitorOnlyFiles]) //eslint-disable-line
118 const blockIp = useBlockIp()
119 + const logAble = useBreakpoint('md')
120 + const [wantLog, wantLogButton] = useToggleButton("Show log", "Hide log", v => ({
121 + icon: History,
122 + sx: { rotate: v ? 0 : '180deg' },
123 + }), state.monitorWithLog)
124 + state.monitorWithLog = wantLog
125 + const logSize = logAble && wantLog ? 6 : 0
126 return h(Fragment, {},
117 - h(Box, { display: 'flex', alignItems: 'center' },
118 - h(SelectField as Field<boolean>, {
119 - fullWidth: false,
120 - value: monitorOnlyFiles,
121 - onChange: v => state.monitorOnlyFiles = v,
122 - options: { "Show downloads+uploads": true, "Show all connections": false }
123 - }),
127 + h(Flex, {},
128 + h(Box, { flex: 1 },
129 + h(SelectField as Field<boolean>, {
130 + fullWidth: false,
131 + value: monitorOnlyFiles,
132 + onChange: v => state.monitorOnlyFiles = v,
133 + options: { "Show downloads+uploads": true, "Show all connections": false }
134 + }),
135 + ),
136 + logAble && h(Flex, { flex: 1, justifyContent: 'space-between' },
137 + wantLog ? "Latest requests" : h(Box),
138 + wantLogButton),
139 ),
125 - h(DataTable, {
126 - error,
127 - rows,
128 - noRows: monitorOnlyFiles && "No downloads at the moment",
129 - addToFooter: pauseButton,
130 - columns: [
131 - {
132 - field: 'ip',
133 - headerName: "Address",
134 - flex: 1,
135 - maxWidth: 400,
136 - renderCell: ({ row, value }) => ipForUrl(value) + ' :' + row.port,
137 - mergeRender: { user: { fontSize: 'small' } },
138 - },
139 - {
140 - field: 'country',
141 - hidden: config.data?.[CFG.geo_enable] !== true,
142 - headerName: "Country",
143 - hideUnder: 'md',
144 - renderCell: ({ value, row }) => h(Country, { code: value, ip: row.ip, def: '-' }),
145 - },
146 - {
147 - field: 'user',
148 - headerName: "User",
149 - hideUnder: 'md',
150 - },
151 - {
152 - field: 'started',
153 - headerName: "Started",
154 - type: 'dateTime',
155 - width: 96,
156 - hideUnder: 'lg',
157 - valueFormatter: ({ value }) => new Date(value as string).toLocaleTimeString()
158 - },
159 - {
160 - field: 'path',
161 - headerName: "File",
162 - flex: 1.5,
163 - renderCell({ value, row }) {
164 - if (!value || !row.op) return
165 - if (row.op === 'browsing')
166 - return h(Box, {}, value, h(Box, { fontSize: 'x-small' }, "browsing"))
167 - return h(Fragment, {},
168 - h(IconProgress, {
169 - icon: row.archive ? FolderZip : row.op === 'upload' ? Upload : Download,
170 - progress: row.opProgress ?? row.opOffset,
171 - offset: row.opOffset,
172 - title: md(formatPerc(row.opProgress) + (row.opTotal ? "\nTotal: " + formatBytes(row.opTotal) : '')),
173 - sx: { mr: 1 }
174 - }),
175 - row.archive ? h(Box, {}, value, h(Box, { fontSize: 'x-small', color: 'text.secondary' }, row.archive))
176 - : with_(value?.lastIndexOf('/'), i => h(Box, {}, value.slice(i + 1),
177 - i > 0 && h(Box, { fontSize: 'x-small', color: 'text.secondary' }, value.slice(0, i))
178 - )),
179 - )
180 - }
181 - },
182 - {
183 - field: 'outSpeed',
184 - headerName: "Speed",
185 - width: 110,
186 - hideUnder: 'sm',
187 - type: 'number',
188 - renderCell: ({ value, row }) => formatSpeedK(Math.max(value||0, row.inSpeed||0) || undefined),
189 - mergeRender: { sent: { fontSize: 'small', textAlign: 'right' }}
190 - },
191 - {
192 - field: 'sent',
193 - headerName: "Sent",
194 - type: 'number',
195 - hideUnder: 'md',
196 - renderCell: ({ value, row}) => formatBytes(Math.max(value||0, row.got||0))
197 - },
198 - {
199 - field: 'v',
200 - headerName: "Protocol",
201 - align: 'center',
202 - hideUnder: Infinity,
203 - renderCell: ({ value }) => h(Fragment, {},
204 - "IPv" + value,
205 - iconTooltip(Lock, "HTTPS", { opacity: .5 })
206 - )
207 - },
208 - {
209 - field: 'agent',
210 - headerName: "Agent",
211 - hideUnder: 'lg',
212 - renderCell: ({ value }) => agentIcons(value)
213 - },
214 - ],
215 - actionsProps: { hideUnder: 'sm' },
216 - actions: ({ row }) => [
217 - h(IconBtn, {
218 - icon: LinkOff,
219 - title: "Disconnect",
220 - doneMessage: true,
221 - onClick: () => apiCall('disconnect', _.pick(row, ['ip', 'port'])).then(x => x.result > 0)
140 + h(Grid, { container: true, flex: 1, columnSpacing: 1 },
141 + h(Grid, { item: true, xs: 12 - logSize },
142 + h(DataTable, {
143 + error,
144 + rows,
145 + noRows: monitorOnlyFiles && "No downloads at the moment",
146 + footerSide: () => pauseButton,
147 + columns: [
148 + {
149 + field: 'ip',
150 + headerName: "Address",
151 + flex: 1,
152 + maxWidth: 400,
153 + renderCell: ({ row, value }) => ipForUrl(value) + ' :' + row.port,
154 + mergeRender: {
155 + user: { display: 'flex', justifyContent: 'space-between', gap: '.5em', },
156 + agent: {},
157 + country: {},
158 + },
159 + },
160 + {
161 + field: 'country',
162 + hidden: config.data?.[CFG.geo_enable] !== true,
163 + headerName: "Country",
164 + hideUnder: 'md',
165 + renderCell: ({ value, row }) => h(Country, { code: value, ip: row.ip }),
166 + },
167 + {
168 + field: 'user',
169 + headerName: "User",
170 + hideUnder: 'md',
171 + },
172 + {
173 + field: 'started',
174 + headerName: "Started",
175 + type: 'dateTime',
176 + width: 96,
177 + hideUnder: 'lg',
178 + valueFormatter: ({ value }) => new Date(value as string).toLocaleTimeString()
179 + },
180 + {
181 + field: 'path',
182 + headerName: "File",
183 + flex: 1.5,
184 + renderCell({ value, row }) {
185 + if (!value || !row.op) return
186 + if (row.op === 'browsing')
187 + return h(Box, {}, value, h(Box, { fontSize: 'x-small' }, "browsing"))
188 + return h(Fragment, {},
189 + h(IconProgress, {
190 + icon: row.archive ? FolderZip : row.op === 'upload' ? Upload : Download,
191 + progress: row.opProgress ?? row.opOffset,
192 + offset: row.opOffset,
193 + title: md(formatPerc(row.opProgress) + (row.opTotal ? "\nTotal: " + formatBytes(row.opTotal) : '')),
194 + sx: { mr: 1 }
195 + }),
196 + row.archive ? h(Box, {}, value, h(Box, {
197 + fontSize: 'x-small',
198 + color: 'text.secondary'
199 + }, row.archive))
200 + : with_(value?.lastIndexOf('/'), i => h(Box, {}, value.slice(i + 1),
201 + i > 0 && h(Box, {
202 + fontSize: 'x-small',
203 + color: 'text.secondary'
204 + }, value.slice(0, i))
205 + )),
206 + )
207 + }
208 + },
209 + {
210 + field: 'outSpeed',
211 + headerName: "Speed",
212 + width: 110,
213 + hideUnder: 'sm',
214 + type: 'number',
215 + renderCell: ({
216 + value,
217 + row
218 + }) => formatSpeedK(Math.max(value || 0, row.inSpeed || 0) || undefined),
219 + mergeRender: { sent: { fontSize: 'small', textAlign: 'right' } }
220 + },
221 + {
222 + field: 'sent',
223 + headerName: "Sent",
224 + type: 'number',
225 + hideUnder: 'md',
226 + renderCell: ({ value, row }) => formatBytes(Math.max(value || 0, row.got || 0))
227 + },
228 + {
229 + field: 'v',
230 + headerName: "Protocol",
231 + align: 'center',
232 + hideUnder: Infinity,
233 + renderCell: ({ value }) => h(Fragment, {},
234 + "IPv" + value,
235 + iconTooltip(Lock, "HTTPS", { opacity: .5 })
236 + )
237 + },
238 + {
239 + field: 'agent',
240 + headerName: "Agent",
241 + hideUnder: 'lg',
242 + renderCell: ({ value }) => agentIcons(value)
243 + },
244 + ],
245 + actionsProps: { hideUnder: 'sm' },
246 + actions: ({ row }) => [
247 + h(IconBtn, {
248 + icon: LinkOff,
249 + title: "Disconnect",
250 + doneMessage: true,
251 + onClick: () => apiCall('disconnect', _.pick(row, ['ip', 'port'])).then(x => x.result > 0)
252 + }),
253 + blockIp.iconBtn(row.ip, "From monitoring", { disabled: row.ip === props?.you }),
254 + ]
255 }),
223 - blockIp.iconBtn(row.ip, "From monitoring", { disabled: row.ip === props?.you }),
224 - ]
225 - })
256 + ),
257 + logAble && wantLog && h(Grid, { item: true, xs: logSize, display: 'flex', flexDirection: 'column' },
258 + h(LogFile, {
259 + file: `${CFG.log}|${CFG.error_log}`,
260 + fillFlex: true,
261 + compact: false,
262 + limit: 1000,
263 + getRowClassName: ({ row }) => row.status < 400 ? '' : 'isError',
264 + sx: { '& .isError': { backgroundColor: '#a443' } },
265 + }) )
266 + )
267 )
268 }
269
270 function formatSpeedK(value: number | undefined) {
271 return value === undefined ? '' : formatSpeed(value * 1000, { digits: 1 })
231 -}
272 +}
\ No newline at end of file
admin/src/OnlinePlugins.ts
+1
@@ -31,6 +31,7 @@ export default function OnlinePlugins() {
31 error: xlate(error, PLUGIN_ERRORS),
32 rows: list.length ? list : [], // workaround for DataGrid bug causing 'no rows' message to be not displayed after 'loading' was also used
33 noRows: "No compatible plugins have been found",
34 + fillFlex: true,
35 initializing,
36 columnVisibilityModel: snap.onlinePluginsColumns,
37 onColumnVisibilityModelChange: newModel => Object.assign(state.onlinePluginsColumns, newModel),
admin/src/api.ts
+12 -3
@@ -38,7 +38,7 @@ export function useApiEx<T=any>(...args: Parameters<typeof useApi>) {
38 }
39 }
40
41 -export function useApiList<T=any, S=T>(cmd:string|Falsy, params: Dict={}, { map, invert, pause }: { pause?: boolean, invert?: boolean, map?: (rec: S) => T }={}) {
41 +export function useApiList<T=any, S=T>(cmd:string|Falsy, params: Dict={}, { map, invert, pause, limit }: { limit?: number, pause?: boolean, invert?: boolean, map?: (rec: S) => T }={}) {
42 const [list, setList] = useStateMounted<T[]>([])
43 const [props, setProps] = useStateMounted<any>(undefined)
44 const [error, setError] = useStateMounted<any>(undefined)
@@ -55,8 +55,17 @@ export function useApiList<T=any, S=T>(cmd:string|Falsy, params: Dict={}, { map,
55 const apply = _.debounce(() => {
56 const chunk = bufferAdd.splice(0, Infinity)
57 if (!chunk.length) return
58 - if (invert) chunk.reverse() // setList callback can be called twice (and will, in dev)
59 - setList(list => invert ? [ ...chunk, ...list ] : [ ...list, ...chunk ])
58 + if (invert) chunk.reverse() // don't move this inside setList, as its callback can be called twice (and will, in dev)
59 + setList(list => {
60 + if (invert) {
61 + const ret = [...chunk, ...list]
62 + ret.splice(limit ?? Infinity, Infinity)
63 + return ret
64 + }
65 + const ret = [...list, ...chunk]
66 + ret.splice(0, length - (limit ?? Infinity))
67 + return ret
68 + })
69 }, 1000, { maxWait: 1000 })
70 setError(undefined)
71 setLoading(true)
admin/src/mui.ts
+4 -4
@@ -132,7 +132,7 @@ export interface BtnProps extends Omit<ButtonProps & IconButtonProps,'disabled'|
132 progress?: boolean | number
133 link?: string
134 confirm?: boolean | ReactNode
135 - labelFrom?: Breakpoint | false
135 + labelIf?: Breakpoint | boolean
136 doneMessage?: boolean | string // displayed only if the result of onClick !== false
137 tooltipProps?: Partial<TooltipProps>
138 modified?: boolean
@@ -140,14 +140,14 @@ export interface BtnProps extends Omit<ButtonProps & IconButtonProps,'disabled'|
140 onClick?: (...args: Parameters<NonNullable<ButtonProps['onClick']>>) => Promisable<any>
141 }
142
143 -export const Btn = forwardRef(({ icon, title, onClick, disabled, progress, link, tooltipProps, confirm, doneMessage, labelFrom, children, modified, loading, ...rest }: BtnProps, forwarded: ForwardedRef<HTMLButtonElement>) => {
143 +export const Btn = forwardRef(({ icon, title, onClick, disabled, progress, link, tooltipProps, confirm, doneMessage, labelIf, children, modified, loading, ...rest }: BtnProps, forwarded: ForwardedRef<HTMLButtonElement>) => {
144 const [loadingState, setLoadingState] = useStateMounted(false)
145 if (typeof disabled === 'string')
146 title = disabled
147 disabled = loadingState || progress || disabled ? true : undefined
148 if (link)
149 onClick = () => window.open(link)
150 - const showLabel = useBreakpoint(labelFrom || 'xs')
150 + const showLabel = useBreakpoint(_.isString(labelIf) ? labelIf : 'xs') && (_.isBoolean(labelIf) ? labelIf : true)
151 const ref = useRefPass<HTMLButtonElement>(forwarded)
152 const common = _.merge(propsForModifiedValues(modified), {
153 ref,
@@ -172,7 +172,7 @@ export const Btn = forwardRef(({ icon, title, onClick, disabled, progress, link,
172 loadingIndicator: typeof progress !== 'number' ? undefined
173 : h(CircularProgress, { size: '1rem', value: progress*100, variant: 'determinate' }),
174 children: showLabel && children,
175 - } as const, common, !showLabel && { sx: { minWidth: 'auto', px: 1, py: '7px', '& span': { mx:0 }, } }))
175 + } as const, common, (!showLabel || !children) && { sx: { minWidth: 'auto', px: 1, py: '7px', '& span': { mx:0 }, } }))
176 : h(IconButton, _.merge(common, { sx: { height: 'fit-content' }, TouchRippleProps: { 'aria-hidden': true } }),
177 (progress || loadingState) && progress !== false // false is also useful to inhibit behavior with loading
178 && h(CircularProgress, {
admin/src/state.ts
+2 -1
@@ -16,6 +16,7 @@ const INIT = {
16 loginRequired: false as boolean | number,
17 username: '',
18 monitorOnlyFiles: true,
19 + monitorWithLog: true,
20 customHtmlSection: '',
21 darkTheme: undefined as undefined | boolean,
22 onlinePluginsColumns: {
@@ -27,7 +28,7 @@ const INIT = {
28 Object.assign(INIT, JSON.parse(localStorage[STORAGE_KEY]||null))
29 export const state = proxy(INIT)
30
30 -const SETTINGS_TO_STORE: (keyof typeof state)[] = ['onlinePluginsColumns', 'monitorOnlyFiles', 'customHtmlSection', 'darkTheme']
31 +const SETTINGS_TO_STORE: (keyof typeof state)[] = ['onlinePluginsColumns', 'monitorOnlyFiles', 'monitorWithLog', 'customHtmlSection', 'darkTheme']
32 const storeSettings = _.debounce(() =>
33 localStorage[STORAGE_KEY] = JSON.stringify(_.pick(state, SETTINGS_TO_STORE)), 500, { maxWait: 1000 })
34 for (const k of SETTINGS_TO_STORE)
shared/react.ts
+46 -3
@@ -1,9 +1,12 @@
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, Fragment, KeyboardEvent, ReactElement, ReactNode,
4 - useCallback, useEffect, useRef, useState } from 'react'
3 +import {
4 + createElement as h, Fragment, KeyboardEvent, MutableRefObject, ReactElement, ReactNode, Ref,
5 + useCallback, useEffect, useMemo, useRef, useState
6 +} from 'react'
7 import { useIsMounted, useWindowSize, useMediaQuery } from 'usehooks-ts'
6 -import { Falsy } from '.'
8 +import { Callback, Falsy } from '.'
9 +import _ from 'lodash'
10
11 export function useStateMounted<T>(init: T) {
12 const isMounted = useIsMounted()
@@ -103,6 +106,46 @@ export function useIsMobile() {
106 return useMediaQuery('(pointer:coarse)')
107 }
108
109 +// calls back with [width, height]
110 +export function useOnResize(cb: Callback<[number, number]>) {
111 + const observer = useMemo(() =>
112 + new ResizeObserver(_.debounce(([{contentRect: r}]) => cb([r.width, r.height]), 10)),
113 + [])
114 +
115 + return useMemo(() => ({
116 + ref(el: any) {
117 + observer.disconnect()
118 + if (el)
119 + observer.observe(el)
120 + }
121 + }), [observer])
122 +}
123 +
124 +export function useGetSize() {
125 + const [size, setSize] = useState<[number,number]>()
126 + const ref = useRef<HTMLElement>()
127 + const props = useOnResize(setSize)
128 + const propsRef = useCallback((el: any) => passRef(el, ref, props.ref), [props])
129 + return useMemo(() => ({
130 + w: size?.[0],
131 + h: size?.[1],
132 + ref,
133 + props: {
134 + ...props,
135 + ref: propsRef
136 + }
137 + }), [size, ref, propsRef])
138 +}
139 +
140 +type FunctionRef<T=HTMLElement> = (instance: (T | null)) => void
141 +export function passRef<T=any>(el: T, ...refs: (MutableRefObject<T> | FunctionRef<T>)[]) {
142 + for (const ref of refs)
143 + if (_.isFunction(ref))
144 + ref(el)
145 + else if (ref)
146 + ref.current = el
147 +}
148 +
149 export function AriaOnly({ children }: { children?: ReactNode }) {
150 return children ? h('div', { className: 'ariaOnly' }, children) : null
151 }
src/api.log.ts
+3 -2
@@ -25,6 +25,7 @@ export default {
25 },
26
27 get_log({ file = 'log' }, ctx) {
28 + const files = file.split('|') // potentially more then one
29 return new SendListReadable({
30 bufferTime: 10,
31 async doAtStart(list) {
@@ -45,11 +46,11 @@ export default {
46 return
47 }
48 // for other logs we only provide updates. Use get_log_file to download past content
48 - if (!_.find(loggers, { name: file }))
49 + if (_.some(files, x => !_.find(loggers, { name: x })) )
50 return list.error(HTTP_NOT_FOUND, true)
51 list.ready()
52 // unsubscribe when connection is interrupted
52 - ctx.res.once('close', events.on(file, x => list.add(x)))
53 + ctx.res.once('close', events.on(files, x => list.add(x)))
54 }
55 })
56