import { useCallback, useEffect, useRef, useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Puzzle, ArrowLeft, ShieldAlert, ActivitySquare, CheckCircle, XCircle, Loader2, Clock, Cpu, Webhook, CalendarClock, AlertTriangle, FolderOpen, Save } from "lucide-react"; import type { PluginLocalFolderDeclaration } from "@paperclipai/shared"; import { useCompany } from "@/context/CompanyContext"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; import { Link, Navigate, useParams } from "@/lib/router"; import { PluginSlotMount, usePluginSlots } from "@/plugins/slots"; import { pluginsApi, type PluginLocalFolderStatus } from "@/api/plugins"; import { queryKeys } from "@/lib/queryKeys"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { ChoosePathButton } from "@/components/PathInstructionsModal"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; import { Tabs, TabsContent } from "@/components/ui/tabs"; import { PageTabBar } from "@/components/PageTabBar"; import { JsonSchemaForm, validateJsonSchemaForm, getDefaultValues, type JsonSchemaNode, } from "@/components/JsonSchemaForm"; /** * PluginSettings page component. * * Detailed settings and diagnostics page for a single installed plugin. * Navigated to from {@link PluginManager} via the Settings gear icon. * * Displays: * - Plugin identity: display name, id, version, description, categories. * - Manifest-declared capabilities (what data and features the plugin can access). * - Health check results (only for `ready` plugins; polled every 30 seconds). * - Runtime dashboard: worker status/uptime, recent job runs, webhook deliveries. * - Auto-generated config form from `instanceConfigSchema` (when no custom settings page). * - Plugin-contributed settings UI via ``. * * Data flow: * - `GET /api/plugins/:pluginId` — plugin record (refreshes on mount). * - `GET /api/plugins/:pluginId/health` — health diagnostics (polling). * Only fetched when `plugin.status === "ready"`. * - `GET /api/plugins/:pluginId/dashboard` — aggregated runtime dashboard data (polling). * - `GET /api/plugins/:pluginId/config` — current config values. * - `POST /api/plugins/:pluginId/config` — save config values. * - `POST /api/plugins/:pluginId/config/test` — test configuration. * * URL params: * - `companyPrefix` — the company slug (for breadcrumb links). * - `pluginId` — UUID of the plugin to display. * * @see PluginManager — parent list page. * @see doc/plugins/PLUGIN_SPEC.md §13 — Plugin Health Checks. * @see doc/plugins/PLUGIN_SPEC.md §19.8 — Plugin Settings UI. */ export function PluginSettings() { const { selectedCompany, selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const { companyPrefix, pluginId } = useParams<{ companyPrefix?: string; pluginId: string }>(); const [activeTab, setActiveTab] = useState<"configuration" | "status">("configuration"); const { data: plugin, isLoading: pluginLoading } = useQuery({ queryKey: queryKeys.plugins.detail(pluginId!), queryFn: () => pluginsApi.get(pluginId!), enabled: !!pluginId, }); const { data: healthData, isLoading: healthLoading } = useQuery({ queryKey: queryKeys.plugins.health(pluginId!), queryFn: () => pluginsApi.health(pluginId!), enabled: !!pluginId && plugin?.status === "ready", refetchInterval: 30000, }); const { data: dashboardData } = useQuery({ queryKey: queryKeys.plugins.dashboard(pluginId!), queryFn: () => pluginsApi.dashboard(pluginId!), enabled: !!pluginId, refetchInterval: 30000, }); const { data: recentLogs } = useQuery({ queryKey: queryKeys.plugins.logs(pluginId!), queryFn: () => pluginsApi.logs(pluginId!, { limit: 50 }), enabled: !!pluginId && plugin?.status === "ready", refetchInterval: 30000, }); // Fetch existing config for the plugin const configSchema = plugin?.manifestJson?.instanceConfigSchema as JsonSchemaNode | undefined; const hasConfigSchema = configSchema && configSchema.properties && Object.keys(configSchema.properties).length > 0; const { data: configData, isLoading: configLoading } = useQuery({ queryKey: queryKeys.plugins.config(pluginId!), queryFn: () => pluginsApi.getConfig(pluginId!), enabled: !!pluginId && !!hasConfigSchema, }); const { slots } = usePluginSlots({ slotTypes: ["settingsPage"], companyId: selectedCompanyId, enabled: !!selectedCompanyId, }); // Filter slots to only show settings pages for this specific plugin const pluginSlots = slots.filter((slot) => slot.pluginId === pluginId); // If the plugin has a custom settingsPage slot, prefer that over auto-generated form const hasCustomSettingsPage = pluginSlots.length > 0; useEffect(() => { setBreadcrumbs([ { label: selectedCompany?.name ?? "Company", href: "/dashboard" }, { label: "Settings", href: "/instance/settings/heartbeats" }, { label: "Plugins", href: "/instance/settings/plugins" }, { label: plugin?.manifestJson?.displayName ?? plugin?.packageName ?? "Plugin Details" }, ]); }, [selectedCompany?.name, setBreadcrumbs, companyPrefix, plugin]); useEffect(() => { setActiveTab("configuration"); }, [pluginId]); if (pluginLoading) { return
Loading plugin details...
; } if (!plugin) { return ; } const displayStatus = plugin.status; const statusVariant = plugin.status === "ready" ? "default" : plugin.status === "error" ? "destructive" : "secondary"; const pluginDescription = plugin.manifestJson.description || "No description provided."; const pluginCapabilities = plugin.manifestJson.capabilities ?? []; const environmentDrivers = plugin.manifestJson.environmentDrivers ?? []; const localFolderDeclarations = plugin.manifestJson.localFolders ?? []; const hasLocalFolders = localFolderDeclarations.length > 0; const environmentDriverNames = environmentDrivers .map((driver) => driver.displayName?.trim() || driver.driverKey) .filter((name, index, values) => values.indexOf(name) === index); const driverLabel = environmentDriverNames.join(", "); return (

{plugin.manifestJson.displayName ?? plugin.packageName}

{displayStatus} v{plugin.manifestJson.version ?? plugin.version}
setActiveTab(value as "configuration" | "status")} className="space-y-6"> setActiveTab(value as "configuration" | "status")} />

