feat(plugins): persist enable-intent across restarts + redesign admin page

The deactivation-on-deploy bug: `status` conflated the admin's ON/OFF intent with
runtime health, so a boot crash flipped status to 'error' and the plugin never
rebooted after the next deploy. Migration 156 adds an `enabled` flag (admin
intent) separate from `status` (runtime health); boot now retries every enabled
plugin regardless of last status, and a crash no longer erases the intent.

Admin → Plugins redesign:
- ON/OFF is a ToggleSwitch bound to `enabled`; runtime health shows separately as
  a coloured status dot, with the last error inline when it crashed.
- "Update → vX" badge when the registry has a newer version (one click updates
  and reactivates).
- Reviewed/unreviewed trust badges, cleaner cards, nicer empty state, registry
  browser marks already-installed plugins. i18n across all locales.
This commit is contained in:
mauriceboe
2026-07-03 19:19:30 +02:00
committed by Maurice
parent 29c2979da4
commit 447cb31e7f
28 changed files with 424 additions and 106 deletions
+181 -95
View File
@@ -1,14 +1,16 @@
import { useEffect, useState } from 'react'
import { Blocks, AlertTriangle, PackageOpen, RefreshCw, Power, Trash2, Download, Bug, X } from 'lucide-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). Lists installed plugins with lifecycle actions
* (activate / deactivate / uninstall), a registry browser to install new ones,
* and each plugin's error log. Gated by the runtime-enabled flag.
* 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 {
@@ -18,6 +20,8 @@ interface PluginRow {
type: string
version: string | null
status: string
enabled: number
last_error: string | null
reviewed_at: string | null
}
interface RegistryItem {
@@ -30,30 +34,57 @@ interface RegistryItem {
reviewedAt: string | null
}
const STATUS_CLASS: Record<string, string> = {
active: 'bg-emerald-500/15 text-emerald-600',
inactive: 'bg-surface-tertiary text-content-muted',
disabled: 'bg-amber-500/15 text-amber-600',
error: 'bg-rose-500/15 text-rose-600',
incompatible: 'bg-orange-500/15 text-orange-600',
// Runtime health → colour + dot. `enabled` is shown separately by the toggle.
const HEALTH: Record<string, { cls: string; dot: string }> = {
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 [enabled, setEnabled] = useState(false)
const [runtimeOn, setRuntimeOn] = useState(false)
const [plugins, setPlugins] = useState<PluginRow[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const [busy, setBusy] = useState<string | null>(null)
const [view, setView] = useState<'installed' | 'browse'>('installed')
const [registry, setRegistry] = useState<RegistryItem[] | null>(null)
const [latest, setLatest] = useState<Record<string, string>>({})
const [errorsFor, setErrorsFor] = useState<{ id: string; rows: Array<{ ts: string; level: string; message: string }> } | null>(null)
const [confirmUninstall, setConfirmUninstall] = useState<string | null>(null)
const [confirmUninstall, setConfirmUninstall] = useState<PluginRow | null>(null)
const refresh = () => {
adminApi.plugins()
.then((d: { enabled: boolean; plugins: PluginRow[] }) => { setEnabled(!!d.enabled); setPlugins(d.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<string, string> = {}
items.forEach((i) => { if (i.latest) map[i.id] = i.latest })
setLatest(map)
})
.catch(() => {})
}
})
.catch(() => setError(true))
.finally(() => setLoading(false))
}
@@ -71,35 +102,40 @@ export default function AdminPluginsPanel() {
if (!registry) adminApi.pluginBrowse().then(setRegistry).catch(() => setRegistry([]))
}
const openErrors = (id: string) =>
adminApi.pluginErrors(id).then((d: { errors: typeof errorsFor extends null ? never : Array<{ ts: string; level: string; message: string }> }) =>
setErrorsFor({ id, rows: d.errors as Array<{ ts: string; level: string; message: string }> })).catch(() => setErrorsFor({ id, rows: [] }))
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 (
<div className="bg-surface-card border border-edge rounded-xl overflow-hidden">
<div className="px-6 py-4 border-b border-edge-secondary flex items-center gap-3">
<div className="w-9 h-9 rounded-xl flex items-center justify-center bg-surface-tertiary">
<Blocks size={17} className="text-content-muted" />
{/* Header */}
<div className="px-6 py-5 border-b border-edge-secondary flex items-center gap-3">
<div className="w-11 h-11 rounded-2xl grid place-items-center bg-accent/10 text-accent">
<Puzzle size={20} />
</div>
<div className="flex-1">
<div className="flex-1 min-w-0">
<h2 className="text-base font-semibold text-content">{t('admin.plugins.title')}</h2>
<p className="text-xs text-content-faint mt-0.5">{t('admin.plugins.subtitle')}</p>
</div>
{enabled && (
{runtimeOn && (
<div className="flex items-center gap-2">
<button onClick={() => act('__rescan', adminApi.pluginRescan, t('admin.plugins.rescanned'))}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border border-edge text-content-muted hover:text-content">
<RefreshCw size={13} /> {t('admin.plugins.rescan')}
className="flex items-center gap-1.5 text-xs font-medium px-3 py-2 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">
<RefreshCw size={14} /> {t('admin.plugins.rescan')}
</button>
<button onClick={view === 'browse' ? () => setView('installed') : openBrowse}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg bg-accent text-accent-text">
{view === 'browse' ? t('admin.plugins.installed') : <><Download size={13} /> {t('admin.plugins.browse')}</>}
className="flex items-center gap-1.5 text-xs font-semibold px-3.5 py-2 rounded-lg bg-accent text-accent-text hover:opacity-90 transition-opacity">
{view === 'browse' ? t('admin.plugins.installed') : <><Download size={14} /> {t('admin.plugins.browse')}</>}
</button>
</div>
)}
</div>
{!enabled && !loading && !error && (
<div className="mx-6 mt-4 p-4 rounded-lg border border-amber-500/30 bg-amber-500/10 flex items-start gap-3">
{/* Runtime-disabled notice */}
{!runtimeOn && !loading && !error && (
<div className="mx-6 mt-4 p-4 rounded-xl border border-amber-500/30 bg-amber-500/10 flex items-start gap-3">
<AlertTriangle size={16} className="text-amber-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium text-amber-700">{t('admin.plugins.disabledTitle')}</p>
@@ -108,72 +144,107 @@ export default function AdminPluginsPanel() {
</div>
)}
<div className="p-6">
<div className="p-4 sm:p-6">
{loading ? (
<div className="py-8 text-center text-sm text-content-faint">{t('common.loading')}</div>
<div className="py-10 text-center text-sm text-content-faint">{t('common.loading')}</div>
) : error ? (
<div className="py-8 text-center text-sm text-rose-600">{t('admin.plugins.loadError')}</div>
<div className="py-10 text-center text-sm text-rose-600">{t('admin.plugins.loadError')}</div>
) : view === 'browse' ? (
<RegistryGrid items={registry} busy={busy} t={t}
<RegistryGrid items={registry} busy={busy} t={t} installedIds={new Set(plugins.map(p => p.id))}
onInstall={(id) => act(id, () => adminApi.pluginInstall(id), t('admin.plugins.installed'))} />
) : plugins.length === 0 ? (
<div className="py-10 text-center">
<PackageOpen size={28} className="mx-auto text-content-faint mb-3" />
<p className="text-sm text-content-muted">{t('admin.plugins.empty')}</p>
<div className="py-14 text-center">
<div className="w-14 h-14 rounded-2xl bg-surface-tertiary grid place-items-center mx-auto mb-4">
<PackageOpen size={26} className="text-content-faint" />
</div>
<p className="text-sm font-medium text-content-muted">{t('admin.plugins.empty')}</p>
{runtimeOn && (
<button onClick={openBrowse} className="mt-4 inline-flex items-center gap-1.5 text-xs font-semibold px-4 py-2 rounded-lg bg-accent text-accent-text">
<Download size={14} /> {t('admin.plugins.browse')}
</button>
)}
</div>
) : (
<div className="divide-y divide-edge-secondary">
{plugins.map(p => (
<div key={p.id} className="flex items-center gap-4 py-3 first:pt-0 last:pb-0">
<div className="w-10 h-10 rounded-xl flex items-center justify-center bg-surface-tertiary shrink-0">
<Blocks size={16} className="text-content-muted" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-content truncate">{p.name}</span>
{p.version && <span className="text-xs text-content-faint">v{p.version}</span>}
<span className="text-[10px] text-content-faint">· {p.type}</span>
<div className="space-y-2.5">
{plugins.map(p => {
const health = HEALTH[p.status] || HEALTH.inactive
const hasUpdate = updateAvailable(p)
return (
<div key={p.id} className="rounded-xl border border-edge bg-surface-secondary/40 p-3.5 flex items-center gap-4">
<div className="w-11 h-11 rounded-xl grid place-items-center bg-surface-card border border-edge shrink-0">
<Blocks size={18} className="text-content-muted" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-content truncate">{p.name}</span>
{p.version && <span className="text-[11px] text-content-faint font-medium">v{p.version}</span>}
{p.reviewed_at && (
<span className="inline-flex items-center gap-0.5 text-[10px] font-medium text-emerald-600">
<ShieldCheck size={11} /> {t('admin.plugins.reviewed')}
</span>
)}
{hasUpdate && (
<button onClick={() => act(p.id, async () => { await adminApi.pluginInstall(p.id); await adminApi.pluginActivate(p.id) }, t('admin.plugins.updated'))}
className="inline-flex items-center gap-1 text-[10px] font-semibold px-2 py-0.5 rounded-full bg-amber-500/15 text-amber-600 hover:bg-amber-500/25 transition-colors">
<ArrowUpCircle size={11} /> {t('admin.plugins.updateTo', { version: latest[p.id] })}
</button>
)}
</div>
<div className="flex items-center gap-2 mt-1">
<span className={`inline-flex items-center gap-1.5 text-[11px] font-medium ${health.cls}`}>
<span className={`w-1.5 h-1.5 rounded-full ${health.dot}`} />
{t(`admin.plugins.status.${p.status}` as never)}
</span>
<span className="text-[11px] text-content-faint">· {t(`admin.plugins.type.${p.type}` as never)}</span>
</div>
{p.status === 'error' && p.last_error && (
<p className="text-[11px] text-rose-500/90 mt-1 truncate">{p.last_error}</p>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<IconBtn title={t('admin.plugins.viewErrors')} onClick={() => openErrors(p.id)}><Bug size={15} /></IconBtn>
<IconBtn title={t('common.delete')} danger onClick={() => setConfirmUninstall(p)}><Trash2 size={15} /></IconBtn>
<div className="pl-2 ml-1 border-l border-edge">
<ToggleSwitch
on={p.enabled === 1}
label={t('admin.plugins.enabledToggle')}
onToggle={() => 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'),
)}
/>
</div>
</div>
{p.description && <p className="text-xs text-content-faint truncate mt-0.5">{p.description}</p>}
</div>
<span className={`shrink-0 rounded-full text-[10px] font-medium px-2 py-0.5 ${STATUS_CLASS[p.status] || STATUS_CLASS.inactive}`}>
{t(`admin.plugins.status.${p.status}` as never)}
</span>
<div className="flex items-center gap-1 shrink-0">
{p.status === 'active' ? (
<IconBtn title={t('admin.plugins.deactivate')} disabled={busy === p.id}
onClick={() => act(p.id, () => adminApi.pluginDeactivate(p.id), t('admin.plugins.deactivated'))}><Power size={14} /></IconBtn>
) : (
<IconBtn title={t('admin.plugins.activate')} disabled={busy === p.id} accent
onClick={() => act(p.id, () => adminApi.pluginActivate(p.id), t('admin.plugins.activated'))}><Power size={14} /></IconBtn>
)}
<IconBtn title={t('admin.plugins.viewErrors')} onClick={() => openErrors(p.id)}><Bug size={14} /></IconBtn>
<IconBtn title={t('common.delete')} danger onClick={() => setConfirmUninstall(p.id)}><Trash2 size={14} /></IconBtn>
</div>
</div>
))}
)
})}
</div>
)}
</div>
<div className="px-6 py-3 border-t border-edge-secondary bg-surface-secondary">
<div className="px-6 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center gap-2">
<ShieldCheck size={14} className="text-content-faint shrink-0" />
<p className="text-xs text-content-faint">{t('admin.plugins.trustNote')}</p>
</div>
{/* Error-log modal */}
{errorsFor && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={() => setErrorsFor(null)}>
<div className="bg-surface-card border border-edge rounded-xl w-full max-w-2xl max-h-[70vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="px-5 py-3 border-b border-edge-secondary flex items-center justify-between">
<span className="text-sm font-semibold text-content">{errorsFor.id} — {t('admin.plugins.errorLog')}</span>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setErrorsFor(null)}>
<div className="bg-surface-card border border-edge rounded-xl w-full max-w-2xl max-h-[70vh] flex flex-col shadow-xl" onClick={e => e.stopPropagation()}>
<div className="px-5 py-3.5 border-b border-edge-secondary flex items-center justify-between">
<span className="text-sm font-semibold text-content flex items-center gap-2"><Bug size={15} /> {errorsFor.id} — {t('admin.plugins.errorLog')}</span>
<button onClick={() => setErrorsFor(null)} className="text-content-faint hover:text-content"><X size={16} /></button>
</div>
<div className="p-4 overflow-y-auto text-xs font-mono">
{errorsFor.rows.length === 0 ? <p className="text-content-faint">{t('admin.plugins.noErrors')}</p> :
{errorsFor.rows.length === 0 ? <p className="text-content-faint py-4 text-center">{t('admin.plugins.noErrors')}</p> :
errorsFor.rows.map((r, i) => (
<div key={i} className="py-1 border-b border-edge-secondary/50">
<span className={r.level === 'error' ? 'text-rose-600' : 'text-amber-600'}>{r.level}</span>
<span className="text-content-faint"> {r.ts} </span>
<span className="text-content-muted">{r.message}</span>
<div key={i} className="py-1.5 border-b border-edge-secondary/50 last:border-0 flex gap-2">
<span className={`shrink-0 font-semibold ${r.level === 'error' ? 'text-rose-500' : 'text-amber-500'}`}>{r.level}</span>
<span className="text-content-faint shrink-0">{r.ts}</span>
<span className="text-content-muted break-all">{r.message}</span>
</div>
))}
</div>
@@ -185,8 +256,8 @@ export default function AdminPluginsPanel() {
isOpen={!!confirmUninstall}
onClose={() => setConfirmUninstall(null)}
onConfirm={async () => {
const id = confirmUninstall!; setConfirmUninstall(null)
await act(id, () => adminApi.pluginUninstall(id, true), t('admin.plugins.uninstalled'))
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')}
@@ -195,43 +266,58 @@ export default function AdminPluginsPanel() {
)
}
function IconBtn({ children, title, onClick, disabled, accent, danger }: {
children: React.ReactNode; title: string; onClick: () => void; disabled?: boolean; accent?: boolean; danger?: boolean
function IconBtn({ children, title, onClick, disabled, danger }: {
children: React.ReactNode; title: string; onClick: () => void; disabled?: boolean; danger?: boolean
}) {
return (
<button title={title} onClick={onClick} disabled={disabled}
className={`w-8 h-8 grid place-items-center rounded-lg border border-edge transition-colors disabled:opacity-40 ${
danger ? 'text-rose-500 hover:bg-rose-500/10' : accent ? 'text-emerald-600 hover:bg-emerald-500/10' : 'text-content-muted hover:text-content'}`}>
className={`w-8 h-8 grid place-items-center rounded-lg transition-colors disabled:opacity-40 ${
danger ? 'text-content-faint hover:text-rose-500 hover:bg-rose-500/10' : 'text-content-faint hover:text-content hover:bg-surface-tertiary'}`}>
{children}
</button>
)
}
function RegistryGrid({ items, onInstall, busy, t }: {
function RegistryGrid({ items, onInstall, busy, t, installedIds }: {
items: RegistryItem[] | null
onInstall: (id: string) => void
busy: string | null
t: (k: string) => string
t: (k: string, p?: Record<string, unknown>) => string
installedIds: Set<string>
}) {
if (!items) return <div className="py-8 text-center text-sm text-content-faint">{t('common.loading')}</div>
if (items.length === 0) return <div className="py-8 text-center text-sm text-content-faint">{t('admin.plugins.registryEmpty')}</div>
if (!items) return <div className="py-10 text-center text-sm text-content-faint">{t('common.loading')}</div>
if (items.length === 0) return <div className="py-10 text-center text-sm text-content-faint">{t('admin.plugins.registryEmpty')}</div>
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{items.map(item => (
<div key={item.id} className="border border-edge rounded-xl p-4 bg-surface-card">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-content">{item.name}</span>
<span className="text-[10px] text-content-faint">{item.latest ? `v${item.latest}` : ''}</span>
{item.reviewedAt && <span className="text-[10px] text-emerald-600">✓ reviewed</span>}
{items.map(item => {
const installed = installedIds.has(item.id)
return (
<div key={item.id} className="border border-edge rounded-xl p-4 bg-surface-secondary/40 flex flex-col">
<div className="flex items-center gap-2">
<div className="w-9 h-9 rounded-lg grid place-items-center bg-surface-card border border-edge shrink-0">
<Blocks size={16} className="text-content-muted" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-semibold text-content truncate">{item.name}</span>
{item.latest && <span className="text-[10px] text-content-faint">v{item.latest}</span>}
</div>
<span className="text-[11px] text-content-faint">{item.author} · {t(`admin.plugins.type.${item.type}` as never)}</span>
</div>
</div>
<p className="text-xs text-content-faint mt-2.5 line-clamp-2 flex-1">{item.description}</p>
<div className="flex items-center justify-between mt-3">
{item.reviewedAt
? <span className="inline-flex items-center gap-1 text-[10px] font-medium text-emerald-600"><ShieldCheck size={12} /> {t('admin.plugins.reviewed')}</span>
: <span className="text-[10px] text-content-faint">{t('admin.plugins.unreviewed')}</span>}
<button onClick={() => onInstall(item.id)} disabled={busy === item.id || installed}
className="text-xs font-semibold px-3.5 py-1.5 rounded-lg bg-accent text-accent-text disabled:opacity-50 disabled:bg-surface-tertiary disabled:text-content-faint">
{installed ? t('admin.plugins.installed') : t('admin.plugins.install')}
</button>
</div>
</div>
<p className="text-xs text-content-faint mt-1 line-clamp-2">{item.description}</p>
<div className="flex items-center justify-between mt-3">
<span className="text-[11px] text-content-faint">{item.author} · {item.type}</span>
<button onClick={() => onInstall(item.id)} disabled={busy === item.id}
className="text-xs px-3 py-1.5 rounded-lg bg-accent text-accent-text disabled:opacity-50">{t('admin.plugins.install')}</button>
</div>
</div>
))}
)
})}
</div>
)
}
+8
View File
@@ -3352,6 +3352,14 @@ function runMigrations(db: Database.Database): void {
);
`);
},
// Migration 156: separate the admin's ON/OFF intent (`enabled`) from the
// runtime health (`status`). A crash used to flip status to 'error', which
// erased the "keep it on" intent, so the plugin never rebooted after a deploy.
// Boot now retries every enabled plugin regardless of last status.
() => {
db.exec("ALTER TABLE plugins ADD COLUMN enabled INTEGER NOT NULL DEFAULT 0;");
db.exec("UPDATE plugins SET enabled = 1 WHERE status = 'active';");
},
];
if (currentVersion < migrations.length) {
@@ -52,8 +52,11 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
} catch {
/* discovery must never block boot */
}
const active = db.prepare("SELECT id FROM plugins WHERE status = 'active'").all() as Array<{ id: string }>;
for (const { id } of active) {
// Boot everything the admin left ENABLED, regardless of the last runtime
// status — a crash or a bad deploy set status='error' but must not silently
// keep the plugin down forever.
const enabled = db.prepare('SELECT id FROM plugins WHERE enabled = 1').all() as Array<{ id: string }>;
for (const { id } of enabled) {
this.activate(id).catch(() => {
/* status is persisted as error by the supervisor hook */
});
@@ -79,7 +82,8 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
// Activating IS the consent gate: grant the plugin's DECLARED permissions
// (the admin reviewed them on the consent screen), persist, then spawn.
const declared = parseArray(row.permissions).filter(isKnownPermission);
db.prepare('UPDATE plugins SET granted_permissions = ? WHERE id = ?').run(JSON.stringify(declared), id);
// Mark it enabled (admin intent) so it reboots after restarts/crashes.
db.prepare('UPDATE plugins SET granted_permissions = ?, enabled = 1 WHERE id = ?').run(JSON.stringify(declared), id);
const config = decryptConfig(parseObject(row.config));
const egress = declared.filter((p) => p.startsWith('http:outbound:')).map((p) => p.slice('http:outbound:'.length));
await this.supervisor.activate(id, new Set(declared), config, egress);
@@ -88,7 +92,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
async deactivate(id: string): Promise<void> {
await this.supervisor.disable(id);
closePluginDataDb(id);
db.prepare("UPDATE plugins SET status = 'inactive', updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(id);
db.prepare("UPDATE plugins SET status = 'inactive', enabled = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(id);
}
/** Stop the plugin, remove its code, and optionally delete all its data. */
+3 -1
View File
@@ -20,6 +20,8 @@ export interface PluginListItem {
icon: string | null;
version: string | null;
status: string;
enabled: number;
last_error: string | null;
reviewed_at: string | null;
source_repo: string | null;
}
@@ -29,7 +31,7 @@ export class PluginsService {
list(): { enabled: boolean; plugins: PluginListItem[] } {
const plugins = db
.prepare(
`SELECT id, name, description, type, icon, version, status, reviewed_at, source_repo
`SELECT id, name, description, type, icon, version, status, enabled, last_error, reviewed_at, source_repo
FROM plugins
ORDER BY sort_order, name`,
)
@@ -12,7 +12,7 @@ const { testDb } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec(`CREATE TABLE plugins (
id TEXT PRIMARY KEY, status TEXT, permissions TEXT DEFAULT '[]', granted_permissions TEXT DEFAULT '[]',
id TEXT PRIMARY KEY, status TEXT, enabled INTEGER DEFAULT 0, permissions TEXT DEFAULT '[]', granted_permissions TEXT DEFAULT '[]',
config TEXT DEFAULT '{}', last_error TEXT, updated_at TEXT);
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER);
@@ -115,11 +115,19 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
process.env.TREK_PLUGINS_ENABLED = 'true';
});
it('deactivate stops the plugin and marks it inactive', async () => {
it('deactivate stops the plugin and clears the enabled intent', async () => {
await runtime.deactivate('counter');
expect(runtime.isActive('counter')).toBe(false);
const row = testDb.prepare("SELECT status FROM plugins WHERE id = 'counter'").get() as { status: string };
const row = testDb.prepare("SELECT status, enabled FROM plugins WHERE id = 'counter'").get() as { status: string; enabled: number };
expect(row.status).toBe('inactive');
expect(row.enabled).toBe(0);
});
it('activate sets the enabled intent so it survives a reboot', async () => {
await runtime.deactivate('counter');
await runtime.activate('counter');
const row = testDb.prepare("SELECT enabled FROM plugins WHERE id = 'counter'").get() as { enabled: number };
expect(row.enabled).toBe(1);
});
it('tolerates malformed granted_permissions / config JSON on activate', async () => {
@@ -132,10 +140,11 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
await rt.deactivate('messy');
});
it('onModuleInit boots plugins that are active in the DB', async () => {
it('onModuleInit boots every ENABLED plugin — even one left in error state', async () => {
fs.mkdirSync(path.join(codeRoot, 'booter', 'server'), { recursive: true });
fs.writeFileSync(path.join(codeRoot, 'booter', 'server', 'index.js'), 'module.exports = { async onLoad() {} };');
testDb.prepare("INSERT INTO plugins (id, status, granted_permissions, config) VALUES ('booter','active','[]','{}')").run();
// status='error' from a previous crash, but enabled=1 → must still boot
testDb.prepare("INSERT INTO plugins (id, status, enabled, granted_permissions, config) VALUES ('booter','error',1,'[]','{}')").run();
const rt = new PluginRuntimeService();
rt.onModuleInit(); // fire-and-forget spawn
@@ -144,6 +153,17 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
await rt.deactivate('booter');
});
it('onModuleInit does NOT boot a disabled plugin', async () => {
fs.mkdirSync(path.join(codeRoot, 'sleeper', 'server'), { recursive: true });
fs.writeFileSync(path.join(codeRoot, 'sleeper', 'server', 'index.js'), 'module.exports = { async onLoad() {} };');
testDb.prepare("INSERT INTO plugins (id, status, enabled, granted_permissions, config) VALUES ('sleeper','inactive',0,'[]','{}')").run();
const rt = new PluginRuntimeService();
rt.onModuleInit();
await new Promise((r) => setTimeout(r, 300));
expect(rt.isActive('sleeper')).toBe(false);
});
it('outboundHostsOf extracts declared http:outbound hosts', () => {
testDb.prepare("INSERT INTO plugins (id, status, granted_permissions, config) VALUES ('net','inactive','[\"db:own\",\"http:outbound:api.x.com\",\"http:outbound:*.y.com\"]','{}')").run();
const rt = new PluginRuntimeService();
@@ -9,7 +9,7 @@ const { testDb } = vi.hoisted(() => {
const db = new Database(':memory:');
db.exec(`CREATE TABLE plugins (
id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT,
status TEXT, reviewed_at TEXT, source_repo TEXT, config TEXT DEFAULT '{}', updated_at TEXT,
status TEXT, enabled INTEGER DEFAULT 0, last_error TEXT, reviewed_at TEXT, source_repo TEXT, config TEXT DEFAULT '{}', updated_at TEXT,
sort_order INTEGER DEFAULT 0);
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER);
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT DEFAULT '2026-01-01');`);
+9
View File
@@ -196,6 +196,15 @@ const admin: TranslationStrings = {
'admin.plugins.uninstallTitle': 'Uninstall plugin?',
'admin.plugins.uninstallBody': 'This stops the plugin, removes its code, and deletes all of its data. This cannot be undone.',
'admin.plugins.trustNote': 'Plugins run isolated with only the permissions you approve. Only install code from authors you trust.',
'admin.plugins.status.starting': 'Starting…',
'admin.plugins.type.widget': 'Widget',
'admin.plugins.type.page': 'Page',
'admin.plugins.type.integration': 'Integration',
'admin.plugins.reviewed': 'Reviewed',
'admin.plugins.unreviewed': 'Not reviewed',
'admin.plugins.updated': 'Plugin updated',
'admin.plugins.updateTo': 'Update → v{version}',
'admin.plugins.enabledToggle': 'Enable plugin',
'plugins.notFound': 'Plugin not found',
'admin.plugins.title': 'Plugins',
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
+9
View File
@@ -175,6 +175,15 @@ const admin: TranslationStrings = {
'admin.plugins.uninstallTitle': 'Uninstall plugin?',
'admin.plugins.uninstallBody': 'This stops the plugin, removes its code, and deletes all of its data. This cannot be undone.',
'admin.plugins.trustNote': 'Plugins run isolated with only the permissions you approve. Only install code from authors you trust.',
'admin.plugins.status.starting': 'Starting…',
'admin.plugins.type.widget': 'Widget',
'admin.plugins.type.page': 'Page',
'admin.plugins.type.integration': 'Integration',
'admin.plugins.reviewed': 'Reviewed',
'admin.plugins.unreviewed': 'Not reviewed',
'admin.plugins.updated': 'Plugin updated',
'admin.plugins.updateTo': 'Update → v{version}',
'admin.plugins.enabledToggle': 'Enable plugin',
'plugins.notFound': 'Plugin not found',
'admin.plugins.title': 'Plugins',
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
+9
View File
@@ -176,6 +176,15 @@ const admin: TranslationStrings = {
'admin.plugins.uninstallTitle': 'Uninstall plugin?',
'admin.plugins.uninstallBody': 'This stops the plugin, removes its code, and deletes all of its data. This cannot be undone.',
'admin.plugins.trustNote': 'Plugins run isolated with only the permissions you approve. Only install code from authors you trust.',
'admin.plugins.status.starting': 'Starting…',
'admin.plugins.type.widget': 'Widget',
'admin.plugins.type.page': 'Page',
'admin.plugins.type.integration': 'Integration',
'admin.plugins.reviewed': 'Reviewed',
'admin.plugins.unreviewed': 'Not reviewed',
'admin.plugins.updated': 'Plugin updated',
'admin.plugins.updateTo': 'Update → v{version}',
'admin.plugins.enabledToggle': 'Enable plugin',
'plugins.notFound': 'Plugin not found',
'admin.plugins.title': 'Plugins',
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
+9
View File
@@ -178,6 +178,15 @@ const admin: TranslationStrings = {
'admin.plugins.uninstallTitle': 'Plugin deinstallieren?',
'admin.plugins.uninstallBody': 'Das stoppt das Plugin, entfernt seinen Code und löscht alle seine Daten. Das kann nicht rückgängig gemacht werden.',
'admin.plugins.trustNote': 'Plugins laufen isoliert mit nur den von dir freigegebenen Berechtigungen. Installiere nur Code von Autoren, denen du vertraust.',
'admin.plugins.status.starting': 'Startet…',
'admin.plugins.type.widget': 'Widget',
'admin.plugins.type.page': 'Seite',
'admin.plugins.type.integration': 'Integration',
'admin.plugins.reviewed': 'Geprüft',
'admin.plugins.unreviewed': 'Ungeprüft',
'admin.plugins.updated': 'Plugin aktualisiert',
'admin.plugins.updateTo': 'Update → v{version}',
'admin.plugins.enabledToggle': 'Plugin aktivieren',
'plugins.notFound': 'Plugin nicht gefunden',
'admin.plugins.title': 'Plugins',
'admin.plugins.subtitle': 'Plugins von Drittanbietern auf deiner Instanz installieren und verwalten.',
+9
View File
@@ -230,6 +230,15 @@ const admin: TranslationStrings = {
'admin.plugins.uninstallTitle': 'Uninstall plugin?',
'admin.plugins.uninstallBody': 'This stops the plugin, removes its code, and deletes all of its data. This cannot be undone.',
'admin.plugins.trustNote': 'Plugins run isolated with only the permissions you approve. Only install code from authors you trust.',
'admin.plugins.status.starting': 'Starting…',
'admin.plugins.type.widget': 'Widget',
'admin.plugins.type.page': 'Page',
'admin.plugins.type.integration': 'Integration',
'admin.plugins.reviewed': 'Reviewed',
'admin.plugins.unreviewed': 'Not reviewed',
'admin.plugins.updated': 'Plugin updated',
'admin.plugins.updateTo': 'Update → v{version}',
'admin.plugins.enabledToggle': 'Enable plugin',
'plugins.notFound': 'Plugin not found',
'admin.plugins.title': 'Plugins',
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
+9
View File
@@ -177,6 +177,15 @@ const admin: TranslationStrings = {
'admin.plugins.uninstallTitle': 'Uninstall plugin?',
'admin.plugins.uninstallBody': 'This stops the plugin, removes its code, and deletes all of its data. This cannot be undone.',
'admin.plugins.trustNote': 'Plugins run isolated with only the permissions you approve. Only install code from authors you trust.',
'admin.plugins.status.starting': 'Starting…',
'admin.plugins.type.widget': 'Widget',
'admin.plugins.type.page': 'Page',
'admin.plugins.type.integration': 'Integration',
'admin.plugins.reviewed': 'Reviewed',
'admin.plugins.unreviewed': 'Not reviewed',
'admin.plugins.updated': 'Plugin updated',
'admin.plugins.updateTo': 'Update → v{version}',
'admin.plugins.enabledToggle': 'Enable plugin',
'plugins.notFound': 'Plugin not found',
'admin.plugins.title': 'Plugins',
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
+9
View File
@@ -179,6 +179,15 @@ const admin: TranslationStrings = {
'admin.plugins.uninstallTitle': 'Uninstall plugin?',
'admin.plugins.uninstallBody': 'This stops the plugin, removes its code, and deletes all of its data. This cannot be undone.',
'admin.plugins.trustNote': 'Plugins run isolated with only the permissions you approve. Only install code from authors you trust.',
'admin.plugins.status.starting': 'Starting…',
'admin.plugins.type.widget': 'Widget',
'admin.plugins.type.page': 'Page',
'admin.plugins.type.integration': 'Integration',
'admin.plugins.reviewed': 'Reviewed',
'admin.plugins.unreviewed': 'Not reviewed',
'admin.plugins.updated': 'Plugin updated',
'admin.plugins.updateTo': 'Update → v{version}',
'admin.plugins.enabledToggle': 'Enable plugin',
'plugins.notFound': 'Plugin not found',
'admin.plugins.title': 'Plugins',
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
+9
View File
@@ -224,6 +224,15 @@ const admin: TranslationStrings = {
'admin.plugins.uninstallTitle': 'Uninstall plugin?',
'admin.plugins.uninstallBody': 'This stops the plugin, removes its code, and deletes all of its data. This cannot be undone.',
'admin.plugins.trustNote': 'Plugins run isolated with only the permissions you approve. Only install code from authors you trust.',
'admin.plugins.status.starting': 'Starting…',
'admin.plugins.type.widget': 'Widget',
'admin.plugins.type.page': 'Page',
'admin.plugins.type.integration': 'Integration',
'admin.plugins.reviewed': 'Reviewed',
'admin.plugins.unreviewed': 'Not reviewed',
'admin.plugins.updated': 'Plugin updated',
'admin.plugins.updateTo': 'Update → v{version}',
'admin.plugins.enabledToggle': 'Enable plugin',
'plugins.notFound': 'Plugin not found',
'admin.plugins.title': 'Plugins',
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
+9
View File
@@ -178,6 +178,15 @@ const admin: TranslationStrings = {
'admin.plugins.uninstallTitle': 'Uninstall plugin?',
'admin.plugins.uninstallBody': 'This stops the plugin, removes its code, and deletes all of its data. This cannot be undone.',
'admin.plugins.trustNote': 'Plugins run isolated with only the permissions you approve. Only install code from authors you trust.',
'admin.plugins.status.starting': 'Starting…',
'admin.plugins.type.widget': 'Widget',
'admin.plugins.type.page': 'Page',
'admin.plugins.type.integration': 'Integration',
'admin.plugins.reviewed': 'Reviewed',
'admin.plugins.unreviewed': 'Not reviewed',
'admin.plugins.updated': 'Plugin updated',
'admin.plugins.updateTo': 'Update → v{version}',
'admin.plugins.enabledToggle': 'Enable plugin',
'plugins.notFound': 'Plugin not found',
'admin.plugins.title': 'Plugins',
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
+9
View File
@@ -220,6 +220,15 @@ const admin: TranslationStrings = {
'admin.plugins.uninstallTitle': 'Uninstall plugin?',
'admin.plugins.uninstallBody': 'This stops the plugin, removes its code, and deletes all of its data. This cannot be undone.',
'admin.plugins.trustNote': 'Plugins run isolated with only the permissions you approve. Only install code from authors you trust.',
'admin.plugins.status.starting': 'Starting…',
'admin.plugins.type.widget': 'Widget',
'admin.plugins.type.page': 'Page',
'admin.plugins.type.integration': 'Integration',
'admin.plugins.reviewed': 'Reviewed',
'admin.plugins.unreviewed': 'Not reviewed',
'admin.plugins.updated': 'Plugin updated',
'admin.plugins.updateTo': 'Update → v{version}',
'admin.plugins.enabledToggle': 'Enable plugin',
'plugins.notFound': 'Plugin not found',
'admin.plugins.title': 'Plugins',
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
+9
View File
@@ -177,6 +177,15 @@ const admin: TranslationStrings = {
'admin.plugins.uninstallTitle': 'Uninstall plugin?',
'admin.plugins.uninstallBody': 'This stops the plugin, removes its code, and deletes all of its data. This cannot be undone.',
'admin.plugins.trustNote': 'Plugins run isolated with only the permissions you approve. Only install code from authors you trust.',
'admin.plugins.status.starting': 'Starting…',
'admin.plugins.type.widget': 'Widget',
'admin.plugins.type.page': 'Page',
'admin.plugins.type.integration': 'Integration',
'admin.plugins.reviewed': 'Reviewed',
'admin.plugins.unreviewed': 'Not reviewed',
'admin.plugins.updated': 'Plugin updated',
'admin.plugins.updateTo': 'Update → v{version}',
'admin.plugins.enabledToggle': 'Enable plugin',
'plugins.notFound': 'Plugin not found',
'admin.plugins.title': 'Plugins',
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
+9
View File
@@ -209,6 +209,15 @@ const admin: TranslationStrings = {
'admin.plugins.uninstallTitle': 'Uninstall plugin?',
'admin.plugins.uninstallBody': 'This stops the plugin, removes its code, and deletes all of its data. This cannot be undone.',
'admin.plugins.trustNote': 'Plugins run isolated with only the permissions you approve. Only install code from authors you trust.',
'admin.plugins.status.starting': 'Starting…',
'admin.plugins.type.widget': 'Widget',
'admin.plugins.type.page': 'Page',
'admin.plugins.type.integration': 'Integration',
'admin.plugins.reviewed': 'Reviewed',
'admin.plugins.unreviewed': 'Not reviewed',
'admin.plugins.updated': 'Plugin updated',
'admin.plugins.updateTo': 'Update → v{version}',
'admin.plugins.enabledToggle': 'Enable plugin',
'plugins.notFound': 'Plugin not found',
'admin.plugins.title': 'Plugins',
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
+9
View File
@@ -215,6 +215,15 @@ const admin: TranslationStrings = {
'admin.plugins.uninstallTitle': 'Uninstall plugin?',
'admin.plugins.uninstallBody': 'This stops the plugin, removes its code, and deletes all of its data. This cannot be undone.',
'admin.plugins.trustNote': 'Plugins run isolated with only the permissions you approve. Only install code from authors you trust.',
'admin.plugins.status.starting': 'Starting…',
'admin.plugins.type.widget': 'Widget',
'admin.plugins.type.page': 'Page',
'admin.plugins.type.integration': 'Integration',
'admin.plugins.reviewed': 'Reviewed',
'admin.plugins.unreviewed': 'Not reviewed',
'admin.plugins.updated': 'Plugin updated',
'admin.plugins.updateTo': 'Update → v{version}',
'admin.plugins.enabledToggle': 'Enable plugin',
'plugins.notFound': 'Plugin not found',
'admin.plugins.title': 'Plugins',
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
+9
View File
@@ -178,6 +178,15 @@ const admin: TranslationStrings = {
'admin.plugins.uninstallTitle': 'Uninstall plugin?',
'admin.plugins.uninstallBody': 'This stops the plugin, removes its code, and deletes all of its data. This cannot be undone.',
'admin.plugins.trustNote': 'Plugins run isolated with only the permissions you approve. Only install code from authors you trust.',
'admin.plugins.status.starting': 'Starting…',
'admin.plugins.type.widget': 'Widget',
'admin.plugins.type.page': 'Page',
'admin.plugins.type.integration': 'Integration',
'admin.plugins.reviewed': 'Reviewed',
'admin.plugins.unreviewed': 'Not reviewed',
'admin.plugins.updated': 'Plugin updated',
'admin.plugins.updateTo': 'Update → v{version}',
'admin.plugins.enabledToggle': 'Enable plugin',
'plugins.notFound': 'Plugin not found',
'admin.plugins.title': 'Plugins',
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
+9
View File
@@ -171,6 +171,15 @@ const admin: TranslationStrings = {
'admin.plugins.uninstallTitle': 'Uninstall plugin?',
'admin.plugins.uninstallBody': 'This stops the plugin, removes its code, and deletes all of its data. This cannot be undone.',
'admin.plugins.trustNote': 'Plugins run isolated with only the permissions you approve. Only install code from authors you trust.',
'admin.plugins.status.starting': 'Starting…',
'admin.plugins.type.widget': 'Widget',
'admin.plugins.type.page': 'Page',
'admin.plugins.type.integration': 'Integration',
'admin.plugins.reviewed': 'Reviewed',
'admin.plugins.unreviewed': 'Not reviewed',
'admin.plugins.updated': 'Plugin updated',
'admin.plugins.updateTo': 'Update → v{version}',
'admin.plugins.enabledToggle': 'Enable plugin',
'plugins.notFound': 'Plugin not found',
'admin.plugins.title': 'Plugins',
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
+9
View File
@@ -178,6 +178,15 @@ const admin: TranslationStrings = {
'admin.plugins.uninstallTitle': 'Uninstall plugin?',
'admin.plugins.uninstallBody': 'This stops the plugin, removes its code, and deletes all of its data. This cannot be undone.',
'admin.plugins.trustNote': 'Plugins run isolated with only the permissions you approve. Only install code from authors you trust.',
'admin.plugins.status.starting': 'Starting…',
'admin.plugins.type.widget': 'Widget',
'admin.plugins.type.page': 'Page',
'admin.plugins.type.integration': 'Integration',
'admin.plugins.reviewed': 'Reviewed',
'admin.plugins.unreviewed': 'Not reviewed',
'admin.plugins.updated': 'Plugin updated',
'admin.plugins.updateTo': 'Update → v{version}',
'admin.plugins.enabledToggle': 'Enable plugin',
'plugins.notFound': 'Plugin not found',
'admin.plugins.title': 'Plugins',
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
+9
View File
@@ -230,6 +230,15 @@ const admin: TranslationStrings = {
'admin.plugins.uninstallTitle': 'Uninstall plugin?',
'admin.plugins.uninstallBody': 'This stops the plugin, removes its code, and deletes all of its data. This cannot be undone.',
'admin.plugins.trustNote': 'Plugins run isolated with only the permissions you approve. Only install code from authors you trust.',
'admin.plugins.status.starting': 'Starting…',
'admin.plugins.type.widget': 'Widget',
'admin.plugins.type.page': 'Page',
'admin.plugins.type.integration': 'Integration',
'admin.plugins.reviewed': 'Reviewed',
'admin.plugins.unreviewed': 'Not reviewed',
'admin.plugins.updated': 'Plugin updated',
'admin.plugins.updateTo': 'Update → v{version}',
'admin.plugins.enabledToggle': 'Enable plugin',
'plugins.notFound': 'Plugin not found',
'admin.plugins.title': 'Plugins',
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
+9
View File
@@ -222,6 +222,15 @@ const admin: TranslationStrings = {
'admin.plugins.uninstallTitle': 'Uninstall plugin?',
'admin.plugins.uninstallBody': 'This stops the plugin, removes its code, and deletes all of its data. This cannot be undone.',
'admin.plugins.trustNote': 'Plugins run isolated with only the permissions you approve. Only install code from authors you trust.',
'admin.plugins.status.starting': 'Starting…',
'admin.plugins.type.widget': 'Widget',
'admin.plugins.type.page': 'Page',
'admin.plugins.type.integration': 'Integration',
'admin.plugins.reviewed': 'Reviewed',
'admin.plugins.unreviewed': 'Not reviewed',
'admin.plugins.updated': 'Plugin updated',
'admin.plugins.updateTo': 'Update → v{version}',
'admin.plugins.enabledToggle': 'Enable plugin',
'plugins.notFound': 'Plugin not found',
'admin.plugins.title': 'Plugins',
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
+9
View File
@@ -178,6 +178,15 @@ const admin: TranslationStrings = {
'admin.plugins.uninstallTitle': 'Uninstall plugin?',
'admin.plugins.uninstallBody': 'This stops the plugin, removes its code, and deletes all of its data. This cannot be undone.',
'admin.plugins.trustNote': 'Plugins run isolated with only the permissions you approve. Only install code from authors you trust.',
'admin.plugins.status.starting': 'Starting…',
'admin.plugins.type.widget': 'Widget',
'admin.plugins.type.page': 'Page',
'admin.plugins.type.integration': 'Integration',
'admin.plugins.reviewed': 'Reviewed',
'admin.plugins.unreviewed': 'Not reviewed',
'admin.plugins.updated': 'Plugin updated',
'admin.plugins.updateTo': 'Update → v{version}',
'admin.plugins.enabledToggle': 'Enable plugin',
'plugins.notFound': 'Plugin not found',
'admin.plugins.title': 'Plugins',
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
+9
View File
@@ -230,6 +230,15 @@ const admin: TranslationStrings = {
'admin.plugins.uninstallTitle': 'Uninstall plugin?',
'admin.plugins.uninstallBody': 'This stops the plugin, removes its code, and deletes all of its data. This cannot be undone.',
'admin.plugins.trustNote': 'Plugins run isolated with only the permissions you approve. Only install code from authors you trust.',
'admin.plugins.status.starting': 'Starting…',
'admin.plugins.type.widget': 'Widget',
'admin.plugins.type.page': 'Page',
'admin.plugins.type.integration': 'Integration',
'admin.plugins.reviewed': 'Reviewed',
'admin.plugins.unreviewed': 'Not reviewed',
'admin.plugins.updated': 'Plugin updated',
'admin.plugins.updateTo': 'Update → v{version}',
'admin.plugins.enabledToggle': 'Enable plugin',
'plugins.notFound': 'Plugin not found',
'admin.plugins.title': 'Plugins',
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
+9
View File
@@ -212,6 +212,15 @@ const admin: TranslationStrings = {
'admin.plugins.uninstallTitle': 'Uninstall plugin?',
'admin.plugins.uninstallBody': 'This stops the plugin, removes its code, and deletes all of its data. This cannot be undone.',
'admin.plugins.trustNote': 'Plugins run isolated with only the permissions you approve. Only install code from authors you trust.',
'admin.plugins.status.starting': 'Starting…',
'admin.plugins.type.widget': 'Widget',
'admin.plugins.type.page': 'Page',
'admin.plugins.type.integration': 'Integration',
'admin.plugins.reviewed': 'Reviewed',
'admin.plugins.unreviewed': 'Not reviewed',
'admin.plugins.updated': 'Plugin updated',
'admin.plugins.updateTo': 'Update → v{version}',
'admin.plugins.enabledToggle': 'Enable plugin',
'plugins.notFound': 'Plugin not found',
'admin.plugins.title': 'Plugins',
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
+9
View File
@@ -173,6 +173,15 @@ const admin: TranslationStrings = {
'admin.plugins.uninstallTitle': 'Uninstall plugin?',
'admin.plugins.uninstallBody': 'This stops the plugin, removes its code, and deletes all of its data. This cannot be undone.',
'admin.plugins.trustNote': 'Plugins run isolated with only the permissions you approve. Only install code from authors you trust.',
'admin.plugins.status.starting': 'Starting…',
'admin.plugins.type.widget': 'Widget',
'admin.plugins.type.page': 'Page',
'admin.plugins.type.integration': 'Integration',
'admin.plugins.reviewed': 'Reviewed',
'admin.plugins.unreviewed': 'Not reviewed',
'admin.plugins.updated': 'Plugin updated',
'admin.plugins.updateTo': 'Update → v{version}',
'admin.plugins.enabledToggle': 'Enable plugin',
'plugins.notFound': 'Plugin not found',
'admin.plugins.title': 'Plugins',
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',