allow for plugin badges
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
? `<img src="${escapeHtml(imageUrl)}" alt="">`
|
||||
: `<i class="${escapeHtml(icon || "fa-solid fa-puzzle-piece")}"></i>`;
|
||||
const inner = `${visual}<span>${escapeHtml(label)}</span>`;
|
||||
const attrs = `class="cv-mini-pill cv-plugin-badge cv-plugin-badge-${escapeHtml(tone)}" title="${escapeHtml(title)}"`;
|
||||
return href
|
||||
? `<a ${attrs} href="${escapeHtml(href)}" target="_blank" rel="noopener">${inner}</a>`
|
||||
: `<span ${attrs}>${inner}</span>`;
|
||||
})
|
||||
.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") ? `<span class="cv-os cv-tone-${os.tone}"><i class="fa ${os.icon}"></i> ${escapeHtml(shortOsLabel(client.os))}</span>` : "",
|
||||
@@ -935,6 +984,7 @@ export function createRenderer({
|
||||
${webcamBadgeHtml(client)}
|
||||
${macPermissionBadgeHtml(client)}
|
||||
${client.notificationsMuted ? `<span class="cv-mini-pill cv-pill-muted" title="Notifications muted"><i class="fa-solid fa-bell-slash"></i></span>` : ""}
|
||||
${pluginBadges}
|
||||
</div>
|
||||
${showField("user") ? `<div class="cv-user-line"><i class="fa-solid fa-user"></i> ${escapeHtml(userLine)}</div>` : ""}
|
||||
${meta ? `<div class="cv-meta-line">${meta}</div>` : ""}
|
||||
@@ -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") ? `<i class="fa-solid fa-user"></i> ${escapeHtml(userLine)}` : "",
|
||||
showField("ip") && client.ip ? `<span class="cv-mid">·</span><i class="fa-solid fa-network-wired"></i> ${escapeHtml(client.ip)}` : "",
|
||||
@@ -1016,6 +1067,7 @@ export function createRenderer({
|
||||
${webcamBadgeHtml(client)}
|
||||
${macPermissionBadgeHtml(client)}
|
||||
${client.notificationsMuted ? `<span class="cv-mini-pill cv-pill-muted" title="Notifications muted"><i class="fa-solid fa-bell-slash"></i></span>` : ""}
|
||||
${pluginBadges}
|
||||
</span>
|
||||
${userMeta ? `<span class="cv-user-line cv-mono">${userMeta}</span>` : ""}
|
||||
</div>
|
||||
@@ -1079,6 +1131,7 @@ export function createRenderer({
|
||||
webcamBadgeHtml(client),
|
||||
macPermissionBadgeHtml(client),
|
||||
client.notificationsMuted ? `<span class="cv-mini-pill cv-pill-muted" title="Notifications muted"><i class="fa-solid fa-bell-slash"></i></span>` : "",
|
||||
dashboardBadgesHtml(client),
|
||||
].join("");
|
||||
|
||||
article.innerHTML = `
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user