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