admin/options: edit config file
Massimo Melina committed
Oct 7, 2023 at 15:46 UTC
1664a4c9c5ded410923ea80e5f6b9f3da3c97631
9 files changed
+138
-32
admin/package.json
+1
@@ -29,6 +29,7 @@
29
"valtio": "^1.11.2",
30
"immer": "^9.0.15",
31
"usehooks-ts": "^2.9.1",
32
+ "watch-size": "^2.0.0",
33
"web-vitals": "^2.1.4"
34
},
35
"devDependencies": {
admin/src/App.ts
+9
-3
@@ -11,6 +11,8 @@ import { LoginRequired } from './LoginRequired'
11
import { Menu } from '@mui/icons-material'
12
import { LocalizationProvider } from '@mui/x-date-pickers'
13
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'
14
+import ConfigFilePage from './ConfigFilePage'
15
+import { useSnapState } from './state'
16
17
function App() {
18
return h(ThemeProvider, { theme: useMyTheme() },
@@ -35,7 +37,8 @@ function ApplyTheme(props:any) {
37
function Routed() {
38
const loc = useLocation().pathname.slice(1)
39
const current = mainMenu.find(x => x.path === loc)
38
- const title = current && (current.title || getMenuLabel(current))
40
+ let { title } = useSnapState()
41
+ title = current && (current.title || getMenuLabel(current)) || title
42
const [open, setOpen] = useState(false)
43
const large = useBreakpoint('lg')
44
return h(Fragment, {},
@@ -62,8 +65,11 @@ function Routed() {
65
}
66
},
67
title && large && h(Typography, { variant:'h2', mb:2 }, title),
65
- h(Routes, {}, mainMenu.map((it,idx) =>
66
- h(Route, { key: idx, path: it.path, element: h(it.comp) })) )
68
+ h(Routes, {},
69
+ mainMenu.map((it,idx) =>
70
+ h(Route, { key: idx, path: it.path, element: h(it.comp) })),
71
+ h(Route, { path: 'edit', element: h(ConfigFilePage) })
72
+ )
73
),
74
h(Dialogs)
75
)
admin/src/ConfigFilePage.ts
new
+69
@@ -0,0 +1,69 @@
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 { createElement as h, Fragment, useEffect, useRef, useState } from 'react';
4
+import { apiCall, useApiEx } from './api'
5
+import { Alert, Box } from '@mui/material'
6
+import { Btn, Flex, IconBtn, isCtrlKey, KeepInScreen, modifiedSx, reloadBtn } from './misc';
7
+import { Save, ContentCopy, EditNote } from '@mui/icons-material'
8
+import { TextEditor } from './TextEditor';
9
+import { state } from './state';
10
+import { DisplayField } from '@hfs/mui-grid-form'
11
+import { toast } from './dialog';
12
+
13
+export default function ConfigFilePage() {
14
+ state.title = "Config file"
15
+ const { data, reload, element } = useApiEx('get_config_text', {})
16
+ const [text, setText] = useState<string | undefined>()
17
+ const [saved, setSaved] = useState<string | undefined>()
18
+ const [edit, setEdit] = useState(false)
19
+ useEffect(() => { setSaved(data?.text) }, [data])
20
+ useEffect(() => { saved !== undefined && setText(saved || '') }, [saved])
21
+ return h(Fragment, {},
22
+ h(Flex, { alignItems: 'center' },
23
+ h(Btn, { icon: ContentCopy, onClick: copy }, "Copy excluding passwords"),
24
+ edit ? h(Fragment, {},
25
+ reloadBtn(reload),
26
+ h(IconBtn, {
27
+ icon: Save,
28
+ title: "Save\n(ctrl+enter)",
29
+ sx: modifiedSx(text !== saved),
30
+ onClick: save,
31
+ }),
32
+ h(Alert, { severity: 'warning', sx: { flex: 1 } }, "Be careful, you can easily break things here"),
33
+ ) : h(Btn, {
34
+ icon: EditNote,
35
+ variant: 'outlined',
36
+ onClick() {
37
+ setEdit(true)
38
+ const el = document.querySelector('main textarea')
39
+ //@ts-ignore
40
+ setTimeout(() => el.focus(), 500)
41
+ }
42
+ }, "Edit"),
43
+ h(Box, { flex: 1}, h(DisplayField, { label: "File path", value: data?.fullPath }))
44
+ ),
45
+ element || text !== undefined && // avoids bad undo behaviour on start
46
+ h(KeepInScreen, { margin: 10 }, h(TextEditor, {
47
+ value: text,
48
+ disabled: !edit,
49
+ style: { background: '#8881' },
50
+ onValueChange: setText,
51
+ onKeyDown(ev) {
52
+ if (['s','Enter'].includes(isCtrlKey(ev) as any)) {
53
+ save().then()
54
+ ev.preventDefault()
55
+ }
56
+ },
57
+ })),
58
+ )
59
+
60
+ function save() {
61
+ return apiCall('set_config_text', { text }).then(() => setSaved(text))
62
+ }
63
+
64
+ function copy() {
65
+ if (!text) return
66
+ navigator.clipboard.writeText(text.replace(/^\s*(\w*password|srp):.+\n/gm, ''))
67
+ toast("copied")
68
+ }
69
+}
admin/src/OptionsPage.ts
+16
-8
@@ -4,7 +4,8 @@ import { Box, Button, FormHelperText } from '@mui/material';
4
import { createElement as h, Fragment, useEffect, useRef } from 'react';
5
import { apiCall, useApiEx } from './api'
6
import { state, useSnapState } from './state'
7
-import { CardMembership, Refresh, Warning } from '@mui/icons-material'
7
+import { Link } from 'react-router-dom'
8
+import { CardMembership, EditNote, Refresh, Warning } from '@mui/icons-material'
9
import { Dict, iconTooltip, InLink, LinkBtn, MAX_TILES_SIZE, modifiedSx, REPO_URL, ipLocalHost,
10
wait, wikiLink, with_, try_, ipForUrl } from './misc'
11
import { Form, BoolField, NumberField, SelectField, FieldProps, Field, StringField } from '@hfs/mui-grid-form';
@@ -75,13 +76,20 @@ export default function OptionsPage() {
76
sx: modifiedSx( Object.keys(changes).length>0),
77
},
78
barSx: { gap: 2 },
78
- addToBar: [h(Button, {
79
- onClick() {
80
- reloadConfig()
81
- reloadStatus()
82
- },
83
- startIcon: h(Refresh),
84
- }, "Reload")],
79
+ addToBar: [
80
+ h(Button, {
81
+ onClick() {
82
+ reloadConfig()
83
+ reloadStatus()
84
+ },
85
+ startIcon: h(Refresh),
86
+ }, "Reload"),
87
+ h(Button, { // @ts-ignore
88
+ component: Link,
89
+ to: "/edit",
90
+ startIcon: h(EditNote),
91
+ }, "Edit config file"),
92
+ ],
93
defaults() {
94
return { sm: 6 }
95
},
shared/api.ts
+1
-1
@@ -99,7 +99,7 @@ export function useApi<T=any>(cmd: string | Falsy, params?: object, options: Api
99
const reload = useCallback(() => loadingRef.current
100
|| setForcer(v => v+1) || (reloadingRef.current = pendingPromise()),
101
[setForcer])
102
- return { data, error, reload, loading: Boolean(loadingRef.current || reloadingRef.current) }
102
+ return { data, setData, error, reload, loading: Boolean(loadingRef.current || reloadingRef.current) }
103
}
104
105
type EventHandler = (type:string, data?:any) => void
shared/react.ts
+18
-11
@@ -1,16 +1,8 @@
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 {
4
- createElement as h,
5
- Fragment,
6
- KeyboardEvent,
7
- ReactElement,
8
- ReactNode,
9
- useCallback,
10
- useEffect,
11
- useState
12
-} from 'react'
13
-import { useIsMounted } from 'usehooks-ts'
3
+import { createElement as h, Fragment, KeyboardEvent, ReactElement, ReactNode,
4
+ useCallback, useEffect, useRef, useState } from 'react'
5
+import { useIsMounted, useWindowSize } from 'usehooks-ts'
6
7
export function useStateMounted<T>(init: T) {
8
const isMounted = useIsMounted()
@@ -89,6 +81,21 @@ export function useBatch<Job=unknown,Result=unknown>(
81
return { data: cached, ...env } as Env & { data: Result | undefined | null } // so you can cache.clear
82
}
83
84
+export function KeepInScreen({ margin, ...props }: any) {
85
+ const ref = useRef<HTMLDivElement>()
86
+ const [maxHeight, setMaxHeight] = useState<undefined | number>()
87
+ const size = useWindowSize()
88
+ useEffect(() => {
89
+ const el = ref.current
90
+ if (!el) return
91
+ const rect = el.getBoundingClientRect()
92
+ const doc = document.documentElement
93
+ const limit = window.innerHeight || doc.clientHeight
94
+ setMaxHeight(limit - rect?.top - margin)
95
+ }, [size])
96
+ return h('div', { ref, style: { maxHeight, overflow: 'auto' }, ...props })
97
+}
98
+
99
const isMac = navigator.platform.match('Mac')
100
export function isCtrlKey(ev: KeyboardEvent) {
101
return (ev.ctrlKey || isMac && ev.metaKey) && ev.key
src/adminApis.ts
+10
-3
@@ -1,7 +1,7 @@
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 { ApiError, ApiHandlers, SendListReadable } from './apiMiddleware'
4
-import { defineConfig, getWholeConfig, setConfig } from './config'
4
+import { configFile, defineConfig, getWholeConfig, setConfig } from './config'
5
import { getIps, getServerStatus, getUrls } from './listen'
6
import {
7
API_VERSION,
@@ -34,6 +34,7 @@ import { customHtmlSections, customHtmlState, saveCustomHtml } from './customHtm
34
import _ from 'lodash'
35
import { getUpdates, localUpdateAvailable, update, updateSupported } from './update'
36
import { consoleLog } from './consoleLog'
37
+import { resolve } from 'path'
38
39
export const adminApis: ApiHandlers = {
40
@@ -61,9 +62,15 @@ export const adminApis: ApiHandlers = {
62
},
63
64
get_config: getWholeConfig,
64
- update({ tag }) {
65
- return update(tag)
65
+ get_config_text() {
66
+ return {
67
+ path: configFile.getPath(),
68
+ fullPath: resolve(configFile.getPath()),
69
+ text: configFile.getText(),
70
+ }
71
},
72
+ set_config_text: ({ text }) => configFile.save(text, { reparse: true }),
73
+ update: ({ tag }) => update(tag),
74
async check_update() {
75
return { options: await getUpdates() }
76
},
src/config.ts
+6
-2
@@ -179,13 +179,17 @@ const saveDebounced = debounceAsync(async () => {
179
if (await stat(bak).then(x => aWeekAgo > Number(x.mtime || x.ctime), () => true))
180
await copyFile(filePath, bak).catch(() => {}) // ignore errors
181
182
- await save(yaml.stringify({ ...state, version: VERSION }, { lineWidth:1000 }))
182
+ await configFile.save(stringify({ ...state, version: VERSION }))
183
.catch(err => console.error('Failed at saving config file, please ensure it is writable.', String(err)))
184
})
185
export const saveConfigAsap = () => void(saveDebounced())
186
187
+function stringify(obj: any) {
188
+ return yaml.stringify(obj, { lineWidth:1000 })
189
+}
190
+
191
console.log("config", filePath)
188
-const { save } = watchLoad(filePath, text => setConfig(yaml.parse(text, { uniqueKeys: false })||{}, false), {
192
+export const configFile = watchLoad(filePath, text => setConfig(yaml.parse(text, { uniqueKeys: false })||{}, false), {
193
failedOnFirstAttempt(){
194
console.log("No config file, using defaults")
195
setConfig({}, false)
src/watchLoad.ts
+8
-4
@@ -8,8 +8,8 @@ export type WatchLoadCanceller = () => void
8
9
interface Options { failedOnFirstAttempt?: ()=>void, immediateFirst?: boolean }
10
11
-type WriteFile = (data: string) => Promise<void>
12
-interface WatchLoadReturn { unwatch:WatchLoadCanceller, save: WriteFile }
11
+type WriteFile = (data: string, options?: { reparse: boolean }) => Promise<void>
12
+interface WatchLoadReturn { unwatch:WatchLoadCanceller, save: WriteFile, getText: () => string | undefined, getPath: () => string }
13
export function watchLoad(path:string, parser:(data:any)=>void|Promise<void>, { failedOnFirstAttempt, immediateFirst }:Options={}): WatchLoadReturn {
14
let doing = false
15
let watcher: FSWatcher | undefined
@@ -17,8 +17,12 @@ export function watchLoad(path:string, parser:(data:any)=>void|Promise<void>, {
17
let retry: NodeJS.Timeout
18
let last: string | undefined
19
install(true)
20
- const save = debounceAsync((data: string) => fs.writeFile(path, data, 'utf8'))
21
- return { unwatch, save }
20
+ const save = debounceAsync(async (data: string, { reparse=false }={}) => {
21
+ await fs.writeFile(path, data, 'utf8')
22
+ if (reparse)
23
+ await parser(data)
24
+ })
25
+ return { unwatch, save, getText: () => last, getPath: () => path }
26
27
function install(first=false) {
28
try {