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