@checkstack/pluginmanager-frontend 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +109 -0
- package/package.json +30 -0
- package/src/PluginManagerMenuItem.tsx +20 -0
- package/src/components/SourceForm.tsx +255 -0
- package/src/components/TypedConfirmModal.tsx +127 -0
- package/src/index.tsx +44 -0
- package/src/pages/InstallPluginPage.tsx +233 -0
- package/src/pages/InstalledPluginsPage.tsx +326 -0
- package/src/pages/PluginEventsPage.tsx +107 -0
- package/tsconfig.json +20 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# @checkstack/pluginmanager-frontend
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 50e5f5f: Runtime plugin system: install + uninstall plugins from npm, GitHub releases
|
|
8
|
+
(including private GitHub Enterprise instances), or tarball uploads at
|
|
9
|
+
runtime, with multi-package bundles, dependency-derived compatibility checks,
|
|
10
|
+
multi-instance coordination via a Postgres artifact store, and
|
|
11
|
+
single-coordinator destructive cleanup.
|
|
12
|
+
|
|
13
|
+
Highlights:
|
|
14
|
+
|
|
15
|
+
- New `PluginSource` discriminated union and `PluginInstaller` /
|
|
16
|
+
`PluginInstallerRegistry` interfaces in `@checkstack/backend-api`. The
|
|
17
|
+
GitHub variant accepts an optional `apiBaseUrl` so deployments backed by
|
|
18
|
+
GitHub Enterprise can install from `https://ghe.example.com/api/v3`
|
|
19
|
+
instead of `api.github.com`.
|
|
20
|
+
- New `installPackageMetadataSchema` (Zod) in `@checkstack/common` validates
|
|
21
|
+
every plugin's `package.json` at install time. Required fields: `name`,
|
|
22
|
+
`version`, `description`, `author`, `license`, `checkstack.type`,
|
|
23
|
+
`checkstack.pluginId`. Optional: `checkstack.bundle`,
|
|
24
|
+
`checkstack.usageInstructions`, `checkstack.allowInstallScripts`.
|
|
25
|
+
- New `pluginManagerContract` in `@checkstack/pluginmanager-common` with
|
|
26
|
+
`list`, `previewInstall`, `install`, `previewUninstall`, `uninstall`, and
|
|
27
|
+
`events` procedures.
|
|
28
|
+
- New `@checkstack/pluginmanager-frontend` admin UI: installed-plugins list
|
|
29
|
+
with per-row uninstall (typed-confirmation modal, schema/configs/cascade
|
|
30
|
+
toggles), install page with NPM / Tarball Upload / GitHub Release tabs
|
|
31
|
+
(Catalog tab disabled — coming soon), and an events page surfacing the
|
|
32
|
+
install/uninstall audit log.
|
|
33
|
+
- New `bunx @checkstack/scripts plugin-pack` CLI for plugin authors —
|
|
34
|
+
per-package mode produces an npm-shaped tarball; `--bundle` mode produces
|
|
35
|
+
an outer tarball containing every sibling declared in
|
|
36
|
+
`package.json#checkstack.bundle`. Published to npm so external authors
|
|
37
|
+
can `bunx` it directly without a workspace checkout.
|
|
38
|
+
- Compatibility derived from `package.json#dependencies` ranges
|
|
39
|
+
(`semver.satisfies` against the platform's loaded `@checkstack/*`
|
|
40
|
+
versions) — no separate `compatibility` field.
|
|
41
|
+
- Multi-instance: originator persists artifacts + `plugins` rows + broadcasts
|
|
42
|
+
install/uninstall; receiving instances do in-process register/unregister
|
|
43
|
+
only. Destructive ops (drop schema, delete plugin_configs, delete
|
|
44
|
+
artifacts, delete `plugins` rows) run exactly once on the originator.
|
|
45
|
+
- Fresh-instance bootstrap: `loadPlugins()` hydrates any
|
|
46
|
+
`is_uninstallable=true` plugin missing from `node_modules` from the
|
|
47
|
+
artifact store before normal Phase 1 register.
|
|
48
|
+
- New schema: `plugin_artifacts` (tarball storage), `plugin_install_events`
|
|
49
|
+
(audit/error log). `plugins` extended with `version`, `metadata`,
|
|
50
|
+
`source`, `bundle_id`, `is_primary`. Local plugin sync now writes
|
|
51
|
+
`version` from each plugin's `package.json` so the admin UI shows real
|
|
52
|
+
versions instead of `—`.
|
|
53
|
+
- Tarball-upload endpoint (`POST /api/pluginmanager/upload-tarball`) for
|
|
54
|
+
the install UI; access-gated by `pluginmanager.plugin.manage`.
|
|
55
|
+
- Plugin Manager menu link added to the user menu (main grid, alongside
|
|
56
|
+
Profile / Notification Settings / etc.).
|
|
57
|
+
|
|
58
|
+
Cross-cutting changes:
|
|
59
|
+
|
|
60
|
+
- Backend request/response logging now flows through `rootLogger` (winston)
|
|
61
|
+
instead of `hono/logger`. 5xx responses include the response body inline
|
|
62
|
+
so swallowed early-return errors are visible in the log.
|
|
63
|
+
- The `/api/:pluginId/*` dispatcher now logs which core service is missing
|
|
64
|
+
or which `pluginId` had no metadata when it 500s.
|
|
65
|
+
- New `registerCorePluginMetadata` on `PluginManager` for core routers
|
|
66
|
+
(like the plugin manager itself) that need their metadata visible to the
|
|
67
|
+
RPC dispatcher without going through the full plugin lifecycle.
|
|
68
|
+
- ESLint: `unicorn/no-null` is now disabled globally. Drizzle distinguishes
|
|
69
|
+
between `null` (writes a real SQL NULL) and `undefined` (skip the column
|
|
70
|
+
on insert), so treating them as interchangeable produced latent bugs at
|
|
71
|
+
the persistence boundary. The bulk of the patch-bumped packages above
|
|
72
|
+
reflect lint-fix touches that landed when this rule was relaxed.
|
|
73
|
+
- Workspace-wide license normalization to `Elastic-2.0` (matches
|
|
74
|
+
`LICENSE.md`). Every `package.json` in the workspace now declares the
|
|
75
|
+
same SPDX identifier; the patch bumps capture this.
|
|
76
|
+
|
|
77
|
+
Plugin packages (every `plugins/*`): added a `pack` npm script
|
|
78
|
+
(`bunx @checkstack/scripts plugin-pack`), mirrored each plugin's
|
|
79
|
+
`pluginId` from `plugin-metadata.ts` into `package.json#checkstack.pluginId`
|
|
80
|
+
so install-time validation passes, stubbed any missing required metadata
|
|
81
|
+
fields (`description`, `author`, `license`), and added
|
|
82
|
+
`checkstack.bundle` to multi-package plugin primaries (telegram, rcon, ssh,
|
|
83
|
+
jira, queue-bullmq, queue-memory, cache-memory).
|
|
84
|
+
|
|
85
|
+
Breaking changes:
|
|
86
|
+
|
|
87
|
+
- The legacy single-method `PluginInstaller` interface (`install(packageName)`)
|
|
88
|
+
is removed. Callers must use `coreServices.pluginInstallerRegistry`.
|
|
89
|
+
- The old `pluginAdminContract` and `createPluginAdminRouter` are removed.
|
|
90
|
+
Replaced by `pluginManagerContract` in `@checkstack/pluginmanager-common`
|
|
91
|
+
and `createPluginManagerRouter` in `core/backend`.
|
|
92
|
+
- `@checkstack/test-utils-backend` no longer exports
|
|
93
|
+
`createMockPluginInstaller` / `MockPluginInstaller` (the legacy interface
|
|
94
|
+
it shimmed is gone).
|
|
95
|
+
|
|
96
|
+
Note: bumps are limited to `minor` (for packages with new public API
|
|
97
|
+
surface) and `patch` (for downstream consumers, license normalization,
|
|
98
|
+
and lint fixes). No `major` bumps despite the `PluginInstaller` removal —
|
|
99
|
+
the legacy interface had no third-party consumers in the wild before this
|
|
100
|
+
runtime plugin system landed, and the contract surface is the same shape
|
|
101
|
+
modulo the rename.
|
|
102
|
+
|
|
103
|
+
### Patch Changes
|
|
104
|
+
|
|
105
|
+
- Updated dependencies [50e5f5f]
|
|
106
|
+
- @checkstack/common@0.8.0
|
|
107
|
+
- @checkstack/pluginmanager-common@0.2.0
|
|
108
|
+
- @checkstack/ui@1.7.1
|
|
109
|
+
- @checkstack/frontend-api@0.4.2
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@checkstack/pluginmanager-frontend",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"license": "Elastic-2.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.tsx",
|
|
7
|
+
"checkstack": {
|
|
8
|
+
"type": "frontend"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"typecheck": "tsgo -b",
|
|
12
|
+
"lint": "bun run lint:code",
|
|
13
|
+
"lint:code": "eslint . --max-warnings 0"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@checkstack/pluginmanager-common": "0.1.0",
|
|
17
|
+
"@checkstack/frontend-api": "0.4.1",
|
|
18
|
+
"@checkstack/common": "0.7.0",
|
|
19
|
+
"@checkstack/ui": "1.7.0",
|
|
20
|
+
"react": "^18.2.0",
|
|
21
|
+
"react-router-dom": "^6.22.0",
|
|
22
|
+
"lucide-react": "^0.344.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"typescript": "^5.0.0",
|
|
26
|
+
"@types/react": "^18.2.0",
|
|
27
|
+
"@checkstack/tsconfig": "0.0.6",
|
|
28
|
+
"@checkstack/scripts": "0.1.2"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useNavigate } from "react-router-dom";
|
|
2
|
+
import { Puzzle } from "lucide-react";
|
|
3
|
+
import { DropdownMenuItem } from "@checkstack/ui";
|
|
4
|
+
import { resolveRoute } from "@checkstack/common";
|
|
5
|
+
import { pluginManagerRoutes } from "@checkstack/pluginmanager-common";
|
|
6
|
+
|
|
7
|
+
export function PluginManagerMenuItem() {
|
|
8
|
+
const navigate = useNavigate();
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<DropdownMenuItem
|
|
12
|
+
onClick={() =>
|
|
13
|
+
navigate(resolveRoute(pluginManagerRoutes.routes.installed))
|
|
14
|
+
}
|
|
15
|
+
icon={<Puzzle className="h-4 w-4" />}
|
|
16
|
+
>
|
|
17
|
+
Plugin Manager
|
|
18
|
+
</DropdownMenuItem>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { Tabs, Input, Label, Button, Badge } from "@checkstack/ui";
|
|
3
|
+
import { extractErrorMessage } from "@checkstack/common";
|
|
4
|
+
import type { PluginSource } from "@checkstack/pluginmanager-common";
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
onSubmit: (source: PluginSource) => void;
|
|
8
|
+
isLoading?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Source picker — NPM / Tarball Upload / GitHub / Catalog (coming soon).
|
|
13
|
+
*
|
|
14
|
+
* Catalog tab is intentionally disabled until the catalog backend ships.
|
|
15
|
+
* Tarball upload posts to the multipart endpoint at
|
|
16
|
+
* `/api/pluginmanager/upload-tarball` which stores the bytes in
|
|
17
|
+
* `plugin_artifacts` and returns an `artifactId` we wrap in a
|
|
18
|
+
* `tarball` PluginSource.
|
|
19
|
+
*/
|
|
20
|
+
export const SourceForm: React.FC<Props> = ({ onSubmit, isLoading }) => {
|
|
21
|
+
const [tab, setTab] = useState<"npm" | "tarball" | "github" | "catalog">(
|
|
22
|
+
"npm",
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const [npmName, setNpmName] = useState("");
|
|
26
|
+
const [npmVersion, setNpmVersion] = useState("");
|
|
27
|
+
const [npmRegistry, setNpmRegistry] = useState("");
|
|
28
|
+
|
|
29
|
+
const [file, setFile] = useState<File | undefined>();
|
|
30
|
+
const [tarballUploading, setTarballUploading] = useState(false);
|
|
31
|
+
const [tarballError, setTarballError] = useState<string | undefined>();
|
|
32
|
+
|
|
33
|
+
const [ghOwner, setGhOwner] = useState("");
|
|
34
|
+
const [ghRepo, setGhRepo] = useState("");
|
|
35
|
+
const [ghTag, setGhTag] = useState("");
|
|
36
|
+
const [ghAsset, setGhAsset] = useState("");
|
|
37
|
+
const [ghApiBaseUrl, setGhApiBaseUrl] = useState("");
|
|
38
|
+
const [ghTokenEnvVar, setGhTokenEnvVar] = useState("");
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className="space-y-4">
|
|
42
|
+
<Tabs
|
|
43
|
+
items={[
|
|
44
|
+
{ id: "npm", label: "NPM" },
|
|
45
|
+
{ id: "tarball", label: "Tarball Upload" },
|
|
46
|
+
{ id: "github", label: "GitHub Release" },
|
|
47
|
+
{ id: "catalog", label: "Catalog (Coming Soon)" },
|
|
48
|
+
]}
|
|
49
|
+
activeTab={tab}
|
|
50
|
+
onTabChange={(id) => {
|
|
51
|
+
if (id !== "catalog") setTab(id as typeof tab);
|
|
52
|
+
}}
|
|
53
|
+
/>
|
|
54
|
+
|
|
55
|
+
{tab === "npm" ? (
|
|
56
|
+
<div className="space-y-3">
|
|
57
|
+
<div className="space-y-1">
|
|
58
|
+
<Label htmlFor="npm-name">Package name</Label>
|
|
59
|
+
<Input
|
|
60
|
+
id="npm-name"
|
|
61
|
+
placeholder="@scope/my-plugin-backend"
|
|
62
|
+
value={npmName}
|
|
63
|
+
onChange={(e) => setNpmName(e.target.value)}
|
|
64
|
+
/>
|
|
65
|
+
</div>
|
|
66
|
+
<div className="space-y-1">
|
|
67
|
+
<Label htmlFor="npm-version">Version (optional)</Label>
|
|
68
|
+
<Input
|
|
69
|
+
id="npm-version"
|
|
70
|
+
placeholder="latest"
|
|
71
|
+
value={npmVersion}
|
|
72
|
+
onChange={(e) => setNpmVersion(e.target.value)}
|
|
73
|
+
/>
|
|
74
|
+
</div>
|
|
75
|
+
<div className="space-y-1">
|
|
76
|
+
<Label htmlFor="npm-registry">Registry URL (optional)</Label>
|
|
77
|
+
<Input
|
|
78
|
+
id="npm-registry"
|
|
79
|
+
placeholder="https://registry.npmjs.org"
|
|
80
|
+
value={npmRegistry}
|
|
81
|
+
onChange={(e) => setNpmRegistry(e.target.value)}
|
|
82
|
+
/>
|
|
83
|
+
</div>
|
|
84
|
+
<Button
|
|
85
|
+
disabled={!npmName || isLoading}
|
|
86
|
+
onClick={() =>
|
|
87
|
+
onSubmit({
|
|
88
|
+
type: "npm",
|
|
89
|
+
packageName: npmName,
|
|
90
|
+
version: npmVersion || undefined,
|
|
91
|
+
registry: npmRegistry || undefined,
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
>
|
|
95
|
+
Preview install
|
|
96
|
+
</Button>
|
|
97
|
+
</div>
|
|
98
|
+
) : undefined}
|
|
99
|
+
|
|
100
|
+
{tab === "tarball" ? (
|
|
101
|
+
<div className="space-y-3">
|
|
102
|
+
<div className="space-y-1">
|
|
103
|
+
<Label htmlFor="tarball-file">Tarball (.tgz)</Label>
|
|
104
|
+
<Input
|
|
105
|
+
id="tarball-file"
|
|
106
|
+
type="file"
|
|
107
|
+
accept=".tgz,.tar.gz"
|
|
108
|
+
onChange={(e) => setFile(e.target.files?.[0])}
|
|
109
|
+
/>
|
|
110
|
+
<p className="text-xs text-muted-foreground">
|
|
111
|
+
Pack with <code>bunx @checkstack/scripts plugin-pack</code>.
|
|
112
|
+
</p>
|
|
113
|
+
</div>
|
|
114
|
+
{tarballError ? (
|
|
115
|
+
<p className="text-sm text-destructive">{tarballError}</p>
|
|
116
|
+
) : undefined}
|
|
117
|
+
<Button
|
|
118
|
+
disabled={!file || tarballUploading || isLoading}
|
|
119
|
+
onClick={async () => {
|
|
120
|
+
if (!file) return;
|
|
121
|
+
setTarballUploading(true);
|
|
122
|
+
setTarballError(undefined);
|
|
123
|
+
try {
|
|
124
|
+
const fd = new FormData();
|
|
125
|
+
fd.append("file", file);
|
|
126
|
+
const resp = await fetch(
|
|
127
|
+
"/api/pluginmanager/upload-tarball",
|
|
128
|
+
{ method: "POST", body: fd },
|
|
129
|
+
);
|
|
130
|
+
if (!resp.ok) throw new Error(await resp.text());
|
|
131
|
+
const { artifactId } = (await resp.json()) as {
|
|
132
|
+
artifactId: string;
|
|
133
|
+
};
|
|
134
|
+
onSubmit({
|
|
135
|
+
type: "tarball",
|
|
136
|
+
artifactId,
|
|
137
|
+
filename: file.name,
|
|
138
|
+
});
|
|
139
|
+
} catch (error) {
|
|
140
|
+
setTarballError(extractErrorMessage(error));
|
|
141
|
+
} finally {
|
|
142
|
+
setTarballUploading(false);
|
|
143
|
+
}
|
|
144
|
+
}}
|
|
145
|
+
>
|
|
146
|
+
{tarballUploading ? "Uploading..." : "Upload & preview"}
|
|
147
|
+
</Button>
|
|
148
|
+
</div>
|
|
149
|
+
) : undefined}
|
|
150
|
+
|
|
151
|
+
{tab === "github" ? (
|
|
152
|
+
<div className="space-y-3">
|
|
153
|
+
<div className="grid grid-cols-2 gap-3">
|
|
154
|
+
<div className="space-y-1">
|
|
155
|
+
<Label htmlFor="gh-owner">Owner</Label>
|
|
156
|
+
<Input
|
|
157
|
+
id="gh-owner"
|
|
158
|
+
placeholder="my-org"
|
|
159
|
+
value={ghOwner}
|
|
160
|
+
onChange={(e) => setGhOwner(e.target.value)}
|
|
161
|
+
/>
|
|
162
|
+
</div>
|
|
163
|
+
<div className="space-y-1">
|
|
164
|
+
<Label htmlFor="gh-repo">Repo</Label>
|
|
165
|
+
<Input
|
|
166
|
+
id="gh-repo"
|
|
167
|
+
placeholder="my-plugin"
|
|
168
|
+
value={ghRepo}
|
|
169
|
+
onChange={(e) => setGhRepo(e.target.value)}
|
|
170
|
+
/>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
<div className="space-y-1">
|
|
174
|
+
<Label htmlFor="gh-tag">Release tag</Label>
|
|
175
|
+
<Input
|
|
176
|
+
id="gh-tag"
|
|
177
|
+
placeholder="v1.2.3"
|
|
178
|
+
value={ghTag}
|
|
179
|
+
onChange={(e) => setGhTag(e.target.value)}
|
|
180
|
+
/>
|
|
181
|
+
</div>
|
|
182
|
+
<div className="space-y-1">
|
|
183
|
+
<Label htmlFor="gh-asset">Asset name (optional)</Label>
|
|
184
|
+
<Input
|
|
185
|
+
id="gh-asset"
|
|
186
|
+
placeholder="my-plugin-1.2.3.tgz"
|
|
187
|
+
value={ghAsset}
|
|
188
|
+
onChange={(e) => setGhAsset(e.target.value)}
|
|
189
|
+
/>
|
|
190
|
+
</div>
|
|
191
|
+
<details className="rounded border border-border p-3">
|
|
192
|
+
<summary className="cursor-pointer text-sm font-medium">
|
|
193
|
+
GitHub Enterprise (advanced)
|
|
194
|
+
</summary>
|
|
195
|
+
<div className="mt-3 space-y-3">
|
|
196
|
+
<div className="space-y-1">
|
|
197
|
+
<Label htmlFor="gh-api-base">API base URL</Label>
|
|
198
|
+
<Input
|
|
199
|
+
id="gh-api-base"
|
|
200
|
+
placeholder="https://github.example.com/api/v3"
|
|
201
|
+
value={ghApiBaseUrl}
|
|
202
|
+
onChange={(e) => setGhApiBaseUrl(e.target.value)}
|
|
203
|
+
/>
|
|
204
|
+
<p className="text-xs text-muted-foreground">
|
|
205
|
+
Leave blank for public github.com. For GitHub Enterprise,
|
|
206
|
+
use your instance's API root.
|
|
207
|
+
</p>
|
|
208
|
+
</div>
|
|
209
|
+
<div className="space-y-1">
|
|
210
|
+
<Label htmlFor="gh-token-env">Token env var name</Label>
|
|
211
|
+
<Input
|
|
212
|
+
id="gh-token-env"
|
|
213
|
+
placeholder="GITHUB_TOKEN"
|
|
214
|
+
value={ghTokenEnvVar}
|
|
215
|
+
onChange={(e) => setGhTokenEnvVar(e.target.value)}
|
|
216
|
+
/>
|
|
217
|
+
<p className="text-xs text-muted-foreground">
|
|
218
|
+
The platform reads the PAT from this environment variable.
|
|
219
|
+
Defaults to <code>GITHUB_TOKEN</code>.
|
|
220
|
+
</p>
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
</details>
|
|
224
|
+
<Button
|
|
225
|
+
disabled={!ghOwner || !ghRepo || !ghTag || isLoading}
|
|
226
|
+
onClick={() =>
|
|
227
|
+
onSubmit({
|
|
228
|
+
type: "github",
|
|
229
|
+
owner: ghOwner,
|
|
230
|
+
repo: ghRepo,
|
|
231
|
+
tag: ghTag,
|
|
232
|
+
assetName: ghAsset || undefined,
|
|
233
|
+
apiBaseUrl: ghApiBaseUrl || undefined,
|
|
234
|
+
tokenEnvVar: ghTokenEnvVar || undefined,
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
>
|
|
238
|
+
Preview install
|
|
239
|
+
</Button>
|
|
240
|
+
</div>
|
|
241
|
+
) : undefined}
|
|
242
|
+
|
|
243
|
+
{tab === "catalog" ? (
|
|
244
|
+
<div>
|
|
245
|
+
<p className="text-sm text-muted-foreground">
|
|
246
|
+
<Badge variant="secondary" className="mr-2">
|
|
247
|
+
Coming Soon
|
|
248
|
+
</Badge>
|
|
249
|
+
The Checkstack plugin catalog is coming soon.
|
|
250
|
+
</p>
|
|
251
|
+
</div>
|
|
252
|
+
) : undefined}
|
|
253
|
+
</div>
|
|
254
|
+
);
|
|
255
|
+
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { Button, Input, Label } from "@checkstack/ui";
|
|
3
|
+
import { AlertTriangle, X } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
isOpen: boolean;
|
|
7
|
+
onClose: () => void;
|
|
8
|
+
onConfirm: () => void;
|
|
9
|
+
title: string;
|
|
10
|
+
description: React.ReactNode;
|
|
11
|
+
confirmPhrase: string;
|
|
12
|
+
confirmLabel?: string;
|
|
13
|
+
variant?: "danger" | "warning";
|
|
14
|
+
isLoading?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Typed-confirmation modal: the user must type the exact phrase before the
|
|
19
|
+
* confirm button enables. Used for both install (security warning) and
|
|
20
|
+
* uninstall (destructive cleanup) flows. The strong typed confirmation is
|
|
21
|
+
* the primary security control for plugin install — plugins run with full
|
|
22
|
+
* platform access.
|
|
23
|
+
*/
|
|
24
|
+
export const TypedConfirmModal: React.FC<Props> = ({
|
|
25
|
+
isOpen,
|
|
26
|
+
onClose,
|
|
27
|
+
onConfirm,
|
|
28
|
+
title,
|
|
29
|
+
description,
|
|
30
|
+
confirmPhrase,
|
|
31
|
+
confirmLabel = "Confirm",
|
|
32
|
+
variant = "danger",
|
|
33
|
+
isLoading = false,
|
|
34
|
+
}) => {
|
|
35
|
+
const [typed, setTyped] = useState("");
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (isOpen) setTyped("");
|
|
39
|
+
}, [isOpen]);
|
|
40
|
+
|
|
41
|
+
if (!isOpen) return;
|
|
42
|
+
|
|
43
|
+
const matches = typed === confirmPhrase;
|
|
44
|
+
const colors =
|
|
45
|
+
variant === "danger"
|
|
46
|
+
? {
|
|
47
|
+
icon: "text-destructive",
|
|
48
|
+
iconBg: "bg-destructive/10",
|
|
49
|
+
button:
|
|
50
|
+
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
|
51
|
+
}
|
|
52
|
+
: {
|
|
53
|
+
icon: "text-warning",
|
|
54
|
+
iconBg: "bg-warning/10",
|
|
55
|
+
button: "bg-warning text-warning-foreground hover:bg-warning/90",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div
|
|
60
|
+
className="fixed inset-0 z-[60] !m-0 flex items-center justify-center bg-black/50"
|
|
61
|
+
onClick={(e) => {
|
|
62
|
+
if (e.target === e.currentTarget) onClose();
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
<div
|
|
66
|
+
className="bg-background rounded-lg shadow-xl max-w-lg w-full mx-4 my-4 max-h-[calc(100dvh-2rem)] overflow-y-auto"
|
|
67
|
+
role="dialog"
|
|
68
|
+
aria-modal="true"
|
|
69
|
+
onClick={(e) => e.stopPropagation()}
|
|
70
|
+
>
|
|
71
|
+
<div className="flex items-start justify-between p-6 pb-4">
|
|
72
|
+
<div className="flex items-start gap-4">
|
|
73
|
+
<div className={`rounded-full p-2 ${colors.iconBg}`}>
|
|
74
|
+
<AlertTriangle className={`w-6 h-6 ${colors.icon}`} />
|
|
75
|
+
</div>
|
|
76
|
+
<h3 className="text-lg font-semibold">{title}</h3>
|
|
77
|
+
</div>
|
|
78
|
+
<button
|
|
79
|
+
onClick={onClose}
|
|
80
|
+
disabled={isLoading}
|
|
81
|
+
className="text-muted-foreground hover:text-foreground"
|
|
82
|
+
type="button"
|
|
83
|
+
aria-label="Close"
|
|
84
|
+
>
|
|
85
|
+
<X className="w-5 h-5" />
|
|
86
|
+
</button>
|
|
87
|
+
</div>
|
|
88
|
+
<div className="px-6 pb-4 space-y-4 text-sm">{description}</div>
|
|
89
|
+
<div className="px-6 pb-4 space-y-2">
|
|
90
|
+
<Label htmlFor="typed-confirm">
|
|
91
|
+
Type{" "}
|
|
92
|
+
<code className="px-1 py-0.5 rounded bg-muted text-xs">
|
|
93
|
+
{confirmPhrase}
|
|
94
|
+
</code>{" "}
|
|
95
|
+
to confirm:
|
|
96
|
+
</Label>
|
|
97
|
+
<Input
|
|
98
|
+
id="typed-confirm"
|
|
99
|
+
value={typed}
|
|
100
|
+
onChange={(e) => setTyped(e.target.value)}
|
|
101
|
+
disabled={isLoading}
|
|
102
|
+
autoFocus
|
|
103
|
+
autoComplete="off"
|
|
104
|
+
/>
|
|
105
|
+
</div>
|
|
106
|
+
<div className="flex items-center justify-end gap-3 px-6 py-4 bg-muted/30 rounded-b-lg">
|
|
107
|
+
<Button
|
|
108
|
+
variant="ghost"
|
|
109
|
+
onClick={onClose}
|
|
110
|
+
disabled={isLoading}
|
|
111
|
+
type="button"
|
|
112
|
+
>
|
|
113
|
+
Cancel
|
|
114
|
+
</Button>
|
|
115
|
+
<button
|
|
116
|
+
onClick={onConfirm}
|
|
117
|
+
disabled={!matches || isLoading}
|
|
118
|
+
className={`inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors h-10 px-4 py-2 disabled:opacity-50 disabled:pointer-events-none ${colors.button}`}
|
|
119
|
+
type="button"
|
|
120
|
+
>
|
|
121
|
+
{isLoading ? "Processing..." : confirmLabel}
|
|
122
|
+
</button>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
};
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createFrontendPlugin,
|
|
3
|
+
UserMenuItemsSlot,
|
|
4
|
+
} from "@checkstack/frontend-api";
|
|
5
|
+
import {
|
|
6
|
+
pluginMetadata,
|
|
7
|
+
pluginManagerRoutes,
|
|
8
|
+
pluginManagerAccess,
|
|
9
|
+
} from "@checkstack/pluginmanager-common";
|
|
10
|
+
import { InstalledPluginsPage } from "./pages/InstalledPluginsPage";
|
|
11
|
+
import { InstallPluginPage } from "./pages/InstallPluginPage";
|
|
12
|
+
import { PluginEventsPage } from "./pages/PluginEventsPage";
|
|
13
|
+
import { PluginManagerMenuItem } from "./PluginManagerMenuItem";
|
|
14
|
+
|
|
15
|
+
export default createFrontendPlugin({
|
|
16
|
+
metadata: pluginMetadata,
|
|
17
|
+
routes: [
|
|
18
|
+
{
|
|
19
|
+
route: pluginManagerRoutes.routes.installed,
|
|
20
|
+
element: <InstalledPluginsPage />,
|
|
21
|
+
title: "Plugin Manager",
|
|
22
|
+
accessRule: pluginManagerAccess.view,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
route: pluginManagerRoutes.routes.install,
|
|
26
|
+
element: <InstallPluginPage />,
|
|
27
|
+
title: "Install plugin",
|
|
28
|
+
accessRule: pluginManagerAccess.install,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
route: pluginManagerRoutes.routes.events,
|
|
32
|
+
element: <PluginEventsPage />,
|
|
33
|
+
title: "Plugin events",
|
|
34
|
+
accessRule: pluginManagerAccess.view,
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
extensions: [
|
|
38
|
+
{
|
|
39
|
+
id: "pluginmanager.user-menu.link",
|
|
40
|
+
slot: UserMenuItemsSlot,
|
|
41
|
+
component: PluginManagerMenuItem,
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { useNavigate } from "react-router-dom";
|
|
3
|
+
import {
|
|
4
|
+
PageLayout,
|
|
5
|
+
Card,
|
|
6
|
+
CardHeader,
|
|
7
|
+
CardTitle,
|
|
8
|
+
CardContent,
|
|
9
|
+
Alert,
|
|
10
|
+
Badge,
|
|
11
|
+
Button,
|
|
12
|
+
useToast,
|
|
13
|
+
} from "@checkstack/ui";
|
|
14
|
+
import {
|
|
15
|
+
usePluginClient,
|
|
16
|
+
accessApiRef,
|
|
17
|
+
useApi,
|
|
18
|
+
wrapInSuspense,
|
|
19
|
+
} from "@checkstack/frontend-api";
|
|
20
|
+
import { extractErrorMessage, resolveRoute } from "@checkstack/common";
|
|
21
|
+
import {
|
|
22
|
+
PluginManagerApi,
|
|
23
|
+
pluginManagerAccess,
|
|
24
|
+
pluginManagerRoutes,
|
|
25
|
+
type PluginSource,
|
|
26
|
+
type InstallPreview,
|
|
27
|
+
} from "@checkstack/pluginmanager-common";
|
|
28
|
+
import { ShieldAlert, Plus } from "lucide-react";
|
|
29
|
+
import { SourceForm } from "../components/SourceForm";
|
|
30
|
+
import { TypedConfirmModal } from "../components/TypedConfirmModal";
|
|
31
|
+
|
|
32
|
+
const InstallPluginPageContent: React.FC = () => {
|
|
33
|
+
const client = usePluginClient(PluginManagerApi);
|
|
34
|
+
const accessApi = useApi(accessApiRef);
|
|
35
|
+
const toast = useToast();
|
|
36
|
+
const navigate = useNavigate();
|
|
37
|
+
const { allowed, loading: accessLoading } = accessApi.useAccess(
|
|
38
|
+
pluginManagerAccess.install,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const [pendingSource, setPendingSource] = useState<PluginSource | undefined>();
|
|
42
|
+
const [preview, setPreview] = useState<InstallPreview | undefined>();
|
|
43
|
+
const [previewError, setPreviewError] = useState<string | undefined>();
|
|
44
|
+
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
45
|
+
|
|
46
|
+
const previewMutation = client.previewInstall.useMutation({
|
|
47
|
+
onSuccess: (result) => {
|
|
48
|
+
setPreview(result);
|
|
49
|
+
setPreviewError(undefined);
|
|
50
|
+
setConfirmOpen(true);
|
|
51
|
+
},
|
|
52
|
+
onError: (error) => {
|
|
53
|
+
setPreview(undefined);
|
|
54
|
+
setPreviewError(extractErrorMessage(error, "Preview failed"));
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const installMutation = client.install.useMutation({
|
|
59
|
+
onSuccess: ({ installedPackages }) => {
|
|
60
|
+
toast.success(
|
|
61
|
+
`Installed ${installedPackages.length} package${
|
|
62
|
+
installedPackages.length === 1 ? "" : "s"
|
|
63
|
+
}`,
|
|
64
|
+
);
|
|
65
|
+
setConfirmOpen(false);
|
|
66
|
+
setPreview(undefined);
|
|
67
|
+
setPendingSource(undefined);
|
|
68
|
+
navigate(resolveRoute(pluginManagerRoutes.routes.installed));
|
|
69
|
+
},
|
|
70
|
+
onError: (error) =>
|
|
71
|
+
toast.error(extractErrorMessage(error, "Install failed")),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const handleSourceSubmit = (source: PluginSource) => {
|
|
75
|
+
setPendingSource(source);
|
|
76
|
+
previewMutation.mutate({ source });
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const compatibilityIssues = preview?.compatibilityIssues ?? [];
|
|
80
|
+
const blockable = compatibilityIssues.length > 0;
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<PageLayout
|
|
84
|
+
title="Install plugin"
|
|
85
|
+
icon={Plus}
|
|
86
|
+
loading={accessLoading}
|
|
87
|
+
allowed={allowed}
|
|
88
|
+
>
|
|
89
|
+
<Alert variant="warning">
|
|
90
|
+
<div className="flex items-start gap-3 text-sm">
|
|
91
|
+
<ShieldAlert className="w-5 h-5 mt-0.5 flex-shrink-0" />
|
|
92
|
+
<div>
|
|
93
|
+
<strong>Plugins run with full platform access.</strong> They can
|
|
94
|
+
read and write any data, including secrets. Only install plugins
|
|
95
|
+
from sources you trust. Malicious plugins can exfiltrate sensitive
|
|
96
|
+
data and damage your platform.
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
</Alert>
|
|
100
|
+
|
|
101
|
+
<Card className="mt-6">
|
|
102
|
+
<CardHeader>
|
|
103
|
+
<CardTitle>Source</CardTitle>
|
|
104
|
+
</CardHeader>
|
|
105
|
+
<CardContent>
|
|
106
|
+
<SourceForm
|
|
107
|
+
onSubmit={handleSourceSubmit}
|
|
108
|
+
isLoading={previewMutation.isPending}
|
|
109
|
+
/>
|
|
110
|
+
{previewError ? (
|
|
111
|
+
<p className="text-destructive text-sm mt-3">{previewError}</p>
|
|
112
|
+
) : undefined}
|
|
113
|
+
</CardContent>
|
|
114
|
+
</Card>
|
|
115
|
+
|
|
116
|
+
<TypedConfirmModal
|
|
117
|
+
isOpen={confirmOpen && !!preview}
|
|
118
|
+
onClose={() => setConfirmOpen(false)}
|
|
119
|
+
onConfirm={() => {
|
|
120
|
+
if (!pendingSource || !preview) return;
|
|
121
|
+
installMutation.mutate({
|
|
122
|
+
source: pendingSource,
|
|
123
|
+
confirm: preview.primary.name,
|
|
124
|
+
});
|
|
125
|
+
}}
|
|
126
|
+
title={
|
|
127
|
+
preview
|
|
128
|
+
? `Install ${preview.primary.name}@${preview.primary.version}`
|
|
129
|
+
: "Install"
|
|
130
|
+
}
|
|
131
|
+
confirmPhrase={preview?.primary.name ?? "INSTALL"}
|
|
132
|
+
confirmLabel={
|
|
133
|
+
blockable ? "Compatibility issues — fix first" : "Install"
|
|
134
|
+
}
|
|
135
|
+
variant="warning"
|
|
136
|
+
isLoading={installMutation.isPending}
|
|
137
|
+
description={
|
|
138
|
+
preview ? <InstallDescription preview={preview} /> : "Loading…"
|
|
139
|
+
}
|
|
140
|
+
/>
|
|
141
|
+
</PageLayout>
|
|
142
|
+
);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const InstallDescription: React.FC<{ preview: InstallPreview }> = ({
|
|
146
|
+
preview,
|
|
147
|
+
}) => {
|
|
148
|
+
const compatibilityIssues = preview.compatibilityIssues;
|
|
149
|
+
const author =
|
|
150
|
+
typeof preview.primary.author === "string"
|
|
151
|
+
? preview.primary.author
|
|
152
|
+
: preview.primary.author.name;
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<div className="space-y-3">
|
|
156
|
+
<p>{preview.primary.description}</p>
|
|
157
|
+
<div className="text-xs text-muted-foreground space-y-1">
|
|
158
|
+
<div>
|
|
159
|
+
<strong>Author:</strong> {author}
|
|
160
|
+
</div>
|
|
161
|
+
<div>
|
|
162
|
+
<strong>License:</strong> {preview.primary.license}
|
|
163
|
+
</div>
|
|
164
|
+
{preview.primary.homepage ? (
|
|
165
|
+
<div>
|
|
166
|
+
<strong>Homepage:</strong>{" "}
|
|
167
|
+
<a
|
|
168
|
+
href={preview.primary.homepage}
|
|
169
|
+
target="_blank"
|
|
170
|
+
rel="noreferrer"
|
|
171
|
+
className="underline"
|
|
172
|
+
>
|
|
173
|
+
{preview.primary.homepage}
|
|
174
|
+
</a>
|
|
175
|
+
</div>
|
|
176
|
+
) : undefined}
|
|
177
|
+
</div>
|
|
178
|
+
{preview.packages.length > 1 ? (
|
|
179
|
+
<div className="space-y-1">
|
|
180
|
+
<strong className="text-sm">Bundle:</strong>
|
|
181
|
+
<div className="flex flex-wrap gap-1">
|
|
182
|
+
{preview.packages.map((p) => (
|
|
183
|
+
<Badge key={p.name} variant="secondary">
|
|
184
|
+
{p.name}@{p.version}
|
|
185
|
+
</Badge>
|
|
186
|
+
))}
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
) : undefined}
|
|
190
|
+
<div className="text-xs text-muted-foreground">
|
|
191
|
+
Total size: {(preview.totalSizeBytes / 1024).toFixed(1)} KB
|
|
192
|
+
</div>
|
|
193
|
+
{preview.hasInstallScripts ? (
|
|
194
|
+
<Alert variant="warning">
|
|
195
|
+
<p className="text-sm">
|
|
196
|
+
This plugin opted in to running install scripts (
|
|
197
|
+
<code>postinstall</code> etc.). These run with platform privileges.
|
|
198
|
+
Confirm only if you trust this source.
|
|
199
|
+
</p>
|
|
200
|
+
</Alert>
|
|
201
|
+
) : undefined}
|
|
202
|
+
{preview.primary.checkstack.usageInstructions ? (
|
|
203
|
+
<details className="text-sm">
|
|
204
|
+
<summary className="cursor-pointer font-medium">
|
|
205
|
+
Usage instructions
|
|
206
|
+
</summary>
|
|
207
|
+
<pre className="whitespace-pre-wrap mt-2 p-3 bg-muted rounded text-xs">
|
|
208
|
+
{preview.primary.checkstack.usageInstructions}
|
|
209
|
+
</pre>
|
|
210
|
+
</details>
|
|
211
|
+
) : undefined}
|
|
212
|
+
{compatibilityIssues.length > 0 ? (
|
|
213
|
+
<Alert variant="error">
|
|
214
|
+
<div className="text-sm">
|
|
215
|
+
<strong>Compatibility issues — install will fail:</strong>
|
|
216
|
+
<ul className="list-disc pl-5 mt-1">
|
|
217
|
+
{compatibilityIssues.map((iss, i) => (
|
|
218
|
+
<li key={i}>{iss.message}</li>
|
|
219
|
+
))}
|
|
220
|
+
</ul>
|
|
221
|
+
</div>
|
|
222
|
+
</Alert>
|
|
223
|
+
) : undefined}
|
|
224
|
+
<p className="text-sm">
|
|
225
|
+
Type <code>{preview.primary.name}</code> to confirm install.
|
|
226
|
+
</p>
|
|
227
|
+
</div>
|
|
228
|
+
);
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
void Button; // imported for future per-issue action buttons
|
|
232
|
+
|
|
233
|
+
export const InstallPluginPage = wrapInSuspense(InstallPluginPageContent);
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { Link } from "react-router-dom";
|
|
3
|
+
import { Puzzle, Trash2, Plus, History } from "lucide-react";
|
|
4
|
+
import {
|
|
5
|
+
PageLayout,
|
|
6
|
+
Card,
|
|
7
|
+
CardHeader,
|
|
8
|
+
CardTitle,
|
|
9
|
+
CardDescription,
|
|
10
|
+
CardContent,
|
|
11
|
+
Badge,
|
|
12
|
+
Button,
|
|
13
|
+
EmptyState,
|
|
14
|
+
Table,
|
|
15
|
+
TableHeader,
|
|
16
|
+
TableRow,
|
|
17
|
+
TableHead,
|
|
18
|
+
TableBody,
|
|
19
|
+
TableCell,
|
|
20
|
+
Checkbox,
|
|
21
|
+
Label,
|
|
22
|
+
useToast,
|
|
23
|
+
} from "@checkstack/ui";
|
|
24
|
+
import {
|
|
25
|
+
usePluginClient,
|
|
26
|
+
accessApiRef,
|
|
27
|
+
useApi,
|
|
28
|
+
wrapInSuspense,
|
|
29
|
+
} from "@checkstack/frontend-api";
|
|
30
|
+
import { extractErrorMessage, resolveRoute } from "@checkstack/common";
|
|
31
|
+
import {
|
|
32
|
+
PluginManagerApi,
|
|
33
|
+
pluginManagerAccess,
|
|
34
|
+
pluginManagerRoutes,
|
|
35
|
+
type InstalledPlugin,
|
|
36
|
+
} from "@checkstack/pluginmanager-common";
|
|
37
|
+
import { TypedConfirmModal } from "../components/TypedConfirmModal";
|
|
38
|
+
|
|
39
|
+
const InstalledPluginsPageContent: React.FC = () => {
|
|
40
|
+
const client = usePluginClient(PluginManagerApi);
|
|
41
|
+
const accessApi = useApi(accessApiRef);
|
|
42
|
+
const toast = useToast();
|
|
43
|
+
|
|
44
|
+
const view = accessApi.useAccess(pluginManagerAccess.view);
|
|
45
|
+
const uninstallAccess = accessApi.useAccess(pluginManagerAccess.uninstall);
|
|
46
|
+
|
|
47
|
+
const { data, isLoading, refetch } = client.list.useQuery();
|
|
48
|
+
|
|
49
|
+
const [target, setTarget] = useState<InstalledPlugin | undefined>();
|
|
50
|
+
const [deleteSchema, setDeleteSchema] = useState(true);
|
|
51
|
+
const [deleteConfigs, setDeleteConfigs] = useState(true);
|
|
52
|
+
const [cascade, setCascade] = useState(false);
|
|
53
|
+
|
|
54
|
+
const previewQuery = client.previewUninstall.useQuery(
|
|
55
|
+
{ pluginName: target?.name ?? "" },
|
|
56
|
+
{ enabled: !!target },
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const uninstallMutation = client.uninstall.useMutation({
|
|
60
|
+
onSuccess: ({ uninstalledPackages }) => {
|
|
61
|
+
toast.success(
|
|
62
|
+
`Uninstalled ${uninstalledPackages.length} package${
|
|
63
|
+
uninstalledPackages.length === 1 ? "" : "s"
|
|
64
|
+
}`,
|
|
65
|
+
);
|
|
66
|
+
setTarget(undefined);
|
|
67
|
+
refetch();
|
|
68
|
+
},
|
|
69
|
+
onError: (error) =>
|
|
70
|
+
toast.error(extractErrorMessage(error, "Uninstall failed")),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const installPath = resolveRoute(pluginManagerRoutes.routes.install);
|
|
74
|
+
const eventsPath = resolveRoute(pluginManagerRoutes.routes.events);
|
|
75
|
+
|
|
76
|
+
const headerActions = (
|
|
77
|
+
<div className="flex gap-2">
|
|
78
|
+
<Button asChild variant="ghost">
|
|
79
|
+
<Link to={eventsPath}>
|
|
80
|
+
<History className="w-4 h-4 mr-2" />
|
|
81
|
+
Events
|
|
82
|
+
</Link>
|
|
83
|
+
</Button>
|
|
84
|
+
<Button asChild>
|
|
85
|
+
<Link to={installPath}>
|
|
86
|
+
<Plus className="w-4 h-4 mr-2" />
|
|
87
|
+
Install plugin
|
|
88
|
+
</Link>
|
|
89
|
+
</Button>
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
if (view.loading || isLoading) {
|
|
94
|
+
return (
|
|
95
|
+
<PageLayout
|
|
96
|
+
title="Plugins"
|
|
97
|
+
icon={Puzzle}
|
|
98
|
+
actions={headerActions}
|
|
99
|
+
loading
|
|
100
|
+
allowed={view.allowed}
|
|
101
|
+
>
|
|
102
|
+
<div />
|
|
103
|
+
</PageLayout>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const plugins = data?.plugins ?? [];
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<PageLayout
|
|
111
|
+
title="Plugins"
|
|
112
|
+
icon={Puzzle}
|
|
113
|
+
actions={headerActions}
|
|
114
|
+
allowed={view.allowed}
|
|
115
|
+
>
|
|
116
|
+
<Card>
|
|
117
|
+
<CardHeader>
|
|
118
|
+
<CardTitle>Installed Plugins</CardTitle>
|
|
119
|
+
<CardDescription>
|
|
120
|
+
Plugins running on this Checkstack instance. Runtime-installed
|
|
121
|
+
plugins (npm, GitHub release, tarball upload) can be uninstalled
|
|
122
|
+
from this page; bundled platform plugins are read-only.
|
|
123
|
+
</CardDescription>
|
|
124
|
+
</CardHeader>
|
|
125
|
+
<CardContent>
|
|
126
|
+
{plugins.length === 0 ? (
|
|
127
|
+
<EmptyState
|
|
128
|
+
title="No plugins"
|
|
129
|
+
description="No plugins discovered yet."
|
|
130
|
+
/>
|
|
131
|
+
) : (
|
|
132
|
+
<Table>
|
|
133
|
+
<TableHeader>
|
|
134
|
+
<TableRow>
|
|
135
|
+
<TableHead>Name</TableHead>
|
|
136
|
+
<TableHead>Version</TableHead>
|
|
137
|
+
<TableHead>Type</TableHead>
|
|
138
|
+
<TableHead>Source</TableHead>
|
|
139
|
+
<TableHead>Status</TableHead>
|
|
140
|
+
<TableHead className="text-right">Actions</TableHead>
|
|
141
|
+
</TableRow>
|
|
142
|
+
</TableHeader>
|
|
143
|
+
<TableBody>
|
|
144
|
+
{plugins.map((p) => (
|
|
145
|
+
<TableRow key={p.name}>
|
|
146
|
+
<TableCell className="font-mono">{p.name}</TableCell>
|
|
147
|
+
<TableCell>{p.version || "—"}</TableCell>
|
|
148
|
+
<TableCell>
|
|
149
|
+
<Badge variant={typeBadgeVariant(p.type)}>{p.type}</Badge>
|
|
150
|
+
</TableCell>
|
|
151
|
+
<TableCell>
|
|
152
|
+
{p.source ? (
|
|
153
|
+
<Badge variant="info">{p.source.type}</Badge>
|
|
154
|
+
) : (
|
|
155
|
+
<Badge variant="secondary">monorepo</Badge>
|
|
156
|
+
)}
|
|
157
|
+
</TableCell>
|
|
158
|
+
<TableCell>
|
|
159
|
+
{p.isUninstallable ? (
|
|
160
|
+
<Badge variant="info">runtime</Badge>
|
|
161
|
+
) : (
|
|
162
|
+
<Badge variant="secondary">core</Badge>
|
|
163
|
+
)}
|
|
164
|
+
</TableCell>
|
|
165
|
+
<TableCell className="text-right">
|
|
166
|
+
{p.isUninstallable && uninstallAccess.allowed ? (
|
|
167
|
+
<Button
|
|
168
|
+
variant="ghost"
|
|
169
|
+
size="sm"
|
|
170
|
+
onClick={() => setTarget(p)}
|
|
171
|
+
>
|
|
172
|
+
<Trash2 className="w-4 h-4 mr-2" />
|
|
173
|
+
Uninstall
|
|
174
|
+
</Button>
|
|
175
|
+
) : undefined}
|
|
176
|
+
</TableCell>
|
|
177
|
+
</TableRow>
|
|
178
|
+
))}
|
|
179
|
+
</TableBody>
|
|
180
|
+
</Table>
|
|
181
|
+
)}
|
|
182
|
+
</CardContent>
|
|
183
|
+
</Card>
|
|
184
|
+
|
|
185
|
+
<TypedConfirmModal
|
|
186
|
+
isOpen={!!target}
|
|
187
|
+
onClose={() => setTarget(undefined)}
|
|
188
|
+
onConfirm={() => {
|
|
189
|
+
if (!target) return;
|
|
190
|
+
uninstallMutation.mutate({
|
|
191
|
+
pluginName: target.name,
|
|
192
|
+
deleteSchema,
|
|
193
|
+
deleteConfigs,
|
|
194
|
+
cascade,
|
|
195
|
+
confirm: target.name,
|
|
196
|
+
});
|
|
197
|
+
}}
|
|
198
|
+
title={`Uninstall ${target?.name}`}
|
|
199
|
+
confirmPhrase={target?.name ?? ""}
|
|
200
|
+
confirmLabel="Uninstall"
|
|
201
|
+
variant="danger"
|
|
202
|
+
isLoading={uninstallMutation.isPending}
|
|
203
|
+
description={
|
|
204
|
+
target ? (
|
|
205
|
+
<UninstallDescription
|
|
206
|
+
target={target}
|
|
207
|
+
preview={previewQuery.data}
|
|
208
|
+
deleteSchema={deleteSchema}
|
|
209
|
+
setDeleteSchema={setDeleteSchema}
|
|
210
|
+
deleteConfigs={deleteConfigs}
|
|
211
|
+
setDeleteConfigs={setDeleteConfigs}
|
|
212
|
+
cascade={cascade}
|
|
213
|
+
setCascade={setCascade}
|
|
214
|
+
/>
|
|
215
|
+
) : (
|
|
216
|
+
""
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
/>
|
|
220
|
+
</PageLayout>
|
|
221
|
+
);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
function typeBadgeVariant(type: string): "default" | "secondary" | "info" {
|
|
225
|
+
switch (type) {
|
|
226
|
+
case "backend": {
|
|
227
|
+
return "default";
|
|
228
|
+
}
|
|
229
|
+
case "frontend": {
|
|
230
|
+
return "info";
|
|
231
|
+
}
|
|
232
|
+
default: {
|
|
233
|
+
return "secondary";
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const UninstallDescription: React.FC<{
|
|
239
|
+
target: InstalledPlugin;
|
|
240
|
+
preview:
|
|
241
|
+
| {
|
|
242
|
+
siblings: string[];
|
|
243
|
+
dependents: string[];
|
|
244
|
+
schemasToDrop: string[];
|
|
245
|
+
pluginConfigCount: number;
|
|
246
|
+
}
|
|
247
|
+
| undefined;
|
|
248
|
+
deleteSchema: boolean;
|
|
249
|
+
setDeleteSchema: (v: boolean) => void;
|
|
250
|
+
deleteConfigs: boolean;
|
|
251
|
+
setDeleteConfigs: (v: boolean) => void;
|
|
252
|
+
cascade: boolean;
|
|
253
|
+
setCascade: (v: boolean) => void;
|
|
254
|
+
}> = ({
|
|
255
|
+
target,
|
|
256
|
+
preview,
|
|
257
|
+
deleteSchema,
|
|
258
|
+
setDeleteSchema,
|
|
259
|
+
deleteConfigs,
|
|
260
|
+
setDeleteConfigs,
|
|
261
|
+
cascade,
|
|
262
|
+
setCascade,
|
|
263
|
+
}) => (
|
|
264
|
+
<>
|
|
265
|
+
<p>
|
|
266
|
+
Uninstalling <code>{target.name}</code> stops the plugin on every
|
|
267
|
+
instance and deletes its data on this instance.
|
|
268
|
+
</p>
|
|
269
|
+
{preview ? (
|
|
270
|
+
<ul className="list-disc pl-5 space-y-1 text-muted-foreground">
|
|
271
|
+
{preview.siblings.length > 1 ? (
|
|
272
|
+
<li>
|
|
273
|
+
Bundle siblings will also be uninstalled:{" "}
|
|
274
|
+
<code>{preview.siblings.join(", ")}</code>
|
|
275
|
+
</li>
|
|
276
|
+
) : undefined}
|
|
277
|
+
{preview.dependents.length > 0 ? (
|
|
278
|
+
<li className="text-warning">
|
|
279
|
+
Dependent plugins:{" "}
|
|
280
|
+
<code>{preview.dependents.join(", ")}</code> — enable cascade
|
|
281
|
+
below to remove them too.
|
|
282
|
+
</li>
|
|
283
|
+
) : undefined}
|
|
284
|
+
<li>Will drop {preview.schemasToDrop.length} Postgres schema(s).</li>
|
|
285
|
+
<li>Will delete {preview.pluginConfigCount} plugin_configs row(s).</li>
|
|
286
|
+
</ul>
|
|
287
|
+
) : undefined}
|
|
288
|
+
|
|
289
|
+
<div className="space-y-2 pt-2">
|
|
290
|
+
<div className="flex items-center gap-2">
|
|
291
|
+
<Checkbox
|
|
292
|
+
id="del-schema"
|
|
293
|
+
checked={deleteSchema}
|
|
294
|
+
onCheckedChange={setDeleteSchema}
|
|
295
|
+
/>
|
|
296
|
+
<Label htmlFor="del-schema">
|
|
297
|
+
Drop Postgres schema(s) (destructive)
|
|
298
|
+
</Label>
|
|
299
|
+
</div>
|
|
300
|
+
<div className="flex items-center gap-2">
|
|
301
|
+
<Checkbox
|
|
302
|
+
id="del-configs"
|
|
303
|
+
checked={deleteConfigs}
|
|
304
|
+
onCheckedChange={setDeleteConfigs}
|
|
305
|
+
/>
|
|
306
|
+
<Label htmlFor="del-configs">
|
|
307
|
+
Delete plugin configurations (destructive)
|
|
308
|
+
</Label>
|
|
309
|
+
</div>
|
|
310
|
+
{preview && preview.dependents.length > 0 ? (
|
|
311
|
+
<div className="flex items-center gap-2">
|
|
312
|
+
<Checkbox
|
|
313
|
+
id="cascade"
|
|
314
|
+
checked={cascade}
|
|
315
|
+
onCheckedChange={setCascade}
|
|
316
|
+
/>
|
|
317
|
+
<Label htmlFor="cascade">Cascade — also uninstall dependents</Label>
|
|
318
|
+
</div>
|
|
319
|
+
) : undefined}
|
|
320
|
+
</div>
|
|
321
|
+
</>
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
export const InstalledPluginsPage = wrapInSuspense(
|
|
325
|
+
InstalledPluginsPageContent,
|
|
326
|
+
);
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { History } from "lucide-react";
|
|
3
|
+
import {
|
|
4
|
+
PageLayout,
|
|
5
|
+
Card,
|
|
6
|
+
CardContent,
|
|
7
|
+
Badge,
|
|
8
|
+
EmptyState,
|
|
9
|
+
Table,
|
|
10
|
+
TableHeader,
|
|
11
|
+
TableRow,
|
|
12
|
+
TableHead,
|
|
13
|
+
TableBody,
|
|
14
|
+
TableCell,
|
|
15
|
+
} from "@checkstack/ui";
|
|
16
|
+
import {
|
|
17
|
+
usePluginClient,
|
|
18
|
+
accessApiRef,
|
|
19
|
+
useApi,
|
|
20
|
+
wrapInSuspense,
|
|
21
|
+
} from "@checkstack/frontend-api";
|
|
22
|
+
import {
|
|
23
|
+
PluginManagerApi,
|
|
24
|
+
pluginManagerAccess,
|
|
25
|
+
type InstallEventStatus,
|
|
26
|
+
} from "@checkstack/pluginmanager-common";
|
|
27
|
+
|
|
28
|
+
const statusVariant: Record<
|
|
29
|
+
InstallEventStatus,
|
|
30
|
+
"default" | "secondary" | "destructive" | "info"
|
|
31
|
+
> = {
|
|
32
|
+
started: "info",
|
|
33
|
+
succeeded: "default",
|
|
34
|
+
failed: "destructive",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const PluginEventsPageContent: React.FC = () => {
|
|
38
|
+
const client = usePluginClient(PluginManagerApi);
|
|
39
|
+
const accessApi = useApi(accessApiRef);
|
|
40
|
+
const { allowed, loading: accessLoading } = accessApi.useAccess(
|
|
41
|
+
pluginManagerAccess.view,
|
|
42
|
+
);
|
|
43
|
+
const { data, isLoading } = client.events.useQuery({ limit: 200 });
|
|
44
|
+
|
|
45
|
+
if (accessLoading || isLoading) {
|
|
46
|
+
return (
|
|
47
|
+
<PageLayout title="Plugin events" icon={History} loading allowed={allowed}>
|
|
48
|
+
<div />
|
|
49
|
+
</PageLayout>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const events = data?.events ?? [];
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<PageLayout title="Plugin events" icon={History} allowed={allowed}>
|
|
57
|
+
{events.length === 0 ? (
|
|
58
|
+
<EmptyState
|
|
59
|
+
icon={<History className="w-12 h-12" />}
|
|
60
|
+
title="No events yet"
|
|
61
|
+
description="Plugin install/uninstall lifecycle events will show up here."
|
|
62
|
+
/>
|
|
63
|
+
) : (
|
|
64
|
+
<Card>
|
|
65
|
+
<CardContent className="p-0">
|
|
66
|
+
<Table>
|
|
67
|
+
<TableHeader>
|
|
68
|
+
<TableRow>
|
|
69
|
+
<TableHead>When</TableHead>
|
|
70
|
+
<TableHead>Plugin</TableHead>
|
|
71
|
+
<TableHead>Action</TableHead>
|
|
72
|
+
<TableHead>Phase</TableHead>
|
|
73
|
+
<TableHead>Status</TableHead>
|
|
74
|
+
<TableHead>Instance</TableHead>
|
|
75
|
+
<TableHead>Error</TableHead>
|
|
76
|
+
</TableRow>
|
|
77
|
+
</TableHeader>
|
|
78
|
+
<TableBody>
|
|
79
|
+
{events.map((e) => (
|
|
80
|
+
<TableRow key={e.id}>
|
|
81
|
+
<TableCell className="font-mono text-xs">
|
|
82
|
+
{new Date(e.createdAt).toLocaleString()}
|
|
83
|
+
</TableCell>
|
|
84
|
+
<TableCell>{e.pluginName ?? "—"}</TableCell>
|
|
85
|
+
<TableCell>{e.action}</TableCell>
|
|
86
|
+
<TableCell>
|
|
87
|
+
<code className="text-xs">{e.phase}</code>
|
|
88
|
+
</TableCell>
|
|
89
|
+
<TableCell>
|
|
90
|
+
<Badge variant={statusVariant[e.status]}>{e.status}</Badge>
|
|
91
|
+
</TableCell>
|
|
92
|
+
<TableCell className="text-xs">{e.instanceId}</TableCell>
|
|
93
|
+
<TableCell className="text-xs text-destructive max-w-xs truncate">
|
|
94
|
+
{e.error ?? ""}
|
|
95
|
+
</TableCell>
|
|
96
|
+
</TableRow>
|
|
97
|
+
))}
|
|
98
|
+
</TableBody>
|
|
99
|
+
</Table>
|
|
100
|
+
</CardContent>
|
|
101
|
+
</Card>
|
|
102
|
+
)}
|
|
103
|
+
</PageLayout>
|
|
104
|
+
);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export const PluginEventsPage = wrapInSuspense(PluginEventsPageContent);
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "@checkstack/tsconfig/frontend.json",
|
|
3
|
+
"include": [
|
|
4
|
+
"src"
|
|
5
|
+
],
|
|
6
|
+
"references": [
|
|
7
|
+
{
|
|
8
|
+
"path": "../common"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"path": "../frontend-api"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"path": "../pluginmanager-common"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"path": "../ui"
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|