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:
jubnl
2026-09-02 23:06:03 +02:00
committed by GitHub
parent b1ce7c303d
commit b82962f95b
59 changed files with 1133 additions and 128 deletions
@@ -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 })),