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:
@@ -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 && (
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
509
ui/src/pages/PluginManager.tsx
Normal file
509
ui/src/pages/PluginManager.tsx
Normal 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
156
ui/src/pages/PluginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
836
ui/src/pages/PluginSettings.tsx
Normal file
836
ui/src/pages/PluginSettings.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user