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 } from "lucide-react"; 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 } from "@/api/plugins"; import { queryKeys } from "@/lib/queryKeys"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; 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 ?? []; 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

{hasCustomSettingsPage ? (
{pluginSlots.map((slot) => ( ))}
) : hasConfigSchema ? ( ) : (

This plugin does not require any settings.

)}
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.

)}
); } // --------------------------------------------------------------------------- // 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 ( ); }