mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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');`);
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
Reference in New Issue
Block a user