Files
PaperClipAI/ui/src/pages/PluginSettings.tsx
T
a3313f6944 fix(ui): complete read recovery and enforce query error rendering (#18822)
## 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>
2026-11-04 14:07:18 -08:00

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