add grouping and make it obvious
This commit is contained in:
@@ -57,6 +57,7 @@ export async function loadWithOptions(options = {}) {
|
|||||||
status: state.filterStatus || "all",
|
status: state.filterStatus || "all",
|
||||||
os: state.filterOs || "all",
|
os: state.filterOs || "all",
|
||||||
country: state.filterCountry || "all",
|
country: state.filterCountry || "all",
|
||||||
|
group: state.filterGroup || "all",
|
||||||
});
|
});
|
||||||
const res = await fetch(`/api/clients?${params.toString()}`);
|
const res = await fetch(`/api/clients?${params.toString()}`);
|
||||||
if (!res.ok) throw new Error(`status ${res.status}`);
|
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||||
|
|||||||
@@ -1781,7 +1781,7 @@ mark.search-current {
|
|||||||
#grid article.card {
|
#grid article.card {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
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);
|
backdrop-filter: blur(4px);
|
||||||
transition:
|
transition:
|
||||||
transform 170ms ease,
|
transform 170ms ease,
|
||||||
@@ -1791,10 +1791,11 @@ mark.search-current {
|
|||||||
|
|
||||||
#grid article.card:hover {
|
#grid article.card:hover {
|
||||||
transform: translateY(-2px);
|
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:
|
box-shadow:
|
||||||
0 18px 36px rgba(2, 6, 23, 0.45),
|
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 */
|
/* Cohesive UI polish layer */
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const searchInput = document.getElementById("search");
|
|||||||
const sortSelect = document.getElementById("sort");
|
const sortSelect = document.getElementById("sort");
|
||||||
const filterStatusSelect = document.getElementById("filter-status");
|
const filterStatusSelect = document.getElementById("filter-status");
|
||||||
const filterOsSelect = document.getElementById("filter-os");
|
const filterOsSelect = document.getElementById("filter-os");
|
||||||
|
const filterGroupSelect = document.getElementById("filter-group");
|
||||||
const showOfflineToggle = document.getElementById("toggle-offline");
|
const showOfflineToggle = document.getElementById("toggle-offline");
|
||||||
const selectAllBtn = document.getElementById("select-all");
|
const selectAllBtn = document.getElementById("select-all");
|
||||||
const clearSelectionBtn = document.getElementById("clear-selection");
|
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_SORT_KEY = "overlord_sort";
|
||||||
const PREF_FILTER_OS_KEY = "overlord_filter_os";
|
const PREF_FILTER_OS_KEY = "overlord_filter_os";
|
||||||
const PREF_FILTER_COUNTRY_KEY = "overlord_filter_country";
|
const PREF_FILTER_COUNTRY_KEY = "overlord_filter_country";
|
||||||
|
const PREF_FILTER_GROUP_KEY = "overlord_filter_group";
|
||||||
|
|
||||||
let currentUser = null;
|
let currentUser = null;
|
||||||
let contextCard = null;
|
let contextCard = null;
|
||||||
@@ -498,6 +500,7 @@ function initializeRenderer() {
|
|||||||
userRole: currentUser?.role,
|
userRole: currentUser?.role,
|
||||||
});
|
});
|
||||||
registerRenderer(renderMerge);
|
registerRenderer(renderMerge);
|
||||||
|
refreshGroupFilter();
|
||||||
loadWithOptions();
|
loadWithOptions();
|
||||||
startAutoRefresh();
|
startAutoRefresh();
|
||||||
|
|
||||||
@@ -588,6 +591,14 @@ filterOsSelect?.addEventListener("change", (e) => {
|
|||||||
loadWithOptions({ force: true, reorder: true });
|
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) => {
|
initCountryPicker((code) => {
|
||||||
state.filterCountry = code;
|
state.filterCountry = code;
|
||||||
localStorage.setItem(PREF_FILTER_COUNTRY_KEY, code);
|
localStorage.setItem(PREF_FILTER_COUNTRY_KEY, code);
|
||||||
@@ -607,7 +618,7 @@ initCountryPicker((code) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const savedSort = localStorage.getItem(PREF_SORT_KEY);
|
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)) {
|
if (savedSort && validSorts.includes(savedSort)) {
|
||||||
state.sort = savedSort;
|
state.sort = savedSort;
|
||||||
if (sortSelect) sortSelect.value = savedSort;
|
if (sortSelect) sortSelect.value = savedSort;
|
||||||
@@ -623,6 +634,12 @@ initCountryPicker((code) => {
|
|||||||
if (savedCountry) {
|
if (savedCountry) {
|
||||||
state.filterCountry = 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) => {
|
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) => {
|
window.banClient = async (clientId) => {
|
||||||
if (!clientId) return;
|
if (!clientId) return;
|
||||||
if (!confirm(`Ban IP for ${clientId} and block future connections?`)) return;
|
if (!confirm(`Ban IP for ${clientId} and block future connections?`)) return;
|
||||||
@@ -1068,6 +1227,11 @@ menu.addEventListener("click", async (e) => {
|
|||||||
}
|
}
|
||||||
closeMenu(clearContext);
|
closeMenu(clearContext);
|
||||||
return;
|
return;
|
||||||
|
} else if (action === "set-group") {
|
||||||
|
const savedClientId = contextCard;
|
||||||
|
closeMenu(clearContext);
|
||||||
|
openGroupPicker(savedClientId);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isClientOnline(contextCard)) {
|
if (!isClientOnline(contextCard)) {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ function escapeHtml(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cardDigest(c) {
|
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({
|
export function createRenderer({
|
||||||
@@ -373,6 +373,9 @@ export function createRenderer({
|
|||||||
card.dataset.customTag = String(client.customTag || "");
|
card.dataset.customTag = String(client.customTag || "");
|
||||||
card.dataset.bookmarked = String(!!client.bookmarked);
|
card.dataset.bookmarked = String(!!client.bookmarked);
|
||||||
card.dataset.admin = String(!!client.isAdmin);
|
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 || "");
|
card._customTagNote = String(client.customTagNote || "");
|
||||||
const os = osBadge(client.os || "unknown");
|
const os = osBadge(client.os || "unknown");
|
||||||
const arch = archBadge(client.arch || "");
|
const arch = archBadge(client.arch || "");
|
||||||
@@ -387,6 +390,8 @@ export function createRenderer({
|
|||||||
const hasTagNote = customTag.length > 0 && customTagNote.length > 0;
|
const hasTagNote = customTag.length > 0 && customTagNote.length > 0;
|
||||||
const isTagNoteExpanded = hasTagNote && wasTagNoteExpanded;
|
const isTagNoteExpanded = hasTagNote && wasTagNoteExpanded;
|
||||||
card.dataset.tagNoteExpanded = isTagNoteExpanded ? "true" : "false";
|
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 hasHwInfo = !!(client.cpu || client.gpu || client.ram);
|
||||||
const isHwExpanded = hasHwInfo && wasHwExpanded;
|
const isHwExpanded = hasHwInfo && wasHwExpanded;
|
||||||
card.dataset.hwExpanded = isHwExpanded ? "true" : "false";
|
card.dataset.hwExpanded = isHwExpanded ? "true" : "false";
|
||||||
@@ -397,7 +402,13 @@ export function createRenderer({
|
|||||||
return [...counts.entries()].map(([name, n]) => n > 1 ? `${name} <span class="hw-gpu-count">×${n}</span>` : escapeHtml(name)).join(", ");
|
return [...counts.entries()].map(([name, n]) => n > 1 ? `${name} <span class="hw-gpu-count">×${n}</span>` : escapeHtml(name)).join(", ");
|
||||||
};
|
};
|
||||||
const gpuHtml = dedupeGpu(client.gpu);
|
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 cardThumb = client.thumbnail
|
||||||
? (() => {
|
? (() => {
|
||||||
const wrapper = document.createElement("div");
|
const wrapper = document.createElement("div");
|
||||||
@@ -429,6 +440,7 @@ export function createRenderer({
|
|||||||
<span>${escapeHtml(displayName)}</span>
|
<span>${escapeHtml(displayName)}</span>
|
||||||
${nickname && client.host ? `<span class="pill pill-ghost text-xs"><i class="fa-solid fa-laptop"></i> ${escapeHtml(client.host)}</span>` : ""}
|
${nickname && client.host ? `<span class="pill pill-ghost text-xs"><i class="fa-solid fa-laptop"></i> ${escapeHtml(client.host)}</span>` : ""}
|
||||||
${customTag ? `<button type="button" class="client-tag-toggle pill text-xs border border-amber-700/80 bg-amber-900/30 text-amber-200 ${hasTagNote ? "cursor-pointer hover:bg-amber-800/40" : "cursor-default opacity-90"}" ${hasTagNote ? `aria-expanded="${isTagNoteExpanded ? "true" : "false"}"` : `disabled aria-disabled="true"`}><i class="fa-solid fa-tag"></i> ${escapeHtml(customTag)} ${customTagNote ? `<i class="fa-regular fa-note-sticky"></i><i class="fa-solid ${isTagNoteExpanded ? "fa-chevron-up" : "fa-chevron-down"}"></i>` : ""}</button>` : ""}
|
${customTag ? `<button type="button" class="client-tag-toggle pill text-xs border border-amber-700/80 bg-amber-900/30 text-amber-200 ${hasTagNote ? "cursor-pointer hover:bg-amber-800/40" : "cursor-default opacity-90"}" ${hasTagNote ? `aria-expanded="${isTagNoteExpanded ? "true" : "false"}"` : `disabled aria-disabled="true"`}><i class="fa-solid fa-tag"></i> ${escapeHtml(customTag)} ${customTagNote ? `<i class="fa-regular fa-note-sticky"></i><i class="fa-solid ${isTagNoteExpanded ? "fa-chevron-up" : "fa-chevron-down"}"></i>` : ""}</button>` : ""}
|
||||||
|
${groupName ? `<span class="pill text-xs border" style="border-color:${escapeHtml(groupColor)};background:${escapeHtml(groupColor)}22;color:${escapeHtml(groupColor)}"><i class="fa-solid fa-layer-group"></i> ${escapeHtml(groupName)}</span>` : ""}
|
||||||
<span class="text-slate-300 text-lg font-semibold flex items-center gap-1"><i class="fa-solid fa-user"></i> ${escapeHtml(client.user || "unknown")}</span>
|
<span class="text-slate-300 text-lg font-semibold flex items-center gap-1"><i class="fa-solid fa-user"></i> ${escapeHtml(client.user || "unknown")}</span>
|
||||||
<span class="pill ${client.online ? "pill-online" : "pill-offline"}">
|
<span class="pill ${client.online ? "pill-online" : "pill-offline"}">
|
||||||
<i class="fa-solid fa-circle"></i>
|
<i class="fa-solid fa-circle"></i>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const state = {
|
|||||||
filterStatus: "all",
|
filterStatus: "all",
|
||||||
filterOs: "all",
|
filterOs: "all",
|
||||||
filterCountry: "all",
|
filterCountry: "all",
|
||||||
|
filterGroup: "all",
|
||||||
lastDigest: "",
|
lastDigest: "",
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
pendingForce: false,
|
pendingForce: false,
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const MENU_GROUPS = [
|
|||||||
{ label: "Reconnect", icon: "fa-solid fa-rotate", icolor: "text-slate-300", action: "reconnect" },
|
{ 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 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 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 },
|
{ divider: true },
|
||||||
{ label: "Elevate (macOS)", icon: "fa-solid fa-arrow-up-right-dots", icolor: "text-green-400", action: "elevate" },
|
{ label: "Elevate (macOS)", icon: "fa-solid fa-arrow-up-right-dots", icolor: "text-green-400", action: "elevate" },
|
||||||
{ divider: true },
|
{ divider: true },
|
||||||
|
|||||||
@@ -88,6 +88,13 @@
|
|||||||
>
|
>
|
||||||
<option value="all">All OS</option>
|
<option value="all">All OS</option>
|
||||||
</select>
|
</select>
|
||||||
|
<select
|
||||||
|
id="filter-group"
|
||||||
|
class="bg-slate-900/70 border border-slate-800 rounded-lg px-3 py-2 text-slate-100"
|
||||||
|
>
|
||||||
|
<option value="all">All Groups</option>
|
||||||
|
<option value="none">No Group</option>
|
||||||
|
</select>
|
||||||
<div id="country-picker-anchor" class="relative"></div>
|
<div id="country-picker-anchor" class="relative"></div>
|
||||||
<select
|
<select
|
||||||
id="sort"
|
id="sort"
|
||||||
@@ -100,6 +107,8 @@
|
|||||||
<option value="ping_desc">Ping ↓</option>
|
<option value="ping_desc">Ping ↓</option>
|
||||||
<option value="country_asc">Country A→Z</option>
|
<option value="country_asc">Country A→Z</option>
|
||||||
<option value="country_desc">Country Z→A</option>
|
<option value="country_desc">Country Z→A</option>
|
||||||
|
<option value="group_asc">Group A→Z</option>
|
||||||
|
<option value="group_desc">Group Z→A</option>
|
||||||
<option value="admin_first">Admin first</option>
|
<option value="admin_first">Admin first</option>
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
|
|||||||
+131
-27
@@ -124,6 +124,20 @@ try {
|
|||||||
db.run(`ALTER TABLE clients ADD COLUMN permissions TEXT`);
|
db.run(`ALTER TABLE clients ADD COLUMN permissions TEXT`);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS client_groups (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
color TEXT NOT NULL DEFAULT '#3b82f6',
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.run(`ALTER TABLE clients ADD COLUMN group_id INTEGER REFERENCES client_groups(id) ON DELETE SET NULL`);
|
||||||
|
} catch {}
|
||||||
|
db.run(`CREATE INDEX IF NOT EXISTS idx_clients_group_id ON clients(group_id);`);
|
||||||
|
|
||||||
db.run(`
|
db.run(`
|
||||||
CREATE TABLE IF NOT EXISTS banned_ips (
|
CREATE TABLE IF NOT EXISTS banned_ips (
|
||||||
ip TEXT PRIMARY KEY,
|
ip TEXT PRIMARY KEY,
|
||||||
@@ -589,6 +603,75 @@ export function setClientTag(
|
|||||||
return ((result as any)?.changes || 0) > 0;
|
return ((result as any)?.changes || 0) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ClientGroup {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
createdAt: number;
|
||||||
|
clientCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listGroups(): ClientGroup[] {
|
||||||
|
const rows = db.query<any>(
|
||||||
|
`SELECT g.id, g.name, g.color, g.created_at as createdAt,
|
||||||
|
COUNT(c.id) as clientCount
|
||||||
|
FROM client_groups g
|
||||||
|
LEFT JOIN clients c ON c.group_id = g.id
|
||||||
|
GROUP BY g.id
|
||||||
|
ORDER BY g.name ASC`,
|
||||||
|
).all();
|
||||||
|
return rows.map((r: any) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
color: r.color,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
clientCount: r.clientCount ?? 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGroup(id: number): ClientGroup | null {
|
||||||
|
const row = db.query<any>(
|
||||||
|
`SELECT id, name, color, created_at as createdAt FROM client_groups WHERE id=?`,
|
||||||
|
).get(id);
|
||||||
|
return row ? { id: row.id, name: row.name, color: row.color, createdAt: row.createdAt } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGroup(name: string, color: string): ClientGroup {
|
||||||
|
const now = Date.now();
|
||||||
|
const result = db.run(
|
||||||
|
`INSERT INTO client_groups (name, color, created_at) VALUES (?, ?, ?)`,
|
||||||
|
name.trim(),
|
||||||
|
color,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
return { id: Number(result.lastInsertRowid), name: name.trim(), color, createdAt: now };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateGroup(id: number, name: string, color: string): boolean {
|
||||||
|
const result = db.run(
|
||||||
|
`UPDATE client_groups SET name=?, color=? WHERE id=?`,
|
||||||
|
name.trim(),
|
||||||
|
color,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
return ((result as any)?.changes || 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteGroup(id: number): boolean {
|
||||||
|
db.run(`UPDATE clients SET group_id=NULL WHERE group_id=?`, id);
|
||||||
|
const result = db.run(`DELETE FROM client_groups WHERE id=?`, id);
|
||||||
|
return ((result as any)?.changes || 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setClientGroup(clientId: string, groupId: number | null): boolean {
|
||||||
|
const result = db.run(
|
||||||
|
`UPDATE clients SET group_id=? WHERE id=?`,
|
||||||
|
groupId,
|
||||||
|
clientId,
|
||||||
|
);
|
||||||
|
return ((result as any)?.changes || 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
export function getClientIp(id: string): string | null {
|
export function getClientIp(id: string): string | null {
|
||||||
const row = db.query<{ ip: string }>(`SELECT ip FROM clients WHERE id=?`).get(id);
|
const row = db.query<{ ip: string }>(`SELECT ip FROM clients WHERE id=?`).get(id);
|
||||||
return row?.ip || null;
|
return row?.ip || null;
|
||||||
@@ -798,107 +881,125 @@ export function listClients(filters: ListFilters): ListResult {
|
|||||||
requireBuildOwner,
|
requireBuildOwner,
|
||||||
allowedClientIds,
|
allowedClientIds,
|
||||||
deniedClientIds,
|
deniedClientIds,
|
||||||
|
groupFilter,
|
||||||
} = filters;
|
} = filters;
|
||||||
const where: string[] = [];
|
const where: string[] = [];
|
||||||
const params: any[] = [];
|
const params: any[] = [];
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
where.push(
|
where.push(
|
||||||
"(LOWER(COALESCE(host,'')) LIKE ? OR LOWER(COALESCE(user,'')) LIKE ? OR LOWER(COALESCE(nickname,'')) LIKE ? OR LOWER(COALESCE(custom_tag,'')) LIKE ? OR LOWER(COALESCE(custom_tag_note,'')) LIKE ? OR LOWER(id) LIKE ?)",
|
"(LOWER(COALESCE(c.host,'')) LIKE ? OR LOWER(COALESCE(c.user,'')) LIKE ? OR LOWER(COALESCE(c.nickname,'')) LIKE ? OR LOWER(COALESCE(c.custom_tag,'')) LIKE ? OR LOWER(COALESCE(c.custom_tag_note,'')) LIKE ? OR LOWER(c.id) LIKE ? OR LOWER(COALESCE(g.name,'')) LIKE ?)",
|
||||||
);
|
);
|
||||||
const needle = `%${search}%`;
|
const needle = `%${search}%`;
|
||||||
params.push(needle, needle, needle, needle, needle, needle);
|
params.push(needle, needle, needle, needle, needle, needle, needle);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (statusFilter === "online") {
|
if (statusFilter === "online") {
|
||||||
where.push("online=1");
|
where.push("c.online=1");
|
||||||
} else if (statusFilter === "offline") {
|
} else if (statusFilter === "offline") {
|
||||||
where.push("online=0");
|
where.push("c.online=0");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (enrollmentFilter && enrollmentFilter !== "all") {
|
if (enrollmentFilter && enrollmentFilter !== "all") {
|
||||||
if (enrollmentFilter === "pending") {
|
if (enrollmentFilter === "pending") {
|
||||||
where.push("(enrollment_status='pending' OR enrollment_status IS NULL)");
|
where.push("(c.enrollment_status='pending' OR c.enrollment_status IS NULL)");
|
||||||
} else {
|
} else {
|
||||||
where.push("enrollment_status=?");
|
where.push("c.enrollment_status=?");
|
||||||
params.push(enrollmentFilter);
|
params.push(enrollmentFilter);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (osFilter && osFilter !== "all") {
|
if (osFilter && osFilter !== "all") {
|
||||||
where.push("os=?");
|
where.push("c.os=?");
|
||||||
params.push(osFilter);
|
params.push(osFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (countryFilter && countryFilter !== "all") {
|
if (countryFilter && countryFilter !== "all") {
|
||||||
where.push("UPPER(COALESCE(country,'ZZ'))=?");
|
where.push("UPPER(COALESCE(c.country,'ZZ'))=?");
|
||||||
params.push(countryFilter.toUpperCase());
|
params.push(countryFilter.toUpperCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof builtByUserId === "number") {
|
if (typeof builtByUserId === "number") {
|
||||||
where.push("built_by_user_id=?");
|
where.push("c.built_by_user_id=?");
|
||||||
params.push(builtByUserId);
|
params.push(builtByUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requireBuildOwner) {
|
if (requireBuildOwner) {
|
||||||
where.push("built_by_user_id IS NOT NULL");
|
where.push("c.built_by_user_id IS NOT NULL");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(allowedClientIds)) {
|
if (Array.isArray(allowedClientIds)) {
|
||||||
if (allowedClientIds.length === 0) {
|
if (allowedClientIds.length === 0) {
|
||||||
where.push("1=0");
|
where.push("1=0");
|
||||||
} else {
|
} else {
|
||||||
where.push(`id IN (${allowedClientIds.map(() => "?").join(",")})`);
|
where.push(`c.id IN (${allowedClientIds.map(() => "?").join(",")})`);
|
||||||
params.push(...allowedClientIds);
|
params.push(...allowedClientIds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(deniedClientIds) && deniedClientIds.length > 0) {
|
if (Array.isArray(deniedClientIds) && deniedClientIds.length > 0) {
|
||||||
where.push(`id NOT IN (${deniedClientIds.map(() => "?").join(",")})`);
|
where.push(`c.id NOT IN (${deniedClientIds.map(() => "?").join(",")})`);
|
||||||
params.push(...deniedClientIds);
|
params.push(...deniedClientIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (groupFilter && groupFilter !== "all") {
|
||||||
|
if (groupFilter === "none") {
|
||||||
|
where.push("c.group_id IS NULL");
|
||||||
|
} else {
|
||||||
|
const gid = Number(groupFilter);
|
||||||
|
if (Number.isFinite(gid)) {
|
||||||
|
where.push("c.group_id=?");
|
||||||
|
params.push(gid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
||||||
|
|
||||||
const orderBy = (() => {
|
const orderBy = (() => {
|
||||||
const online = "online DESC";
|
const online = "c.online DESC";
|
||||||
const bookmark = "bookmarked DESC";
|
const bookmark = "c.bookmarked DESC";
|
||||||
switch (sort) {
|
switch (sort) {
|
||||||
case "stable":
|
case "stable":
|
||||||
return `ORDER BY ${online}, ${bookmark}, id ASC`;
|
return `ORDER BY ${online}, ${bookmark}, c.id ASC`;
|
||||||
case "ping_asc":
|
case "ping_asc":
|
||||||
return `ORDER BY ${online}, ${bookmark}, ping_ms IS NULL, ping_ms ASC, id ASC`;
|
return `ORDER BY ${online}, ${bookmark}, c.ping_ms IS NULL, c.ping_ms ASC, c.id ASC`;
|
||||||
case "ping_desc":
|
case "ping_desc":
|
||||||
return `ORDER BY ${online}, ${bookmark}, ping_ms IS NULL, ping_ms DESC, id ASC`;
|
return `ORDER BY ${online}, ${bookmark}, c.ping_ms IS NULL, c.ping_ms DESC, c.id ASC`;
|
||||||
case "host_asc":
|
case "host_asc":
|
||||||
return `ORDER BY ${online}, ${bookmark}, LOWER(COALESCE(nickname, host)) ASC, id ASC`;
|
return `ORDER BY ${online}, ${bookmark}, LOWER(COALESCE(c.nickname, c.host)) ASC, c.id ASC`;
|
||||||
case "host_desc":
|
case "host_desc":
|
||||||
return `ORDER BY ${online}, ${bookmark}, LOWER(COALESCE(nickname, host)) DESC, id ASC`;
|
return `ORDER BY ${online}, ${bookmark}, LOWER(COALESCE(c.nickname, c.host)) DESC, c.id ASC`;
|
||||||
case "country_asc":
|
case "country_asc":
|
||||||
return `ORDER BY ${online}, ${bookmark}, LOWER(COALESCE(country, 'zz')) ASC, id ASC`;
|
return `ORDER BY ${online}, ${bookmark}, LOWER(COALESCE(c.country, 'zz')) ASC, c.id ASC`;
|
||||||
case "country_desc":
|
case "country_desc":
|
||||||
return `ORDER BY ${online}, ${bookmark}, LOWER(COALESCE(country, 'zz')) DESC, id ASC`;
|
return `ORDER BY ${online}, ${bookmark}, LOWER(COALESCE(c.country, 'zz')) DESC, c.id ASC`;
|
||||||
case "admin_first":
|
case "admin_first":
|
||||||
return `ORDER BY ${online}, ${bookmark}, is_admin DESC, id ASC`;
|
return `ORDER BY ${online}, ${bookmark}, c.is_admin DESC, c.id ASC`;
|
||||||
|
case "group_asc":
|
||||||
|
return `ORDER BY ${online}, ${bookmark}, g.name IS NULL, LOWER(g.name) ASC, c.id ASC`;
|
||||||
|
case "group_desc":
|
||||||
|
return `ORDER BY ${online}, ${bookmark}, g.name IS NULL, LOWER(g.name) DESC, c.id ASC`;
|
||||||
default:
|
default:
|
||||||
return `ORDER BY ${online}, ${bookmark}, last_seen DESC, id ASC`;
|
return `ORDER BY ${online}, ${bookmark}, c.last_seen DESC, c.id ASC`;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const totalRow = db
|
const totalRow = db
|
||||||
.query<{ c: number }>(`SELECT COUNT(*) as c FROM clients ${whereSql}`)
|
.query<{ c: number }>(`SELECT COUNT(*) as c FROM clients c LEFT JOIN client_groups g ON g.id = c.group_id ${whereSql}`)
|
||||||
.get(...params) ?? { c: 0 };
|
.get(...params) ?? { c: 0 };
|
||||||
const onlineRow = db
|
const onlineRow = db
|
||||||
.query<{ c: number }>(
|
.query<{ c: number }>(
|
||||||
`SELECT COUNT(*) as c FROM clients ${whereSql ? `${whereSql} AND online=1` : "WHERE online=1"}`,
|
`SELECT COUNT(*) as c FROM clients c LEFT JOIN client_groups g ON g.id = c.group_id ${whereSql ? `${whereSql} AND c.online=1` : "WHERE c.online=1"}`,
|
||||||
)
|
)
|
||||||
.get(...params) ?? { c: 0 };
|
.get(...params) ?? { c: 0 };
|
||||||
const offset = (page - 1) * pageSize;
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
const rows = db
|
const rows = db
|
||||||
.query<any>(
|
.query<any>(
|
||||||
`SELECT id, hwid, role, ip, host, os, arch, version, user, nickname, custom_tag as customTag, custom_tag_note as customTagNote, monitors, country, last_seen as lastSeen, online, ping_ms as pingMs, bookmarked, build_tag as buildTag, built_by_user_id as builtByUserId, enrollment_status as enrollmentStatus, public_key as publicKey, key_fingerprint as keyFingerprint, cpu, gpu, ram, is_admin as isAdmin, elevation, permissions, disconnect_reason as disconnectReason, disconnect_detail as disconnectDetail
|
`SELECT c.id, c.hwid, c.role, c.ip, c.host, c.os, c.arch, c.version, c.user, c.nickname, c.custom_tag as customTag, c.custom_tag_note as customTagNote, c.monitors, c.country, c.last_seen as lastSeen, c.online, c.ping_ms as pingMs, c.bookmarked, c.build_tag as buildTag, c.built_by_user_id as builtByUserId, c.enrollment_status as enrollmentStatus, c.public_key as publicKey, c.key_fingerprint as keyFingerprint, c.cpu, c.gpu, c.ram, c.is_admin as isAdmin, c.elevation, c.permissions, c.disconnect_reason as disconnectReason, c.disconnect_detail as disconnectDetail, c.group_id as groupId, g.name as groupName, g.color as groupColor
|
||||||
FROM clients
|
FROM clients c
|
||||||
|
LEFT JOIN client_groups g ON g.id = c.group_id
|
||||||
${whereSql}
|
${whereSql}
|
||||||
${orderBy}
|
${orderBy}
|
||||||
LIMIT ? OFFSET ?`,
|
LIMIT ? OFFSET ?`,
|
||||||
@@ -937,6 +1038,9 @@ export function listClients(filters: ListFilters): ListResult {
|
|||||||
permissions: c.permissions ? (() => { try { return JSON.parse(c.permissions); } catch { return null; } })() : null,
|
permissions: c.permissions ? (() => { try { return JSON.parse(c.permissions); } catch { return null; } })() : null,
|
||||||
disconnectReason: c.disconnectReason || null,
|
disconnectReason: c.disconnectReason || null,
|
||||||
disconnectDetail: c.disconnectDetail || null,
|
disconnectDetail: c.disconnectDetail || null,
|
||||||
|
groupId: typeof c.groupId === "number" ? c.groupId : null,
|
||||||
|
groupName: c.groupName || null,
|
||||||
|
groupColor: c.groupColor || null,
|
||||||
thumbnail: getThumbnail(c.id),
|
thumbnail: getThumbnail(c.id),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ import {
|
|||||||
setClientTag,
|
setClientTag,
|
||||||
setOnlineState,
|
setOnlineState,
|
||||||
unbanIp,
|
unbanIp,
|
||||||
|
listGroups,
|
||||||
|
getGroup,
|
||||||
|
createGroup,
|
||||||
|
updateGroup,
|
||||||
|
deleteGroup,
|
||||||
|
setClientGroup,
|
||||||
} from "../../db";
|
} from "../../db";
|
||||||
import { metrics } from "../../metrics";
|
import { metrics } from "../../metrics";
|
||||||
import { encodeMessage } from "../../protocol";
|
import { encodeMessage } from "../../protocol";
|
||||||
@@ -59,6 +65,7 @@ export async function handleClientRoutes(
|
|||||||
): Promise<Response | null> {
|
): Promise<Response | null> {
|
||||||
if (
|
if (
|
||||||
!url.pathname.startsWith("/api/clients") &&
|
!url.pathname.startsWith("/api/clients") &&
|
||||||
|
!url.pathname.startsWith("/api/groups") &&
|
||||||
!url.pathname.match(/^\/api\/clients\/.+\/command$/)
|
!url.pathname.match(/^\/api\/clients\/.+\/command$/)
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
@@ -77,9 +84,10 @@ export async function handleClientRoutes(
|
|||||||
const osFilter = url.searchParams.get("os") || "all";
|
const osFilter = url.searchParams.get("os") || "all";
|
||||||
const countryFilter = url.searchParams.get("country") || "all";
|
const countryFilter = url.searchParams.get("country") || "all";
|
||||||
const enrollmentFilter = url.searchParams.get("enrollmentFilter") || "approved";
|
const enrollmentFilter = url.searchParams.get("enrollmentFilter") || "approved";
|
||||||
|
const groupFilter = url.searchParams.get("group") || "all";
|
||||||
const isEnrollmentRequest = url.searchParams.has("enrollmentFilter");
|
const isEnrollmentRequest = url.searchParams.has("enrollmentFilter");
|
||||||
if (user.role === "admin") {
|
if (user.role === "admin") {
|
||||||
const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter, countryFilter, enrollmentFilter });
|
const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter, countryFilter, enrollmentFilter, groupFilter });
|
||||||
return Response.json(result, { headers: deps.CORS_HEADERS });
|
return Response.json(result, { headers: deps.CORS_HEADERS });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +101,7 @@ export async function handleClientRoutes(
|
|||||||
osFilter,
|
osFilter,
|
||||||
countryFilter,
|
countryFilter,
|
||||||
enrollmentFilter,
|
enrollmentFilter,
|
||||||
|
groupFilter,
|
||||||
builtByUserId: user.userId,
|
builtByUserId: user.userId,
|
||||||
requireBuildOwner: true,
|
requireBuildOwner: true,
|
||||||
});
|
});
|
||||||
@@ -125,6 +134,7 @@ export async function handleClientRoutes(
|
|||||||
osFilter,
|
osFilter,
|
||||||
countryFilter,
|
countryFilter,
|
||||||
enrollmentFilter,
|
enrollmentFilter,
|
||||||
|
groupFilter,
|
||||||
allowedClientIds,
|
allowedClientIds,
|
||||||
deniedClientIds,
|
deniedClientIds,
|
||||||
});
|
});
|
||||||
@@ -758,5 +768,124 @@ export async function handleClientRoutes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/groups") {
|
||||||
|
const user = await authenticateRequest(req);
|
||||||
|
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||||
|
|
||||||
|
if (req.method === "GET") {
|
||||||
|
return Response.json({ groups: listGroups() }, { headers: deps.CORS_HEADERS });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST") {
|
||||||
|
try { requirePermission(user, "clients:control"); } catch (error) {
|
||||||
|
if (error instanceof Response) return error;
|
||||||
|
return new Response("Forbidden", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: any = {};
|
||||||
|
try { body = await req.json(); } catch {
|
||||||
|
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = typeof body?.name === "string" ? body.name.trim() : "";
|
||||||
|
const color = typeof body?.color === "string" ? body.color.trim() : "#3b82f6";
|
||||||
|
if (!name || name.length > 64) {
|
||||||
|
return Response.json({ error: "Name is required and must be 64 characters or fewer" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!/^#[0-9a-fA-F]{6}$/.test(color)) {
|
||||||
|
return Response.json({ error: "Color must be a valid hex code (#RRGGBB)" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = createGroup(name, color);
|
||||||
|
const ip = server.requestIP(req)?.address || "unknown";
|
||||||
|
logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, details: `create_group:${name}`, success: true });
|
||||||
|
return Response.json(group, { status: 201, headers: deps.CORS_HEADERS });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupIdMatch = url.pathname.match(/^\/api\/groups\/(\d+)$/);
|
||||||
|
if (groupIdMatch) {
|
||||||
|
const user = await authenticateRequest(req);
|
||||||
|
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||||
|
try { requirePermission(user, "clients:control"); } catch (error) {
|
||||||
|
if (error instanceof Response) return error;
|
||||||
|
return new Response("Forbidden", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupId = Number(groupIdMatch[1]);
|
||||||
|
|
||||||
|
if (req.method === "PATCH") {
|
||||||
|
let body: any = {};
|
||||||
|
try { body = await req.json(); } catch {
|
||||||
|
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = getGroup(groupId);
|
||||||
|
if (!existing) return Response.json({ error: "Group not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const name = typeof body?.name === "string" ? body.name.trim() : existing.name;
|
||||||
|
const color = typeof body?.color === "string" ? body.color.trim() : existing.color;
|
||||||
|
if (name.length === 0 || name.length > 64) {
|
||||||
|
return Response.json({ error: "Name must be 1-64 characters" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!/^#[0-9a-fA-F]{6}$/.test(color)) {
|
||||||
|
return Response.json({ error: "Color must be a valid hex code (#RRGGBB)" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = updateGroup(groupId, name, color);
|
||||||
|
if (!updated) return Response.json({ error: "Group not found" }, { status: 404 });
|
||||||
|
notifyDashboardViewers();
|
||||||
|
const ip = server.requestIP(req)?.address || "unknown";
|
||||||
|
logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, details: `update_group:${groupId}`, success: true });
|
||||||
|
return Response.json({ ok: true, id: groupId, name, color }, { headers: deps.CORS_HEADERS });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "DELETE") {
|
||||||
|
const deleted = deleteGroup(groupId);
|
||||||
|
if (!deleted) return Response.json({ error: "Group not found" }, { status: 404 });
|
||||||
|
notifyDashboardViewers();
|
||||||
|
const ip = server.requestIP(req)?.address || "unknown";
|
||||||
|
logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, details: `delete_group:${groupId}`, success: true });
|
||||||
|
return Response.json({ ok: true }, { headers: deps.CORS_HEADERS });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientGroupMatch = url.pathname.match(/^\/api\/clients\/([^/]+)\/group$/);
|
||||||
|
if (req.method === "PATCH" && clientGroupMatch) {
|
||||||
|
const user = await authenticateRequest(req);
|
||||||
|
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||||
|
try { requirePermission(user, "clients:control"); } catch (error) {
|
||||||
|
if (error instanceof Response) return error;
|
||||||
|
return new Response("Forbidden", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetId = clientGroupMatch[1];
|
||||||
|
if (!canUserAccessClient(user.userId, user.role, targetId)) {
|
||||||
|
return new Response("Forbidden: Client access denied", { status: 403 });
|
||||||
|
}
|
||||||
|
if (!clientExists(targetId)) {
|
||||||
|
return Response.json({ error: "Client not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: any = {};
|
||||||
|
try { body = await req.json(); } catch {
|
||||||
|
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupId = body?.groupId === null ? null : Number(body?.groupId);
|
||||||
|
if (groupId !== null && (isNaN(groupId) || groupId < 1)) {
|
||||||
|
return Response.json({ error: "Invalid groupId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = setClientGroup(targetId, groupId);
|
||||||
|
if (!updated) {
|
||||||
|
return Response.json({ error: "Client not found or group does not exist" }, { status: 404 });
|
||||||
|
}
|
||||||
|
notifyDashboardViewers();
|
||||||
|
const ip = server.requestIP(req)?.address || "unknown";
|
||||||
|
logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, targetClientId: targetId, details: groupId ? `set_group:${groupId}` : "clear_group", success: true });
|
||||||
|
return Response.json({ ok: true, groupId }, { headers: deps.CORS_HEADERS });
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ export type ClientInfo = {
|
|||||||
keyFingerprint?: string;
|
keyFingerprint?: string;
|
||||||
disconnectReason?: string;
|
disconnectReason?: string;
|
||||||
disconnectDetail?: string;
|
disconnectDetail?: string;
|
||||||
|
groupId?: number | null;
|
||||||
|
groupName?: string | null;
|
||||||
|
groupColor?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ListFilters = {
|
export type ListFilters = {
|
||||||
@@ -53,6 +56,7 @@ export type ListFilters = {
|
|||||||
requireBuildOwner?: boolean;
|
requireBuildOwner?: boolean;
|
||||||
allowedClientIds?: string[];
|
allowedClientIds?: string[];
|
||||||
deniedClientIds?: string[];
|
deniedClientIds?: string[];
|
||||||
|
groupFilter?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ListItem = ClientInfo & {
|
export type ListItem = ClientInfo & {
|
||||||
|
|||||||
Reference in New Issue
Block a user