@samitouri / QOSami-HFS / commits / 5b7ac051

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 }