mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +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:
@@ -2223,11 +2223,22 @@ describe('AdminPluginsPanel — instance settings', () => {
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-CFG-002: a plugin with NO instance fields gets no menu item', async () => {
|
||||
await openRowMenu(plugin({ instanceSettingsCount: 0 }))
|
||||
await openRowMenu(plugin({ instanceSettingsCount: 0, instanceActionsCount: 0 }))
|
||||
expect(screen.getByText('View error log')).toBeInTheDocument() // menu is open
|
||||
expect(screen.queryByText('Instance settings')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-CFG-008: a plugin with instance ACTIONS but no fields still offers the menu item', async () => {
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins/trek-gotify/config', () =>
|
||||
HttpResponse.json({ fields: [], config: {}, actions: [{ key: 'ping', label: 'Ping server', danger: false, scope: 'instance' }] })),
|
||||
)
|
||||
await openRowMenu(plugin({ instanceSettingsCount: 0, instanceActionsCount: 1 }))
|
||||
expect(screen.getByText('Instance settings')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('Instance settings'))
|
||||
expect(await screen.findByRole('button', { name: 'Ping server' }, { timeout: 5000 })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-CFG-003: saving sends the edits but never the untouched secret mask', async () => {
|
||||
let body: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
@@ -2293,6 +2304,73 @@ describe('AdminPluginsPanel — instance settings', () => {
|
||||
expect(body).toEqual({ mode: 'fast', enabled: false, retries: '3' })
|
||||
})
|
||||
|
||||
const ACTIONS = [
|
||||
{ key: 'ping', label: 'Ping server', danger: false, scope: 'instance' },
|
||||
{ key: 'purge', label: 'Purge cache', hint: 'Drops every cached tile', danger: true, scope: 'instance' },
|
||||
]
|
||||
|
||||
async function openWithActions(p: Record<string, unknown> = plugin({ instanceSettingsCount: 2 })) {
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins/trek-gotify/config', () =>
|
||||
HttpResponse.json({ fields: FIELDS, config: { apiUrl: 'https://gotify.mydomain.com' }, actions: ACTIONS })),
|
||||
)
|
||||
await openRowMenu(p)
|
||||
fireEvent.click(screen.getByText('Instance settings'))
|
||||
await waitFor(() => expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument(), { timeout: 5000 })
|
||||
}
|
||||
|
||||
it('FE-COMP-PLUGINS-ACT-010: instance actions render below the fields and run against the admin route', async () => {
|
||||
let posted = ''
|
||||
server.use(
|
||||
http.post('*/api/admin/plugins/trek-gotify/actions/:key', ({ params }) => {
|
||||
posted = String(params.key)
|
||||
return HttpResponse.json({ ok: true, message: 'pong' })
|
||||
}),
|
||||
)
|
||||
await openWithActions()
|
||||
expect(screen.getByText('Drops every cached tile')).toBeInTheDocument() // hint
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Ping server' }))
|
||||
expect(await screen.findByText('pong', {}, { timeout: 5000 })).toBeInTheDocument()
|
||||
expect(posted).toBe('ping')
|
||||
// The dialog stays open — the admin may run another one.
|
||||
expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-ACT-011: a danger action asks first; confirming runs it', async () => {
|
||||
let posted = ''
|
||||
server.use(
|
||||
http.post('*/api/admin/plugins/trek-gotify/actions/:key', ({ params }) => {
|
||||
posted = String(params.key)
|
||||
return HttpResponse.json({ ok: false, message: 'cache locked' })
|
||||
}),
|
||||
)
|
||||
await openWithActions()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Purge cache' }))
|
||||
expect(await screen.findByText('Run this action?')).toBeInTheDocument()
|
||||
expect(posted).toBe('')
|
||||
fireEvent.click(screen.getByRole('button', { name: /^confirm$/i }))
|
||||
expect(await screen.findByText('cache locked', {}, { timeout: 5000 })).toBeInTheDocument()
|
||||
expect(posted).toBe('purge')
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-ACT-012: an inactive plugin shows the buttons disabled with the activation hint', async () => {
|
||||
await openWithActions(plugin({ instanceSettingsCount: 2, status: 'inactive', enabled: 0 }))
|
||||
expect(screen.getByRole('button', { name: 'Ping server' })).toBeDisabled()
|
||||
expect(screen.getByText('Activate the plugin to run its actions')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-ACT-013: a dirty form is saved before the action runs', async () => {
|
||||
const calls: string[] = []
|
||||
server.use(
|
||||
http.put('*/api/admin/plugins/trek-gotify/config', () => { calls.push('save'); return HttpResponse.json({ config: {}, restarted: false }) }),
|
||||
http.post('*/api/admin/plugins/trek-gotify/actions/:key', () => { calls.push('run'); return HttpResponse.json({ ok: true }) }),
|
||||
)
|
||||
await openWithActions()
|
||||
fireEvent.change(screen.getByDisplayValue('https://gotify.mydomain.com'), { target: { value: 'https://new.example' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Ping server' }))
|
||||
await waitFor(() => expect(calls).toEqual(['save', 'run']), { timeout: 5000 })
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-CFG-006: a failed config fetch is a toast, not a broken dialog', async () => {
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard,
|
||||
Radio, Luggage, Globe, Image, CalendarDays, Bell, Info, History, PauseCircle,
|
||||
Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, Route, Navigation, Clock, LocateFixed, Palette, Bot,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import PluginIcon from '../shared/PluginIcon'
|
||||
import { adminApi } from '../../api/client'
|
||||
@@ -53,6 +54,9 @@ interface PluginRow {
|
||||
/** How many `scope:'instance'` settings fields the plugin declares — gates the
|
||||
* "Instance settings" menu item without a per-plugin fetch. */
|
||||
instanceSettingsCount?: number
|
||||
/** How many `scope:'instance'` actions the plugin declares — a plugin with actions
|
||||
* but no settings fields still needs the menu item to run them. */
|
||||
instanceActionsCount?: number
|
||||
dependencies?: PluginDependencies
|
||||
dependencyStatus?: DependencyStatus
|
||||
dependencyIssues?: DependencyIssues
|
||||
@@ -579,9 +583,9 @@ export default function AdminPluginsPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
const openInstanceSettings = (id: string) => {
|
||||
const openInstanceSettings = (p: { id: string; status: string }) => {
|
||||
setMenu(null)
|
||||
settings.open(id)
|
||||
settings.open(p.id, p.status === 'active')
|
||||
}
|
||||
|
||||
const openErrors = (id: string) => {
|
||||
@@ -973,7 +977,7 @@ export default function AdminPluginsPanel() {
|
||||
code: p.updateBlock!.code, detail: p.updateBlock!.detail,
|
||||
})}
|
||||
onErrors={() => openErrors(p.id)} onEgress={() => openEgress(p.id)}
|
||||
onSettings={() => openInstanceSettings(p.id)}
|
||||
onSettings={() => openInstanceSettings(p)}
|
||||
onUninstall={() => { setMenu(null); setConfirmUninstall(p) }} />
|
||||
))}
|
||||
</div>
|
||||
@@ -1103,11 +1107,50 @@ export default function AdminPluginsPanel() {
|
||||
{f.hint && <span className="block text-xs text-content-muted mt-1">{f.hint}</span>}
|
||||
</label>
|
||||
))}
|
||||
{(() => {
|
||||
const form = settings.form
|
||||
if (!form || form.actions.length === 0) return null
|
||||
return (
|
||||
<div className="border-t border-edge-secondary pt-4">
|
||||
<span className="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">
|
||||
{t('admin.plugins.actions')}
|
||||
</span>
|
||||
{!form.active && (
|
||||
<p className="text-xs text-content-muted mb-2">{t('admin.plugins.actions.inactive')}</p>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
{form.actions.map(a => {
|
||||
const res = settings.actionResult[a.key]
|
||||
return (
|
||||
<div key={a.key} className="flex flex-wrap items-center gap-2">
|
||||
<button type="button"
|
||||
onClick={() => settings.runAction(a)}
|
||||
disabled={!form.active || settings.runningAction !== null || settings.saving}
|
||||
className={`inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm font-semibold disabled:opacity-60 ${
|
||||
a.danger ? 'border-danger text-danger' : 'border-edge text-content'
|
||||
}`}
|
||||
>
|
||||
{settings.runningAction === a.key && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{a.label}
|
||||
</button>
|
||||
{a.hint && <span className="text-xs text-content-muted">{a.hint}</span>}
|
||||
{res && (
|
||||
<span aria-live="polite" className={`text-xs font-medium ${res.ok ? 'text-success' : 'text-danger'}`}>
|
||||
{res.message || (res.ok ? t('common.success') : t('common.error'))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{settings.error && <p className="text-xs text-danger">{settings.error}</p>}
|
||||
</div>
|
||||
<div className="px-5 py-3.5 border-t border-edge-secondary flex justify-end">
|
||||
<button type="button"
|
||||
disabled={settings.saving}
|
||||
disabled={settings.saving || settings.runningAction !== null}
|
||||
onClick={() => void settings.save()}
|
||||
className="rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-accent-text disabled:opacity-60"
|
||||
>{t('common.save')}</button>
|
||||
@@ -1116,6 +1159,15 @@ export default function AdminPluginsPanel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={settings.pendingAction !== null}
|
||||
onClose={settings.cancelPendingAction}
|
||||
onConfirm={settings.confirmPendingAction}
|
||||
title={settings.pendingAction?.label ?? ''}
|
||||
message={t('admin.plugins.actions.confirm')}
|
||||
confirmLabel={t('common.confirm')}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={!!confirmUninstall}
|
||||
onClose={() => setConfirmUninstall(null)}
|
||||
@@ -1402,9 +1454,9 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIn
|
||||
{p.enabled === 1 && (
|
||||
<MenuItem icon={<RotateCw size={14} />} label={t('admin.plugins.restart')} onClick={onRestart} />
|
||||
)}
|
||||
{/* Only a plugin that DECLARES scope:'instance' fields gets the item — an
|
||||
empty form would invite the admin to configure nothing. */}
|
||||
{(p.instanceSettingsCount ?? 0) > 0 && (
|
||||
{/* A plugin gets the item if it declares scope:'instance' fields OR actions —
|
||||
an action-only plugin still needs the dialog to run its buttons. */}
|
||||
{((p.instanceSettingsCount ?? 0) > 0 || (p.instanceActionsCount ?? 0) > 0) && (
|
||||
<MenuItem icon={<SlidersHorizontal size={14} />} label={t('admin.plugins.instanceSettings')} onClick={onSettings} />
|
||||
)}
|
||||
<MenuItem icon={<Bug size={14} />} label={t('admin.plugins.viewErrors')} onClick={onErrors} />
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// FE-COMP-PLUGINS-ACT-001 to -006 — the shared instance-settings hook, action side.
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useInstanceSettings } from './useInstanceSettings'
|
||||
|
||||
vi.mock('../shared/Toast', () => ({ useToast: () => ({ success: vi.fn(), error: vi.fn() }) }))
|
||||
vi.mock('../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k }) }))
|
||||
|
||||
const FIELDS = [{ key: 'apiUrl', label: 'API URL', input_type: 'text', required: false, secret: false }]
|
||||
const PURGE = { key: 'purge', label: 'Purge', danger: true, scope: 'instance' as const }
|
||||
const PING = { key: 'ping', label: 'Ping', danger: false, scope: 'instance' as const }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(adminApi, 'pluginConfig').mockResolvedValue({ fields: FIELDS, config: { apiUrl: 'https://a.example' }, actions: [PING, PURGE] })
|
||||
vi.spyOn(adminApi, 'pluginSaveConfig').mockResolvedValue({ config: { apiUrl: 'https://b.example' }, restarted: false })
|
||||
vi.spyOn(adminApi, 'runPluginAction').mockResolvedValue({ ok: true, message: 'pong' })
|
||||
})
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
async function opened(active = true) {
|
||||
const hook = renderHook(() => useInstanceSettings())
|
||||
act(() => hook.result.current.open('p', active))
|
||||
await waitFor(() => expect(hook.result.current.form).not.toBeNull())
|
||||
return hook
|
||||
}
|
||||
|
||||
/** A promise this test controls the settling of, to pin down state mid-flight. */
|
||||
function deferred<T>() {
|
||||
let resolve!: (v: T) => void
|
||||
const promise = new Promise<T>(r => { resolve = r })
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('useInstanceSettings — actions', () => {
|
||||
it('FE-COMP-PLUGINS-ACT-001: open carries the instance actions, the active flag, and a clean form', async () => {
|
||||
const { result } = await opened()
|
||||
expect(result.current.form?.actions).toEqual([PING, PURGE])
|
||||
expect(result.current.form?.active).toBe(true)
|
||||
expect(result.current.form?.dirty).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-ACT-002: a clean form runs the action straight away and keeps the dialog open', async () => {
|
||||
const { result } = await opened()
|
||||
act(() => result.current.runAction(PING))
|
||||
await waitFor(() => expect(result.current.actionResult.ping).toEqual({ ok: true, message: 'pong' }))
|
||||
expect(adminApi.pluginSaveConfig).not.toHaveBeenCalled()
|
||||
expect(adminApi.runPluginAction).toHaveBeenCalledWith('p', 'ping')
|
||||
expect(result.current.form).not.toBeNull()
|
||||
expect(result.current.runningAction).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-ACT-003: a dirty form saves first, then runs, and is clean afterwards', async () => {
|
||||
const { result } = await opened()
|
||||
act(() => result.current.setValue('apiUrl', 'https://b.example'))
|
||||
expect(result.current.form?.dirty).toBe(true)
|
||||
act(() => result.current.runAction(PING))
|
||||
await waitFor(() => expect(result.current.actionResult.ping).toBeDefined())
|
||||
expect(adminApi.pluginSaveConfig).toHaveBeenCalledWith('p', { apiUrl: 'https://b.example' })
|
||||
expect(vi.mocked(adminApi.pluginSaveConfig).mock.invocationCallOrder[0]).toBeLessThan(vi.mocked(adminApi.runPluginAction).mock.invocationCallOrder[0])
|
||||
expect(result.current.form?.dirty).toBe(false)
|
||||
expect(result.current.form).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-ACT-004: a save failure surfaces in error and the action never runs', async () => {
|
||||
vi.mocked(adminApi.pluginSaveConfig).mockRejectedValue({ response: { data: { error: 'nope' } } })
|
||||
const { result } = await opened()
|
||||
act(() => result.current.setValue('apiUrl', 'x'))
|
||||
act(() => result.current.runAction(PING))
|
||||
await waitFor(() => expect(result.current.error).toBe('nope'))
|
||||
expect(adminApi.runPluginAction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-ACT-005: a danger action waits for confirmation; cancel drops it, confirm runs it', async () => {
|
||||
const { result } = await opened()
|
||||
act(() => result.current.runAction(PURGE))
|
||||
expect(result.current.pendingAction).toEqual(PURGE)
|
||||
expect(adminApi.runPluginAction).not.toHaveBeenCalled()
|
||||
act(() => result.current.cancelPendingAction())
|
||||
expect(result.current.pendingAction).toBeNull()
|
||||
act(() => result.current.runAction(PURGE))
|
||||
act(() => result.current.confirmPendingAction())
|
||||
await waitFor(() => expect(adminApi.runPluginAction).toHaveBeenCalledWith('p', 'purge'))
|
||||
expect(result.current.pendingAction).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-ACT-006: an inactive plugin never posts, and a failed result is stored as such', async () => {
|
||||
const { result } = await opened(false)
|
||||
act(() => result.current.runAction(PING))
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
expect(adminApi.runPluginAction).not.toHaveBeenCalled()
|
||||
|
||||
vi.mocked(adminApi.runPluginAction).mockResolvedValue({ ok: false, message: 'credentials rejected' })
|
||||
const active = await opened(true)
|
||||
act(() => active.result.current.runAction(PING))
|
||||
await waitFor(() => expect(active.result.current.actionResult.ping).toEqual({ ok: false, message: 'credentials rejected' }))
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-ACT-007: an edit made while the pre-action save is in flight is not clobbered when dirty clears', async () => {
|
||||
const savePromise = deferred<{ config: Record<string, unknown>; restarted: boolean }>()
|
||||
vi.mocked(adminApi.pluginSaveConfig).mockReturnValue(savePromise.promise)
|
||||
const { result } = await opened()
|
||||
act(() => result.current.setValue('apiUrl', 'https://a-edit.example'))
|
||||
act(() => result.current.runAction(PING))
|
||||
await waitFor(() => expect(adminApi.pluginSaveConfig).toHaveBeenCalledWith('p', { apiUrl: 'https://a-edit.example' }))
|
||||
|
||||
// A concurrent edit lands while the save is still pending.
|
||||
act(() => result.current.setValue('apiUrl', 'https://b-edit.example'))
|
||||
|
||||
await act(async () => {
|
||||
savePromise.resolve({ config: { apiUrl: 'https://a-edit.example' }, restarted: false })
|
||||
await savePromise.promise
|
||||
})
|
||||
|
||||
await waitFor(() => expect(adminApi.runPluginAction).toHaveBeenCalledWith('p', 'ping'))
|
||||
expect(result.current.form?.dirty).toBe(true)
|
||||
expect(result.current.form?.values.apiUrl).toBe('https://b-edit.example')
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-ACT-008: an in-flight action from a closed dialog does not leak into the next one', async () => {
|
||||
const runPromise = deferred<{ ok: boolean; message?: string }>()
|
||||
vi.mocked(adminApi.runPluginAction).mockReturnValueOnce(runPromise.promise)
|
||||
const { result } = await opened()
|
||||
act(() => result.current.runAction(PING))
|
||||
await waitFor(() => expect(adminApi.runPluginAction).toHaveBeenCalledWith('p', 'ping'))
|
||||
|
||||
act(() => result.current.close())
|
||||
act(() => result.current.open('q', true))
|
||||
await waitFor(() => expect(result.current.form?.id).toBe('q'))
|
||||
expect(result.current.runningAction).toBeNull()
|
||||
|
||||
act(() => result.current.runAction(PING))
|
||||
await waitFor(() => expect(adminApi.runPluginAction).toHaveBeenCalledWith('q', 'ping'))
|
||||
await waitFor(() => expect(result.current.actionResult.ping).toEqual({ ok: true, message: 'pong' }))
|
||||
|
||||
// The stale request from the closed dialog settles after the fact — it must not overwrite the new dialog's result.
|
||||
await act(async () => {
|
||||
runPromise.resolve({ ok: true, message: 'stale pong' })
|
||||
await runPromise.promise
|
||||
})
|
||||
expect(result.current.actionResult.ping).toEqual({ ok: true, message: 'pong' })
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-ACT-009: a 404 "Plugin is not active" from the server (DB active, no live child) shows the inactive message and flips active off', async () => {
|
||||
vi.mocked(adminApi.runPluginAction).mockRejectedValue({ response: { status: 404, data: { error: 'Plugin is not active' } } })
|
||||
const { result } = await opened(true)
|
||||
expect(result.current.form?.active).toBe(true)
|
||||
act(() => result.current.runAction(PING))
|
||||
await waitFor(() => expect(result.current.actionResult.ping).toEqual({ ok: false, message: 'admin.plugins.actions.inactive' }))
|
||||
expect(result.current.form?.active).toBe(false)
|
||||
expect(result.current.runningAction).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,7 +275,7 @@ describe('PluginSettingsTab', () => {
|
||||
});
|
||||
|
||||
it('FE-COMP-PLUGINSETTINGS-014: a plugin with only actions gets a card without a Save button', async () => {
|
||||
serve('weather', { actions: [{ key: 'test', label: 'Test connection', hint: 'Pings the API', danger: false }] });
|
||||
serve('weather', { actions: [{ key: 'test', label: 'Test connection', hint: 'Pings the API', danger: false, scope: 'user' }] });
|
||||
setPlugins([plugin()]);
|
||||
render(<PluginSettingsTab />);
|
||||
|
||||
@@ -288,7 +288,7 @@ describe('PluginSettingsTab', () => {
|
||||
|
||||
it('FE-COMP-PLUGINSETTINGS-015: running an action shows the message it returns', async () => {
|
||||
const user = userEvent.setup();
|
||||
serve('weather', { actions: [{ key: 'test', label: 'Test connection', danger: false }] });
|
||||
serve('weather', { actions: [{ key: 'test', label: 'Test connection', danger: false, scope: 'user' }] });
|
||||
server.use(http.post('/api/plugin-settings/weather/actions/test', () =>
|
||||
HttpResponse.json({ ok: true, message: 'Reached the API' })));
|
||||
setPlugins([plugin()]);
|
||||
@@ -300,7 +300,7 @@ describe('PluginSettingsTab', () => {
|
||||
|
||||
it('FE-COMP-PLUGINSETTINGS-016: a message-less result falls back to the generic ok/error labels', async () => {
|
||||
const user = userEvent.setup();
|
||||
serve('weather', { actions: [{ key: 'test', label: 'Test connection', danger: false }] });
|
||||
serve('weather', { actions: [{ key: 'test', label: 'Test connection', danger: false, scope: 'user' }] });
|
||||
server.use(http.post('/api/plugin-settings/weather/actions/test', () => HttpResponse.json({ ok: false })));
|
||||
setPlugins([plugin()]);
|
||||
render(<PluginSettingsTab />);
|
||||
@@ -311,7 +311,7 @@ describe('PluginSettingsTab', () => {
|
||||
|
||||
it('FE-COMP-PLUGINSETTINGS-017: a failing action reports an error result', async () => {
|
||||
const user = userEvent.setup();
|
||||
serve('weather', { actions: [{ key: 'test', label: 'Test connection', danger: false }] });
|
||||
serve('weather', { actions: [{ key: 'test', label: 'Test connection', danger: false, scope: 'user' }] });
|
||||
server.use(http.post('/api/plugin-settings/weather/actions/test', () =>
|
||||
HttpResponse.json({ error: 'down' }, { status: 500 })));
|
||||
setPlugins([plugin()]);
|
||||
@@ -327,7 +327,7 @@ describe('PluginSettingsTab', () => {
|
||||
const user = userEvent.setup();
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
let ran = 0;
|
||||
serve('weather', { actions: [{ key: 'wipe', label: 'Wipe cache', danger: true }] });
|
||||
serve('weather', { actions: [{ key: 'wipe', label: 'Wipe cache', danger: true, scope: 'user' }] });
|
||||
server.use(http.post('/api/plugin-settings/weather/actions/wipe', () => {
|
||||
ran += 1;
|
||||
return HttpResponse.json({ ok: true, message: 'Wiped' });
|
||||
|
||||
Reference in New Issue
Block a user