admin/plugins: pause-plugins button
Massimo Melina committed
Dec 3, 2024 at 19:42 UTC
10c100b2fd4204ecb84efe37d39e71b021709953
7 files changed
+68
-37
admin/src/InstalledPlugins.ts
+11
-2
@@ -7,7 +7,7 @@ import { DataTable, DataTableColumn } from './DataTable'
7
import {
8
Clear, Delete, Error as ErrorIcon, FormatPaint as ThemeIcon, PlayCircle, Settings, StopCircle, Upgrade
9
} from '@mui/icons-material'
10
-import { HTTP_FAILED_DEPENDENCY, md, newObj, prefix, with_, xlate } from './misc'
10
+import { CFG, HTTP_FAILED_DEPENDENCY, md, newObj, prefix, with_, xlate } from './misc'
11
import { alertDialog, confirmDialog, formDialog, toast } from './dialog'
12
import _ from 'lodash'
13
import { Account } from './AccountsPage'
@@ -15,7 +15,7 @@ import { BoolField, Field, FieldProps, MultiSelectField, NumberField, SelectFiel
15
import { ArrayField } from './ArrayField'
16
import FileField from './FileField'
17
import { PLUGIN_ERRORS } from './PluginsPage'
18
-import { Btn, hTooltip, IconBtn, iconTooltip } from './mui'
18
+import { Btn, hTooltip, IconBtn, iconTooltip, usePauseButton } from './mui'
19
import VfsPathField from './VfsPathField'
20
21
export default function InstalledPlugins({ updates }: { updates?: true }) {
@@ -25,6 +25,9 @@ export default function InstalledPlugins({ updates }: { updates?: true }) {
25
_.sortBy(list, x => (x.started || x.error ? '0' : '1') + treatPluginName(x.id)))
26
}, [list.length]);
27
const size = 'small'
28
+ const { pause, pauseButton } = usePauseButton("plugins", () => getSingleConfig(CFG.suspend_plugins).then(x => !x), {
29
+ onClick: () => apiCall('set_config', { values: { [CFG.suspend_plugins]: !pause } })
30
+ })
31
return h(DataTable, {
32
error: xlate(error, PLUGIN_ERRORS),
33
rows: list.length ? list : [], // workaround for DataGrid bug causing 'no rows' message to be not displayed after 'loading' was also used
@@ -54,6 +57,7 @@ export default function InstalledPlugins({ updates }: { updates?: true }) {
57
hideUnder: 'sm',
58
},
59
],
60
+ footerSide: () => !updates && pauseButton,
61
actions: ({ row, id }) => updates ? [
62
h(IconBtn, {
63
icon: Upgrade,
@@ -81,6 +85,7 @@ export default function InstalledPlugins({ updates }: { updates?: true }) {
85
} : {
86
icon: PlayCircle,
87
title: `Start ${id}`,
88
+ disabled: pause,
89
size,
90
onClick: () => startPlugin(id),
91
}),
@@ -135,6 +140,10 @@ export default function InstalledPlugins({ updates }: { updates?: true }) {
140
})
141
}
142
143
+function getSingleConfig(k: string) {
144
+ return apiCall('get_config', { only: [k] }).then(x => x[k])
145
+}
146
+
147
// hide the hfs- prefix, as one may want to use it for its repository, because github is the context, but in the hfs context the prefix it's not only redundant, but also ruins the sorting
148
function treatPluginName(name: string) {
149
return name.replace(/hfs-/, '')
admin/src/mui.ts
+21
-8
@@ -9,7 +9,9 @@ import {
9
} from 'react'
10
import { Box, BoxProps, Breakpoint, ButtonProps, CircularProgress, IconButton, IconButtonProps, Link, LinkProps,
11
Tooltip, TooltipProps, useMediaQuery } from '@mui/material'
12
-import { anyDialogOpen, closeDialog, formatPerc, isIpLan, isIpLocalHost, prefix, WIKI_URL, with_ } from './misc'
12
+import {
13
+ anyDialogOpen, closeDialog, formatPerc, isIpLan, isIpLocalHost, prefix, WIKI_URL, with_, Functionable, callable
14
+} from './misc'
15
import { dontBotherWithKeys, restartAnimation, useBatch, useStateMounted } from '@hfs/shared'
16
import { Promisable, StringField } from '@hfs/mui-grid-form'
17
import { alertDialog, confirmDialog, toast } from './dialog'
@@ -237,17 +239,25 @@ export function LinkBtn({ ...rest }: LinkProps) {
239
})
240
}
241
240
-export function usePauseButton(props?: Partial<IconBtnProps>) {
241
- const [going, btn] = useToggleButton("Pause", "Play", v => ({
242
+export function usePauseButton(name='', def: ToggleButtonDefault=true, props?: Partial<IconBtnProps>) {
243
+ const [going, btn] = useToggleButton(`Pause ${name}`, `Resume ${name}`, v => ({
244
icon: v ? PauseCircle : PlayCircle,
245
sx: { rotate: v ? '180deg' : '0deg' },
246
...props,
245
- }), true)
247
+ }), def)
248
return { pause: !going, pauseButton: btn }
249
}
250
249
-export function useToggleButton(onTitle: string, offTitle: undefined | string, iconBtn: (state:boolean) => Omit<IconBtnProps, 'onClick'>, def=false) {
250
- const [state, setState] = useState(def)
251
+type ToggleButtonDefault = Functionable<Promisable<boolean>>
252
+export function useToggleButton(onTitle: string, offTitle: undefined | string, iconBtn: (state:boolean) => IconBtnProps, init: ToggleButtonDefault=false) {
253
+ const [state, setState] = useState<boolean>(init instanceof Promise || init instanceof Function ? (() => {
254
+ const x = callable(init)
255
+ if (!(x instanceof Promise))
256
+ return x
257
+ x.then(v => setState(v))
258
+ return false
259
+ }) : init)
260
+
261
const toggle = useCallback(() => setState(x => !x), [])
262
const props = iconBtn(state)
263
const el = useMemo(() => h(IconBtn, {
@@ -258,9 +268,12 @@ export function useToggleButton(onTitle: string, offTitle: undefined | string, i
268
'aria-pressed': state,
269
...props,
270
sx: { transition: 'all .5s', ...props.sx },
261
- onClick: toggle,
271
+ onClick(ev) {
272
+ props.onClick?.(ev)
273
+ toggle()
274
+ },
275
}), [state]) // memoize or tooltip flickers on mouse-over
263
- return [state, el] as const
276
+ return [state, el, setState] as const
277
}
278
279
export function NetmaskField(props: StringFieldProps) {
shared/react.ts
+1
-1
@@ -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 {
4
- createElement as h, Fragment, KeyboardEvent, MutableRefObject, ReactElement, ReactNode, Ref,
4
+ createElement as h, Fragment, KeyboardEvent, MutableRefObject, ReactElement, ReactNode,
5
useCallback, useEffect, useMemo, useRef, useState
6
} from 'react'
7
import { useIsMounted, useWindowSize, useMediaQuery } from 'usehooks-ts'
src/api.plugins.ts
+6
-4
@@ -3,11 +3,11 @@
3
import {
4
AvailablePlugin, enablePlugins, getAvailablePlugins, getPluginConfigFields, mapPlugins, Plugin, pluginsConfig,
5
PATH as PLUGINS_PATH, enablePlugin, getPluginInfo, setPluginConfig, isPluginRunning,
6
- stopPlugin, startPlugin, CommonPluginInterface, getMissingDependencies, findPluginByRepo,
6
+ stopPlugin, startPlugin, CommonPluginInterface, getMissingDependencies, findPluginByRepo, suspendPlugins,
7
} from './plugins'
8
import _ from 'lodash'
9
import assert from 'assert'
10
-import { HTTP_CONFLICT, newObj, waitFor } from './misc'
10
+import { HTTP_CONFLICT, HTTP_PRECONDITION_FAILED, newObj, waitFor } from './misc'
11
import { ApiError, ApiHandlers } from './apiMiddleware'
12
import { rm } from 'fs/promises'
13
import {
@@ -71,8 +71,10 @@ const apis: ApiHandlers = {
71
async start_plugin({ id }) {
72
if (isPluginRunning(id))
73
return { msg: 'already running' }
74
+ if (suspendPlugins.get())
75
+ return new ApiError(HTTP_PRECONDITION_FAILED, 'all plugins suspended')
76
await stopPlugin(id)
75
- return startPlugin(id).then(() => 0, e => new ApiError(HTTP_SERVER_ERROR, e.message))
77
+ return startPlugin(id).then(() => ({}), e => new ApiError(HTTP_SERVER_ERROR, e.message))
78
},
79
80
async stop_plugin({ id }) {
@@ -165,7 +167,7 @@ const apis: ApiHandlers = {
167
if (deleteConfig)
168
setPluginConfig(id, null)
169
return {}
168
- }
170
+ },
171
172
}
173
src/cross.ts
+3
-2
@@ -27,13 +27,14 @@ export const THEME_OPTIONS = { auto: '', light: 'light', dark: 'dark' }
27
export const CFG = constMap(['geo_enable', 'geo_allow', 'geo_list', 'geo_allow_unknown', 'dynamic_dns_url',
28
'log', 'error_log', 'log_rotation', 'dont_log_net', 'log_gui', 'log_api', 'log_ua', 'log_spam', 'track_ips',
29
'max_downloads', 'max_downloads_per_ip', 'max_downloads_per_account', 'roots', 'force_address', 'split_uploads',
30
- 'allow_session_ip_change', 'force_lang'])
30
+ 'allow_session_ip_change', 'force_lang', 'suspend_plugins'])
31
export const LIST = { add: '+', remove: '-', update: '=', props: 'props', ready: 'ready', error: 'e' }
32
export type Dict<T=any> = Record<string, T>
33
export type Falsy = false | null | undefined | '' | 0
34
type Truthy<T> = T extends false | '' | 0 | null | undefined | void ? never : T
35
export type Callback<IN=void, OUT=void> = (x:IN) => OUT
36
export type Promisable<T> = T | Promise<T>
37
+export type Functionable<T> = T | ((...args: any[]) => T)
38
export type StringifyProps<T> = { [P in keyof T]: Exclude<T[P], Date> extends T[P] ? string | Exclude<T[P], Date> : T[P] }
39
export interface VfsPerms {
40
can_see?: Who
@@ -485,7 +486,7 @@ export function mapFilter<T=unknown, R=T>(arr: T[], map: (x:T, idx: number) => R
486
}, [] as R[])
487
}
488
488
-export function callable<T>(x: T | ((...args: unknown[]) => T), ...args: unknown[]) {
489
+export function callable<T>(x: Functionable<T>, ...args: unknown[]) {
490
return _.isFunction(x) ? x(...args) : x
491
}
492
src/github.ts
+4
-4
@@ -5,7 +5,7 @@ import {
5
DAY, httpString, httpStream, unzip, AsapStream, debounceAsync, asyncGeneratorToArray, wait, popKey, onlyTruthy
6
} from './misc'
7
import {
8
- DISABLING_SUFFIX, enablePlugin, findPluginByRepo, getAvailablePlugins, getPluginInfo, isPluginEnabled, mapPlugins,
8
+ DISABLING_SUFFIX, enablePlugin, findPluginByRepo, getAvailablePlugins, getPluginInfo, isPluginRunning, mapPlugins,
9
parsePluginSource, PATH as PLUGINS_PATH, Repo, startPlugin, stopPlugin, STORAGE_FOLDER
10
} from './plugins'
11
import { ApiError } from './apiMiddleware'
@@ -95,8 +95,8 @@ export async function downloadPlugin(repo: Repo, { branch='', overwrite=false }=
95
return rm(dest, { force: true }).then(() => dest, () => false)
96
})
97
// ready to replace
98
- const wasEnabled = isPluginEnabled(folder)
99
- if (wasEnabled)
98
+ const wasRunning = isPluginRunning(folder)
99
+ if (wasRunning)
100
await stopPlugin(folder) // stop old
101
let retry = 3
102
while (retry--) { // move data, and consider late release of the resource, up to a few seconds
@@ -111,7 +111,7 @@ export async function downloadPlugin(repo: Repo, { branch='', overwrite=false }=
111
// final replace
112
await rename(tempInstallPath, installPath)
113
.catch(e => { throw e.code !== 'ENOENT' ? e : new ApiError(HTTP_NOT_ACCEPTABLE, "missing main file") })
114
- if (wasEnabled)
114
+ if (wasRunning)
115
void startPlugin(folder) // don't wait, in case it fails to start. We still use startPlugin instead of enablePlugin, as it will take care of disabling other themes.
116
.catch(() => {}) // it will possibly fail (with 'miss') because the plugin has probably not been loaded yet.
117
events.emit('pluginDownloaded', { id: folder, repo })
src/plugins.ts
+22
-16
@@ -8,7 +8,7 @@ import { API_VERSION, APP_PATH, COMPATIBLE_API_VERSION, HTTP_NOT_FOUND, IS_WINDO
8
import * as Const from './const'
9
import Koa from 'koa'
10
import {
11
- adjustStaticPathForGlob, callable, Callback, debounceAsync, Dict, getOrSet, objSameKeys, onlyTruthy,
11
+ adjustStaticPathForGlob, callable, Callback, CFG, debounceAsync, Dict, getOrSet, objSameKeys, onlyTruthy,
12
PendingPromise, pendingPromise, Promisable, same, tryJson, wait, waitFor, wantArray, watchDir
13
} from './misc'
14
import * as misc from './misc'
@@ -38,8 +38,8 @@ export function isPluginRunning(id: string) {
38
return Boolean(plugins.get(id)?.started)
39
}
40
41
-export function isPluginEnabled(id: string) {
42
- return enablePlugins.get().includes(id)
41
+export function isPluginEnabled(id: string, considerSuspension=false) {
42
+ return (!considerSuspension || !suspendPlugins.get()) && enablePlugins.get().includes(id)
43
}
44
45
export function enablePlugin(id: string, state=true) {
@@ -350,6 +350,8 @@ export const pluginsWatcher = watchDir(PATH, rescanAsap)
350
export const enablePlugins = defineConfig('enable_plugins', ['antibrute'])
351
enablePlugins.sub(rescanAsap)
352
353
+export const suspendPlugins = defineConfig(CFG.suspend_plugins, false)
354
+
355
export const pluginsConfig = defineConfig('plugins_config', {} as Record<string,any>)
356
357
const pluginWatchers = new Map<string, ReturnType<typeof watchPlugin>>()
@@ -364,35 +366,37 @@ export async function rescan() {
366
if (!dirent.isDirectory() || path.endsWith(DISABLING_SUFFIX)) continue
367
const id = path.split('/').slice(-1)[0]!
368
met.push(id)
367
- const w = pluginWatchers.get(id)
368
- if (w) continue
369
- console.debug('plugin watch', id)
370
- pluginWatchers.set(id, watchPlugin(id, join(path, 'plugin.js')))
369
+ if (!pluginWatchers.has(id))
370
+ pluginWatchers.set(id, watchPlugin(id, join(path, 'plugin.js')))
371
}
372
for (const [id, cancelWatcher] of pluginWatchers.entries())
373
if (!met.includes(id)) {
374
enablePlugin(id, false)
375
- console.debug('plugin unwatch', id)
375
cancelWatcher()
376
pluginWatchers.delete(id)
377
}
378
}
379
380
function watchPlugin(id: string, path: string) {
381
+ console.debug('plugin watch', id)
382
const module = resolve(path)
383
let starting: PendingPromise | undefined
384
- const unsub = enablePlugins.sub(() => { // we take care of enabled-state after it was loaded
385
- if (!getPluginInfo(id)) return // not loaded yet
386
- const enabled = isPluginEnabled(id)
387
- if (enabled === isPluginRunning(id)) return
388
- if (enabled) start()
389
- else stop()
390
- })
384
+ const unsub = enablePlugins.sub(() => getPluginInfo(id) && considerStart()) // only after it has been loaded
385
+ const unsub2 = suspendPlugins.sub(() => getPluginInfo(id) && considerStart())
386
+ function considerStart() {
387
+ const should = isPluginEnabled(id, true)
388
+ if (should === isPluginRunning(id)) return
389
+ if (should) {
390
+ start()
391
+ return true
392
+ }
393
+ stop()
394
+ }
395
const { unwatch } = watchLoad(module, async source => {
396
const notRunning = availablePlugins[id]
397
if (!source)
398
return onUninstalled()
395
- if (isPluginEnabled(id))
399
+ if (isPluginEnabled(id, true))
400
return start()
401
const p = parsePluginSource(id, source)
402
if (same(notRunning, p)) return
@@ -400,7 +404,9 @@ function watchPlugin(id: string, path: string) {
404
events.emit(notRunning ? 'pluginUpdated' : 'pluginInstalled', p)
405
})
406
return () => {
407
+ console.debug('plugin unwatch', id)
408
unsub()
409
+ unsub2()
410
unwatch()
411
return onUninstalled()
412
}