mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
feat(plugins): install an older version of a plugin (#2035)
Adds a version picker to the plugin admin panel, on desktop and mobile: every published version with its own server-computed compatibility verdict, so the client never re-derives semver range logic. A deliberate non-latest pick sets an update hold on the row, which keeps the update banner and "Update all" from immediately nagging the rollback away. The hold releases when an install lands back on the newest compatible version by any path, or when the admin resumes updates explicitly. An unresolvable registry never sets one: refusing to pin on missing information beats silently muting future updates.
This commit is contained in:
@@ -201,6 +201,9 @@ function registryEntry(over: Record<string, unknown> = {}) {
|
||||
id: 'trek-gotify', name: 'Gotify', author: 'Acme', description: 'Push', repo: 'acme/gotify',
|
||||
type: 'integration', latest: '2.0.0', minTrekVersion: null, reviewedAt: null,
|
||||
screenshotUrl: null, signed: true, authorPublicKey: 'NEWKEYbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
// The real browse response always carries latestCompatible (server-side hostCompat);
|
||||
// mirror `latest` by default, exactly like a fully-compatible entry.
|
||||
latestCompatible: (over.latest as string | undefined) ?? '2.0.0',
|
||||
...over,
|
||||
}
|
||||
}
|
||||
@@ -1087,6 +1090,235 @@ describe('AdminPluginsPanel — updates', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Update availability must follow what this TREK can actually INSTALL (latestCompatible,
|
||||
* computed server-side), never the absolute newest published version — a banner counting
|
||||
* versions the update endpoint would refuse nags the admin toward a guaranteed 400.
|
||||
*/
|
||||
describe('AdminPluginsPanel — compatible updates only', () => {
|
||||
const outdated = plugin({ source_repo: 'acme/gotify', version: '1.0.0', operatorEgress: false })
|
||||
|
||||
it('FE-COMP-PLUGINS-UPD-001: an update this TREK cannot install is neither offered nor counted', async () => {
|
||||
panelWith([outdated], {
|
||||
registry: [registryEntry({ latest: '2.0.0', latestCompatible: null, trek: '>=4.0.0', hostVersion: '3.3.0' })],
|
||||
})
|
||||
render(<AdminPluginsPanel />)
|
||||
await screen.findByText('Gotify')
|
||||
|
||||
expect(screen.queryByRole('button', { name: /update → v2\.0\.0/i })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/updates available for your plugins/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-UPD-002: the update offer is the newest compatible version, not the absolute latest', async () => {
|
||||
panelWith([outdated], {
|
||||
registry: [registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0' })],
|
||||
})
|
||||
render(<AdminPluginsPanel />)
|
||||
|
||||
expect(await screen.findByRole('button', { name: /update → v2\.0\.0/i })).toBeInTheDocument()
|
||||
expect(screen.getByText('1 updates available for your plugins.')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /update → v3\.0\.0/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-UPD-003: a newer version needing a newer TREK leaves a passive hint on the row', async () => {
|
||||
panelWith([outdated], {
|
||||
registry: [registryEntry({ latest: '2.0.0', latestCompatible: null, trek: '>=4.0.0', hostVersion: '3.3.0' })],
|
||||
})
|
||||
render(<AdminPluginsPanel />)
|
||||
|
||||
expect(await screen.findByText('v2.0.0 available — needs TREK >=4.0.0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-UPD-004: a legacy entry with only minTrekVersion still gets a hint range', async () => {
|
||||
panelWith([outdated], {
|
||||
registry: [registryEntry({ latest: '2.0.0', latestCompatible: null, trek: null, minTrekVersion: '4.0.0', hostVersion: '3.3.0' })],
|
||||
})
|
||||
render(<AdminPluginsPanel />)
|
||||
|
||||
expect(await screen.findByText('v2.0.0 available — needs TREK >=4.0.0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-UPD-005: no hint when the latest version is the one on offer', async () => {
|
||||
panelWith([outdated], { registry: [registryEntry({ latest: '2.0.0' })] })
|
||||
render(<AdminPluginsPanel />)
|
||||
|
||||
expect(await screen.findByRole('button', { name: /update → v2\.0\.0/i })).toBeInTheDocument()
|
||||
expect(screen.queryByText(/needs TREK/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The update hold (#plugins): a deliberate non-latest install sets updateHold on the row,
|
||||
* which takes it out of the banner count and "Update all" — a rollback the admin just
|
||||
* made must not be nagged straight back. The row says so and offers to resume.
|
||||
*/
|
||||
describe('AdminPluginsPanel — update hold', () => {
|
||||
const held = plugin({ source_repo: 'acme/gotify', version: '1.0.0', operatorEgress: false, updateHold: true })
|
||||
|
||||
it('FE-COMP-PLUGINS-HOLD-001: a held plugin leaves the banner and loses its update button', async () => {
|
||||
panelWith([held], { registry: [registryEntry({ latest: '2.0.0' })] })
|
||||
render(<AdminPluginsPanel />)
|
||||
await screen.findByText('Gotify')
|
||||
|
||||
expect(screen.queryByText(/updates available for your plugins/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /update → v2\.0\.0/i })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Updates paused at v1.0.0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-HOLD-002: Resume updates releases the hold on the server', async () => {
|
||||
let resumed = false
|
||||
panelWith([held], { registry: [registryEntry({ latest: '2.0.0' })] })
|
||||
server.use(http.post('*/api/admin/plugins/trek-gotify/resume-updates', () => {
|
||||
resumed = true
|
||||
return HttpResponse.json({ updateHold: false })
|
||||
}))
|
||||
withToast()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /resume updates/i }))
|
||||
|
||||
await waitFor(() => expect(resumed).toBe(true))
|
||||
expect(await screen.findByText('Updates resumed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-HOLD-003: Update all skips held plugins', async () => {
|
||||
const updated: string[] = []
|
||||
panelWith(
|
||||
[held, plugin({ id: 'trek-ntfy', name: 'Ntfy', source_repo: 'acme/ntfy', version: '1.0.0', operatorEgress: false })],
|
||||
{ registry: [registryEntry({ latest: '2.0.0' }), registryEntry({ id: 'trek-ntfy', latest: '3.0.0' })] },
|
||||
)
|
||||
server.use(http.post('*/api/admin/plugins/:id/update', ({ params }) => {
|
||||
updated.push(String(params.id))
|
||||
return HttpResponse.json({ version: '3.0.0', activated: true, newPermissions: [], newEgress: [] })
|
||||
}))
|
||||
withToast()
|
||||
|
||||
expect(await screen.findByText('1 updates available for your plugins.')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: /update all/i }))
|
||||
|
||||
await waitFor(() => expect(updated).toEqual(['trek-ntfy']))
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The version picker (#plugins): install any published version from the Discover detail
|
||||
* modal, and roll an installed plugin back through the update pipeline (which owns the
|
||||
* stop-child + re-consent machinery). Compat verdicts are SERVER-computed per version —
|
||||
* the picker only renders them, it never re-derives range logic.
|
||||
*/
|
||||
describe('AdminPluginsPanel — version picker', () => {
|
||||
const VERSIONS = [
|
||||
{ version: '3.0.0', publishedAt: '2026-08-01', size: 2048, signed: true, trek: '>=4.0.0', compatible: false },
|
||||
{ version: '2.0.0', publishedAt: '2026-07-01', size: 1024, signed: true, trek: '>=3.0.0 <4.0.0', compatible: true },
|
||||
{ version: '1.5.0', publishedAt: '2026-06-01', size: 1000, signed: true, trek: '>=3.0.0 <4.0.0', compatible: true },
|
||||
]
|
||||
const detailWithVersions = () => ({
|
||||
...registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false }),
|
||||
size: 1024, publishedAt: null, manifest: null, versions: VERSIONS,
|
||||
})
|
||||
|
||||
function mockDiscoverDetail() {
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [] })),
|
||||
http.get('*/api/admin/plugins/registry', () =>
|
||||
HttpResponse.json([registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false })])),
|
||||
http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions())),
|
||||
)
|
||||
}
|
||||
|
||||
it('FE-COMP-PLUGINS-VPICK-001: the detail modal lists every version and installs the picked one', async () => {
|
||||
let body: unknown = null
|
||||
mockDiscoverDetail()
|
||||
server.use(http.post('*/api/admin/plugins/install', async ({ request }) => {
|
||||
body = await request.json()
|
||||
return HttpResponse.json({ id: 'trek-gotify', version: '1.5.0' })
|
||||
}))
|
||||
withToast()
|
||||
const tabs = await screen.findAllByRole('tab', { name: /^discover/i })
|
||||
fireEvent.click(tabs[0])
|
||||
fireEvent.click(await screen.findByText('Gotify'))
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /^install 1\.5\.0$/i }))
|
||||
|
||||
await waitFor(() => expect(body).toEqual({ id: 'trek-gotify', version: '1.5.0' }))
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-VPICK-002: an incompatible version is greyed with its TREK requirement, not installable', async () => {
|
||||
mockDiscoverDetail()
|
||||
render(<AdminPluginsPanel />)
|
||||
const tabs = await screen.findAllByRole('tab', { name: /^discover/i })
|
||||
fireEvent.click(tabs[0])
|
||||
fireEvent.click(await screen.findByText('Gotify'))
|
||||
|
||||
expect(await screen.findByText(/^needs TREK >=4\.0\.0$/i)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /^install 3\.0\.0$/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-VPICK-003: Change version rolls back through update, after an explicit data warning', async () => {
|
||||
let body: unknown = null
|
||||
panelWith([plugin({ source_repo: 'acme/gotify', version: '2.0.0', operatorEgress: false })], {
|
||||
registry: [registryEntry({ latest: '2.0.0' })],
|
||||
})
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions())),
|
||||
http.post('*/api/admin/plugins/trek-gotify/update', async ({ request }) => {
|
||||
body = await request.json()
|
||||
return HttpResponse.json({ version: '1.5.0', activated: true, newPermissions: [], newEgress: [] })
|
||||
}),
|
||||
)
|
||||
withToast()
|
||||
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /change version/i }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /^switch to 1\.5\.0$/i }))
|
||||
|
||||
// The rollback keeps the plugin's data — the older code may not read it. Informed consent, no snapshot.
|
||||
expect(await screen.findByText(/data written by the newer version stays in place/i)).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: /roll back/i }))
|
||||
|
||||
await waitFor(() => expect(body).toEqual({ version: '1.5.0' }))
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-VPICK-004: picking a NEWER version in the picker updates without the downgrade warning', async () => {
|
||||
let body: unknown = null
|
||||
panelWith([plugin({ source_repo: 'acme/gotify', version: '1.5.0', operatorEgress: false })], {
|
||||
registry: [registryEntry({ latest: '2.0.0' })],
|
||||
})
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions())),
|
||||
http.post('*/api/admin/plugins/trek-gotify/update', async ({ request }) => {
|
||||
body = await request.json()
|
||||
return HttpResponse.json({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] })
|
||||
}),
|
||||
)
|
||||
withToast()
|
||||
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /change version/i }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /^switch to 2\.0\.0$/i }))
|
||||
|
||||
await waitFor(() => expect(body).toEqual({ version: '2.0.0' }))
|
||||
expect(screen.queryByText(/data written by the newer version stays in place/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-VPICK-005: the installed version is marked and not switchable; sideloads get no picker', async () => {
|
||||
panelWith([
|
||||
plugin({ source_repo: 'acme/gotify', version: '2.0.0', operatorEgress: false }),
|
||||
plugin({ id: 'side-load', name: 'Sideload', source_repo: 'local:upload', version: '1.0.0', operatorEgress: false }),
|
||||
], { registry: [registryEntry({ latest: '2.0.0' })] })
|
||||
server.use(http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions())))
|
||||
withToast()
|
||||
|
||||
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-side-load'))
|
||||
expect(screen.queryByRole('button', { name: /change version/i })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('plugin-row-menu-btn-side-load')) // close
|
||||
|
||||
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /change version/i }))
|
||||
|
||||
const current = await screen.findByTestId('version-row-2.0.0')
|
||||
expect(within(current).getByText('Installed')).toBeInTheDocument()
|
||||
expect(within(current).queryByRole('button', { name: /switch/i })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AdminPluginsPanel — capability and dependency chips', () => {
|
||||
it('FE-COMP-PLUGINS-PANEL-035: a permission-rich plugin renders one chip per capability', async () => {
|
||||
panelWith([plugin({
|
||||
@@ -1405,7 +1637,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
||||
|
||||
it('FE-W5PLG-005: the update filter and the updates-first sort both use the registry version', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockPanel(three(), [{ id: 'c-page', name: 'Gamma Page', latest: '9.9.9' }])
|
||||
mockPanel(three(), [{ id: 'c-page', name: 'Gamma Page', latest: '9.9.9', latestCompatible: '9.9.9' }])
|
||||
render(<AdminPluginsPanel />)
|
||||
await screen.findByText('Alpha Widget')
|
||||
await waitFor(() => expect(screen.getByText(/updates? available/i)).toBeInTheDocument())
|
||||
@@ -1423,7 +1655,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
||||
|
||||
it('FE-W5PLG-006: switching tabs snaps a tab-only sort key back to name', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockPanel(three(), [{ id: 'c-page', name: 'Gamma Page', latest: '9.9.9' }])
|
||||
mockPanel(three(), [{ id: 'c-page', name: 'Gamma Page', latest: '9.9.9', latestCompatible: '9.9.9' }])
|
||||
render(<AdminPluginsPanel />)
|
||||
await screen.findByText('Alpha Widget')
|
||||
|
||||
@@ -1880,8 +2112,8 @@ describe('AdminPluginsPanel — update all', () => {
|
||||
plugins: [updatableRow(), updatableRow({ id: 'b-widget', name: 'Beta Widget' })],
|
||||
})),
|
||||
http.get('*/api/admin/plugins/registry', () => HttpResponse.json([
|
||||
{ id: 'a-widget', name: 'Alpha Widget', author: 'acme', description: 'd', type: 'widget', latest: '2.0.0' },
|
||||
{ id: 'b-widget', name: 'Beta Widget', author: 'acme', description: 'd', type: 'widget', latest: '2.0.0' },
|
||||
{ id: 'a-widget', name: 'Alpha Widget', author: 'acme', description: 'd', type: 'widget', latest: '2.0.0', latestCompatible: '2.0.0' },
|
||||
{ id: 'b-widget', name: 'Beta Widget', author: 'acme', description: 'd', type: 'widget', latest: '2.0.0', latestCompatible: '2.0.0' },
|
||||
])),
|
||||
http.post('*/api/admin/plugins/:id/update', async ({ params }) => {
|
||||
started.push(String(params.id))
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud,
|
||||
ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert,
|
||||
SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard,
|
||||
Radio, Luggage, Globe, Image, CalendarDays, Bell,
|
||||
Radio, Luggage, Globe, Image, CalendarDays, Bell, Info, History, PauseCircle,
|
||||
Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, Route, Navigation, Clock, LocateFixed, Palette,
|
||||
} from 'lucide-react'
|
||||
import PluginIcon from '../shared/PluginIcon'
|
||||
@@ -63,6 +63,8 @@ interface PluginRow {
|
||||
keyFingerprint?: string | null
|
||||
/** Why an update was refused, if one was. `version` is the version that was refused. */
|
||||
updateBlock?: { code: string; detail: string | null; version: string | null } | null
|
||||
/** A deliberate non-latest install paused updates: out of the banner/Update all until resumed. */
|
||||
updateHold?: boolean
|
||||
}
|
||||
interface RegistryItem {
|
||||
id: string
|
||||
@@ -93,9 +95,21 @@ interface RegistryItem {
|
||||
/** The full key — public, and carried in full because re-trust compares it exactly. */
|
||||
authorPublicKey?: string | null
|
||||
}
|
||||
/** One published version, with its SERVER-computed compat verdict (the client has no semver). */
|
||||
interface VersionInfo {
|
||||
version: string
|
||||
publishedAt: string | null
|
||||
size: number | null
|
||||
signed: boolean
|
||||
/** The declared TREK requirement, or null when the entry carries no bounds at all. */
|
||||
trek: string | null
|
||||
compatible: boolean
|
||||
}
|
||||
interface RegistryDetail extends RegistryItem {
|
||||
size: number | null
|
||||
publishedAt: string | null
|
||||
/** Every published version, newest first — the version picker's data. */
|
||||
versions?: VersionInfo[]
|
||||
manifest: {
|
||||
// Optional because a registry may omit an empty list — the detail view has to
|
||||
// degrade rather than throw halfway through its render.
|
||||
@@ -392,6 +406,12 @@ export default function AdminPluginsPanel() {
|
||||
const [egressSaving, setEgressSaving] = useState(false)
|
||||
const [egressError, setEgressError] = useState('')
|
||||
const [confirmUninstall, setConfirmUninstall] = useState<PluginRow | null>(null)
|
||||
// The version picker for an INSTALLED plugin ("Change version…"); versions land async
|
||||
// from the registry detail endpoint, which computes each version's compat verdict.
|
||||
const [versionPicker, setVersionPicker] = useState<{ plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean } | null>(null)
|
||||
// A picked version OLDER than the installed one — held here until the admin consents
|
||||
// to the data risk (the plugin's data dir stays; older code may not understand it).
|
||||
const [confirmDowngrade, setConfirmDowngrade] = useState<{ plugin: PluginRow; version: string } | null>(null)
|
||||
// A QUEUE, not one slot: "Update All" can produce several re-consent prompts —
|
||||
// each must be shown, not silently overwritten by the last one.
|
||||
const [consentQueue, setConsentQueue] = useState<Array<{ plugin: PluginRow; version: string; newPermissions: string[]; newEgress: string[] }>>([])
|
||||
@@ -420,10 +440,13 @@ export default function AdminPluginsPanel() {
|
||||
|
||||
// Index the registry once per fetch: the version map the update badges read, plus the
|
||||
// whole entry (author key + signed flag) the trust badges and re-trust dialog need.
|
||||
// The map holds latestCompatible — the newest version THIS TREK can install (computed
|
||||
// server-side) — not the absolute latest, so the banner never counts an update the
|
||||
// update endpoint would refuse.
|
||||
const indexRegistry = (items: RegistryItem[]) => {
|
||||
const vers: Record<string, string> = {}
|
||||
const byId: Record<string, RegistryItem> = {}
|
||||
items.forEach((i) => { byId[i.id] = i; if (i.latest) vers[i.id] = i.latest })
|
||||
items.forEach((i) => { byId[i.id] = i; if (i.latestCompatible) vers[i.id] = i.latestCompatible })
|
||||
setLatest(vers)
|
||||
setRegById(byId)
|
||||
}
|
||||
@@ -551,7 +574,21 @@ export default function AdminPluginsPanel() {
|
||||
.catch(() => setErrorsFor({ id, rows: [] }))
|
||||
}
|
||||
|
||||
const updateAvailable = (p: PluginRow) => !!(p.version && latest[p.id] && isNewer(latest[p.id], p.version))
|
||||
// A held plugin (deliberate non-latest install) is deliberately NOT an update candidate:
|
||||
// the admin just rolled it back, and the banner nagging them straight back would make
|
||||
// the rollback fight the UI. The row carries its own "paused" marker + resume instead.
|
||||
const updateAvailable = (p: PluginRow) => !!(p.version && !p.updateHold && latest[p.id] && isNewer(latest[p.id], p.version))
|
||||
const resumeUpdates = (p: PluginRow) => act(p.id, () => adminApi.pluginResumeUpdates(p.id), t('admin.plugins.updatesResumed'))
|
||||
// A newer version exists but this TREK can't install it (and it isn't the one on offer):
|
||||
// said passively on the row, so the admin learns a TREK upgrade unlocks it instead of
|
||||
// wondering why no update shows. Null when the registry gives no range to point at.
|
||||
const newerIncompatible = (p: PluginRow): { version: string; range: string } | null => {
|
||||
const reg = regById[p.id]
|
||||
if (!reg?.latest || !p.version || !isNewer(reg.latest, p.version)) return null
|
||||
if (latest[p.id] === reg.latest) return null
|
||||
const range = reg.trek ?? (reg.minTrekVersion ? `>=${reg.minTrekVersion}` : null)
|
||||
return range ? { version: reg.latest, range } : null
|
||||
}
|
||||
const install = (id: string, version?: string) => act(id, () => adminApi.pluginInstall(id, version ? { version } : undefined), t('admin.plugins.installed'))
|
||||
const restart = (id: string) => act(id, async () => { await adminApi.pluginDeactivate(id); await adminApi.pluginActivate(id) }, t('admin.plugins.restarted'))
|
||||
// Dev-link: register a plugin from a local built directory (dev only). Reuses the
|
||||
@@ -639,9 +676,11 @@ export default function AdminPluginsPanel() {
|
||||
.finally(() => { setBusy(null); refresh() })
|
||||
}
|
||||
|
||||
const runUpdate = (p: PluginRow) => {
|
||||
// `version` pins the exact version to install (rollback / explicit switch); omitted,
|
||||
// the server resolves the newest TREK-compatible version itself.
|
||||
const runUpdate = (p: PluginRow, version?: string) => {
|
||||
setBusy(p.id); setMenu(null)
|
||||
return adminApi.pluginUpdate(p.id)
|
||||
return adminApi.pluginUpdate(p.id, version)
|
||||
.then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }) => {
|
||||
if (r.activated || (r.newPermissions.length === 0 && r.newEgress.length === 0)) toast.success(t('admin.plugins.updated'))
|
||||
else setConsentQueue(qq => [...qq, { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }])
|
||||
@@ -673,6 +712,27 @@ export default function AdminPluginsPanel() {
|
||||
.finally(() => { setRetrusting(false); refresh() })
|
||||
}
|
||||
|
||||
// "Change version…" on an installed row: fetch the per-version list (with the server's
|
||||
// compat verdicts) and open the picker. The fetch is keyed to the plugin so a stale
|
||||
// response can't populate a picker that was reopened for another row.
|
||||
const openVersionPicker = (p: PluginRow) => {
|
||||
setMenu(null)
|
||||
setVersionPicker({ plugin: p, versions: null, failed: false })
|
||||
adminApi.pluginDetail(p.id)
|
||||
.then((d: RegistryDetail) => setVersionPicker(cur => (cur && cur.plugin.id === p.id ? { ...cur, versions: d.versions ?? [] } : cur)))
|
||||
.catch(() => setVersionPicker(cur => (cur && cur.plugin.id === p.id ? { ...cur, failed: true } : cur)))
|
||||
}
|
||||
|
||||
// Switching DOWN keeps the plugin's data dir, which the older code may not understand —
|
||||
// that asks for explicit consent first. Any other switch is the ordinary update path.
|
||||
const pickVersion = (version: string) => {
|
||||
if (!versionPicker) return
|
||||
const p = versionPicker.plugin
|
||||
setVersionPicker(null)
|
||||
if (p.version && isNewer(p.version, version)) setConfirmDowngrade({ plugin: p, version })
|
||||
else void runUpdate(p, version)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const updatable = useMemo(() => plugins.filter(updateAvailable), [plugins, latest])
|
||||
|
||||
@@ -885,9 +945,12 @@ export default function AdminPluginsPanel() {
|
||||
) : shownInstalled.map(p => (
|
||||
<InstalledRow key={p.id} p={p} t={t} busy={busy} menu={menu} setMenu={setMenu}
|
||||
hasUpdate={updateAvailable(p)} latestVer={latest[p.id]}
|
||||
newerIncompatible={newerIncompatible(p)}
|
||||
blocked={blockIsCurrent(p, latest[p.id])}
|
||||
onToggle={() => toggle(p)}
|
||||
onUpdate={() => runUpdate(p)} onRestart={() => restart(p.id)}
|
||||
onChangeVersion={() => openVersionPicker(p)}
|
||||
onResume={() => void resumeUpdates(p)}
|
||||
onReviewBlock={() => setSignatureBlock({
|
||||
subject: { id: p.id, name: p.name, keyFingerprint: p.keyFingerprint ?? null },
|
||||
code: p.updateBlock!.code, detail: p.updateBlock!.detail,
|
||||
@@ -990,6 +1053,20 @@ export default function AdminPluginsPanel() {
|
||||
message={t('admin.plugins.uninstallBody')}
|
||||
/>
|
||||
|
||||
{versionPicker && (
|
||||
<VersionPickerDialog plugin={versionPicker.plugin} versions={versionPicker.versions} failed={versionPicker.failed}
|
||||
busy={busy} t={t} locale={locale} onPick={pickVersion} onClose={() => setVersionPicker(null)} />
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={!!confirmDowngrade}
|
||||
onClose={() => setConfirmDowngrade(null)}
|
||||
onConfirm={() => { const d = confirmDowngrade!; setConfirmDowngrade(null); void runUpdate(d.plugin, d.version) }}
|
||||
title={t('admin.plugins.downgradeTitle')}
|
||||
message={t('admin.plugins.downgradeBody', { from: confirmDowngrade?.plugin.version ?? '', to: confirmDowngrade?.version ?? '' })}
|
||||
confirmLabel={t('admin.plugins.downgradeConfirm')}
|
||||
/>
|
||||
|
||||
{signatureBlock && (
|
||||
<SignatureBlockDialog
|
||||
data={signatureBlock} entry={regById[signatureBlock.subject.id]} busy={retrusting} t={t}
|
||||
@@ -1102,10 +1179,10 @@ function EmptyState({ t, onDiscover }: { t: T; onDiscover: () => void }) {
|
||||
)
|
||||
}
|
||||
|
||||
function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked, onToggle, onUpdate, onRestart, onReviewBlock, onErrors, onEgress, onUninstall }: {
|
||||
function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked, onToggle, onUpdate, onRestart, onChangeVersion, onResume, onReviewBlock, onErrors, onEgress, onUninstall }: {
|
||||
p: PluginRow; t: T; busy: string | null; menu: string | null; setMenu: (v: string | null) => void
|
||||
hasUpdate: boolean; latestVer?: string; blocked: boolean
|
||||
onToggle: () => void; onUpdate: () => void; onRestart: () => void; onReviewBlock: () => void
|
||||
hasUpdate: boolean; latestVer?: string; newerIncompatible: { version: string; range: string } | null; blocked: boolean
|
||||
onToggle: () => void; onUpdate: () => void; onRestart: () => void; onChangeVersion: () => void; onResume: () => void; onReviewBlock: () => void
|
||||
onErrors: () => void; onEgress: () => void; onUninstall: () => void
|
||||
}) {
|
||||
const caps = deriveCaps(parseJson<string[]>(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t)
|
||||
@@ -1164,6 +1241,26 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* A newer version this TREK can't run — informational, never a button: the fix
|
||||
is a TREK upgrade, so the row must not nag or offer a doomed install. */}
|
||||
{newerIncompatible && (
|
||||
<div className="flex items-center gap-1.5 mt-1.5 text-[11.5px] text-content-faint">
|
||||
<Info size={13} className="shrink-0" />
|
||||
<span className="truncate">{t('admin.plugins.newerNeedsTrek', { version: newerIncompatible.version, range: newerIncompatible.range })}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Held: the admin deliberately installed a non-latest version, so updates are
|
||||
paused rather than nagged — resuming is one click, right where the pause shows. */}
|
||||
{p.updateHold && (
|
||||
<div className="flex items-center gap-1.5 mt-1.5 text-[11.5px] text-content-faint">
|
||||
<PauseCircle size={13} className="shrink-0" />
|
||||
<span className="truncate">{t('admin.plugins.updatesHeld', { version: p.version ?? '' })}</span>
|
||||
<button type="button" onClick={onResume} disabled={busy === p.id}
|
||||
className="shrink-0 font-semibold underline underline-offset-2 hover:opacity-80 transition-opacity disabled:opacity-50">
|
||||
{t('admin.plugins.resumeUpdates')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{p.status === 'error' && p.last_error ? (
|
||||
<div className="flex items-center gap-1.5 mt-1.5 text-[11.5px] text-danger">
|
||||
<AlertTriangle size={13} className="shrink-0" /><span className="truncate">{p.last_error}</span>
|
||||
@@ -1233,6 +1330,10 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked
|
||||
)}
|
||||
<MenuItem icon={<Bug size={14} />} label={t('admin.plugins.viewErrors')} onClick={onErrors} />
|
||||
<MenuItem icon={<Globe size={14} />} label={t('admin.plugins.allowedHosts')} onClick={onEgress} />
|
||||
{/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */}
|
||||
{isRegistrySourced(p.source_repo) && (
|
||||
<MenuItem icon={<History size={14} />} label={t('admin.plugins.changeVersion')} onClick={onChangeVersion} />
|
||||
)}
|
||||
{p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link' && (
|
||||
<>
|
||||
<a href={`https://github.com/${p.source_repo}`} target="_blank" rel="noreferrer" onClick={() => setMenu(null)}
|
||||
@@ -1521,6 +1622,34 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Every published version, installable individually. The compat verdict per
|
||||
version is SERVER-computed — an incompatible one is explained, never offered. */}
|
||||
{(detail?.versions?.length ?? 0) > 1 && (
|
||||
<div className="mt-5">
|
||||
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.versionsTitle')}</h4>
|
||||
<div className="mt-2 space-y-0.5">
|
||||
{detail!.versions!.map(v => (
|
||||
<div key={v.version} className="flex items-center gap-2.5 py-1">
|
||||
<span className={`text-[12.5px] font-medium tabular-nums ${v.compatible ? 'text-content' : 'text-content-faint'}`}>v{v.version}</span>
|
||||
{v.publishedAt && <span className="text-[11.5px] text-content-faint">{new Date(v.publishedAt).toLocaleDateString(locale)}</span>}
|
||||
{v.signed && <ShieldCheck size={13} className="text-success shrink-0" />}
|
||||
<span className="ml-auto" />
|
||||
{v.compatible ? (
|
||||
!installed && (
|
||||
<button type="button" onClick={() => onInstall(item.id, v.version)} disabled={busy === item.id}
|
||||
className="text-[11.5px] font-semibold px-2.5 py-1 rounded-full border border-edge bg-surface-card text-content-secondary hover:text-content hover:border-content-faint transition-colors disabled:opacity-50">
|
||||
{t('admin.plugins.installCompatible', { version: v.version })}
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<span className="text-[11.5px] text-content-faint">{t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 px-4 sm:px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary">
|
||||
@@ -1548,6 +1677,55 @@ function Meta({ k, v }: { k: string; v: string }) {
|
||||
return <div><div className="text-[12px] text-content-faint">{k}</div><div className="text-[12.5px] font-medium text-content mt-0.5">{v}</div></div>
|
||||
}
|
||||
|
||||
/**
|
||||
* "Change version…" for an INSTALLED plugin. Rows render the server's per-version compat
|
||||
* verdict: the installed version is marked, a compatible one gets a Switch action (the
|
||||
* caller routes a DOWNGRADE through the data-risk confirm), an incompatible one is
|
||||
* explained and never offered.
|
||||
*/
|
||||
function VersionPickerDialog({ plugin, versions, failed, busy, t, locale, onPick, onClose }: {
|
||||
plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean; busy: string | null
|
||||
t: T; locale: string; onPick: (version: string) => void; onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
<div className="bg-surface-card border border-edge rounded-2xl w-full max-w-md max-h-[80vh] overflow-auto shadow-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-4 py-3.5 border-b border-edge-secondary">
|
||||
<h3 className="text-[15px] font-semibold text-content truncate">{t('admin.plugins.versionPickerTitle', { name: plugin.name })}</h3>
|
||||
<button type="button" onClick={onClose} className="w-8 h-8 grid place-items-center rounded-lg text-content-faint hover:text-content hover:bg-surface-tertiary transition-colors shrink-0"><X size={16} /></button>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
{failed && <p className="text-xs text-danger px-2.5 py-2">{t('admin.plugins.detailError')}</p>}
|
||||
{!failed && !versions && <p className="text-xs text-content-faint px-2.5 py-2">{t('common.loading')}</p>}
|
||||
{versions?.length === 0 && <p className="text-xs text-content-faint px-2.5 py-2">{t('admin.plugins.noVersions')}</p>}
|
||||
{versions?.map(v => {
|
||||
const current = v.version === plugin.version
|
||||
return (
|
||||
<div key={v.version} data-testid={`version-row-${v.version}`}
|
||||
className="flex items-center gap-2.5 px-2.5 py-2 rounded-xl hover:bg-surface-secondary transition-colors">
|
||||
<span className={`text-[13px] font-medium tabular-nums ${v.compatible ? 'text-content' : 'text-content-faint'}`}>v{v.version}</span>
|
||||
{v.publishedAt && <span className="text-[11.5px] text-content-faint">{new Date(v.publishedAt).toLocaleDateString(locale)}</span>}
|
||||
{v.signed && <ShieldCheck size={13} className="text-success shrink-0" />}
|
||||
<span className="ml-auto" />
|
||||
{current ? (
|
||||
<span className="text-[11.5px] font-medium text-content-faint">{t('admin.plugins.installed')}</span>
|
||||
) : v.compatible ? (
|
||||
<button type="button" onClick={() => onPick(v.version)} disabled={busy === plugin.id}
|
||||
className="text-[11.5px] font-semibold px-2.5 py-1 rounded-full border border-edge bg-surface-card text-content-secondary hover:text-content hover:border-content-faint transition-colors disabled:opacity-50">
|
||||
{t('admin.plugins.versionSwitch', { version: v.version })}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-[11.5px] text-content-faint">{t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A signature check refused an install/update.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user