admin/plugins/installed: dedicated view for plugin's log #911
Massimo Melina committed
Feb 20, 2025 at 20:02 UTC
21cc34693b4914ed4f5ccf3ed8fd5d20e136ef97
5 files changed
+79
-17
admin/src/InstalledPlugins.ts
+55
-14
@@ -2,12 +2,14 @@
2
3
import { apiCall, useApiEx, useApiList } from './api'
4
import { createElement as h, Fragment, useEffect } from 'react'
5
-import { Box, Link } from '@mui/material'
5
+import { Box, Breakpoint, Link, Paper, useTheme } from '@mui/material'
6
import { DataTable, DataTableColumn } from './DataTable'
7
import {
8
- Clear, Delete, Error as ErrorIcon, FormatPaint as ThemeIcon, PlayCircle, Settings, StopCircle, Upgrade
8
+ Clear, Delete, Error as ErrorIcon, FormatPaint as ThemeIcon, ListAlt, PlayCircle, Settings, StopCircle, Upgrade
9
} from '@mui/icons-material'
10
-import { CFG, Html, HTTP_FAILED_DEPENDENCY, md, newObj, prefix, with_, xlate } from './misc'
10
+import {
11
+ CFG, Html, HTTP_FAILED_DEPENDENCY, md, newObj, prefix, with_, xlate, formatTime, formatDate, replaceStringToReact
12
+} from './misc'
13
import { alertDialog, confirmDialog, formDialog, toast } from './dialog'
14
import _ from 'lodash'
15
import { Account } from './AccountsPage'
@@ -15,7 +17,7 @@ import { BoolField, Field, FieldProps, MultiSelectField, NumberField, SelectFiel
17
import { ArrayField } from './ArrayField'
18
import FileField from './FileField'
19
import { PLUGIN_ERRORS } from './PluginsPage'
18
-import { Btn, hTooltip, IconBtn, iconTooltip, usePauseButton } from './mui'
20
+import { Btn, Flex, hTooltip, IconBtn, iconTooltip, usePauseButton } from './mui'
21
import VfsPathField from './VfsPathField'
22
23
export default function InstalledPlugins({ updates }: { updates?: true }) {
@@ -28,6 +30,7 @@ export default function InstalledPlugins({ updates }: { updates?: true }) {
30
const { pause, pauseButton } = usePauseButton("plugins", () => getSingleConfig(CFG.suspend_plugins).then(x => !x), {
31
onClick: () => apiCall('set_config', { values: { [CFG.suspend_plugins]: !pause } })
32
})
33
+ const theme = useTheme()
34
return h(DataTable, {
35
error: xlate(error, PLUGIN_ERRORS),
36
rows: list.length ? list : [], // workaround for DataGrid bug causing 'no rows' message to be not displayed after 'loading' was also used
@@ -90,28 +93,66 @@ export default function InstalledPlugins({ updates }: { updates?: true }) {
93
onClick: () => startPlugin(id),
94
}),
95
h(IconBtn, {
93
- icon: Settings,
94
- title: "Options",
96
+ icon: row.config || !row.started ? Settings : ListAlt,
97
+ title: row.config ? "Options" : "Log",
98
size,
96
- disabled: !row.started && "Start plugin to access options"
97
- || !row.config && "No options available for this plugin",
99
+ disabled: !row.started && "Start plugin to access options",
100
async onClick() {
101
const { config: lastSaved } = await apiCall('get_plugin', { id })
102
+ // support css values without having to wrap in sx, as in DialogProps it only supports breakpoints
103
+ let maxWidth = with_(row.configDialog, x => theme.breakpoints.values[x?.maxWidth as Breakpoint] || x?.sx?.maxWidth || xlate(x?.maxWidth, { xs: 0 }) || 432)
104
+ if (typeof maxWidth === 'number') // @ts-ignore
105
+ maxWidth += 'px'
106
+ const showOptions = Boolean(row.config)
107
const values = await formDialog({
101
- title: `Options for ${id}`,
108
+ title: showOptions ? `Options for ${id}` : `Log for ${id}`,
109
form: values => ({
103
- before: h(Box, { mx: 2, mb: 3 }, row.description),
104
- fields: makeFields(row.config, values),
105
- save: { children: "Save and close" },
110
+ before: row.description && h(Box, { mx: 2, mb: 2 }, row.description),
111
+ fields: makeFields(row.config || {}, values),
112
+ save: showOptions ? { children: "Save and close" } : false,
113
barSx: { gap: 1 },
114
addToBar: [h(Btn, { variant: 'outlined', onClick: () => save(values) }, "Save")],
115
}),
116
values: lastSaved,
117
dialogProps: _.merge({ maxWidth: 'md', sx: { m: 'auto' } }, // center content when it is smaller than mobile (because of full-screen)
118
row.configDialog,
112
- // this makes maxWidth support css values without having to wrap in sx, as in DialogProps it only supports breakpoints
113
- with_(row.configDialog?.maxWidth, x => x?.length === 2 ? { maxWidth: x } : x ? { maxWidth: false, sx: { maxWidth: x } } : null),
119
+ { maxWidth: false, sx: { maxWidth: null } }, // cancel maxWidth to move it to the Box below
120
),
121
+ Wrapper({ children }: any) {
122
+ const { list } = useApiList('get_plugin_log', { id }, {
123
+ invert: true,
124
+ map(x) { x.ts = new Date(x.ts) }
125
+ })
126
+ let lastDate: any
127
+ return h(Flex, { alignItems: 'stretch', justifyContent: 'center', flexWrap: 'wrap', flexDirection: showOptions ? undefined : 'column' },
128
+ h(Box, { maxWidth, minWidth: 'min-content' /*in case content requires more space (eg: reverse-proxy's table)*/ }, children),
129
+ list.length > 0 ? h(Paper, { elevation: 1, sx: { position: 'relative', fontFamily: 'monospace', flex: 1, minWidth: 'min(40em, 90vw)', minHeight: '20em', px: .5 } },
130
+ h(Box, { my: .5, pb: .5, borderBottom: '1px solid' }, "Output (last on top)"),
131
+ h(Box, { position: 'absolute', bottom: 0, top: '1.8em', left: 0, right: 0, sx: { overflowY: 'auto' } },
132
+ h(Box, {
133
+ sx: {
134
+ textIndent: '-1em', pl: '1em',
135
+ position: 'absolute', width: 'calc(100% - 1.2em)', ml: '2px', pt: '.2em',
136
+ }
137
+ }, list.map(x => {
138
+ formatDate(x.ts)
139
+ const thisDate = formatDate(x.ts)
140
+ return h(Fragment, { key: x.id },
141
+ thisDate !== lastDate && (lastDate = thisDate),
142
+ h(Box, {},
143
+ h(Box, { title: thisDate, display: 'inline', color: 'text.secondary', mr: 1 }, formatTime(x.ts)),
144
+ replaceStringToReact(x.msg, /https?:\/\/\S+/, m => h(Link, {
145
+ href: m[0],
146
+ target: '_blank'
147
+ }, m[0])) // make links clickable
148
+ )
149
+ )
150
+ }
151
+ ))
152
+ )
153
+ ) : showOptions ? null : h(Box, { p: '1em', pt: 0 }, "Log is empty")
154
+ )
155
+ }
156
})
157
if (values && !_.isEqual(lastSaved, values))
158
return save(values)
admin/src/api.ts
+1
-1
@@ -38,7 +38,7 @@ export function useApiEx<T=any>(...args: Parameters<typeof useApi>) {
38
}
39
}
40
41
-export function useApiList<T=any, S=T>(cmd:string|Falsy, params: Dict={}, { map, invert, pause, limit }: { limit?: number, pause?: boolean, invert?: boolean, map?: (rec: S) => T }={}) {
41
+export function useApiList<T=any, S=T>(cmd:string|Falsy, params: Dict={}, { map, invert, pause, limit }: { limit?: number, pause?: boolean, invert?: boolean, map?: (rec: S) => unknown }={}) {
42
const [list, setList] = useStateMounted<T[]>([])
43
const [props, setProps] = useStateMounted<any>(undefined)
44
const [error, setError] = useStateMounted<any>(undefined)
admin/src/dialog.ts
+3
-2
@@ -147,9 +147,10 @@ type FormDialog<T> = Omit<FormProps<T>, 'values' | 'save' | 'set'>
147
before?: any
148
}
149
export async function formDialog<T>(
150
- { form, values, ...options }: Omit<DialogOptions, 'Content'> & {
150
+ { form, values, Wrapper, ...options }: Omit<DialogOptions, 'Content'> & {
151
values?: Partial<T>,
152
form: FormDialog<T> | ((values: Partial<T>) => FormDialog<T>), // allow callback form
153
+ Wrapper?: FC
154
},
155
) : Promise<T> {
156
return new Promise(resolve => {
@@ -160,7 +161,7 @@ export async function formDialog<T>(
161
Content() {
162
const [curValues, setCurValues] = useState<Partial<T>>(values||{})
163
const { onChange, before, ...props } = typeof form === 'function' ? form(curValues) : form
163
- return h(Fragment, {},
164
+ return h(Wrapper || Fragment, {},
165
before,
166
h(Form, {
167
...props,
src/api.plugins.ts
+10
@@ -170,6 +170,16 @@ const apis: ApiHandlers = {
170
return {}
171
},
172
173
+ get_plugin_log({ id }, ctx) {
174
+ const p = getPluginInfo(id)
175
+ if (!p)
176
+ return new ApiError(HTTP_NOT_FOUND)
177
+ const list = new SendListReadable({ addAtStart: p.log })
178
+ return list.events(ctx, {
179
+ ['pluginLog:' + id]: x => list.add(x)
180
+ })
181
+ },
182
+
183
}
184
185
export default apis
src/plugins.ts
+10
@@ -239,10 +239,12 @@ type OnDirEntry = (params:OnDirEntryParams) => void | false
239
export class Plugin implements CommonPluginInterface {
240
started: Date | null = new Date()
241
icons: CustomizedIcons
242
+ log: { ts: Date, msg: string }[]
243
244
constructor(readonly id:string, readonly folder:string, private readonly data:any, private onUnload:()=>unknown){
245
if (!data) throw 'invalid data'
246
247
+ this.log = []
248
this.data = data = { ...data } // clone to make object modifiable. Objects coming from import are not.
249
// some validation
250
for (const k of ['frontend_css', 'frontend_js']) {
@@ -503,6 +505,7 @@ function watchPlugin(id: string, path: string) {
505
await mkdir(storageDir, { recursive: true })
506
const openDbs: KvStorage[] = []
507
const subbedConfigs: Callback[] = []
508
+ const pluginReady = pendingPromise()
509
await initPlugin(pluginData, { // following properties are not available in server_code
510
id,
511
srcDir: __dirname,
@@ -516,6 +519,12 @@ function watchPlugin(id: string, path: string) {
519
},
520
log(...args: any[]) {
521
console.log('plugin', id+':', ...args)
522
+ pluginReady.then(() => { // log() maybe invoked during init(), while plugin is undefined
523
+ if (!plugin) return
524
+ plugin.log.unshift({ ts: new Date, msg: args.map(x => x && typeof x === 'object' ? JSON.stringify(x) : String(x)).join(' ') })
525
+ plugin.log.length = Math.min(100, plugin.log.length) // truncate
526
+ events.emit('pluginLog:' + id, plugin.log[0])
527
+ })
528
},
529
getConfig(cfgKey?: string) {
530
const cur = pluginsConfig.get()?.[id]
@@ -555,6 +564,7 @@ function watchPlugin(id: string, path: string) {
564
await Promise.allSettled(openDbs.map(x => x.close()))
565
openDbs.length = 0
566
})
567
+ pluginReady.resolve()
568
if (alreadyRunning)
569
events.emit('pluginUpdated', Object.assign(_.pick(plugin, 'started'), getPluginInfo(id)))
570
else {