Merge remote-tracking branch 'public-gh/master' into paperclip-subissues

* public-gh/master:
  Drop lockfile from watcher change
  Tighten plugin dev file watching
  Fix plugin smoke example typecheck
  Fix plugin dev watcher and migration snapshot
  Clarify plugin authoring and external dev workflow
  Expand kitchen sink plugin demos
  fix: set AGENT_HOME env var for agent processes
  Add kitchen sink plugin example
  Simplify plugin runtime and cleanup lifecycle
  Add plugin framework and settings UI

# Conflicts:
#	packages/db/src/migrations/meta/0029_snapshot.json
#	packages/db/src/migrations/meta/_journal.json
This commit is contained in:
Dotta
2026-03-14 13:56:09 -05:00
141 changed files with 47521 additions and 961 deletions

View File

@@ -24,6 +24,7 @@ import { ActiveAgentsPanel } from "../components/ActiveAgentsPanel";
import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts";
import { PageSkeleton } from "../components/PageSkeleton";
import type { Agent, Issue } from "@paperclipai/shared";
import { PluginSlotOutlet } from "@/plugins/slots";
function getRecentIssues(issues: Issue[]): Issue[] {
return [...issues]
@@ -276,6 +277,13 @@ export function Dashboard() {
</ChartCard>
</div>
<PluginSlotOutlet
slotTypes={["dashboardWidget"]}
context={{ companyId: selectedCompanyId }}
className="grid gap-4 md:grid-cols-2"
itemClassName="rounded-lg border bg-card p-4 shadow-sm"
/>
<div className="grid md:grid-cols-2 gap-4">
{/* Recent Activity */}
{recentActivity.length > 0 && (

View File

@@ -26,6 +26,8 @@ import { StatusIcon } from "../components/StatusIcon";
import { PriorityIcon } from "../components/PriorityIcon";
import { StatusBadge } from "../components/StatusBadge";
import { Identity } from "../components/Identity";
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
import { PluginLauncherOutlet } from "@/plugins/launchers";
import { Separator } from "@/components/ui/separator";
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
@@ -244,6 +246,7 @@ export function IssueDetail() {
queryFn: () => issuesApi.get(issueId!),
enabled: !!issueId,
});
const resolvedCompanyId = issue?.companyId ?? selectedCompanyId;
const { data: comments } = useQuery({
queryKey: queryKeys.issues.comments(issueId!),
@@ -333,6 +336,21 @@ export function IssueDetail() {
companyId: selectedCompanyId,
userId: currentUserId,
});
const { slots: issuePluginDetailSlots } = usePluginSlots({
slotTypes: ["detailTab"],
entityType: "issue",
companyId: resolvedCompanyId,
enabled: !!resolvedCompanyId,
});
const issuePluginTabItems = useMemo(
() => issuePluginDetailSlots.map((slot) => ({
value: `plugin:${slot.pluginKey}:${slot.id}`,
label: slot.displayName,
slot,
})),
[issuePluginDetailSlots],
);
const activePluginTab = issuePluginTabItems.find((item) => item.value === detailTab) ?? null;
const agentMap = useMemo(() => {
const map = new Map<string, Agent>();
@@ -868,6 +886,47 @@ export function IssueDetail() {
/>
</div>
<PluginSlotOutlet
slotTypes={["toolbarButton", "contextMenuItem"]}
entityType="issue"
context={{
companyId: issue.companyId,
projectId: issue.projectId ?? null,
entityId: issue.id,
entityType: "issue",
}}
className="flex flex-wrap gap-2"
itemClassName="inline-flex"
missingBehavior="placeholder"
/>
<PluginLauncherOutlet
placementZones={["toolbarButton"]}
entityType="issue"
context={{
companyId: issue.companyId,
projectId: issue.projectId ?? null,
entityId: issue.id,
entityType: "issue",
}}
className="flex flex-wrap gap-2"
itemClassName="inline-flex"
/>
<PluginSlotOutlet
slotTypes={["taskDetailView"]}
entityType="issue"
context={{
companyId: issue.companyId,
projectId: issue.projectId ?? null,
entityId: issue.id,
entityType: "issue",
}}
className="space-y-3"
itemClassName="rounded-lg border border-border p-3"
missingBehavior="placeholder"
/>
<IssueDocumentsSection
issue={issue}
canDeleteDocuments={Boolean(session?.user?.id)}
@@ -971,12 +1030,19 @@ export function IssueDetail() {
<ActivityIcon className="h-3.5 w-3.5" />
Activity
</TabsTrigger>
{issuePluginTabItems.map((item) => (
<TabsTrigger key={item.value} value={item.value}>
{item.label}
</TabsTrigger>
))}
</TabsList>
<TabsContent value="comments">
<CommentThread
comments={commentsWithRunMeta}
linkedRuns={timelineRuns}
companyId={issue.companyId}
projectId={issue.projectId}
issueStatus={issue.status}
agentMap={agentMap}
draftKey={`paperclip:issue-comment-draft:${issue.id}`}
@@ -1242,6 +1308,21 @@ export function IssueDetail() {
</div>
)}
</TabsContent>
{activePluginTab && (
<TabsContent value={activePluginTab.value}>
<PluginSlotMount
slot={activePluginTab.slot}
context={{
companyId: issue.companyId,
projectId: issue.projectId ?? null,
entityId: issue.id,
entityType: "issue",
}}
missingBehavior="placeholder"
/>
</TabsContent>
)}
</Tabs>
{linkedApprovals && linkedApprovals.length > 0 && (

View File

@@ -0,0 +1,509 @@
/**
* @fileoverview Plugin Manager page — admin UI for discovering,
* installing, enabling/disabling, and uninstalling plugins.
*
* @see PLUGIN_SPEC.md §9 — Plugin Marketplace / Manager
*/
import { useEffect, useMemo, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { PluginRecord } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { AlertTriangle, FlaskConical, Plus, Power, Puzzle, Settings, Trash } from "lucide-react";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { pluginsApi } from "@/api/plugins";
import { queryKeys } from "@/lib/queryKeys";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useToast } from "@/context/ToastContext";
import { cn } from "@/lib/utils";
function firstNonEmptyLine(value: string | null | undefined): string | null {
if (!value) return null;
const line = value
.split(/\r?\n/)
.map((entry) => entry.trim())
.find(Boolean);
return line ?? null;
}
function getPluginErrorSummary(plugin: PluginRecord): string {
return firstNonEmptyLine(plugin.lastError) ?? "Plugin entered an error state without a stored error message.";
}
/**
* PluginManager page component.
*
* Provides a management UI for the Paperclip plugin system:
* - Lists all installed plugins with their status, version, and category badges.
* - Allows installing new plugins by npm package name.
* - Provides per-plugin actions: enable, disable, navigate to settings.
* - Uninstall with a two-step confirmation dialog to prevent accidental removal.
*
* Data flow:
* - Reads from `GET /api/plugins` via `pluginsApi.list()`.
* - Mutations (install / uninstall / enable / disable) invalidate
* `queryKeys.plugins.all` so the list refreshes automatically.
*
* @see PluginSettings — linked from the Settings icon on each plugin row.
* @see doc/plugins/PLUGIN_SPEC.md §3 — Plugin Lifecycle for status semantics.
*/
export function PluginManager() {
const { selectedCompany } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const { pushToast } = useToast();
const [installPackage, setInstallPackage] = useState("");
const [installDialogOpen, setInstallDialogOpen] = useState(false);
const [uninstallPluginId, setUninstallPluginId] = useState<string | null>(null);
const [uninstallPluginName, setUninstallPluginName] = useState<string>("");
const [errorDetailsPlugin, setErrorDetailsPlugin] = useState<PluginRecord | null>(null);
useEffect(() => {
setBreadcrumbs([
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
{ label: "Settings", href: "/instance/settings/heartbeats" },
{ label: "Plugins" },
]);
}, [selectedCompany?.name, setBreadcrumbs]);
const { data: plugins, isLoading, error } = useQuery({
queryKey: queryKeys.plugins.all,
queryFn: () => pluginsApi.list(),
});
const examplesQuery = useQuery({
queryKey: queryKeys.plugins.examples,
queryFn: () => pluginsApi.listExamples(),
});
const invalidatePluginQueries = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.plugins.all });
queryClient.invalidateQueries({ queryKey: queryKeys.plugins.examples });
queryClient.invalidateQueries({ queryKey: queryKeys.plugins.uiContributions });
};
const installMutation = useMutation({
mutationFn: (params: { packageName: string; version?: string; isLocalPath?: boolean }) =>
pluginsApi.install(params),
onSuccess: () => {
invalidatePluginQueries();
setInstallDialogOpen(false);
setInstallPackage("");
pushToast({ title: "Plugin installed successfully", tone: "success" });
},
onError: (err: Error) => {
pushToast({ title: "Failed to install plugin", body: err.message, tone: "error" });
},
});
const uninstallMutation = useMutation({
mutationFn: (pluginId: string) => pluginsApi.uninstall(pluginId),
onSuccess: () => {
invalidatePluginQueries();
pushToast({ title: "Plugin uninstalled successfully", tone: "success" });
},
onError: (err: Error) => {
pushToast({ title: "Failed to uninstall plugin", body: err.message, tone: "error" });
},
});
const enableMutation = useMutation({
mutationFn: (pluginId: string) => pluginsApi.enable(pluginId),
onSuccess: () => {
invalidatePluginQueries();
pushToast({ title: "Plugin enabled", tone: "success" });
},
onError: (err: Error) => {
pushToast({ title: "Failed to enable plugin", body: err.message, tone: "error" });
},
});
const disableMutation = useMutation({
mutationFn: (pluginId: string) => pluginsApi.disable(pluginId),
onSuccess: () => {
invalidatePluginQueries();
pushToast({ title: "Plugin disabled", tone: "info" });
},
onError: (err: Error) => {
pushToast({ title: "Failed to disable plugin", body: err.message, tone: "error" });
},
});
const installedPlugins = plugins ?? [];
const examples = examplesQuery.data ?? [];
const installedByPackageName = new Map(installedPlugins.map((plugin) => [plugin.packageName, plugin]));
const examplePackageNames = new Set(examples.map((example) => example.packageName));
const errorSummaryByPluginId = useMemo(
() =>
new Map(
installedPlugins.map((plugin) => [plugin.id, getPluginErrorSummary(plugin)])
),
[installedPlugins]
);
if (isLoading) return <div className="p-4 text-sm text-muted-foreground">Loading plugins...</div>;
if (error) return <div className="p-4 text-sm text-destructive">Failed to load plugins.</div>;
return (
<div className="space-y-6 max-w-5xl">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Puzzle className="h-6 w-6 text-muted-foreground" />
<h1 className="text-xl font-semibold">Plugin Manager</h1>
</div>
<Dialog open={installDialogOpen} onOpenChange={setInstallDialogOpen}>
<DialogTrigger asChild>
<Button size="sm" className="gap-2">
<Plus className="h-4 w-4" />
Install Plugin
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Install Plugin</DialogTitle>
<DialogDescription>
Enter the npm package name of the plugin you wish to install.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="packageName">npm Package Name</Label>
<Input
id="packageName"
placeholder="@paperclipai/plugin-example"
value={installPackage}
onChange={(e) => setInstallPackage(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setInstallDialogOpen(false)}>Cancel</Button>
<Button
onClick={() => installMutation.mutate({ packageName: installPackage })}
disabled={!installPackage || installMutation.isPending}
>
{installMutation.isPending ? "Installing..." : "Install"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-700" />
<div className="space-y-1 text-sm">
<p className="font-medium text-foreground">Plugins are alpha.</p>
<p className="text-muted-foreground">
The plugin runtime and API surface are still changing. Expect breaking changes while this feature settles.
</p>
</div>
</div>
</div>
<section className="space-y-3">
<div className="flex items-center gap-2">
<FlaskConical className="h-5 w-5 text-muted-foreground" />
<h2 className="text-base font-semibold">Available Plugins</h2>
<Badge variant="outline">Examples</Badge>
</div>
{examplesQuery.isLoading ? (
<div className="text-sm text-muted-foreground">Loading bundled examples...</div>
) : examplesQuery.error ? (
<div className="text-sm text-destructive">Failed to load bundled examples.</div>
) : examples.length === 0 ? (
<div className="rounded-md border border-dashed px-4 py-3 text-sm text-muted-foreground">
No bundled example plugins were found in this checkout.
</div>
) : (
<ul className="divide-y rounded-md border bg-card">
{examples.map((example) => {
const installedPlugin = installedByPackageName.get(example.packageName);
const installPending =
installMutation.isPending &&
installMutation.variables?.isLocalPath &&
installMutation.variables.packageName === example.localPath;
return (
<li key={example.packageName}>
<div className="flex items-center gap-4 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{example.displayName}</span>
<Badge variant="outline">Example</Badge>
{installedPlugin ? (
<Badge
variant={installedPlugin.status === "ready" ? "default" : "secondary"}
className={installedPlugin.status === "ready" ? "bg-green-600 hover:bg-green-700" : ""}
>
{installedPlugin.status}
</Badge>
) : (
<Badge variant="secondary">Not installed</Badge>
)}
</div>
<p className="mt-1 text-sm text-muted-foreground">{example.description}</p>
<p className="mt-1 text-xs text-muted-foreground">{example.packageName}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{installedPlugin ? (
<>
{installedPlugin.status !== "ready" && (
<Button
variant="outline"
size="sm"
disabled={enableMutation.isPending}
onClick={() => enableMutation.mutate(installedPlugin.id)}
>
Enable
</Button>
)}
<Button variant="outline" size="sm" asChild>
<Link to={`/instance/settings/plugins/${installedPlugin.id}`}>
{installedPlugin.status === "ready" ? "Open Settings" : "Review"}
</Link>
</Button>
</>
) : (
<Button
size="sm"
disabled={installPending || installMutation.isPending}
onClick={() =>
installMutation.mutate({
packageName: example.localPath,
isLocalPath: true,
})
}
>
{installPending ? "Installing..." : "Install Example"}
</Button>
)}
</div>
</div>
</li>
);
})}
</ul>
)}
</section>
<section className="space-y-3">
<div className="flex items-center gap-2">
<Puzzle className="h-5 w-5 text-muted-foreground" />
<h2 className="text-base font-semibold">Installed Plugins</h2>
</div>
{!installedPlugins.length ? (
<Card className="bg-muted/30">
<CardContent className="flex flex-col items-center justify-center py-10">
<Puzzle className="h-10 w-10 text-muted-foreground mb-4" />
<p className="text-sm font-medium">No plugins installed</p>
<p className="text-xs text-muted-foreground mt-1">
Install a plugin to extend functionality.
</p>
</CardContent>
</Card>
) : (
<ul className="divide-y rounded-md border bg-card">
{installedPlugins.map((plugin) => (
<li key={plugin.id}>
<div className="flex items-start gap-4 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<Link
to={`/instance/settings/plugins/${plugin.id}`}
className="font-medium hover:underline truncate block"
title={plugin.manifestJson.displayName ?? plugin.packageName}
>
{plugin.manifestJson.displayName ?? plugin.packageName}
</Link>
{examplePackageNames.has(plugin.packageName) && (
<Badge variant="outline">Example</Badge>
)}
</div>
<div>
<p className="text-xs text-muted-foreground mt-0.5 truncate" title={plugin.packageName}>
{plugin.packageName} · v{plugin.manifestJson.version ?? plugin.version}
</p>
</div>
<p className="text-sm text-muted-foreground truncate mt-0.5" title={plugin.manifestJson.description}>
{plugin.manifestJson.description || "No description provided."}
</p>
{plugin.status === "error" && (
<div className="mt-3 rounded-md border border-red-500/25 bg-red-500/[0.06] px-3 py-2">
<div className="flex flex-wrap items-start gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm font-medium text-red-700 dark:text-red-300">
<AlertTriangle className="h-4 w-4 shrink-0" />
<span>Plugin error</span>
</div>
<p
className="mt-1 text-sm text-red-700/90 dark:text-red-200/90 break-words"
title={plugin.lastError ?? undefined}
>
{errorSummaryByPluginId.get(plugin.id)}
</p>
</div>
<Button
variant="outline"
size="sm"
className="border-red-500/30 bg-background/60 text-red-700 hover:bg-red-500/10 hover:text-red-800 dark:text-red-200 dark:hover:text-red-100"
onClick={() => setErrorDetailsPlugin(plugin)}
>
View full error
</Button>
</div>
</div>
)}
</div>
<div className="flex shrink-0 self-center">
<div className="flex flex-col items-end gap-2">
<div className="flex items-center gap-2">
<Badge
variant={
plugin.status === "ready"
? "default"
: plugin.status === "error"
? "destructive"
: "secondary"
}
className={cn(
"shrink-0",
plugin.status === "ready" ? "bg-green-600 hover:bg-green-700" : ""
)}
>
{plugin.status}
</Badge>
<Button
variant="outline"
size="icon-sm"
className="h-8 w-8"
title={plugin.status === "ready" ? "Disable" : "Enable"}
onClick={() => {
if (plugin.status === "ready") {
disableMutation.mutate(plugin.id);
} else {
enableMutation.mutate(plugin.id);
}
}}
disabled={enableMutation.isPending || disableMutation.isPending}
>
<Power className={cn("h-4 w-4", plugin.status === "ready" ? "text-green-600" : "")} />
</Button>
<Button
variant="outline"
size="icon-sm"
className="h-8 w-8 text-destructive hover:text-destructive"
title="Uninstall"
onClick={() => {
setUninstallPluginId(plugin.id);
setUninstallPluginName(plugin.manifestJson.displayName ?? plugin.packageName);
}}
disabled={uninstallMutation.isPending}
>
<Trash className="h-4 w-4" />
</Button>
</div>
<Button variant="outline" size="sm" className="mt-2 h-8" asChild>
<Link to={`/instance/settings/plugins/${plugin.id}`}>
<Settings className="h-4 w-4" />
Configure
</Link>
</Button>
</div>
</div>
</div>
</li>
))}
</ul>
)}
</section>
<Dialog
open={uninstallPluginId !== null}
onOpenChange={(open) => { if (!open) setUninstallPluginId(null); }}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Uninstall Plugin</DialogTitle>
<DialogDescription>
Are you sure you want to uninstall <strong>{uninstallPluginName}</strong>? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setUninstallPluginId(null)}>Cancel</Button>
<Button
variant="destructive"
disabled={uninstallMutation.isPending}
onClick={() => {
if (uninstallPluginId) {
uninstallMutation.mutate(uninstallPluginId, {
onSettled: () => setUninstallPluginId(null),
});
}
}}
>
{uninstallMutation.isPending ? "Uninstalling..." : "Uninstall"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={errorDetailsPlugin !== null}
onOpenChange={(open) => { if (!open) setErrorDetailsPlugin(null); }}
>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Error Details</DialogTitle>
<DialogDescription>
{errorDetailsPlugin?.manifestJson.displayName ?? errorDetailsPlugin?.packageName ?? "Plugin"} hit an error state.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-md border border-red-500/25 bg-red-500/[0.06] px-4 py-3">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-700 dark:text-red-300" />
<div className="space-y-1 text-sm">
<p className="font-medium text-red-700 dark:text-red-300">
What errored
</p>
<p className="text-red-700/90 dark:text-red-200/90 break-words">
{errorDetailsPlugin ? getPluginErrorSummary(errorDetailsPlugin) : "No error summary available."}
</p>
</div>
</div>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">Full error output</p>
<pre className="max-h-[50vh] overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-5 whitespace-pre-wrap break-words">
{errorDetailsPlugin?.lastError ?? "No stored error message."}
</pre>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setErrorDetailsPlugin(null)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

156
ui/src/pages/PluginPage.tsx Normal file
View File

@@ -0,0 +1,156 @@
import { useEffect, useMemo } from "react";
import { Link, Navigate, useParams } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { pluginsApi } from "@/api/plugins";
import { queryKeys } from "@/lib/queryKeys";
import { PluginSlotMount } from "@/plugins/slots";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
import { NotFoundPage } from "./NotFound";
/**
* Company-context plugin page. Renders a plugin's `page` slot at
* `/:companyPrefix/plugins/:pluginId` when the plugin declares a page slot
* and is enabled for that company.
*
* @see doc/plugins/PLUGIN_SPEC.md §19.2 — Company-Context Routes
* @see doc/plugins/PLUGIN_SPEC.md §24.4 — Company-Context Plugin Page
*/
export function PluginPage() {
const { companyPrefix: routeCompanyPrefix, pluginId, pluginRoutePath } = useParams<{
companyPrefix?: string;
pluginId?: string;
pluginRoutePath?: string;
}>();
const { companies, selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const routeCompany = useMemo(() => {
if (!routeCompanyPrefix) return null;
const requested = routeCompanyPrefix.toUpperCase();
return companies.find((c) => c.issuePrefix.toUpperCase() === requested) ?? null;
}, [companies, routeCompanyPrefix]);
const hasInvalidCompanyPrefix = Boolean(routeCompanyPrefix) && !routeCompany;
const resolvedCompanyId = useMemo(() => {
if (routeCompany) return routeCompany.id;
if (routeCompanyPrefix) return null;
return selectedCompanyId ?? null;
}, [routeCompany, routeCompanyPrefix, selectedCompanyId]);
const companyPrefix = useMemo(
() => (resolvedCompanyId ? companies.find((c) => c.id === resolvedCompanyId)?.issuePrefix ?? null : null),
[companies, resolvedCompanyId],
);
const { data: contributions } = useQuery({
queryKey: queryKeys.plugins.uiContributions,
queryFn: () => pluginsApi.listUiContributions(),
enabled: !!resolvedCompanyId && (!!pluginId || !!pluginRoutePath),
});
const pageSlot = useMemo(() => {
if (!contributions) return null;
if (pluginId) {
const contribution = contributions.find((c) => c.pluginId === pluginId);
if (!contribution) return null;
const slot = contribution.slots.find((s) => s.type === "page");
if (!slot) return null;
return {
...slot,
pluginId: contribution.pluginId,
pluginKey: contribution.pluginKey,
pluginDisplayName: contribution.displayName,
pluginVersion: contribution.version,
};
}
if (!pluginRoutePath) return null;
const matches = contributions.flatMap((contribution) => {
const slot = contribution.slots.find((entry) => entry.type === "page" && entry.routePath === pluginRoutePath);
if (!slot) return [];
return [{
...slot,
pluginId: contribution.pluginId,
pluginKey: contribution.pluginKey,
pluginDisplayName: contribution.displayName,
pluginVersion: contribution.version,
}];
});
if (matches.length !== 1) return null;
return matches[0] ?? null;
}, [pluginId, pluginRoutePath, contributions]);
const context = useMemo(
() => ({
companyId: resolvedCompanyId ?? null,
companyPrefix,
}),
[resolvedCompanyId, companyPrefix],
);
useEffect(() => {
if (pageSlot) {
setBreadcrumbs([
{ label: "Plugins", href: "/instance/settings/plugins" },
{ label: pageSlot.pluginDisplayName },
]);
}
}, [pageSlot, companyPrefix, setBreadcrumbs]);
if (!resolvedCompanyId) {
if (hasInvalidCompanyPrefix) {
return <NotFoundPage scope="invalid_company_prefix" requestedPrefix={routeCompanyPrefix} />;
}
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">Select a company to view this page.</p>
</div>
);
}
if (!contributions) {
return <div className="text-sm text-muted-foreground">Loading</div>;
}
if (!pluginId && pluginRoutePath) {
const duplicateMatches = contributions.filter((contribution) =>
contribution.slots.some((slot) => slot.type === "page" && slot.routePath === pluginRoutePath),
);
if (duplicateMatches.length > 1) {
return (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
Multiple plugins declare the route <code>{pluginRoutePath}</code>. Use the plugin-id route until the conflict is resolved.
</div>
);
}
}
if (!pageSlot) {
if (pluginRoutePath) {
return <NotFoundPage scope="board" />;
}
// No page slot: redirect to plugin settings where plugin info is always shown
const settingsPath = pluginId ? `/instance/settings/plugins/${pluginId}` : "/instance/settings/plugins";
return <Navigate to={settingsPath} replace />;
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" asChild>
<Link to={companyPrefix ? `/${companyPrefix}/dashboard` : "/dashboard"}>
<ArrowLeft className="h-4 w-4 mr-1" />
Back
</Link>
</Button>
</div>
<PluginSlotMount
slot={pageSlot}
context={context}
className="min-h-[200px]"
missingBehavior="placeholder"
/>
</div>
);
}

View File

@@ -0,0 +1,836 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Puzzle, ArrowLeft, ShieldAlert, ActivitySquare, CheckCircle, XCircle, Loader2, Clock, Cpu, Webhook, CalendarClock, AlertTriangle } from "lucide-react";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { Link, Navigate, useParams } from "@/lib/router";
import { PluginSlotMount, usePluginSlots } from "@/plugins/slots";
import { pluginsApi } from "@/api/plugins";
import { queryKeys } from "@/lib/queryKeys";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import { PageTabBar } from "@/components/PageTabBar";
import {
JsonSchemaForm,
validateJsonSchemaForm,
getDefaultValues,
type JsonSchemaNode,
} from "@/components/JsonSchemaForm";
/**
* PluginSettings page component.
*
* Detailed settings and diagnostics page for a single installed plugin.
* Navigated to from {@link PluginManager} via the Settings gear icon.
*
* Displays:
* - Plugin identity: display name, id, version, description, categories.
* - Manifest-declared capabilities (what data and features the plugin can access).
* - Health check results (only for `ready` plugins; polled every 30 seconds).
* - Runtime dashboard: worker status/uptime, recent job runs, webhook deliveries.
* - Auto-generated config form from `instanceConfigSchema` (when no custom settings page).
* - Plugin-contributed settings UI via `<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 [activeTab, setActiveTab] = useState<"configuration" | "status">("configuration");
const { data: plugin, isLoading: pluginLoading } = useQuery({
queryKey: queryKeys.plugins.detail(pluginId!),
queryFn: () => pluginsApi.get(pluginId!),
enabled: !!pluginId,
});
const { data: healthData, isLoading: healthLoading } = useQuery({
queryKey: queryKeys.plugins.health(pluginId!),
queryFn: () => pluginsApi.health(pluginId!),
enabled: !!pluginId && plugin?.status === "ready",
refetchInterval: 30000,
});
const { data: dashboardData } = useQuery({
queryKey: queryKeys.plugins.dashboard(pluginId!),
queryFn: () => pluginsApi.dashboard(pluginId!),
enabled: !!pluginId,
refetchInterval: 30000,
});
const { data: recentLogs } = useQuery({
queryKey: queryKeys.plugins.logs(pluginId!),
queryFn: () => pluginsApi.logs(pluginId!, { limit: 50 }),
enabled: !!pluginId && plugin?.status === "ready",
refetchInterval: 30000,
});
// Fetch existing config for the plugin
const configSchema = plugin?.manifestJson?.instanceConfigSchema as JsonSchemaNode | undefined;
const hasConfigSchema = configSchema && configSchema.properties && Object.keys(configSchema.properties).length > 0;
const { data: configData, isLoading: configLoading } = useQuery({
queryKey: queryKeys.plugins.config(pluginId!),
queryFn: () => pluginsApi.getConfig(pluginId!),
enabled: !!pluginId && !!hasConfigSchema,
});
const { slots } = usePluginSlots({
slotTypes: ["settingsPage"],
companyId: selectedCompanyId,
enabled: !!selectedCompanyId,
});
// Filter slots to only show settings pages for this specific plugin
const pluginSlots = slots.filter((slot) => slot.pluginId === pluginId);
// If the plugin has a custom settingsPage slot, prefer that over auto-generated form
const hasCustomSettingsPage = pluginSlots.length > 0;
useEffect(() => {
setBreadcrumbs([
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
{ label: "Settings", href: "/instance/settings/heartbeats" },
{ label: "Plugins", href: "/instance/settings/plugins" },
{ label: plugin?.manifestJson?.displayName ?? plugin?.packageName ?? "Plugin Details" },
]);
}, [selectedCompany?.name, setBreadcrumbs, companyPrefix, plugin]);
useEffect(() => {
setActiveTab("configuration");
}, [pluginId]);
if (pluginLoading) {
return <div className="p-4 text-sm text-muted-foreground">Loading plugin details...</div>;
}
if (!plugin) {
return <Navigate to="/instance/settings/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 ?? [];
return (
<div className="space-y-6 max-w-5xl">
<div className="flex items-center gap-4">
<Link to="/instance/settings/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-[minmax(0,1.4fr)_minmax(220px,0.8fr)]">
<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>
{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!}
schema={configSchema!}
initialValues={configData?.configJson}
isLoading={configLoading}
pluginStatus={plugin.status}
supportsConfigTest={(plugin as unknown as { supportsConfigTest?: boolean }).supportsConfigTest === true}
/>
) : (
<p className="text-sm text-muted-foreground">
This plugin does not require any settings.
</p>
)}
</section>
</div>
</TabsContent>
<TabsContent value="status" className="space-y-6">
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_320px]">
<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-[10px]">
{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-[10px]">{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-[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>
);
}
// ---------------------------------------------------------------------------
// PluginConfigForm — auto-generated form for instanceConfigSchema
// ---------------------------------------------------------------------------
interface PluginConfigFormProps {
pluginId: string;
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, 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(() => {
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>) =>
pluginsApi.saveConfig(pluginId, configJson),
onSuccess: () => {
setSaveMessage({ type: "success", text: "Configuration saved." });
setTestResult(null);
queryClient.invalidateQueries({ queryKey: queryKeys.plugins.config(pluginId) });
// Clear success message after 3s
setTimeout(() => setSaveMessage(null), 3000);
},
onError: (err: Error) => {
setSaveMessage({ type: "error", text: err.message || "Failed to save configuration." });
},
});
// Test configuration mutation
const testMutation = useMutation({
mutationFn: (configJson: Record<string, unknown>) =>
pluginsApi.testConfig(pluginId, configJson),
onSuccess: (result) => {
if (result.valid) {
setTestResult({ type: "success", text: "Configuration test passed." });
} else {
setTestResult({ type: "error", text: result.message || "Configuration test failed." });
}
},
onError: (err: Error) => {
setTestResult({ type: "error", text: err.message || "Configuration test failed." });
},
});
const handleChange = useCallback((newValues: Record<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}
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}
/>
);
}

View File

@@ -19,10 +19,18 @@ import { PageSkeleton } from "../components/PageSkeleton";
import { PageTabBar } from "../components/PageTabBar";
import { projectRouteRef, cn } from "../lib/utils";
import { Tabs } from "@/components/ui/tabs";
import { PluginLauncherOutlet } from "@/plugins/launchers";
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
/* ── Top-level tab types ── */
type ProjectTab = "overview" | "list" | "configuration";
type ProjectBaseTab = "overview" | "list" | "configuration";
type ProjectPluginTab = `plugin:${string}`;
type ProjectTab = ProjectBaseTab | ProjectPluginTab;
function isProjectPluginTab(value: string | null): value is ProjectPluginTab {
return typeof value === "string" && value.startsWith("plugin:");
}
function resolveProjectTab(pathname: string, projectId: string): ProjectTab | null {
const segments = pathname.split("/").filter(Boolean);
@@ -213,8 +221,12 @@ export function ProjectDetail() {
}, [companies, companyPrefix]);
const lookupCompanyId = routeCompanyId ?? selectedCompanyId ?? undefined;
const canFetchProject = routeProjectRef.length > 0 && (isUuidLike(routeProjectRef) || Boolean(lookupCompanyId));
const activeTab = routeProjectRef ? resolveProjectTab(location.pathname, routeProjectRef) : null;
const activeRouteTab = routeProjectRef ? resolveProjectTab(location.pathname, routeProjectRef) : null;
const pluginTabFromSearch = useMemo(() => {
const tab = new URLSearchParams(location.search).get("tab");
return isProjectPluginTab(tab) ? tab : null;
}, [location.search]);
const activeTab = activeRouteTab ?? pluginTabFromSearch;
const { data: project, isLoading, error } = useQuery({
queryKey: [...queryKeys.projects.detail(routeProjectRef), lookupCompanyId ?? null],
@@ -224,6 +236,24 @@ export function ProjectDetail() {
const canonicalProjectRef = project ? projectRouteRef(project) : routeProjectRef;
const projectLookupRef = project?.id ?? routeProjectRef;
const resolvedCompanyId = project?.companyId ?? selectedCompanyId;
const {
slots: pluginDetailSlots,
isLoading: pluginDetailSlotsLoading,
} = usePluginSlots({
slotTypes: ["detailTab"],
entityType: "project",
companyId: resolvedCompanyId,
enabled: !!resolvedCompanyId,
});
const pluginTabItems = useMemo(
() => pluginDetailSlots.map((slot) => ({
value: `plugin:${slot.pluginKey}:${slot.id}` as ProjectPluginTab,
label: slot.displayName,
slot,
})),
[pluginDetailSlots],
);
const activePluginTab = pluginTabItems.find((item) => item.value === activeTab) ?? null;
useEffect(() => {
if (!project?.companyId || project.companyId === selectedCompanyId) return;
@@ -261,6 +291,10 @@ export function ProjectDetail() {
useEffect(() => {
if (!project) return;
if (routeProjectRef === canonicalProjectRef) return;
if (isProjectPluginTab(activeTab)) {
navigate(`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(activeTab)}`, { replace: true });
return;
}
if (activeTab === "overview") {
navigate(`/projects/${canonicalProjectRef}/overview`, { replace: true });
return;
@@ -328,6 +362,10 @@ export function ProjectDetail() {
}
}, [invalidateProject, lookupCompanyId, projectLookupRef, resolvedCompanyId, scheduleFieldReset, setFieldState]);
if (pluginTabFromSearch && !pluginDetailSlotsLoading && !activePluginTab) {
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
}
// Redirect bare /projects/:id to /projects/:id/issues
if (routeProjectRef && activeTab === null) {
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
@@ -338,6 +376,10 @@ export function ProjectDetail() {
if (!project) return null;
const handleTabChange = (tab: ProjectTab) => {
if (isProjectPluginTab(tab)) {
navigate(`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(tab)}`);
return;
}
if (tab === "overview") {
navigate(`/projects/${canonicalProjectRef}/overview`);
} else if (tab === "configuration") {
@@ -364,12 +406,47 @@ export function ProjectDetail() {
/>
</div>
<PluginSlotOutlet
slotTypes={["toolbarButton", "contextMenuItem"]}
entityType="project"
context={{
companyId: resolvedCompanyId ?? null,
companyPrefix: companyPrefix ?? null,
projectId: project.id,
projectRef: canonicalProjectRef,
entityId: project.id,
entityType: "project",
}}
className="flex flex-wrap gap-2"
itemClassName="inline-flex"
missingBehavior="placeholder"
/>
<PluginLauncherOutlet
placementZones={["toolbarButton"]}
entityType="project"
context={{
companyId: resolvedCompanyId ?? null,
companyPrefix: companyPrefix ?? null,
projectId: project.id,
projectRef: canonicalProjectRef,
entityId: project.id,
entityType: "project",
}}
className="flex flex-wrap gap-2"
itemClassName="inline-flex"
/>
<Tabs value={activeTab ?? "list"} onValueChange={(value) => handleTabChange(value as ProjectTab)}>
<PageTabBar
items={[
{ value: "overview", label: "Overview" },
{ value: "list", label: "List" },
{ value: "configuration", label: "Configuration" },
...pluginTabItems.map((item) => ({
value: item.value,
label: item.label,
})),
]}
align="start"
value={activeTab ?? "list"}
@@ -402,6 +479,21 @@ export function ProjectDetail() {
/>
</div>
)}
{activePluginTab && (
<PluginSlotMount
slot={activePluginTab.slot}
context={{
companyId: resolvedCompanyId,
companyPrefix: companyPrefix ?? null,
projectId: project.id,
projectRef: canonicalProjectRef,
entityId: project.id,
entityType: "project",
}}
missingBehavior="placeholder"
/>
)}
</div>
);
}