main
ts 436 lines 18.9 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 { 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 }