fix: admin/logs: server-side errors retrieving log were not displayed

Massimo Melina committed Aug 9, 2022 at 12:01 UTC 3bb5c0d707c1a8cb6112d1e8a219cef4c4f37e95
4 files changed +54 -32
admin/src/api.ts
+3 -3
@@ -1,7 +1,7 @@
1 // This file is part of HFS - Copyright 2021-2022, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3 import { createElement as h, useCallback, useEffect, useMemo, useRef } from 'react'
4 -import { Dict, Falsy, getCookie, IconBtn, spinner, useStateMounted } from './misc'
4 +import { Dict, err2msg, Falsy, getCookie, IconBtn, spinner, useStateMounted, wantArray } from './misc'
5 import { Alert } from '@mui/material'
6 import _ from 'lodash'
7 import { state } from './state'
@@ -173,13 +173,13 @@ export function useApiList<T=any>(cmd:string|Falsy, params: Dict={}, { addId=fal
173 case 'msg':
174 if (src?.readyState === src?.CLOSED)
175 return stop()
176 - if (data === 'end') {
176 + if (data === 'ready') {
177 flush()
178 setInitializing(false)
179 return
180 }
181 if (data.error)
182 - return setError(data.error)
182 + return setError(err2msg(data.error))
183 if (data.add) {
184 const rec = map(data.add)
185 if (addId)
server/src/adminApis.ts
+26 -19
@@ -1,6 +1,6 @@
1 // This file is part of HFS - Copyright 2021-2022, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3 -import { ApiError, ApiHandlers } from './apiMiddleware'
3 +import { ApiError, ApiHandlers, SendListReadable } from './apiMiddleware'
4 import { defineConfig, getWholeConfig, setConfig } from './config'
5 import { getStatus, getUrls, httpsPortCfg, portCfg } from './listen'
6 import {
@@ -23,7 +23,6 @@ import _ from 'lodash'
23 import events from './events'
24 import { getFromAccount } from './perm'
25 import Koa from 'koa'
26 -import { Readable } from 'stream'
26 import { getProxyDetected } from './middlewares'
27 import { writeFile } from 'fs/promises'
28 import { createReadStream } from 'fs'
@@ -91,23 +90,31 @@ export const adminApis: ApiHandlers = {
90 },
91
92 async get_log({ file }, ctx) {
94 - const logger = loggers.find(l => l.name === file)
95 - if (!logger)
96 - return new ApiError(404)
97 - const ret = new Readable({ objectMode: true, read(){} })
98 - const input = createReadStream(logger.path)
99 - readline.createInterface({ input }).on('line', line => {
100 - if (ctx.aborted)
101 - return input.close()
102 - ret.push({ add: parse(line) })
103 - }).on('close', () => // file is automatically closed, so we continue by events
104 - ctx.res.once('close', onOff(events, { // unsubscribe when connection is interrupted
105 - [logger.name](entry) {
106 - ret.push({ add: entry })
107 - }
108 - })))
109 -
110 - return ret
93 + return new SendListReadable(list => {
94 + const logger = loggers.find(l => l.name === file)
95 + if (!logger)
96 + return list.error(404)
97 + const input = createReadStream(logger.path)
98 + input.on('error', async (e: any) => {
99 + if (e.code !== 'ENOENT') // ignore ENOENT, consider it an empty log
100 + return list.error(e.code || e.message)
101 + })
102 + input.on('ready', () => {
103 + list.ready()
104 + readline.createInterface({ input }).on('line', line => {
105 + if (ctx.aborted)
106 + return input.close()
107 + const obj = parse(line)
108 + if (obj)
109 + list.add(obj)
110 + }).on('close', () => // file is automatically closed, so we continue by events
111 + ctx.res.once('close', onOff(events, { // unsubscribe when connection is interrupted
112 + [logger.name](entry) {
113 + list.add(entry)
114 + }
115 + })) )
116 + })
117 + })
118
119 function parse(line: string) {
120 const m = /^(.+?) - (.+?) \[(.{11}):(.{14})] "(\w+) ([^"]+) HTTP\/\d.\d" (\d+) (.+)$/.exec(line)
server/src/api.plugins.ts
+1 -1
@@ -110,7 +110,7 @@ const apis: ApiHandlers = {
110 catch (err: any) {
111 list.error(err.code || err.message)
112 }
113 - list.end()
113 + list.ready()
114 })
115 return list
116 },
server/src/apiMiddleware.ts
+24 -9
@@ -32,8 +32,13 @@ export function apiMiddleware(apis: ApiHandlers) : Koa.Middleware {
32 : await apis[ctx.path](params || {}, ctx)
33 if (isAsyncGenerator(res))
34 res = asyncGeneratorToReadable(res)
35 - if (res instanceof Readable) // Readable, we'll go SSE-mode
36 - return res.pipe(createSSE(ctx))
35 + if (res instanceof Readable) { // Readable, we'll go SSE-mode
36 + res.pipe(createSSE(ctx))
37 + const stillRes = res // satisfy ts
38 + ctx.req.on('close', () => // by closing the generated stream, creator of the stream will know the request is over without having to access anything else
39 + stillRes.destroy())
40 + return
41 + }
42 if (res instanceof ApiError) {
43 ctx.body = res.message
44 return ctx.status = res.status
@@ -68,15 +73,22 @@ async function getJsonFromReq(req: IncomingMessage): Promise<any> {
73 }
74
75 // offer an api for a generic dynamic list. Suitable to be the result of an api.
76 +type SendListFunc<T> = (list:SendListReadable<T>) => void
77 export class SendListReadable<T> extends Readable {
78 protected lastError: string | number | undefined
73 - constructor(addAtStart?: T[]) {
79 + constructor(addOrDoAtStart?: T[] | SendListFunc<T>) {
80 super({ objectMode: true, read(){} })
75 - if (addAtStart) {
76 - for (const x of addAtStart)
77 - this.add(x)
78 - this.end()
81 + this.on('end', () =>
82 + this.destroy())
83 + if (!addOrDoAtStart)
84 + return
85 + if (typeof addOrDoAtStart === 'function') {
86 + setTimeout(() => addOrDoAtStart(this))
87 + return
88 }
89 + for (const x of addOrDoAtStart)
90 + this.add(x)
91 + this.ready()
92 }
93 add(rec: T) {
94 this.push({ add: rec })
@@ -90,8 +102,8 @@ export class SendListReadable<T> extends Readable {
102 close() {
103 this.push(null)
104 }
93 - end() { // useful to indicate the end of an initial phase, but we leave open for updates
94 - this.push('end')
105 + ready() { // useful to indicate the end of an initial phase, but we leave open for updates
106 + this.push('ready')
107 }
108 error(msg: NonNullable<typeof this.lastError>) {
109 this.push({ error: msg })
@@ -108,4 +120,7 @@ export class SendListReadable<T> extends Readable {
120 ctx.res.once('close', off)
121 return this
122 }
123 + isClosed() {
124 + return this.destroyed
125 + }
126 }