mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 12:33:05 +01:00
## Thinking Path > - Paperclip lets people manage agent work through the board. > - The board reads task, agent, settings, plugin, and run data from the server. > - A deploy can interrupt those reads after content is already visible. > - Local error branches can replace that content with an error or show raw codes. > - This pull request moves the remaining reads to the shared recovery behavior. > - The query-error guard prevents new local error patterns from returning. ## Linked Issues or Issue Description **What happened?** Loaded lists, settings, task panels, and manual reads could clear their content or show errors during a server restart. Some reads also disabled the shared retry policy. **Expected behavior** Keep loaded content during a temporary failure. Show the shared connection banner. Retry reads after recovery. Replace content after a real access denial or missing resource, with readable copy. **Steps to reproduce** Open a task, settings page, or run log. Return 503 with `tenant_app_unavailable` from the API. Restore the API and check that content refreshes. Repeat with 403 and 404 to check the failure state. Related merged work: Refs #15754, Refs #15772, Refs #17960. This change covers remaining reads and enforcement. Browser persistence and the outage browser suite follow separately. ## What Changed - Repair an existing PostgreSQL deadline fixture at UTC boundaries. A daily cap defers a heartbeat until both its original interval and the next UTC day allow it. Keep the counted run in its seeded day and cover noon, just before midnight and the first second after midnight with explicit clocks and real PostgreSQL. Production scheduling is unchanged. - Integrate the saved list, settings, shell, task, and manual-read recovery changes against current master. Preserve the current task, Inbox, and run-log structure. - Add `useResilientFetch` for component-local reads. Preserve loaded data, cancel stale requests, and retry through the shared connectivity policy. Keep browser and pairing capabilities in memory. - Retain run log chunks across stream changes and catch up after recovery without duplicate chunks or stalled requests. - Keep genuine authorization and missing-resource failures visible. Translate known single-word codes such as `forbidden`. Show known skills and disconnected-app failures before sibling loading states. Keep Retry bound to its failed request. - Make the query-error guard enforce by default and run it in the CI policy job. Document the narrow exceptions and the read recovery contract. - Invalidate pending OAuth discovery when the operator leaves setup or its context changes. Keep reserved-popup cleanup bound to the current attempt. ## Verification - Current read head `09d1db29dea` is merge-ready: all 59 check runs are completed (54 successful, three skipped, two neutral), the legacy Snyk status is successful, and both required `ci / verify` and `ci / e2e` gates pass. Greptile Apex reviewed this exact head at 5/5 with no unresolved threads; the branch has no merge conflicts. The current CI run is `41748339602`. - Current read head `09d1db29dea` includes master `683fa4725e9`. The latest change touches only the existing server deadline test fixture. Application source remains the previously verified `86376fa751e` revision: all 60 affected skills/disconnected-app tests and both application UI typechecks passed, along with token gates and the enforcing query-error guard. Those UI suites were not repeated for this test-only repair. - The fixture repair passes all seven deadline integration cases and the complete server typecheck, including the server test layer. The old assertion deterministically fails at 23:59:06.888 UTC; its one-second-old run also fails the first-second-of-day probe. The corrected fixture passes noon and both midnight probes. The permanent three-case coverage uses seeded 2099 clocks. - Twelve new regressions fail against the previous implementation. Both skills variants and disconnected-app pages show either request's 403 or 429 while the sibling pauses or remains fetching. Permission failures stay visible. Retry stays usable for its failed request and recovers independently. An initial test-fixture query-key typo was corrected before the passing run. The finding is replied to and resolved. Greptile reviewed prior application revision `86376fa751e` at 5/5 with no new actionable findings or unresolved threads. The current test-only head has now received its own Apex 5/5 review with no unresolved threads, and its CI is successful as recorded above. - The preceding OAuth fix passed all 237 setup tests on both application branches. Its ten regressions prove that Back, Cancel, unmount, or context changes retire initial discovery without creating a connection, caching old identities, navigating an abandoned popup, or closing a newer attempt. - Earlier master integration `e992f770339` passed all 309 tests across eight affected Slack, recovery, retry, submission and task files, plus UI typecheck, token gates, enforcing query-error scan and seven guard tests. The integration preserves Slack workspace reconnect and shared recovery, readable execution errors, source-inspection company scoping, and current submission behavior. Newly inherited retained-file recovery controls keep cached data through transient failures and hide it after definitive denial. - The preceding cache head `97c0bc3966f` passed 1,038 focused cache/write/editor/account/task/API tests. All 22 built-UI outage/write/loading cases passed in 4.7 minutes on exact browser head `b7deaaee5da`, with strict checking of all three specs. The latest skills/app rendering fix is covered by its affected component suites; those broader cases were not repeated for this separate read path. - Historical broader UI, CLI and build results and unresolved aggregate server/DB/adapter failures remain in the PR discussion. Interrupted CI runs were audited and recorded separately; they are not counted as passes. Controlled local outages do not prove hosted authentication or a real Cloud restart/deploy. Real hosted acceptance remains required after merge and deployment. Commands for the latest fix: ```sh pnpm --filter @paperclipai/ui exec vitest run src/pages/CompanySkills.test.tsx src/pages/apps/AppNotConnected.test.tsx pnpm --filter @paperclipai/ui typecheck pnpm check:token-gates node scripts/check-query-error-rendering.mjs --enforce --summary ``` ## Risks This migration touches many read surfaces. The main risks are initial loading, request cancellation, retained authorization state, and run-log paging. The tests cover those states. There are no server, schema, dependency, or API contract changes. The follow-up browser suite and hosted deploy acceptance are required before release acceptance. Recovered commits retain their original authors. If squash-merging, preserve `Co-Authored-By: Bender (Fable) <bender@agents.paperclip.ing>` and the Paperclip trailer. ## Model Used OpenAI `gpt-6.1-sol` through Codex, with xhigh reasoning effort, tool use, and code execution. The context window size is not exposed. Saved source commits keep their original authorship. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have described the issue in-PR following the bug report template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket id - [x] The affected local tests pass; broader limitations are recorded above - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com> Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Bender (Fable) <bender@agents.paperclip.ing> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1257 lines
50 KiB
TypeScript
1257 lines
50 KiB
TypeScript
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 { useManagedSandboxOnly } from "@/hooks/useManagedSandboxOnly";
|
|
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";
|
|
import { describeError } from "@/api/errors";
|
|
import { QueryErrorState, QueryPlaceholder, useQueryView } from "@/components/QueryView";
|
|
|
|
/**
|
|
* 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 `<PluginSlotOutlet type="settingsPage" />`.
|
|
*
|
|
* 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 { hideHostPaths } = useManagedSandboxOnly();
|
|
const [activeTab, setActiveTab] = useState<"configuration" | "status">("configuration");
|
|
|
|
const pluginQuery = useQuery({
|
|
queryKey: queryKeys.plugins.detail(pluginId!),
|
|
queryFn: () => pluginsApi.get(pluginId!),
|
|
enabled: !!pluginId,
|
|
});
|
|
const plugin = pluginQuery.data;
|
|
const pluginView = useQueryView(pluginQuery);
|
|
|
|
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 configQueryKey = pluginId && selectedCompanyId
|
|
? queryKeys.plugins.config(pluginId, selectedCompanyId)
|
|
: ["plugins", pluginId ?? "__missing_plugin__", "companies", "__missing_company__", "config"] as const;
|
|
|
|
const { data: configData, isLoading: configLoading } = useQuery({
|
|
queryKey: configQueryKey,
|
|
queryFn: () => pluginsApi.getConfig(pluginId!, selectedCompanyId!),
|
|
enabled: !!pluginId && !!hasConfigSchema && !!selectedCompanyId,
|
|
});
|
|
|
|
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 ?? "Organization", href: "/dashboard" },
|
|
{ label: "Settings", href: "/company/settings" },
|
|
{ label: "Plugins", href: "/company/settings/instance/plugins" },
|
|
{ label: plugin?.manifestJson?.displayName ?? plugin?.packageName ?? "Plugin Details" },
|
|
]);
|
|
}, [selectedCompany?.name, setBreadcrumbs, companyPrefix, plugin]);
|
|
|
|
useEffect(() => {
|
|
setActiveTab("configuration");
|
|
}, [pluginId]);
|
|
|
|
if (pluginView.kind === "loading" || pluginView.kind === "reconnecting") {
|
|
return <QueryPlaceholder size="page" label="Loading plugin details" className="p-4" />;
|
|
}
|
|
|
|
// A failed load must not bounce the operator back to the plugin list: a
|
|
// cached plugin keeps the page up through an outage, and a real failure
|
|
// shows readable copy with Retry.
|
|
if (pluginView.kind === "error") {
|
|
return (
|
|
<QueryErrorState
|
|
size="page"
|
|
className="p-4"
|
|
error={pluginQuery.error}
|
|
action="load this plugin"
|
|
onRetry={pluginView.retry}
|
|
retrying={pluginView.isFetching}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (!plugin) {
|
|
return <Navigate to="/company/settings/instance/plugins" replace />;
|
|
}
|
|
|
|
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 ?? [];
|
|
// A plugin local folder is an absolute path on the execution host. Under the
|
|
// managed-sandbox-only policy the platform-managed environment owns the
|
|
// filesystem, so the whole section disappears and the Settings tab falls back
|
|
// to whatever else the plugin declares. The section also stays hidden until
|
|
// the policy is known.
|
|
const hasLocalFolders = localFolderDeclarations.length > 0 && !hideHostPaths;
|
|
const environmentDriverNames = environmentDrivers
|
|
.map((driver) => driver.displayName?.trim() || driver.driverKey)
|
|
.filter((name, index, values) => values.indexOf(name) === index);
|
|
const driverLabel = environmentDriverNames.join(", ");
|
|
|
|
return (
|
|
<div className="max-w-6xl space-y-6">
|
|
<div className="flex items-center gap-4">
|
|
<Link to="/company/settings/instance/plugins">
|
|
<Button variant="outline" size="icon" className="h-8 w-8">
|
|
<ArrowLeft className="h-4 w-4" />
|
|
</Button>
|
|
</Link>
|
|
<div className="flex items-center gap-2">
|
|
<Puzzle className="h-6 w-6 text-muted-foreground" />
|
|
<h1 className="text-xl font-semibold">{plugin.manifestJson.displayName ?? plugin.packageName}</h1>
|
|
<Badge variant={statusVariant} className="ml-2">
|
|
{displayStatus}
|
|
</Badge>
|
|
<Badge variant="outline" className="ml-1">
|
|
v{plugin.manifestJson.version ?? plugin.version}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
|
|
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "configuration" | "status")} className="space-y-6">
|
|
<PageTabBar
|
|
align="start"
|
|
items={[
|
|
{ value: "configuration", label: "Configuration" },
|
|
{ value: "status", label: "Status" },
|
|
]}
|
|
value={activeTab}
|
|
onValueChange={(value) => setActiveTab(value as "configuration" | "status")}
|
|
/>
|
|
|
|
<TabsContent value="configuration" className="space-y-6">
|
|
<div className="space-y-8">
|
|
<section className="space-y-5">
|
|
<h2 className="text-base font-semibold">About</h2>
|
|
<div className="grid gap-8 lg:grid-cols-(--gtc-52)">
|
|
<div className="space-y-2">
|
|
<h3 className="text-sm font-medium text-muted-foreground">Description</h3>
|
|
<p className="text-sm leading-6 text-foreground/90">{pluginDescription}</p>
|
|
</div>
|
|
<div className="space-y-4 text-sm">
|
|
<div className="space-y-1.5">
|
|
<h3 className="font-medium text-muted-foreground">Author</h3>
|
|
<p className="text-foreground">{plugin.manifestJson.author}</p>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<h3 className="font-medium text-muted-foreground">Categories</h3>
|
|
<div className="flex flex-wrap gap-2">
|
|
{plugin.categories.length > 0 ? (
|
|
plugin.categories.map((category) => (
|
|
<Badge key={category} variant="outline" className="capitalize">
|
|
{category}
|
|
</Badge>
|
|
))
|
|
) : (
|
|
<span className="text-foreground">None</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<Separator />
|
|
|
|
<section className="space-y-4">
|
|
<div className="space-y-1">
|
|
<h2 className="text-base font-semibold">Settings</h2>
|
|
</div>
|
|
{hasLocalFolders ? (
|
|
<PluginLocalFoldersSettings
|
|
pluginId={pluginId!}
|
|
companyId={selectedCompanyId}
|
|
declarations={localFolderDeclarations}
|
|
/>
|
|
) : null}
|
|
{hasCustomSettingsPage ? (
|
|
<div className="space-y-3">
|
|
{pluginSlots.map((slot) => (
|
|
<PluginSlotMount
|
|
key={`${slot.pluginKey}:${slot.id}`}
|
|
slot={slot}
|
|
context={{
|
|
companyId: selectedCompanyId,
|
|
companyPrefix: companyPrefix ?? null,
|
|
}}
|
|
missingBehavior="placeholder"
|
|
/>
|
|
))}
|
|
</div>
|
|
) : hasConfigSchema ? (
|
|
<PluginConfigForm
|
|
pluginId={pluginId!}
|
|
companyId={selectedCompanyId}
|
|
schema={configSchema!}
|
|
initialValues={configData?.configJson}
|
|
isLoading={configLoading}
|
|
pluginStatus={plugin.status}
|
|
supportsConfigTest={(plugin as unknown as { supportsConfigTest?: boolean }).supportsConfigTest === true}
|
|
/>
|
|
) : environmentDrivers.length > 0 ? (
|
|
<div className="rounded-md border border-border/60 bg-muted/20 px-4 py-3 text-sm">
|
|
<p className="font-medium text-foreground">Configure this plugin from Settings → Environments.</p>
|
|
<p className="mt-1 text-muted-foreground">
|
|
{driverLabel || "This plugin"} registers environment runtime settings there so the execution target
|
|
stays instance-scoped while secret bindings still resolve through the selected organization context.
|
|
</p>
|
|
<div className="mt-3">
|
|
<Link to="/company/settings/instance/environments">
|
|
<Button variant="outline" size="sm">Open Environments</Button>
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
) : !hasLocalFolders ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
This plugin does not require any settings.
|
|
</p>
|
|
) : null}
|
|
</section>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="status" className="space-y-6">
|
|
<div className="grid gap-6 xl:grid-cols-(--gtc-39)">
|
|
<div className="space-y-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base flex items-center gap-1.5">
|
|
<Cpu className="h-4 w-4" />
|
|
Runtime Dashboard
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Worker process, scheduled jobs, and webhook deliveries
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
{dashboardData ? (
|
|
<>
|
|
<div>
|
|
<h3 className="text-sm font-medium mb-3 flex items-center gap-1.5">
|
|
<Cpu className="h-3.5 w-3.5 text-muted-foreground" />
|
|
Worker Process
|
|
</h3>
|
|
{dashboardData.worker ? (
|
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Status</span>
|
|
<Badge variant={dashboardData.worker.status === "running" ? "default" : "secondary"}>
|
|
{dashboardData.worker.status}
|
|
</Badge>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">PID</span>
|
|
<span className="font-mono text-xs">{dashboardData.worker.pid ?? "—"}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Uptime</span>
|
|
<span className="text-xs">{formatUptime(dashboardData.worker.uptime)}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Pending RPCs</span>
|
|
<span className="text-xs">{dashboardData.worker.pendingRequests}</span>
|
|
</div>
|
|
{dashboardData.worker.totalCrashes > 0 && (
|
|
<>
|
|
<div className="flex justify-between col-span-2">
|
|
<span className="text-muted-foreground flex items-center gap-1">
|
|
<AlertTriangle className="h-3 w-3 text-amber-500" />
|
|
Crashes
|
|
</span>
|
|
<span className="text-xs">
|
|
{dashboardData.worker.consecutiveCrashes} consecutive / {dashboardData.worker.totalCrashes} total
|
|
</span>
|
|
</div>
|
|
{dashboardData.worker.lastCrashAt && (
|
|
<div className="flex justify-between col-span-2">
|
|
<span className="text-muted-foreground">Last Crash</span>
|
|
<span className="text-xs">{formatTimestamp(dashboardData.worker.lastCrashAt)}</span>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground italic">No worker process registered.</p>
|
|
)}
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
<div>
|
|
<h3 className="text-sm font-medium mb-3 flex items-center gap-1.5">
|
|
<CalendarClock className="h-3.5 w-3.5 text-muted-foreground" />
|
|
Recent Job Runs
|
|
</h3>
|
|
{dashboardData.recentJobRuns.length > 0 ? (
|
|
<div className="space-y-2">
|
|
{dashboardData.recentJobRuns.map((run) => (
|
|
<div
|
|
key={run.id}
|
|
className="flex items-center justify-between gap-2 rounded-md bg-muted/50 px-2 py-1.5 text-sm"
|
|
>
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<JobStatusDot status={run.status} />
|
|
<span className="truncate font-mono text-xs" title={run.jobKey ?? run.jobId}>
|
|
{run.jobKey ?? run.jobId.slice(0, 8)}
|
|
</span>
|
|
<Badge variant="outline" className="px-1 py-0 text-(length:--text-nano)">
|
|
{run.trigger}
|
|
</Badge>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
|
|
{run.durationMs != null ? <span>{formatDuration(run.durationMs)}</span> : null}
|
|
<span title={run.createdAt}>{formatRelativeTime(run.createdAt)}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground italic">No job runs recorded yet.</p>
|
|
)}
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
<div>
|
|
<h3 className="text-sm font-medium mb-3 flex items-center gap-1.5">
|
|
<Webhook className="h-3.5 w-3.5 text-muted-foreground" />
|
|
Recent Webhook Deliveries
|
|
</h3>
|
|
{dashboardData.recentWebhookDeliveries.length > 0 ? (
|
|
<div className="space-y-2">
|
|
{dashboardData.recentWebhookDeliveries.map((delivery) => (
|
|
<div
|
|
key={delivery.id}
|
|
className="flex items-center justify-between gap-2 rounded-md bg-muted/50 px-2 py-1.5 text-sm"
|
|
>
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<DeliveryStatusDot status={delivery.status} />
|
|
<span className="truncate font-mono text-xs" title={delivery.webhookKey}>
|
|
{delivery.webhookKey}
|
|
</span>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
|
|
{delivery.durationMs != null ? <span>{formatDuration(delivery.durationMs)}</span> : null}
|
|
<span title={delivery.createdAt}>{formatRelativeTime(delivery.createdAt)}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground italic">No webhook deliveries recorded yet.</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1.5 border-t border-border/50 pt-2 text-xs text-muted-foreground">
|
|
<Clock className="h-3 w-3" />
|
|
Last checked: {new Date(dashboardData.checkedAt).toLocaleTimeString()}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">
|
|
Runtime diagnostics are unavailable right now.
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{recentLogs && recentLogs.length > 0 ? (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base flex items-center gap-1.5">
|
|
<ActivitySquare className="h-4 w-4" />
|
|
Recent Logs
|
|
</CardTitle>
|
|
<CardDescription>Last {recentLogs.length} log entries</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="max-h-64 space-y-1 overflow-y-auto font-mono text-xs">
|
|
{recentLogs.map((entry) => (
|
|
<div
|
|
key={entry.id}
|
|
className={`flex gap-2 py-0.5 ${
|
|
entry.level === "error"
|
|
? "text-destructive"
|
|
: entry.level === "warn"
|
|
? "text-yellow-600 dark:text-yellow-400"
|
|
: entry.level === "debug"
|
|
? "text-muted-foreground/60"
|
|
: "text-muted-foreground"
|
|
}`}
|
|
>
|
|
<span className="shrink-0 text-muted-foreground/50">{new Date(entry.createdAt).toLocaleTimeString()}</span>
|
|
<Badge variant="outline" className="h-4 shrink-0 px-1 text-(length:--text-nano)">{entry.level}</Badge>
|
|
<span className="truncate" title={entry.message}>{entry.message}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base flex items-center gap-1.5">
|
|
<ActivitySquare className="h-4 w-4" />
|
|
Health Status
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{healthLoading ? (
|
|
<p className="text-sm text-muted-foreground">Checking health...</p>
|
|
) : healthData ? (
|
|
<div className="space-y-4 text-sm">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-muted-foreground">Overall</span>
|
|
<Badge variant={healthData.healthy ? "default" : "destructive"}>
|
|
{healthData.status}
|
|
</Badge>
|
|
</div>
|
|
|
|
{healthData.checks.length > 0 ? (
|
|
<div className="space-y-2 border-t border-border/50 pt-2">
|
|
{healthData.checks.map((check, i) => (
|
|
<div key={i} className="flex items-start justify-between gap-2">
|
|
<span className="truncate text-muted-foreground" title={check.name}>
|
|
{check.name}
|
|
</span>
|
|
{check.passed ? (
|
|
<CheckCircle className="h-4 w-4 shrink-0 text-green-500" />
|
|
) : (
|
|
<XCircle className="h-4 w-4 shrink-0 text-destructive" />
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
|
|
{healthData.lastError ? (
|
|
<div className="break-words rounded border border-destructive/20 bg-destructive/10 p-2 text-xs text-destructive">
|
|
{healthData.lastError}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3 text-sm text-muted-foreground">
|
|
<div className="flex items-center justify-between">
|
|
<span>Lifecycle</span>
|
|
<Badge variant={statusVariant}>{displayStatus}</Badge>
|
|
</div>
|
|
<p>Health checks run once the plugin is ready.</p>
|
|
{plugin.lastError ? (
|
|
<div className="break-words rounded border border-destructive/20 bg-destructive/10 p-2 text-xs text-destructive">
|
|
{plugin.lastError}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Details</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
|
<div className="flex justify-between gap-3">
|
|
<span>Plugin ID</span>
|
|
<span className="font-mono text-xs text-right">{plugin.id}</span>
|
|
</div>
|
|
<div className="flex justify-between gap-3">
|
|
<span>Plugin Key</span>
|
|
<span className="font-mono text-xs text-right">{plugin.pluginKey}</span>
|
|
</div>
|
|
<div className="flex justify-between gap-3">
|
|
<span>NPM Package</span>
|
|
<span className="max-w-(--sz-170px) truncate text-right text-xs" title={plugin.packageName}>
|
|
{plugin.packageName}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between gap-3">
|
|
<span>Version</span>
|
|
<span className="text-right text-foreground">v{plugin.manifestJson.version ?? plugin.version}</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base flex items-center gap-1.5">
|
|
<ShieldAlert className="h-4 w-4" />
|
|
Permissions
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{pluginCapabilities.length > 0 ? (
|
|
<ul className="space-y-2 text-sm text-muted-foreground">
|
|
{pluginCapabilities.map((cap) => (
|
|
<li key={cap} className="rounded-md bg-muted/40 px-2.5 py-2 font-mono text-xs text-foreground/85">
|
|
{cap}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground italic">No special permissions requested.</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PluginLocalFoldersSettings — host-managed company-scoped folders
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface PluginLocalFoldersSettingsProps {
|
|
pluginId: string;
|
|
companyId: string | null;
|
|
declarations: PluginLocalFolderDeclaration[];
|
|
}
|
|
|
|
function PluginLocalFoldersSettings({ pluginId, companyId, declarations }: PluginLocalFoldersSettingsProps) {
|
|
const foldersQuery = useQuery({
|
|
queryKey: companyId
|
|
? queryKeys.plugins.localFolders(pluginId, companyId)
|
|
: ["plugins", pluginId, "companies", "none", "local-folders"],
|
|
queryFn: () => pluginsApi.listLocalFolders(pluginId, companyId!),
|
|
enabled: !!companyId,
|
|
});
|
|
const foldersView = useQueryView(foldersQuery);
|
|
|
|
// Loaded folder statuses stay on the rows through an outage.
|
|
const statusByKey = new Map((foldersQuery.data?.folders ?? []).map((folder) => [folder.folderKey, folder]));
|
|
|
|
if (!companyId) {
|
|
return (
|
|
<div className="rounded-md border border-border/60 bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
|
|
Select an organization to configure this plugin's local folders.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
|
<h3 className="text-sm font-medium">Local folders</h3>
|
|
</div>
|
|
{foldersView.kind === "error" ? (
|
|
<QueryErrorState
|
|
size="panel"
|
|
error={foldersQuery.error}
|
|
action="load local folder settings"
|
|
onRetry={foldersView.retry}
|
|
retrying={foldersView.isFetching}
|
|
/>
|
|
) : null}
|
|
{foldersView.kind === "loading" || foldersView.kind === "reconnecting" ? (
|
|
<QueryPlaceholder size="panel" label="Loading local folders" className="py-3" />
|
|
) : (
|
|
<div className="space-y-3">
|
|
{declarations.map((declaration) => (
|
|
<PluginLocalFolderRow
|
|
key={declaration.folderKey}
|
|
pluginId={pluginId}
|
|
companyId={companyId}
|
|
declaration={declaration}
|
|
status={statusByKey.get(declaration.folderKey)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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: describeError(err).body });
|
|
},
|
|
});
|
|
|
|
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 (
|
|
<div className="space-y-4 rounded-md border border-border/70 bg-background px-4 py-4">
|
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
<div className="min-w-0 space-y-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<h4 className="text-sm font-medium">{declaration.displayName}</h4>
|
|
<Badge variant="outline" className="font-mono text-(length:--text-nano)">
|
|
{declaration.folderKey}
|
|
</Badge>
|
|
<Badge variant={status?.healthy ? "default" : "secondary"}>
|
|
{status?.healthy ? "Healthy" : "Needs attention"}
|
|
</Badge>
|
|
</div>
|
|
{declaration.description ? (
|
|
<p className="max-w-3xl text-sm leading-5 text-muted-foreground">
|
|
{declaration.description}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
<Badge variant={access === "readWrite" ? "default" : "outline"}>
|
|
{access === "readWrite" ? "Read/write" : "Read only"}
|
|
</Badge>
|
|
</div>
|
|
|
|
<div className="grid gap-3 text-sm sm:grid-cols-3">
|
|
<FolderStatusMetric label="Configured" value={status?.configured ? "Yes" : "No"} ok={!!status?.configured} />
|
|
<FolderStatusMetric label="Readable" value={status?.readable ? "Yes" : "No"} ok={!!status?.readable} />
|
|
<FolderStatusMetric
|
|
label="Writable"
|
|
value={access === "read" ? "Not requested" : status?.writable ? "Yes" : "No"}
|
|
ok={access === "read" || !!status?.writable}
|
|
/>
|
|
</div>
|
|
|
|
{status?.path ? (
|
|
<div className="space-y-1 text-sm">
|
|
<div className="text-xs font-medium text-muted-foreground">Configured path</div>
|
|
<div className="break-all rounded-md bg-muted/60 px-2 py-1.5 font-mono text-xs text-foreground">
|
|
{status.path}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground" htmlFor={`local-folder-${declaration.folderKey}`}>
|
|
Local folder path
|
|
</label>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
id={`local-folder-${declaration.folderKey}`}
|
|
className="min-w-0 flex-1 rounded-md border border-border bg-background px-2.5 py-1.5 font-mono text-sm outline-none focus:border-foreground/40 focus:ring-2 focus:ring-ring/20"
|
|
value={pathValue}
|
|
onChange={(event) => {
|
|
setPathValue(event.target.value);
|
|
setMessage(null);
|
|
}}
|
|
placeholder="/absolute/path/to/folder"
|
|
/>
|
|
<ChoosePathButton className="h-8" />
|
|
<Button
|
|
size="sm"
|
|
onClick={handleSave}
|
|
disabled={saveMutation.isPending || !isDirty || !companyId}
|
|
>
|
|
{saveMutation.isPending ? (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<Save className="h-3.5 w-3.5" />
|
|
)}
|
|
Save
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<FolderRequirements status={status} declaration={declaration} />
|
|
|
|
{status?.problems?.length ? (
|
|
<div className="space-y-2 rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
<div className="font-medium">Validation problems</div>
|
|
<ul className="space-y-1">
|
|
{status.problems.map((problem, index) => (
|
|
<li key={`${problem.code}:${problem.path ?? ""}:${index}`}>
|
|
{problem.message}
|
|
{problem.path ? <span className="font-mono"> {problem.path}</span> : null}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
) : null}
|
|
|
|
{message ? (
|
|
<div
|
|
className={`rounded-md border px-3 py-2 text-sm ${
|
|
message.type === "success"
|
|
? "border-green-200 bg-green-50 text-green-700 dark:border-green-900 dark:bg-green-950/30 dark:text-green-400"
|
|
: "border-destructive/20 bg-destructive/10 text-destructive"
|
|
}`}
|
|
>
|
|
{message.text}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FolderStatusMetric({ label, value, ok }: { label: string; value: string; ok: boolean }) {
|
|
return (
|
|
<div className="flex items-center justify-between rounded-md border border-border/60 px-2.5 py-2">
|
|
<span className="text-muted-foreground">{label}</span>
|
|
<Badge variant={ok ? "default" : "secondary"}>{value}</Badge>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="grid gap-3 text-sm md:grid-cols-2">
|
|
<RequirementList
|
|
title="Required directories"
|
|
items={requiredDirectories}
|
|
missingItems={missingDirectories}
|
|
missingLabel="Missing directories"
|
|
inspectionUnavailable={rootNotInspected}
|
|
/>
|
|
<RequirementList
|
|
title="Required files"
|
|
items={requiredFiles}
|
|
missingItems={missingFiles}
|
|
missingLabel="Missing files"
|
|
inspectionUnavailable={rootNotInspected}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="space-y-2 rounded-md border border-border/60 px-3 py-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="text-xs font-medium text-muted-foreground">{title}</span>
|
|
{inspectionUnavailable ? (
|
|
<Badge variant="secondary" className="text-(length:--text-nano)">
|
|
Not inspected
|
|
</Badge>
|
|
) : missingItems.length > 0 ? (
|
|
<Badge variant="destructive" className="text-(length:--text-nano)">
|
|
{missingItems.length} missing
|
|
</Badge>
|
|
) : (
|
|
<Badge variant="outline" className="text-(length:--text-nano)">Present</Badge>
|
|
)}
|
|
</div>
|
|
{items.length > 0 ? (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{items.map((item) => {
|
|
const missing = missingItems.includes(item);
|
|
return (
|
|
<span
|
|
key={item}
|
|
className={`rounded border px-1.5 py-0.5 font-mono text-(length:--text-micro) ${
|
|
inspectionUnavailable
|
|
? "border-amber-300/60 bg-amber-50 text-amber-700 dark:border-amber-800/70 dark:bg-amber-950/30 dark:text-amber-300"
|
|
: missing
|
|
? "border-destructive/30 bg-destructive/10 text-destructive"
|
|
: "border-border bg-muted/50 text-foreground/80"
|
|
}`}
|
|
>
|
|
{item}
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-muted-foreground">None declared.</p>
|
|
)}
|
|
{inspectionUnavailable ? (
|
|
<p className="text-xs text-amber-700 dark:text-amber-300">Configured root was not inspected.</p>
|
|
) : missingItems.length > 0 ? (
|
|
<p className="text-xs text-destructive">{missingLabel}: {missingItems.join(", ")}</p>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function isLikelyAbsolutePath(pathValue: string) {
|
|
return (
|
|
pathValue.startsWith("/") ||
|
|
/^[A-Za-z]:[\\/]/.test(pathValue) ||
|
|
pathValue.startsWith("\\\\")
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PluginConfigForm — auto-generated form for instanceConfigSchema
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface PluginConfigFormProps {
|
|
pluginId: string;
|
|
companyId: string | null;
|
|
schema: JsonSchemaNode;
|
|
initialValues?: Record<string, unknown>;
|
|
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, companyId, schema, initialValues, isLoading, pluginStatus, supportsConfigTest }: PluginConfigFormProps) {
|
|
const queryClient = useQueryClient();
|
|
|
|
// Form values: start with saved values, fall back to schema defaults
|
|
const [values, setValues] = useState<Record<string, unknown>>(() => ({
|
|
...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(() => {
|
|
hasHydratedRef.current = false;
|
|
setValues(getDefaultValues(schema));
|
|
}, [companyId, pluginId, schema]);
|
|
|
|
useEffect(() => {
|
|
if (initialValues && !hasHydratedRef.current) {
|
|
hasHydratedRef.current = true;
|
|
setValues({
|
|
...getDefaultValues(schema),
|
|
...initialValues,
|
|
});
|
|
}
|
|
}, [initialValues, schema]);
|
|
|
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
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<string, unknown>) => {
|
|
if (!companyId) throw new Error("Select an organization before saving plugin configuration.");
|
|
return pluginsApi.saveConfig(pluginId, companyId, configJson);
|
|
},
|
|
onSuccess: () => {
|
|
setSaveMessage({ type: "success", text: "Configuration saved." });
|
|
setTestResult(null);
|
|
if (companyId) {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.plugins.config(pluginId, companyId) });
|
|
}
|
|
// Clear success message after 3s
|
|
setTimeout(() => setSaveMessage(null), 3000);
|
|
},
|
|
onError: (err: Error) => {
|
|
setSaveMessage({ type: "error", text: describeError(err).body });
|
|
},
|
|
});
|
|
|
|
// Test configuration mutation
|
|
const testMutation = useMutation({
|
|
mutationFn: (configJson: Record<string, unknown>) => {
|
|
if (!companyId) throw new Error("Select an organization before testing plugin configuration.");
|
|
return pluginsApi.testConfig(pluginId, companyId, 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: describeError(err).body });
|
|
},
|
|
});
|
|
|
|
const handleChange = useCallback((newValues: Record<string, unknown>) => {
|
|
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 (
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground py-4">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
Loading configuration...
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<JsonSchemaForm
|
|
schema={schema}
|
|
values={values}
|
|
onChange={handleChange}
|
|
errors={errors}
|
|
disabled={saveMutation.isPending}
|
|
/>
|
|
|
|
{/* Status messages */}
|
|
{saveMessage && (
|
|
<div
|
|
className={`text-sm p-2 rounded border ${
|
|
saveMessage.type === "success"
|
|
? "text-green-700 bg-green-50 border-green-200 dark:text-green-400 dark:bg-green-950/30 dark:border-green-900"
|
|
: "text-destructive bg-destructive/10 border-destructive/20"
|
|
}`}
|
|
>
|
|
{saveMessage.text}
|
|
</div>
|
|
)}
|
|
|
|
{testResult && (
|
|
<div
|
|
className={`text-sm p-2 rounded border ${
|
|
testResult.type === "success"
|
|
? "text-green-700 bg-green-50 border-green-200 dark:text-green-400 dark:bg-green-950/30 dark:border-green-900"
|
|
: "text-destructive bg-destructive/10 border-destructive/20"
|
|
}`}
|
|
>
|
|
{testResult.text}
|
|
</div>
|
|
)}
|
|
|
|
{/* Action buttons */}
|
|
<div className="flex items-center gap-2 pt-2">
|
|
<Button
|
|
onClick={handleSave}
|
|
disabled={saveMutation.isPending || !isDirty}
|
|
size="sm"
|
|
>
|
|
{saveMutation.isPending ? (
|
|
<>
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
Saving...
|
|
</>
|
|
) : (
|
|
"Save Configuration"
|
|
)}
|
|
</Button>
|
|
{pluginStatus === "ready" && supportsConfigTest && (
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleTestConnection}
|
|
disabled={testMutation.isPending || !companyId}
|
|
size="sm"
|
|
>
|
|
{testMutation.isPending ? (
|
|
<>
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
Testing...
|
|
</>
|
|
) : (
|
|
"Test Configuration"
|
|
)}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 (
|
|
<span
|
|
className={`inline-block h-2 w-2 rounded-full shrink-0 ${colorClass}`}
|
|
title={status}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 (
|
|
<span
|
|
className={`inline-block h-2 w-2 rounded-full shrink-0 ${colorClass}`}
|
|
title={status}
|
|
/>
|
|
);
|
|
}
|