mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 06:42:06 +01:00
## Thinking Path > - Paperclip is the open source app that people use to manage AI agents for work. > - Operators use the settings area to control a company and its Paperclip instance. > - The current navigation separates related settings and uses duplicate instance pages. > - Company exports also do independent reads in sequence and do extra work for previews. > - Hardened workspace commands can differ from their saved command after loopback binding. > - This pull request makes these related operator workflows consistent and faster. > - The benefit is one clear settings area, faster exports, and stable runtime command matching. ## Linked Issues or Issue Description Refs #338 Related: #9834 **What existing behavior does this improve?** This improves the company settings UI, company export preparation, and workspace runtime command matching. **Current behavior** Company and instance settings use separate navigation and duplicate pages. Export preparation reads many independent records in sequence. Preview generation can also build an unused organization image. A command with a forced loopback bind can fail to match its saved runtime command. **Proposed behavior** Use one settings navigation and put general instance controls on the company General page. Load independent export data with bounded concurrency, skip unused preview image work, and load the export page only when it is needed. Treat the loopback-bound form of a command as the same runtime command. **Reason and benefit** Operators get one clear settings area. Large company exports need fewer serialized reads. Export previews and initial UI loads do less work. Hardened runtime services remain linked to their saved command definitions. **Breaking changes** The obsolete instance General URL redirects to the unified settings page. Access and Heartbeats remain available, and legacy bookmarks keep their destinations. No API response shape or database schema changes. ## What Changed - Unified company and instance settings navigation and removed duplicate instance settings pages. - Embedded general instance controls in the company General page and kept access-sensitive navigation behavior. - Preserved instance Access and Heartbeats controls in the unified navigation and normalized old bookmarks to those destinations. - Improved environment and access-state handling when workspace seed requests overlap. - Added bounded export reads, a lighter preview path, deferred export preparation, and lazy export-page loading. - Matched loopback-bound runtime commands to their saved command definitions. - Added focused shared, server, and UI regression tests. ## Verification - `pnpm exec vitest run <18 changed test files>`: 18 files and 256 tests passed. - `pnpm check:token-gates`: passed all four token gates. - `pnpm -r typecheck`: passed for all workspace projects. - `pnpm build`: passed for all workspace projects. - `pnpm test:run`: tests ran without a reported failure, but the runner did not close after the server handoff tests. The process closed with status 0 after an interrupt. - Focused latest-head route tests: 2 files and 4 tests passed. - GitHub latest-head checks: all completed without failure. - Greptile: 5/5 with no unresolved review threads. ## Risks - Medium risk: settings routes and navigation changed across several operator roles. - Medium risk: bounded export concurrency increases simultaneous database reads. The limits stay below the normal pool size. - Low risk: runtime command matching accepts only the known Tailscale HTTPS loopback transformation. - No migrations are included. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex with a GPT-5-family coding model. The runtime does not expose the exact deployed model ID or context-window size. Reasoning, tool use, and local code execution were enabled. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [ ] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details Exception: This task requires the existing execution branch. The harness does not permit a branch rename. - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
570 lines
24 KiB
TypeScript
570 lines
24 KiB
TypeScript
/**
|
|
* @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 { useToastActions } 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.";
|
|
}
|
|
|
|
function isExperimentalPluginIdentity(input: {
|
|
packageName?: string | null;
|
|
packagePath?: string | null;
|
|
manifestJson?: PluginRecord["manifestJson"] | null;
|
|
bundledExperimental?: boolean;
|
|
}) {
|
|
if (input.bundledExperimental) return true;
|
|
|
|
const packageName = input.packageName ?? "";
|
|
const packagePath = input.packagePath ?? "";
|
|
if (packageName.includes("sandbox") || packagePath.includes("sandbox")) return true;
|
|
return input.manifestJson?.environmentDrivers?.some((driver) => driver.kind === "sandbox_provider") === true;
|
|
}
|
|
|
|
function ExperimentalBadge() {
|
|
return (
|
|
<Badge
|
|
variant="outline"
|
|
className="border-amber-500/30 bg-amber-500/10 text-amber-700 hover:bg-amber-500/10 dark:text-amber-200"
|
|
>
|
|
Experimental
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 } = useToastActions();
|
|
|
|
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: "/company/settings" },
|
|
{ label: "Plugins" },
|
|
]);
|
|
}, [selectedCompany?.name, setBreadcrumbs]);
|
|
|
|
const { data: plugins, isLoading, error } = useQuery({
|
|
queryKey: queryKeys.plugins.all,
|
|
queryFn: () => pluginsApi.list(),
|
|
});
|
|
|
|
const bundledQuery = useQuery({
|
|
queryKey: queryKeys.plugins.examples,
|
|
queryFn: () => pluginsApi.listBundled(),
|
|
});
|
|
|
|
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 bundledPlugins = bundledQuery.data ?? [];
|
|
const installedByPackageName = new Map(installedPlugins.map((plugin) => [plugin.packageName, plugin]));
|
|
const bundledByPackageName = new Map(bundledPlugins.map((plugin) => [plugin.packageName, plugin]));
|
|
// Scope the in-section banner to bundled (local-path) installs so an npm-dialog
|
|
// install failure does not surface its error in the bundled-plugins section.
|
|
const installErrorMessage = installMutation.variables?.isLocalPath
|
|
? installMutation.error?.message ?? null
|
|
: null;
|
|
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="max-w-6xl space-y-6">
|
|
<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">Bundled</Badge>
|
|
</div>
|
|
|
|
{installErrorMessage && (
|
|
<div className="rounded-md border border-destructive/25 bg-destructive/[0.06] px-4 py-3 text-sm text-destructive whitespace-pre-wrap break-words">
|
|
{installErrorMessage}
|
|
</div>
|
|
)}
|
|
|
|
{bundledQuery.isLoading ? (
|
|
<div className="text-sm text-muted-foreground">Loading bundled plugins...</div>
|
|
) : bundledQuery.error ? (
|
|
<div className="text-sm text-destructive">Failed to load bundled plugins.</div>
|
|
) : bundledPlugins.length === 0 ? (
|
|
<div className="rounded-md border border-dashed px-4 py-3 text-sm text-muted-foreground">
|
|
No bundled plugins were found in this checkout.
|
|
</div>
|
|
) : (
|
|
<Card className="block py-0">
|
|
<ul className="divide-y">
|
|
{bundledPlugins.map((bundledPlugin) => {
|
|
const installedPlugin = installedByPackageName.get(bundledPlugin.packageName);
|
|
const installPending =
|
|
installMutation.isPending &&
|
|
installMutation.variables?.isLocalPath &&
|
|
installMutation.variables.packageName === bundledPlugin.localPath;
|
|
|
|
return (
|
|
<li key={bundledPlugin.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">{bundledPlugin.displayName}</span>
|
|
<Badge variant="outline">
|
|
{bundledPlugin.tag === "first-party" ? "First-party" : "Example"}
|
|
</Badge>
|
|
{isExperimentalPluginIdentity({
|
|
packageName: bundledPlugin.packageName,
|
|
packagePath: bundledPlugin.localPath,
|
|
bundledExperimental: bundledPlugin.experimental,
|
|
}) && <ExperimentalBadge />}
|
|
{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">{bundledPlugin.description}</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">{bundledPlugin.packageName}</p>
|
|
{installPending && !bundledPlugin.hasBuiltEntrypoints && (
|
|
<p className="mt-2 text-xs text-muted-foreground">Building plugin...</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={`/company/settings/instance/plugins/${installedPlugin.id}`}>
|
|
{installedPlugin.status === "ready" ? "Open Settings" : "Review"}
|
|
</Link>
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<Button
|
|
size="sm"
|
|
disabled={installPending || installMutation.isPending}
|
|
onClick={() =>
|
|
installMutation.mutate({
|
|
packageName: bundledPlugin.localPath,
|
|
isLocalPath: true,
|
|
})
|
|
}
|
|
>
|
|
{installPending ? "Installing..." : "Install"}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</Card>
|
|
)}
|
|
</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>
|
|
) : (
|
|
<Card className="block py-0">
|
|
<ul className="divide-y">
|
|
{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={`/company/settings/instance/plugins/${plugin.id}`}
|
|
className="font-medium hover:underline truncate block"
|
|
title={plugin.manifestJson.displayName ?? plugin.packageName}
|
|
>
|
|
{plugin.manifestJson.displayName ?? plugin.packageName}
|
|
</Link>
|
|
{bundledByPackageName.has(plugin.packageName) && (
|
|
<Badge variant="outline">
|
|
{bundledByPackageName.get(plugin.packageName)?.tag === "first-party"
|
|
? "First-party"
|
|
: "Example"}
|
|
</Badge>
|
|
)}
|
|
{isExperimentalPluginIdentity({
|
|
packageName: plugin.packageName,
|
|
packagePath: plugin.packagePath,
|
|
manifestJson: plugin.manifestJson,
|
|
bundledExperimental: bundledByPackageName.get(plugin.packageName)?.experimental,
|
|
}) && <ExperimentalBadge />}
|
|
</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={`/company/settings/instance/plugins/${plugin.id}`}>
|
|
<Settings className="h-4 w-4" />
|
|
Configure
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</Card>
|
|
)}
|
|
</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-(--sz-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>
|
|
);
|
|
}
|