add grouping and make it obvious

This commit is contained in:
KingKDot
2026-04-14 19:38:27 -05:00
parent e94fe65dbf
commit 1807a94e9f
10 changed files with 460 additions and 34 deletions
+165 -1
View File
@@ -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 = '<option value="all">All Groups</option><option value="none">No Group</option>';
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 = `
<h3 class="text-lg font-semibold text-slate-100 flex items-center gap-2"><i class="fa-solid fa-layer-group text-blue-400"></i> Set Group</h3>
<div class="group-picker-list flex flex-col gap-1 overflow-y-auto max-h-60"></div>
<div class="border-t border-slate-700 pt-3">
<p class="text-xs text-slate-400 mb-2">Create new group</p>
<div class="flex gap-2">
<input type="text" class="group-new-name flex-1 bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-100 outline-none focus:border-blue-500" placeholder="Group name" maxlength="64" />
<input type="color" class="group-new-color w-10 h-10 rounded-lg border border-slate-700 bg-slate-800 cursor-pointer" value="#3b82f6" />
<button class="group-create-btn px-3 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium">Create</button>
</div>
</div>
`;
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 = '<i class="fa-solid fa-xmark text-slate-500"></i> 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 = `<span class="inline-block w-3 h-3 rounded-full flex-shrink-0" style="background:${g.color}"></span> ${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)) {