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