@samitouri / QOSami-HFS / commits / ed4fe3a7

fix: admin/options: faulty "listen interface"

Massimo Melina committed Nov 11, 2024 at 19:06 UTC ed4fe3a7ae0ec7741bedb9273b4ae961ca54cb11
2 files changed +45 -31
admin/src/OptionsPage.ts
+22 -15
@@ -6,7 +6,8 @@ import { apiCall, useApiEx } from './api'
6 import { state, useSnapState } from './state'
7 import { Link as RouterLink } from 'react-router-dom'
8 import { CardMembership, EditNote, Refresh, Warning } from '@mui/icons-material'
9 -import { Dict, MAX_TILE_SIZE, REPO_URL, isIpLocalHost, wait, with_, try_, ipForUrl, SORT_BY_OPTIONS, THEME_OPTIONS,
9 +import { adminApis } from '../../src/adminApis'
10 +import { Dict, MAX_TILE_SIZE, REPO_URL, wait, with_, try_, ipForUrl, SORT_BY_OPTIONS, THEME_OPTIONS,
11 CFG, md, IMAGE_FILEMASK } from './misc'
12 import { iconTooltip, InLink, LinkBtn, propsForModifiedValues, wikiLink, useBreakpoint, NetmaskField, WildcardsSupported } from './mui'
13 import { Form, BoolField, NumberField, SelectField, FieldProps, Field, StringField } from '@hfs/mui-grid-form';
@@ -34,7 +35,7 @@ export default function OptionsPage() {
35 const { data, reload: reloadConfig, element } = useApiEx('get_config', { omit: ['vfs'] })
36 const snap = useSnapState()
37 const { changes } = useSnapshot(pageState)
37 - const statusApi = useApiEx(data && 'get_status')
38 + const statusApi = useApiEx<typeof adminApis.get_status>(data && 'get_status')
39 const status = statusApi.data
40 const reloadStatus = exposedReloadStatus = statusApi.reload
41 useEffect(() => void reloadStatus(), [data]) //eslint-disable-line
@@ -43,6 +44,17 @@ export default function OptionsPage() {
44
45 const admins = useApiEx('get_admins').data?.list
46
47 + const hn = window.location.hostname
48 + const isLH = hn === 'localhost'
49 + const isV6 = hn.includes(':')
50 + const listenInterfaceOptions = [
51 + { label: "any", value: '' },
52 + { label: "any IPv4", value: '0.0.0.0', disabled: !isLH && isV6 },
53 + { label: "any IPv6", value: '::', disabled: !isLH && !isV6 },
54 + ...['127.0.0.1', '::1'].map(x => ({ label: x, value: x, disabled: !isLH && hn !== x })),
55 + ...status?.ips?.map(x => ({ value: x, disabled: hn !== x })) || [],
56 + ]
57 +
58 if (element)
59 return element
60 if (statusApi.error)
@@ -106,7 +118,7 @@ export default function OptionsPage() {
118 httpsEnabled && { k: 'cert', comp: FileField, sm: 4, label: "HTTPS certificate file",
119 helperText: wikiLink('HTTPS#certificate', "What is this?"),
120 error: with_(status?.https.error, e => isCertError(e) && (
109 - status.https.listening ? e
121 + status!.https.listening ? e
122 : [e, ' - ', h(LinkBtn, { key: 'fix', onClick: suggestMakingCert }, "make one")] )),
123 },
124 httpsEnabled && { k: 'private_key', comp: FileField, sm: 4, label: "HTTPS private key file",
@@ -121,14 +133,9 @@ export default function OptionsPage() {
133 k: 'listen_interface',
134 comp: SelectField,
135 sm: 4,
124 - options: [
125 - { label: "any", value: '' },
126 - { label: "any IPv4", value: '0.0.0.0' },
127 - { label: "any IPv6", value: '::' },
128 - '127.0.0.1',
129 - '::1',
130 - ...status?.ips || []
131 - ]
136 + afterList: listenInterfaceOptions.some(x => x.disabled)
137 + && h(Box, { p: '8px 16px 0', borderTop: '1px solid', fontSize: 'small' }, "Disabled addresses depend on the address you used to connect"),
138 + options: listenInterfaceOptions,
139 },
140 { k: 'max_kbps', ...maxSpeedDefaults, sm: 4, label: "Limit output", helperText: "Doesn't apply to localhost" },
141 { k: 'max_kbps_per_ip', ...maxSpeedDefaults, sm: 4, label: "Limit output per-IP" },
@@ -266,12 +273,12 @@ export default function OptionsPage() {
273 if (onHttps && certChange && !await confirmDialog("You may disrupt https service, kicking you out"))
274 return
275 await apiCall('set_config', { values: changes })
269 - if (newPort !== undefined || changes.listen_interface && !(loc.hostname === 'localhost' && isIpLocalHost(changes.listen_interface))) {
276 + if (newPort !== undefined) {
277 await alertDialog("You are being redirected but in some cases this may fail. Hold on tight!", 'warning')
271 - const host = ipForUrl(changes.listen_interface || loc.hostname)
278 + const x = ipForUrl(loc.hostname)
279 // we have to jump protocol also in case of random port, because we want people to know their port while using GUI
273 - return window.location.href = newPort <= 0 ? `${onHttps ? 'http:' : 'https:'}//${host}:${otherPort}${loc.pathname}`
274 - : `${loc.protocol}//${host}:${newPort || values[keys[0]]}${loc.pathname}`
280 + return window.location.href = newPort <= 0 ? `${onHttps ? 'http:' : 'https:'}//${x}:${otherPort}${loc.pathname}`
281 + : `${loc.protocol}//${x}:${newPort || values[keys[0]]}${loc.pathname}`
282 }
283 const portChange = 'port' in changes || 'https_port' in changes
284 setTimeout(reloadStatus, portChange || certChange ? 1000 : 0) // give some time to apply news
mui-grid-form/SelectField.ts
+23 -16
@@ -7,11 +7,11 @@ import { FormControl, FormControlLabel, FormLabel, MenuItem, Radio, InputLabel,
7 import { SxProps } from '@mui/system'
8
9 type SelectOptions<T> = { [label:string]: T } | SelectOption<T>[]
10 -type SelectOption<T> = SelectPair<T> | (T extends string | number ? T : never)
11 -interface SelectPair<T> { label: string, value: T }
10 +type SelectOption<T> = SelectOptionNormalized<T> | (T extends string | number ? T : never)
11 +interface SelectOptionNormalized<T> { label?: string, value: T, disabled?: boolean }
12
13 export function SelectField<T>(props: FieldProps<T> & CommonSelectProps<T>) {
14 - const { value, onChange, setApi, options, sx, disabled, ...rest } = props
14 + const { value, onChange, setApi, options, sx, disabled, afterList, ...rest } = props
15 const normalizedOptions = useMemo(() => normalizeOptions(options), [options])
16 const jsonValue = JSON.stringify(value)
17 const currentOption = normalizedOptions?.find(x => JSON.stringify(x.value) === jsonValue)
@@ -21,11 +21,15 @@ export function SelectField<T>(props: FieldProps<T> & CommonSelectProps<T>) {
21 // avoid warning for invalid option. This can easily happen for a split-second when you keep value in a useState (or other async way) and calculate options with a useMemo (or other sync way) causing a temporary misalignment.
22 value: currentOption ? jsonValue : '',
23 disabled: normalizedOptions?.length === 0 || disabled,
24 - children: !normalizedOptions ? h(LinearProgress) : normalizedOptions.map((o, i) => h(MenuItem, {
25 - key: i,
26 - value: JSON.stringify(o?.value),
27 - children: h(Fragment, { key: i }, o?.label) // without this fragment/key, a label as h(span) will produce warnings
28 - })),
24 + children: !normalizedOptions ? h(LinearProgress) : [
25 + ...normalizedOptions.map((o, i) => h(MenuItem, {
26 + key: i,
27 + value: JSON.stringify(o?.value),
28 + disabled: o?.disabled,
29 + children: h(Fragment, { key: i }, o?.label ?? String(o?.value)) // without this fragment/key, a label as h(span) will produce warnings
30 + })),
31 + h('div', { key: -1 }, afterList),
32 + ],
33 ...commonSelectProps(props),
34 ...rest,
35 onChange(event) {
@@ -40,11 +44,11 @@ export function SelectField<T>(props: FieldProps<T> & CommonSelectProps<T>) {
44 }
45
46 type MultiSelectFieldProps<T> = FieldProps<T[]> & CommonSelectProps<T> & {
43 - renderOption?: (option: SelectPair<T>) => ReactNode
47 + renderOption?: (option: SelectOptionNormalized<T>) => ReactNode
48 clearable?: boolean
49 }
50 export function MultiSelectField<T>({ renderOption, ...props }: MultiSelectFieldProps<T>) {
47 - const { value, onChange, setApi, options, placeholder, helperText, label, valueSeparator = ', ', clearable=true, ...rest } = props
51 + const { value, onChange, setApi, options, placeholder, helperText, label, valueSeparator = ', ', clearable=true, afterList, ...rest } = props
52 const normalizedOptions = useMemo(() => normalizeOptions(options), [options])
53 const valueAsOptions = useMemo(() => !Array.isArray(value) ? []
54 : value.map(x => normalizedOptions?.find(o => o.value === x) || { value: x, label: String(x) }),
@@ -53,6 +57,7 @@ export function MultiSelectField<T>({ renderOption, ...props }: MultiSelectField
57 const labelId = useId()
58 const helperId = useId()
59 const showClear = valueAsOptions.length > 0
60 + renderOption ??= x => x.label ?? String(x.value)
61 return h(FormControl, { fullWidth: true, variant: 'filled', hiddenLabel: !label },
62 h(InputLabel, {
63 id: labelId,
@@ -74,9 +79,9 @@ export function MultiSelectField<T>({ renderOption, ...props }: MultiSelectField
79 'aria-describedby': helperId,
80 }),
81 renderValue: () => h('div', {
77 - 'aria-label': label + ': ' + valueAsOptions.map(x => x.label),
82 + 'aria-label': label + ': ' + valueAsOptions.map(x => x.label ?? String(x.value)),
83 style: { overflow: "hidden", display: "flex", flexWrap: "wrap", gap: ".5em" },
79 - children: valueAsOptions.map((x, i) => h('span', { key: i }, renderOption?.(x) ?? x.label, i < valueAsOptions.length - 1 && valueSeparator)),
84 + children: valueAsOptions.map((x, i) => h('span', { key: i }, renderOption!(x), i < valueAsOptions.length - 1 && valueSeparator)),
85 }),
86 ...rest,
87 },
@@ -94,8 +99,9 @@ export function MultiSelectField<T>({ renderOption, ...props }: MultiSelectField
99 }, showClear ? "Unselect all" : "Select all"),
100 ...normalizedOptions?.map(o => h(MenuItem, { value: JSON.stringify(o?.value) }, // encode, as this supports only string|number
101 h(Checkbox, { checked: value?.includes(o.value) || false }),
97 - h(ListItemText, { primary: renderOption?.(o) ?? o.label })
102 + h(ListItemText, { primary: renderOption!(o) })
103 )) || [],
104 + afterList,
105 ),
106 h(FormHelperText, { id: helperId, error: props.error }, helperText),
107 )
@@ -107,6 +113,7 @@ interface CommonSelectProps<T> extends HelperCommon<T> {
113 disabled?: boolean
114 // pass options undefined to display a loading indicator in place of the options
115 options?: SelectOptions<T>
116 + afterList?: ReactNode
117 }
118 function commonSelectProps<T>(props: CommonSelectProps<T>) {
119 return {
@@ -121,11 +128,11 @@ function commonSelectProps<T>(props: CommonSelectProps<T>) {
128 }
129
130 function normalizeOptions<T>(options?: SelectOptions<T>) {
124 - return !options ? undefined : !Array.isArray(options) ? Object.entries(options).map(([label,value]) => ({ value, label }))
125 - : options.map(o => typeof o === 'string' || typeof o === 'number' ? { value: o, label: String(o) } : o as SelectPair<T>)
131 + return !options ? undefined : !Array.isArray(options) ? Object.entries(options).map(([label,value]) => ({ value, label } as SelectOptionNormalized<T>))
132 + : options.map(o => typeof o === 'string' || typeof o === 'number' ? { value: o } : o as SelectOptionNormalized<T>)
133 }
134
128 -export function RadioField<T>({ label, options, value, onChange }: FieldProps<T> & { options:SelectPair<T>[] }) {
135 +export function RadioField<T>({ label, options, value, onChange }: FieldProps<T> & { options:SelectOptionNormalized<T>[] }) {
136 return h(FormControl, {},
137 label && h(FormLabel, {}, label),
138 h(RadioGroup, {