add bookmarking clients, fix sorting issues, add sorting by country and only showing specific countries.

This commit is contained in:
KingKDot
2026-03-17 00:11:25 -05:00
parent 0cdc5efefb
commit 50bc381943
9 changed files with 561 additions and 9 deletions
@@ -0,0 +1,419 @@
// Country data: code -> [name, continent]
const COUNTRIES = {
AF: ["Afghanistan", "Asia"],
AL: ["Albania", "Europe"],
DZ: ["Algeria", "Africa"],
AD: ["Andorra", "Europe"],
AO: ["Angola", "Africa"],
AG: ["Antigua & Barbuda", "North America"],
AR: ["Argentina", "South America"],
AM: ["Armenia", "Asia"],
AU: ["Australia", "Oceania"],
AT: ["Austria", "Europe"],
AZ: ["Azerbaijan", "Asia"],
BS: ["Bahamas", "North America"],
BH: ["Bahrain", "Asia"],
BD: ["Bangladesh", "Asia"],
BB: ["Barbados", "North America"],
BY: ["Belarus", "Europe"],
BE: ["Belgium", "Europe"],
BZ: ["Belize", "North America"],
BJ: ["Benin", "Africa"],
BT: ["Bhutan", "Asia"],
BO: ["Bolivia", "South America"],
BA: ["Bosnia & Herzegovina", "Europe"],
BW: ["Botswana", "Africa"],
BR: ["Brazil", "South America"],
BN: ["Brunei", "Asia"],
BG: ["Bulgaria", "Europe"],
BF: ["Burkina Faso", "Africa"],
BI: ["Burundi", "Africa"],
CV: ["Cabo Verde", "Africa"],
KH: ["Cambodia", "Asia"],
CM: ["Cameroon", "Africa"],
CA: ["Canada", "North America"],
CF: ["Central African Republic", "Africa"],
TD: ["Chad", "Africa"],
CL: ["Chile", "South America"],
CN: ["China", "Asia"],
CO: ["Colombia", "South America"],
KM: ["Comoros", "Africa"],
CG: ["Congo", "Africa"],
CD: ["Congo (DR)", "Africa"],
CR: ["Costa Rica", "North America"],
HR: ["Croatia", "Europe"],
CU: ["Cuba", "North America"],
CY: ["Cyprus", "Europe"],
CZ: ["Czechia", "Europe"],
DK: ["Denmark", "Europe"],
DJ: ["Djibouti", "Africa"],
DM: ["Dominica", "North America"],
DO: ["Dominican Republic", "North America"],
EC: ["Ecuador", "South America"],
EG: ["Egypt", "Africa"],
SV: ["El Salvador", "North America"],
GQ: ["Equatorial Guinea", "Africa"],
ER: ["Eritrea", "Africa"],
EE: ["Estonia", "Europe"],
SZ: ["Eswatini", "Africa"],
ET: ["Ethiopia", "Africa"],
FJ: ["Fiji", "Oceania"],
FI: ["Finland", "Europe"],
FR: ["France", "Europe"],
GA: ["Gabon", "Africa"],
GM: ["Gambia", "Africa"],
GE: ["Georgia", "Asia"],
DE: ["Germany", "Europe"],
GH: ["Ghana", "Africa"],
GR: ["Greece", "Europe"],
GD: ["Grenada", "North America"],
GT: ["Guatemala", "North America"],
GN: ["Guinea", "Africa"],
GW: ["Guinea-Bissau", "Africa"],
GY: ["Guyana", "South America"],
HT: ["Haiti", "North America"],
HN: ["Honduras", "North America"],
HU: ["Hungary", "Europe"],
IS: ["Iceland", "Europe"],
IN: ["India", "Asia"],
ID: ["Indonesia", "Asia"],
IR: ["Iran", "Asia"],
IQ: ["Iraq", "Asia"],
IE: ["Ireland", "Europe"],
IL: ["Israel", "Asia"],
IT: ["Italy", "Europe"],
JM: ["Jamaica", "North America"],
JP: ["Japan", "Asia"],
JO: ["Jordan", "Asia"],
KZ: ["Kazakhstan", "Asia"],
KE: ["Kenya", "Africa"],
KI: ["Kiribati", "Oceania"],
KP: ["North Korea", "Asia"],
KR: ["South Korea", "Asia"],
KW: ["Kuwait", "Asia"],
KG: ["Kyrgyzstan", "Asia"],
LA: ["Laos", "Asia"],
LV: ["Latvia", "Europe"],
LB: ["Lebanon", "Asia"],
LS: ["Lesotho", "Africa"],
LR: ["Liberia", "Africa"],
LY: ["Libya", "Africa"],
LI: ["Liechtenstein", "Europe"],
LT: ["Lithuania", "Europe"],
LU: ["Luxembourg", "Europe"],
MO: ["Macao", "Asia"],
MG: ["Madagascar", "Africa"],
MW: ["Malawi", "Africa"],
MY: ["Malaysia", "Asia"],
MV: ["Maldives", "Asia"],
ML: ["Mali", "Africa"],
MT: ["Malta", "Europe"],
MH: ["Marshall Islands", "Oceania"],
MR: ["Mauritania", "Africa"],
MU: ["Mauritius", "Africa"],
MX: ["Mexico", "North America"],
FM: ["Micronesia", "Oceania"],
MD: ["Moldova", "Europe"],
MC: ["Monaco", "Europe"],
MN: ["Mongolia", "Asia"],
ME: ["Montenegro", "Europe"],
MA: ["Morocco", "Africa"],
MZ: ["Mozambique", "Africa"],
MM: ["Myanmar", "Asia"],
NA: ["Namibia", "Africa"],
NR: ["Nauru", "Oceania"],
NP: ["Nepal", "Asia"],
NL: ["Netherlands", "Europe"],
NZ: ["New Zealand", "Oceania"],
NI: ["Nicaragua", "North America"],
NE: ["Niger", "Africa"],
NG: ["Nigeria", "Africa"],
MK: ["North Macedonia", "Europe"],
NO: ["Norway", "Europe"],
OM: ["Oman", "Asia"],
PK: ["Pakistan", "Asia"],
PW: ["Palau", "Oceania"],
PS: ["Palestine", "Asia"],
PA: ["Panama", "North America"],
PG: ["Papua New Guinea", "Oceania"],
PY: ["Paraguay", "South America"],
PE: ["Peru", "South America"],
PH: ["Philippines", "Asia"],
PL: ["Poland", "Europe"],
PT: ["Portugal", "Europe"],
QA: ["Qatar", "Asia"],
RO: ["Romania", "Europe"],
RU: ["Russia", "Europe"],
RW: ["Rwanda", "Africa"],
KN: ["Saint Kitts & Nevis", "North America"],
LC: ["Saint Lucia", "North America"],
VC: ["Saint Vincent & Grenadines", "North America"],
WS: ["Samoa", "Oceania"],
SM: ["San Marino", "Europe"],
ST: ["São Tomé & Príncipe", "Africa"],
SA: ["Saudi Arabia", "Asia"],
SN: ["Senegal", "Africa"],
RS: ["Serbia", "Europe"],
SC: ["Seychelles", "Africa"],
SL: ["Sierra Leone", "Africa"],
SG: ["Singapore", "Asia"],
SK: ["Slovakia", "Europe"],
SI: ["Slovenia", "Europe"],
SB: ["Solomon Islands", "Oceania"],
SO: ["Somalia", "Africa"],
ZA: ["South Africa", "Africa"],
SS: ["South Sudan", "Africa"],
ES: ["Spain", "Europe"],
LK: ["Sri Lanka", "Asia"],
SD: ["Sudan", "Africa"],
SR: ["Suriname", "South America"],
SE: ["Sweden", "Europe"],
CH: ["Switzerland", "Europe"],
SY: ["Syria", "Asia"],
TW: ["Taiwan", "Asia"],
TJ: ["Tajikistan", "Asia"],
TZ: ["Tanzania", "Africa"],
TH: ["Thailand", "Asia"],
TL: ["Timor-Leste", "Asia"],
TG: ["Togo", "Africa"],
TO: ["Tonga", "Oceania"],
TT: ["Trinidad & Tobago", "North America"],
TN: ["Tunisia", "Africa"],
TR: ["Turkey", "Asia"],
TM: ["Turkmenistan", "Asia"],
TV: ["Tuvalu", "Oceania"],
UG: ["Uganda", "Africa"],
UA: ["Ukraine", "Europe"],
AE: ["United Arab Emirates", "Asia"],
GB: ["United Kingdom", "Europe"],
US: ["United States", "North America"],
UY: ["Uruguay", "South America"],
UZ: ["Uzbekistan", "Asia"],
VU: ["Vanuatu", "Oceania"],
VE: ["Venezuela", "South America"],
VN: ["Vietnam", "Asia"],
YE: ["Yemen", "Asia"],
ZM: ["Zambia", "Africa"],
ZW: ["Zimbabwe", "Africa"],
};
const CONTINENT_ORDER = [
"North America",
"South America",
"Europe",
"Africa",
"Asia",
"Oceania",
];
function flagHtml(code) {
if (!code) return "";
const cc = String(code).toLowerCase();
if (!/^[a-z]{2}$/.test(cc) || cc === "zz") return "🏴";
return `<span class="fi fi-${cc}"></span>`;
}
export function initCountryPicker(onSelect) {
const anchor = document.getElementById("country-picker-anchor");
if (!anchor) return;
let currentCode = "all";
let countryData = []; // [{ code, count }]
let panelOpen = false;
const btn = document.createElement("button");
btn.type = "button";
btn.id = "country-picker-btn";
btn.className =
"inline-flex items-center gap-2 bg-slate-900/70 border border-slate-800 rounded-lg px-3 py-2 text-slate-100 hover:border-slate-600 transition-colors";
btn.innerHTML = `<i class="fa-solid fa-earth-americas text-slate-400"></i><span id="country-picker-label">All Countries</span><i class="fa-solid fa-chevron-down text-slate-400 text-xs"></i>`;
anchor.appendChild(btn);
const panel = document.createElement("div");
panel.id = "country-picker-panel";
panel.className =
"hidden absolute z-[9999] mt-1 w-72 rounded-xl border border-slate-700 bg-slate-900 shadow-2xl flex flex-col overflow-hidden";
panel.style.maxHeight = "420px";
const searchWrap = document.createElement("div");
searchWrap.className = "p-2 border-b border-slate-800 flex-shrink-0";
searchWrap.innerHTML = `<div class="flex items-center gap-2 bg-slate-800 border border-slate-700 rounded-lg px-2 py-1.5"><i class="fa-solid fa-magnifying-glass text-slate-400 text-xs"></i><input id="country-search" type="search" placeholder="Search country..." class="bg-transparent outline-none text-sm text-slate-100 placeholder:text-slate-500 w-full"></div>`;
panel.appendChild(searchWrap);
const list = document.createElement("div");
list.className = "overflow-y-auto flex-1";
panel.appendChild(list);
document.body.appendChild(panel);
function positionPanel() {
const rect = btn.getBoundingClientRect();
panel.style.top = `${rect.bottom + window.scrollY + 4}px`;
panel.style.left = `${rect.left + window.scrollX}px`;
}
function openPanel() {
panelOpen = true;
panel.classList.remove("hidden");
positionPanel();
setTimeout(() => {
const inp = panel.querySelector("#country-search");
if (inp) inp.focus();
}, 0);
}
function closePanel() {
panelOpen = false;
panel.classList.add("hidden");
const inp = panel.querySelector("#country-search");
if (inp) inp.value = "";
renderList("");
}
btn.addEventListener("click", (e) => {
e.stopPropagation();
if (panelOpen) {
closePanel();
} else {
openPanel();
}
});
document.addEventListener("click", (e) => {
if (!panelOpen) return;
if (!panel.contains(e.target) && e.target !== btn) {
closePanel();
}
});
window.addEventListener("resize", () => {
if (panelOpen) positionPanel();
});
const searchInput = searchWrap.querySelector("#country-search");
searchInput.addEventListener("input", (e) => {
renderList(e.target.value.trim().toLowerCase());
});
function setSelected(code, label, flagMarkup) {
currentCode = code;
const labelEl = document.getElementById("country-picker-label");
if (labelEl) {
if (code === "all") {
labelEl.innerHTML = "All Countries";
} else {
labelEl.innerHTML = `${flagMarkup}&nbsp;${label}`;
}
}
onSelect(code);
closePanel();
}
function renderList(query) {
list.innerHTML = "";
const presentCodes = new Set(countryData.map((d) => d.code));
const countMap = Object.fromEntries(countryData.map((d) => [d.code, d.count]));
const allItem = makeItem(
`<i class="fa-solid fa-earth-americas text-slate-400"></i>`,
"All Countries",
"",
"all",
null,
currentCode === "all",
);
if (!query) list.appendChild(allItem);
const continentGroups = {};
for (const code of presentCodes) {
if (code === "ZZ") continue;
const info = COUNTRIES[code];
const name = info ? info[0] : code;
const continent = info ? info[1] : "Other";
if (query && !name.toLowerCase().includes(query) && !code.toLowerCase().includes(query)) continue;
if (!continentGroups[continent]) continentGroups[continent] = [];
continentGroups[continent].push({ code, name, count: countMap[code] || 0 });
}
const continentKeys = Object.keys(continentGroups).sort((a, b) => {
const ai = CONTINENT_ORDER.indexOf(a);
const bi = CONTINENT_ORDER.indexOf(b);
if (ai === -1 && bi === -1) return a.localeCompare(b);
if (ai === -1) return 1;
if (bi === -1) return -1;
return ai - bi;
});
for (const continent of continentKeys) {
const countries = continentGroups[continent].sort((a, b) =>
a.name.localeCompare(b.name),
);
const header = document.createElement("div");
header.className = "px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-slate-500 bg-slate-950/50 sticky top-0";
header.textContent = continent;
list.appendChild(header);
for (const { code, name, count } of countries) {
const flag = flagHtml(code);
const item = makeItem(flag, name, code, code, count, currentCode === code);
list.appendChild(item);
}
}
if (list.children.length === 0 || (list.children.length === 1 && !query)) {
const empty = document.createElement("div");
empty.className = "px-4 py-6 text-center text-slate-500 text-sm";
empty.textContent = query ? "No matching countries" : "No country data yet";
list.appendChild(empty);
}
}
function makeItem(flagMarkup, name, code, value, count, active) {
const el = document.createElement("button");
el.type = "button";
el.className = `w-full text-left flex items-center gap-3 px-3 py-2 text-sm hover:bg-slate-800 transition-colors ${active ? "bg-slate-800/80 text-blue-300" : "text-slate-200"}`;
const flagSpan = document.createElement("span");
flagSpan.className = "w-6 text-center flex-shrink-0 text-base";
flagSpan.innerHTML = flagMarkup;
const nameSpan = document.createElement("span");
nameSpan.className = "flex-1 truncate";
nameSpan.textContent = name;
el.appendChild(flagSpan);
el.appendChild(nameSpan);
if (count !== null) {
const badge = document.createElement("span");
badge.className = "text-xs text-slate-400 font-mono flex-shrink-0";
badge.textContent = count;
el.appendChild(badge);
}
el.addEventListener("click", () => setSelected(value, name, flagMarkup));
return el;
}
async function loadCountries() {
try {
const res = await fetch("/api/clients/countries");
if (!res.ok) return;
const data = await res.json();
countryData = Array.isArray(data.countries) ? data.countries : [];
renderList("");
} catch (err) {
console.error("country-picker: failed to load countries", err);
}
}
loadCountries();
setInterval(loadCountries, 30000);
renderList("");
}
+1
View File
@@ -25,6 +25,7 @@ export async function loadWithOptions(options = {}) {
sort: state.sort,
status: state.filterStatus || "all",
os: state.filterOs || "all",
country: state.filterCountry || "all",
});
const res = await fetch(`/api/clients?${params.toString()}`);
if (!res.ok) throw new Error(`status ${res.status}`);
+8
View File
@@ -10,6 +10,7 @@ import {
requestPreview,
requestThumbnail,
} from "./data.js";
import { initCountryPicker } from "./country-picker.js";
const grid = document.getElementById("grid");
const totalPill = document.getElementById("total-pill");
@@ -445,6 +446,13 @@ filterOsSelect?.addEventListener("change", (e) => {
loadWithOptions({ force: true });
});
initCountryPicker((code) => {
state.filterCountry = code;
state.page = 1;
state.lastDigest = "";
loadWithOptions({ force: true });
});
showOfflineToggle?.addEventListener("change", (e) => {
if (e.target.checked) {
state.filterStatus = lastNonOnlineStatus || "all";
+38 -1
View File
@@ -212,6 +212,7 @@ export function createRenderer({
card.dataset.os = String(client.os || "").toLowerCase();
card.dataset.nickname = String(client.nickname || "");
card.dataset.customTag = String(client.customTag || "");
card.dataset.bookmarked = String(!!client.bookmarked);
card._customTagNote = String(client.customTagNote || "");
const os = osBadge(client.os || "unknown");
const arch = archBadge(client.arch || "");
@@ -226,7 +227,7 @@ export function createRenderer({
const hasTagNote = customTag.length > 0 && customTagNote.length > 0;
const isTagNoteExpanded = hasTagNote && wasTagNoteExpanded;
card.dataset.tagNoteExpanded = isTagNoteExpanded ? "true" : "false";
card.className = `card rounded-xl border border-slate-800 bg-slate-900/70 p-4 shadow-lg ${client.online ? "" : "card-offline"} tone-${os.tone}`;
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 cardThumb = client.thumbnail
? (() => {
const wrapper = document.createElement("div");
@@ -278,6 +279,7 @@ export function createRenderer({
</div>
</div>
<div class="flex items-center gap-3">
<button class="bookmark-btn inline-flex items-center justify-center w-9 h-9 rounded-lg border ${client.bookmarked ? "border-yellow-600 bg-yellow-900/50 text-yellow-300" : "border-slate-700 bg-slate-800/50 text-slate-500 hover:text-yellow-300 hover:border-yellow-700"} transition-colors" data-id="${escapeHtml(client.id)}" title="${client.bookmarked ? "Remove bookmark" : "Bookmark"}"><i class="fa-${client.bookmarked ? "solid" : "regular"} fa-star"></i></button>
<span class="text-emerald-300 font-mono text-sm inline-flex items-center gap-2"><i class="fa-solid fa-satellite-dish"></i> ${formatPing(client.pingMs)}</span>
${isViewer ? "" : `<button class="command-btn inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-slate-800 bg-slate-800/70 hover:bg-slate-700" data-id="${escapeHtml(client.id)}"><i class="fa-solid fa-bars"></i> Commands</button>`}
${isViewer ? "" : `<button class="ban-btn inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-red-800 bg-red-900/60 hover:bg-red-800 text-red-100" data-id="${escapeHtml(client.id)}"><i class="fa-solid fa-ban"></i> Ban</button>`}
@@ -323,6 +325,41 @@ export function createRenderer({
});
}
card.querySelector(".bookmark-btn")?.addEventListener("click", async (e) => {
e.stopPropagation();
const btn = e.currentTarget;
const id = btn.dataset.id;
const isBookmarked = card.dataset.bookmarked === "true";
try {
const res = await fetch(`/api/clients/${id}/bookmark`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bookmarked: !isBookmarked }),
});
if (res.ok) {
card.dataset.bookmarked = String(!isBookmarked);
const icon = btn.querySelector("i");
if (!isBookmarked) {
btn.classList.remove("border-slate-700", "bg-slate-800/50", "text-slate-500");
btn.classList.add("border-yellow-600", "bg-yellow-900/50", "text-yellow-300");
card.classList.remove("border-slate-800");
card.classList.add("border-yellow-600/60");
if (icon) { icon.classList.remove("fa-regular"); icon.classList.add("fa-solid"); }
btn.title = "Remove bookmark";
} else {
btn.classList.remove("border-yellow-600", "bg-yellow-900/50", "text-yellow-300");
btn.classList.add("border-slate-700", "bg-slate-800/50", "text-slate-500");
card.classList.remove("border-yellow-600/60");
card.classList.add("border-slate-800");
if (icon) { icon.classList.remove("fa-solid"); icon.classList.add("fa-regular"); }
btn.title = "Bookmark";
}
}
} catch (err) {
console.error("bookmark toggle failed", err);
}
});
card.querySelector(".client-tag-toggle")?.addEventListener("click", (e) => {
e.stopPropagation();
const notePanel = card.querySelector(".client-tag-note");
+1
View File
@@ -5,6 +5,7 @@ export const state = {
sort: "last_seen_desc",
filterStatus: "all",
filterOs: "all",
filterCountry: "all",
lastDigest: "",
isLoading: false,
pendingForce: false,
+3
View File
@@ -87,6 +87,7 @@
>
<option value="all">All OS</option>
</select>
<div id="country-picker-anchor" class="relative"></div>
<select
id="sort"
class="bg-slate-900/70 border border-slate-800 rounded-lg px-3 py-2 text-slate-100"
@@ -95,6 +96,8 @@
<option value="host_asc">Host A→Z</option>
<option value="ping_asc">Ping ↑</option>
<option value="ping_desc">Ping ↓</option>
<option value="country_asc">Country A→Z</option>
<option value="country_desc">Country Z→A</option>
</select>
<button
id="select-all"
+49 -7
View File
@@ -27,7 +27,8 @@ db.run(`
country TEXT,
last_seen INTEGER,
online INTEGER,
ping_ms INTEGER
ping_ms INTEGER,
bookmarked INTEGER NOT NULL DEFAULT 0
);
`);
try {
@@ -48,6 +49,9 @@ try {
try {
db.run(`ALTER TABLE clients ADD COLUMN custom_tag_note TEXT`);
} catch {}
try {
db.run(`ALTER TABLE clients ADD COLUMN bookmarked INTEGER NOT NULL DEFAULT 0`);
} catch {}
db.run(
`CREATE INDEX IF NOT EXISTS idx_clients_online_last_seen ON clients(online, last_seen DESC);`,
);
@@ -294,6 +298,7 @@ export function listClients(filters: ListFilters): ListResult {
sort,
statusFilter,
osFilter,
countryFilter,
allowedClientIds,
deniedClientIds,
} = filters;
@@ -319,6 +324,11 @@ export function listClients(filters: ListFilters): ListResult {
params.push(osFilter);
}
if (countryFilter && countryFilter !== "all") {
where.push("UPPER(COALESCE(country,'ZZ'))=?");
params.push(countryFilter.toUpperCase());
}
if (Array.isArray(allowedClientIds)) {
if (allowedClientIds.length === 0) {
where.push("1=0");
@@ -336,17 +346,22 @@ export function listClients(filters: ListFilters): ListResult {
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
const orderBy = (() => {
const bookmark = "bookmarked DESC";
switch (sort) {
case "ping_asc":
return "ORDER BY ping_ms IS NULL, ping_ms ASC";
return `ORDER BY ${bookmark}, ping_ms IS NULL, ping_ms ASC, id ASC`;
case "ping_desc":
return "ORDER BY ping_ms IS NULL, ping_ms DESC";
return `ORDER BY ${bookmark}, ping_ms IS NULL, ping_ms DESC, id ASC`;
case "host_asc":
return "ORDER BY LOWER(COALESCE(nickname, host)) ASC";
return `ORDER BY ${bookmark}, LOWER(COALESCE(nickname, host)) ASC, id ASC`;
case "host_desc":
return "ORDER BY LOWER(COALESCE(nickname, host)) DESC";
return `ORDER BY ${bookmark}, LOWER(COALESCE(nickname, host)) DESC, id ASC`;
case "country_asc":
return `ORDER BY ${bookmark}, LOWER(COALESCE(country, 'zz')) ASC, id ASC`;
case "country_desc":
return `ORDER BY ${bookmark}, LOWER(COALESCE(country, 'zz')) DESC, id ASC`;
default:
return "ORDER BY last_seen DESC";
return `ORDER BY ${bookmark}, last_seen DESC, id ASC`;
}
})();
@@ -362,7 +377,7 @@ export function listClients(filters: ListFilters): ListResult {
const rows = db
.query<any>(
`SELECT id, hwid, role, 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
`SELECT id, hwid, role, 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
FROM clients
${whereSql}
${orderBy}
@@ -387,6 +402,7 @@ export function listClients(filters: ListFilters): ListResult {
country: c.country || "ZZ",
pingMs: c.pingMs ?? null,
online: c.online === 1,
bookmarked: c.bookmarked === 1,
thumbnail: getThumbnail(c.id),
}));
@@ -591,6 +607,32 @@ export function clientExists(id: string): boolean {
return !!row?.id;
}
export function listDistinctCountries(): { code: string; count: number }[] {
const rows = db
.query<{ code: string; count: number }>(
`SELECT UPPER(COALESCE(NULLIF(country, ''), 'ZZ')) as code, COUNT(*) as count
FROM clients
GROUP BY UPPER(COALESCE(NULLIF(country, ''), 'ZZ'))
ORDER BY count DESC`,
)
.all();
return rows.map((r) => ({ code: r.code, count: Number(r.count) || 0 }));
}
export function setClientBookmark(id: string, bookmarked: boolean): boolean {
const result = db.run(
`UPDATE clients SET bookmarked=? WHERE id=?`,
bookmarked ? 1 : 0,
id,
);
return ((result as any)?.changes || 0) > 0;
}
export function getClientBookmark(id: string): boolean {
const row = db.query<{ bookmarked: number }>(`SELECT bookmarked FROM clients WHERE id=?`).get(id);
return row?.bookmarked === 1;
}
export interface BuildRecord {
id: string;
status: string;
@@ -11,6 +11,8 @@ import {
isIpBanned,
listBannedIps,
listClients,
listDistinctCountries,
setClientBookmark,
setClientNickname,
setClientTag,
setOnlineState,
@@ -71,8 +73,9 @@ export async function handleClientRoutes(
const sort = url.searchParams.get("sort") || "last_seen_desc";
const statusFilter = url.searchParams.get("status") || "all";
const osFilter = url.searchParams.get("os") || "all";
const countryFilter = url.searchParams.get("country") || "all";
if (user.role === "admin") {
const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter });
const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter, countryFilter });
return Response.json(result, { headers: deps.CORS_HEADERS });
}
@@ -100,12 +103,20 @@ export async function handleClientRoutes(
sort,
statusFilter,
osFilter,
countryFilter,
allowedClientIds,
deniedClientIds,
});
return Response.json(result, { headers: deps.CORS_HEADERS });
}
if (url.pathname === "/api/clients/countries") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
const countries = listDistinctCountries();
return Response.json({ countries }, { headers: deps.CORS_HEADERS });
}
if (url.pathname === "/api/clients/banned-ips") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
@@ -398,6 +409,35 @@ export async function handleClientRoutes(
);
}
const bookmarkMatch = url.pathname.match(/^\/api\/clients\/([^/]+)\/bookmark$/);
if (req.method === "PATCH" && bookmarkMatch) {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
const targetId = bookmarkMatch[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 bookmarked = !!body?.bookmarked;
const updated = setClientBookmark(targetId, bookmarked);
if (!updated) {
return Response.json({ error: "Client not found" }, { status: 404 });
}
return Response.json({ ok: true, bookmarked }, { headers: deps.CORS_HEADERS });
}
if (req.method === "POST") {
const cmdMatch = url.pathname.match(/^\/api\/clients\/(.+)\/command$/);
if (cmdMatch) {
+1
View File
@@ -31,6 +31,7 @@ export type ListFilters = {
sort: string;
statusFilter?: string;
osFilter?: string;
countryFilter?: string;
allowedClientIds?: string[];
deniedClientIds?: string[];
};