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' export interface InstanceSettingsForm { id: string fields: PluginUserSettingField[] values: Record /** 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 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() const toast = useToast() const [form, setForm] = useState(null) const [saving, setSaving] = useState(false) const [error, setError] = useState('') const [actionResult, setActionResult] = useState>({}) const [runningAction, setRunningAction] = useState(null) const [pendingAction, setPendingAction] = useState(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, 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), 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 }, dirty: true })) const close = () => { openSeq.current++; setForm(null); setPendingAction(null); setRunningAction(null) } /** Persist the form. Returns false when the save was refused (error already shown). */ const persist = async (f: InstanceSettingsForm): Promise => { const missing = findMissingRequired(f.fields, f.values) if (missing) { setError(t('admin.plugins.requiredMissing', { field: missing.label || missing.key })) return false } setSaving(true); setError('') try { const d = await adminApi.pluginSaveConfig(f.id, settingsPatch(f.fields, f.values)) toast.success(t(d.restarted ? 'admin.plugins.settingsSavedRestarted' : 'admin.plugins.settingsSaved')) 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) } } 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, } }