| 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 './misc' |
| 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, FormApi |
| 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 | // array fields contain a DataGrid whose intrinsic width settles in steps, so give the form a stable preferred width |
| 25 | const workaround = _.some(callable(row.config, lastSaved), { type: 'array' }) ? `min(100%, ${maxWidth})` : undefined |
| 26 | const apiRef = { current: undefined as FormApi | undefined } |
| 27 | // support css values without having to wrap in sx, as in DialogProps it only supports breakpoints |
| 28 | const showOptions = Boolean(row.config) |
| 29 | const values = await formDialog({ |
| 30 | title: showOptions ? `Options for ${id}` : `Log for ${id}`, |
| 31 | form: values => ({ |
| 32 | before: row.description && h(Box, { sx: { mx: 2, mb: 2 } }, row.description), |
| 33 | fields: makeFields(callable(row.config, values) || {}, values), |
| 34 | save: showOptions ? { children: "Save and close" } : false, |
| 35 | barSx: { gap: 1 }, |
| 36 | apiRef, |
| 37 | addToBar: [h(Btn, { |
| 38 | variant: 'outlined', |
| 39 | async onClick() { |
| 40 | // this action must reuse form validation without falling through to the dialog-closing submit path |
| 41 | if (await apiRef.current?.validate()) |
| 42 | await save(values) |
| 43 | } |
| 44 | }, "Save")], |
| 45 | }), |
| 46 | values: lastSaved, |
| 47 | dialogProps: _.merge({ maxWidth: 'md', sx: { m: 'auto' } }, // center content when it is smaller than mobile (because of full-screen) |
| 48 | row.configDialog, |
| 49 | { maxWidth: false, sx: { maxWidth: null } }, // cancel maxWidth to move it to the Box below |
| 50 | ), |
| 51 | Wrapper({ children }: any) { |
| 52 | const { list, setList } = useApiList('get_plugin_log', { id }, { |
| 53 | map(x) { x.ts = new Date(x.ts) } |
| 54 | }) |
| 55 | let lastDate: any |
| 56 | return h(Flex, { alignItems: 'stretch', justifyContent: 'center', flexWrap: 'wrap', flexDirection: showOptions ? undefined : 'column' }, |
| 57 | h(Box, { sx: { |
| 58 | maxWidth, |
| 59 | width: workaround, |
| 60 | minWidth: 'min-content', // in case content requires more space (eg: reverse-proxy's table) |
| 61 | } }, children), |
| 62 | h(Paper, { elevation: 1, sx: { position: 'relative', fontFamily: 'monospace', flex: 1, minWidth: 'min(40em, 90vw)', minHeight: '20em', px: .5 } }, |
| 63 | h(Box, { sx: { my: .5, pb: .5, borderBottom: '1px solid', display: 'flex', alignItems: 'center', justifyContent: 'space-between' } }, |
| 64 | "Output", |
| 65 | h(Btn, { size: 'small', sx: { p: 0 }, onClick() { setList([]) } }, "Clear") |
| 66 | ), |
| 67 | h(Box, { |
| 68 | ref: useAutoScroll(list), |
| 69 | sx: { position: 'absolute', bottom: 0, top: '31px', left: 0, right: 0, overflowY: 'auto' } |
| 70 | }, |
| 71 | !list.length && h(Box, { sx: { p: 1 } }, "Log is empty"), |
| 72 | h(Box, { |
| 73 | sx: { |
| 74 | textIndent: '-1em', pl: '1em', |
| 75 | position: 'absolute', width: 'calc(100% - 1.2em)', ml: '2px', pt: '.2em', |
| 76 | } |
| 77 | }, list.map(x => { |
| 78 | formatDate(x.ts) |
| 79 | const thisDate = formatDate(x.ts) |
| 80 | return h(Fragment, { key: x.id }, |
| 81 | thisDate !== lastDate && (lastDate = thisDate), |
| 82 | h(Box, {}, |
| 83 | h(Box, { title: thisDate, sx: { display: 'inline', color: 'text.secondary', mr: 1 } }, formatTime(x.ts)), |
| 84 | replaceStringToReact(x.msg, /https?:\/\/\S+/, m => h(Link, { |
| 85 | href: m[0], |
| 86 | target: '_blank' |
| 87 | }, m[0])) // make links clickable |
| 88 | ) |
| 89 | ) |
| 90 | })) |
| 91 | ) |
| 92 | ) |
| 93 | ) |
| 94 | } |
| 95 | }) |
| 96 | if (values && !_.isEqual(lastSaved, values)) |
| 97 | return save(values) |
| 98 | |
| 99 | async function save(values: any) { |
| 100 | await apiCall('set_plugin', { id, config: values }) |
| 101 | Object.assign(lastSaved, values) |
| 102 | toast("Configuration saved") |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | function makeFields(config: any, values: any) { |
| 107 | return Object.entries(config).map(([k,o]: [string,any]) => { |
| 108 | if (!o) return |
| 109 | let { type, defaultValue, frontend, showIf, ...rest } = o |
| 110 | try { |
| 111 | rest.getError = evalWrapper(rest.getError) |
| 112 | if (typeof showIf === 'string') |
| 113 | o.showIf = // compile once |
| 114 | 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. |
| 115 | if (showIf && !showIf(values)) |
| 116 | return |
| 117 | } |
| 118 | catch {} |
| 119 | rest.helperText &&= md(rest.helperText, { html: false }) |
| 120 | const comp = (type2comp as any)[type] as Field<any> | undefined |
| 121 | if (values === false && type === 'date_time') |
| 122 | rest.$type = 'dateTime' |
| 123 | if (comp === ArrayField) { |
| 124 | let {fields} = rest |
| 125 | rest.valuesForAdd = newObj(callable(fields, false), x => x.defaultValue) |
| 126 | if (typeof fields === 'string') |
| 127 | fields = evalWrapper(fields) |
| 128 | rest.details ??= false |
| 129 | rest.fields = (values: unknown) => _.map(makeFields(callable(fields, values), values), (v,k) => v && ({ k, ...v, defaultValue: undefined })).filter(Boolean) |
| 130 | } |
| 131 | if (defaultValue !== undefined && type === 'boolean') |
| 132 | rest.placeholder = `Default value is ${JSON.stringify(defaultValue)}` |
| 133 | return { k, comp, ...rest } |
| 134 | }) |
| 135 | } |
| 136 | |
| 137 | // centralize usage of eval get a single warning at build time |
| 138 | export function evalWrapper(s: string) { |
| 139 | return (0, eval)(s) |
| 140 | } |
| 141 | |
| 142 | const type2comp = { |
| 143 | string: StringField, |
| 144 | number: NumberField, |
| 145 | boolean: BoolField, |
| 146 | select: SelectField, |
| 147 | multiselect: MultiSelectField, |
| 148 | array: ArrayField, |
| 149 | real_path: FileField, |
| 150 | vfs_path: VfsPathField, |
| 151 | username: UsernameField, |
| 152 | color: ColorField, |
| 153 | show_html: ({ html }: any) => h(Html, {}, String(html)), |
| 154 | date_time: DateTimeField, |
| 155 | net_mask: NetmaskField, |
| 156 | } |
| 157 | ;(type2comp as any).showHtml = type2comp.show_html // legacy pre 3.1.0 |
| 158 | |
| 159 | function UsernameField({ value, onChange, multiple, groups, ...rest }: FieldProps<string>) { |
| 160 | const { data, element, loading } = useApiEx<typeof adminApis.get_accounts>('get_accounts') |
| 161 | const list = useMemo(() => data && _.sortBy(data.list, [x => !x.isGroup, x => !x.adminActualAccess, 'username']), [data]) |
| 162 | type UsernameOption = { value: string, label: string, a?: Account } // an account may be passed as value but not exist (anymore) |
| 163 | return (!loading || !data) && element || h((multiple ? MultiSelectField : SelectField) as Field<string>, { |
| 164 | value, onChange, |
| 165 | options: list?.filter(x => groups === undefined || groups === x.isGroup).map(a => ({ value: a.username, label: a.username, a })), |
| 166 | renderOption: (x: UsernameOption) => { |
| 167 | if (!x.a) |
| 168 | return h('span', { style: { textDecoration: 'line-through' } }, x.label) |
| 169 | const icon = x.a.isGroup && account2icon(x.a) || x.a.adminActualAccess && iconTooltip(MilitaryTech, "Can login into Admin") |
| 170 | return !icon ? x.label |
| 171 | : h('span', {}, |
| 172 | h('span', { style: { marginLeft: -8, marginRight: 8 } }, icon), |
| 173 | x.label) |
| 174 | }, |
| 175 | ...rest, |
| 176 | }) |
| 177 | } |
| 178 | |
| 179 | function ColorField(rest: FieldProps<string>) { |
| 180 | return h(StringField, { |
| 181 | inputProps: { type: 'color', style: { marginRight: 24 }, ...!rest.value && { value: '#888888', style: { zIndex: 1, opacity: .1 } } }, |
| 182 | InputProps: { endAdornment: rest.value ? h(Btn, { |
| 183 | icon: Clear, |
| 184 | size: 'small', |
| 185 | sx: { position: 'absolute', right: 4 }, |
| 186 | title: "Clear", |
| 187 | onClick(event) { |
| 188 | rest.onChange(null as any, { was: rest.value, event: event }) |
| 189 | } |
| 190 | }) : h(Box, { |
| 191 | sx: { |
| 192 | position: 'absolute', |
| 193 | width: '100%', |
| 194 | bottom: 2, |
| 195 | pt: '3px', |
| 196 | textAlign: 'center', |
| 197 | color: '#fff', |
| 198 | background: 'repeating-linear-gradient(45deg, #333, #333 10px, #444 10px, #444 20px)', |
| 199 | } |
| 200 | }, "default") }, |
| 201 | typing: true, |
| 202 | ...rest, |
| 203 | }) |
| 204 | } |