mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
Feat/plugin instance actions (#2209)
* feat(shared): scope plugin action descriptors * feat(plugin-sdk): instance-scoped settings actions * feat(plugins): run instance-scoped actions from the admin settings dialog * feat(admin): instance-scoped plugin actions in the settings dialog * fix(plugins): surface instance actions without instance fields and harden the action gate * docs(plugin-sdk): note that hosts before 4.2.0 ignore action scope * fix(admin): open the dialog for instance actions, explain inactive 404s, guard the save window * docs(wiki): plugin pages for instance actions and the admin panel changes
This commit is contained in:
@@ -1,24 +1,34 @@
|
||||
import { useState } from 'react'
|
||||
import { adminApi, type PluginUserSettingField } from '../../api/client'
|
||||
import { useRef, useState } from 'react'
|
||||
import { adminApi, type PluginAction, type PluginUserSettingField } from '../../api/client'
|
||||
import type { PluginActionResult } from '@trek/shared'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { seedSettingsValues, findMissingRequired, settingsPatch } from '../Plugins/settingsForm'
|
||||
|
||||
/** The server's stand-in for a stored secret — a display artifact, never a value to send back. */
|
||||
|
||||
export interface InstanceSettingsForm {
|
||||
id: string
|
||||
fields: PluginUserSettingField[]
|
||||
values: Record<string, string | boolean>
|
||||
/** The plugin's `scope:'instance'` action buttons. */
|
||||
actions: PluginAction[]
|
||||
/** Whether the plugin is active: an action needs a running child to execute. */
|
||||
active: boolean
|
||||
/** An edit since open/last save. An action saves a dirty form before it runs. */
|
||||
dirty: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The admin-owned `scope:'instance'` settings form — the ONE logic path behind
|
||||
* both admin shells (the desktop modal and the phone sheet render their own
|
||||
* markup over this state). Open fetches the declared fields plus the stored
|
||||
* (masked) values; save skips an untouched secret mask so it never overwrites
|
||||
* the stored ciphertext, and reports a restart when the server re-spawned a
|
||||
* running plugin (the child reads its config once, at init).
|
||||
* (masked) values and the instance-scope actions; save skips an untouched secret
|
||||
* mask so it never overwrites the stored ciphertext, and reports a restart when
|
||||
* the server re-spawned a running plugin (the child reads its config once, at init).
|
||||
*
|
||||
* Actions run AS the clicking admin. A dirty form is saved first (so the action
|
||||
* sees the config the admin is looking at), the dialog stays open, and the result
|
||||
* shows beside the button. A `danger` action parks in `pendingAction` until the
|
||||
* shell's confirm UI calls `confirmPendingAction`.
|
||||
*/
|
||||
export function useInstanceSettings() {
|
||||
const { t } = useTranslation()
|
||||
@@ -26,40 +36,112 @@ export function useInstanceSettings() {
|
||||
const [form, setForm] = useState<InstanceSettingsForm | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [actionResult, setActionResult] = useState<Record<string, PluginActionResult>>({})
|
||||
const [runningAction, setRunningAction] = useState<string | null>(null)
|
||||
const [pendingAction, setPendingAction] = useState<PluginAction | null>(null)
|
||||
// Bumped on every open()/close() so an in-flight action from a closed/replaced
|
||||
// dialog can tell it's stale and bail without touching the new dialog's state.
|
||||
const openSeq = useRef(0)
|
||||
|
||||
const open = (id: string) => {
|
||||
const open = (id: string, active: boolean) => {
|
||||
openSeq.current++
|
||||
setError('')
|
||||
setActionResult({})
|
||||
setPendingAction(null)
|
||||
setRunningAction(null)
|
||||
adminApi.pluginConfig(id)
|
||||
.then(d => {
|
||||
setForm({ id, fields: d.fields, values: seedSettingsValues(d.fields, d.config) })
|
||||
setForm({ id, fields: d.fields, values: seedSettingsValues(d.fields, d.config), actions: d.actions ?? [], active, dirty: false })
|
||||
})
|
||||
.catch(() => toast.error(t('common.error')))
|
||||
}
|
||||
|
||||
const setValue = (key: string, value: string | boolean) =>
|
||||
setForm(s => s && ({ ...s, values: { ...s.values, [key]: value } }))
|
||||
setForm(s => s && ({ ...s, values: { ...s.values, [key]: value }, dirty: true }))
|
||||
|
||||
const close = () => setForm(null)
|
||||
const close = () => { openSeq.current++; setForm(null); setPendingAction(null); setRunningAction(null) }
|
||||
|
||||
const save = async () => {
|
||||
if (!form) return
|
||||
const missing = findMissingRequired(form.fields, form.values)
|
||||
/** Persist the form. Returns false when the save was refused (error already shown). */
|
||||
const persist = async (f: InstanceSettingsForm): Promise<boolean> => {
|
||||
const missing = findMissingRequired(f.fields, f.values)
|
||||
if (missing) {
|
||||
setError(t('admin.plugins.requiredMissing', { field: missing.label || missing.key }))
|
||||
return
|
||||
return false
|
||||
}
|
||||
setSaving(true); setError('')
|
||||
try {
|
||||
const d = await adminApi.pluginSaveConfig(form.id, settingsPatch(form.fields, form.values))
|
||||
const d = await adminApi.pluginSaveConfig(f.id, settingsPatch(f.fields, f.values))
|
||||
toast.success(t(d.restarted ? 'admin.plugins.settingsSavedRestarted' : 'admin.plugins.settingsSaved'))
|
||||
setForm(null)
|
||||
return true
|
||||
} catch (e) {
|
||||
const err = e as { response?: { data?: { error?: string } } }
|
||||
setError(err.response?.data?.error || t('common.error'))
|
||||
return false
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return { form, saving, error, open, setValue, close, save }
|
||||
const save = async () => {
|
||||
if (!form) return
|
||||
if (await persist(form)) setForm(null)
|
||||
}
|
||||
|
||||
const perform = async (a: PluginAction) => {
|
||||
if (!form || !form.active || runningAction) return
|
||||
const seq = openSeq.current
|
||||
// Set BEFORE the dirty-save window, not just around the invoke call, so a second
|
||||
// click (or the shells' Save button, see M7) is blocked for the whole run — not just
|
||||
// once the pre-run save has already gone out.
|
||||
setRunningAction(a.key)
|
||||
try {
|
||||
if (form.dirty) {
|
||||
if (!(await persist(form))) return
|
||||
if (openSeq.current !== seq) return
|
||||
// Only clear dirty if the values are still the snapshot we just persisted —
|
||||
// a reference check, since setValue always creates a new values object. A
|
||||
// concurrent edit during the save produced a new object, so dirty stays true
|
||||
// and the edit isn't silently dropped from the next save/action.
|
||||
setForm(s => s && s.values === form.values ? { ...s, dirty: false } : s)
|
||||
}
|
||||
if (openSeq.current !== seq) return
|
||||
const res = await adminApi.runPluginAction(form.id, a.key)
|
||||
if (openSeq.current !== seq) return
|
||||
setActionResult(r => ({ ...r, [a.key]: res }))
|
||||
} catch (e) {
|
||||
if (openSeq.current !== seq) return
|
||||
const err = e as { response?: { status?: number; data?: { error?: string } } }
|
||||
if (err.response?.status === 404 && err.response?.data?.error === 'Plugin is not active') {
|
||||
// The DB `status` column said active when the dialog opened, but the server's
|
||||
// live-process check (the supervisor's actual child map) disagrees — a stale
|
||||
// row with no running child. Believe the server: show the real reason instead
|
||||
// of a bare "Error", and disable the buttons instead of leaving them clickable.
|
||||
setActionResult(r => ({ ...r, [a.key]: { ok: false, message: t('admin.plugins.actions.inactive') } }))
|
||||
setForm(s => s && { ...s, active: false })
|
||||
} else {
|
||||
setActionResult(r => ({ ...r, [a.key]: { ok: false, message: t('common.error') } }))
|
||||
}
|
||||
} finally {
|
||||
if (openSeq.current === seq) setRunningAction(null)
|
||||
}
|
||||
}
|
||||
|
||||
const runAction = (a: PluginAction) => {
|
||||
if (!form || !form.active) return
|
||||
if (a.danger) { setPendingAction(a); return }
|
||||
void perform(a)
|
||||
}
|
||||
|
||||
const confirmPendingAction = () => {
|
||||
const a = pendingAction
|
||||
setPendingAction(null)
|
||||
if (a) void perform(a)
|
||||
}
|
||||
|
||||
const cancelPendingAction = () => setPendingAction(null)
|
||||
|
||||
return {
|
||||
form, saving, error, open, setValue, close, save,
|
||||
actionResult, runningAction, pendingAction, runAction, confirmPendingAction, cancelPendingAction,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user