allow for plugin badges

This commit is contained in:
KingKDot
2026-06-25 11:45:28 -05:00
parent a51c465227
commit 4b3fd52bc5
9 changed files with 320 additions and 5 deletions
+16
View File
@@ -352,10 +352,26 @@ export type PluginManifest = {
label?: string;
icon?: string;
};
dashboard?: PluginDashboardIntegration;
build?: PluginBuildIntegration;
hasServer?: boolean;
};
export type PluginDashboardIntegration = {
clientBadges?: PluginDashboardBadgeDefinition[];
};
export type PluginDashboardBadgeDefinition = {
id: string;
label?: string;
title?: string;
icon?: string;
imageUrl?: string;
href?: string;
tone?: "info" | "good" | "warn" | "danger" | string;
priority?: number;
};
export type PluginBuildIntegration = {
enabledByDefault?: boolean;
label?: string;
@@ -96,6 +96,39 @@ describe("loadPluginBundle", () => {
expect(bundle.binaryPath).toBe(join(root, "plink", "plink-windows-amd64.dll"));
});
test("preserves dashboard badge metadata from config", async () => {
const root = await createTempRoot();
const AdmZip = require("adm-zip");
const zip = new AdmZip();
zip.addFile("config.json", Buffer.from(JSON.stringify({
name: "Dashboard Demo",
apiVersion: 2,
runtime: "server",
serverEntry: "src/server.ts",
dashboard: {
clientBadges: [{
id: "phone-link",
label: "Phone Link",
icon: "fa-solid fa-mobile-screen-button",
imageUrl: "/plugins/demo/assets/icon.png",
tone: "good",
priority: 90,
}],
},
})));
zip.addFile("dashboard-demo.html", Buffer.from("<main id=\"dashboard-demo\"></main>"));
zip.addFile("dashboard-demo.css", Buffer.from("#dashboard-demo { display: block; }"));
zip.addFile("src/ui.ts", Buffer.from("document.body.dataset.dashboardDemo = '1';"));
zip.addFile("src/server.ts", Buffer.from("export default { rpc: {} };"));
zip.writeZip(join(root, "dashboard-demo.zip"));
await ensurePluginExtracted(root, "dashboard-demo", (name) => name);
const manifest = JSON.parse(await readFile(join(root, "dashboard-demo", "manifest.json"), "utf-8"));
expect(manifest.dashboard.clientBadges[0].id).toBe("phone-link");
expect(manifest.dashboard.clientBadges[0].imageUrl).toBe("/plugins/demo/assets/icon.png");
});
test("computes stable need hashes and invalidates changed approvals", () => {
const state: PluginState = {
enabled: {},
@@ -220,6 +220,7 @@ export async function ensurePluginExtracted(
js: `${safeId}.js`,
},
...(extraConfig.navbar && { navbar: extraConfig.navbar }),
...(extraConfig.dashboard && { dashboard: extraConfig.dashboard }),
...(build && { build }),
hasServer: serverEntry !== null || !!serverTsEntry,
};
@@ -26,6 +26,7 @@ type PluginManifest = {
wasm?: string;
needs?: any;
needsHash?: string;
dashboard?: any;
};
type PluginBundle = {
@@ -186,6 +187,55 @@ export async function handlePluginRoutes(
return Response.json({ plugins: enriched });
}
if (req.method === "POST" && url.pathname === "/api/plugins/dashboard-contributions") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
let body: any = {};
try { body = await req.json(); } catch {}
const requestedClientIds = Array.isArray(body.clientIds)
? body.clientIds.map((id: unknown) => String(id || "").trim()).filter(Boolean).slice(0, 500)
: [];
const clientIdSet = new Set(requestedClientIds);
const manifests = await deps.listPluginManifests();
const plugins = manifests
.filter((p) => deps.pluginState.enabled[p.id] !== false)
.filter((p) => canUserAccessPlugin(user.userId, user.role, p.id))
.filter((p) => p.dashboard && typeof p.dashboard === "object");
const contributions: Array<{ pluginId: string; clientId: string; badges: any[] }> = [];
for (const plugin of plugins) {
if (!deps.pluginRuntime.isRunning(plugin.id)) continue;
try {
const result: any = await deps.pluginRuntime.rpc(
plugin.id,
"dashboardContributions",
{ clientIds: requestedClientIds },
{ id: user.userId, role: user.role },
);
const rows = Array.isArray(result?.contributions) ? result.contributions : Array.isArray(result) ? result : [];
for (const row of rows) {
const clientId = String(row?.clientId || "").trim();
if (!clientId || (clientIdSet.size && !clientIdSet.has(clientId))) continue;
const badges = Array.isArray(row?.badges) ? row.badges.slice(0, 8) : [];
if (!badges.length) continue;
contributions.push({ pluginId: plugin.id, clientId, badges });
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (!message.includes("Unknown RPC method")) {
logger.debug(`[plugin:${plugin.id}] dashboard contributions skipped: ${message}`);
}
}
}
return Response.json({
plugins: plugins.map((p) => ({ id: p.id, name: p.name, dashboard: p.dashboard })),
contributions,
});
}
if (req.method === "GET" && url.pathname === "/api/plugins/trusted-keys") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
@@ -892,7 +942,7 @@ export async function handlePluginRoutes(
);
}
const target = clientManager.getClient(targetId);
if (!target) return new Response("Not found", { status: 404 });
if (!target) return new Response(`Client not online or not found: ${targetId}`, { status: 404 });
if (deps.isPluginLoaded(targetId, pluginId)) {
return Response.json({ ok: true, alreadyLoaded: true });
}
@@ -989,7 +1039,7 @@ export async function handlePluginRoutes(
return new Response("Forbidden", { status: 403 });
}
const target = clientManager.getClient(targetId);
if (!target) return new Response("Not found", { status: 404 });
if (!target) return new Response(`Client not online or not found: ${targetId}`, { status: 404 });
if (deps.pluginState.enabled[pluginId] === false) {
return Response.json({ ok: false, error: "Plugin disabled" }, { status: 400 });
}
@@ -1062,7 +1112,7 @@ export async function handlePluginRoutes(
return new Response("Forbidden", { status: 403 });
}
const target = clientManager.getClient(targetId);
if (!target) return new Response("Not found", { status: 404 });
if (!target) return new Response(`Client not online or not found: ${targetId}`, { status: 404 });
target.ws.send(
encodeMessage({