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") ? `