import { AlertTriangle, ArrowUpCircle, Bug, CircleDot, Github, Globe, History, Info, KeyRound, Link2, MoreHorizontal, PauseCircle, RotateCw, ShieldAlert, ShieldCheck, SlidersHorizontal, Trash2, UploadCloud, } from 'lucide-react'; import { useRef, type ComponentType, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; import { POPOVER } from '../../Packing/packingPopoverStyles'; import ToggleSwitch from '../../Settings/ToggleSwitch'; import { fs } from '../../shared/DialogShell'; import PluginIcon from '../../shared/PluginIcon'; import { Tooltip } from '../../shared/Tooltip'; import { deriveCaps } from '../pluginCaps'; import { anchorMenu, MENU_ITEM, useFollowTrigger } from './pluginMenu'; import { deriveDeps, isRegistrySourced, parseJson, type PluginRow, type T } from './pluginModel'; // Runtime health → dot colour on the icon tile. const HEALTH: Record = { active: 'bg-success', starting: 'bg-info animate-pulse', error: 'bg-danger', inactive: 'bg-content-faint/60', disabled: 'bg-warning', incompatible: 'bg-warning', }; /** Every fact about a plugin wears the same chip; only its icon carries a colour. */ const FACT = 'inline-flex max-w-full items-center gap-1.5 rounded-full border border-edge-faint bg-surface-secondary px-2 py-[2px] font-medium text-content-secondary'; type Tone = 'warning' | 'danger' | 'neutral'; const NOTICE_LOOK: Record = { warning: 'bg-warning-soft text-warning', danger: 'bg-danger-soft text-danger', neutral: 'bg-surface-secondary text-content-muted', }; /** A state of the plugin worth reading on its own line: what it means, and the one action that fixes it. */ interface Notice { key: string; tone: Tone; icon: ComponentType<{ size?: number; className?: string }>; text: string; action?: { label: string; onClick: () => void; disabled?: boolean }; } function Fact({ icon, iconClass = 'text-content-muted', hint, children, }: { icon: ReactNode; iconClass?: string; hint?: string; children: ReactNode; }) { const chip = ( {icon} {children} ); return hint ? ( {chip} ) : ( chip ); } function NoticeLine({ n }: { n: Notice }) { return (
{n.text} {n.action && ( )}
); } function MenuItem({ icon, label, onClick, danger, }: { icon: ReactNode; label: string; onClick: () => void; danger?: boolean; }) { return ( ); } interface InstalledRowProps { p: PluginRow; t: T; busy: string | null; menu: string | null; setMenu: (v: string | null) => void; hasUpdate: boolean; latestVer?: string; newerIncompatible: { version: string; range: string } | null; blocked: boolean; onToggle: () => void; onUpdate: () => void; onRestart: () => void; onChangeVersion: () => void; onResume: () => void; onReviewBlock: () => void; onErrors: () => void; onEgress: () => void; onSettings: () => void; onUninstall: () => void; } /** * One installed plugin, read top to bottom: its name and version, what it does, the * facts about it as one row of alike chips (what it adds, whether it was reviewed and * signed, where it may connect), then each state that needs attention on a line of its * own with the action that resolves it. The controls stay on the right. */ export default function PluginInstalledRow(props: InstalledRowProps) { const { p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked } = props; const caps = deriveCaps( parseJson(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t ); const deps = deriveDeps(p, t); const menuOpen = menu === `row:${p.id}`; const menuBtnRef = useRef(null); const working = busy === p.id; const hostCount = p.egressHostCount ?? 0; // The menu is portaled to and positioned `fixed` against the ⋯ button, // because the row's ancestor (PageSidebar) is `overflow-hidden` and would clip // an in-flow `absolute` menu — losing Delete on the lower rows of a long list. useFollowTrigger(menuOpen); const notices: Notice[] = []; if (p.status === 'error' && p.last_error) { notices.push({ key: 'error', tone: 'danger', icon: AlertTriangle, text: p.last_error }); } // A refused update leaves a WORKING plugin pinned at its old version: its own state, // kept until resolved rather than dying with the toast that first reported it. if (blocked && p.updateBlock) { notices.push({ key: 'block', tone: 'warning', icon: ShieldAlert, text: t('admin.plugins.updateBlocked', { reason: p.updateBlock.detail ?? p.updateBlock.code }), action: { label: t('admin.plugins.reviewBlock'), onClick: props.onReviewBlock }, }); } for (const [i, d] of deps.entries()) { if (d.blocked || d.warn) notices.push({ key: `dep${i}`, tone: 'warning', icon: d.icon, text: d.label }); } // Held: the admin deliberately installed a non-latest version, so updates are paused // rather than nagged — resuming is one click, right where the pause shows. if (p.updateHold) { notices.push({ key: 'hold', tone: 'neutral', icon: PauseCircle, text: t('admin.plugins.updatesHeld', { version: p.version ?? '' }), action: { label: t('admin.plugins.resumeUpdates'), onClick: props.onResume, disabled: working }, }); } // A newer version this TREK can't run: informational, never a button, the fix is a TREK upgrade. if (newerIncompatible) { notices.push({ key: 'newer', tone: 'neutral', icon: Info, text: t('admin.plugins.newerNeedsTrek', { version: newerIncompatible.version, range: newerIncompatible.range }), }); } return (
{p.name} {p.version && ( v{p.version} )}
{p.description && (

{p.description}

)}
{caps.map((c, i) => ( } iconClass={c.net ? 'text-info' : 'text-content-muted'}> {c.label} ))} {deps .filter((d) => !d.blocked && !d.warn) .map((d, i) => ( }> {d.label} ))} {p.reviewed_at && ( } iconClass="text-success"> {t('admin.plugins.reviewed')} )} {p.source_repo === 'local:upload' && ( } iconClass="text-warning" hint={t('admin.plugins.sideloadedHint')}> {t('admin.plugins.sideloaded')} )} {p.source_repo === 'local:link' && ( } hint={t('admin.plugins.devLinkHint')}> {t('admin.plugins.devLinkBadge')} )} {/* Registry plugins only: a sideloaded or dev-linked plugin already says something stronger. */} {isRegistrySourced(p.source_repo) && (p.signed ? ( } hint={t('admin.plugins.signedHint')}> {t('admin.plugins.signed')} ) : ( } iconClass="text-warning" hint={t('admin.plugins.unsignedHint')}> {t('admin.plugins.unsigned')} ))} {/* A service only the operator can name (a self-hosted Gotify/ntfy): the admin adds its host, and until one exists the plugin reaches nothing, so it asks for it. */} {p.operatorEgress && ( )}
{notices.length > 0 && (
{notices.map((n) => ( ))}
)}
{hasUpdate && ( )} {p.enabled === 1 ? t('admin.plugins.status.active') : t('admin.plugins.stateOff')}
{menuOpen && createPortal(
{p.enabled === 1 && ( } label={t('admin.plugins.restart')} onClick={props.onRestart} /> )} {/* Instance fields OR actions: an action-only plugin still needs the dialog to run its buttons. */} {((p.instanceSettingsCount ?? 0) > 0 || (p.instanceActionsCount ?? 0) > 0) && ( } label={t('admin.plugins.instanceSettings')} onClick={props.onSettings} /> )} } label={t('admin.plugins.viewErrors')} onClick={props.onErrors} /> {/* Only a plugin that DECLARED operatorEgress: an admin is never invited to widen egress for one that did not ask. */} {p.operatorEgress && ( } label={t('admin.plugins.allowedHosts')} onClick={props.onEgress} /> )} {/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */} {isRegistrySourced(p.source_repo) && ( } label={t('admin.plugins.changeVersion')} onClick={props.onChangeVersion} /> )} {p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link' && ( <> setMenu(null)} className={`${MENU_ITEM} text-content-secondary`} style={fs(12.5, 'body')} > {' '} {t('admin.plugins.sourceRepo')} setMenu(null)} className={`${MENU_ITEM} text-content-secondary`} style={fs(12.5, 'body')} > {' '} {t('admin.plugins.reportIssue')} )}
} label={t('common.delete')} danger onClick={props.onUninstall} />
, document.body )}
); }