| 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, ReactNode, useEffect, useMemo, useState } from 'react' |
| 4 | import httpCodes from './httpCodes' |
| 5 | import { Box, Tab, Tabs } from '@mui/material' |
| 6 | import { PageProps } from './App' |
| 7 | import { API_URL, apiCall, useApi, useApiEx, useApiList } from './api' |
| 8 | import { DataTable, DataTableColumn, DataTableProps } from './DataTable' |
| 9 | import { |
| 10 | CFG, Dict, formatBytes, HTTP_UNAUTHORIZED, newDialog, prefix, shortenAgent, splitAt, tryJson, md, typedKeys, with_, |
| 11 | _dbg, mapFilter, safeDecodeURIComponent, stringAfter, onlyTruthy, formatTimestamp, formatSpeed, copyTextToClipboard |
| 12 | } from '@hfs/shared' |
| 13 | import { |
| 14 | NetmaskField, Flex, IconBtn, useBreakpoint, usePauseButton, useToggleButton, Country, |
| 15 | hTooltip, Btn, wikiLink |
| 16 | } from './mui' |
| 17 | import _ from 'lodash' |
| 18 | import { |
| 19 | AutoDelete, LinkOff, ClearAll, Delete, Download, Settings, SmartToy, Terminal, ContentCopy |
| 20 | } from '@mui/icons-material' |
| 21 | import { ConfigForm } from './ConfigForm' |
| 22 | import { BoolField, SelectField } from '@hfs/mui-grid-form' |
| 23 | import { toast, useDialogBarColors } from './dialog' |
| 24 | import { BlockIpBtn } from './blockIp' |
| 25 | import { ALL as COUNTRIES } from './countries' |
| 26 | import { useRoutedTab } from './routing' |
| 27 | |
| 28 | const logLabels = { |
| 29 | log: "Served", |
| 30 | error_log: "Failed", |
| 31 | console: "Console", |
| 32 | disconnections: "Disconnections", |
| 33 | ips: "IPs", |
| 34 | } |
| 35 | const LOG_FILES = typedKeys(logLabels) |
| 36 | |
| 37 | let reloadIps: any |
| 38 | |
| 39 | export default function LogsPage({ setTitleSide }: PageProps) { |
| 40 | const files = LOG_FILES |
| 41 | const [tab, setTab] = useRoutedTab('logs', files) |
| 42 | const shorterLabels = !useBreakpoint('sm') && { error_log: "Not", console: h(Terminal), disconnections: h(LinkOff) } |
| 43 | const file = files[tab] |
| 44 | const fileAvailable = file.endsWith('log') |
| 45 | |
| 46 | const logInfo = useApiEx('get_log_info') |
| 47 | setTitleSide(useMemo(() => fileAvailable && (logInfo.element || with_(logInfo.data, data => |
| 48 | h(Box, { sx: { fontSize: 'smaller' } }, |
| 49 | `Current: ${formatBytes(_.sum(Object.values(data.current)))}`, |
| 50 | h('br'), |
| 51 | with_(Object.values(data.rotated).flat(), rotatedAsArray => |
| 52 | `Archived: ${formatBytes(_.sumBy(rotatedAsArray, 'size'))} / ${rotatedAsArray.length} files`) |
| 53 | ) |
| 54 | )), [logInfo.element, logInfo.data, fileAvailable])) |
| 55 | |
| 56 | return h(Fragment, {}, |
| 57 | h(Flex, { gap: 0 }, |
| 58 | h(Tabs, { value: tab, onChange(ev,i){ setTab(i) } }, |
| 59 | files.map(f => h(Tab, { |
| 60 | label: _.get(shorterLabels, f) || logLabels[f], |
| 61 | key: f, |
| 62 | sx: { minWidth: 0, px: { xs: 1.5, sm: 2 } } // save space |
| 63 | }))), |
| 64 | h(Box, { sx: { flex: 1 } }), |
| 65 | h(IconBtn, { |
| 66 | icon: Download, |
| 67 | title: fileAvailable ? "Download as file" : "Not available", |
| 68 | link: API_URL + `get_log_file?file=${file}`, |
| 69 | disabled: !fileAvailable |
| 70 | }), |
| 71 | h(IconBtn, { icon: Settings, title: "Options", onClick: showLogOptions }) |
| 72 | ), |
| 73 | files.map(f => |
| 74 | h(LogFile, { hidden: file !== f, file: f, key: f, fillFlex: true }) ), |
| 75 | ) |
| 76 | |
| 77 | function showLogOptions() { |
| 78 | newDialog({ |
| 79 | title: "Log options", |
| 80 | dialogProps: { sx: { maxWidth: '40em' } }, |
| 81 | Content() { |
| 82 | return h(ConfigForm, { |
| 83 | barSx: { gap: 2, width: '100%', ...useDialogBarColors() }, |
| 84 | form: { |
| 85 | stickyBar: true, |
| 86 | fields: [ |
| 87 | { k: CFG.log, label: logLabels.log, sm: 6, helperText: "Requests are logged here. Empty to disable it." }, |
| 88 | { k: CFG.error_log, label: logLabels.error_log, sm: 6, placeholder: "errors go to main log", |
| 89 | helperText: "Write errors in a different file. Empty to use same file." |
| 90 | }, |
| 91 | { k: CFG.log_rotation, comp: SelectField, sm: 6, options: [{ value:'', label:"disabled" }, 'daily', 'weekly', 'monthly' ], |
| 92 | helperText: [wikiLink('Logs#rotation', "To keep log-files smaller"), " (deletion is not automatic)"], |
| 93 | }, |
| 94 | { k: CFG.dont_log_net, comp: NetmaskField, label: "Don't log address", sm: 6, placeholder: "no exception" }, |
| 95 | { k: CFG.log_gui, sm: 6, comp: BoolField, label: "Log interface loading", helperText: "Some requests are necessary to load the interface" }, |
| 96 | { k: CFG.log_api, sm: 6, comp: BoolField, label: "Log API requests", helperText: "Requests for commands" }, |
| 97 | { k: CFG.log_ua, sm: 6, comp: BoolField, label: "Log User-Agent", helperText: "Contains browser and possibly OS information. Can double the size of your logs on disk." }, |
| 98 | { k: CFG.log_spam, sm: 6, comp: BoolField, label: "Log spam requests", helperText: md`Spam requests are *failed* requests that you probably don't want to see` }, |
| 99 | { k: CFG.track_ips, sm: 6, comp: BoolField, label: "Keep track of IPs", |
| 100 | parentProps: { sx: { display: 'flex', gap: 1 } }, |
| 101 | after: h(Btn, { |
| 102 | size: 'small', variant: 'outlined', color: 'warning', |
| 103 | confirm: true, doneMessage: true, |
| 104 | onClick: () => apiCall('reset_ips').then(reloadIps) |
| 105 | }, "Reset") |
| 106 | }, |
| 107 | ] |
| 108 | } |
| 109 | }) |
| 110 | } |
| 111 | }) |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | const LOGS_ON_FILE: string[] = [CFG.log, CFG.error_log] |
| 116 | |
| 117 | type LogFileProps = { filter?: (row:any) => boolean, limit?: number, hidden?: boolean, file: string, footerSide?: ReactNode } & Partial<DataTableProps> |
| 118 | export function LogFile({ file, footerSide, hidden, limit, filter, ...rest }: LogFileProps) { |
| 119 | const [showCountry, setShowCountry] = useState(false) |
| 120 | const [showAgent, setShowAgent] = useState(false) |
| 121 | const { pause, pauseButton } = usePauseButton() |
| 122 | const [showApi, showApiButton] = useToggleButton("Show APIs", "Hide APIs", v => ({ |
| 123 | icon: SmartToy, |
| 124 | sx: { rotate: v ? 0 : '180deg' }, |
| 125 | }), true) |
| 126 | const [totalSize, setTotalSize] = useState(NaN) |
| 127 | const [limited, setLimited] = useState(true) |
| 128 | const [skipped, setSkipped] = useState(0) |
| 129 | const MAX = 2**20 // 1MB |
| 130 | const invert = true |
| 131 | const [firstSight, setFirstSight] = useState(!hidden) |
| 132 | useEffect(() => setFirstSight(x => x || !hidden), [hidden]) |
| 133 | const hasFile = LOGS_ON_FILE.includes(file) |
| 134 | useApi(firstSight && hasFile && 'get_log_file', { file, range: limited || !skipped ? String(-MAX) : `0-${skipped}` }, { |
| 135 | skipParse: true, skipLog: true, |
| 136 | onResponse(res, body) { |
| 137 | const lines = body.split('\n') |
| 138 | if (limited) { |
| 139 | const size = Number(splitAt('/', res.headers.get('Content-Range') ||'')?.[1]) |
| 140 | if (isNaN(size)) throw _dbg("shouldn't happen") |
| 141 | setTotalSize(size) |
| 142 | if (body.length >= size) |
| 143 | setLimited(false) |
| 144 | else |
| 145 | setSkipped(size! - body.length + lines.shift().length + 1) |
| 146 | } |
| 147 | else if (skipped) { |
| 148 | toast(`Entire log loaded, ${formatBytes(skipped)}`) |
| 149 | setSkipped(0) |
| 150 | } |
| 151 | const treated = mapFilter(lines, (x: any, i) => enhanceLogLine(parseLogLine(x, i)), Boolean, invert) |
| 152 | setList(x => [...x, ...treated]) |
| 153 | } |
| 154 | }) |
| 155 | const { list, setList, error, connecting, reload } = useApiList(firstSight && 'get_log', { file }, { limit, invert, pause, map: enhanceLogLine }) |
| 156 | const isIps = file === 'ips' |
| 157 | if (isIps) |
| 158 | reloadIps = reload |
| 159 | const tsColumn: DataTableColumn = { |
| 160 | field: 'ts', |
| 161 | headerName: "Timestamp", |
| 162 | type: 'dateTime', |
| 163 | width: 96, |
| 164 | valueGetter: v => new Date(v), |
| 165 | renderCell: ({ value }) => h(Fragment, {}, value.toLocaleDateString(), h('br'), value.toLocaleTimeString()), |
| 166 | } |
| 167 | const ipColumn: DataTableColumn = { |
| 168 | field: 'ip', |
| 169 | headerName: "Address", |
| 170 | flex: .6, |
| 171 | minWidth: 130, |
| 172 | maxWidth: 230, |
| 173 | mergeRender: { |
| 174 | user: { sx: { display: 'flex', justifyContent: 'space-between', gap: '.5em' } }, |
| 175 | country: showCountry && {}, |
| 176 | ua: {}, |
| 177 | }, |
| 178 | } |
| 179 | const rows = useMemo(() => |
| 180 | filter ? list.filter(filter) |
| 181 | : showApi || list?.[0]?.uri === undefined ? list |
| 182 | : list.filter(x => !x.uri.startsWith(API_URL)), |
| 183 | [list, showApi, filter]) |
| 184 | const isConsole = file === 'console' |
| 185 | return hidden ? null : h(DataTable, { |
| 186 | persist: 'log_' + file, |
| 187 | error, |
| 188 | loading: connecting, |
| 189 | rows, |
| 190 | compact: true, |
| 191 | actionsProps: { hideUnder: 'md' }, |
| 192 | actions: isConsole ? undefined : (({ row }) => onlyTruthy([ |
| 193 | h(BlockIpBtn, { ip: row.ip, comment: "From log" }), |
| 194 | isIps && h(Btn, { |
| 195 | icon: Delete, |
| 196 | confirm: true, |
| 197 | title: `Delete ${row.ip}`, |
| 198 | doneMessage: true, |
| 199 | onClick: () => apiCall('delete_ips', { ip: row.ip }).then(() => setList(was => was.filter(x => x.ip !== row.ip))) |
| 200 | }), |
| 201 | isIps && h(Btn, { |
| 202 | icon: AutoDelete, |
| 203 | confirm: true, |
| 204 | title: `Delete all records up to ${formatTimestamp(row.ts)}`, |
| 205 | onClick: () => apiCall('delete_ips', { ts: row.ts }).then(res => toast(`${res.n} deleted`)).then(reload) |
| 206 | }), |
| 207 | hasFile && h(Btn, { |
| 208 | icon: ContentCopy, |
| 209 | title: "Copy request", |
| 210 | onClick() { copyTextToClipboard(JSON.stringify(_.omit(row, 'id'), undefined, 2)) } |
| 211 | }) |
| 212 | ])), |
| 213 | initialState: isIps ? { sorting: { sortModel: [{ field: 'ts', sort: 'desc' }] } } : undefined, |
| 214 | ...rest, |
| 215 | footerSide: width => h(Box, {}, // 4 icons don't fit the tab row on mobile |
| 216 | pauseButton, |
| 217 | file.endsWith('log') && showApiButton, |
| 218 | !connecting && skipped > 0 && h(Btn, { |
| 219 | icon: ClearAll, |
| 220 | variant: 'outlined', |
| 221 | sx: { ml: { sm: 1 } }, |
| 222 | labelIf: width > 700, |
| 223 | title: `Only ${formatBytes(MAX)} was loaded, for speed. Total size is ${formatBytes(totalSize)}`, |
| 224 | loading: !limited, |
| 225 | onClick: () => setLimited(false) |
| 226 | }, "Load whole log"), |
| 227 | footerSide, |
| 228 | ), |
| 229 | columns: isConsole ? [ |
| 230 | tsColumn, |
| 231 | { |
| 232 | field: 'k', |
| 233 | headerName: "Level", |
| 234 | hideUnder: 'sm', |
| 235 | }, |
| 236 | { |
| 237 | field: 'msg', |
| 238 | headerName: "Message", |
| 239 | flex: 1, |
| 240 | mergeRender: { k: { override: { valueFormatter: (value) => value !== 'log' && value } } } |
| 241 | } |
| 242 | ] : isIps || file === 'disconnections' ? [ |
| 243 | tsColumn, |
| 244 | ipColumn, |
| 245 | { |
| 246 | headerName: "Country", |
| 247 | field: 'country', |
| 248 | flex: 1, |
| 249 | hideUnder: !showCountry || 'md', |
| 250 | valueGetter: (value) => _.find(COUNTRIES, { code: value })?.name || value, |
| 251 | renderCell: ({ row }) => h(Country, { code: row.country, long: true, def: '-' }), |
| 252 | }, |
| 253 | !isIps && { |
| 254 | field: 'msg', |
| 255 | headerName: "Message", |
| 256 | flex: 4, |
| 257 | } |
| 258 | ] : [ |
| 259 | ipColumn, |
| 260 | { |
| 261 | headerName: "Country", |
| 262 | field: 'country', |
| 263 | valueGetter: (_value: any, row: any) => row.extra?.country, |
| 264 | hideUnder: !showCountry || 'xl', |
| 265 | renderCell: ({ value }) => h(Country, { code: value, def: '-' }), |
| 266 | }, |
| 267 | { |
| 268 | field: 'user', |
| 269 | headerName: "Username", |
| 270 | flex: .3, |
| 271 | maxWidth: 200, |
| 272 | hideUnder: 'xl', |
| 273 | }, |
| 274 | tsColumn, |
| 275 | { |
| 276 | field: 'method', |
| 277 | headerName: "Method", |
| 278 | width: 80, |
| 279 | hideUnder: 'xl', |
| 280 | }, |
| 281 | { |
| 282 | field: 'status', |
| 283 | headerName: "Code", |
| 284 | type: 'number', |
| 285 | width: 70, |
| 286 | hideUnder: 'xl', |
| 287 | renderCell: ({ value }) => hTooltip(prefix(value + ' - ', httpCodes[value]) || "Unknown", undefined, |
| 288 | h(Box, { sx: { bgcolor: '#888a', color: '#fff', borderRadius: '.3em', p: '.05em .3em', lineHeight: '1.2em' } }, value)) |
| 289 | }, |
| 290 | { |
| 291 | field: 'length', |
| 292 | headerName: "Size", |
| 293 | type: 'number', |
| 294 | hideUnder: 'md', |
| 295 | valueFormatter: (value) => formatBytes(value as number) |
| 296 | }, |
| 297 | { |
| 298 | headerName: "Agent", |
| 299 | field: 'ua', |
| 300 | width: 60, |
| 301 | hideUnder: !showAgent || 'md', |
| 302 | valueGetter: (_value: any, row: any) => row.extra?.ua, |
| 303 | renderCell: ({ value }) => agentIcons(value), |
| 304 | }, |
| 305 | { |
| 306 | field: 'notes', |
| 307 | headerName: "Notes", |
| 308 | width: 110, |
| 309 | hideUnder: 'sm', |
| 310 | cellClassName: 'wrap', |
| 311 | renderCell: ({ value }) => value && h(Box, { sx: { whiteSpace: 'pre-wrap' } }, value), |
| 312 | }, |
| 313 | { |
| 314 | field: 'uri', |
| 315 | headerName: "URI", |
| 316 | flex: 2, |
| 317 | minWidth: 100, |
| 318 | sx: { wordBreak: 'break-all' }, // be flexible, uri can be a mess |
| 319 | mergeRender: { method: {}, status: {} }, |
| 320 | renderCell: ({ value, row }) => { |
| 321 | const [path, query] = splitAt('?', value).map(x => safeDecodeURIComponent(x)) |
| 322 | const ul = row.extra?.ul |
| 323 | if (_.isArray(ul)) |
| 324 | return path + ul.join(' + ') |
| 325 | if (!path.startsWith(API_URL)) |
| 326 | return [path, query && h(Box as any, { key: 0, component: 'span', sx: { color: 'text.secondary', fontSize: 'smaller' } }, '?', query)] |
| 327 | const name = path.slice(API_URL.length) |
| 328 | const params = query && ': ' + Array.from(new URLSearchParams(query)).map(x => `${x[0]}=${tryJson(x[1]) ?? x[1]}`).join(' ; ') |
| 329 | return "API " + name + params |
| 330 | } |
| 331 | }, |
| 332 | { |
| 333 | field: 'agentText', |
| 334 | valueGetter: (_value: any, row: any) => row.extra?.ua, |
| 335 | headerName: "Agent text", |
| 336 | flex: 2, |
| 337 | hideUnder: true, |
| 338 | }, |
| 339 | ] |
| 340 | }) |
| 341 | |
| 342 | function enhanceLogLine(row: any) { |
| 343 | if (!row) return |
| 344 | const { extra } = row |
| 345 | if ((extra?.country || row.country) && !showCountry) |
| 346 | setShowCountry(true) |
| 347 | if (extra?.ua && !showAgent) |
| 348 | setShowAgent(true) |
| 349 | if (row.uri) { |
| 350 | const upload = row.method === 'PUT' || extra?.ul |
| 351 | const partial = upload && stringAfter('?', row.uri).includes('partial=') |
| 352 | if (upload) |
| 353 | row.length = (extra?.size ?? 0) |
| 354 | + (!partial && Number(row.uri.match(/\?.*resume=(\d+)/)?.[1]) || 0) // show full size for full uploads |
| 355 | row.notes = extra?.dl ? "full download " + (extra.speed ? formatSpeed(extra.speed, { sep: ' ' }) : '') // 'dl' here is not the '?dl' of the url, and has a different meaning |
| 356 | : upload ? `${partial ? "partial " : ""} upload ${extra.speed ? formatSpeed(extra.speed, { sep: ' ' }) : ''}` |
| 357 | : row.status === HTTP_UNAUTHORIZED && row.uri?.startsWith(API_URL + 'loginSrp') ? "login failed" + prefix(':\n', extra?.u) |
| 358 | : _.map(extra?.params, (v, k) => `${k}: ${v}\n`).join('') + (row.notes || '') |
| 359 | if (extra?.aborted) |
| 360 | row.notes += ' (aborted)' |
| 361 | } |
| 362 | return row |
| 363 | } |
| 364 | } |
| 365 | |
| 366 | const UW = 'https://upload.wikimedia.org/wikipedia/commons/' |
| 367 | const CLIENT_ICONS = { |
| 368 | Chrome: UW + 'e/e1/Google_Chrome_icon_%28February_2022%29.svg', |
| 369 | Chromium: UW + 'f/fe/Chromium_Material_Icon.svg', |
| 370 | Firefox: UW + 'a/a0/Firefox_logo%2C_2019.svg', |
| 371 | Safari: UW + '../en/7/71/Safari_Liquid_Glass_icon.png', |
| 372 | Edge: UW + '9/98/Microsoft_Edge_logo_%282019%29.svg', |
| 373 | Opera: UW + '4/49/Opera_2015_icon.svg', |
| 374 | Finder: UW + 'thumb/b/b9/Finder_Icon_macOS_Tahoe.png/250px-Finder_Icon_macOS_Tahoe.png', |
| 375 | Cyberduck: UW + 'archive/4/48/20091115091336%21Cyberduck_icon.png', |
| 376 | ForkLift: UW + '../en/9/96/ForkLift_3_File_Manager_and_File_Transfer_Client_Logo.png', |
| 377 | Explorer: UW + '3/33/Microsoft_PowerToys-Logo_File_Explorer_Preview_02.svg', |
| 378 | WinSCP: UW + '4/4f/WinSCP_6_Logo.png', |
| 379 | } |
| 380 | const OS_ICONS = { |
| 381 | Android: UW + 'd/d7/Android_robot.svg', |
| 382 | Linux: UW + '0/0a/Tux-shaded.svg', |
| 383 | Windows: UW + '0/0a/Unofficial_Windows_logo_variant_-_2002%E2%80%932012_%28Multicolored%29.svg', |
| 384 | macOS: UW + '7/74/Apple_logo_dark_grey.svg', // grey works for both themes |
| 385 | iOS: UW + '7/74/Apple_logo_dark_grey.svg', // grey works for both themes |
| 386 | } |
| 387 | const OSS = { |
| 388 | iOS: /iPhone OS|iPad/, |
| 389 | macOS: /Mac OS|Darwin/, |
| 390 | Windows: /Windows NT|^Microsoft-WebDAV|^WinSCP/, |
| 391 | Android: /Android/, |
| 392 | Linux: /Linux/, |
| 393 | } |
| 394 | |
| 395 | export function agentIcons(agent: string | undefined) { |
| 396 | if (!agent) return |
| 397 | const short = shortenAgent(agent) |
| 398 | const browserIcon = h(AgentIcon, { k: short, altText: true, map: CLIENT_ICONS }) |
| 399 | const os = _.findKey(OSS, re => re.test(agent)) |
| 400 | return h(Box, { sx: { fontSize: '110%' } }, browserIcon, ' ', os && osIcon(os as any)) |
| 401 | } |
| 402 | |
| 403 | const alreadyFailed: any = {} |
| 404 | |
| 405 | export function osIcon(k: keyof typeof OS_ICONS) { |
| 406 | return h(AgentIcon, { k, map: OS_ICONS }) |
| 407 | } |
| 408 | |
| 409 | function AgentIcon({ k, map, altText }: { k: string, map: Dict<string>, altText?: boolean }) { |
| 410 | const src = map[k] |
| 411 | const [err, setErr] = useState(alreadyFailed[k]) |
| 412 | return !src || err ? h(Fragment, {}, altText ? k : null) : h('img', { |
| 413 | src, |
| 414 | alt: k + " icon", |
| 415 | title: k, |
| 416 | style: { height: '1.2em', verticalAlign: 'bottom', marginRight: '.2em' }, |
| 417 | onError() { setErr(alreadyFailed[k] = true) } |
| 418 | }) |
| 419 | } |
| 420 | |
| 421 | function parseLogLine(line: string, id: number) { |
| 422 | const m = /^(.+?) (.+?) (.+?) \[(.{11}):(.{14})] "(\w+) ([^"]+) HTTP\/\d.\d" (\d+) (-|\d+) ?(.*)/.exec(line) |
| 423 | if (!m) return |
| 424 | const [, ip, , user, date, time, method, uri, status, length, extra] = m |
| 425 | return { // keep object format same as events emitted by the log module |
| 426 | id, |
| 427 | ip, |
| 428 | user: user === '-' ? undefined : user, |
| 429 | ts: new Date(date + ' ' + time), |
| 430 | method, |
| 431 | uri, |
| 432 | status: Number(status), |
| 433 | length: length === '-' ? undefined : Number(length), |
| 434 | extra: tryJson(tryJson(extra)) || undefined, |
| 435 | } |
| 436 | } |