import { useState, useEffect, useCallback } from 'react'; import { PluginsResponse, PluginTiming, PluginTimingsResponse } from '../types'; import { formatMs, formatNumber } from '../utils/format'; interface AddPluginForm { pluginId: string; image: string; instances: number; memory: string; cpus: string; } function Plugins() { const [pluginsData, setPluginsData] = useState(null); const [timings, setTimings] = useState([]); const [selectedPlugin, setSelectedPlugin] = useState(null); const [configEditing, setConfigEditing] = useState | null>(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [scanStatus, setScanStatus] = useState(null); // Container plugin management state const [showAddPluginModal, setShowAddPluginModal] = useState(false); const [showRemoveConfirm, setShowRemoveConfirm] = useState(null); const [addPluginForm, setAddPluginForm] = useState({ pluginId: '', image: '', instances: 1, memory: '256m', cpus: '0.5', }); const [restartAllStatus, setRestartAllStatus] = useState(null); const fetchPlugins = useCallback(async () => { try { const res = await fetch('/api/plugins'); if (res.ok) { const data = await res.json(); setPluginsData(data); setError(null); } else if (res.status === 503) { setError('Plugin manager not yet initialized. Process a file to initialize plugins.'); } } catch (err) { console.error('Failed to fetch plugins:', err); setError('Failed to fetch plugins'); } }, []); const fetchTimings = useCallback(async () => { try { const res = await fetch('/api/plugins/timings'); if (res.ok) { const data: PluginTimingsResponse = await res.json(); setTimings(data.timings || []); } } catch (err) { console.error('Failed to fetch plugin timings:', err); } }, []); useEffect(() => { fetchPlugins(); fetchTimings(); const interval = setInterval(() => { fetchPlugins(); fetchTimings(); }, 5000); return () => clearInterval(interval); }, [fetchPlugins, fetchTimings]); const togglePlugin = async (pluginId: string, active: boolean) => { setLoading(true); try { const endpoint = active ? `/api/plugins/${pluginId}/deactivate` : `/api/plugins/${pluginId}/activate`; const res = await fetch(endpoint, { method: 'POST' }); if (!res.ok) { const data = await res.json(); throw new Error(data.details || data.error || 'Failed to toggle plugin'); } await fetchPlugins(); } catch (err: any) { alert(err.message); } finally { setLoading(false); } }; const updateConfig = async (pluginId: string, config: Record) => { setLoading(true); try { const res = await fetch(`/api/plugins/${pluginId}/config`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config) }); if (!res.ok) { const data = await res.json(); throw new Error(data.details || 'Failed to update config'); } await fetchPlugins(); setConfigEditing(null); } catch (err: any) { alert(err.message); } finally { setLoading(false); } }; const triggerScan = async () => { setScanStatus('Scanning...'); try { await fetch('/api/scan/trigger', { method: 'POST' }); setScanStatus('Scan triggered'); setTimeout(() => setScanStatus(null), 3000); } catch (err) { console.error('Failed to trigger scan:', err); setScanStatus('Scan failed'); } }; const rescanPlugins = async () => { setLoading(true); try { const res = await fetch('/api/plugins/rescan', { method: 'POST' }); if (res.ok) { await fetchPlugins(); } } catch (err) { console.error('Failed to rescan plugins:', err); } finally { setLoading(false); } }; const clearCache = async (pluginId: string) => { setLoading(true); try { await fetch(`/api/plugins/${pluginId}/clear-cache`, { method: 'POST' }); } catch (err) { console.error('Failed to clear cache:', err); } finally { setLoading(false); } }; const [recomputeStatus, setRecomputeStatus] = useState(null); const triggerRecompute = async (pluginId: string) => { if (!confirm(`This will re-run the ${pluginId} plugin on all files. Continue?`)) { return; } setLoading(true); setRecomputeStatus('Queuing...'); try { const res = await fetch(`/api/plugins/${pluginId}/recompute`, { method: 'POST' }); if (res.ok) { const data = await res.json(); setRecomputeStatus(`Queued ${data.filesQueued} files`); setTimeout(() => setRecomputeStatus(null), 5000); } else { const data = await res.json(); throw new Error(data.details || data.error || 'Failed to trigger recompute'); } } catch (err: any) { setRecomputeStatus('Failed'); alert(err.message); setTimeout(() => setRecomputeStatus(null), 3000); } finally { setLoading(false); } }; const getTimingForPlugin = (pluginId: string): PluginTiming | undefined => { return timings.find(t => t.pluginId === pluginId); }; // Container plugin management functions const restartPlugin = async (pluginId: string) => { setLoading(true); try { const res = await fetch(`/api/plugins/containers/${pluginId}/restart`, { method: 'POST' }); if (!res.ok) { const data = await res.json(); throw new Error(data.details || data.error || 'Failed to restart plugin'); } await fetchPlugins(); } catch (err: any) { alert(err.message); } finally { setLoading(false); } }; const restartAllPlugins = async () => { setLoading(true); setRestartAllStatus('Restarting...'); try { const res = await fetch('/api/plugins/containers/restart-all', { method: 'POST' }); if (!res.ok) { const data = await res.json(); throw new Error(data.details || data.error || 'Failed to restart plugins'); } setRestartAllStatus('Restarted'); setTimeout(() => setRestartAllStatus(null), 3000); await fetchPlugins(); } catch (err: any) { setRestartAllStatus('Failed'); alert(err.message); setTimeout(() => setRestartAllStatus(null), 3000); } finally { setLoading(false); } }; const addPlugin = async () => { if (!addPluginForm.pluginId || !addPluginForm.image) { alert('Plugin ID and Image are required'); return; } setLoading(true); try { const res = await fetch('/api/plugins/containers', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pluginId: addPluginForm.pluginId, image: addPluginForm.image, instances: addPluginForm.instances, resources: { memory: addPluginForm.memory, cpus: parseFloat(addPluginForm.cpus) || 0.5, }, }), }); if (!res.ok) { const data = await res.json(); throw new Error(data.details || data.error || 'Failed to add plugin'); } setShowAddPluginModal(false); setAddPluginForm({ pluginId: '', image: '', instances: 1, memory: '256m', cpus: '0.5', }); await fetchPlugins(); } catch (err: any) { alert(err.message); } finally { setLoading(false); } }; const removePlugin = async (pluginId: string) => { setLoading(true); try { const res = await fetch(`/api/plugins/containers/${pluginId}`, { method: 'DELETE' }); if (!res.ok) { const data = await res.json(); throw new Error(data.details || data.error || 'Failed to remove plugin'); } setShowRemoveConfirm(null); if (selectedPlugin === pluginId) { setSelectedPlugin(null); } await fetchPlugins(); } catch (err: any) { alert(err.message); } finally { setLoading(false); } }; const selectedPluginData = pluginsData?.plugins.find(p => p.id === selectedPlugin); // Group plugins by queue assignment const getQueueAssignment = () => { if (!pluginsData) return { metadata: [], hashing: [] }; const metadata: Array<{ plugin: typeof pluginsData.plugins[0]; timing?: PluginTiming }> = []; const hashing: Array<{ plugin: typeof pluginsData.plugins[0]; timing?: PluginTiming }> = []; pluginsData.plugins.forEach(plugin => { if (!plugin.active) return; const timing = getTimingForPlugin(plugin.id); const avgTime = timing?.avgTime || 0; // Determine queue based on: // 1. Plugin's defaultQueue from manifest // 2. Or inferred from average execution time (>= 1000ms = background/hashing) const isBackground = plugin.defaultQueue === 'background' || (!plugin.defaultQueue && avgTime >= 1000); if (isBackground) { hashing.push({ plugin, timing }); } else { metadata.push({ plugin, timing }); } }); return { metadata, hashing }; }; const queueAssignment = getQueueAssignment(); return (

Plugins

{error && (
{error}
)}
{/* Plugin List */}

Loaded Plugins {pluginsData && ( {pluginsData.activeCount}/{pluginsData.totalCount} active )}

{pluginsData?.plugins.length === 0 ? (
No plugins found
) : (
{pluginsData?.plugins.map((plugin) => { const timing = getTimingForPlugin(plugin.id); const isSelected = selectedPlugin === plugin.id; return (
setSelectedPlugin(plugin.id)} >
{plugin.name} v{plugin.version}
{plugin.description && (
{plugin.description}
)} {plugin.status === 'error' && (
{plugin.error}
)} {timing && timing.totalCalls > 0 && (
{formatNumber(timing.totalCalls)} calls avg: {formatMs(timing.avgTime)}
)} {plugin.dependencies.length > 0 && (
Depends: {plugin.dependencies.join(', ')}
)}
); })}
)} {pluginsData && pluginsData.executionOrder.length > 0 && (

Execution Order

{pluginsData.executionOrder.map((id, idx) => ( {idx + 1}. {id} ))}
)}
{/* Plugin Details */}
{selectedPluginData ? ( <>

{selectedPluginData.name}

{selectedPluginData.active ? 'Active' : 'Inactive'}

Info

ID {selectedPluginData.id}
Version {selectedPluginData.version}
Status {selectedPluginData.status}
{selectedPluginData.description && (

{selectedPluginData.description}

)}
{/* Timing Stats */} {(() => { const timing = getTimingForPlugin(selectedPluginData.id); if (!timing || timing.totalCalls === 0) return null; return (

Performance

{formatNumber(timing.totalCalls)}
Total Calls
{formatMs(timing.avgTime)}
Avg Time
{formatMs(timing.minTime)}
Min Time
{formatMs(timing.maxTime)}
Max Time
); })()} {/* Configuration */} {selectedPluginData.configSchema && Object.keys(selectedPluginData.configSchema).length > 0 && (

Configuration

{!configEditing ? ( ) : (
)}
{Object.entries(selectedPluginData.configSchema).map(([key, schema]) => { const value = configEditing ? configEditing[key] : selectedPluginData.config[key]; return (
{schema.description && ( {schema.description} )} {configEditing ? ( schema.type === 'boolean' ? ( ) : schema.type === 'number' ? ( setConfigEditing({ ...configEditing, [key]: e.target.value ? Number(e.target.value) : undefined })} /> ) : ( setConfigEditing({ ...configEditing, [key]: e.target.value || undefined })} placeholder={schema.default !== undefined ? String(schema.default) : ''} /> ) ) : ( {value !== undefined ? String(value) : not set} )}
); })}
)} {/* Metadata Schema */} {selectedPluginData.metadataSchema && Object.keys(selectedPluginData.metadataSchema).length > 0 && (

Metadata Schema

Fields this plugin can set on files

{Object.entries(selectedPluginData.metadataSchema).map(([key, field]) => (
{key} {field.type} {field.description && ( {field.description} )}
))}
)} {/* Actions */}

Actions

Recompute will re-run this plugin on all files, bypassing cache checks.

) : (
Select a plugin to view details
)}
{/* Performance Overview */} {timings.length > 0 && (

Plugin Performance Overview

Processing time for each metadata extraction plugin during light processing phase

{timings .filter(t => t.totalCalls > 0) .sort((a, b) => b.avgTime - a.avgTime) .map((timing) => { const plugin = pluginsData?.plugins.find(p => p.id === timing.pluginId); const isQuick = timing.avgTime < 1000; return (
setSelectedPlugin(timing.pluginId)} >

{plugin?.name || timing.pluginId}

{formatMs(timing.avgTime)}
Average Time
{formatNumber(timing.totalCalls)} runs | min: {formatMs(timing.minTime)} | max: {formatMs(timing.maxTime)}
); })}
)} {/* Queue Assignment Section */} {pluginsData && pluginsData.activeCount > 0 && (

Pipeline Queue Assignment

Active plugins are assigned to pipeline stages based on their execution characteristics. Plugins with avg time < 1s run in Metadata stage, others in Hashing stage.

{/* Metadata Queue */}
📝

Metadata Stage

{queueAssignment.metadata.length} plugins

Fast plugins (<1s avg) - runs during light processing

{queueAssignment.metadata.length === 0 ? (
No plugins assigned
) : ( queueAssignment.metadata.map(({ plugin, timing }) => (
setSelectedPlugin(plugin.id)} > {plugin.name} {timing && timing.totalCalls > 0 && ( {formatMs(timing.avgTime)} )}
)) )}
{/* Arrow */}
→
{/* Hashing Queue */}
#

Hashing Stage

{queueAssignment.hashing.length} plugins

Slow plugins (≥1s avg) - runs during hash processing

{queueAssignment.hashing.length === 0 ? (
No plugins assigned
) : ( queueAssignment.hashing.map(({ plugin, timing }) => (
setSelectedPlugin(plugin.id)} > {plugin.name} {timing && timing.totalCalls > 0 && ( {formatMs(timing.avgTime)} )}
)) )}
)} {/* Add Plugin Modal */} {showAddPluginModal && (
setShowAddPluginModal(false)}>
e.stopPropagation()}>

Add Container Plugin

setAddPluginForm({ ...addPluginForm, pluginId: e.target.value })} /> Lowercase alphanumeric with hyphens (e.g., my-plugin)
setAddPluginForm({ ...addPluginForm, image: e.target.value })} /> Docker image name and tag
setAddPluginForm({ ...addPluginForm, instances: parseInt(e.target.value) || 1 })} />
setAddPluginForm({ ...addPluginForm, memory: e.target.value })} />
setAddPluginForm({ ...addPluginForm, cpus: e.target.value })} />
)} {/* Remove Confirmation Dialog */} {showRemoveConfirm && (
setShowRemoveConfirm(null)}>
e.stopPropagation()}>

Remove Plugin

Are you sure you want to remove plugin '{showRemoveConfirm}'?

This will stop the container and remove it from configuration.

)}
); } export default Plugins;