@samitouri / QOSami-HFS / commits / 3c15e3a6

better code: refactored large function

Massimo Melina committed Mar 26, 2026 at 09:48 UTC 3c15e3a6ca1b3deedb8eefe8948d09526608bb32
2 files changed +201 -181
admin/src/InstalledPlugins.ts
+13 -181
@@ -1,31 +1,20 @@
1 // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3 -import { apiCall, useApiEx, useApiList } from './api'
4 -import { createElement as h, Fragment, useEffect, useMemo, useState } from 'react'
5 -import { Account, account2icon } from './AccountsPage'
6 -import { Box, Breakpoint, Link, Paper, Table, TableCell, TableRow, useTheme } from '@mui/material'
3 +import { apiCall, useApiList } from './api'
4 +import { createElement as h, Fragment, useEffect, useState } from 'react'
5 +import { Box, Breakpoint, Link, Table, TableCell, TableRow, useTheme } from '@mui/material'
6 import { DataTable, DataTableColumn } from './DataTable'
7 import {
9 - Clear, Delete, Error as ErrorIcon, FormatPaint as ThemeIcon, ListAlt, MilitaryTech, PlayCircle, Settings, StopCircle, Upgrade
8 + Delete, Error as ErrorIcon, FormatPaint as ThemeIcon, ListAlt, PlayCircle, Settings, StopCircle, Upgrade
9 } from '@mui/icons-material'
10 import {
12 - CFG, Html, HTTP_FAILED_DEPENDENCY, md, newObj, prefix, with_, xlate, formatTime, formatDate, replaceStringToReact,
13 - callable, tryJson, useAutoScroll, NBSP, isPrimitive, HIDE_IN_TESTS, wait
11 + CFG, HTTP_FAILED_DEPENDENCY, md, prefix, with_, xlate, tryJson, NBSP, isPrimitive, HIDE_IN_TESTS, wait
12 } from './misc'
15 -import { alertDialog, confirmDialog, formDialog, toast } from './dialog'
13 +import { alertDialog, confirmDialog, toast } from './dialog'
14 import _ from 'lodash'
17 -import { adminApis } from '../../src/adminApis'
18 -import { BoolField, Field, FieldProps, MultiSelectField, NumberField, SelectField, StringField } from '@hfs/mui-grid-form'
19 -import { ArrayField } from './ArrayField'
20 -import FileField from './FileField'
15 import { PLUGIN_ERRORS } from './PluginsPage'
22 -import { Btn, Flex, hTooltip, IconBtn, iconTooltip, NetmaskField, usePauseButton } from './mui'
23 -import VfsPathField from './VfsPathField'
24 -import { DateTimeField } from './DateTimeField'
25 -
26 -function evalWrapper(s: string) {
27 - return eval(s)
28 -}
16 +import { Btn, hTooltip, IconBtn, iconTooltip, usePauseButton } from './mui'
17 +import { showPluginOptions, evalWrapper } from './pluginOptions'
18
19 // updates=true will show the "check updates" version of the page
20 export default function InstalledPlugins({ updates }: { updates?: true }) {
@@ -141,77 +130,13 @@ export default function InstalledPlugins({ updates }: { updates?: true }) {
130 size,
131 disabled: !row.started && "Start plugin to access options"
132 || !row.config && !row.log && "No options and no log for this plugin",
144 - async onClick() {
145 - const { config: lastSaved } = await apiCall('get_plugin', { id })
146 - // support css values without having to wrap in sx, as in DialogProps it only supports breakpoints
147 - let maxWidth = with_(row.configDialog, x => theme.breakpoints.values[x?.maxWidth as Breakpoint] || x?.sx?.maxWidth || xlate(x?.maxWidth, { xs: 0 }) || 432)
133 + onClick() {
134 + const cd = row.configDialog
135 + // support css values for maxWidth without having to wrap in sx, as in DialogProps it only supports breakpoints
136 + let maxWidth = theme.breakpoints.values[cd?.maxWidth as Breakpoint] || cd?.sx?.maxWidth || xlate(cd?.maxWidth, { xs: 0 }) || 432
137 if (typeof maxWidth === 'number') // @ts-ignore
138 maxWidth += 'px'
150 - const showOptions = Boolean(row.config)
151 - const values = await formDialog({
152 - title: showOptions ? `Options for ${id}` : `Log for ${id}`,
153 - form: values => ({
154 - before: row.description && h(Box, { mx: 2, mb: 2 }, row.description),
155 - fields: makeFields(callable(row.config, values) || {}, values),
156 - save: showOptions ? { children: "Save and close" } : false,
157 - barSx: { gap: 1 },
158 - addToBar: [h(Btn, { variant: 'outlined', onClick: () => save(values) }, "Save")],
159 - }),
160 - values: lastSaved,
161 - dialogProps: _.merge({ maxWidth: 'md', sx: { m: 'auto' } }, // center content when it is smaller than mobile (because of full-screen)
162 - row.configDialog,
163 - { maxWidth: false, sx: { maxWidth: null } }, // cancel maxWidth to move it to the Box below
164 - ),
165 - Wrapper({ children }: any) {
166 - const { list, setList } = useApiList('get_plugin_log', { id }, {
167 - map(x) { x.ts = new Date(x.ts) }
168 - })
169 - const autoScroll = useAutoScroll(list)
170 - let lastDate: any
171 - return h(Flex, { alignItems: 'stretch', justifyContent: 'center', flexWrap: 'wrap', flexDirection: showOptions ? undefined : 'column' },
172 - h(Box, { maxWidth, minWidth: 'min-content' /*in case content requires more space (eg: reverse-proxy's table)*/ }, children),
173 - h(Paper, { elevation: 1, sx: { position: 'relative', fontFamily: 'monospace', flex: 1, minWidth: 'min(40em, 90vw)', minHeight: '20em', px: .5 } },
174 - h(Box, { my: .5, pb: .5, borderBottom: '1px solid', display: 'flex', alignItems: 'center', justifyContent: 'space-between' },
175 - "Output",
176 - h(Btn, { size: 'small', sx: { p: 0 }, onClick() { setList([]) } }, "Clear")
177 - ),
178 - h(Box, {
179 - position: 'absolute', bottom: 0, top: '31px', left: 0, right: 0, sx: { overflowY: 'auto' },
180 - ref: autoScroll,
181 - },
182 - !list.length && h(Box, { p: 1 }, "Log is empty"),
183 - h(Box, {
184 - sx: {
185 - textIndent: '-1em', pl: '1em',
186 - position: 'absolute', width: 'calc(100% - 1.2em)', ml: '2px', pt: '.2em',
187 - }
188 - }, list.map(x => {
189 - formatDate(x.ts)
190 - const thisDate = formatDate(x.ts)
191 - return h(Fragment, { key: x.id },
192 - thisDate !== lastDate && (lastDate = thisDate),
193 - h(Box, {},
194 - h(Box, { title: thisDate, display: 'inline', color: 'text.secondary', mr: 1 }, formatTime(x.ts)),
195 - replaceStringToReact(x.msg, /https?:\/\/\S+/, m => h(Link, {
196 - href: m[0],
197 - target: '_blank'
198 - }, m[0])) // make links clickable
199 - )
200 - )
201 - }))
202 - )
203 - )
204 - )
205 - }
206 - })
207 - if (values && !_.isEqual(lastSaved, values))
208 - return save(values)
209 -
210 - async function save(values: any) {
211 - await apiCall('set_plugin', { id, config: values })
212 - Object.assign(lastSaved, values)
213 - toast("Configuration saved")
214 - }
139 + return showPluginOptions(row, maxWidth)
140 }
141 }),
142 h(IconBtn, {
@@ -260,52 +185,6 @@ export function renderName({ row, value }: any) {
185 }
186 }
187
263 -function makeFields(config: any, values: any) {
264 - return Object.entries(config).map(([k,o]: [string,any]) => {
265 - if (!o) return
266 - let { type, defaultValue, frontend, showIf, ...rest } = o
267 - try {
268 - rest.getError = evalWrapper(rest.getError)
269 - if (typeof showIf === 'string') // compile once
270 - rest.showIf = showIf = evalWrapper(showIf) // eval is normally considered a threat, but this code is coming from a plugin that's already running on your server, so you already decided to trust it. Here it will run in your browser, and inside the page that administrating the same server.
271 - if (showIf && !showIf(values))
272 - return
273 - }
274 - catch {}
275 - rest.helperText &&= md(rest.helperText, { html: false })
276 - const comp = (type2comp as any)[type] as Field<any> | undefined
277 - if (values === false && type === 'date_time')
278 - rest.$type = 'dateTime'
279 - if (comp === ArrayField) {
280 - let {fields} = rest
281 - rest.valuesForAdd = newObj(callable(fields, false), x => x.defaultValue)
282 - if (typeof fields === 'string')
283 - fields = evalWrapper(fields)
284 - rest.details ??= false
285 - rest.fields = (values: unknown) => _.map(makeFields(callable(fields, values), values), (v,k) => v && ({ k, ...v, defaultValue: undefined })).filter(Boolean)
286 - }
287 - if (defaultValue !== undefined && type === 'boolean')
288 - rest.placeholder = `Default value is ${JSON.stringify(defaultValue)}`
289 - return { k, comp, ...rest }
290 - })
291 -}
292 -
293 -const type2comp = {
294 - string: StringField,
295 - number: NumberField,
296 - boolean: BoolField,
297 - select: SelectField,
298 - multiselect: MultiSelectField,
299 - array: ArrayField,
300 - real_path: FileField,
301 - vfs_path: VfsPathField,
302 - username: UsernameField,
303 - color: ColorField,
304 - showHtml: ({ html }: any) => h(Html, {}, String(html)),
305 - date_time: DateTimeField,
306 - net_mask: NetmaskField,
307 -}
308 -
188 export async function startPlugin(id: string) {
189 try {
190 await apiCall('start_plugin', { id })
@@ -317,53 +196,6 @@ export async function startPlugin(id: string) {
196 }
197 }
198
320 -function UsernameField({ value, onChange, multiple, groups, ...rest }: FieldProps<string>) {
321 - const { data, element, loading } = useApiEx<typeof adminApis.get_accounts>('get_accounts')
322 - const list = useMemo(() => data && _.sortBy(data.list, [x => !x.isGroup, x => !x.adminActualAccess, 'username']), [data])
323 - type UsernameOption = { value: string, label: string, a: Account }
324 - return !loading && element || h((multiple ? MultiSelectField : SelectField) as Field<string>, {
325 - value, onChange,
326 - options: list?.filter(x => groups === undefined || groups === x.isGroup).map(a => ({ value: a.username, label: a.username, a })),
327 - renderOption: (x: UsernameOption) => {
328 - const icon = x.a.isGroup && account2icon(x.a) || x.a.adminActualAccess && iconTooltip(MilitaryTech, "Can login into Admin")
329 - if (!icon)
330 - return x.label
331 - return !icon ? x.label
332 - : h('span', {},
333 - h('span', { style: { marginLeft: -8, marginRight: 8 } }, icon),
334 - x.label)
335 - },
336 - ...rest,
337 - })
338 -}
339 -
340 -function ColorField(rest: FieldProps<string>) {
341 - return h(StringField, {
342 - inputProps: { type: 'color', style: { marginRight: 24 }, ...!rest.value && { value: '#888888', style: { zIndex: 1, opacity: .1 } } },
343 - InputProps: { endAdornment: rest.value ? h(Btn, {
344 - icon: Clear,
345 - size: 'small',
346 - sx: { position: 'absolute', right: 4 },
347 - title: "Clear",
348 - onClick(event) {
349 - rest.onChange(null as any, { was: rest.value, event: event })
350 - }
351 - }) : h(Box, {
352 - sx: {
353 - position: 'absolute',
354 - width: '100%',
355 - bottom: 2,
356 - pt: '3px',
357 - textAlign: 'center',
358 - color: '#fff',
359 - background: 'repeating-linear-gradient(45deg, #333, #333 10px, #444 10px, #444 20px)',
360 - }
361 - }, "default") },
362 - typing: true,
363 - ...rest,
364 - })
365 -}
366 -
199 export const descriptionField: DataTableColumn = {
200 field: 'description',
201 mergeRender: { isTheme: {} } ,
admin/src/pluginOptions.ts new
+188
@@ -0,0 +1,188 @@
1 +import { apiCall } from '@hfs/shared/api'
2 +import { createElement as h, Fragment, useMemo } from 'react'
3 +import { Box, Link, Paper } from '@mui/material'
4 +import { callable, formatDate, formatTime, newObj } from '../../src/cross'
5 +import { Btn, Flex, iconTooltip, NetmaskField } from './mui'
6 +import { MilitaryTech, Clear } from '@mui/icons-material'
7 +import { Html, md, replaceStringToReact, useAutoScroll } from '@hfs/shared'
8 +import {
9 + BoolField, Field, FieldProps, MultiSelectField, NumberField, SelectField, StringField
10 +} from '@hfs/mui-grid-form'
11 +import { ArrayField } from './ArrayField'
12 +import _ from 'lodash'
13 +import FileField from './FileField'
14 +import VfsPathField from './VfsPathField'
15 +import { DateTimeField } from './DateTimeField'
16 +import { formDialog, toast } from './dialog'
17 +import { useApiEx, useApiList } from './api'
18 +import { adminApis } from '../../src/adminApis'
19 +import { Account, account2icon } from './AccountsPage'
20 +
21 +export async function showPluginOptions(row: any, maxWidth: string) {
22 + const {id} = row
23 + const { config: lastSaved } = await apiCall('get_plugin', { id })
24 + // support css values without having to wrap in sx, as in DialogProps it only supports breakpoints
25 + const showOptions = Boolean(row.config)
26 + const values = await formDialog({
27 + title: showOptions ? `Options for ${id}` : `Log for ${id}`,
28 + form: values => ({
29 + before: row.description && h(Box, { mx: 2, mb: 2 }, row.description),
30 + fields: makeFields(callable(row.config, values) || {}, values),
31 + save: showOptions ? { children: "Save and close" } : false,
32 + barSx: { gap: 1 },
33 + addToBar: [h(Btn, { variant: 'outlined', onClick: () => save(values) }, "Save")],
34 + }),
35 + values: lastSaved,
36 + dialogProps: _.merge({ maxWidth: 'md', sx: { m: 'auto' } }, // center content when it is smaller than mobile (because of full-screen)
37 + row.configDialog,
38 + { maxWidth: false, sx: { maxWidth: null } }, // cancel maxWidth to move it to the Box below
39 + ),
40 + Wrapper({ children }: any) {
41 + const { list, setList } = useApiList('get_plugin_log', { id }, {
42 + map(x) { x.ts = new Date(x.ts) }
43 + })
44 + const autoScroll = useAutoScroll(list)
45 + let lastDate: any
46 + return h(Flex, { alignItems: 'stretch', justifyContent: 'center', flexWrap: 'wrap', flexDirection: showOptions ? undefined : 'column' },
47 + h(Box, { maxWidth, minWidth: 'min-content' /*in case content requires more space (eg: reverse-proxy's table)*/ }, children),
48 + h(Paper, { elevation: 1, sx: { position: 'relative', fontFamily: 'monospace', flex: 1, minWidth: 'min(40em, 90vw)', minHeight: '20em', px: .5 } },
49 + h(Box, { my: .5, pb: .5, borderBottom: '1px solid', display: 'flex', alignItems: 'center', justifyContent: 'space-between' },
50 + "Output",
51 + h(Btn, { size: 'small', sx: { p: 0 }, onClick() { setList([]) } }, "Clear")
52 + ),
53 + h(Box, {
54 + position: 'absolute', bottom: 0, top: '31px', left: 0, right: 0, sx: { overflowY: 'auto' },
55 + ref: autoScroll,
56 + },
57 + !list.length && h(Box, { p: 1 }, "Log is empty"),
58 + h(Box, {
59 + sx: {
60 + textIndent: '-1em', pl: '1em',
61 + position: 'absolute', width: 'calc(100% - 1.2em)', ml: '2px', pt: '.2em',
62 + }
63 + }, list.map(x => {
64 + formatDate(x.ts)
65 + const thisDate = formatDate(x.ts)
66 + return h(Fragment, { key: x.id },
67 + thisDate !== lastDate && (lastDate = thisDate),
68 + h(Box, {},
69 + h(Box, { title: thisDate, display: 'inline', color: 'text.secondary', mr: 1 }, formatTime(x.ts)),
70 + replaceStringToReact(x.msg, /https?:\/\/\S+/, m => h(Link, {
71 + href: m[0],
72 + target: '_blank'
73 + }, m[0])) // make links clickable
74 + )
75 + )
76 + }))
77 + )
78 + )
79 + )
80 + }
81 + })
82 + if (values && !_.isEqual(lastSaved, values))
83 + return save(values)
84 +
85 + async function save(values: any) {
86 + await apiCall('set_plugin', { id, config: values })
87 + Object.assign(lastSaved, values)
88 + toast("Configuration saved")
89 + }
90 +}
91 +
92 +function makeFields(config: any, values: any) {
93 + return Object.entries(config).map(([k,o]: [string,any]) => {
94 + if (!o) return
95 + let { type, defaultValue, frontend, showIf, ...rest } = o
96 + try {
97 + rest.getError = evalWrapper(rest.getError)
98 + if (typeof showIf === 'string') // compile once
99 + rest.showIf = showIf = evalWrapper(showIf) // eval is normally considered a threat, but this code is coming from a plugin that's already running on your server, so you already decided to trust it. Here it will run in your browser, and inside the page that administrating the same server.
100 + if (showIf && !showIf(values))
101 + return
102 + }
103 + catch {}
104 + rest.helperText &&= md(rest.helperText, { html: false })
105 + const comp = (type2comp as any)[type] as Field<any> | undefined
106 + if (values === false && type === 'date_time')
107 + rest.$type = 'dateTime'
108 + if (comp === ArrayField) {
109 + let {fields} = rest
110 + rest.valuesForAdd = newObj(callable(fields, false), x => x.defaultValue)
111 + if (typeof fields === 'string')
112 + fields = evalWrapper(fields)
113 + rest.details ??= false
114 + rest.fields = (values: unknown) => _.map(makeFields(callable(fields, values), values), (v,k) => v && ({ k, ...v, defaultValue: undefined })).filter(Boolean)
115 + }
116 + if (defaultValue !== undefined && type === 'boolean')
117 + rest.placeholder = `Default value is ${JSON.stringify(defaultValue)}`
118 + return { k, comp, ...rest }
119 + })
120 +}
121 +
122 +// centralize usage of eval get a single warning at build time
123 +export function evalWrapper(s: string) {
124 + return eval(s)
125 +}
126 +
127 +const type2comp = {
128 + string: StringField,
129 + number: NumberField,
130 + boolean: BoolField,
131 + select: SelectField,
132 + multiselect: MultiSelectField,
133 + array: ArrayField,
134 + real_path: FileField,
135 + vfs_path: VfsPathField,
136 + username: UsernameField,
137 + color: ColorField,
138 + showHtml: ({ html }: any) => h(Html, {}, String(html)),
139 + date_time: DateTimeField,
140 + net_mask: NetmaskField,
141 +}
142 +
143 +function UsernameField({ value, onChange, multiple, groups, ...rest }: FieldProps<string>) {
144 + const { data, element, loading } = useApiEx<typeof adminApis.get_accounts>('get_accounts')
145 + const list = useMemo(() => data && _.sortBy(data.list, [x => !x.isGroup, x => !x.adminActualAccess, 'username']), [data])
146 + type UsernameOption = { value: string, label: string, a: Account }
147 + return !loading && element || h((multiple ? MultiSelectField : SelectField) as Field<string>, {
148 + value, onChange,
149 + options: list?.filter(x => groups === undefined || groups === x.isGroup).map(a => ({ value: a.username, label: a.username, a })),
150 + renderOption: (x: UsernameOption) => {
151 + const icon = x.a.isGroup && account2icon(x.a) || x.a.adminActualAccess && iconTooltip(MilitaryTech, "Can login into Admin")
152 + if (!icon)
153 + return x.label
154 + return !icon ? x.label
155 + : h('span', {},
156 + h('span', { style: { marginLeft: -8, marginRight: 8 } }, icon),
157 + x.label)
158 + },
159 + ...rest,
160 + })
161 +}
162 +
163 +function ColorField(rest: FieldProps<string>) {
164 + return h(StringField, {
165 + inputProps: { type: 'color', style: { marginRight: 24 }, ...!rest.value && { value: '#888888', style: { zIndex: 1, opacity: .1 } } },
166 + InputProps: { endAdornment: rest.value ? h(Btn, {
167 + icon: Clear,
168 + size: 'small',
169 + sx: { position: 'absolute', right: 4 },
170 + title: "Clear",
171 + onClick(event) {
172 + rest.onChange(null as any, { was: rest.value, event: event })
173 + }
174 + }) : h(Box, {
175 + sx: {
176 + position: 'absolute',
177 + width: '100%',
178 + bottom: 2,
179 + pt: '3px',
180 + textAlign: 'center',
181 + color: '#fff',
182 + background: 'repeating-linear-gradient(45deg, #333, #333 10px, #444 10px, #444 20px)',
183 + }
184 + }, "default") },
185 + typing: true,
186 + ...rest,
187 + })
188 +}