main
ts 285 lines 13.8 KB
Raw
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 _ from "lodash"
4 import { createElement as h, useMemo, Fragment, useState } from "react"
5 import { apiCall, useApiEvents, useApiEx, useApiList } from "./api"
6 import { LinkOff as DisconnectIcon, Lock, FolderZip, Upload, Download, ChevronRight, ChevronLeft, History } from '@mui/icons-material'
7 import { Alert, Box, Chip, ChipProps, Grid } from '@mui/material'
8 import { DataTable, fillFlexParentSx } from './DataTable'
9 import {
10 formatBytes, ipForUrl, CFG, formatSpeed, with_, createDurationFormatter, formatTimestamp, formatPerc, md, Callback,
11 reactJoin, SPECIAL_URI,
12 } from "./misc"
13 import {
14 IconBtn, IconProgress, iconTooltip, usePauseButton, useBreakpoint, Country, hTooltip, useToggleButton, Flex, Btn
15 } from './mui'
16 import { Field, SelectField } from '@hfs/mui-grid-form'
17 import { StandardCSSProperties } from '@mui/system/styleFunctionSx/StandardCssProperties'
18 import { agentIcons, LogFile } from './LogsPage'
19 import { state, useSnapState } from './state'
20 import { BlockIpBtn } from './blockIp'
21 import { alertDialog, confirmDialog, toast } from './dialog'
22 import { useInterval } from 'usehooks-ts'
23 import { PageProps } from './App'
24 import { adminApis } from '../../src/adminApis'
25
26 export default function MonitorPage({ setTitleSide }: PageProps) {
27 setTitleSide(useMemo(() =>
28 h(Alert, { severity: 'info', sx: { display: { xs: 'none', sm: 'inherit' } } }, "If you are behind a proxy, connections list may not match browsers activity"),
29 []))
30 return h(Fragment, {},
31 h(MoreInfo),
32 h(Connections),
33 )
34 }
35
36 function MoreInfo() {
37 const { data: status, element, reload } = useApiEx<typeof adminApis.get_status>('get_status')
38 useInterval(reload, 10_000) // status hardly change, but it can
39 const { data: stats } = useApiEvents('get_connection_stats')
40 const [allInfo, setAllInfo] = useState(false)
41 const md = useBreakpoint('md')
42 const sm = useBreakpoint('sm')
43 const xl = useBreakpoint('xl')
44 const formatDuration = createDurationFormatter({ maxTokens: 2, skipZeroes: true })
45 return element || h(Box, { sx: { display: 'flex', flexWrap: 'wrap', gap: { xs: .5, md: 1 }, mb: { xs: 1, sm: 2 } } },
46 (allInfo || md) && pair('started', {
47 label: "Uptime",
48 render: x => formatDuration(Date.now() - +new Date(x)),
49 title: x => "Started: " + formatTimestamp(x),
50 }),
51 (allInfo || sm) && pair('sent_got', {
52 render: x => ({ Sent: formatBytes(x[0]), Got: formatBytes(x[1]) }),
53 title: x => "Since: " + formatTimestamp(x[2]),
54 onDelete: () => confirmDialog("Reset stats?")
55 .then(yes => yes && apiCall('clear_persistent', { k: ['totalSent', 'totalGot'] })
56 .then(() => alertDialog("Done", 'success'), alertDialog))
57 }),
58 pair('outSpeedKb', { label: "Output", render: formatSpeedK, minWidth: '8.5em' }),
59 pair('inSpeedKb', { label: "Input", render: formatSpeedK, minWidth: '8.5em' }),
60 (allInfo || sm) && pair('ips', { label: "IPs", title: () => stats && `${stats.connections.toLocaleString()} connections` }),
61 (md || allInfo && md || status?.http?.error) && pair('http', { label: "HTTP", render: port }),
62 (md || allInfo && md || status?.https?.error) && pair('https', { label: "HTTPS", render: port }),
63 (xl || allInfo) && pair('ram', { label: "RAM", render: formatBytes }),
64 !xl && h(IconBtn, {
65 size: 'small',
66 icon: allInfo ? ChevronLeft : ChevronRight,
67 title: "Show more",
68 onClick: () => setAllInfo(x => !x)
69 }),
70 )
71
72 type Color = ChipProps['color']
73 type Render = (v: any) => [string, Color?] | string | { [label: string]: string }
74
75 interface PairOptions {
76 label?: string
77 render?: Render
78 minWidth?: StandardCSSProperties['minWidth']
79 title?: (v: any) => string
80 onDelete?: Callback
81 }
82
83 function pair(k: string, { label, minWidth, render, title, onDelete }: PairOptions = {}) {
84 let v = _.get(stats, k) ?? _.get(status, k)
85 if (v === undefined)
86 return null
87 let color: Color = undefined
88 const renderedTitle = title?.(v)
89 if (render) {
90 v = render(v)
91 if (Array.isArray(v))
92 [v, color] = v
93 }
94 if (!label)
95 label = _.capitalize(k.replaceAll('_', ' '))
96 return hTooltip(renderedTitle, undefined, h(Chip, {
97 variant: 'filled',
98 color,
99 onDelete,
100 label: reactJoin('', _.map(_.isPlainObject(v) ? v : { [label]: v }, (v, label) =>
101 h('span', { style: { display: 'inline-block', minWidth } },
102 h('b', {}, label), ': ', v,
103 ))),
104 }))
105 }
106
107 function port(v: any): ReturnType<Render> {
108 return v.listening ? ["port " + v.port, 'success']
109 : v.error ? [v.error, 'error']
110 : "off"
111 }
112
113 }
114
115 function Connections() {
116 const { list, error, props } = useApiList('get_connections')
117 const config = useApiEx('get_config', { only: [CFG.geo_enable] })
118 const { monitorOnlyFiles } = useSnapState()
119 const { pause, pauseButton } = usePauseButton()
120 const rows = useMemo(() =>
121 (!monitorOnlyFiles ? list : list?.filter((x: any) => x.op)) ?? [],
122 [!pause && list, monitorOnlyFiles]) //eslint-disable-line
123 const logAble = useBreakpoint('md')
124 const [wantLog, wantLogButton] = useToggleButton("Show log", "Hide log", v => ({
125 icon: History,
126 sx: { rotate: v ? 0 : '180deg' },
127 }), state.monitorWithLog)
128 state.monitorWithLog = wantLog
129 const logSize = logAble && wantLog ? 6 : 0
130 return h(Fragment, {},
131 h(Flex, {},
132 h(Flex, { flex: 1 },
133 h(SelectField as Field<boolean>, {
134 fullWidth: false,
135 value: monitorOnlyFiles,
136 onChange: v => state.monitorOnlyFiles = v,
137 options: { "Show downloads+uploads": true, "Show all connections": false }
138 }),
139 ),
140 logAble && h(Flex, { flex: 1, justifyContent: 'space-between' },
141 wantLog ? "Live log" : h(Box),
142 wantLogButton),
143 ),
144 h(Grid, { container: true, sx: { flex: 1 }, columnSpacing: 1 },
145 h(Grid, { size: 12 - logSize, sx: fillFlexParentSx },
146 h(DataTable, {
147 persist: 'connections',
148 error,
149 rows,
150 getRowId: (row: any) => row.ip + ':' + row.port,
151 fillFlex: true,
152 noRows: monitorOnlyFiles && "No downloads/uploads at the moment",
153 actionsHeader: pauseButton,
154 footerSide: () => h(Flex, {},
155 h(Btn, {
156 size: 'small',
157 icon: DisconnectIcon,
158 labelIf: 'xl',
159 confirm: "Disconnecting all connections but localhost. Continue?",
160 onClick: () => apiCall('disconnect', { allButLocalhost: true }).then(x => toast(`Disconnected: ${x.result}`))
161 }, "Disconnect all")
162 ),
163 columns: [
164 {
165 field: 'ip',
166 headerName: "Address",
167 flex: 1,
168 maxWidth: 400,
169 renderCell: ({ row, value }) => ipForUrl(value) + ' :' + row.port,
170 mergeRender: {
171 user: { sx: { display: 'flex', justifyContent: 'space-between', gap: '.5em' } },
172 agent: {},
173 country: {},
174 },
175 },
176 {
177 field: 'country',
178 headerName: "Country",
179 hideUnder: config.data?.[CFG.geo_enable] !== true || 'md',
180 renderCell: ({ value, row }) => h(Country, { code: value, ip: row.ip }),
181 },
182 {
183 field: 'user',
184 headerName: "User",
185 hideUnder: 'md',
186 },
187 {
188 field: 'started',
189 headerName: "Started",
190 type: 'dateTime',
191 width: 96,
192 hideUnder: 'lg',
193 valueFormatter: (value) => new Date(value as string).toLocaleTimeString()
194 },
195 {
196 field: 'path',
197 headerName: "File",
198 flex: 1.5,
199 renderCell({ value, row }) {
200 if (!value || !row.op) return
201 const rowContentSx = { display: 'flex', alignItems: 'center', height: '100%', minWidth: 0, gap: 1 } as const
202 if (row.op === 'browsing')
203 return h(Box, { sx: rowContentSx }, h(Box, {}, value, h(Box, { sx: { fontSize: 'x-small' } }, "browsing")))
204 // keep icon and filename on the same row: datagrid v7 wraps cell content differently than before
205 return h(Box, { sx: rowContentSx },
206 h(IconProgress, {
207 icon: row.archive ? FolderZip : row.op === 'upload' ? Upload : Download,
208 progress: row.opProgress ?? row.opOffset,
209 offset: row.opOffset,
210 title: md(formatPerc(row.opProgress) + (row.opTotal ? "\nTotal: " + formatBytes(row.opTotal) : '')),
211 }),
212 // clamp line-height locally so this cell doesn't inherit tall line metrics from datagrid wrappers
213 h(Box, { sx: { lineHeight: '1.2em', minWidth: 0 } }, row.archive ? h(Box, {}, value, h(Box, {
214 sx: { fontSize: 'x-small', color: 'text.secondary' }
215 }, row.archive))
216 : with_(value?.lastIndexOf('/'), i => h(Box, {}, value.slice(i + 1),
217 i > 0 && h(Box, {
218 sx: { fontSize: 'x-small', color: 'text.secondary' }
219 }, value.slice(0, i))
220 ))),
221 )
222 }
223 },
224 {
225 field: 'outSpeedKb',
226 headerName: "Speed",
227 width: 110,
228 hideUnder: 'sm',
229 type: 'number',
230 renderCell: ({ value, row }) => formatSpeedK(Math.max(value || 0, row.inSpeedKb || 0) || undefined),
231 mergeRender: { sent: { sx: { fontSize: 'small', textAlign: 'right' } } }
232 },
233 {
234 field: 'sent',
235 headerName: "Sent",
236 type: 'number',
237 hideUnder: 'md',
238 renderCell: ({ value, row }) => formatBytes(Math.max(value || 0, row.got || 0))
239 },
240 {
241 field: 'v',
242 headerName: "Protocol",
243 align: 'center',
244 hideUnder: Infinity,
245 renderCell: ({ value }) => h(Fragment, {},
246 "IPv" + value,
247 iconTooltip(Lock, "HTTPS", { opacity: .5 })
248 )
249 },
250 {
251 field: 'agent',
252 headerName: "Agent",
253 hideUnder: 'lg',
254 renderCell: ({ value }) => agentIcons(value)
255 },
256 ],
257 actionsProps: { hideUnder: 'sm' },
258 actions: ({ row }) => [
259 h(IconBtn, {
260 icon: DisconnectIcon,
261 title: "Disconnect",
262 doneMessage: true,
263 onClick: () => apiCall('disconnect', _.pick(row, ['ip', 'port'])).then(x => x.result > 0)
264 }),
265 h(BlockIpBtn, { ip: row.ip, comment: "From monitoring", disabled: row.ip === props?.you }),
266 ]
267 }),
268 ),
269 logAble && wantLog && h(Grid, { size: logSize, sx: fillFlexParentSx },
270 h(LogFile, {
271 file: `${CFG.log}|${CFG.error_log}`,
272 filter: monitorOnlyFiles ? (row => !row.uri.startsWith(SPECIAL_URI)) : undefined,
273 fillFlex: true,
274 compact: false,
275 limit: 1000,
276 getRowClassName: ({ row }) => row.status < 400 ? '' : 'isError',
277 sx: { '& .isError': { backgroundColor: '#a443' } },
278 }) )
279 )
280 )
281 }
282
283 function formatSpeedK(value: number | undefined) {
284 return value === undefined ? '' : formatSpeed(value * 1000, { digits: 1 })
285 }