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, {