admin/home: update button
Massimo Melina committed
May 3, 2023 at 10:20 UTC
5b7ac051e6d3e26d31d75489d0468bb0f59417bb
8 files changed
+155
-41
admin/src/HomePage.ts
+46
-6
@@ -1,22 +1,30 @@
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 } from 'react'
3
+import { createElement as h, useState } from 'react'
4
import { Box, Button, LinearProgress, Link } from '@mui/material'
5
import { apiCall, useApi, useApiEx, useApiList } from './api'
6
-import { dontBotherWithKeys, InLink, objSameKeys, onlyTruthy } from './misc'
7
-import { CheckCircle, Error, Info, Launch, Warning } from '@mui/icons-material'
6
+import { Btn, dontBotherWithKeys, InLink, objSameKeys, onlyTruthy, prefix, wait } from './misc'
7
+import { BrowserUpdated as UpdateIcon, CheckCircle, Error, Info, Launch, Warning } from '@mui/icons-material'
8
import md from './md'
9
-import { useSnapState } from './state'
10
-import { confirmDialog } from './dialog'
9
+import { state, useSnapState } from './state'
10
+import { alertDialog, confirmDialog, toast } from './dialog'
11
import { isCertError, isKeyError, makeCertAndSave } from './OptionsPage'
12
import { VfsNode } from './VfsPage'
13
import { Account } from './AccountsPage'
14
import _ from 'lodash'
15
+import { subscribeKey } from 'valtio/utils'
16
17
export const REPO_URL = 'https://github.com/rejetto/hfs/'
18
19
interface ServerStatus { listening: boolean, port: number, error?: string, busy?: string }
19
-interface Status { http: ServerStatus, https: ServerStatus, frpDetected: boolean }
20
+
21
+interface Status {
22
+ http: ServerStatus
23
+ https: ServerStatus
24
+ frpDetected: boolean
25
+ update: boolean | string
26
+ version: string
27
+}
28
29
export default function HomePage() {
30
const SOLUTION_SEP = " — "
@@ -26,6 +34,7 @@ export default function HomePage() {
34
const [account] = useApi<Account>(username && 'get_account')
35
const { data: cfg, reload: reloadCfg } = useApiEx('get_config', { only: ['https_port', 'cert', 'private_key', 'proxies'] })
36
const { list: plugins } = useApiList('get_plugins')
37
+ const [onlineVersion, setOnlineVersion] = useState('')
38
if (statusEl || !status)
39
return statusEl
40
const { http, https } = status
@@ -76,9 +85,40 @@ export default function HomePage() {
85
h('li',{}, `disable "admin access for localhost" in HFS (safe, but you won't see users' IPs)`),
86
)),
87
entry('', h(Link, { target: 'support', href: REPO_URL + 'discussions' }, "Get support")),
88
+ h(Box, { mt: 4 },
89
+ status.update === 'local' ? h(Btn, { icon: UpdateIcon, onClick: update }, "Update from local file")
90
+ : !onlineVersion ? h(Btn, {
91
+ variant: 'outlined',
92
+ icon: UpdateIcon,
93
+ onClick: () => apiCall('check_update').then(x => setOnlineVersion(x.name), x => {
94
+ setOnlineVersion('')
95
+ alertDialog(x).then()
96
+ })
97
+ }, "Check for updates")
98
+ : status?.version === onlineVersion ? entry('', "You got the latest version")
99
+ : !status.update ? entry('', `Version ${onlineVersion} available`)
100
+ : h(Btn, { icon: UpdateIcon, onClick: update }, prefix("Update to ", onlineVersion))
101
+ ),
102
)
103
}
104
105
+async function update() {
106
+ if (!await confirmDialog("Update may take less than a minute, depending on the speed of your server")) return
107
+ toast('Downloading')
108
+ await apiCall('update')
109
+ toast("Restarting")
110
+ const restarting = Date.now()
111
+ while (await apiCall('NONE').then(() => 0, e => !e.code)) { // while we get no response
112
+ if (Date.now() - restarting > 10_000)
113
+ toast("This is taking too long, please check your server", 'warning')
114
+ await wait(500)
115
+ }
116
+ // the server is back on, SSE is restored and login dialog may appear, unwanted because we are just waiting to reload
117
+ subscribeKey(state, 'loginRequired', () => state.loginRequired = false)
118
+ await alertDialog("Procedure complete", 'success')
119
+ window.location.reload() // show new gui
120
+}
121
+
122
type Color = '' | 'success' | 'warning' | 'error'
123
124
function entry(color: Color, ...content: any[]) {
admin/src/misc.ts
+59
-6
@@ -1,13 +1,25 @@
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, FC, Fragment, ReactNode } from 'react'
4
-import { Box, Breakpoint, CircularProgress, IconButton, Link, Tooltip, useMediaQuery } from '@mui/material'
4
+import {
5
+ Box,
6
+ Breakpoint,
7
+ ButtonProps,
8
+ CircularProgress,
9
+ IconButton,
10
+ IconButtonProps,
11
+ Link,
12
+ Tooltip, TooltipProps,
13
+ useMediaQuery
14
+} from '@mui/material'
15
import { Link as RouterLink } from 'react-router-dom'
16
import { SxProps } from '@mui/system'
17
import { Refresh, SvgIconComponent } from '@mui/icons-material'
18
import { alertDialog, confirmDialog } from './dialog'
19
import { apiCall } from './api'
20
import { formatPerc, useStateMounted } from '@hfs/shared'
21
+import { Promisable } from '@hfs/mui-grid-form'
22
+import { LoadingButton, LoadingButtonProps } from '@mui/lab'
23
export * from '@hfs/shared'
24
25
export function spinner() {
@@ -27,15 +39,15 @@ export function isEqualLax(a: any,b: any): boolean {
39
export function modifiedSx(is: boolean) {
40
return is ? { outline: '2px solid' } : undefined
41
}
30
-
31
-interface IconBtnProps {
42
+interface IconBtnProps extends Omit<IconButtonProps, 'disabled'|'title'|'onClick'> {
43
title?: ReactNode
44
icon: SvgIconComponent
45
disabled?: boolean | string
46
progress?: boolean | number
47
link?: string
48
confirm?: string
38
- [rest: string]: any
49
+ tooltipProps?: Partial<TooltipProps>
50
+ onClick: (...args: Parameters<NonNullable<IconButtonProps['onClick']>>) => Promisable<any>
51
}
52
53
export function IconBtn({ title, icon, onClick, disabled, progress, link, tooltipProps, confirm, ...rest }: IconBtnProps) {
@@ -47,9 +59,9 @@ export function IconBtn({ title, icon, onClick, disabled, progress, link, toolti
59
let ret: ReturnType<FC> = h(IconButton, {
60
disabled: Boolean(loading || progress || disabled),
61
...rest,
50
- async onClick() {
62
+ async onClick(...args) {
63
if (confirm && !await confirmDialog(confirm)) return
52
- const ret = onClick?.apply(this,arguments)
64
+ const ret = onClick?.apply(this,args)
65
if (ret && ret instanceof Promise) {
66
setLoading(true)
67
ret.catch(alertDialog).finally(()=> setLoading(false))
@@ -69,6 +81,47 @@ export function IconBtn({ title, icon, onClick, disabled, progress, link, toolti
81
return ret
82
}
83
84
+interface BtnProps extends Omit<LoadingButtonProps,'disabled'|'title'|'onClick'> {
85
+ icon: SvgIconComponent
86
+ title?: ReactNode
87
+ disabled?: boolean | string
88
+ progress?: boolean | number
89
+ link?: string
90
+ confirm?: string
91
+ tooltipProps?: TooltipProps
92
+ onClick: (...args: Parameters<NonNullable<ButtonProps['onClick']>>) => Promisable<any>
93
+}
94
+export function Btn({ icon, title, onClick, disabled, progress, link, tooltipProps, confirm, ...rest }: BtnProps) {
95
+ const [loading, setLoading] = useStateMounted(false)
96
+ if (typeof disabled === 'string') {
97
+ title = disabled
98
+ disabled = true
99
+ }
100
+ if (link)
101
+ onClick = () => window.open(link)
102
+ let ret: ReturnType<FC> = h(LoadingButton, {
103
+ variant: 'contained',
104
+ startIcon: h(icon),
105
+ loading: Boolean(loading || progress),
106
+ loadingPosition: 'start',
107
+ loadingIndicator: typeof progress !== 'number' ? undefined
108
+ : h(CircularProgress, { size: '1rem', value: progress*100, variant: 'determinate' }),
109
+ disabled,
110
+ ...rest,
111
+ async onClick(...args) {
112
+ if (confirm && !await confirmDialog(confirm)) return
113
+ const ret = onClick?.apply(this,args)
114
+ if (ret && ret instanceof Promise) {
115
+ setLoading(true)
116
+ ret.catch(alertDialog).finally(()=> setLoading(false))
117
+ }
118
+ }
119
+ })
120
+ if (title)
121
+ ret = h(Tooltip, { title, ...tooltipProps, children: h('span',{},ret) })
122
+ return ret
123
+}
124
+
125
export function iconTooltip(icon: SvgIconComponent, tooltip: string, sx?: SxProps) {
126
return h(Tooltip, { title: tooltip, children: h(icon, { sx }) })
127
}
mui-grid-form/index.ts
+1
-1
@@ -40,7 +40,7 @@ export interface FieldDescriptor<T=any> {
40
// it seems necessary to cast (Multi)SelectField sometimes
41
export type Field<T> = FC<FieldProps<T>>
42
43
-type Promisable<T> = T | Promise<T>
43
+export type Promisable<T> = T | Promise<T>
44
interface FieldApi { getError: () => Promisable<ValidationError>, [rest: string]: any }
45
export interface FieldProps<T> {
46
label?: string | ReactElement
src/adminApis.ts
+4
@@ -31,6 +31,7 @@ import { execFile } from 'child_process'
31
import { promisify } from 'util'
32
import { customHtmlSections, customHtmlState, saveCustomHtml } from './customHtml'
33
import _ from 'lodash'
34
+import { getUpdate, localUpdateAvailable, update, updateSupported } from './update'
35
36
export const adminApis: ApiHandlers = {
37
@@ -57,6 +58,8 @@ export const adminApis: ApiHandlers = {
58
},
59
60
get_config: getWholeConfig,
61
+ update,
62
+ check_update: () => getUpdate().then(x => _.pick(x, 'name')),
63
64
get_custom_html() {
65
return {
@@ -86,6 +89,7 @@ export const adminApis: ApiHandlers = {
89
compatibleApiVersion: COMPATIBLE_API_VERSION,
90
...await getServerStatus(),
91
urls: getUrls(),
92
+ update: !updateSupported() ? false : await localUpdateAvailable() ? 'local' : true,
93
proxyDetected: getProxyDetected(),
94
frpDetected: localhostAdmin.get() && !getProxyDetected()
95
&& getConnections().every(isLocalHost)
src/commands.ts
+14
-13
@@ -6,21 +6,22 @@ import _ from 'lodash'
6
import { getUpdate, update } from './update'
7
import { openAdmin } from './listen'
8
import yaml from 'yaml'
9
-import { BUILD_TIMESTAMP, VERSION } from './const'
9
+import { argv, BUILD_TIMESTAMP, VERSION } from './const'
10
import { createInterface } from 'readline'
11
12
-try {
13
- /*
14
- is this try-block useful in case the stdin is unavailable?
15
- Not sure, but someone reported a problem using nohup https://github.com/rejetto/hfs/issues/74
16
- and I've found this example try-catching https://github.com/DefinitelyTyped/DefinitelyTyped/blob/dda83a906914489e09ca28afea12948529015d4a/types/node/readline.d.ts#L489
17
- */
18
- createInterface({ input: process.stdin }).on('line', parseCommandLine)
19
- console.log(`HINT: type "help" for help`)
20
-}
21
-catch {
22
- console.log("console commands not available")
23
-}
12
+if (!argv.updating)
13
+ try {
14
+ /*
15
+ is this try-block useful in case the stdin is unavailable?
16
+ Not sure, but someone reported a problem using nohup https://github.com/rejetto/hfs/issues/74
17
+ and I've found this example try-catching https://github.com/DefinitelyTyped/DefinitelyTyped/blob/dda83a906914489e09ca28afea12948529015d4a/types/node/readline.d.ts#L489
18
+ */
19
+ createInterface({ input: process.stdin }).on('line', parseCommandLine)
20
+ console.log(`HINT: type "help" for help`)
21
+ }
22
+ catch {
23
+ console.log("console commands not available")
24
+ }
25
26
function parseCommandLine(line: string) {
27
if (!line) return
src/const.ts
+1
@@ -57,6 +57,7 @@ console.log(`License https://www.gnu.org/licenses/gpl-3.0.txt`)
57
console.log('started', HFS_STARTED.toLocaleString(), DEV)
58
console.log('version', VERSION||'-')
59
console.log('build', BUILD_TIMESTAMP||'-')
60
+console.log('pid', process.pid)
61
if (argv.cwd)
62
process.chdir(argv.cwd)
63
else if (!process.execPath.endsWith('.exe')) { // still considering whether to use this behavior with Windows users, who may be less accustomed to it
src/listen.ts
+2
-2
@@ -9,7 +9,7 @@ import { networkInterfaces } from 'os';
9
import { newConnection } from './connections'
10
import open from 'open'
11
import { debounceAsync, onlyTruthy, wait } from './misc'
12
-import { ADMIN_URI, DEV } from './const'
12
+import { ADMIN_URI, argv, DEV } from './const'
13
import findProcess from 'find-process'
14
import { anyAccountCanLoginAdmin } from './adminApis'
15
import _ from 'lodash'
@@ -34,7 +34,7 @@ portCfg.sub(async port => {
34
if (!port) return
35
httpSrv.on('connection', newConnection)
36
printUrls(port, 'http')
37
- if (openBrowserAtStart.get())
37
+ if (openBrowserAtStart.get() && !argv.updated)
38
openAdmin()
39
})
40
src/update.ts
+28
-13
@@ -3,12 +3,13 @@
3
import { getRepoInfo } from './github'
4
import { argv, HFS_REPO, IS_BINARY, IS_WINDOWS, VERSION } from './const'
5
import { basename, dirname, join } from 'path'
6
-import { spawn } from 'child_process'
6
+import { spawn, spawnSync } from 'child_process'
7
import { httpsStream, onProcessExit, unzip } from './misc'
8
import { createReadStream, renameSync, unlinkSync } from 'fs'
9
import { pluginsWatcher } from './plugins'
10
import { access, chmod, stat } from 'fs/promises'
11
import { Readable } from 'stream'
12
+import open from 'open'
13
14
export async function getUpdate() {
15
const [latest] = await getRepoInfo(HFS_REPO + '/releases?per_page=1')
@@ -17,11 +18,20 @@ export async function getUpdate() {
18
return latest
19
}
20
21
+const LOCAL_UPDATE = 'hfs-update.zip' // update from file takes precedence over net
22
+
23
+export function localUpdateAvailable() {
24
+ return access(LOCAL_UPDATE).then(() => true, () => false)
25
+}
26
+
27
+export function updateSupported() {
28
+ return IS_BINARY
29
+}
30
+
31
export async function update() {
21
- if (!IS_BINARY)
32
+ if (!updateSupported())
33
throw "only binary versions are supported for now"
23
- const ZIP = 'hfs-update.zip' // update from file takes precedence over net
24
- let updateSource: Readable | undefined = await access(ZIP).then(() => createReadStream(ZIP), () => undefined)
34
+ let updateSource: Readable | false = await localUpdateAvailable() && createReadStream(LOCAL_UPDATE)
35
if (!updateSource) {
36
const update = await getUpdate()
37
const assetSearch = ({ win32: 'windows', darwin: 'mac', linux: 'linux' } as any)[process.platform]
@@ -56,27 +66,32 @@ export async function update() {
66
catch {}
67
renameSync(bin, oldBin)
68
console.log("launching new version in background", newBinFile)
59
- spawn(newBin, ['--updating', binFile], { detached: true, shell: true, stdio:'inherit' })
60
- .on('error', console.error)
69
+ launch(newBin, ['--updating', binFile], { sync: true }) // sync necessary to work on mac by double-click
70
})
71
console.log('quitting')
63
- process.exit()
72
+ setTimeout(() => process.exit()) // give time to return (and caller to complete, eg: rest api to reply)
73
}
74
catch {
75
pluginsWatcher.unpause()
76
}
77
}
78
79
+function launch(cmd: string, pars: string[]=[], options?: { sync: boolean } & Parameters<typeof spawn>[2]) {
80
+ return (options?.sync ? spawnSync : spawn)(cmd, pars, { detached: true, shell: true, stdio: [0,1,2], ...options })
81
+}
82
+
83
if (argv.updating) { // we were launched with a temporary name, restore original name to avoid breaking references
84
const bin = process.execPath
85
const dest = join(dirname(bin), argv.updating)
86
renameSync(bin, dest)
74
- console.log("renamed binary file to", argv.updating)
87
// have to relaunch with new name, or otherwise next update will fail with EBUSY on hfs.exe
76
- onProcessExit(() => {
77
- spawn(dest, [], { detached: true, shell: true, stdio:'inherit' })
78
- .on('error', console.error)
79
- })
80
- console.log('restarting')
88
+ console.log("renamed binary file to", argv.updating, "and restarting")
89
+ // be sure to test launching both double-clicking and in a terminal
90
+ if (IS_WINDOWS) // this method on Mac works only once, and without console
91
+ onProcessExit(() =>
92
+ launch(dest, ['--updated']) ) // launch+sync here would cause old process to stay open, locking ports
93
+ else
94
+ open(dest).then()
95
+
96
process.exit()
97
}