| 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 | } |