@samitouri / QOSami-HFS / commits / 10c100b2

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 }