admin/logs

Massimo Melina committed Apr 6, 2022 at 12:12 UTC f6c7f4b849b04f242678350c21ce0b21998ea662
11 files changed +163 -21
admin/src/ConfigPage.ts
+6 -2
@@ -17,6 +17,11 @@ let exposedReloadStatus: undefined | (() => void)
17
18 subscribeKey(state, 'config', recalculateChanges)
19
20 +export const logLabels = {
21 + log: "Access log file",
22 + error_log: "Error log file"
23 +}
24 +
25 export default function ConfigPage() {
26 const [res, reloadConfig] = useApiComp('get_config', { omit: ['vfs'] })
27 let snap = useSnapState()
@@ -75,8 +80,7 @@ export default function ConfigPage() {
80 values.https_port >= 0 && { k: 'private_key', label: "HTTPS private key file" },
81 { k: 'max_kbps', ...maxSpeedDefaults, label: "Limit output KB/s", helperText: "Doesn't apply to localhost" },
82 { k: 'max_kbps_per_ip', ...maxSpeedDefaults, label: "Limit output KB/s per-ip" },
78 - { k: 'log', label: "Main log file" },
79 - { k: 'error_log', label: "Error log file" },
83 + ...Object.entries(logLabels).map(a => ({ k: a[0], label: a[1] })),
84 { k: 'log_rotation', comp: SelectField, options: [{ value:'', label:"disabled" }, 'daily', 'weekly', 'monthly' ],
85 helperText: "To avoid an endlessly-growing single log file, you can opt for rotation"
86 },
admin/src/LogsPage.ts new
+73
@@ -0,0 +1,73 @@
1 +import { createElement as h, Fragment, useState } from 'react';
2 +import { Tab, Tabs } from '@mui/material'
3 +import { useApiList } from './api'
4 +import { DataGrid } from '@mui/x-data-grid'
5 +import { formatBytes } from '@hfs/shared'
6 +import { logLabels } from './ConfigPage'
7 +import { typedKeys } from './misc';
8 +
9 +export default function LogsPage() {
10 + const [tab, setTab] = useState(0)
11 + const files = typedKeys(logLabels)
12 + return h(Fragment, {},
13 + h(Tabs, { value: tab, onChange(ev,i){ setTab(i) } },
14 + files.map(f => h(Tab, { label: logLabels[f], key: f })) ),
15 + h(LogFile, { key: tab, file: files[tab] }), // without key, some state is unwantedly preserved across files
16 + )
17 +}
18 +
19 +function LogFile({ file }: { file: string }) {
20 + const { list, error } = useApiList('get_log', { file }, { addId: true })
21 + if (error)
22 + return error
23 + return h(DataGrid, {
24 + loading: !list,
25 + autoHeight: true,
26 + rows: list as any,
27 + pageSize: 10,
28 + rowsPerPageOptions: [5, 10, 20, 100],
29 + componentsProps: {
30 + pagination: {
31 + showFirstButton: true,
32 + showLastButton: true,
33 + }
34 + },
35 + columns: [
36 + {
37 + field: 'ip',
38 + headerName: "Address",
39 + flex: 1,
40 + minWidth: 100,
41 + maxWidth: 400,
42 + },
43 + {
44 + field: 'ts',
45 + headerName: "Timestamp",
46 + type: 'dateTime',
47 + width: 200,
48 + valueFormatter: ({ value }) => new Date(value as string).toLocaleString()
49 + },
50 + {
51 + field: 'method',
52 + headerName: "Method",
53 + },
54 + {
55 + field: 'code',
56 + headerName: "Code",
57 + type: 'number',
58 + },
59 + {
60 + field: 'size',
61 + headerName: "Size",
62 + type: 'number',
63 + valueFormatter: ({ value }) => formatBytes(value as number)
64 + },
65 + {
66 + field: 'uri',
67 + headerName: "URI",
68 + flex: 1,
69 + minWidth: 100,
70 + },
71 + ]
72 + })
73 +}
admin/src/MainMenu.ts
+13 -2
@@ -2,7 +2,16 @@
2
3 import { createElement as h, FC } from 'react';
4 import { List, ListItemButton, ListItemIcon, ListItemText, Typography } from '@mui/material'
5 -import { AccountTree, Logout, ManageAccounts, Monitor, Public, Settings, SvgIconComponent } from '@mui/icons-material'
5 +import {
6 + AccountTree,
7 + History,
8 + Logout,
9 + ManageAccounts,
10 + Monitor,
11 + Public,
12 + Settings,
13 + SvgIconComponent
14 +} from '@mui/icons-material'
15 import _ from 'lodash'
16 import { NavLink } from 'react-router-dom'
17 import MonitorPage from './MonitorPage'
@@ -11,6 +20,7 @@ import VfsPage from './VfsPage';
20 import AccountsPage from './AccountsPage';
21 import HomePage from './HomePage'
22 import LogoutPage from './LogoutPage';
23 +import LogsPage from './LogsPage';
24
25 interface MenuEntry {
26 path: string
@@ -26,7 +36,8 @@ export const mainMenu: MenuEntry[] = [
36 { path: 'accounts', icon: ManageAccounts, comp: AccountsPage },
37 { path: 'monitor', icon: Monitor, comp: MonitorPage },
38 { path: 'configuration', icon: Settings, comp: ConfigPage },
29 - { path: 'Logout', icon: Logout, comp: LogoutPage }
39 + { path: 'logs', icon: History, comp: LogsPage },
40 + { path: 'logout', icon: Logout, comp: LogoutPage }
41 ]
42
43 export default function Menu({ onSelect }: { onSelect: ()=>void }) {
admin/src/MonitorPage.ts
+7 -4
@@ -83,14 +83,15 @@ function Connections() {
83 {
84 field: 'started',
85 headerName: "Started",
86 + type: 'dateTime',
87 width: 130,
87 - valueGetter: ({ value }) => new Date(value).toLocaleTimeString()
88 + valueFormatter: ({ value }) => new Date(value as string).toLocaleTimeString()
89 },
90 {
91 field: 'path',
92 headerName: "File",
93 flex: 1,
93 - renderCell: ({ value }) => {
94 + renderCell({ value }) {
95 if (!value) return
96 const i = value?.lastIndexOf('/')
97 return h(Fragment, {}, value.slice(i + 1),
@@ -110,12 +111,14 @@ function Connections() {
111 {
112 field: 'outSpeed',
113 headerName: "Speed",
113 - valueGetter: ({ value }) => value ? formatBytes(value * 1000, "B/s", 1000) : ''
114 + type: 'number',
115 + valueFormatter: ({ value }) => value ? formatBytes(value as number * 1000, "B/s", 1000) : ''
116 },
117 {
118 field: 'sent',
119 headerName: "Total",
118 - valueGetter: ({ value }) => formatBytes(value)
120 + type: 'number',
121 + valueFormatter: ({ value }) => formatBytes(value as number)
122 },
123 {
124 field: "Actions ",
admin/src/api.ts
+10 -5
@@ -94,13 +94,14 @@ function addCsrf(params?: Dict) {
94 return csrf ? { csrf, ...params } : params
95 }
96
97 -export function useApiList<Record>(cmd:string|Falsy, params: Dict={}) {
98 - const [list, setList] = useStateMounted<Record[]>([])
97 +export function useApiList<T=any>(cmd:string|Falsy, params: Dict={}, { addId=false, map=((x:any)=>x) }={}) {
98 + const [list, setList] = useStateMounted<T[]>([])
99 const [error, setError] = useStateMounted<any>(undefined)
100 const [loading, setLoading] = useStateMounted(false)
101 + const idRef = useRef(0)
102 useEffect(() => {
103 if (!cmd) return
103 - const buffer: Record[] = []
104 + const buffer: T[] = []
105 const flush = () => {
106 const chunk = buffer.splice(0, Infinity)
107 if (chunk.length)
@@ -120,8 +121,12 @@ export function useApiList<Record>(cmd:string|Falsy, params: Dict={}) {
121 case 'msg':
122 if (src?.readyState === src?.CLOSED)
123 return stop()
123 - if (data.add)
124 - return buffer.push(data.add)
124 + if (data.add) {
125 + const rec = map(data.add)
126 + if (addId)
127 + rec.id = ++idRef.current
128 + return buffer.push(rec)
129 + }
130 if (data.remove) {
131 const matchOnList: ReturnType<typeof _.matches>[] = []
132 // first remove from the buffer
admin/src/misc.ts
+4
@@ -64,3 +64,7 @@ export async function manipulateConfig(k: string, work:(data:any) => any) {
64 if (JSON.stringify(was) !== JSON.stringify(will))
65 await apiCall('set_config', { values: { [k]: will } })
66 }
67 +
68 +export function typedKeys<T>(o: T) {
69 + return Object.keys(o) as (keyof T)[]
70 +}
server/src/adminApis.ts
+37 -2
@@ -7,7 +7,7 @@ import { BUILD_TIMESTAMP, FORBIDDEN, HFS_STARTED, VERSION } from './const'
7 import vfsApis from './api.vfs'
8 import accountsApis from './api.accounts'
9 import { Connection, getConnections } from './connections'
10 -import { isLocalHost, onOffMap, pendingPromise } from './misc'
10 +import { isLocalHost, onOff, pendingPromise } from './misc'
11 import _ from 'lodash'
12 import events from './events'
13 import { getFromAccount } from './perm'
@@ -15,6 +15,9 @@ import Koa from 'koa'
15 import { Readable } from 'stream'
16 import { getProxyDetected } from './middlewares'
17 import { writeFile } from 'fs/promises'
18 +import { createReadStream } from 'fs'
19 +import * as readline from 'readline'
20 +import { loggers } from './log'
21
22 export const adminApis: ApiHandlers = {
23
@@ -73,7 +76,7 @@ export const adminApis: ApiHandlers = {
76 for (const conn of getConnections())
77 ret.push({ add: serializeConnection(conn) })
78 // then send updates
76 - const off = onOffMap(events, {
79 + const off = onOff(events, {
80 connection: conn => ret.push({ add: serializeConnection(conn) }),
81 connectionClosed(conn: Connection) {
82 ret.push({ remove: [ serializeConnection(conn, true) ] })
@@ -113,6 +116,38 @@ export const adminApis: ApiHandlers = {
116 await writeFile(files.private_key, private_key)
117 await writeFile(files.cert, cert)
118 return files
119 + },
120 +
121 + async get_log({ file }, ctx) {
122 + const logger = loggers.find(l => l.name === file)
123 + if (!logger)
124 + return new ApiError(404)
125 + const ret = new Readable({ objectMode: true, read(){} })
126 + const input = createReadStream(logger.path)
127 + readline.createInterface({ input }).on('line', line => {
128 + if (ctx.aborted)
129 + return input.close()
130 + ret.push({ add: parse(line) })
131 + }).on('close', () => // file is automatically closed, so we continue by events
132 + ctx.res.once('close', onOff(events, { // unsubscribe when connection is interrupted
133 + [logger.name](entry) {
134 + ret.push({ add: entry })
135 + }
136 + })))
137 +
138 + return ret
139 +
140 + function parse(line: string) {
141 + const m = /^(.+) - - \[(.{11}):(.{14})] "(\w+) ([^"]+) HTTP\/\d.\d" (\d+) (.+)$/.exec(line)
142 + return m && { // keep object format same as events emitted by the log module
143 + ip: m[1],
144 + ts: new Date(m[2] + ' ' + m[3]),
145 + method: m[4],
146 + uri: m[5],
147 + code: Number(m[6]),
148 + size: m[7] === '-' ? undefined : Number(m[7])
149 + }
150 + }
151 }
152 }
153
server/src/config.ts
+2 -2
@@ -5,7 +5,7 @@ import { argv } from './const'
5 import { watchLoad } from './watchLoad'
6 import yaml from 'yaml'
7 import _ from 'lodash'
8 -import { debounceAsync, objSameKeys, onOffMap } from './misc'
8 +import { debounceAsync, objSameKeys, onOff } from './misc'
9 import { exists } from 'fs'
10 import { promisify } from 'util'
11
@@ -57,7 +57,7 @@ export function subscribeConfig<T>({ k, ...definition }:{ k:string } & Partial<C
57 if (v !== undefined)
58 cb(v)
59 }
60 - return onOffMap(cfgEvents, { [eventName]: cb })
60 + return onOff(cfgEvents, { [eventName]: cb })
61 }
62
63 export function getConfig(k:string) {
server/src/log.ts
+10 -2
@@ -7,12 +7,17 @@ import { createWriteStream } from 'fs'
7 import * as util from 'util'
8 import { rename, stat } from 'fs/promises'
9 import { DAY } from './const'
10 +import events from './events'
11 +import _ from 'lodash'
12
13 class Logger {
14 stream?: Writable
15 last?: Date
16 path: string = ''
17
18 + constructor(readonly name: string){
19 + }
20 +
21 async setPath(path: string) {
22 this.path = path
23 this.stream?.end()
@@ -32,8 +37,10 @@ class Logger {
37 }
38 }
39
35 -const accessLogger = new Logger()
36 -const errorLogger = new Logger()
40 +// we'll have names same as config keys. These are used also by the get_log api.
41 +const accessLogger = new Logger('log')
42 +const errorLogger = new Logger('error_log')
43 +export const loggers = [accessLogger, errorLogger]
44
45 subscribeConfig({ k: 'log', defaultValue: 'access.log' }, path => {
46 console.debug('log file: ' + (path || 'disabled'))
@@ -77,6 +84,7 @@ export function log(): Koa.Middleware {
84 logger.last = now
85 const format = '%s - - [%s] "%s %s HTTP/%s" %d %s\n';
86 const date = a[2]+'/'+a[1]+'/'+a[3]+':'+a[4]+' '+a[5].slice(3)
87 + events.emit(logger.name, Object.assign(_.pick(ctx, ['ip', 'method','status','length']), { ts: now, uri: ctx.path }))
88 logger.stream!.write(util.format( format,
89 ctx.ip,
90 date,
server/src/misc.ts
+1 -1
@@ -149,7 +149,7 @@ export function pendingPromise<T>() {
149 }
150
151 // install multiple handlers and returns a handy 'uninstall' function which requires no parameter. Pass a map {event:handler}
152 -export function onOffMap(em: EventEmitter, events: { [eventName:string]: (...args: any[]) => void }) {
152 +export function onOff(em: EventEmitter, events: { [eventName:string]: (...args: any[]) => void }) {
153 events = { ...events } // avoid later modifications, as we need this later for uninstallation
154 for (const k in events)
155 em.on(k, events[k])
todo.md
-1
@@ -1,7 +1,6 @@
1 # To do
2 - watch certificates for change
3 - admin/fs: render virtual folders differently
4 -- admin/logs
4 - admin/config: hide advanced settings
5 - admin/fs: drag&drop to move items around
6 - admin/fs: support insert/delete key