diff --git a/Overlord-Server/public/assets/data.js b/Overlord-Server/public/assets/data.js
index 46c7f5f..72a11ad 100644
--- a/Overlord-Server/public/assets/data.js
+++ b/Overlord-Server/public/assets/data.js
@@ -57,6 +57,7 @@ export async function loadWithOptions(options = {}) {
status: state.filterStatus || "all",
os: state.filterOs || "all",
country: state.filterCountry || "all",
+ group: state.filterGroup || "all",
});
const res = await fetch(`/api/clients?${params.toString()}`);
if (!res.ok) throw new Error(`status ${res.status}`);
diff --git a/Overlord-Server/public/assets/main.css b/Overlord-Server/public/assets/main.css
index 299b651..15f332f 100644
--- a/Overlord-Server/public/assets/main.css
+++ b/Overlord-Server/public/assets/main.css
@@ -1781,7 +1781,7 @@ mark.search-current {
#grid article.card {
position: relative;
overflow: hidden;
- border-color: rgba(71, 85, 105, 0.55) !important;
+ border-color: var(--group-color, rgba(71, 85, 105, 0.55)) !important;
backdrop-filter: blur(4px);
transition:
transform 170ms ease,
@@ -1791,10 +1791,11 @@ mark.search-current {
#grid article.card:hover {
transform: translateY(-2px);
- border-color: rgba(99, 102, 241, 0.45) !important;
+ border-color: var(--group-color, rgba(99, 102, 241, 0.45)) !important;
box-shadow:
0 18px 36px rgba(2, 6, 23, 0.45),
- 0 0 0 1px rgba(99, 102, 241, 0.14);
+ 0 0 0 1px color-mix(in srgb, var(--group-color, rgba(99, 102, 241, 0.14)) 40%, transparent),
+ 0 0 20px 4px color-mix(in srgb, var(--group-color, transparent) 30%, transparent);
}
/* Cohesive UI polish layer */
diff --git a/Overlord-Server/public/assets/main.js b/Overlord-Server/public/assets/main.js
index 2c75bf2..351c508 100644
--- a/Overlord-Server/public/assets/main.js
+++ b/Overlord-Server/public/assets/main.js
@@ -22,6 +22,7 @@ const searchInput = document.getElementById("search");
const sortSelect = document.getElementById("sort");
const filterStatusSelect = document.getElementById("filter-status");
const filterOsSelect = document.getElementById("filter-os");
+const filterGroupSelect = document.getElementById("filter-group");
const showOfflineToggle = document.getElementById("toggle-offline");
const selectAllBtn = document.getElementById("select-all");
const clearSelectionBtn = document.getElementById("clear-selection");
@@ -45,6 +46,7 @@ const PREF_FILTER_STATUS_KEY = "overlord_filter_status";
const PREF_SORT_KEY = "overlord_sort";
const PREF_FILTER_OS_KEY = "overlord_filter_os";
const PREF_FILTER_COUNTRY_KEY = "overlord_filter_country";
+const PREF_FILTER_GROUP_KEY = "overlord_filter_group";
let currentUser = null;
let contextCard = null;
@@ -498,6 +500,7 @@ function initializeRenderer() {
userRole: currentUser?.role,
});
registerRenderer(renderMerge);
+ refreshGroupFilter();
loadWithOptions();
startAutoRefresh();
@@ -588,6 +591,14 @@ filterOsSelect?.addEventListener("change", (e) => {
loadWithOptions({ force: true, reorder: true });
});
+filterGroupSelect?.addEventListener("change", (e) => {
+ state.filterGroup = e.target.value;
+ localStorage.setItem(PREF_FILTER_GROUP_KEY, state.filterGroup);
+ state.page = 1;
+ state.lastDigest = "";
+ loadWithOptions({ force: true, reorder: true });
+});
+
initCountryPicker((code) => {
state.filterCountry = code;
localStorage.setItem(PREF_FILTER_COUNTRY_KEY, code);
@@ -607,7 +618,7 @@ initCountryPicker((code) => {
}
const savedSort = localStorage.getItem(PREF_SORT_KEY);
- const validSorts = ["stable", "last_seen_desc", "host_asc", "ping_asc", "ping_desc", "country_asc", "country_desc"];
+ const validSorts = ["stable", "last_seen_desc", "host_asc", "ping_asc", "ping_desc", "country_asc", "country_desc", "group_asc", "group_desc"];
if (savedSort && validSorts.includes(savedSort)) {
state.sort = savedSort;
if (sortSelect) sortSelect.value = savedSort;
@@ -623,6 +634,12 @@ initCountryPicker((code) => {
if (savedCountry) {
state.filterCountry = savedCountry;
}
+
+ const savedGroup = localStorage.getItem(PREF_FILTER_GROUP_KEY);
+ if (savedGroup) {
+ state.filterGroup = savedGroup;
+ if (filterGroupSelect) filterGroupSelect.value = savedGroup;
+ }
})();
showOfflineToggle?.addEventListener("change", (e) => {
@@ -834,6 +851,148 @@ window.setClientTag = async (clientId, tag, note) => {
}
};
+window.setClientGroup = async (clientId, groupId) => {
+ if (!clientId) return false;
+ try {
+ const res = await fetch(`/api/clients/${clientId}/group`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ groupId }),
+ });
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ alert(data.error || "Failed to update client group");
+ return false;
+ }
+ return true;
+ } catch (err) {
+ console.error(err);
+ alert("Failed to update client group");
+ return false;
+ }
+};
+
+async function loadGroups() {
+ try {
+ const res = await fetch("/api/groups");
+ if (!res.ok) return [];
+ const data = await res.json();
+ return data.groups || [];
+ } catch { return []; }
+}
+
+async function refreshGroupFilter() {
+ const groups = await loadGroups();
+ if (!filterGroupSelect) return groups;
+ const current = filterGroupSelect.value;
+ filterGroupSelect.innerHTML = '';
+ groups.forEach((g) => {
+ const opt = document.createElement("option");
+ opt.value = String(g.id);
+ opt.textContent = g.name;
+ opt.style.color = g.color;
+ filterGroupSelect.appendChild(opt);
+ });
+ filterGroupSelect.value = current;
+ return groups;
+}
+
+async function openGroupPicker(clientId) {
+ const groups = await loadGroups();
+ const card = getClientCard(clientId);
+ const currentGroupId = card?.dataset.groupId || "";
+
+ const overlay = document.createElement("div");
+ overlay.className = "fixed inset-0 z-[10000] flex items-center justify-center bg-black/60";
+ overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); });
+
+ const modal = document.createElement("div");
+ modal.className = "bg-slate-900 border border-slate-700 rounded-xl shadow-2xl p-6 w-[400px] max-h-[80vh] flex flex-col gap-4";
+ modal.innerHTML = `
+
Set Group
+
+
+ `;
+
+ const listEl = modal.querySelector(".group-picker-list");
+
+ function renderList() {
+ listEl.innerHTML = "";
+ const noneBtn = document.createElement("button");
+ noneBtn.className = `w-full text-left px-3 py-2 rounded-lg text-sm flex items-center gap-2 transition-colors ${!currentGroupId ? "bg-slate-700 text-white" : "text-slate-300 hover:bg-slate-800"}`;
+ noneBtn.innerHTML = ' No Group';
+ noneBtn.addEventListener("click", async () => {
+ const ok = await window.setClientGroup(clientId, null);
+ if (ok) setTimeout(() => loadWithOptions({ force: true }), 200);
+ overlay.remove();
+ });
+ listEl.appendChild(noneBtn);
+
+ groups.forEach((g) => {
+ const btn = document.createElement("button");
+ const isActive = String(g.id) === currentGroupId;
+ btn.className = `w-full text-left px-3 py-2 rounded-lg text-sm flex items-center gap-2 transition-colors ${isActive ? "bg-slate-700 text-white" : "text-slate-300 hover:bg-slate-800"}`;
+ btn.innerHTML = ` ${escapeHtml(g.name)}`;
+ btn.addEventListener("click", async () => {
+ const ok = await window.setClientGroup(clientId, g.id);
+ if (ok) setTimeout(() => loadWithOptions({ force: true }), 200);
+ overlay.remove();
+ });
+ listEl.appendChild(btn);
+ });
+ }
+
+ renderList();
+
+ const createBtn = modal.querySelector(".group-create-btn");
+ createBtn.addEventListener("click", async () => {
+ const nameInput = modal.querySelector(".group-new-name");
+ const colorInput = modal.querySelector(".group-new-color");
+ const name = nameInput.value.trim();
+ const color = colorInput.value;
+ if (!name) { nameInput.focus(); return; }
+ try {
+ const res = await fetch("/api/groups", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name, color }),
+ });
+ if (!res.ok) {
+ const d = await res.json().catch(() => ({}));
+ alert(d.error || "Failed to create group");
+ return;
+ }
+ const newGroup = await res.json();
+ groups.push(newGroup);
+ nameInput.value = "";
+ renderList();
+ refreshGroupFilter();
+ } catch (err) {
+ console.error(err);
+ alert("Failed to create group");
+ }
+ });
+
+ overlay.appendChild(modal);
+ document.body.appendChild(overlay);
+ modal.querySelector(".group-new-name").addEventListener("keydown", (e) => {
+ if (e.key === "Enter") createBtn.click();
+ });
+}
+
+function escapeHtml(text) {
+ const div = document.createElement("div");
+ div.textContent = text;
+ return div.innerHTML;
+}
+
window.banClient = async (clientId) => {
if (!clientId) return;
if (!confirm(`Ban IP for ${clientId} and block future connections?`)) return;
@@ -1068,6 +1227,11 @@ menu.addEventListener("click", async (e) => {
}
closeMenu(clearContext);
return;
+ } else if (action === "set-group") {
+ const savedClientId = contextCard;
+ closeMenu(clearContext);
+ openGroupPicker(savedClientId);
+ return;
}
if (!isClientOnline(contextCard)) {
diff --git a/Overlord-Server/public/assets/render.js b/Overlord-Server/public/assets/render.js
index 5d858a8..94ef69c 100644
--- a/Overlord-Server/public/assets/render.js
+++ b/Overlord-Server/public/assets/render.js
@@ -16,7 +16,7 @@ function escapeHtml(text) {
}
function cardDigest(c) {
- return `${c.id}|${!!c.online}|${c.lastSeen}|${c.pingMs}|${c.host}|${c.user}|${c.os}|${c.arch}|${c.version}|${c.monitors}|${c.thumbnail}|${c.country}|${c.nickname}|${c.customTag}|${c.customTagNote}|${!!c.bookmarked}|${!!c.isAdmin}|${c.elevation}|${c.cpu}|${c.gpu}|${c.ram}|${c.hwid}|${c.disconnectReason}|${c.disconnectDetail}|${JSON.stringify(c.permissions)}`;
+ return `${c.id}|${!!c.online}|${c.lastSeen}|${c.pingMs}|${c.host}|${c.user}|${c.os}|${c.arch}|${c.version}|${c.monitors}|${c.thumbnail}|${c.country}|${c.nickname}|${c.customTag}|${c.customTagNote}|${!!c.bookmarked}|${!!c.isAdmin}|${c.elevation}|${c.cpu}|${c.gpu}|${c.ram}|${c.hwid}|${c.disconnectReason}|${c.disconnectDetail}|${JSON.stringify(c.permissions)}|${c.groupId}|${c.groupName}|${c.groupColor}`;
}
export function createRenderer({
@@ -373,6 +373,9 @@ export function createRenderer({
card.dataset.customTag = String(client.customTag || "");
card.dataset.bookmarked = String(!!client.bookmarked);
card.dataset.admin = String(!!client.isAdmin);
+ card.dataset.groupId = String(client.groupId || "");
+ card.dataset.groupName = String(client.groupName || "");
+ card.dataset.groupColor = String(client.groupColor || "");
card._customTagNote = String(client.customTagNote || "");
const os = osBadge(client.os || "unknown");
const arch = archBadge(client.arch || "");
@@ -387,6 +390,8 @@ export function createRenderer({
const hasTagNote = customTag.length > 0 && customTagNote.length > 0;
const isTagNoteExpanded = hasTagNote && wasTagNoteExpanded;
card.dataset.tagNoteExpanded = isTagNoteExpanded ? "true" : "false";
+ const groupName = String(client.groupName || "").trim();
+ const groupColor = String(client.groupColor || "").trim();
const hasHwInfo = !!(client.cpu || client.gpu || client.ram);
const isHwExpanded = hasHwInfo && wasHwExpanded;
card.dataset.hwExpanded = isHwExpanded ? "true" : "false";
@@ -397,7 +402,13 @@ export function createRenderer({
return [...counts.entries()].map(([name, n]) => n > 1 ? `${name} ×${n}` : escapeHtml(name)).join(", ");
};
const gpuHtml = dedupeGpu(client.gpu);
- card.className = `card rounded-xl border ${client.bookmarked ? "border-yellow-600/60" : "border-slate-800"} bg-slate-900/70 p-4 shadow-lg ${client.online ? "" : "card-offline"} tone-${os.tone}`;
+ const borderClass = client.bookmarked ? "border-yellow-600/60" : "border-slate-800";
+ card.className = `card rounded-xl border ${borderClass} bg-slate-900/70 p-4 shadow-lg ${client.online ? "" : "card-offline"} tone-${os.tone}`;
+ if (!client.bookmarked && groupColor) {
+ card.style.setProperty("--group-color", groupColor);
+ } else {
+ card.style.removeProperty("--group-color");
+ }
const cardThumb = client.thumbnail
? (() => {
const wrapper = document.createElement("div");
@@ -429,6 +440,7 @@ export function createRenderer({
${escapeHtml(displayName)}
${nickname && client.host ? ` ${escapeHtml(client.host)}` : ""}
${customTag ? `` : ""}
+ ${groupName ? ` ${escapeHtml(groupName)}` : ""}
${escapeHtml(client.user || "unknown")}
diff --git a/Overlord-Server/public/assets/state.js b/Overlord-Server/public/assets/state.js
index 9b31748..f53f0ac 100644
--- a/Overlord-Server/public/assets/state.js
+++ b/Overlord-Server/public/assets/state.js
@@ -6,6 +6,7 @@ export const state = {
filterStatus: "all",
filterOs: "all",
filterCountry: "all",
+ filterGroup: "all",
lastDigest: "",
isLoading: false,
pendingForce: false,
diff --git a/Overlord-Server/public/assets/ui.js b/Overlord-Server/public/assets/ui.js
index 1530b92..bb95310 100644
--- a/Overlord-Server/public/assets/ui.js
+++ b/Overlord-Server/public/assets/ui.js
@@ -44,6 +44,7 @@ const MENU_GROUPS = [
{ label: "Reconnect", icon: "fa-solid fa-rotate", icolor: "text-slate-300", action: "reconnect" },
{ label: "Set Nickname", icon: "fa-solid fa-signature", icolor: "text-slate-300", action: "set-nickname" },
{ label: "Set Custom Tag", icon: "fa-solid fa-tag", icolor: "text-slate-300", action: "set-custom-tag" },
+ { label: "Set Group", icon: "fa-solid fa-layer-group", icolor: "text-blue-300", action: "set-group" },
{ divider: true },
{ label: "Elevate (macOS)", icon: "fa-solid fa-arrow-up-right-dots", icolor: "text-green-400", action: "elevate" },
{ divider: true },
diff --git a/Overlord-Server/public/index.html b/Overlord-Server/public/index.html
index 96622be..b731efd 100644
--- a/Overlord-Server/public/index.html
+++ b/Overlord-Server/public/index.html
@@ -88,6 +88,13 @@
>
+