mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-08 11:13:44 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - An instance can turn on the `enableManagedSandboxOnly` feature, which hides the local environment and runs every agent in the platform-managed environment > - That feature already gated the environment pickers, the onboarding wizard, and the server-side run selection, but many other screens still showed absolute paths on the execution host and still let the user pick an execution engine > - On such an instance those controls name a filesystem the user cannot reach; a path written there is stored and then ignored, which reads as a broken control > - This pull request hides the remaining host-path and execution-engine surfaces behind the same feature, adds a server rule that refuses a project-workspace path write while the feature is on, and closes a related route gap in the isolated-workspace pages > - The benefit is that a managed instance shows no host path and no folder picker anywhere, and a write that carries a path now fails with a clear message instead of being silently discarded ## Linked Issues or Issue Description No public issue exists. The description below follows `.github/ISSUE_TEMPLATE/enhancement.yml`. **What existing behavior does this improve?** The `enableManagedSandboxOnly` instance feature, and the UI surfaces that show a host filesystem path: project properties, the new-project dialog, the project workspace and execution workspace detail pages, the workspace and task cards, plugin local folders, and the agent configuration form with its per-adapter fields. It also improves route gating for `enableIsolatedWorkspaces`. **Subsystem affected** Cross-cutting (`ui/` and `server/`). **Current behavior** When `enableManagedSandboxOnly` is on, the local environment disappears from the environment pickers and the server refuses to run an agent on the local host. Everything else stays visible. A user still sees: - the project "Local folder" row, its absolute path, and the Set/Change/Clear buttons - the "Local folder" field and its "Choose" folder picker in the new-project dialog - the "Local path" field and fact row on a project workspace - the "Paths" and "Lifecycle commands" groups on an execution workspace - the working directory on workspace cards, task properties, and runtime service rows - the plugin "Local folders" section - "Working directory (deprecated)", "Command", "Execution engine", "ACP server command", "ACP state directory", and "Agent instructions file" in the agent configuration form A path typed into any of these names a filesystem no agent on the instance uses. The project workspace API also accepts a `cwd` write and stores it. Separately, `/workspaces`, `/execution-workspaces/*`, and `/projects/:projectId/workspaces/:workspaceId` render for anyone who types or bookmarks the URL, even with `enableIsolatedWorkspaces` off. Only the sidebar entry reads that flag. **Proposed behavior** With `enableManagedSandboxOnly` on, none of those surfaces render. A project whose codebase came from a managed checkout keeps its one-line "Paperclip-managed folder." label and shows no path. The non-path controls stay: repo URL, branch, service URL, port, command output, ACP session mode, ACP non-interactive permissions, Codex fast mode, and the sandbox toggles. The project-workspace create and patch routes, and the nested workspace on project create, answer `422` with "This instance runs agents only in the platform-managed environment; local folders are not configurable." when the payload carries a non-null `cwd`. A `cwd: null` write still passes, so an instance that just turned the feature on can clear a stale path. With `enableIsolatedWorkspaces` off, the three workspace route groups redirect to the dashboard. **Reason and benefit** A control that cannot do anything is worse than a missing control: the user fills it in, saves, and gets no error and no effect. The server rule turns that silent no-op into a clear refusal. The route gate stops a feature that an instance has turned off from staying reachable by URL, which is the same standard the Cases, Pipelines, and hidden-settings pages already meet. **Breaking changes** None for a default instance: both flags are off by default for self-hosted and managed instances, so nothing changes unless an operator turns them on. Stored `adapterConfig` values are never cleared, so turning the feature off restores every previous value. ## What Changed - Add `ui/src/hooks/useManagedSandboxOnly.ts`, modelled on `useAppsEnabled`, for components that do not already read the experimental settings. It exposes `hideHostPaths`, which fails closed while the settings query is in flight, so a cold cache never flashes a host path before the policy resolves. Components that keep their own settings read compute the same gate from `isFetched`. - Add `managedSandboxOnly` to `AdapterConfigFieldsProps` and populate it where `AgentConfigForm` builds the adapter field props. Resolve the effective instructions-file gate once as `hideInstructionsFile || hideHostPaths`, so every adapter hides that path field with no per-adapter edit. - Hide under the flag: the project "Local folder" block and its absolute-path edit panel (a managed checkout keeps its label, without the path); the new-project "Local folder" field; the project-workspace "Local path" field and fact row; the execution-workspace "Paths" and "Lifecycle commands" groups; the working directory on the workspace summary card, the task workspace card, the task properties "Folder" row, and the runtime service rows; the plugin "Local folders" section; "Working directory (deprecated)" and "Command" in the agent form; and the per-adapter "Execution engine", "ACP server command", and "ACP state directory" for `claude_local`, `codex_local`, and `gemini_local`. - Drop two working-directory fallbacks that had no gate to read: the close-workspace dialog now falls back to "No additional details", and the reuse-existing workspace label and picker subtitle fall back to a neutral phrase. - Refuse a non-null `cwd` with `422` on `POST /projects/:id/workspaces`, `PATCH /projects/:id/workspaces/:workspaceId`, and the nested workspace on `POST /companies/:companyId/projects`, following the `assertNoAgentHostWorkspaceCommandMutation` precedent on those routes. - Add `IsolatedWorkspacesRouteGate` and wrap the `/workspaces`, `/execution-workspaces/*`, and `/projects/:projectId/workspaces/:workspaceId` routes with it. - Leave the SSH "Remote workspace path" and the workspace file browser alone, with a comment explaining why. ## Verification Automated: - `pnpm --filter @paperclipai/ui exec vitest run` — 479 of 480 files pass (4455 of 4456 tests). The one failure is `OnboardingWizard.test.tsx > renders instead of throwing when the browser denies storage access`, which also fails on `origin/master` and is unrelated to this change. - `pnpm --filter @paperclipai/server exec vitest run project workspace instance-settings` — 45 of 50 files pass. Four files fail on macOS for reasons unrelated to this change: `workspace-instance-cleanup`, `workspace-runtime`, `execution-workspace-runtime-control-conflict`, and `workspace-runtime-exposure` compare `/var/...` against the resolved `/private/var/...` or bind real ports. The same files fail on a clean `master` checkout on the same machine. - `pnpm --filter @paperclipai/ui typecheck` - `tsc --noEmit` in `server/` (after `pnpm --filter @paperclipai/plugin-sdk ensure-build-deps`). The package `typecheck` script also builds the Rust runner, which needs `cargo`; it is not installed on the machine that ran this. New and extended tests: - `ui/src/adapters/managed-sandbox-only-config-fields.test.tsx` — the three adapters drop the execution engine, the ACP paths, the instructions-file path, and every "Choose" button when the flag is on, and keep the non-path controls. - `ui/src/components/AgentConfigForm.render.test.tsx` — flag-on and flag-off renders for the working directory, the command, the engine, the ACP paths, and the resolved adapter field props. - `ui/src/components/ProjectProperties.managed-sandbox.test.tsx`, `ui/src/components/NewProjectDialog.managed-sandbox.test.tsx`, `ui/src/pages/ProjectWorkspaceDetail.test.tsx`, `ui/src/components/ProjectWorkspaceSummaryCard.test.tsx`, `ui/src/components/WorkspaceRuntimeControls.test.tsx`. - `ui/src/components/IsolatedWorkspacesRouteGate.test.tsx` — redirect when off, render when on, and render nothing while the flag query is in flight. - "Still loading" cases for the project properties, the new-project dialog, the workspace summary card, the runtime service rows, and the agent configuration form, each asserting that no host path renders before the policy resolves. - `server/src/__tests__/project-workspace-managed-sandbox-routes.test.ts` — the `422` on all three write paths, the `cwd: null` pass-through, and the flag-off pass-through. Manual check to reproduce: turn on Managed Environment Only in instance experimental settings, then open a project, the new-project dialog, an agent's configuration, and a workspace page. No path, folder icon, or "Choose" button appears. Turn the setting off and each control returns with its stored value. No documentation change was needed. The operator-facing text for both settings lives in the feature catalog entry, which already states the contract this pull request now enforces across the UI. ## Risks - Low. Both flags default to off, so a default instance is unchanged. - The hidden fields are presentation only. No stored `adapterConfig` value is cleared, because an import carries adapter configuration written on another instance and clearing it would break that flow. Turning the setting off shows every previous value again. - The `422` is the one behavior change for an API caller, and only while the setting is on. `cwd: null` still passes so a stale path can be cleared. - The route gate renders nothing until the flag query settles, so an instance with isolated workspaces on never flashes a redirect. An instance with the feature off now redirects a bookmarked workspace URL to the dashboard. - Every host-path guard fails closed while the settings query is in flight, so a default instance shows those controls a moment later than before on a cold load. That is the safe direction: the alternative flashes a path a managed instance must never show. - Two path surfaces stay on purpose, each with a comment: the SSH "Remote workspace path" is a path on the user's own remote host, and the workspace file browser shows workspace-relative paths. The instance Adapters page also keeps its "Local path" install option, since that page is an instance-admin surface the hosting operator can already hide through the hidden-settings mechanism. ## Model Used Claude (Anthropic), Claude Opus, 1M context window, extended thinking, agentic tool use through Claude Code. ## 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 either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [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 any 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
1234 lines
49 KiB
TypeScript
1234 lines
49 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";
|
|
|
|
/**
|
|
* 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 { data: plugin, isLoading: pluginLoading } = useQuery({
|
|
queryKey: queryKeys.plugins.detail(pluginId!),
|
|
queryFn: () => pluginsApi.get(pluginId!),
|
|
enabled: !!pluginId,
|
|
});
|
|
|
|
const { data: healthData, isLoading: healthLoading } = useQuery({
|
|
queryKey: queryKeys.plugins.health(pluginId!),
|
|
queryFn: () => pluginsApi.health(pluginId!),
|
|
enabled: !!pluginId && plugin?.status === "ready",
|
|
refetchInterval: 30000,
|
|
});
|
|
|
|
const { data: dashboardData } = useQuery({
|
|
queryKey: queryKeys.plugins.dashboard(pluginId!),
|
|
queryFn: () => pluginsApi.dashboard(pluginId!),
|
|
enabled: !!pluginId,
|
|
refetchInterval: 30000,
|
|
});
|
|
|
|
const { data: recentLogs } = useQuery({
|
|
queryKey: queryKeys.plugins.logs(pluginId!),
|
|
queryFn: () => pluginsApi.logs(pluginId!, { limit: 50 }),
|
|
enabled: !!pluginId && plugin?.status === "ready",
|
|
refetchInterval: 30000,
|
|
});
|
|
|
|
// Fetch existing config for the plugin
|
|
const configSchema = plugin?.manifestJson?.instanceConfigSchema as JsonSchemaNode | undefined;
|
|
const hasConfigSchema = configSchema && configSchema.properties && Object.keys(configSchema.properties).length > 0;
|
|
|
|
const 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 (pluginLoading) {
|
|
return <div className="p-4 text-sm text-muted-foreground">Loading plugin details...</div>;
|
|
}
|
|
|
|
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 { data, isLoading, error } = useQuery({
|
|
queryKey: companyId
|
|
? queryKeys.plugins.localFolders(pluginId, companyId)
|
|
: ["plugins", pluginId, "companies", "none", "local-folders"],
|
|
queryFn: () => pluginsApi.listLocalFolders(pluginId, companyId!),
|
|
enabled: !!companyId,
|
|
});
|
|
|
|
const statusByKey = new Map((data?.folders ?? []).map((folder) => [folder.folderKey, folder]));
|
|
|
|
if (!companyId) {
|
|
return (
|
|
<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>
|
|
{error ? (
|
|
<div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
{(error as Error).message || "Failed to load local folder settings."}
|
|
</div>
|
|
) : null}
|
|
{isLoading ? (
|
|
<div className="flex items-center gap-2 py-3 text-sm text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
Loading local folders...
|
|
</div>
|
|
) : (
|
|
<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: err.message || "Failed to save local folder." });
|
|
},
|
|
});
|
|
|
|
const trimmedPath = pathValue.trim();
|
|
const isDirty = trimmedPath !== serverPath;
|
|
const access = status?.access ?? declaration.access ?? "readWrite";
|
|
|
|
const handleSave = useCallback(() => {
|
|
if (!trimmedPath) {
|
|
setMessage({ type: "error", text: "Local folder path is required." });
|
|
return;
|
|
}
|
|
if (!isLikelyAbsolutePath(trimmedPath)) {
|
|
setMessage({ type: "error", text: "Local folder must be a full absolute path." });
|
|
return;
|
|
}
|
|
setMessage(null);
|
|
saveMutation.mutate(trimmedPath);
|
|
}, [saveMutation, trimmedPath]);
|
|
|
|
return (
|
|
<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: err.message || "Failed to save configuration." });
|
|
},
|
|
});
|
|
|
|
// 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: err.message || "Configuration test failed." });
|
|
},
|
|
});
|
|
|
|
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}
|
|
/>
|
|
);
|
|
}
|