feat(FN-4050): add one-click install support for reports plugin
- Add reports plugin to bundled plugin resolver so it can be installed from packaged assets - Expose reports plugin in dashboard plugin manager and server routes for one-click install flows - Add CLI and dashboard test coverage for bundled install behavior and plugin route availability - Update reports/plugin management/settings docs and include a changeset for @runfusion/fusion
This commit is contained in:
5
.changeset/reports-plugin-one-click-install.md
Normal file
5
.changeset/reports-plugin-one-click-install.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add one-click bundled plugin install support for Reports in Settings → Plugins.
|
||||||
@@ -58,7 +58,7 @@ For full multi-project details, see [Plugin Scope in Multi-Project Mode](./multi
|
|||||||
2. Review bundled entries in **Bundled Plugins** and currently installed entries.
|
2. Review bundled entries in **Bundled Plugins** and currently installed entries.
|
||||||
3. Check each plugin’s status/state in the manager.
|
3. Check each plugin’s status/state in the manager.
|
||||||
|
|
||||||
First-party bundled entries include runtime plugins plus integrations like **Dependency Graph**, **Roadmap**, **WhatsApp Chat**, and **CLI Printing Press**.
|
First-party bundled entries include runtime plugins plus integrations like **Dependency Graph**, **Reports**, **Roadmap**, **WhatsApp Chat**, and **CLI Printing Press**.
|
||||||
|
|
||||||
Expected outcome: You can see what is already installed, what is bundled and available, and each plugin’s current lifecycle state.
|
Expected outcome: You can see what is already installed, what is bundled and available, and each plugin’s current lifecycle state.
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# Reports Plugin
|
# Reports Plugin
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
1. Open **Settings → Plugins → Fusion Plugins**.
|
||||||
|
2. In **Bundled Plugins**, click **Install** for **Reports**.
|
||||||
|
3. Enable the plugin if it is not already started.
|
||||||
|
|
||||||
|
When installed and enabled, the plugin registers the **Reports** dashboard view destination.
|
||||||
|
|
||||||
## Rendering & Export
|
## Rendering & Export
|
||||||
|
|
||||||
The reports plugin renders deterministic HTML via `src/render/html-template.ts` using ordered `data-section` blocks and tokenized styles from `src/render/html-styles.ts`. Section toggles and `sectionOrder` are respected from report settings metadata, and both dark/light themes are embedded directly in the output document (no dashboard stylesheet dependency).
|
The reports plugin renders deterministic HTML via `src/render/html-template.ts` using ordered `data-section` blocks and tokenized styles from `src/render/html-styles.ts`. Section toggles and `sectionOrder` are respected from report settings metadata, and both dark/light themes are embedded directly in the output document (no dashboard stylesheet dependency).
|
||||||
|
|||||||
@@ -638,7 +638,7 @@ fn plugin install ./plugins/fusion-plugin-hermes-runtime
|
|||||||
fn plugin install ./plugins/fusion-plugin-openclaw-runtime
|
fn plugin install ./plugins/fusion-plugin-openclaw-runtime
|
||||||
```
|
```
|
||||||
|
|
||||||
> 💡 In the dashboard, go to **Settings → Plugins → Fusion Plugins**. The **Bundled Plugins** section surfaces Agent Browser, Hermes, Paperclip, OpenClaw, Droid, and Dependency Graph directly from shipped manifests, shows install status, and provides one-click install actions for plugins that are not yet installed.
|
> 💡 In the dashboard, go to **Settings → Plugins → Fusion Plugins**. The **Bundled Plugins** section surfaces Agent Browser, Hermes, Paperclip, OpenClaw, Droid, Dependency Graph, and Reports directly from shipped manifests, shows install status, and provides one-click install actions for plugins that are not yet installed.
|
||||||
>
|
>
|
||||||
> ℹ️ Bundled runtime plugins (`fusion-plugin-paperclip-runtime`, `fusion-plugin-hermes-runtime`, `fusion-plugin-openclaw-runtime`) support lazy install semantics in settings: the card can open before installation (initial `GET /api/plugins/:id/settings` returns empty/default settings instead of 404), and the first save triggers auto-install (`PUT /api/plugins/:id/settings`). They are **not** auto-installed at app boot or npm install time. If a bundled asset is genuinely unavailable in the current build, save returns an explicit server error instead of a late plugin-not-found 404.
|
> ℹ️ Bundled runtime plugins (`fusion-plugin-paperclip-runtime`, `fusion-plugin-hermes-runtime`, `fusion-plugin-openclaw-runtime`) support lazy install semantics in settings: the card can open before installation (initial `GET /api/plugins/:id/settings` returns empty/default settings instead of 404), and the first save triggers auto-install (`PUT /api/plugins/:id/settings`). They are **not** auto-installed at app boot or npm install time. If a bundled asset is genuinely unavailable in the current build, save returns an explicit server error instead of a late plugin-not-found 404.
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const BUNDLED_PLUGIN_ID = "fusion-plugin-dependency-graph";
|
|||||||
const HERMES_PLUGIN_ID = "fusion-plugin-hermes-runtime";
|
const HERMES_PLUGIN_ID = "fusion-plugin-hermes-runtime";
|
||||||
const CURSOR_PLUGIN_ID = "fusion-plugin-cursor-runtime";
|
const CURSOR_PLUGIN_ID = "fusion-plugin-cursor-runtime";
|
||||||
const ROADMAP_PLUGIN_ID = "fusion-plugin-roadmap";
|
const ROADMAP_PLUGIN_ID = "fusion-plugin-roadmap";
|
||||||
|
const REPORTS_PLUGIN_ID = "fusion-plugin-reports";
|
||||||
const CLI_PRINTING_PRESS_PLUGIN_ID = "fusion-plugin-cli-printing-press";
|
const CLI_PRINTING_PRESS_PLUGIN_ID = "fusion-plugin-cli-printing-press";
|
||||||
|
|
||||||
function makeManifest(overrides?: Partial<{ id: string; version: string; name: string }>) {
|
function makeManifest(overrides?: Partial<{ id: string; version: string; name: string }>) {
|
||||||
@@ -225,6 +226,10 @@ describe("ensureBundledDependencyGraphPluginInstalled", () => {
|
|||||||
it("includes CLI printing press plugin in bundled plugin ids", () => {
|
it("includes CLI printing press plugin in bundled plugin ids", () => {
|
||||||
expect(BUNDLED_PLUGIN_IDS).toContain(CLI_PRINTING_PRESS_PLUGIN_ID);
|
expect(BUNDLED_PLUGIN_IDS).toContain(CLI_PRINTING_PRESS_PLUGIN_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes reports plugin in bundled plugin ids", () => {
|
||||||
|
expect(BUNDLED_PLUGIN_IDS).toContain(REPORTS_PLUGIN_ID);
|
||||||
|
});
|
||||||
it("fresh install: registers and loads the plugin when not in DB", async () => {
|
it("fresh install: registers and loads the plugin when not in DB", async () => {
|
||||||
setupBundleExists();
|
setupBundleExists();
|
||||||
const store = makePluginStore();
|
const store = makePluginStore();
|
||||||
@@ -417,6 +422,33 @@ describe("ensureBundledDependencyGraphPluginInstalled", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("registers reports plugin via generic bundled installer", async () => {
|
||||||
|
const manifest = makeManifest({ id: REPORTS_PLUGIN_ID, name: "Reports" });
|
||||||
|
mockExistsSync.mockImplementation((p: string) => {
|
||||||
|
if (p.endsWith("manifest.json") && p.includes(REPORTS_PLUGIN_ID)) return true;
|
||||||
|
if (p.endsWith("/src/index.ts") && p.includes(REPORTS_PLUGIN_ID)) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
mockReadFile.mockResolvedValue(JSON.stringify(manifest));
|
||||||
|
mockValidatePluginManifest.mockReturnValue({ valid: true, errors: [] });
|
||||||
|
|
||||||
|
const store = makePluginStore();
|
||||||
|
const loader = makePluginLoader();
|
||||||
|
|
||||||
|
const result = await ensureBundledPluginInstalled(
|
||||||
|
store as unknown as import("@fusion/core").PluginStore,
|
||||||
|
loader as unknown as import("@fusion/core").PluginLoader,
|
||||||
|
REPORTS_PLUGIN_ID,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe("installed");
|
||||||
|
expect(store.registerPlugin).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ manifest: expect.objectContaining({ id: REPORTS_PLUGIN_ID }) }),
|
||||||
|
);
|
||||||
|
const registerCall = store.registerPlugin.mock.calls[0]?.[0] as { path: string };
|
||||||
|
expect(registerCall.path).toContain(REPORTS_PLUGIN_ID);
|
||||||
|
});
|
||||||
|
|
||||||
it("registers Hermes from bundled.js when bundled, src, and dist entries all exist", async () => {
|
it("registers Hermes from bundled.js when bundled, src, and dist entries all exist", async () => {
|
||||||
const manifest = makeManifest({ id: HERMES_PLUGIN_ID, name: "Hermes Runtime" });
|
const manifest = makeManifest({ id: HERMES_PLUGIN_ID, name: "Hermes Runtime" });
|
||||||
mockExistsSync.mockImplementation((p: string) => {
|
mockExistsSync.mockImplementation((p: string) => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const CURSOR_RUNTIME_PLUGIN_ID = "fusion-plugin-cursor-runtime";
|
|||||||
|
|
||||||
export const BUNDLED_PLUGIN_IDS = [
|
export const BUNDLED_PLUGIN_IDS = [
|
||||||
"fusion-plugin-dependency-graph",
|
"fusion-plugin-dependency-graph",
|
||||||
|
"fusion-plugin-reports",
|
||||||
"fusion-plugin-whatsapp-chat",
|
"fusion-plugin-whatsapp-chat",
|
||||||
"fusion-plugin-roadmap",
|
"fusion-plugin-roadmap",
|
||||||
"fusion-plugin-hermes-runtime",
|
"fusion-plugin-hermes-runtime",
|
||||||
|
|||||||
@@ -123,6 +123,13 @@ const BUILTIN_PLUGINS: BuiltinPlugin[] = [
|
|||||||
category: "integration",
|
category: "integration",
|
||||||
path: "./plugins/fusion-plugin-dependency-graph",
|
path: "./plugins/fusion-plugin-dependency-graph",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "fusion-plugin-reports",
|
||||||
|
name: "Reports",
|
||||||
|
description: "View report history, compare runs side-by-side, and export standalone HTML summaries.",
|
||||||
|
category: "integration",
|
||||||
|
path: "./plugins/fusion-plugin-reports",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "fusion-plugin-whatsapp-chat",
|
id: "fusion-plugin-whatsapp-chat",
|
||||||
name: "WhatsApp Chat",
|
name: "WhatsApp Chat",
|
||||||
|
|||||||
@@ -267,6 +267,7 @@ describe("PluginManager", () => {
|
|||||||
expect(screen.getByText("OpenClaw Runtime")).toBeTruthy();
|
expect(screen.getByText("OpenClaw Runtime")).toBeTruthy();
|
||||||
expect(screen.getByText("Droid Runtime")).toBeTruthy();
|
expect(screen.getByText("Droid Runtime")).toBeTruthy();
|
||||||
expect(screen.getByText("Dependency Graph")).toBeTruthy();
|
expect(screen.getByText("Dependency Graph")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Reports")).toBeTruthy();
|
||||||
expect(screen.getByText("WhatsApp Chat")).toBeTruthy();
|
expect(screen.getByText("WhatsApp Chat")).toBeTruthy();
|
||||||
expect(screen.getByText("CLI Printing Press")).toBeTruthy();
|
expect(screen.getByText("CLI Printing Press")).toBeTruthy();
|
||||||
expect(screen.getByText(/Pairs to WhatsApp Web \(multi-device\) with QR or pairing code/i)).toBeTruthy();
|
expect(screen.getByText(/Pairs to WhatsApp Web \(multi-device\) with QR or pairing code/i)).toBeTruthy();
|
||||||
@@ -426,6 +427,26 @@ describe("PluginManager", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("installs reports from the built-in section", async () => {
|
||||||
|
render(<PluginManager addToast={addToast} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchPlugins).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
const reportsLabel = await screen.findByText("Reports");
|
||||||
|
const reportsCard = reportsLabel.closest(".plugin-builtins-item");
|
||||||
|
expect(reportsCard).toBeTruthy();
|
||||||
|
|
||||||
|
const installButton = within(reportsCard as HTMLElement).getByRole("button", { name: /Install Reports/i });
|
||||||
|
await userEvent.click(installButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(installPlugin).toHaveBeenCalledWith({ path: "./plugins/fusion-plugin-reports" }, undefined);
|
||||||
|
expect(addToast).toHaveBeenCalledWith("Reports installed globally", "success");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("installs CLI Printing Press from the built-in section", async () => {
|
it("installs CLI Printing Press from the built-in section", async () => {
|
||||||
render(<PluginManager addToast={addToast} />);
|
render(<PluginManager addToast={addToast} />);
|
||||||
|
|
||||||
@@ -842,6 +863,37 @@ describe("PluginManager", () => {
|
|||||||
expect(screen.getByTestId("plugin-manager-detail")).toBeTruthy();
|
expect(screen.getByTestId("plugin-manager-detail")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows Manage for installed reports in built-in section", async () => {
|
||||||
|
vi.mocked(fetchPlugins).mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
...mockPlugins[0],
|
||||||
|
id: "fusion-plugin-reports",
|
||||||
|
name: "Reports",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(<PluginManager addToast={addToast} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getAllByText("Reports").length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
const reportsCard = screen.getAllByText("Reports")[1]?.closest(".plugin-builtins-item");
|
||||||
|
expect(reportsCard).toBeTruthy();
|
||||||
|
|
||||||
|
const manageButton = within(reportsCard as HTMLElement).getByRole("button", { name: /^Manage$/i });
|
||||||
|
expect(manageButton).not.toBeDisabled();
|
||||||
|
expect(within(reportsCard as HTMLElement).getAllByText("Installed").length).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
await userEvent.click(manageButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchPluginSettings).toHaveBeenCalledWith("fusion-plugin-reports", undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId("plugin-manager-detail")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
describe("SSE Live Updates", () => {
|
describe("SSE Live Updates", () => {
|
||||||
it("subscribes to plugin:lifecycle SSE events", async () => {
|
it("subscribes to plugin:lifecycle SSE events", async () => {
|
||||||
vi.mocked(fetchPlugins).mockResolvedValueOnce(mockPlugins);
|
vi.mocked(fetchPlugins).mockResolvedValueOnce(mockPlugins);
|
||||||
|
|||||||
@@ -517,6 +517,38 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () =
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("installs bundled reports plugin when relative path misses cwd", async () => {
|
||||||
|
const bundledManifest = {
|
||||||
|
...VALID_MANIFEST,
|
||||||
|
id: "fusion-plugin-reports",
|
||||||
|
name: "Reports",
|
||||||
|
};
|
||||||
|
mockExistsSync.mockImplementation((p: string) => p.includes("fusion-plugin-reports/manifest.json"));
|
||||||
|
mockAccess.mockImplementation((p: string) => {
|
||||||
|
if (p.includes("fusion-plugin-reports")) return Promise.resolve();
|
||||||
|
return Promise.reject(new Error("not found"));
|
||||||
|
});
|
||||||
|
mockReadFile.mockResolvedValue(JSON.stringify(bundledManifest));
|
||||||
|
(pluginStore.registerPlugin as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
...INSTALLED_PLUGIN,
|
||||||
|
id: "fusion-plugin-reports",
|
||||||
|
name: "Reports",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
|
||||||
|
mode: "install",
|
||||||
|
path: "./plugins/fusion-plugin-reports",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
manifest: expect.objectContaining({ id: "fusion-plugin-reports" }),
|
||||||
|
path: expect.stringContaining("fusion-plugin-reports"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns 404 with helpful message when local and bundled paths are unresolved", async () => {
|
it("returns 404 with helpful message when local and bundled paths are unresolved", async () => {
|
||||||
mockExistsSync.mockReturnValue(false);
|
mockExistsSync.mockReturnValue(false);
|
||||||
mockAccess.mockRejectedValue(new Error("not found"));
|
mockAccess.mockRejectedValue(new Error("not found"));
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ const BUNDLED_PLUGIN_RUNTIMES: Array<{
|
|||||||
];
|
];
|
||||||
const BUNDLED_PLUGIN_IDS = new Set([
|
const BUNDLED_PLUGIN_IDS = new Set([
|
||||||
"fusion-plugin-dependency-graph",
|
"fusion-plugin-dependency-graph",
|
||||||
|
"fusion-plugin-reports",
|
||||||
"fusion-plugin-whatsapp-chat",
|
"fusion-plugin-whatsapp-chat",
|
||||||
"fusion-plugin-roadmap",
|
"fusion-plugin-roadmap",
|
||||||
"fusion-plugin-hermes-runtime",
|
"fusion-plugin-hermes-runtime",
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
Generates HTML system activity reports with multi-agent review.
|
Generates HTML system activity reports with multi-agent review.
|
||||||
|
|
||||||
|
## Install (one-click)
|
||||||
|
|
||||||
|
1. Open **Settings → Plugins → Fusion Plugins**.
|
||||||
|
2. In **Bundled Plugins**, click **Install** on **Reports**.
|
||||||
|
3. Enable the plugin if prompted.
|
||||||
|
|
||||||
|
Once installed and enabled, Fusion registers the **Reports** dashboard destination automatically.
|
||||||
|
|
||||||
## Scaffold seams (interim)
|
## Scaffold seams (interim)
|
||||||
|
|
||||||
The plugin currently exports four interim scaffold seams to unblock downstream implementation work:
|
The plugin currently exports four interim scaffold seams to unblock downstream implementation work:
|
||||||
|
|||||||
Reference in New Issue
Block a user