main
ts 204 lines 9.73 KB
Raw
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 }