About

Description

{pluginDescription}

Author

{plugin.manifestJson.author}

Categories

{plugin.categories.length > 0 ? ( plugin.categories.map((category) => ( {category} )) ) : ( None )}

Settings

{hasLocalFolders ? ( ) : null} {hasCustomSettingsPage ? (
{pluginSlots.map((slot) => ( ))}
) : hasConfigSchema ? ( ) : environmentDrivers.length > 0 ? (

Configure this plugin from Company Environments.

{driverLabel || "This plugin"} registers environment runtime settings there so credentials stay company-scoped instead of instance-global.

) : !hasLocalFolders ? (

This plugin does not require any settings.

) : null}
Runtime Dashboard Worker process, scheduled jobs, and webhook deliveries {dashboardData ? ( <>

Worker Process

{dashboardData.worker ? (
Status {dashboardData.worker.status}
PID {dashboardData.worker.pid ?? "—"}
Uptime {formatUptime(dashboardData.worker.uptime)}
Pending RPCs {dashboardData.worker.pendingRequests}
{dashboardData.worker.totalCrashes > 0 && ( <>
Crashes {dashboardData.worker.consecutiveCrashes} consecutive / {dashboardData.worker.totalCrashes} total
{dashboardData.worker.lastCrashAt && (
Last Crash {formatTimestamp(dashboardData.worker.lastCrashAt)}
)} )}
) : (

No worker process registered.

)}

Recent Job Runs

{dashboardData.recentJobRuns.length > 0 ? (
{dashboardData.recentJobRuns.map((run) => (
{run.jobKey ?? run.jobId.slice(0, 8)} {run.trigger}
{run.durationMs != null ? {formatDuration(run.durationMs)} : null} {formatRelativeTime(run.createdAt)}
))}
) : (

No job runs recorded yet.

)}

Recent Webhook Deliveries

{dashboardData.recentWebhookDeliveries.length > 0 ? (
{dashboardData.recentWebhookDeliveries.map((delivery) => (
{delivery.webhookKey}
{delivery.durationMs != null ? {formatDuration(delivery.durationMs)} : null} {formatRelativeTime(delivery.createdAt)}
))}
) : (

No webhook deliveries recorded yet.

)}
Last checked: {new Date(dashboardData.checkedAt).toLocaleTimeString()}
) : (

Runtime diagnostics are unavailable right now.

)}
{recentLogs && recentLogs.length > 0 ? ( Recent Logs Last {recentLogs.length} log entries
{recentLogs.map((entry) => (
{new Date(entry.createdAt).toLocaleTimeString()} {entry.level} {entry.message}
))}
) : null}
Health Status {healthLoading ? (

Checking health...

) : healthData ? (
Overall {healthData.status}
{healthData.checks.length > 0 ? (
{healthData.checks.map((check, i) => (
{check.name} {check.passed ? ( ) : ( )}
))}
) : null} {healthData.lastError ? (
{healthData.lastError}
) : null}
) : (
Lifecycle {displayStatus}

Health checks run once the plugin is ready.

{plugin.lastError ? (
{plugin.lastError}
) : null}
)}
Details
Plugin ID {plugin.id}
Plugin Key {plugin.pluginKey}
NPM Package {plugin.packageName}
Version v{plugin.manifestJson.version ?? plugin.version}
Permissions {pluginCapabilities.length > 0 ? (
    {pluginCapabilities.map((cap) => (
  • {cap}
  • ))}
) : (

No special permissions requested.

)}
); } // --------------------------------------------------------------------------- // PluginLocalFoldersSettings — host-managed company-scoped folders // --------------------------------------------------------------------------- interface PluginLocalFoldersSettingsProps { pluginId: string; companyId: string | null; declarations: PluginLocalFolderDeclaration[]; } function PluginLocalFoldersSettings({ pluginId, companyId, declarations }: PluginLocalFoldersSettingsProps) { const { data, isLoading, error } = useQuery({ queryKey: companyId ? queryKeys.plugins.localFolders(pluginId, companyId) : ["plugins", pluginId, "companies", "none", "local-folders"], queryFn: () => pluginsApi.listLocalFolders(pluginId, companyId!), enabled: !!companyId, }); const statusByKey = new Map((data?.folders ?? []).map((folder) => [folder.folderKey, folder])); if (!companyId) { return (
Select a company to configure this plugin's local folders.
); } return (

Local folders

{error ? (
{(error as Error).message || "Failed to load local folder settings."}
) : null} {isLoading ? (
Loading local folders...
) : (
{declarations.map((declaration) => ( ))}
)}
); } interface PluginLocalFolderRowProps { pluginId: string; companyId: string; declaration: PluginLocalFolderDeclaration; status?: PluginLocalFolderStatus; } function PluginLocalFolderRow({ pluginId, companyId, declaration, status }: PluginLocalFolderRowProps) { const queryClient = useQueryClient(); const serverPath = status?.path ?? ""; const [pathValue, setPathValue] = useState(serverPath); const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); useEffect(() => { setPathValue(serverPath); setMessage(null); }, [serverPath, declaration.folderKey]); const saveMutation = useMutation({ mutationFn: (path: string) => pluginsApi.configureLocalFolder(pluginId, companyId, declaration.folderKey, { path, access: declaration.access, requiredDirectories: declaration.requiredDirectories, requiredFiles: declaration.requiredFiles, }), onSuccess: (nextStatus) => { setMessage({ type: nextStatus.healthy ? "success" : "error", text: nextStatus.healthy ? "Local folder saved." : "Local folder saved, but validation still needs attention.", }); queryClient.invalidateQueries({ queryKey: queryKeys.plugins.localFolders(pluginId, companyId) }); }, onError: (err: Error) => { setMessage({ type: "error", text: err.message || "Failed to save local folder." }); }, }); const trimmedPath = pathValue.trim(); const isDirty = trimmedPath !== serverPath; const access = status?.access ?? declaration.access ?? "readWrite"; const handleSave = useCallback(() => { if (!trimmedPath) { setMessage({ type: "error", text: "Local folder path is required." }); return; } if (!isLikelyAbsolutePath(trimmedPath)) { setMessage({ type: "error", text: "Local folder must be a full absolute path." }); return; } setMessage(null); saveMutation.mutate(trimmedPath); }, [saveMutation, trimmedPath]); return (

{declaration.displayName}

{declaration.folderKey} {status?.healthy ? "Healthy" : "Needs attention"}
{declaration.description ? (

{declaration.description}

) : null}
{access === "readWrite" ? "Read/write" : "Read only"}
{status?.path ? (
Configured path
{status.path}
) : null}
{ setPathValue(event.target.value); setMessage(null); }} placeholder="/absolute/path/to/folder" />
{status?.problems?.length ? (
Validation problems
    {status.problems.map((problem, index) => (
  • {problem.message} {problem.path ? {problem.path} : null}
  • ))}
) : null} {message ? (
{message.text}
) : null}
); } function FolderStatusMetric({ label, value, ok }: { label: string; value: string; ok: boolean }) { return (
{label} {value}
); } function FolderRequirements({ status, declaration, }: { status?: PluginLocalFolderStatus; declaration: PluginLocalFolderDeclaration; }) { const requiredDirectories = status?.requiredDirectories ?? declaration.requiredDirectories ?? []; const requiredFiles = status?.requiredFiles ?? declaration.requiredFiles ?? []; const missingDirectories = status?.missingDirectories ?? requiredDirectories; const missingFiles = status?.missingFiles ?? requiredFiles; const rootNotInspected = isRootNotInspected(status); if (requiredDirectories.length === 0 && requiredFiles.length === 0) return null; return (
); } function isRootNotInspected(status?: PluginLocalFolderStatus) { if (!status?.configured || status.readable) return false; return status.problems.some((problem) => problem.code === "missing" || problem.code === "not_readable" || problem.code === "not_directory" ); } function RequirementList({ title, items, missingItems, missingLabel, inspectionUnavailable, }: { title: string; items: string[]; missingItems: string[]; missingLabel: string; inspectionUnavailable?: boolean; }) { return (
{title} {inspectionUnavailable ? ( Not inspected ) : missingItems.length > 0 ? ( {missingItems.length} missing ) : ( Present )}
{items.length > 0 ? (
{items.map((item) => { const missing = missingItems.includes(item); return ( {item} ); })}
) : (

None declared.

)} {inspectionUnavailable ? (

Configured root was not inspected.

) : missingItems.length > 0 ? (

{missingLabel}: {missingItems.join(", ")}

) : null}
); } function isLikelyAbsolutePath(pathValue: string) { return ( pathValue.startsWith("/") || /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith("\\\\") ); } // --------------------------------------------------------------------------- // PluginConfigForm — auto-generated form for instanceConfigSchema // --------------------------------------------------------------------------- interface PluginConfigFormProps { pluginId: string; schema: JsonSchemaNode; initialValues?: Record; isLoading?: boolean; /** Current plugin lifecycle status — "Test Configuration" only available when `ready`. */ pluginStatus?: string; /** Whether the plugin worker implements `validateConfig`. */ supportsConfigTest?: boolean; } /** * Inner component that manages form state, validation, save, and "Test Configuration" * for the auto-generated plugin config form. * * Separated from PluginSettings to isolate re-render scope — only the form * re-renders on field changes, not the entire page. */ function PluginConfigForm({ pluginId, schema, initialValues, isLoading, pluginStatus, supportsConfigTest }: PluginConfigFormProps) { const queryClient = useQueryClient(); // Form values: start with saved values, fall back to schema defaults const [values, setValues] = useState>(() => ({ ...getDefaultValues(schema), ...(initialValues ?? {}), })); // Sync when saved config loads asynchronously — only on first load so we // don't overwrite in-progress user edits if the query refetches (e.g. on // window focus). const hasHydratedRef = useRef(false); useEffect(() => { if (initialValues && !hasHydratedRef.current) { hasHydratedRef.current = true; setValues({ ...getDefaultValues(schema), ...initialValues, }); } }, [initialValues, schema]); const [errors, setErrors] = useState>({}); const [saveMessage, setSaveMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); const [testResult, setTestResult] = useState<{ type: "success" | "error"; text: string } | null>(null); // Dirty tracking: compare against initial values const isDirty = JSON.stringify(values) !== JSON.stringify({ ...getDefaultValues(schema), ...(initialValues ?? {}), }); // Save mutation const saveMutation = useMutation({ mutationFn: (configJson: Record) => pluginsApi.saveConfig(pluginId, configJson), onSuccess: () => { setSaveMessage({ type: "success", text: "Configuration saved." }); setTestResult(null); queryClient.invalidateQueries({ queryKey: queryKeys.plugins.config(pluginId) }); // Clear success message after 3s setTimeout(() => setSaveMessage(null), 3000); }, onError: (err: Error) => { setSaveMessage({ type: "error", text: err.message || "Failed to save configuration." }); }, }); // Test configuration mutation const testMutation = useMutation({ mutationFn: (configJson: Record) => pluginsApi.testConfig(pluginId, configJson), onSuccess: (result) => { if (result.valid) { setTestResult({ type: "success", text: "Configuration test passed." }); } else { setTestResult({ type: "error", text: result.message || "Configuration test failed." }); } }, onError: (err: Error) => { setTestResult({ type: "error", text: err.message || "Configuration test failed." }); }, }); const handleChange = useCallback((newValues: Record) => { setValues(newValues); // Clear field-level errors as the user types setErrors({}); setSaveMessage(null); }, []); const handleSave = useCallback(() => { // Validate before saving const validationErrors = validateJsonSchemaForm(schema, values); if (Object.keys(validationErrors).length > 0) { setErrors(validationErrors); return; } setErrors({}); saveMutation.mutate(values); }, [schema, values, saveMutation]); const handleTestConnection = useCallback(() => { // Validate before testing const validationErrors = validateJsonSchemaForm(schema, values); if (Object.keys(validationErrors).length > 0) { setErrors(validationErrors); return; } setErrors({}); setTestResult(null); testMutation.mutate(values); }, [schema, values, testMutation]); if (isLoading) { return (
Loading configuration...
); } return (
{/* Status messages */} {saveMessage && (
{saveMessage.text}
)} {testResult && (
{testResult.text}
)} {/* Action buttons */}
{pluginStatus === "ready" && supportsConfigTest && ( )}
); } // --------------------------------------------------------------------------- // Dashboard helper components and formatting utilities // --------------------------------------------------------------------------- /** * Format an uptime value (in milliseconds) to a human-readable string. */ function formatUptime(uptimeMs: number | null): string { if (uptimeMs == null) return "—"; const totalSeconds = Math.floor(uptimeMs / 1000); if (totalSeconds < 60) return `${totalSeconds}s`; const minutes = Math.floor(totalSeconds / 60); if (minutes < 60) return `${minutes}m ${totalSeconds % 60}s`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h ${minutes % 60}m`; const days = Math.floor(hours / 24); return `${days}d ${hours % 24}h`; } /** * Format a duration in milliseconds to a compact display string. */ function formatDuration(ms: number): string { if (ms < 1000) return `${ms}ms`; if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; return `${(ms / 60000).toFixed(1)}m`; } /** * Format an ISO timestamp to a relative time string (e.g., "2m ago"). */ function formatRelativeTime(isoString: string): string { const now = Date.now(); const then = new Date(isoString).getTime(); const diffMs = now - then; if (diffMs < 0) return "just now"; const seconds = Math.floor(diffMs / 1000); if (seconds < 60) return `${seconds}s ago`; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m ago`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h ago`; const days = Math.floor(hours / 24); return `${days}d ago`; } /** * Format a unix timestamp (ms since epoch) to a locale string. */ function formatTimestamp(epochMs: number): string { return new Date(epochMs).toLocaleString(); } /** * Status indicator dot for job run statuses. */ function JobStatusDot({ status }: { status: string }) { const colorClass = status === "success" || status === "succeeded" ? "bg-green-500" : status === "failed" ? "bg-red-500" : status === "running" ? "bg-blue-500 animate-pulse" : status === "cancelled" ? "bg-gray-400" : "bg-amber-500"; // queued, pending return ( ); } /** * Status indicator dot for webhook delivery statuses. */ function DeliveryStatusDot({ status }: { status: string }) { const colorClass = status === "processed" || status === "success" ? "bg-green-500" : status === "failed" ? "bg-red-500" : status === "received" ? "bg-blue-500" : "bg-amber-500"; // pending return ( ); }