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 {