import { useEffect, useState } from 'react' import { Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, ArrowUpCircle, Puzzle } from 'lucide-react' import { adminApi } from '../../api/client' import { useTranslation } from '../../i18n' import { useToast } from '../shared/Toast' import ConfirmDialog from '../shared/ConfirmDialog' import ToggleSwitch from '../Settings/ToggleSwitch' /** * Admin → Plugins (#plugins). Separates the admin's ON/OFF intent (the toggle, * backed by `enabled`) from runtime health (`status`), surfaces per-plugin errors * and available updates, and hosts the registry browser. Gated by the * runtime-enabled flag. */ interface PluginRow { id: string name: string description: string | null type: string version: string | null status: string enabled: number last_error: string | null reviewed_at: string | null } interface RegistryItem { id: string name: string author: string description: string type: string latest: string | null reviewedAt: string | null } // Runtime health → colour + dot. `enabled` is shown separately by the toggle. const HEALTH: Record = { active: { cls: 'text-emerald-600', dot: 'bg-emerald-500' }, starting: { cls: 'text-sky-600', dot: 'bg-sky-500 animate-pulse' }, error: { cls: 'text-rose-600', dot: 'bg-rose-500' }, inactive: { cls: 'text-content-faint', dot: 'bg-content-faint/50' }, disabled: { cls: 'text-amber-600', dot: 'bg-amber-500' }, incompatible: { cls: 'text-orange-600', dot: 'bg-orange-500' }, } function isNewer(a: string, b: string): boolean { const pa = a.split('.').map(Number) const pb = b.split('.').map(Number) for (let i = 0; i < 3; i++) { const x = pa[i] || 0 const y = pb[i] || 0 if (x !== y) return x > y } return false } export default function AdminPluginsPanel() { const { t } = useTranslation() const toast = useToast() const [runtimeOn, setRuntimeOn] = useState(false) const [plugins, setPlugins] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(false) const [busy, setBusy] = useState(null) const [view, setView] = useState<'installed' | 'browse'>('installed') const [registry, setRegistry] = useState(null) const [latest, setLatest] = useState>({}) const [errorsFor, setErrorsFor] = useState<{ id: string; rows: Array<{ ts: string; level: string; message: string }> } | null>(null) const [confirmUninstall, setConfirmUninstall] = useState(null) const refresh = () => { adminApi.plugins() .then((d: { enabled: boolean; plugins: PluginRow[] }) => { setRuntimeOn(!!d.enabled) setPlugins(d.plugins || []) // Learn the latest registry versions in the background for update badges. if ((d.plugins || []).length) { adminApi.pluginBrowse() .then((items: RegistryItem[]) => { const map: Record = {} items.forEach((i) => { if (i.latest) map[i.id] = i.latest }) setLatest(map) }) .catch(() => {}) } }) .catch(() => setError(true)) .finally(() => setLoading(false)) } useEffect(refresh, []) const act = async (id: string, fn: () => Promise, ok: string) => { setBusy(id) try { await fn(); toast.success(ok); refresh() } catch (e) { toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError')) } finally { setBusy(null) } } const openBrowse = () => { setView('browse') if (!registry) adminApi.pluginBrowse().then(setRegistry).catch(() => setRegistry([])) } const openErrors = (id: string) => adminApi.pluginErrors(id) .then((d: { errors: Array<{ ts: string; level: string; message: string }> }) => setErrorsFor({ id, rows: d.errors })) .catch(() => setErrorsFor({ id, rows: [] })) const updateAvailable = (p: PluginRow) => !!(p.version && latest[p.id] && isNewer(latest[p.id], p.version)) return (
{/* Header */}

{t('admin.plugins.title')}

{t('admin.plugins.subtitle')}

{runtimeOn && (
)}
{/* Runtime-disabled notice */} {!runtimeOn && !loading && !error && (

{t('admin.plugins.disabledTitle')}

{t('admin.plugins.disabledBody')}

)}
{loading ? (
{t('common.loading')}
) : error ? (
{t('admin.plugins.loadError')}
) : view === 'browse' ? ( p.id))} onInstall={(id) => act(id, () => adminApi.pluginInstall(id), t('admin.plugins.installed'))} /> ) : plugins.length === 0 ? (

{t('admin.plugins.empty')}

{runtimeOn && ( )}
) : (
{plugins.map(p => { const health = HEALTH[p.status] || HEALTH.inactive const hasUpdate = updateAvailable(p) return (
{p.name} {p.version && v{p.version}} {p.reviewed_at && ( {t('admin.plugins.reviewed')} )} {hasUpdate && ( )}
{t(`admin.plugins.status.${p.status}` as never)} · {t(`admin.plugins.type.${p.type}` as never)}
{p.status === 'error' && p.last_error && (

{p.last_error}

)}
openErrors(p.id)}> setConfirmUninstall(p)}>
busy !== p.id && act( p.id, () => p.enabled === 1 ? adminApi.pluginDeactivate(p.id) : adminApi.pluginActivate(p.id), p.enabled === 1 ? t('admin.plugins.deactivated') : t('admin.plugins.activated'), )} />
) })}
)}

{t('admin.plugins.trustNote')}

{/* Error-log modal */} {errorsFor && (
setErrorsFor(null)}>
e.stopPropagation()}>
{errorsFor.id} — {t('admin.plugins.errorLog')}
{errorsFor.rows.length === 0 ?

{t('admin.plugins.noErrors')}

: errorsFor.rows.map((r, i) => (
{r.level} {r.ts} {r.message}
))}
)} setConfirmUninstall(null)} onConfirm={async () => { const p = confirmUninstall!; setConfirmUninstall(null) await act(p.id, () => adminApi.pluginUninstall(p.id, true), t('admin.plugins.uninstalled')) }} title={t('admin.plugins.uninstallTitle')} message={t('admin.plugins.uninstallBody')} />
) } function IconBtn({ children, title, onClick, disabled, danger }: { children: React.ReactNode; title: string; onClick: () => void; disabled?: boolean; danger?: boolean }) { return ( ) } function RegistryGrid({ items, onInstall, busy, t, installedIds }: { items: RegistryItem[] | null onInstall: (id: string) => void busy: string | null t: (k: string, p?: Record) => string installedIds: Set }) { if (!items) return
{t('common.loading')}
if (items.length === 0) return
{t('admin.plugins.registryEmpty')}
return (
{items.map(item => { const installed = installedIds.has(item.id) return (
{item.name} {item.latest && v{item.latest}}
{item.author} · {t(`admin.plugins.type.${item.type}` as never)}

{item.description}

{item.reviewedAt ? {t('admin.plugins.reviewed')} : {t('admin.plugins.unreviewed')}}
) })}
) }