UI improvements
This commit is contained in:
@@ -6,6 +6,9 @@ const FALLBACK_POLL_MS = 30000;
|
||||
const PREF_REFRESH_KEY = "overlord_refresh_interval_seconds";
|
||||
let pollTimer = null;
|
||||
let render = () => {};
|
||||
let lastClientData = null;
|
||||
const pageCache = new Map();
|
||||
const PAGE_CACHE_LIMIT = 8;
|
||||
let dashboardWs = null;
|
||||
let dashboardWsConnected = false;
|
||||
let wsReconnectTimer = null;
|
||||
@@ -42,6 +45,45 @@ export function registerRenderer(fn) {
|
||||
render = fn;
|
||||
}
|
||||
|
||||
function clientQueryParams(page = state.page) {
|
||||
return new URLSearchParams({
|
||||
page: String(page),
|
||||
pageSize: String(state.pageSize),
|
||||
q: state.searchTerm,
|
||||
sort: state.sort,
|
||||
status: state.filterStatus || "all",
|
||||
os: state.filterOs || "all",
|
||||
country: state.filterCountry || "all",
|
||||
group: state.filterGroup || "all",
|
||||
});
|
||||
}
|
||||
|
||||
function rememberPage(key, data) {
|
||||
pageCache.set(key, data);
|
||||
while (pageCache.size > PAGE_CACHE_LIMIT) {
|
||||
pageCache.delete(pageCache.keys().next().value);
|
||||
}
|
||||
}
|
||||
|
||||
async function prefetchClientPage(page) {
|
||||
if (page < 1) return;
|
||||
const params = clientQueryParams(page);
|
||||
const key = params.toString();
|
||||
if (pageCache.has(key)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/clients?${key}`);
|
||||
if (!res.ok) return;
|
||||
rememberPage(key, await res.json());
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function prefetchAdjacentPages(data) {
|
||||
const totalPages = Math.max(1, Math.ceil((Number(data.total) || 0) / (Number(data.pageSize) || state.pageSize || 1)));
|
||||
const current = Number(data.page) || state.page;
|
||||
if (current < totalPages) prefetchClientPage(current + 1);
|
||||
if (current > 1) prefetchClientPage(current - 1);
|
||||
}
|
||||
|
||||
export async function loadWithOptions(options = {}) {
|
||||
const { force = false, reorder = false } = options;
|
||||
if (state.isLoading) {
|
||||
@@ -51,19 +93,19 @@ export async function loadWithOptions(options = {}) {
|
||||
}
|
||||
state.isLoading = true;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: String(state.page),
|
||||
pageSize: String(state.pageSize),
|
||||
q: state.searchTerm,
|
||||
sort: state.sort,
|
||||
status: state.filterStatus || "all",
|
||||
os: state.filterOs || "all",
|
||||
country: state.filterCountry || "all",
|
||||
group: state.filterGroup || "all",
|
||||
});
|
||||
const res = await fetch(`/api/clients?${params.toString()}`);
|
||||
const params = clientQueryParams();
|
||||
const cacheKey = params.toString();
|
||||
const cached = pageCache.get(cacheKey);
|
||||
if (force && cached) {
|
||||
lastClientData = cached;
|
||||
render(cached, { reorder, fromCache: true });
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/clients?${cacheKey}`);
|
||||
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||
const data = await res.json();
|
||||
lastClientData = data;
|
||||
rememberPage(cacheKey, data);
|
||||
|
||||
updateOsFilter(data.items);
|
||||
|
||||
@@ -73,6 +115,7 @@ export async function loadWithOptions(options = {}) {
|
||||
}
|
||||
state.lastDigest = digest;
|
||||
render(data, { reorder });
|
||||
prefetchAdjacentPages(data);
|
||||
const pag = document.getElementById("pagination");
|
||||
if (pag) pag.style.visibility = "";
|
||||
} catch (err) {
|
||||
@@ -89,6 +132,12 @@ export async function loadWithOptions(options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
export function renderCachedClients(options = {}) {
|
||||
if (!lastClientData) return false;
|
||||
render(lastClientData, { ...options, fromCache: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateOsFilter(items) {
|
||||
const osSelect = document.getElementById("filter-os");
|
||||
if (!osSelect) return;
|
||||
|
||||
@@ -1085,9 +1085,9 @@ article.card[data-bookmarked="true"] {
|
||||
}
|
||||
|
||||
.input-row:focus-within {
|
||||
border-color: rgba(92, 145, 255, 0.5);
|
||||
border-color: rgba(100, 116, 139, 0.82);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 0 0 3px rgba(92, 145, 255, 0.1);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.input-row i {
|
||||
@@ -1096,7 +1096,7 @@ article.card[data-bookmarked="true"] {
|
||||
}
|
||||
|
||||
.input-row:focus-within i {
|
||||
color: #7a5bff;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.input-row input {
|
||||
@@ -1252,6 +1252,25 @@ article.card[data-bookmarked="true"] {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.large-client-set .card-animate,
|
||||
.large-client-set .cv-row,
|
||||
.large-client-set .cv-card,
|
||||
.large-client-set .cv-trow,
|
||||
.large-client-set .cv-actions,
|
||||
.large-client-set .cv-card-actions,
|
||||
.large-client-set .cv-card-check,
|
||||
.large-client-set .layout-toggle-btn,
|
||||
.large-client-set .dashboard-menu summary,
|
||||
.large-client-set .dashboard-menu-panel,
|
||||
.large-client-set .cv-expand-btn i {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.large-client-set .cv-card:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@keyframes cardFlipFall {
|
||||
0% {
|
||||
opacity: 1;
|
||||
@@ -1885,6 +1904,7 @@ mark.search-current {
|
||||
|
||||
#filter-status,
|
||||
#filter-os,
|
||||
#filter-group,
|
||||
#sort {
|
||||
transition:
|
||||
border-color 150ms ease,
|
||||
@@ -1894,10 +1914,11 @@ mark.search-current {
|
||||
|
||||
#filter-status:focus,
|
||||
#filter-os:focus,
|
||||
#filter-group:focus,
|
||||
#sort:focus {
|
||||
border-color: rgba(99, 102, 241, 0.65) !important;
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.18);
|
||||
outline: none;
|
||||
border-color: rgba(100, 116, 139, 0.82) !important;
|
||||
box-shadow: none !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
#search-shell {
|
||||
@@ -1908,14 +1929,207 @@ mark.search-current {
|
||||
}
|
||||
|
||||
#search-shell:focus-within {
|
||||
border-color: rgba(99, 102, 241, 0.65) !important;
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.18);
|
||||
border-color: rgba(100, 116, 139, 0.82) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.dashboard-toolbar {
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.dashboard-search {
|
||||
flex: 1 1 420px;
|
||||
min-width: 240px;
|
||||
}
|
||||
.dashboard-toolbar-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.dashboard-icon-btn,
|
||||
.dashboard-toggle,
|
||||
.dashboard-menu > summary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(71, 85, 105, 0.72);
|
||||
background: #172033;
|
||||
color: #cbd5e1;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 150ms ease,
|
||||
border-color 150ms ease,
|
||||
background-color 150ms ease,
|
||||
color 150ms ease;
|
||||
}
|
||||
.dashboard-icon-btn:hover,
|
||||
.dashboard-toggle:hover,
|
||||
.dashboard-menu > summary:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(129, 140, 248, 0.55);
|
||||
background: #1f2a3e;
|
||||
color: #f8fafc;
|
||||
}
|
||||
.dashboard-toggle input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.dashboard-toggle:has(input:not(:checked)) {
|
||||
color: #64748b;
|
||||
background: rgba(15, 23, 42, 0.75);
|
||||
}
|
||||
.dashboard-menu {
|
||||
position: relative;
|
||||
}
|
||||
.dashboard-menu > summary {
|
||||
list-style: none;
|
||||
}
|
||||
.dashboard-menu > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.dashboard-menu[open] > summary {
|
||||
color: #818cf8;
|
||||
border-color: rgba(129, 140, 248, 0.55);
|
||||
background: #20294a;
|
||||
}
|
||||
.dashboard-menu-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
z-index: 40;
|
||||
min-width: 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(71, 85, 105, 0.78);
|
||||
border-radius: 8px;
|
||||
background: #111827;
|
||||
box-shadow: 0 18px 44px rgba(2, 6, 23, 0.55);
|
||||
}
|
||||
.dashboard-filter-panel {
|
||||
min-width: 250px;
|
||||
}
|
||||
.dashboard-menu-panel select,
|
||||
.dashboard-menu-panel button {
|
||||
width: 100%;
|
||||
}
|
||||
.dashboard-menu-panel select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
height: 36px;
|
||||
padding: 0 42px 0 10px;
|
||||
border: 1px solid rgba(71, 85, 105, 0.7) !important;
|
||||
border-radius: 6px;
|
||||
background-color: #172033 !important;
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='38' height='34' viewBox='0 0 38 34' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 5v24' stroke='%23475569' stroke-width='1'/%3E%3Cpath d='M14 13l5 5 5-5' fill='none' stroke='%2394a3b8' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") !important;
|
||||
background-position: right center !important;
|
||||
background-repeat: no-repeat !important;
|
||||
color: #dbe4f0;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.dashboard-menu-panel select option {
|
||||
background: #111827;
|
||||
color: #dbe4f0;
|
||||
}
|
||||
.dashboard-menu-panel select:hover {
|
||||
border-color: rgba(129, 140, 248, 0.55) !important;
|
||||
background-color: #1f2a3e !important;
|
||||
}
|
||||
.dashboard-menu-panel select:focus {
|
||||
border-color: rgba(100, 116, 139, 0.82) !important;
|
||||
box-shadow: none !important;
|
||||
outline: none !important;
|
||||
}
|
||||
.dashboard-menu-panel #country-picker-btn {
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 0 10px;
|
||||
background: #172033 !important;
|
||||
border-color: rgba(71, 85, 105, 0.7) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.dashboard-menu-panel #country-picker-btn:hover {
|
||||
border-color: rgba(129, 140, 248, 0.55) !important;
|
||||
background: #1f2a3e !important;
|
||||
}
|
||||
.dashboard-menu-panel #country-picker-btn .fa-chevron-down {
|
||||
align-self: stretch;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin: 0 -2px 0 auto;
|
||||
padding-left: 10px;
|
||||
border-left: 1px solid rgba(71, 85, 105, 0.7);
|
||||
}
|
||||
.dashboard-menu-panel #country-picker-btn:hover .fa-chevron-down {
|
||||
border-left-color: rgba(129, 140, 248, 0.45);
|
||||
}
|
||||
#country-picker-panel .p-2 > div {
|
||||
background: #172033 !important;
|
||||
border-color: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
#country-picker-panel .p-2 > div:focus-within {
|
||||
border-color: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
#country-search,
|
||||
#country-search:focus {
|
||||
background: transparent !important;
|
||||
border-color: transparent !important;
|
||||
box-shadow: none !important;
|
||||
outline: none !important;
|
||||
}
|
||||
.dashboard-fields-panel {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(92px, 1fr));
|
||||
gap: 7px 10px;
|
||||
min-width: 260px;
|
||||
}
|
||||
.dashboard-fields-panel label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
color: #cbd5e1;
|
||||
font-size: 12px;
|
||||
}
|
||||
.dashboard-fields-panel input {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: #6366f1;
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.dashboard-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
.dashboard-search {
|
||||
flex-basis: auto;
|
||||
width: 100%;
|
||||
}
|
||||
.dashboard-toolbar-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
#select-all,
|
||||
#clear-selection,
|
||||
#prev,
|
||||
#next {
|
||||
#next,
|
||||
#page-jump-go {
|
||||
transition:
|
||||
transform 150ms ease,
|
||||
border-color 150ms ease,
|
||||
@@ -1925,12 +2139,70 @@ mark.search-current {
|
||||
#select-all:hover,
|
||||
#clear-selection:hover,
|
||||
#prev:hover,
|
||||
#next:hover {
|
||||
#next:hover,
|
||||
#page-jump-go:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(99, 102, 241, 0.45) !important;
|
||||
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.4);
|
||||
}
|
||||
|
||||
.page-jump {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 40px;
|
||||
padding: 0 5px 0 10px;
|
||||
border: 1px solid rgba(71, 85, 105, 0.62);
|
||||
border-radius: 8px;
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.page-jump label,
|
||||
.page-jump-total {
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.page-jump input {
|
||||
width: 4ch;
|
||||
min-width: 4ch;
|
||||
max-width: 10ch;
|
||||
height: 28px;
|
||||
padding: 0 4px;
|
||||
border: 1px solid rgba(71, 85, 105, 0.72);
|
||||
border-radius: 6px;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.page-jump input:focus {
|
||||
border-color: rgba(100, 116, 139, 0.9);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.page-jump input::-webkit-outer-spin-button,
|
||||
.page-jump input::-webkit-inner-spin-button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-jump button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 28px;
|
||||
border: 1px solid rgba(71, 85, 105, 0.72);
|
||||
border-radius: 6px;
|
||||
background: #172033;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
#bulk-toolbar {
|
||||
background:
|
||||
radial-gradient(
|
||||
@@ -1969,8 +2241,8 @@ mark.search-current {
|
||||
--ui-surface-strong: rgba(11, 17, 33, 0.9);
|
||||
--ui-surface-elevated: rgba(14, 22, 40, 0.94);
|
||||
--ui-border: rgba(148, 163, 184, 0.2);
|
||||
--ui-border-strong: rgba(99, 102, 241, 0.4);
|
||||
--ui-ring: rgba(99, 102, 241, 0.28);
|
||||
--ui-border-strong: rgba(100, 116, 139, 0.82);
|
||||
--ui-ring: transparent;
|
||||
--ui-shadow: 0 14px 34px rgba(2, 6, 23, 0.35);
|
||||
}
|
||||
|
||||
@@ -2070,6 +2342,7 @@ textarea,
|
||||
#search,
|
||||
#filter-status,
|
||||
#filter-os,
|
||||
#filter-group,
|
||||
#sort,
|
||||
#console-input {
|
||||
background-color: var(--ui-surface);
|
||||
@@ -2089,11 +2362,12 @@ textarea:focus,
|
||||
.input-row:focus-within,
|
||||
#filter-status:focus,
|
||||
#filter-os:focus,
|
||||
#filter-group:focus,
|
||||
#sort:focus,
|
||||
#console-input:focus {
|
||||
border-color: var(--ui-border-strong) !important;
|
||||
box-shadow: 0 0 0 3px var(--ui-ring);
|
||||
outline: none;
|
||||
box-shadow: none !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.pill,
|
||||
@@ -2691,6 +2965,14 @@ tbody tr:hover {
|
||||
.cv-tone-pill-arch { color: #67e8f9; }
|
||||
.cv-tone-pill-kali { color: #7dd3fc; }
|
||||
.cv-tone-pill-fedora { color: #bfdbfe; }
|
||||
.cv-tone-pill-redhat { color: #fca5a5; }
|
||||
.cv-tone-pill-centos { color: #c4b5fd; }
|
||||
.cv-tone-pill-suse { color: #86efac; }
|
||||
.cv-tone-pill-raspi { color: #f9a8d4; }
|
||||
.cv-tone-pill-freebsd { color: #fca5a5; }
|
||||
.cv-tone-pill-alpine { color: #7dd3fc; }
|
||||
.cv-tone-pill-manjaro { color: #6ee7b7; }
|
||||
.cv-tone-pill-mint { color: #86efac; }
|
||||
.cv-tone-pill-linux { color: #86efac; }
|
||||
.cv-tone-pill-x64 { color: #5eead4; }
|
||||
.cv-tone-pill-arm { color: #7dd3fc; }
|
||||
@@ -2698,6 +2980,48 @@ tbody tr:hover {
|
||||
.cv-tone-pill-unknown { color: var(--cv-text-mute); }
|
||||
.cv-os, .cv-arch { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.cv-os i { font-size: 11px; }
|
||||
.cv-os .cv-os-icon,
|
||||
.cv-card-meta-bit .cv-os-icon {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
flex: 0 0 13px;
|
||||
font-family: inherit;
|
||||
font-size: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.cv-os .cv-os-icon::before,
|
||||
.cv-card-meta-bit .cv-os-icon::before {
|
||||
content: "";
|
||||
display: block;
|
||||
}
|
||||
.cv-os-icon-arch::before {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #67e8f9;
|
||||
clip-path: polygon(50% 3%, 95% 96%, 70% 82%, 61% 62%, 50% 39%, 39% 62%, 30% 82%, 5% 96%);
|
||||
}
|
||||
.cv-os-icon-mint::before {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border: 1px solid rgba(134, 239, 172, 0.9);
|
||||
border-radius: 3px;
|
||||
background: #16351f;
|
||||
box-shadow: inset 0 0 0 2px rgba(134, 239, 172, 0.16);
|
||||
}
|
||||
.cv-os-icon-mint::after {
|
||||
content: "LM";
|
||||
position: absolute;
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
font-size: 6px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
color: #86efac;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
/* Arch chip in table variant */
|
||||
.clients-table .cv-arch-chip {
|
||||
@@ -2720,7 +3044,7 @@ tbody tr:hover {
|
||||
.clients-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
z-index: 0;
|
||||
background: var(--cv-bg) !important;
|
||||
color: var(--cv-text-mute);
|
||||
font-size: 11px;
|
||||
@@ -2826,7 +3150,7 @@ tbody tr:hover {
|
||||
.cv-card.is-bookmarked { border-color: rgba(202, 138, 4, 0.5); }
|
||||
.cv-card:has(.client-checkbox:checked) {
|
||||
border-color: var(--cv-accent-border);
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.18);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.cv-card-header {
|
||||
@@ -3024,6 +3348,103 @@ tbody tr:hover {
|
||||
}
|
||||
.cv-card-hw-bit i { color: var(--cv-text-dim); font-size: 10px; }
|
||||
|
||||
.cv-cpu-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
height: 24px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cv-card-hw .cv-cpu-badge { flex: 0 1 auto; }
|
||||
.cv-cpu-badge span,
|
||||
.cv-cpu-badge strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.cv-cpu-apple {
|
||||
background: #050505;
|
||||
color: #ffffff;
|
||||
}
|
||||
.cv-cpu-apple i { color: #ffffff; font-size: 13px; }
|
||||
.cv-cpu-apple strong { font-weight: 800; letter-spacing: 0; }
|
||||
.cv-cpu-intel {
|
||||
background: #0068b5;
|
||||
color: #ffffff;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.cv-cpu-intel strong {
|
||||
font-weight: 800;
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.cv-cpu-amd {
|
||||
background: #151515;
|
||||
color: #ffffff;
|
||||
}
|
||||
.cv-cpu-amd strong { color: #f87171; letter-spacing: 0; }
|
||||
.cv-cpu-generic {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--cv-text-mute);
|
||||
}
|
||||
.cv-cpu-generic i { color: var(--cv-text-dim); }
|
||||
|
||||
.cv-battery {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
height: 24px;
|
||||
color: var(--cv-text-mute);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cv-battery-shell {
|
||||
position: relative;
|
||||
width: 28px;
|
||||
height: 14px;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 3px;
|
||||
padding: 2px;
|
||||
opacity: 0.95;
|
||||
}
|
||||
.cv-battery-shell::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: -4px;
|
||||
width: 3px;
|
||||
height: 6px;
|
||||
border-radius: 0 2px 2px 0;
|
||||
background: currentColor;
|
||||
}
|
||||
.cv-battery-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
min-width: 2px;
|
||||
border-radius: 2px;
|
||||
background: #86efac;
|
||||
}
|
||||
.cv-battery.is-low { color: #fb7185; }
|
||||
.cv-battery.is-mid .cv-battery-fill { background: #fcd34d; }
|
||||
.cv-battery.is-charging .cv-battery-fill { background: #60a5fa; }
|
||||
.cv-battery.is-low .cv-battery-fill { background: #fb7185; }
|
||||
.cv-battery-text {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--cv-text);
|
||||
}
|
||||
.cv-battery-text i { color: #60a5fa; font-size: 9px; }
|
||||
.cv-battery-label { color: var(--cv-text-dim); }
|
||||
|
||||
.cv-text-dim { color: var(--cv-text-dim); }
|
||||
|
||||
.cv-card-actions {
|
||||
@@ -3293,8 +3714,7 @@ body.sb-collapsed .sb-collapse-btn i {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.sb-link:focus-visible {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 1px;
|
||||
outline: none;
|
||||
}
|
||||
/* Collapsed: center icon, fill the rail */
|
||||
body.sb-collapsed .sb-link {
|
||||
@@ -3769,8 +4189,7 @@ body.sb-open #sb-backdrop { display: block; }
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.sb-group-btn:focus-visible {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 1px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sb-chevron {
|
||||
@@ -3868,8 +4287,7 @@ body.sb-collapsed .sb-link-child {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
#nav-reveal-btn:focus-visible {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#top-nav {
|
||||
@@ -3942,3 +4360,32 @@ body.sb-ready.nav-hidden #nav-reveal-btn {
|
||||
#nav-reveal-btn:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Keep dashboard controls below the app navigation while scrolling. */
|
||||
#top-nav,
|
||||
html.nav-pre-sidebar #top-nav,
|
||||
body.sb-ready #top-nav {
|
||||
z-index: 200 !important;
|
||||
}
|
||||
|
||||
#nav-reveal-btn,
|
||||
#sb-mobile-bar {
|
||||
z-index: 210 !important;
|
||||
}
|
||||
|
||||
.dashboard-toolbar {
|
||||
z-index: 20 !important;
|
||||
}
|
||||
|
||||
.dashboard-menu-panel,
|
||||
.nav-dd-menu {
|
||||
z-index: 40 !important;
|
||||
}
|
||||
|
||||
/* No browser/theme focus boxes anywhere in the app UI. */
|
||||
:where(a, button, input, select, textarea, summary, [tabindex]):focus,
|
||||
:where(a, button, input, select, textarea, summary, [tabindex]):focus-visible,
|
||||
:where(.input-row, #search-shell, #country-picker-panel .p-2 > div):focus-within {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { openMenu, closeMenu, openModal, wireModalClose, menu } from "./ui.js";
|
||||
import {
|
||||
registerRenderer,
|
||||
loadWithOptions,
|
||||
renderCachedClients,
|
||||
startAutoRefresh,
|
||||
sendCommand,
|
||||
requestPreview,
|
||||
@@ -16,14 +17,18 @@ import { ThumbnailLoader } from "./thumbnail-loader.js";
|
||||
const grid = document.getElementById("grid");
|
||||
const totalPill = document.getElementById("total-pill");
|
||||
const pageLabel = document.getElementById("page-label");
|
||||
const pageTotal = document.getElementById("page-total");
|
||||
const prevBtn = document.getElementById("prev");
|
||||
const nextBtn = document.getElementById("next");
|
||||
const pageJumpForm = document.getElementById("page-jump-form");
|
||||
const pageJumpInput = document.getElementById("page-jump-input");
|
||||
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 displayFieldsMenu = document.getElementById("display-fields-menu");
|
||||
const selectAllBtn = document.getElementById("select-all");
|
||||
const clearSelectionBtn = document.getElementById("clear-selection");
|
||||
const logoutBtn = document.getElementById("logout-btn");
|
||||
@@ -50,10 +55,38 @@ 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";
|
||||
const PREF_DISPLAY_FIELDS_KEY = "overlord_display_fields";
|
||||
const DEFAULT_DISPLAY_FIELDS = {
|
||||
user: true,
|
||||
ip: true,
|
||||
hwid: false,
|
||||
system: true,
|
||||
version: true,
|
||||
monitors: true,
|
||||
group: true,
|
||||
hardware: true,
|
||||
battery: true,
|
||||
};
|
||||
let displayFields = { ...DEFAULT_DISPLAY_FIELDS };
|
||||
|
||||
let currentUser = null;
|
||||
let contextCard = null;
|
||||
let availableOsList = new Set();
|
||||
let rendererInitialized = false;
|
||||
let lastKnownTotalPages = 1;
|
||||
|
||||
window.setDashboardPageBounds = (currentPage, totalPages) => {
|
||||
lastKnownTotalPages = Math.max(1, Number(totalPages) || 1);
|
||||
const safeCurrentPage = Math.max(1, Number(currentPage) || 1);
|
||||
if (pageLabel) pageLabel.textContent = "Page";
|
||||
if (pageTotal) pageTotal.textContent = String(lastKnownTotalPages);
|
||||
if (!pageJumpInput) return;
|
||||
pageJumpInput.max = String(lastKnownTotalPages);
|
||||
pageJumpInput.style.width = `${Math.min(8, Math.max(2, String(lastKnownTotalPages).length, String(safeCurrentPage).length)) + 2}ch`;
|
||||
if (document.activeElement !== pageJumpInput) {
|
||||
pageJumpInput.value = String(safeCurrentPage);
|
||||
}
|
||||
};
|
||||
|
||||
function setServerVersionLabel(version) {
|
||||
if (!serverVersionText) return;
|
||||
@@ -667,13 +700,69 @@ function setLayout(layout) {
|
||||
applyLayoutToggleUI(next);
|
||||
if (rendererSetLayout) rendererSetLayout(next);
|
||||
state.lastDigest = "";
|
||||
loadWithOptions({ force: true, reorder: true });
|
||||
if (!renderCachedClients({ reorder: true, force: true })) {
|
||||
loadWithOptions({ force: true, reorder: true });
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll("#layout-toggle .layout-toggle-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => setLayout(btn.dataset.layout));
|
||||
});
|
||||
|
||||
function loadDisplayFields() {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(PREF_DISPLAY_FIELDS_KEY) || "null");
|
||||
if (saved && typeof saved === "object") {
|
||||
displayFields = { ...DEFAULT_DISPLAY_FIELDS, ...saved };
|
||||
}
|
||||
} catch {
|
||||
displayFields = { ...DEFAULT_DISPLAY_FIELDS };
|
||||
}
|
||||
}
|
||||
|
||||
function syncDisplayFieldInputs() {
|
||||
displayFieldsMenu?.querySelectorAll("input[data-field]").forEach((input) => {
|
||||
input.checked = displayFields[input.dataset.field] !== false;
|
||||
});
|
||||
}
|
||||
|
||||
function saveDisplayFields() {
|
||||
localStorage.setItem(PREF_DISPLAY_FIELDS_KEY, JSON.stringify(displayFields));
|
||||
}
|
||||
|
||||
function rerenderDisplayFields() {
|
||||
state.lastDigest = "";
|
||||
if (!renderCachedClients({ reorder: true, force: true })) {
|
||||
loadWithOptions({ force: true, reorder: true });
|
||||
}
|
||||
}
|
||||
|
||||
loadDisplayFields();
|
||||
syncDisplayFieldInputs();
|
||||
|
||||
displayFieldsMenu?.addEventListener("change", (e) => {
|
||||
const input = e.target.closest("input[data-field]");
|
||||
if (!input) return;
|
||||
displayFields[input.dataset.field] = input.checked;
|
||||
saveDisplayFields();
|
||||
rerenderDisplayFields();
|
||||
});
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
document.querySelectorAll(".dashboard-menu[open]").forEach((details) => {
|
||||
if (!details.contains(e.target)) details.removeAttribute("open");
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll(".dashboard-menu").forEach((details) => {
|
||||
details.addEventListener("toggle", () => {
|
||||
if (!details.open) return;
|
||||
document.querySelectorAll(".dashboard-menu[open]").forEach((other) => {
|
||||
if (other !== details) other.removeAttribute("open");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
let dashboardThumbnailLoader = null;
|
||||
|
||||
async function isDashboardThumbnailEnabled() {
|
||||
@@ -729,6 +818,8 @@ async function setupDashboardThumbnailLoader() {
|
||||
}
|
||||
|
||||
function initializeRenderer() {
|
||||
if (rendererInitialized) return;
|
||||
rendererInitialized = true;
|
||||
const savedLayout = localStorage.getItem(PREF_LAYOUT_KEY) || "rows";
|
||||
if (grid) grid.dataset.layout = ["rows", "table", "cards"].includes(savedLayout) ? savedLayout : "rows";
|
||||
applyLayoutToggleUI(grid?.dataset.layout || "rows");
|
||||
@@ -750,6 +841,7 @@ function initializeRenderer() {
|
||||
requestThumbnail,
|
||||
pingClient: (id) => sendCommand(id, "ping"),
|
||||
userRole: currentUser?.role,
|
||||
getDisplayFields: () => displayFields,
|
||||
});
|
||||
rendererSetLayout = rSetLayout;
|
||||
registerRenderer(renderMerge);
|
||||
@@ -758,7 +850,7 @@ function initializeRenderer() {
|
||||
loadWithOptions();
|
||||
startAutoRefresh();
|
||||
|
||||
if (typeof anime !== "undefined") {
|
||||
if (typeof anime !== "undefined" && !document.documentElement.classList.contains("large-client-set")) {
|
||||
anime
|
||||
.timeline({ easing: "easeOutQuad" })
|
||||
.add({
|
||||
@@ -874,7 +966,7 @@ import("./country-picker.js").then(({ initCountryPicker }) => {
|
||||
}
|
||||
|
||||
const savedSort = localStorage.getItem(PREF_SORT_KEY);
|
||||
const validSorts = ["stable", "last_seen_desc", "host_asc", "ping_asc", "ping_desc", "country_asc", "country_desc", "group_asc", "group_desc"];
|
||||
const validSorts = ["stable", "last_seen_desc", "host_asc", "ping_asc", "ping_desc", "country_asc", "country_desc", "group_asc", "group_desc", "admin_first"];
|
||||
if (savedSort && validSorts.includes(savedSort)) {
|
||||
state.sort = savedSort;
|
||||
if (sortSelect) sortSelect.value = savedSort;
|
||||
@@ -1448,6 +1540,17 @@ nextBtn?.addEventListener("click", () => {
|
||||
loadWithOptions({ force: true, reorder: true });
|
||||
});
|
||||
|
||||
pageJumpForm?.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
const requested = Math.trunc(Number(pageJumpInput?.value || 1));
|
||||
const targetPage = Math.min(lastKnownTotalPages, Math.max(1, Number.isFinite(requested) ? requested : 1));
|
||||
if (pageJumpInput) pageJumpInput.value = String(targetPage);
|
||||
if (targetPage === state.page) return;
|
||||
state.page = targetPage;
|
||||
state.lastDigest = "";
|
||||
loadWithOptions({ force: true, reorder: true });
|
||||
});
|
||||
|
||||
window.addEventListener("click", (e) => {
|
||||
const target = e.target;
|
||||
if (target.closest && target.closest(".command-btn")) return;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TabulatorFull as Tabulator } from "/vendor/tabulator/tabulator_esm.min.js";
|
||||
|
||||
const enrollmentTableEl = document.getElementById("enrollment-table");
|
||||
const body = document.getElementById("enrollment-body");
|
||||
const loadingRow = document.getElementById("enrollment-loading");
|
||||
const emptyEl = document.getElementById("enrollment-empty");
|
||||
const selectAllEl = document.getElementById("select-all");
|
||||
const bulkApproveBtn = document.getElementById("bulk-approve-btn");
|
||||
const bulkDenyBtn = document.getElementById("bulk-deny-btn");
|
||||
const statPending = document.getElementById("stat-pending");
|
||||
@@ -10,9 +10,9 @@ const statDenied = document.getElementById("stat-denied");
|
||||
const statBannedIps = document.getElementById("stat-banned-ips");
|
||||
const searchInput = document.getElementById("search-input");
|
||||
const bannedIpsSection = document.getElementById("banned-ips-section");
|
||||
const bannedIpsTableEl = document.getElementById("banned-ips-table");
|
||||
const bannedIpsBody = document.getElementById("banned-ips-body");
|
||||
const bannedIpsEmpty = document.getElementById("banned-ips-empty");
|
||||
const clientsTable = document.getElementById("clients-table-wrap");
|
||||
const clientsTable = document.getElementById("enrollment-table")?.closest(".bg-slate-900\\/50");
|
||||
const addBanBtn = document.getElementById("add-ban-btn");
|
||||
const manualBanForm = document.getElementById("manual-ban-form");
|
||||
const banIpInput = document.getElementById("ban-ip-input");
|
||||
@@ -44,10 +44,8 @@ const denyReasonModalBackdrop = document.getElementById("deny-reason-modal-backd
|
||||
let currentFilter = "pending";
|
||||
let searchQuery = "";
|
||||
let clients = [];
|
||||
let enrollmentTable = null;
|
||||
let bannedIpsTable = null;
|
||||
const selectedIds = new Set();
|
||||
const expandedCells = new Set(); // tracks "clientId:field" keys
|
||||
let lastEnrollmentDigest = "";
|
||||
|
||||
const SUSPICIOUS_FLAG_LABELS = {
|
||||
hwid_flood: "HWID Flood (40+ same hardware ID)",
|
||||
@@ -103,8 +101,9 @@ async function loadStats() {
|
||||
}
|
||||
|
||||
async function loadClients() {
|
||||
const hasRows = body.querySelector("tr[data-id]");
|
||||
loadingRow.classList.toggle("hidden", !!hasRows);
|
||||
emptyEl.classList.add("hidden");
|
||||
if (enrollmentTable) enrollmentTable.replaceData([]);
|
||||
|
||||
const fetchFilter = currentFilter === "suspicious" ? "all" : currentFilter;
|
||||
try {
|
||||
@@ -136,26 +135,84 @@ async function loadClients() {
|
||||
})
|
||||
: base;
|
||||
|
||||
loadingRow.classList.add("hidden");
|
||||
|
||||
const showBulkActions = (currentFilter === "pending" || currentFilter === "suspicious") &&
|
||||
filtered.some((c) => (c.enrollmentStatus || "pending") === "pending");
|
||||
approveAllBtn.classList.toggle("hidden", !showBulkActions);
|
||||
denyAllBtn.classList.toggle("hidden", !showBulkActions);
|
||||
|
||||
const visibleIds = new Set(filtered.map((c) => c.id));
|
||||
for (const id of selectedIds) {
|
||||
if (!visibleIds.has(id)) selectedIds.delete(id);
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
lastEnrollmentDigest = `empty:${currentFilter}:${q}`;
|
||||
body.querySelectorAll("tr:not(#enrollment-loading)").forEach((r) => r.remove());
|
||||
emptyEl.classList.remove("hidden");
|
||||
updateBulkButtons();
|
||||
return;
|
||||
}
|
||||
|
||||
if (enrollmentTable) enrollmentTable.replaceData(filtered);
|
||||
const nextDigest = tableDigest(filtered, [
|
||||
"id",
|
||||
"host",
|
||||
"user",
|
||||
"os",
|
||||
"cpu",
|
||||
"gpu",
|
||||
"ram",
|
||||
"ip",
|
||||
"country",
|
||||
"keyFingerprint",
|
||||
"lastSeen",
|
||||
"enrollmentStatus",
|
||||
"denyReason",
|
||||
"suspiciousFlags",
|
||||
]);
|
||||
if (nextDigest === lastEnrollmentDigest) {
|
||||
updateBulkButtons();
|
||||
return;
|
||||
}
|
||||
lastEnrollmentDigest = nextDigest;
|
||||
|
||||
const selectedBeforeRender = new Set(getSelectedIds());
|
||||
body.querySelectorAll("tr:not(#enrollment-loading)").forEach((r) => r.remove());
|
||||
|
||||
for (const c of filtered) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "hover:bg-slate-800/40 transition-colors";
|
||||
tr.dataset.id = c.id;
|
||||
|
||||
const statusPill = statusBadgeWithReason(c.enrollmentStatus || "pending", c.denyReason);
|
||||
const fp = c.keyFingerprint ? c.keyFingerprint.substring(0, 16) + "..." : "-";
|
||||
const lastSeen = c.lastSeen ? timeAgo(c.lastSeen) : "-";
|
||||
|
||||
tr.innerHTML = `
|
||||
<td class="px-4 py-3"><input type="checkbox" class="row-check h-4 w-4 rounded border-slate-600" data-id="${esc(c.id)}" ${selectedBeforeRender.has(c.id) ? "checked" : ""} /></td>
|
||||
<td class="px-4 py-3 text-sm font-medium text-slate-200">${esc(c.host || c.id)}${suspiciousBadges(c.suspiciousFlags)}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-400">${esc(c.user || "-")}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-400">${esc(c.os || "-")}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-400">${expandableCell(c.id, "cpu", c.cpu)}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-400">${c.gpu ? dedupeGpu(c.gpu) : esc("-")}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-400">${esc(c.ram || "-")}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-400">${esc(c.ip || "-")}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-400">${esc(c.country || "-")}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-500 font-mono">${esc(fp)}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-500">${lastSeen}</td>
|
||||
<td class="px-4 py-3">${statusPill}</td>
|
||||
<td class="px-4 py-3"><div class="flex items-center justify-end gap-2 flex-nowrap">${actionButtons(c)}</div></td>
|
||||
`;
|
||||
tr.classList.add("cursor-pointer");
|
||||
body.appendChild(tr);
|
||||
}
|
||||
updateBulkButtons();
|
||||
}
|
||||
|
||||
function tableDigest(items, fields) {
|
||||
return JSON.stringify(items.map((item) => {
|
||||
const row = {};
|
||||
for (const field of fields) row[field] = item[field] ?? null;
|
||||
return row;
|
||||
}));
|
||||
}
|
||||
|
||||
function statusBadge(status) {
|
||||
const map = {
|
||||
pending:
|
||||
@@ -232,129 +289,6 @@ function expandableCell(clientId, field, value) {
|
||||
`</span>`;
|
||||
}
|
||||
|
||||
function selectionHeaderFormatter() {
|
||||
const rows = enrollmentTable?.getData() || [];
|
||||
const allChecked = rows.length > 0 && rows.every((row) => selectedIds.has(row.id));
|
||||
return `<input type="checkbox" class="enrollment-select-all h-4 w-4 rounded border-slate-600" ${allChecked ? "checked" : ""} />`;
|
||||
}
|
||||
|
||||
function rowSelectionFormatter(cell) {
|
||||
const id = cell.getRow().getData().id;
|
||||
return `<input type="checkbox" class="row-check h-4 w-4 rounded border-slate-600" data-id="${esc(id)}" ${selectedIds.has(id) ? "checked" : ""} />`;
|
||||
}
|
||||
|
||||
function initEnrollmentTable() {
|
||||
if (!enrollmentTableEl) return;
|
||||
enrollmentTable = new Tabulator(enrollmentTableEl, {
|
||||
data: [],
|
||||
height: "32rem",
|
||||
layout: "fitDataStretch",
|
||||
placeholder: "No clients in this category.",
|
||||
index: "id",
|
||||
virtualDom: true,
|
||||
columns: [
|
||||
{
|
||||
title: "",
|
||||
field: "id",
|
||||
width: 54,
|
||||
hozAlign: "center",
|
||||
headerSort: false,
|
||||
headerFormatter: selectionHeaderFormatter,
|
||||
formatter: rowSelectionFormatter,
|
||||
},
|
||||
{
|
||||
title: "Host",
|
||||
field: "host",
|
||||
width: 190,
|
||||
formatter: (cell) => {
|
||||
const c = cell.getRow().getData();
|
||||
return `<span class="font-medium text-slate-200">${esc(c.host || c.id)}</span>${suspiciousBadges(c.suspiciousFlags)}`;
|
||||
},
|
||||
},
|
||||
{ title: "User", field: "user", width: 140, formatter: (cell) => esc(cell.getValue() || "-") },
|
||||
{ title: "OS", field: "os", width: 120, formatter: (cell) => esc(cell.getValue() || "-") },
|
||||
{
|
||||
title: "CPU",
|
||||
field: "cpu",
|
||||
width: 210,
|
||||
formatter: (cell) => {
|
||||
const c = cell.getRow().getData();
|
||||
return expandableCell(c.id, "cpu", c.cpu);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "GPU",
|
||||
field: "gpu",
|
||||
width: 220,
|
||||
formatter: (cell) => cell.getValue() ? dedupeGpu(cell.getValue()) : esc("-"),
|
||||
},
|
||||
{ title: "RAM", field: "ram", width: 100, formatter: (cell) => esc(cell.getValue() || "-") },
|
||||
{ title: "IP", field: "ip", width: 140, formatter: (cell) => esc(cell.getValue() || "-") },
|
||||
{ title: "Country", field: "country", width: 120, formatter: (cell) => esc(cell.getValue() || "-") },
|
||||
{
|
||||
title: "Key Fingerprint",
|
||||
field: "keyFingerprint",
|
||||
width: 170,
|
||||
formatter: (cell) => `<span class="font-mono text-slate-500">${esc(cell.getValue() ? cell.getValue().substring(0, 16) + "..." : "-")}</span>`,
|
||||
},
|
||||
{
|
||||
title: "Last Seen",
|
||||
field: "lastSeen",
|
||||
width: 120,
|
||||
sorter: "number",
|
||||
formatter: (cell) => `<span class="text-slate-500">${cell.getValue() ? timeAgo(cell.getValue()) : "-"}</span>`,
|
||||
},
|
||||
{
|
||||
title: "Status",
|
||||
field: "enrollmentStatus",
|
||||
width: 150,
|
||||
formatter: (cell) => {
|
||||
const c = cell.getRow().getData();
|
||||
return statusBadgeWithReason(c.enrollmentStatus || "pending", c.denyReason);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Actions",
|
||||
field: "id",
|
||||
width: 300,
|
||||
headerSort: false,
|
||||
hozAlign: "right",
|
||||
formatter: (cell) => `<div class="flex items-center justify-end gap-2 flex-nowrap">${actionButtons(cell.getRow().getData())}</div>`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function initBannedIpsTable() {
|
||||
if (!bannedIpsTableEl) return;
|
||||
bannedIpsTable = new Tabulator(bannedIpsTableEl, {
|
||||
data: [],
|
||||
height: "24rem",
|
||||
layout: "fitColumns",
|
||||
placeholder: "No banned IPs.",
|
||||
index: "ip",
|
||||
columns: [
|
||||
{ title: "IP Address", field: "ip", width: 180, formatter: (cell) => `<span class="font-mono text-slate-200">${esc(cell.getValue())}</span>` },
|
||||
{ title: "Reason", field: "reason", formatter: (cell) => esc(cell.getValue() || "-") },
|
||||
{
|
||||
title: "Banned At",
|
||||
field: "createdAt",
|
||||
width: 150,
|
||||
sorter: "number",
|
||||
formatter: (cell) => `<span class="text-slate-500">${cell.getValue() ? timeAgo(cell.getValue()) : "-"}</span>`,
|
||||
},
|
||||
{
|
||||
title: "Actions",
|
||||
field: "ip",
|
||||
width: 120,
|
||||
headerSort: false,
|
||||
hozAlign: "right",
|
||||
formatter: (cell) => `<button class="act-unban px-2 py-1 rounded text-xs font-medium bg-slate-600 hover:bg-slate-700 text-white" data-ip="${esc(cell.getValue())}"><i class="fa-solid fa-unlock mr-1"></i>Unban</button>`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function timeAgo(ts) {
|
||||
const diff = Date.now() - ts;
|
||||
if (diff < 60000) return "just now";
|
||||
@@ -401,24 +335,26 @@ async function bulkAction(action) {
|
||||
} catch (e) {
|
||||
if (window.showToast) window.showToast(`Failed: ${e.message}`, "error");
|
||||
}
|
||||
selectedIds.clear();
|
||||
selectAllEl.checked = false;
|
||||
await Promise.all([loadClients(), loadStats()]);
|
||||
}
|
||||
|
||||
function getSelectedIds() {
|
||||
return [...selectedIds];
|
||||
return [...document.querySelectorAll(".row-check:checked")].map(
|
||||
(el) => el.dataset.id,
|
||||
);
|
||||
}
|
||||
|
||||
function updateBulkButtons() {
|
||||
const visibleIds = new Set((enrollmentTable?.getData() || []).map((row) => row.id));
|
||||
for (const id of selectedIds) {
|
||||
if (!visibleIds.has(id)) selectedIds.delete(id);
|
||||
}
|
||||
const count = selectedIds.size;
|
||||
const rowChecks = [...document.querySelectorAll(".row-check")];
|
||||
const selected = getSelectedIds();
|
||||
const count = selected.length;
|
||||
bulkApproveBtn.classList.toggle("hidden", count === 0);
|
||||
bulkDenyBtn.classList.toggle("hidden", count === 0);
|
||||
bulkBanBtn.classList.toggle("hidden", count === 0);
|
||||
enrollmentTable?.redraw(true);
|
||||
if (selectAllEl) {
|
||||
selectAllEl.checked = rowChecks.length > 0 && rowChecks.every((cb) => cb.checked);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tab switching ──────────────────────────────────────────────────
|
||||
@@ -454,7 +390,7 @@ document.querySelectorAll(".enrollment-tab").forEach((tab) => {
|
||||
});
|
||||
|
||||
// ── Table delegation ───────────────────────────────────────────────
|
||||
enrollmentTableEl?.addEventListener("click", (e) => {
|
||||
body.addEventListener("click", (e) => {
|
||||
// Expandable CPU/GPU cells
|
||||
const expander = e.target.closest(".hw-expand");
|
||||
if (expander) {
|
||||
@@ -469,6 +405,19 @@ enrollmentTableEl?.addEventListener("click", (e) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const row = e.target.closest("tr[data-id]");
|
||||
if (
|
||||
row &&
|
||||
!e.target.closest("button, input, a, select, textarea")
|
||||
) {
|
||||
const checkbox = row.querySelector(".row-check");
|
||||
if (checkbox) {
|
||||
checkbox.checked = !checkbox.checked;
|
||||
updateBulkButtons();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = e.target.closest("button");
|
||||
if (!btn) return;
|
||||
const id = btn.dataset.id;
|
||||
@@ -479,24 +428,11 @@ enrollmentTableEl?.addEventListener("click", (e) => {
|
||||
else if (btn.classList.contains("act-ban-ip")) banClientIp(id);
|
||||
});
|
||||
|
||||
enrollmentTableEl?.addEventListener("change", (e) => {
|
||||
const selectAll = e.target.closest(".enrollment-select-all");
|
||||
if (selectAll) {
|
||||
const rows = enrollmentTable?.getData() || [];
|
||||
for (const row of rows) {
|
||||
if (selectAll.checked) selectedIds.add(row.id);
|
||||
else selectedIds.delete(row.id);
|
||||
}
|
||||
updateBulkButtons();
|
||||
return;
|
||||
}
|
||||
|
||||
const checkbox = e.target.closest(".row-check");
|
||||
if (!checkbox) return;
|
||||
const id = checkbox.dataset.id;
|
||||
if (!id) return;
|
||||
if (checkbox.checked) selectedIds.add(id);
|
||||
else selectedIds.delete(id);
|
||||
body.addEventListener("change", () => updateBulkButtons());
|
||||
selectAllEl.addEventListener("change", () => {
|
||||
document.querySelectorAll(".row-check").forEach((cb) => {
|
||||
cb.checked = selectAllEl.checked;
|
||||
});
|
||||
updateBulkButtons();
|
||||
});
|
||||
|
||||
@@ -575,8 +511,8 @@ async function banClientIp(clientId) {
|
||||
}
|
||||
|
||||
async function loadBannedIps() {
|
||||
bannedIpsBody.innerHTML = "";
|
||||
bannedIpsEmpty.classList.add("hidden");
|
||||
if (bannedIpsTable) bannedIpsTable.replaceData([]);
|
||||
|
||||
try {
|
||||
const data = await api("/api/enrollment/banned-ips");
|
||||
@@ -587,13 +523,27 @@ async function loadBannedIps() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (bannedIpsTable) bannedIpsTable.replaceData(items);
|
||||
for (const entry of items) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "hover:bg-slate-800/40 transition-colors";
|
||||
tr.innerHTML = `
|
||||
<td class="px-4 py-3 text-sm font-mono text-slate-200">${esc(entry.ip)}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-400">${esc(entry.reason || "-")}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-500">${entry.createdAt ? timeAgo(entry.createdAt) : "-"}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<button class="act-unban px-2 py-1 rounded text-xs font-medium bg-slate-600 hover:bg-slate-700 text-white" data-ip="${esc(entry.ip)}">
|
||||
<i class="fa-solid fa-unlock mr-1"></i>Unban
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
bannedIpsBody.appendChild(tr);
|
||||
}
|
||||
} catch {
|
||||
bannedIpsEmpty.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
bannedIpsTableEl?.addEventListener("click", async (e) => {
|
||||
bannedIpsBody.addEventListener("click", async (e) => {
|
||||
const btn = e.target.closest("button");
|
||||
if (!btn) return;
|
||||
if (btn.classList.contains("act-unban")) {
|
||||
@@ -773,8 +723,6 @@ denyReasonModalConfirm.addEventListener("click", async () => {
|
||||
});
|
||||
|
||||
// ── Init ───────────────────────────────────────────────────────────
|
||||
initEnrollmentTable();
|
||||
initBannedIpsTable();
|
||||
loadSettings();
|
||||
loadStats();
|
||||
loadClients();
|
||||
|
||||
@@ -150,10 +150,64 @@ function shortenCpu(raw = "") {
|
||||
.slice(0, 38);
|
||||
}
|
||||
|
||||
function cpuBrand(raw = "") {
|
||||
const text = String(raw).toLowerCase();
|
||||
const appleMatch = text.match(/\bapple\s+(m\d+(?:\s*(?:pro|max|ultra))?)\b/i);
|
||||
if (appleMatch) {
|
||||
return { vendor: "apple", chip: appleMatch[1].replace(/\s+/g, " ").toUpperCase() };
|
||||
}
|
||||
const intelMatch = text.match(/\b(?:intel(?:\(r\))?\s+)?(?:core\(tm\)\s+)?(i[3579]-\d{3,5}[a-z0-9]*)\b/i);
|
||||
if (text.includes("intel") || intelMatch) {
|
||||
return { vendor: "intel", chip: intelMatch ? intelMatch[1].toUpperCase() : "CPU" };
|
||||
}
|
||||
const amdMatch = text.match(/\b(?:amd\s+)?(?:ryzen\s+)?([3579]\s+\d{4}[a-z0-9]*)\b/i);
|
||||
if (text.includes("amd") || text.includes("ryzen")) {
|
||||
return { vendor: "amd", chip: amdMatch ? amdMatch[1].replace(/\s+/g, " ").toUpperCase() : "RYZEN" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function cpuBadgeHtml(raw = "", { compact = false } = {}) {
|
||||
const brand = cpuBrand(raw);
|
||||
const label = shortenCpu(raw) || "CPU";
|
||||
if (!brand) {
|
||||
return `<span class="cv-cpu-badge cv-cpu-generic" title="${escapeHtml(raw || label)}"><i class="fa-solid fa-microchip"></i><span>${escapeHtml(label)}</span></span>`;
|
||||
}
|
||||
if (brand.vendor === "apple") {
|
||||
return `<span class="cv-cpu-badge cv-cpu-apple" title="${escapeHtml(raw)}"><i class="fa-brands fa-apple"></i><strong>${escapeHtml(brand.chip)}</strong></span>`;
|
||||
}
|
||||
if (brand.vendor === "intel") {
|
||||
return `<span class="cv-cpu-badge cv-cpu-intel" title="${escapeHtml(raw)}"><strong>intel</strong>${compact ? "" : `<span>${escapeHtml(brand.chip)}</span>`}</span>`;
|
||||
}
|
||||
return `<span class="cv-cpu-badge cv-cpu-amd" title="${escapeHtml(raw)}"><strong>AMD</strong>${compact ? "" : `<span>${escapeHtml(brand.chip)}</span>`}</span>`;
|
||||
}
|
||||
|
||||
function batteryInfo(client) {
|
||||
const n = Number(client.batteryPercent);
|
||||
if (!Number.isFinite(n) || n <= 0 || n > 100) return null;
|
||||
return { percent: Math.round(n), charging: client.batteryCharging === true };
|
||||
}
|
||||
|
||||
function batteryHtml(client, { compact = false } = {}) {
|
||||
const battery = batteryInfo(client);
|
||||
if (!battery) return "";
|
||||
const tone = battery.percent < 20 ? "is-low" : battery.percent <= 35 ? "is-mid" : "is-good";
|
||||
const title = `Battery ${battery.percent}%${battery.charging ? " charging" : ""}`;
|
||||
return `<span class="cv-battery ${tone} ${battery.charging ? "is-charging" : ""}" title="${escapeHtml(title)}">
|
||||
<span class="cv-battery-shell"><span class="cv-battery-fill" style="width:${battery.percent}%"></span></span>
|
||||
<span class="cv-battery-text">${battery.percent}%${battery.charging ? ` <i class="fa-solid fa-bolt"></i>` : ""}</span>
|
||||
${compact ? "" : `<span class="cv-battery-label">${battery.charging ? "Charging" : "Battery"}</span>`}
|
||||
</span>`;
|
||||
}
|
||||
|
||||
function shortOsLabel(osRaw = "") {
|
||||
const badge = osBadge(osRaw || "");
|
||||
if (badge?.label && badge.label !== "Linux" && badge.label !== "Windows") return badge.label;
|
||||
const o = String(osRaw).toLowerCase();
|
||||
if (o.includes("windows 11")) return "Win 11";
|
||||
if (o.includes("windows 10")) return "Win 10";
|
||||
const winVersion = o.match(/\bwindows\s+(10|11)\b/i);
|
||||
if (winVersion) return `W${winVersion[1]}`;
|
||||
if (o.includes("windows 11")) return "W11";
|
||||
if (o.includes("windows 10")) return "W10";
|
||||
if (o.includes("windows")) return "Win";
|
||||
if (o.includes("ubuntu")) return "Ubuntu";
|
||||
if (o.includes("debian")) return "Debian";
|
||||
@@ -175,15 +229,31 @@ export function createRenderer({
|
||||
requestThumbnail,
|
||||
pingClient,
|
||||
userRole,
|
||||
getDisplayFields,
|
||||
}) {
|
||||
const isViewer = userRole === "viewer";
|
||||
const MAX_ANIMATED_CARDS = 120;
|
||||
const LARGE_CLIENT_THRESHOLD = 50_000;
|
||||
const MAX_ANIMATED_CARDS = 0;
|
||||
const INSERT_BATCH_SIZE = 40;
|
||||
const TOUCH_LONG_PRESS_MS = 520;
|
||||
const TOUCH_MOVE_CANCEL_PX = 10;
|
||||
let renderToken = 0;
|
||||
let gridDelegated = false;
|
||||
let currentLayout = (grid?.dataset.layout || "rows").toLowerCase();
|
||||
let tableScaffoldDigest = "";
|
||||
let currentDisplayDigest = "";
|
||||
|
||||
function displayFields() {
|
||||
return typeof getDisplayFields === "function" ? getDisplayFields() || {} : {};
|
||||
}
|
||||
|
||||
function showField(name) {
|
||||
return displayFields()[name] !== false;
|
||||
}
|
||||
|
||||
function displayDigest() {
|
||||
return JSON.stringify(displayFields());
|
||||
}
|
||||
|
||||
function getCardContainer() {
|
||||
if (currentLayout === "table") {
|
||||
@@ -194,7 +264,10 @@ export function createRenderer({
|
||||
|
||||
function ensureLayoutScaffold() {
|
||||
if (currentLayout === "table") {
|
||||
if (grid.querySelector("table.clients-table")) return;
|
||||
const prefs = displayFields();
|
||||
const scaffoldDigest = displayDigest();
|
||||
if (grid.querySelector("table.clients-table") && tableScaffoldDigest === scaffoldDigest) return;
|
||||
tableScaffoldDigest = scaffoldDigest;
|
||||
grid.innerHTML = `
|
||||
<table class="clients-table">
|
||||
<thead>
|
||||
@@ -205,9 +278,9 @@ export function createRenderer({
|
||||
<th>Client</th>
|
||||
<th class="cv-th-status">Status</th>
|
||||
<th class="cv-th-last">Last seen</th>
|
||||
<th class="cv-th-system">System</th>
|
||||
${prefs.system !== false ? `<th class="cv-th-system">System</th>` : ""}
|
||||
<th class="cv-th-ping">Ping</th>
|
||||
<th class="cv-th-group">Group</th>
|
||||
${prefs.group !== false ? `<th class="cv-th-group">Group</th>` : ""}
|
||||
<th class="cv-th-actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -215,6 +288,7 @@ export function createRenderer({
|
||||
</table>
|
||||
`;
|
||||
} else {
|
||||
tableScaffoldDigest = "";
|
||||
const t = grid.querySelector("table.clients-table");
|
||||
if (t) t.remove();
|
||||
}
|
||||
@@ -453,14 +527,16 @@ export function createRenderer({
|
||||
}
|
||||
}
|
||||
if (!needsReorder) return;
|
||||
const byId = new Map();
|
||||
cards.forEach((card) => byId.set(card.dataset.id, card));
|
||||
items.forEach((client) => {
|
||||
const card = container.querySelector(`${ROW_SELECTOR}[data-id="${client.id}"]`);
|
||||
const card = byId.get(client.id);
|
||||
if (card) container.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function cardDigest(c) {
|
||||
return `${currentLayout}|${c.id}|${!!c.online}|${c.lastSeen}|${c.pingMs}|${c.host}|${c.user}|${c.os}|${c.arch}|${c.version}|${c.monitors}|${c.country}|${c.nickname}|${c.customTag}|${c.customTagNote}|${!!c.bookmarked}|${!!c.isAdmin}|${c.elevation}|${c.cpu}|${c.gpu}|${c.ram}|${c.hwid}|${c.disconnectReason}|${c.disconnectDetail}|${c.groupId}|${c.groupName}|${c.groupColor}|${!!c.notificationsMuted}`;
|
||||
return `${currentDisplayDigest}|${currentLayout}|${c.id}|${!!c.online}|${c.lastSeen}|${c.pingMs}|${c.host}|${c.user}|${c.os}|${c.arch}|${c.version}|${c.monitors}|${c.country}|${c.nickname}|${c.customTag}|${c.customTagNote}|${!!c.bookmarked}|${!!c.isAdmin}|${c.elevation}|${c.cpu}|${c.gpu}|${c.ram}|${c.batteryPercent}|${c.batteryCharging}|${c.hwid}|${c.disconnectReason}|${c.disconnectDetail}|${c.groupId}|${c.groupName}|${c.groupColor}|${!!c.notificationsMuted}`;
|
||||
}
|
||||
|
||||
function cardThumbDigest(c) {
|
||||
@@ -482,31 +558,40 @@ export function createRenderer({
|
||||
|
||||
function renderMerge(data, options = {}) {
|
||||
setupGridDelegation();
|
||||
currentDisplayDigest = displayDigest();
|
||||
ensureLayoutScaffold();
|
||||
const container = getCardContainer();
|
||||
const { reorder = false } = options;
|
||||
totalPill.textContent = `${data.online ?? data.total} online / ${data.total} total`;
|
||||
const totalPages = Math.max(1, Math.ceil(data.total / data.pageSize));
|
||||
pageLabel.textContent = `Page ${data.page} of ${totalPages}`;
|
||||
if (pageLabel) pageLabel.textContent = "Page";
|
||||
if (typeof window.setDashboardPageBounds === "function") {
|
||||
window.setDashboardPageBounds(data.page, totalPages);
|
||||
}
|
||||
prevBtnState(data.page, totalPages);
|
||||
|
||||
const items = data.items || [];
|
||||
const seen = new Set();
|
||||
const newClients = [];
|
||||
const renderId = ++renderToken;
|
||||
const largeClientSet = (Number(data.total) || 0) >= LARGE_CLIENT_THRESHOLD;
|
||||
document.documentElement.classList.toggle("large-client-set", largeClientSet);
|
||||
const prefersReducedMotion =
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
const hadCards = container.querySelectorAll(ROW_SELECTOR).length > 0;
|
||||
const existingCards = container.querySelectorAll(ROW_SELECTOR);
|
||||
const hadCards = existingCards.length > 0;
|
||||
const existingById = new Map();
|
||||
existingCards.forEach((card) => existingById.set(card.dataset.id, card));
|
||||
|
||||
const uninstallingIds = window.__uninstallingClientIds;
|
||||
|
||||
items.forEach((client) => {
|
||||
seen.add(client.id);
|
||||
if (uninstallingIds && uninstallingIds.has(client.id)) return;
|
||||
const existing = container.querySelector(`${ROW_SELECTOR}[data-id="${client.id}"]`);
|
||||
const existing = existingById.get(client.id);
|
||||
if (existing) {
|
||||
if (existing.classList.contains("card-uninstalling")) return;
|
||||
const digest = cardDigest(client);
|
||||
@@ -527,7 +612,7 @@ export function createRenderer({
|
||||
newClients.push(client);
|
||||
});
|
||||
|
||||
Array.from(container.querySelectorAll(ROW_SELECTOR))
|
||||
Array.from(existingCards)
|
||||
.filter((el) => !seen.has(el.dataset.id) && !el.classList.contains("card-uninstalling"))
|
||||
.forEach((el) => el.remove());
|
||||
|
||||
@@ -537,7 +622,7 @@ export function createRenderer({
|
||||
|
||||
if (newClients.length === 0) return;
|
||||
|
||||
const allowAnimation = !hadCards && !prefersReducedMotion && items.length <= 1000 && currentLayout !== "table";
|
||||
const allowAnimation = MAX_ANIMATED_CARDS > 0 && !largeClientSet && !hadCards && !prefersReducedMotion && items.length <= 1000 && currentLayout !== "table";
|
||||
const animateLimit = Math.min(newClients.length, MAX_ANIMATED_CARDS);
|
||||
|
||||
let idx = 0;
|
||||
@@ -674,17 +759,21 @@ export function createRenderer({
|
||||
const displayName = nickname || client.host || deviceId;
|
||||
const userLine = client.user || client.host || deviceId;
|
||||
const hasTagNote = customTag.length > 0 && customTagNote.length > 0;
|
||||
const hasHwInfo = !!(client.cpu || client.gpu || client.ram);
|
||||
const showHardware = showField("hardware");
|
||||
const showBattery = showField("battery");
|
||||
const cpuHtml = client.cpu ? cpuBadgeHtml(client.cpu) : "—";
|
||||
const batteryIndicator = showBattery ? batteryHtml(client) : "";
|
||||
const verLatest = String(client.version || "").startsWith("2.0.");
|
||||
|
||||
const metaParts = [
|
||||
`<span class="cv-os cv-tone-${os.tone}"><i class="fa ${os.icon}"></i> ${escapeHtml(shortOsLabel(client.os))}</span>`,
|
||||
`<span class="cv-arch cv-tone-${arch.tone}">${escapeHtml(arch.label)}</span>`,
|
||||
`<span class="cv-ver"><i class="fa ${ver.icon}"></i> ${escapeHtml(ver.label)}</span>`,
|
||||
`<span class="cv-mons"><i class="fa fa-display"></i> ${client.monitors || 1}</span>`,
|
||||
showField("system") ? `<span class="cv-os cv-tone-${os.tone}"><i class="fa ${os.icon}"></i> ${escapeHtml(shortOsLabel(client.os))}</span>` : "",
|
||||
showField("system") ? `<span class="cv-arch cv-tone-${arch.tone}">${escapeHtml(arch.label)}</span>` : "",
|
||||
showField("version") ? `<span class="cv-ver"><i class="fa ${ver.icon}"></i> ${escapeHtml(ver.label)}</span>` : "",
|
||||
showField("monitors") ? `<span class="cv-mons"><i class="fa fa-display"></i> ${client.monitors || 1}</span>` : "",
|
||||
nickname && client.host && nickname !== client.host ? `<span class="cv-host"><i class="fa-solid fa-laptop"></i> ${escapeHtml(client.host)}</span>` : "",
|
||||
client.ip ? `<span class="cv-ip cv-mono"><i class="fa-solid fa-network-wired"></i> ${escapeHtml(client.ip)}</span>` : "",
|
||||
hwid ? `<span class="cv-hwid cv-mono" title="HWID ${escapeHtml(client.hwid || "")}"><i class="fa-solid fa-fingerprint"></i> ${escapeHtml(hwid)}</span>` : "",
|
||||
showField("ip") && client.ip ? `<span class="cv-ip cv-mono"><i class="fa-solid fa-network-wired"></i> ${escapeHtml(client.ip)}</span>` : "",
|
||||
showField("hwid") && hwid ? `<span class="cv-hwid cv-mono" title="HWID ${escapeHtml(client.hwid || "")}"><i class="fa-solid fa-fingerprint"></i> ${escapeHtml(hwid)}</span>` : "",
|
||||
batteryIndicator,
|
||||
].filter(Boolean);
|
||||
const meta = metaParts.join(metaSeparator());
|
||||
|
||||
@@ -708,8 +797,8 @@ export function createRenderer({
|
||||
${client.elevation === "trustedinstaller" ? `<span class="cv-mini-pill cv-pill-ti" title="TrustedInstaller"><i class="fa-solid fa-lock"></i></span>` : ""}
|
||||
${client.notificationsMuted ? `<span class="cv-mini-pill cv-pill-muted" title="Notifications muted"><i class="fa-solid fa-bell-slash"></i></span>` : ""}
|
||||
</div>
|
||||
<div class="cv-user-line"><i class="fa-solid fa-user"></i> ${escapeHtml(userLine)}</div>
|
||||
<div class="cv-meta-line">${meta}</div>
|
||||
${showField("user") ? `<div class="cv-user-line"><i class="fa-solid fa-user"></i> ${escapeHtml(userLine)}</div>` : ""}
|
||||
${meta ? `<div class="cv-meta-line">${meta}</div>` : ""}
|
||||
${customTag ? `<button type="button" class="client-tag-toggle cv-tag ${hasTagNote ? "has-note" : ""}" ${hasTagNote ? `aria-expanded="false"` : `disabled aria-disabled="true"`}><i class="fa-solid fa-tag"></i> ${escapeHtml(customTag)}${hasTagNote ? ` <i class="fa-solid fa-chevron-down"></i>` : ""}</button>` : ""}
|
||||
${hasTagNote ? `<div class="client-tag-note hidden">${escapeHtml(customTagNote)}</div>` : ""}
|
||||
</div>
|
||||
@@ -717,7 +806,7 @@ export function createRenderer({
|
||||
<span class="cv-time-line"><i class="fa-regular fa-clock"></i> ${formatAgo(client.lastSeen)}</span>
|
||||
<span class="cv-ping-line ${pingTone(client.pingMs)}"><i class="fa-solid fa-satellite-dish"></i> ${formatPing(client.pingMs)}</span>
|
||||
</div>
|
||||
<div class="cv-group-cell">${groupPillHtml(client) || `<span class="cv-group-empty">—</span>`}</div>
|
||||
${showField("group") ? `<div class="cv-group-cell">${groupPillHtml(client) || `<span class="cv-group-empty">—</span>`}</div>` : ""}
|
||||
<div class="cv-spacer"></div>
|
||||
<div class="cv-actions">
|
||||
${isViewer ? "" : `<button class="command-btn cv-btn-primary" data-id="${escapeHtml(client.id)}"><i class="fa-solid fa-terminal"></i><span>Commands</span></button>`}
|
||||
@@ -728,13 +817,14 @@ export function createRenderer({
|
||||
<div class="cv-expand-panel hidden">
|
||||
<div class="cv-expand-grid">
|
||||
<div class="cv-field"><span class="cv-field-label">ID</span><span class="cv-field-value cv-mono copy-id-btn" data-copy="${escapeHtml(client.id)}" title="Copy full ID">${escapeHtml(deviceId)} <i class="fa-regular fa-copy copy-id-icon"></i></span></div>
|
||||
<div class="cv-field"><span class="cv-field-label">Hardware ID</span><span class="cv-field-value cv-mono">${escapeHtml(hwid || "—")}</span></div>
|
||||
<div class="cv-field"><span class="cv-field-label">IP</span><span class="cv-field-value cv-mono">${escapeHtml(client.ip || "—")}</span></div>
|
||||
<div class="cv-field"><span class="cv-field-label">OS</span><span class="cv-field-value">${escapeHtml(client.os || "Unknown")} ${escapeHtml(arch.label)}</span></div>
|
||||
<div class="cv-field"><span class="cv-field-label">CPU</span><span class="cv-field-value">${escapeHtml(client.cpu || "—")}</span></div>
|
||||
<div class="cv-field"><span class="cv-field-label">RAM</span><span class="cv-field-value">${escapeHtml(client.ram || "—")}</span></div>
|
||||
${client.gpu ? `<div class="cv-field cv-field-wide"><span class="cv-field-label">GPU</span><span class="cv-field-value">${escapeHtml(client.gpu)}</span></div>` : ""}
|
||||
${!verLatest && client.version ? `<div class="cv-field"><span class="cv-field-label">Version</span><span class="cv-field-value cv-warn">v${escapeHtml(client.version)} (outdated)</span></div>` : ""}
|
||||
${showField("hwid") ? `<div class="cv-field"><span class="cv-field-label">Hardware ID</span><span class="cv-field-value cv-mono">${escapeHtml(hwid || "—")}</span></div>` : ""}
|
||||
${showField("ip") ? `<div class="cv-field"><span class="cv-field-label">IP</span><span class="cv-field-value cv-mono">${escapeHtml(client.ip || "—")}</span></div>` : ""}
|
||||
${showField("system") ? `<div class="cv-field"><span class="cv-field-label">OS</span><span class="cv-field-value">${escapeHtml(client.os || "Unknown")} ${escapeHtml(arch.label)}</span></div>` : ""}
|
||||
${showHardware ? `<div class="cv-field"><span class="cv-field-label">CPU</span><span class="cv-field-value">${cpuHtml}</span></div>` : ""}
|
||||
${showHardware ? `<div class="cv-field"><span class="cv-field-label">RAM</span><span class="cv-field-value">${escapeHtml(client.ram || "—")}</span></div>` : ""}
|
||||
${batteryIndicator ? `<div class="cv-field"><span class="cv-field-label">Battery</span><span class="cv-field-value">${batteryIndicator}</span></div>` : ""}
|
||||
${showHardware && client.gpu ? `<div class="cv-field cv-field-wide"><span class="cv-field-label">GPU</span><span class="cv-field-value">${escapeHtml(client.gpu)}</span></div>` : ""}
|
||||
${showField("version") && !verLatest && client.version ? `<div class="cv-field"><span class="cv-field-label">Version</span><span class="cv-field-value cv-warn">v${escapeHtml(client.version)} (outdated)</span></div>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -754,6 +844,11 @@ export function createRenderer({
|
||||
const nickname = String(client.nickname || "").trim();
|
||||
const displayName = nickname || client.host || deviceId;
|
||||
const userLine = client.user || client.host || deviceId;
|
||||
const userMeta = [
|
||||
showField("user") ? `<i class="fa-solid fa-user"></i> ${escapeHtml(userLine)}` : "",
|
||||
showField("ip") && client.ip ? `<span class="cv-mid">·</span><i class="fa-solid fa-network-wired"></i> ${escapeHtml(client.ip)}` : "",
|
||||
showField("hwid") && client.hwid ? `<span class="cv-mid">·</span><i class="fa-solid fa-fingerprint"></i> ${escapeHtml(shortId(client.hwid))}` : "",
|
||||
].filter(Boolean).join("");
|
||||
|
||||
tr.innerHTML = `
|
||||
<td class="cv-td-check">
|
||||
@@ -779,7 +874,7 @@ export function createRenderer({
|
||||
${client.elevation === "trustedinstaller" ? `<span class="cv-mini-pill cv-pill-ti" title="TI"><i class="fa-solid fa-lock"></i></span>` : ""}
|
||||
${client.notificationsMuted ? `<span class="cv-mini-pill cv-pill-muted" title="Notifications muted"><i class="fa-solid fa-bell-slash"></i></span>` : ""}
|
||||
</span>
|
||||
<span class="cv-user-line cv-mono"><i class="fa-solid fa-user"></i> ${escapeHtml(userLine)}${client.ip ? `<span class="cv-mid">·</span><i class="fa-solid fa-network-wired"></i> ${escapeHtml(client.ip)}` : ""}</span>
|
||||
${userMeta ? `<span class="cv-user-line cv-mono">${userMeta}</span>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -787,11 +882,11 @@ export function createRenderer({
|
||||
<span class="cv-status-cell">${statusDot(client)} ${client.online ? "Online" : "Offline"}</span>
|
||||
</td>
|
||||
<td class="cv-td-last cv-tab-num">${formatAgo(client.lastSeen)}</td>
|
||||
<td class="cv-td-system">
|
||||
${showField("system") ? `<td class="cv-td-system">
|
||||
<span class="cv-system-cell"><span class="cv-os cv-tone-${os.tone}"><i class="fa ${os.icon}"></i> ${escapeHtml(shortOsLabel(client.os))}</span> <span class="cv-arch-chip cv-tone-${arch.tone}">${escapeHtml(arch.label)}</span></span>
|
||||
</td>
|
||||
</td>` : ""}
|
||||
<td class="cv-td-ping cv-tab-num cv-mono ${pingTone(client.pingMs)}">${formatPing(client.pingMs)}</td>
|
||||
<td class="cv-td-group">${groupPillHtml(client) || `<span class="cv-group-empty">—</span>`}</td>
|
||||
${showField("group") ? `<td class="cv-td-group">${groupPillHtml(client) || `<span class="cv-group-empty">—</span>`}</td>` : ""}
|
||||
<td class="cv-td-actions">
|
||||
<div class="cv-actions cv-actions-table">
|
||||
${isViewer ? "" : `<button class="command-btn cv-btn-primary cv-btn-sm" data-id="${escapeHtml(client.id)}"><i class="fa-solid fa-terminal"></i><span>Commands</span></button>`}
|
||||
@@ -821,13 +916,15 @@ export function createRenderer({
|
||||
const verLatest = String(client.version || "").startsWith("2.0.");
|
||||
const hasGroup = !!String(client.groupName || "").trim();
|
||||
const showHost = nickname && client.host && nickname !== client.host;
|
||||
const cpuShort = client.cpu ? shortenCpu(client.cpu) : "";
|
||||
const showHardware = showField("hardware");
|
||||
const cpuHtml = showHardware && client.cpu ? cpuBadgeHtml(client.cpu) : "";
|
||||
const batteryIndicator = showField("battery") ? batteryHtml(client, { compact: true }) : "";
|
||||
|
||||
const metaParts = [
|
||||
`<span class="cv-card-meta-bit cv-tone-${os.tone}"><i class="fa ${os.icon}"></i> ${escapeHtml(shortOsLabel(client.os))}</span>`,
|
||||
`<span class="cv-card-meta-bit cv-tone-${arch.tone}">${escapeHtml(arch.label)}</span>`,
|
||||
`<span class="cv-card-meta-bit"><i class="fa fa-display"></i> ${client.monitors || 1}</span>`,
|
||||
client.version ? `<span class="cv-card-meta-bit ${verLatest ? "" : "cv-warn"}"><i class="fa fa-tag"></i> v${escapeHtml(client.version)}</span>` : "",
|
||||
showField("system") ? `<span class="cv-card-meta-bit cv-tone-${os.tone}"><i class="fa ${os.icon}"></i> ${escapeHtml(shortOsLabel(client.os))}</span>` : "",
|
||||
showField("system") ? `<span class="cv-card-meta-bit cv-tone-${arch.tone}">${escapeHtml(arch.label)}</span>` : "",
|
||||
showField("monitors") ? `<span class="cv-card-meta-bit"><i class="fa fa-display"></i> ${client.monitors || 1}</span>` : "",
|
||||
showField("version") && client.version ? `<span class="cv-card-meta-bit ${verLatest ? "" : "cv-warn"}"><i class="fa fa-tag"></i> v${escapeHtml(client.version)}</span>` : "",
|
||||
].filter(Boolean).join("");
|
||||
|
||||
const elevationBadges = [
|
||||
@@ -863,19 +960,20 @@ export function createRenderer({
|
||||
<span class="cv-name">${escapeHtml(displayName)}</span>
|
||||
${elevationBadges}
|
||||
</div>
|
||||
<div class="cv-user-line cv-mono"><i class="fa-solid fa-user"></i> ${escapeHtml(userLine)}${showHost ? ` <span class="cv-text-dim">@ ${escapeHtml(client.host)}</span>` : ""}</div>
|
||||
${showField("user") ? `<div class="cv-user-line cv-mono"><i class="fa-solid fa-user"></i> ${escapeHtml(userLine)}${showHost ? ` <span class="cv-text-dim">@ ${escapeHtml(client.host)}</span>` : ""}</div>` : ""}
|
||||
${customTag ? `<div class="cv-card-tag"><i class="fa-solid fa-tag"></i> ${escapeHtml(customTag)}</div>` : ""}
|
||||
<div class="cv-card-net">
|
||||
${client.ip ? `<span class="cv-card-net-bit"><i class="fa-solid fa-network-wired"></i> <span class="cv-mono">${escapeHtml(client.ip)}</span></span>` : ""}
|
||||
${hwidShort ? `<span class="cv-card-net-bit"><i class="fa-solid fa-fingerprint"></i> <span class="cv-mono">${escapeHtml(hwidShort)}</span></span>` : ""}
|
||||
${showField("ip") && client.ip ? `<span class="cv-card-net-bit"><i class="fa-solid fa-network-wired"></i> <span class="cv-mono">${escapeHtml(client.ip)}</span></span>` : ""}
|
||||
${showField("hwid") && hwidShort ? `<span class="cv-card-net-bit"><i class="fa-solid fa-fingerprint"></i> <span class="cv-mono">${escapeHtml(hwidShort)}</span></span>` : ""}
|
||||
</div>
|
||||
<div class="cv-card-meta">
|
||||
${metaParts || (showField("group") && hasGroup) ? `<div class="cv-card-meta">
|
||||
${metaParts}
|
||||
${hasGroup ? `<span class="cv-group-spacer">${groupPillHtml(client)}</span>` : ""}
|
||||
</div>
|
||||
${cpuShort || client.ram ? `<div class="cv-card-hw">
|
||||
${cpuShort ? `<span class="cv-card-hw-bit" title="${escapeHtml(client.cpu)}"><i class="fa-solid fa-microchip"></i> ${escapeHtml(cpuShort)}</span>` : ""}
|
||||
${client.ram ? `<span class="cv-card-hw-bit"><i class="fa-solid fa-memory"></i> ${escapeHtml(client.ram)}</span>` : ""}
|
||||
${showField("group") && hasGroup ? `<span class="cv-group-spacer">${groupPillHtml(client)}</span>` : ""}
|
||||
</div>` : ""}
|
||||
${cpuHtml || (showHardware && client.ram) || batteryIndicator ? `<div class="cv-card-hw">
|
||||
${cpuHtml}
|
||||
${showHardware && client.ram ? `<span class="cv-card-hw-bit"><i class="fa-solid fa-memory"></i> ${escapeHtml(client.ram)}</span>` : ""}
|
||||
${batteryIndicator}
|
||||
</div>` : ""}
|
||||
<div class="cv-card-actions">
|
||||
${isViewer ? "" : `<button class="command-btn cv-btn-primary cv-btn-flex" data-id="${escapeHtml(client.id)}"><i class="fa-solid fa-terminal"></i><span>Commands</span></button>`}
|
||||
|
||||
@@ -23,6 +23,20 @@ export function digestData(data, { page, pageSize, searchTerm, sort }) {
|
||||
os: c.os,
|
||||
monitors: c.monitors,
|
||||
elevation: c.elevation,
|
||||
bookmarked: !!c.bookmarked,
|
||||
isAdmin: !!c.isAdmin,
|
||||
hwid: c.hwid,
|
||||
cpu: c.cpu,
|
||||
gpu: c.gpu,
|
||||
ram: c.ram,
|
||||
batteryPercent: c.batteryPercent,
|
||||
batteryCharging: c.batteryCharging,
|
||||
groupId: c.groupId,
|
||||
groupName: c.groupName,
|
||||
groupColor: c.groupColor,
|
||||
notificationsMuted: !!c.notificationsMuted,
|
||||
hasThumbnail: !!c.hasThumbnail,
|
||||
thumbnailVersion: c.thumbnailVersion,
|
||||
})) || [];
|
||||
return JSON.stringify({
|
||||
page,
|
||||
|
||||
@@ -37,20 +37,40 @@ export function osBadge(osRaw = "") {
|
||||
icon: "fa-linux",
|
||||
tone: "pill-unknown",
|
||||
};
|
||||
if (os.includes("windows 11"))
|
||||
return { label: "W11", icon: "fa-brands fa-windows", tone: "pill-win" };
|
||||
if (os.includes("windows 10"))
|
||||
return { label: "W10", icon: "fa-brands fa-windows", tone: "pill-win" };
|
||||
if (os.includes("windows"))
|
||||
return { label: "Windows", icon: "fa-brands fa-windows", tone: "pill-win" };
|
||||
if (os.includes("mac") || os.includes("darwin"))
|
||||
return { label: "macOS", icon: "fa-brands fa-apple", tone: "pill-mac" };
|
||||
if (os.includes("linux mint"))
|
||||
return { label: "Mint", icon: "cv-os-icon cv-os-icon-mint", tone: "pill-mint" };
|
||||
if (os.includes("ubuntu"))
|
||||
return { label: "Ubuntu", icon: "fa-brands fa-linux", tone: "pill-ubuntu" };
|
||||
return { label: "Ubuntu", icon: "fa-brands fa-ubuntu", tone: "pill-ubuntu" };
|
||||
if (os.includes("debian"))
|
||||
return { label: "Debian", icon: "fa-brands fa-linux", tone: "pill-debian" };
|
||||
return { label: "Debian", icon: "fa-brands fa-debian", tone: "pill-debian" };
|
||||
if (os.includes("arch"))
|
||||
return { label: "Arch", icon: "fa-brands fa-linux", tone: "pill-arch" };
|
||||
return { label: "Arch", icon: "cv-os-icon cv-os-icon-arch", tone: "pill-arch" };
|
||||
if (os.includes("kali"))
|
||||
return { label: "Kali", icon: "fa-brands fa-linux", tone: "pill-kali" };
|
||||
if (os.includes("fedora"))
|
||||
return { label: "Fedora", icon: "fa-brands fa-linux", tone: "pill-fedora" };
|
||||
return { label: "Fedora", icon: "fa-brands fa-fedora", tone: "pill-fedora" };
|
||||
if (os.includes("red hat") || os.includes("rhel"))
|
||||
return { label: "RHEL", icon: "fa-brands fa-redhat", tone: "pill-redhat" };
|
||||
if (os.includes("centos"))
|
||||
return { label: "CentOS", icon: "fa-brands fa-centos", tone: "pill-centos" };
|
||||
if (os.includes("suse") || os.includes("opensuse"))
|
||||
return { label: "SUSE", icon: "fa-brands fa-suse", tone: "pill-suse" };
|
||||
if (os.includes("raspbian") || os.includes("raspberry"))
|
||||
return { label: "Raspberry Pi", icon: "fa-brands fa-raspberry-pi", tone: "pill-raspi" };
|
||||
if (os.includes("freebsd"))
|
||||
return { label: "FreeBSD", icon: "fa-brands fa-freebsd", tone: "pill-freebsd" };
|
||||
if (os.includes("alpine"))
|
||||
return { label: "Alpine", icon: "fa-brands fa-linux", tone: "pill-alpine" };
|
||||
if (os.includes("manjaro"))
|
||||
return { label: "Manjaro", icon: "fa-brands fa-linux", tone: "pill-manjaro" };
|
||||
if (os.includes("linux"))
|
||||
return { label: "Linux", icon: "fa-brands fa-linux", tone: "pill-linux" };
|
||||
return base;
|
||||
|
||||
@@ -38,10 +38,10 @@
|
||||
>0 online</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3 items-center">
|
||||
<div class="dashboard-toolbar">
|
||||
<label
|
||||
id="search-shell"
|
||||
class="flex items-center gap-2 bg-slate-800/70 border border-slate-700/60 rounded-lg px-3 py-2 w-full sm:w-auto flex-1"
|
||||
class="dashboard-search flex items-center gap-2 bg-slate-800/70 border border-slate-700/60 rounded-lg px-3 py-2"
|
||||
>
|
||||
<i class="fa-solid fa-magnifying-glass text-slate-500"></i>
|
||||
<input
|
||||
@@ -51,99 +51,71 @@
|
||||
placeholder="Search by nickname, host, user, id"
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
class="inline-flex items-center gap-2 bg-slate-800/70 border border-slate-700/60 rounded-lg px-3 py-2 text-slate-300"
|
||||
>
|
||||
<input
|
||||
id="toggle-offline"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-slate-600 bg-slate-800 text-blue-600 focus:ring-blue-600"
|
||||
checked
|
||||
/>
|
||||
<span class="text-sm text-slate-300">Show offline</span>
|
||||
</label>
|
||||
<select
|
||||
id="filter-status"
|
||||
class="bg-slate-800/70 border border-slate-700/60 rounded-lg px-3 py-2 text-slate-300"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="online">Online Only</option>
|
||||
<option value="offline">Offline Only</option>
|
||||
</select>
|
||||
<select
|
||||
id="filter-os"
|
||||
class="bg-slate-800/70 border border-slate-700/60 rounded-lg px-3 py-2 text-slate-300"
|
||||
>
|
||||
<option value="all">All OS</option>
|
||||
</select>
|
||||
<select
|
||||
id="filter-group"
|
||||
class="bg-slate-800/70 border border-slate-700/60 rounded-lg px-3 py-2 text-slate-300"
|
||||
>
|
||||
<option value="all">All Groups</option>
|
||||
<option value="none">No Group</option>
|
||||
</select>
|
||||
<div id="country-picker-anchor" class="relative"></div>
|
||||
<select
|
||||
id="sort"
|
||||
class="bg-slate-800/70 border border-slate-700/60 rounded-lg px-3 py-2 text-slate-300"
|
||||
>
|
||||
<option value="stable">Default</option>
|
||||
<option value="last_seen_desc">Newest</option>
|
||||
<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>
|
||||
<option value="group_asc">Group A→Z</option>
|
||||
<option value="group_desc">Group Z→A</option>
|
||||
<option value="admin_first">Admin first</option>
|
||||
</select>
|
||||
<button
|
||||
id="select-all"
|
||||
class="px-3 py-2 rounded-lg bg-slate-800/70 hover:bg-slate-700/70 text-slate-300 border border-slate-700/60 transition-colors"
|
||||
>
|
||||
<i class="fa-solid fa-check"></i> Select all
|
||||
</button>
|
||||
<button
|
||||
id="clear-selection"
|
||||
class="px-3 py-2 rounded-lg bg-slate-800/70 hover:bg-slate-700/70 text-slate-400 border border-slate-700/60 transition-colors"
|
||||
>
|
||||
Clear selection
|
||||
</button>
|
||||
<div
|
||||
id="layout-toggle"
|
||||
role="group"
|
||||
aria-label="Layout"
|
||||
class="ml-auto inline-flex items-center rounded-lg border border-slate-700/60 bg-slate-800/70 p-0.5"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-layout="rows"
|
||||
class="layout-toggle-btn"
|
||||
title="Row cards"
|
||||
aria-pressed="true"
|
||||
>
|
||||
<i class="fa-solid fa-list"></i>
|
||||
<div class="dashboard-toolbar-actions">
|
||||
<label class="dashboard-toggle" title="Show offline clients">
|
||||
<input id="toggle-offline" type="checkbox" checked />
|
||||
<i class="fa-solid fa-power-off"></i>
|
||||
</label>
|
||||
<details class="dashboard-menu">
|
||||
<summary title="Filters"><i class="fa-solid fa-filter"></i></summary>
|
||||
<div class="dashboard-menu-panel dashboard-filter-panel">
|
||||
<select id="filter-status">
|
||||
<option value="all">All Status</option>
|
||||
<option value="online">Online Only</option>
|
||||
<option value="offline">Offline Only</option>
|
||||
</select>
|
||||
<select id="filter-os">
|
||||
<option value="all">All OS</option>
|
||||
</select>
|
||||
<select id="filter-group">
|
||||
<option value="all">All Groups</option>
|
||||
<option value="none">No Group</option>
|
||||
</select>
|
||||
<div id="country-picker-anchor" class="relative"></div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="dashboard-menu">
|
||||
<summary title="Sort"><i class="fa-solid fa-arrow-down-wide-short"></i></summary>
|
||||
<div class="dashboard-menu-panel">
|
||||
<select id="sort">
|
||||
<option value="stable">Default</option>
|
||||
<option value="last_seen_desc">Newest</option>
|
||||
<option value="host_asc">Host A-Z</option>
|
||||
<option value="ping_asc">Ping up</option>
|
||||
<option value="ping_desc">Ping down</option>
|
||||
<option value="country_asc">Country A-Z</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>
|
||||
</select>
|
||||
</div>
|
||||
</details>
|
||||
<details class="dashboard-menu">
|
||||
<summary title="Visible fields"><i class="fa-solid fa-sliders"></i></summary>
|
||||
<div id="display-fields-menu" class="dashboard-menu-panel dashboard-fields-panel">
|
||||
<label><input type="checkbox" data-field="user" checked /> User</label>
|
||||
<label><input type="checkbox" data-field="ip" checked /> IP</label>
|
||||
<label><input type="checkbox" data-field="hwid" /> HWID</label>
|
||||
<label><input type="checkbox" data-field="system" checked /> System</label>
|
||||
<label><input type="checkbox" data-field="version" checked /> Version</label>
|
||||
<label><input type="checkbox" data-field="monitors" checked /> Monitors</label>
|
||||
<label><input type="checkbox" data-field="group" checked /> Group</label>
|
||||
<label><input type="checkbox" data-field="hardware" checked /> Hardware</label>
|
||||
<label><input type="checkbox" data-field="battery" checked /> Battery</label>
|
||||
</div>
|
||||
</details>
|
||||
<button id="select-all" class="dashboard-icon-btn" title="Select all">
|
||||
<i class="fa-solid fa-check"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-layout="table"
|
||||
class="layout-toggle-btn"
|
||||
title="Dense table"
|
||||
aria-pressed="false"
|
||||
>
|
||||
<i class="fa-solid fa-table-list"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-layout="cards"
|
||||
class="layout-toggle-btn"
|
||||
title="Card wall"
|
||||
aria-pressed="false"
|
||||
>
|
||||
<i class="fa-solid fa-grip"></i>
|
||||
<button id="clear-selection" class="dashboard-icon-btn" title="Clear selection">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
<div id="layout-toggle" role="group" aria-label="Layout" class="inline-flex items-center rounded-lg border border-slate-700/60 bg-slate-800/70 p-0.5">
|
||||
<button type="button" data-layout="rows" class="layout-toggle-btn" title="Row cards" aria-pressed="true"><i class="fa-solid fa-list"></i></button>
|
||||
<button type="button" data-layout="table" class="layout-toggle-btn" title="Dense table" aria-pressed="false"><i class="fa-solid fa-table-list"></i></button>
|
||||
<button type="button" data-layout="cards" class="layout-toggle-btn" title="Card wall" aria-pressed="false"><i class="fa-solid fa-grip"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Bulk Actions Toolbar -->
|
||||
@@ -211,7 +183,14 @@
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<span class="text-slate-400" id="page-label"></span>
|
||||
<form id="page-jump-form" class="page-jump" title="Jump to page">
|
||||
<label id="page-label" for="page-jump-input">Page</label>
|
||||
<input id="page-jump-input" type="number" min="1" value="1" inputmode="numeric" />
|
||||
<span class="page-jump-total">/ <span id="page-total">1</span></span>
|
||||
<button id="page-jump-go" type="submit" title="Go to page">
|
||||
<i class="fa-solid fa-arrow-right"></i>
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
id="next"
|
||||
class="px-4 py-2 rounded-lg border border-slate-700/60 bg-slate-800/70 hover:bg-slate-700/70 text-slate-300 disabled:opacity-40"
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
|
||||
<link rel="stylesheet" href="/assets/tailwind.css" />
|
||||
<link rel="stylesheet" href="/vendor/fontawesome/css/all.min.css" />
|
||||
<link rel="stylesheet" href="/vendor/tabulator/tabulator_midnight.min.css" />
|
||||
<link rel="stylesheet" href="/assets/main.css" />
|
||||
<link rel="stylesheet" href="/assets/custom.css" />
|
||||
<script defer src="/assets/toast.js"></script>
|
||||
@@ -114,8 +113,35 @@
|
||||
</div>
|
||||
|
||||
<!-- Clients Table -->
|
||||
<div id="clients-table-wrap" class="bg-slate-900/50 border border-slate-800 rounded-xl overflow-hidden">
|
||||
<div id="enrollment-table" class="overlord-tabulator"></div>
|
||||
<div class="bg-slate-900/50 border border-slate-800 rounded-xl overflow-x-auto">
|
||||
<table class="w-full min-w-[1200px]" id="enrollment-table">
|
||||
<thead class="bg-slate-800/50 border-b border-slate-700">
|
||||
<tr>
|
||||
<th class="w-10 px-4 py-3">
|
||||
<input type="checkbox" id="select-all" class="h-4 w-4 rounded border-slate-600" />
|
||||
</th>
|
||||
<th class="text-left px-4 py-3 text-sm font-semibold text-slate-300">Host</th>
|
||||
<th class="text-left px-4 py-3 text-sm font-semibold text-slate-300">User</th>
|
||||
<th class="text-left px-4 py-3 text-sm font-semibold text-slate-300">OS</th>
|
||||
<th class="text-left px-4 py-3 text-sm font-semibold text-slate-300">CPU</th>
|
||||
<th class="text-left px-4 py-3 text-sm font-semibold text-slate-300">GPU</th>
|
||||
<th class="text-left px-4 py-3 text-sm font-semibold text-slate-300">RAM</th>
|
||||
<th class="text-left px-4 py-3 text-sm font-semibold text-slate-300">IP</th>
|
||||
<th class="text-left px-4 py-3 text-sm font-semibold text-slate-300">Country</th>
|
||||
<th class="text-left px-4 py-3 text-sm font-semibold text-slate-300">Key Fingerprint</th>
|
||||
<th class="text-left px-4 py-3 text-sm font-semibold text-slate-300">Last Seen</th>
|
||||
<th class="text-left px-4 py-3 text-sm font-semibold text-slate-300">Status</th>
|
||||
<th class="text-right px-4 py-3 text-sm font-semibold text-slate-300">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="enrollment-body" class="divide-y divide-slate-800">
|
||||
<tr id="enrollment-loading">
|
||||
<td colspan="13" class="px-6 py-12 text-center text-slate-500">
|
||||
<i class="fa-solid fa-spinner fa-spin mr-2"></i>Loading...
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div id="enrollment-empty" class="hidden px-6 py-12 text-center text-slate-500">
|
||||
<i class="fa-solid fa-inbox text-3xl mb-3 block"></i>
|
||||
<p>No clients in this category.</p>
|
||||
@@ -157,7 +183,17 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-slate-900/50 border border-slate-800 rounded-xl overflow-hidden">
|
||||
<div id="banned-ips-table" class="overlord-tabulator"></div>
|
||||
<table class="w-full">
|
||||
<thead class="bg-slate-800/50 border-b border-slate-700">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-3 text-sm font-semibold text-slate-300">IP Address</th>
|
||||
<th class="text-left px-4 py-3 text-sm font-semibold text-slate-300">Reason</th>
|
||||
<th class="text-left px-4 py-3 text-sm font-semibold text-slate-300">Banned At</th>
|
||||
<th class="text-right px-4 py-3 text-sm font-semibold text-slate-300">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="banned-ips-body" class="divide-y divide-slate-800"></tbody>
|
||||
</table>
|
||||
<div id="banned-ips-empty" class="hidden px-6 py-12 text-center text-slate-500">
|
||||
<i class="fa-solid fa-shield-halved text-3xl mb-3 block"></i>
|
||||
<p>No banned IPs.</p>
|
||||
|
||||
@@ -21,13 +21,112 @@ CREATE TABLE IF NOT EXISTS clients (
|
||||
country TEXT,
|
||||
last_seen INTEGER,
|
||||
online INTEGER,
|
||||
ping_ms INTEGER
|
||||
ping_ms INTEGER,
|
||||
cpu TEXT,
|
||||
gpu TEXT,
|
||||
ram TEXT,
|
||||
battery_percent INTEGER,
|
||||
battery_charging INTEGER,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
elevation TEXT,
|
||||
enrollment_status TEXT NOT NULL DEFAULT 'approved',
|
||||
bookmarked INTEGER NOT NULL DEFAULT 0,
|
||||
notifications_muted INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
"""
|
||||
|
||||
ROLES = ["client", "viewer"]
|
||||
OSES = ["windows", "linux", "darwin", "ubuntu", "debian", "arch", "kali", "fedora"]
|
||||
ARCHES = ["amd64", "arm64", "x86", "arm"]
|
||||
OS_PROFILES = [
|
||||
{
|
||||
"os": "Windows 11 Pro 23H2",
|
||||
"arches": ["amd64", "arm64"],
|
||||
"cpus": ["Intel(R) Core(TM) i7-13700H", "Intel(R) Core(TM) i9-13900K", "AMD Ryzen 7 7840U"],
|
||||
"gpus": ["NVIDIA GeForce RTX 4070 Laptop GPU", "Intel Iris Xe Graphics", "AMD Radeon 780M"],
|
||||
"ram": ["16 GB", "32 GB", "64 GB"],
|
||||
"battery": True,
|
||||
},
|
||||
{
|
||||
"os": "Windows 10 Pro 22H2",
|
||||
"arches": ["amd64", "x86"],
|
||||
"cpus": ["Intel(R) Core(TM) i5-10400", "Intel(R) Core(TM) i7-9700K", "AMD Ryzen 5 3600"],
|
||||
"gpus": ["NVIDIA GeForce GTX 1660", "Intel UHD Graphics 630", "AMD Radeon RX 580"],
|
||||
"ram": ["8 GB", "16 GB", "32 GB"],
|
||||
"battery": False,
|
||||
},
|
||||
{
|
||||
"os": "Ubuntu 24.04.1 LTS",
|
||||
"arches": ["amd64", "arm64"],
|
||||
"cpus": ["AMD Ryzen 9 7950X", "Intel(R) Core(TM) i7-12700", "Apple M2"],
|
||||
"gpus": ["NVIDIA GeForce RTX 3060", "AMD Radeon RX 6700 XT", "llvmpipe"],
|
||||
"ram": ["8 GB", "16 GB", "32 GB"],
|
||||
"battery": True,
|
||||
},
|
||||
{
|
||||
"os": "Debian GNU/Linux 12 (bookworm)",
|
||||
"arches": ["amd64", "arm64"],
|
||||
"cpus": ["Intel(R) Xeon(R) CPU E5-2670", "AMD EPYC 7282", "Intel(R) Core(TM) i5-8250U"],
|
||||
"gpus": ["virtio_gpu", "Intel UHD Graphics 620", "NVIDIA T400"],
|
||||
"ram": ["4 GB", "8 GB", "16 GB"],
|
||||
"battery": False,
|
||||
},
|
||||
{
|
||||
"os": "Fedora Linux 40 (Workstation Edition)",
|
||||
"arches": ["amd64"],
|
||||
"cpus": ["AMD Ryzen 7 5800X", "Intel(R) Core(TM) Ultra 7 155H"],
|
||||
"gpus": ["AMD Radeon RX 6800", "Intel Arc Graphics"],
|
||||
"ram": ["16 GB", "32 GB"],
|
||||
"battery": True,
|
||||
},
|
||||
{
|
||||
"os": "Arch Linux",
|
||||
"arches": ["amd64"],
|
||||
"cpus": ["AMD Ryzen 5 7600X", "Intel(R) Core(TM) i5-13600K"],
|
||||
"gpus": ["NVIDIA GeForce RTX 3080", "AMD Radeon RX 7900 XT"],
|
||||
"ram": ["16 GB", "32 GB", "64 GB"],
|
||||
"battery": False,
|
||||
},
|
||||
{
|
||||
"os": "Kali GNU/Linux Rolling",
|
||||
"arches": ["amd64", "arm64"],
|
||||
"cpus": ["Intel(R) Core(TM) i7-8650U", "AMD Ryzen 5 PRO 4650U"],
|
||||
"gpus": ["Intel UHD Graphics 620", "VMware SVGA II Adapter"],
|
||||
"ram": ["4 GB", "8 GB", "16 GB"],
|
||||
"battery": True,
|
||||
},
|
||||
{
|
||||
"os": "Linux Mint 22",
|
||||
"arches": ["amd64"],
|
||||
"cpus": ["AMD Ryzen 7 5700U", "Intel(R) Core(TM) i5-1135G7"],
|
||||
"gpus": ["AMD Radeon Graphics", "Intel Iris Xe Graphics"],
|
||||
"ram": ["8 GB", "16 GB"],
|
||||
"battery": True,
|
||||
},
|
||||
{
|
||||
"os": "Red Hat Enterprise Linux 9.4",
|
||||
"arches": ["amd64"],
|
||||
"cpus": ["Intel(R) Xeon(R) Silver 4210", "AMD EPYC 7313P"],
|
||||
"gpus": ["Matrox G200e", "NVIDIA T1000"],
|
||||
"ram": ["32 GB", "64 GB", "128 GB"],
|
||||
"battery": False,
|
||||
},
|
||||
{
|
||||
"os": "openSUSE Tumbleweed",
|
||||
"arches": ["amd64"],
|
||||
"cpus": ["AMD Ryzen 9 7900", "Intel(R) Core(TM) i7-13700"],
|
||||
"gpus": ["AMD Radeon RX 7600", "Intel Arc A770"],
|
||||
"ram": ["16 GB", "32 GB"],
|
||||
"battery": False,
|
||||
},
|
||||
{
|
||||
"os": "macOS 14.5",
|
||||
"arches": ["arm64", "amd64"],
|
||||
"cpus": ["Apple M2", "Apple M2 Pro", "Apple M3 Max", "Intel(R) Core(TM) i9-9980HK"],
|
||||
"gpus": ["Apple GPU", "AMD Radeon Pro 5500M"],
|
||||
"ram": ["8 GB", "16 GB", "32 GB", "64 GB"],
|
||||
"battery": True,
|
||||
},
|
||||
]
|
||||
COUNTRIES = [
|
||||
"US",
|
||||
"GB",
|
||||
@@ -73,10 +172,11 @@ def random_version() -> str:
|
||||
def random_row(now_ms: int, online_rate: float) -> Tuple:
|
||||
client_id = uuid.uuid4().hex
|
||||
hwid = uuid.uuid4().hex
|
||||
role = random.choice(ROLES)
|
||||
role = "client" if random.random() > 0.02 else random.choice(ROLES)
|
||||
host = random_host()
|
||||
os_name = random.choice(OSES)
|
||||
arch = random.choice(ARCHES)
|
||||
profile = random.choice(OS_PROFILES)
|
||||
os_name = profile["os"]
|
||||
arch = random.choice(profile.get("arches") or ARCHES)
|
||||
version = random_version()
|
||||
user = random_user()
|
||||
monitors = random.randint(1, 3)
|
||||
@@ -84,6 +184,20 @@ def random_row(now_ms: int, online_rate: float) -> Tuple:
|
||||
last_seen = now_ms - random.randint(0, 7 * 24 * 60 * 60 * 1000)
|
||||
online = 1 if random.random() < online_rate else 0
|
||||
ping_ms = random.choice([None, random.randint(10, 400), random.randint(400, 2000)])
|
||||
cpu = random.choice(profile["cpus"])
|
||||
gpu = random.choice(profile["gpus"])
|
||||
ram = random.choice(profile["ram"])
|
||||
battery_percent = None
|
||||
battery_charging = None
|
||||
if profile.get("battery") and random.random() < 0.72:
|
||||
battery_percent = random.randint(7, 100)
|
||||
battery_charging = 1 if random.random() < 0.38 else 0
|
||||
is_admin = 1 if random.random() < 0.22 else 0
|
||||
elevation = random.choice(["user", "admin", "system", None])
|
||||
if is_admin and elevation == "user":
|
||||
elevation = "admin"
|
||||
bookmarked = 1 if random.random() < 0.08 else 0
|
||||
notifications_muted = 1 if random.random() < 0.05 else 0
|
||||
return (
|
||||
client_id,
|
||||
hwid,
|
||||
@@ -98,6 +212,16 @@ def random_row(now_ms: int, online_rate: float) -> Tuple:
|
||||
last_seen,
|
||||
online,
|
||||
ping_ms,
|
||||
cpu,
|
||||
gpu,
|
||||
ram,
|
||||
battery_percent,
|
||||
battery_charging,
|
||||
is_admin,
|
||||
elevation,
|
||||
"approved",
|
||||
bookmarked,
|
||||
notifications_muted,
|
||||
)
|
||||
|
||||
|
||||
@@ -117,9 +241,30 @@ def batched_rows(
|
||||
|
||||
def ensure_schema(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(SCHEMA)
|
||||
columns = {
|
||||
row[1] for row in conn.execute("PRAGMA table_info(clients)").fetchall()
|
||||
}
|
||||
additions = {
|
||||
"cpu": "TEXT",
|
||||
"gpu": "TEXT",
|
||||
"ram": "TEXT",
|
||||
"battery_percent": "INTEGER",
|
||||
"battery_charging": "INTEGER",
|
||||
"is_admin": "INTEGER NOT NULL DEFAULT 0",
|
||||
"elevation": "TEXT",
|
||||
"enrollment_status": "TEXT NOT NULL DEFAULT 'approved'",
|
||||
"bookmarked": "INTEGER NOT NULL DEFAULT 0",
|
||||
"notifications_muted": "INTEGER NOT NULL DEFAULT 0",
|
||||
}
|
||||
for name, decl in additions.items():
|
||||
if name not in columns:
|
||||
conn.execute(f"ALTER TABLE clients ADD COLUMN {name} {decl}")
|
||||
|
||||
|
||||
def seed(db_path: str, count: int, truncate: bool, online_rate: float) -> None:
|
||||
parent = os.path.dirname(os.path.abspath(db_path))
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
ensure_schema(conn)
|
||||
@@ -127,8 +272,8 @@ def seed(db_path: str, count: int, truncate: bool, online_rate: float) -> None:
|
||||
conn.execute("DELETE FROM clients")
|
||||
conn.commit()
|
||||
sql = (
|
||||
"INSERT INTO clients (id, hwid, role, host, os, arch, version, user, monitors, country, last_seen, online, ping_ms) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
"INSERT INTO clients (id, hwid, role, host, os, arch, version, user, monitors, country, last_seen, online, ping_ms, cpu, gpu, ram, battery_percent, battery_charging, is_admin, elevation, enrollment_status, bookmarked, notifications_muted) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
total = 0
|
||||
for batch in batched_rows(count, online_rate=online_rate):
|
||||
@@ -164,7 +309,7 @@ def main() -> None:
|
||||
"--count",
|
||||
type=int,
|
||||
default=100000,
|
||||
help="How many rows to insert (default: 10000)",
|
||||
help="How many rows to insert (default: 100000)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--truncate", action="store_true", help="Delete existing rows before seeding"
|
||||
|
||||
@@ -219,8 +219,8 @@ export function upsertClientRow(
|
||||
) {
|
||||
const now = partial.lastSeen ?? Date.now();
|
||||
db.run(
|
||||
`INSERT INTO clients (id, hwid, role, ip, host, os, arch, version, user, nickname, custom_tag, custom_tag_note, monitors, country, last_seen, online, ping_ms, build_tag, built_by_user_id, enrollment_status, public_key, key_fingerprint, cpu, gpu, ram, is_admin, elevation, permissions)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?, 0), ?, ?)
|
||||
`INSERT INTO clients (id, hwid, role, ip, host, os, arch, version, user, nickname, custom_tag, custom_tag_note, monitors, country, last_seen, online, ping_ms, build_tag, built_by_user_id, enrollment_status, public_key, key_fingerprint, cpu, gpu, ram, battery_percent, battery_charging, is_admin, elevation, permissions)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?, 0), ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
hwid=COALESCE(excluded.hwid, clients.hwid),
|
||||
role=COALESCE(excluded.role, clients.role),
|
||||
@@ -246,6 +246,8 @@ export function upsertClientRow(
|
||||
cpu=COALESCE(excluded.cpu, clients.cpu),
|
||||
gpu=COALESCE(excluded.gpu, clients.gpu),
|
||||
ram=COALESCE(excluded.ram, clients.ram),
|
||||
battery_percent=COALESCE(excluded.battery_percent, clients.battery_percent),
|
||||
battery_charging=COALESCE(excluded.battery_charging, clients.battery_charging),
|
||||
is_admin=COALESCE(excluded.is_admin, clients.is_admin),
|
||||
elevation=COALESCE(excluded.elevation, clients.elevation),
|
||||
permissions=COALESCE(excluded.permissions, clients.permissions)
|
||||
@@ -275,6 +277,8 @@ export function upsertClientRow(
|
||||
partial.cpu ?? null,
|
||||
partial.gpu ?? null,
|
||||
partial.ram ?? null,
|
||||
partial.batteryPercent ?? null,
|
||||
partial.batteryCharging !== undefined && partial.batteryCharging !== null ? (partial.batteryCharging ? 1 : 0) : null,
|
||||
partial.isAdmin !== undefined ? (partial.isAdmin ? 1 : 0) : null,
|
||||
partial.elevation ?? null,
|
||||
partial.permissions ? JSON.stringify(partial.permissions) : null,
|
||||
@@ -618,6 +622,7 @@ export function markAllClientsOffline() {
|
||||
|
||||
export const SUSPICIOUS_FLOOD_THRESHOLD = Math.max(2, Number(process.env.OVERLORD_SUSPICIOUS_FLOOD_THRESHOLD || 40));
|
||||
const SUSPICIOUS_IP_FLOOD_WINDOW_MS = Math.max(60_000, Number(process.env.OVERLORD_SUSPICIOUS_IP_WINDOW_MS || 300_000));
|
||||
const SUSPICIOUS_SCAN_MAX_CLIENTS = Math.max(0, Number(process.env.OVERLORD_SUSPICIOUS_SCAN_MAX_CLIENTS || 50_000));
|
||||
|
||||
export function getFloodedHwids(threshold = SUSPICIOUS_FLOOD_THRESHOLD): Set<string> {
|
||||
const rows = db
|
||||
@@ -853,6 +858,12 @@ export function listClients(filters: ListFilters): ListResult {
|
||||
}
|
||||
|
||||
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
||||
const needsGroupJoinForFilter =
|
||||
!!search ||
|
||||
sort === "group_asc" ||
|
||||
sort === "group_desc" ||
|
||||
(groupFilter !== undefined && groupFilter !== "all");
|
||||
const countJoinSql = needsGroupJoinForFilter ? "LEFT JOIN client_groups g ON g.id = c.group_id" : "";
|
||||
|
||||
const orderBy = (() => {
|
||||
const online = "c.online DESC";
|
||||
@@ -884,27 +895,48 @@ export function listClients(filters: ListFilters): ListResult {
|
||||
})();
|
||||
|
||||
const totalRow = db
|
||||
.query<{ c: number }>(`SELECT COUNT(*) as c FROM clients c LEFT JOIN client_groups g ON g.id = c.group_id ${whereSql}`)
|
||||
.query<{ c: number }>(`SELECT COUNT(*) as c FROM clients c ${countJoinSql} ${whereSql}`)
|
||||
.get(...params) ?? { c: 0 };
|
||||
const onlineRow = db
|
||||
.query<{ c: number }>(
|
||||
`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"}`,
|
||||
`SELECT COUNT(*) as c FROM clients c ${countJoinSql} ${whereSql ? `${whereSql} AND c.online=1` : "WHERE c.online=1"}`,
|
||||
)
|
||||
.get(...params) ?? { c: 0 };
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const rows = db
|
||||
.query<any>(
|
||||
`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, c.notifications_muted as notificationsMuted, c.deny_reason as denyReason
|
||||
FROM clients c
|
||||
LEFT JOIN client_groups g ON g.id = c.group_id
|
||||
${whereSql}
|
||||
${orderBy}
|
||||
LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.all(...params, pageSize, offset);
|
||||
const clientFields =
|
||||
"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.battery_percent as batteryPercent, c.battery_charging as batteryCharging, c.is_admin as isAdmin, c.elevation, c.permissions, c.disconnect_reason as disconnectReason, c.disconnect_detail as disconnectDetail, c.group_id as groupId, c.notifications_muted as notificationsMuted, c.deny_reason as denyReason";
|
||||
|
||||
const { floodedHwids, floodedHardware, floodedIps } = getFloodSetsWithCache();
|
||||
const rows = needsGroupJoinForFilter
|
||||
? db
|
||||
.query<any>(
|
||||
`SELECT ${clientFields}, g.name as groupName, g.color as groupColor
|
||||
FROM clients c
|
||||
LEFT JOIN client_groups g ON g.id = c.group_id
|
||||
${whereSql}
|
||||
${orderBy}
|
||||
LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.all(...params, pageSize, offset)
|
||||
: db
|
||||
.query<any>(
|
||||
`WITH page_clients AS (
|
||||
SELECT ${clientFields}
|
||||
FROM clients c
|
||||
${whereSql}
|
||||
${orderBy}
|
||||
LIMIT ? OFFSET ?
|
||||
)
|
||||
SELECT pc.*, g.name as groupName, g.color as groupColor
|
||||
FROM page_clients pc
|
||||
LEFT JOIN client_groups g ON g.id = pc.groupId`,
|
||||
)
|
||||
.all(...params, pageSize, offset);
|
||||
|
||||
const shouldScanSuspicious = SUSPICIOUS_SCAN_MAX_CLIENTS === 0 || totalRow.c <= SUSPICIOUS_SCAN_MAX_CLIENTS;
|
||||
const suspiciousSets = shouldScanSuspicious
|
||||
? getFloodSetsWithCache()
|
||||
: { floodedHwids: new Set<string>(), floodedHardware: new Set<string>(), floodedIps: new Set<string>() };
|
||||
const thumbnailSummaries = getThumbnailSummaries(rows.map((row: any) => row.id));
|
||||
|
||||
const items = rows.map((c: any) => {
|
||||
@@ -936,6 +968,8 @@ export function listClients(filters: ListFilters): ListResult {
|
||||
cpu: c.cpu || null,
|
||||
gpu: c.gpu || null,
|
||||
ram: c.ram || null,
|
||||
batteryPercent: typeof c.batteryPercent === "number" ? c.batteryPercent : null,
|
||||
batteryCharging: c.batteryCharging === null || c.batteryCharging === undefined ? null : c.batteryCharging === 1,
|
||||
isAdmin: c.isAdmin === 1,
|
||||
elevation: c.elevation || null,
|
||||
permissions: c.permissions ? (() => { try { return JSON.parse(c.permissions); } catch { return null; } })() : null,
|
||||
@@ -948,7 +982,7 @@ export function listClients(filters: ListFilters): ListResult {
|
||||
denyReason: c.denyReason || null,
|
||||
hasThumbnail: thumbnail?.hasThumbnail ?? false,
|
||||
thumbnailVersion: thumbnail?.thumbnailVersion ?? 0,
|
||||
suspiciousFlags: computeClientSuspiciousFlags(c, { floodedHwids, floodedHardware, floodedIps }),
|
||||
suspiciousFlags: shouldScanSuspicious ? computeClientSuspiciousFlags(c, suspiciousSets) : [],
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -79,6 +79,12 @@ try {
|
||||
try {
|
||||
db.run(`ALTER TABLE clients ADD COLUMN ram TEXT`);
|
||||
} catch {}
|
||||
try {
|
||||
db.run(`ALTER TABLE clients ADD COLUMN battery_percent INTEGER`);
|
||||
} catch {}
|
||||
try {
|
||||
db.run(`ALTER TABLE clients ADD COLUMN battery_charging INTEGER`);
|
||||
} catch {}
|
||||
try {
|
||||
db.run(`ALTER TABLE clients ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0`);
|
||||
} catch {}
|
||||
@@ -94,6 +100,12 @@ db.run(
|
||||
db.run(
|
||||
`CREATE INDEX IF NOT EXISTS idx_clients_online_last_seen ON clients(online, last_seen DESC);`,
|
||||
);
|
||||
db.run(
|
||||
`CREATE INDEX IF NOT EXISTS idx_clients_enrollment_online_bookmark_id ON clients(enrollment_status, online DESC, bookmarked DESC, id ASC);`,
|
||||
);
|
||||
db.run(
|
||||
`CREATE INDEX IF NOT EXISTS idx_clients_enrollment_online_last_seen ON clients(enrollment_status, online DESC, last_seen DESC, id ASC);`,
|
||||
);
|
||||
db.run(
|
||||
`CREATE INDEX IF NOT EXISTS idx_clients_os_last_seen ON clients(os, last_seen DESC);`,
|
||||
);
|
||||
|
||||
@@ -29,6 +29,8 @@ export type Hello = {
|
||||
monitors: number;
|
||||
monitorInfo?: { width: number; height: number }[];
|
||||
country?: string;
|
||||
batteryPercent?: number;
|
||||
batteryCharging?: boolean;
|
||||
publicKey?: string;
|
||||
signature?: string;
|
||||
publicIP?: string;
|
||||
|
||||
@@ -20,8 +20,8 @@ function acceptsGzip(req: Request): boolean {
|
||||
return (req.headers.get("accept-encoding") ?? "").includes("gzip");
|
||||
}
|
||||
|
||||
const STATIC_ASSET_CACHE = "public, max-age=86400, stale-while-revalidate=604800";
|
||||
const MUTABLE_ASSET_CACHE = "public, max-age=3600, stale-while-revalidate=86400";
|
||||
const STATIC_ASSET_CACHE = "public, max-age=31536000, immutable";
|
||||
const MUTABLE_ASSET_CACHE = "public, max-age=86400, stale-while-revalidate=604800";
|
||||
const NO_CACHE = "no-cache";
|
||||
|
||||
function assetCacheControl(relativePath: string): string {
|
||||
|
||||
@@ -75,7 +75,7 @@ export async function handleClientRoutes(
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
const page = Math.max(1, Number(url.searchParams.get("page") || 1));
|
||||
const pageSize = Math.max(1, Number(url.searchParams.get("pageSize") || 12));
|
||||
const pageSize = Math.min(200, Math.max(1, Number(url.searchParams.get("pageSize") || 12)));
|
||||
const search = (url.searchParams.get("q") || "").toLowerCase().trim();
|
||||
const sort = url.searchParams.get("sort") || "last_seen_desc";
|
||||
const statusFilter = url.searchParams.get("status") || "all";
|
||||
|
||||
@@ -456,6 +456,12 @@ export async function handleWebSocketMessage(
|
||||
if (typeof v !== "string") return undefined;
|
||||
return v.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "").slice(0, max);
|
||||
};
|
||||
const _percent = (v: unknown): number | undefined => {
|
||||
if (typeof v !== "number" || !Number.isFinite(v)) return undefined;
|
||||
const n = Math.round(v);
|
||||
return n >= 0 && n <= 100 ? n : undefined;
|
||||
};
|
||||
const batteryPercent = _percent((payload as any).batteryPercent);
|
||||
upsertClientRow({
|
||||
id: resolvedId,
|
||||
hwid: _s((payload as any).hwid) || resolvedId,
|
||||
@@ -470,6 +476,8 @@ export async function handleWebSocketMessage(
|
||||
cpu: _s((payload as any).cpu),
|
||||
gpu: _s((payload as any).gpu),
|
||||
ram: _s((payload as any).ram, 64),
|
||||
batteryPercent,
|
||||
batteryCharging: batteryPercent !== undefined ? !!(payload as any).batteryCharging : undefined,
|
||||
country,
|
||||
lastSeen: Date.now(),
|
||||
online: 0 as any,
|
||||
|
||||
@@ -37,6 +37,8 @@ export type ClientInfo = {
|
||||
cpu?: string;
|
||||
gpu?: string;
|
||||
ram?: string;
|
||||
batteryPercent?: number | null;
|
||||
batteryCharging?: boolean | null;
|
||||
isAdmin?: boolean;
|
||||
elevation?: string;
|
||||
permissions?: Record<string, boolean>;
|
||||
|
||||
@@ -35,6 +35,13 @@ function sanitizeMonitorCount(val: unknown): number | undefined {
|
||||
return n;
|
||||
}
|
||||
|
||||
function sanitizePercent(val: unknown): number | undefined {
|
||||
if (typeof val !== "number" || !Number.isFinite(val)) return undefined;
|
||||
const n = Math.round(val);
|
||||
if (n < 0 || n > 100) return undefined;
|
||||
return n;
|
||||
}
|
||||
|
||||
function sanitizeJsonField(
|
||||
val: unknown,
|
||||
opts: { maxJsonBytes: number; maxArrayLen?: number; maxKeys?: number },
|
||||
@@ -101,6 +108,11 @@ export async function handleHello(
|
||||
info.cpu = sanitizeInfoString((payload as any).cpu) || info.cpu;
|
||||
info.gpu = sanitizeInfoString((payload as any).gpu) || info.gpu;
|
||||
info.ram = sanitizeInfoString((payload as any).ram, 64) || info.ram;
|
||||
const batteryPercent = sanitizePercent((payload as any).batteryPercent);
|
||||
if (batteryPercent !== undefined) {
|
||||
info.batteryPercent = batteryPercent;
|
||||
info.batteryCharging = !!(payload as any).batteryCharging;
|
||||
}
|
||||
const geoip = await getGeoip();
|
||||
const geo = info.ip ? geoip.lookup(info.ip) : undefined;
|
||||
const countryRaw =
|
||||
@@ -127,6 +139,8 @@ export async function handleHello(
|
||||
cpu: info.cpu,
|
||||
gpu: info.gpu,
|
||||
ram: info.ram,
|
||||
batteryPercent: info.batteryPercent,
|
||||
batteryCharging: info.batteryCharging,
|
||||
isAdmin: info.isAdmin,
|
||||
elevation: info.elevation,
|
||||
permissions: info.permissions,
|
||||
|
||||
Reference in New Issue
Block a user