From 4b3fd52bc5f96f1d0db80f6df5bbe760c42fe20f Mon Sep 17 00:00:00 2001 From: KingKDot Date: Thu, 25 Jun 2026 11:45:28 -0500 Subject: [PATCH] allow for plugin badges --- .gitignore | 3 +- Overlord-Server/public/assets/main.css | 27 +++++++ Overlord-Server/public/assets/main.js | 72 +++++++++++++++++++ Overlord-Server/public/assets/render.js | 55 +++++++++++++- Overlord-Server/src/protocol.ts | 16 +++++ .../src/server/plugin-state-bundle.test.ts | 33 +++++++++ .../src/server/plugin-state-bundle.ts | 1 + .../src/server/routes/plugin-routes.ts | 56 ++++++++++++++- plugins/docs/bundle-format.md | 62 ++++++++++++++++ 9 files changed, 320 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 82b3d1e..827f6bf 100644 --- a/.gitignore +++ b/.gitignore @@ -514,4 +514,5 @@ FodyWeavers.xsd *.lib save.txt *.wasm -Albedo/* \ No newline at end of file +Albedo/* +plink/* \ No newline at end of file diff --git a/Overlord-Server/public/assets/main.css b/Overlord-Server/public/assets/main.css index 4840708..813aa39 100644 --- a/Overlord-Server/public/assets/main.css +++ b/Overlord-Server/public/assets/main.css @@ -2961,6 +2961,33 @@ tbody tr:hover { .cv-pill-muted { background: rgba(245, 158, 11, 0.12); color: #fcd34d; border-color: rgba(245, 158, 11, 0.3); } .cv-pill-macperm.is-ok { background: rgba(16, 185, 129, 0.12); color: #86efac; border-color: rgba(16, 185, 129, 0.3); } .cv-pill-macperm.is-warn { background: rgba(245, 158, 11, 0.12); color: #fcd34d; border-color: rgba(245, 158, 11, 0.3); } +.cv-plugin-badge { + width: auto; + min-width: 18px; + gap: 4px; + padding: 0 5px; + color: #bfdbfe; + background: rgba(59, 130, 246, 0.12); + border-color: rgba(59, 130, 246, 0.3); + text-decoration: none; + cursor: pointer; +} +.cv-plugin-badge span { + max-width: 74px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.cv-plugin-badge img { + width: 13px; + height: 13px; + object-fit: contain; + border-radius: 3px; +} +.cv-plugin-badge-good { color: #86efac; background: rgba(16, 185, 129, 0.12); border-color: rgba(16, 185, 129, 0.3); } +.cv-plugin-badge-warn { color: #fcd34d; background: rgba(245, 158, 11, 0.12); border-color: rgba(245, 158, 11, 0.3); } +.cv-plugin-badge-danger { color: #fca5a5; background: rgba(239, 68, 68, 0.12); border-color: rgba(239, 68, 68, 0.3); } +.cv-plugin-badge:hover { filter: brightness(1.18); } .cv-perm-list { display: flex; diff --git a/Overlord-Server/public/assets/main.js b/Overlord-Server/public/assets/main.js index 06a9f15..7de7ca8 100644 --- a/Overlord-Server/public/assets/main.js +++ b/Overlord-Server/public/assets/main.js @@ -139,6 +139,9 @@ const clearContext = () => { let _pluginCache = null; let _pluginCacheAge = 0; const PLUGIN_CACHE_TTL = 30_000; +const pluginDashboardBadges = new Map(); +let pluginDashboardDigest = ""; +let pluginDashboardRefreshInFlight = false; async function refreshPluginCache() { try { @@ -157,6 +160,71 @@ function getCachedPlugins() { return null; } +function dashboardBadgeHref(href, clientId, pluginId) { + const raw = String(href || `/plugins/${encodeURIComponent(pluginId)}?clientId=${encodeURIComponent(clientId)}`); + return raw + .replaceAll("{clientId}", encodeURIComponent(clientId)) + .replaceAll("{pluginId}", encodeURIComponent(pluginId)); +} + +function getDashboardBadgesForClient(client) { + return pluginDashboardBadges.get(client?.id || "") || []; +} + +async function refreshDashboardPluginContributions(items = []) { + if (pluginDashboardRefreshInFlight) return; + const clientIds = items.map((item) => item?.id).filter(Boolean); + if (!clientIds.length) { + if (pluginDashboardBadges.size) { + pluginDashboardBadges.clear(); + pluginDashboardDigest = ""; + renderCachedClients({ force: true, fromPluginDashboard: true }); + } + return; + } + pluginDashboardRefreshInFlight = true; + try { + const res = await fetch("/api/plugins/dashboard-contributions", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ clientIds }), + }); + if (!res.ok) return; + const data = await res.json(); + const pluginNames = new Map((Array.isArray(data.plugins) ? data.plugins : []).map((p) => [p.id, p.name || p.id])); + const next = new Map(); + for (const row of Array.isArray(data.contributions) ? data.contributions : []) { + const clientId = String(row?.clientId || ""); + const pluginId = String(row?.pluginId || ""); + if (!clientId || !pluginId || !clientIds.includes(clientId)) continue; + const badges = Array.isArray(row.badges) ? row.badges : []; + const current = next.get(clientId) || []; + for (const badge of badges) { + const id = String(badge?.id || pluginId || "plugin"); + current.push({ + ...badge, + id: `${pluginId}:${id}`, + pluginId, + pluginName: pluginNames.get(pluginId) || pluginId, + href: dashboardBadgeHref(badge?.href, clientId, pluginId), + }); + } + next.set(clientId, current); + } + const nextDigest = JSON.stringify(Array.from(next.entries()).sort(([a], [b]) => a.localeCompare(b))); + if (nextDigest === pluginDashboardDigest) return; + pluginDashboardDigest = nextDigest; + pluginDashboardBadges.clear(); + for (const [clientId, badges] of next.entries()) pluginDashboardBadges.set(clientId, badges); + renderCachedClients({ force: true, fromPluginDashboard: true }); + } catch { + // Dashboard badges are decorative; leave the last known state in place. + } finally { + pluginDashboardRefreshInFlight = false; + } +} + function detectClientPlatform(clientId) { if (!clientId) { return "unknown"; @@ -1031,11 +1099,15 @@ function initializeRenderer() { userRole: currentUser?.role, getServerVersion: () => currentServerVersion, getDisplayFields: () => displayFields, + getDashboardBadges: getDashboardBadgesForClient, }); rendererSetLayout = rSetLayout; registerRenderer((data, options) => { renderMerge(data, options); updateDashboardStatsFromClients(data); + if (!options?.fromPluginDashboard) { + refreshDashboardPluginContributions(data?.items || []); + } }); initDashboardStats(); setupDashboardThumbnailLoader(); diff --git a/Overlord-Server/public/assets/render.js b/Overlord-Server/public/assets/render.js index 919d940..3f76214 100644 --- a/Overlord-Server/public/assets/render.js +++ b/Overlord-Server/public/assets/render.js @@ -328,6 +328,7 @@ export function createRenderer({ userRole, getServerVersion, getDisplayFields, + getDashboardBadges, }) { const isViewer = userRole === "viewer"; const LARGE_CLIENT_THRESHOLD = 50_000; @@ -510,6 +511,11 @@ export function createRenderer({ return; } + if (e.target.closest(".cv-plugin-badge")) { + e.stopPropagation(); + return; + } + const thumbImg = e.target.closest(".thumb-img"); if (thumbImg) { if (thumbImg.src) openModal(thumbImg.src); @@ -661,8 +667,50 @@ export function createRenderer({ }); } + function dashboardBadges(client) { + if (typeof getDashboardBadges !== "function") return []; + const badges = getDashboardBadges(client) || []; + return Array.isArray(badges) ? badges.slice(0, 8) : []; + } + + function dashboardBadgesDigest(client) { + return JSON.stringify(dashboardBadges(client).map((badge) => ({ + id: badge.id, + pluginId: badge.pluginId, + label: badge.label, + title: badge.title, + icon: badge.icon, + imageUrl: badge.imageUrl, + href: badge.href, + tone: badge.tone, + priority: badge.priority, + }))); + } + + function dashboardBadgesHtml(client) { + return dashboardBadges(client) + .sort((a, b) => (Number(b.priority) || 0) - (Number(a.priority) || 0)) + .map((badge) => { + const label = String(badge.label || badge.title || badge.pluginId || "Plugin"); + const title = String(badge.title || label); + const tone = String(badge.tone || "info").replace(/[^a-z0-9_-]/gi, "").toLowerCase() || "info"; + const icon = String(badge.icon || "").replace(/[^a-z0-9_\- ]/gi, "").trim(); + const imageUrl = String(badge.imageUrl || "").trim(); + const href = String(badge.href || "").trim(); + const visual = imageUrl + ? `` + : ``; + const inner = `${visual}${escapeHtml(label)}`; + const attrs = `class="cv-mini-pill cv-plugin-badge cv-plugin-badge-${escapeHtml(tone)}" title="${escapeHtml(title)}"`; + return href + ? `${inner}` + : `${inner}`; + }) + .join(""); + } + function cardDigest(c) { - return `${currentDisplayDigest}|${currentLayout}|${c.id}|${!!c.online}|${c.lastSeen}|${c.pingMs}|${c.host}|${c.user}|${c.os}|${c.arch}|${c.version}|${c.monitors}|${c.country}|${c.nickname}|${c.customTag}|${c.customTagNote}|${!!c.bookmarked}|${!!c.isAdmin}|${c.elevation}|${JSON.stringify(c.permissions || {})}|${c.cpu}|${c.gpu}|${c.ram}|${c.batteryPercent}|${c.batteryCharging}|${!!c.webcamAvailable}|${JSON.stringify(c.webcamDevices || [])}|${c.hwid}|${c.disconnectReason}|${c.disconnectDetail}|${c.groupId}|${c.groupName}|${c.groupColor}|${!!c.notificationsMuted}`; + return `${currentDisplayDigest}|${currentLayout}|${dashboardBadgesDigest(c)}|${c.id}|${!!c.online}|${c.lastSeen}|${c.pingMs}|${c.host}|${c.user}|${c.os}|${c.arch}|${c.version}|${c.monitors}|${c.country}|${c.nickname}|${c.customTag}|${c.customTagNote}|${!!c.bookmarked}|${!!c.isAdmin}|${c.elevation}|${JSON.stringify(c.permissions || {})}|${c.cpu}|${c.gpu}|${c.ram}|${c.batteryPercent}|${c.batteryCharging}|${!!c.webcamAvailable}|${JSON.stringify(c.webcamDevices || [])}|${c.hwid}|${c.disconnectReason}|${c.disconnectDetail}|${c.groupId}|${c.groupName}|${c.groupColor}|${!!c.notificationsMuted}`; } function cardThumbDigest(c) { @@ -901,6 +949,7 @@ export function createRenderer({ const cpuHtml = client.cpu ? cpuBadgeHtml(client.cpu) : "—"; const batteryIndicator = showBattery ? batteryHtml(client) : ""; const verLatest = isClientVersionCurrent(client.version, typeof getServerVersion === "function" ? getServerVersion() : ""); + const pluginBadges = dashboardBadgesHtml(client); const metaParts = [ showField("system") ? ` ${escapeHtml(shortOsLabel(client.os))}` : "", @@ -935,6 +984,7 @@ export function createRenderer({ ${webcamBadgeHtml(client)} ${macPermissionBadgeHtml(client)} ${client.notificationsMuted ? `` : ""} + ${pluginBadges} ${showField("user") ? `
${escapeHtml(userLine)}
` : ""} ${meta ? `
${meta}
` : ""} @@ -985,6 +1035,7 @@ export function createRenderer({ const nickname = String(client.nickname || "").trim(); const displayName = nickname || client.host || deviceId; const userLine = client.user || client.host || deviceId; + const pluginBadges = dashboardBadgesHtml(client); const userMeta = [ showField("user") ? ` ${escapeHtml(userLine)}` : "", showField("ip") && client.ip ? `· ${escapeHtml(client.ip)}` : "", @@ -1016,6 +1067,7 @@ export function createRenderer({ ${webcamBadgeHtml(client)} ${macPermissionBadgeHtml(client)} ${client.notificationsMuted ? `` : ""} + ${pluginBadges} ${userMeta ? `${userMeta}` : ""} @@ -1079,6 +1131,7 @@ export function createRenderer({ webcamBadgeHtml(client), macPermissionBadgeHtml(client), client.notificationsMuted ? `` : "", + dashboardBadgesHtml(client), ].join(""); article.innerHTML = ` diff --git a/Overlord-Server/src/protocol.ts b/Overlord-Server/src/protocol.ts index 5c99140..256c30f 100644 --- a/Overlord-Server/src/protocol.ts +++ b/Overlord-Server/src/protocol.ts @@ -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; diff --git a/Overlord-Server/src/server/plugin-state-bundle.test.ts b/Overlord-Server/src/server/plugin-state-bundle.test.ts index 3c91513..b406916 100644 --- a/Overlord-Server/src/server/plugin-state-bundle.test.ts +++ b/Overlord-Server/src/server/plugin-state-bundle.test.ts @@ -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("
")); + 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: {}, diff --git a/Overlord-Server/src/server/plugin-state-bundle.ts b/Overlord-Server/src/server/plugin-state-bundle.ts index facf4ea..e3e7171 100644 --- a/Overlord-Server/src/server/plugin-state-bundle.ts +++ b/Overlord-Server/src/server/plugin-state-bundle.ts @@ -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, }; diff --git a/Overlord-Server/src/server/routes/plugin-routes.ts b/Overlord-Server/src/server/routes/plugin-routes.ts index 98d1905..662544c 100644 --- a/Overlord-Server/src/server/routes/plugin-routes.ts +++ b/Overlord-Server/src/server/routes/plugin-routes.ts @@ -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({ diff --git a/plugins/docs/bundle-format.md b/plugins/docs/bundle-format.md index 9e5356c..25542bf 100644 --- a/plugins/docs/bundle-format.md +++ b/plugins/docs/bundle-format.md @@ -67,6 +67,68 @@ Server-only plugins are not sent to clients and do not use auto-load. `navbar.icon` accepts a Font Awesome 6 solid icon class such as `fa-cube`, `fa-key`, or `fa-network-wired`. If the plugin is enabled, navbar plugins appear in the Plugin Apps group and open at `/plugins/`. +## Dashboard Badges + +Plugins can contribute compact badges to client cards on the main dashboard. Declare the dashboard integration in `config.json`: + +```json +{ + "dashboard": { + "clientBadges": [ + { + "id": "phone-link", + "label": "Phone Link", + "title": "Phone Link detected", + "icon": "fa-solid fa-mobile-screen-button", + "imageUrl": "/plugins/example/assets/phone-link.png", + "tone": "good", + "priority": 90 + } + ] + } +} +``` + +Badge fields: + +| Field | Description | +|-------|-------------| +| `id` | Stable badge id within the plugin. | +| `label` | Short text shown beside the icon when space allows. | +| `title` | Hover text. Defaults to `label`. | +| `icon` | Font Awesome class, used when `imageUrl` is omitted. | +| `imageUrl` | Optional image URL or data URL. Rendered as a small dashboard icon. | +| `href` | Optional link. `{clientId}` and `{pluginId}` are URL-encoded before rendering. Defaults to `/plugins/?clientId=`. | +| `tone` | Visual tone: `info`, `good`, `warn`, or `danger`. | +| `priority` | Higher numbers render first when multiple plugin badges exist. | + +For per-client state, implement a server plugin RPC named `dashboardContributions`. The dashboard calls it with the visible client IDs: + +```ts +export default { + rpc: { + async dashboardContributions(ctx, params: { clientIds: string[] }) { + return { + contributions: params.clientIds.map((clientId) => ({ + clientId, + badges: [{ + id: "phone-link", + label: "Phone Link", + title: "Phone Link detected", + icon: "fa-solid fa-mobile-screen-button", + tone: "good", + href: "/plugins/example?clientId={clientId}", + priority: 90 + }] + })) + }; + } + } +}; +``` + +Return no contribution for clients that should not show a badge. Server plugins usually update their own plugin database from `onEvent` and have `dashboardContributions` read that state. + ## TypeScript UI And Server Logic Plugin bundles can ship source-oriented browser and server logic. The server compiles these during extraction: