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 {