UI improvements
This commit is contained in:
@@ -32,7 +32,6 @@ import (
|
|||||||
"nhooyr.io/websocket"
|
"nhooyr.io/websocket"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
func runClient(cfg config.Config) {
|
func runClient(cfg config.Config) {
|
||||||
//garble:controlflow block_splits=10 junk_jumps=10 flatten_passes=2
|
//garble:controlflow block_splits=10 junk_jumps=10 flatten_passes=2
|
||||||
baseBackoff := computeBaseBackoff()
|
baseBackoff := computeBaseBackoff()
|
||||||
@@ -294,7 +293,6 @@ func refreshServerURLsFromRaw(cfg *config.Config) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func createHTTPTransport(cfg config.Config) *http.Transport {
|
func createHTTPTransport(cfg config.Config) *http.Transport {
|
||||||
tlsConfig := &tls.Config{
|
tlsConfig := &tls.Config{
|
||||||
InsecureSkipVerify: cfg.TLSInsecureSkipVerify,
|
InsecureSkipVerify: cfg.TLSInsecureSkipVerify,
|
||||||
@@ -608,6 +606,9 @@ func runSession(ctx context.Context, cancel context.CancelFunc, conn *websocket.
|
|||||||
if osVal == "" {
|
if osVal == "" {
|
||||||
osVal = runtime.GOOS
|
osVal = runtime.GOOS
|
||||||
}
|
}
|
||||||
|
if prettyOS := strings.TrimSpace(sysinfo.OSName()); prettyOS != "" {
|
||||||
|
osVal = prettyOS
|
||||||
|
}
|
||||||
|
|
||||||
archVal := strings.TrimSpace(cfg.Arch)
|
archVal := strings.TrimSpace(cfg.Arch)
|
||||||
if archVal == "" {
|
if archVal == "" {
|
||||||
@@ -636,6 +637,8 @@ func runSession(ctx context.Context, cancel context.CancelFunc, conn *websocket.
|
|||||||
hello.CPU = hw.CPU
|
hello.CPU = hw.CPU
|
||||||
hello.GPU = hw.GPU
|
hello.GPU = hw.GPU
|
||||||
hello.RAM = hw.RAM
|
hello.RAM = hw.RAM
|
||||||
|
hello.BatteryPercent = hw.BatteryPercent
|
||||||
|
hello.BatteryCharging = hw.BatteryCharging
|
||||||
hello.IsAdmin = sysinfo.IsAdmin()
|
hello.IsAdmin = sysinfo.IsAdmin()
|
||||||
hello.Elevation = sysinfo.Elevation()
|
hello.Elevation = sysinfo.Elevation()
|
||||||
hello.Permissions = sysinfo.DarwinPermissions()
|
hello.Permissions = sysinfo.DarwinPermissions()
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package sysinfo
|
package sysinfo
|
||||||
|
|
||||||
type Info struct {
|
type Info struct {
|
||||||
CPU string `json:"cpu,omitempty"`
|
CPU string `json:"cpu,omitempty"`
|
||||||
GPU string `json:"gpu,omitempty"`
|
GPU string `json:"gpu,omitempty"`
|
||||||
RAM string `json:"ram,omitempty"`
|
RAM string `json:"ram,omitempty"`
|
||||||
|
BatteryPercent *int `json:"batteryPercent,omitempty"`
|
||||||
|
BatteryCharging bool `json:"batteryCharging,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Collect() Info {
|
func Collect() Info {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package sysinfo
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
@@ -12,13 +13,54 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func collectPlatform() Info {
|
func collectPlatform() Info {
|
||||||
|
percent, charging := batteryStatus()
|
||||||
return Info{
|
return Info{
|
||||||
CPU: cpuName(),
|
CPU: cpuName(),
|
||||||
GPU: gpuName(),
|
GPU: gpuName(),
|
||||||
RAM: totalRAM(),
|
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 {
|
func cpuName() string {
|
||||||
name, err := unix.Sysctl("machdep.cpu.brand_string")
|
name, err := unix.Sysctl("machdep.cpu.brand_string")
|
||||||
if err != nil || name == "" {
|
if err != nil || name == "" {
|
||||||
|
|||||||
@@ -11,13 +11,74 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func collectPlatform() Info {
|
func collectPlatform() Info {
|
||||||
|
percent, charging := batteryStatus()
|
||||||
return Info{
|
return Info{
|
||||||
CPU: cpuName(),
|
CPU: cpuName(),
|
||||||
GPU: gpuName(),
|
GPU: gpuName(),
|
||||||
RAM: totalRAM(),
|
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 {
|
func cpuName() string {
|
||||||
data, err := os.ReadFile("/proc/cpuinfo")
|
data, err := os.ReadFile("/proc/cpuinfo")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -14,13 +14,70 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func collectPlatform() Info {
|
func collectPlatform() Info {
|
||||||
|
percent, charging := batteryStatus()
|
||||||
return Info{
|
return Info{
|
||||||
CPU: cpuName(),
|
CPU: cpuName(),
|
||||||
GPU: gpuName(),
|
GPU: gpuName(),
|
||||||
RAM: totalRAM(),
|
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 {
|
func cpuName() string {
|
||||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE,
|
k, err := registry.OpenKey(registry.LOCAL_MACHINE,
|
||||||
`HARDWARE\DESCRIPTION\System\CentralProcessor\0`, registry.QUERY_VALUE)
|
`HARDWARE\DESCRIPTION\System\CentralProcessor\0`, registry.QUERY_VALUE)
|
||||||
|
|||||||
@@ -1,29 +1,31 @@
|
|||||||
package wire
|
package wire
|
||||||
|
|
||||||
type Hello struct {
|
type Hello struct {
|
||||||
Type string `msgpack:"type"`
|
Type string `msgpack:"type"`
|
||||||
ID string `msgpack:"id"`
|
ID string `msgpack:"id"`
|
||||||
HWID string `msgpack:"hwid"`
|
HWID string `msgpack:"hwid"`
|
||||||
Host string `msgpack:"host"`
|
Host string `msgpack:"host"`
|
||||||
OS string `msgpack:"os"`
|
OS string `msgpack:"os"`
|
||||||
Arch string `msgpack:"arch"`
|
Arch string `msgpack:"arch"`
|
||||||
HostArch string `msgpack:"hostArch,omitempty"`
|
HostArch string `msgpack:"hostArch,omitempty"`
|
||||||
Version string `msgpack:"version"`
|
Version string `msgpack:"version"`
|
||||||
User string `msgpack:"user"`
|
User string `msgpack:"user"`
|
||||||
Monitors int `msgpack:"monitors"`
|
Monitors int `msgpack:"monitors"`
|
||||||
MonitorInfo []MonitorInfo `msgpack:"monitorInfo,omitempty"`
|
MonitorInfo []MonitorInfo `msgpack:"monitorInfo,omitempty"`
|
||||||
Country string `msgpack:"country,omitempty"`
|
Country string `msgpack:"country,omitempty"`
|
||||||
BuildTag string `msgpack:"buildTag,omitempty"`
|
BuildTag string `msgpack:"buildTag,omitempty"`
|
||||||
PublicKey string `msgpack:"publicKey,omitempty"`
|
PublicKey string `msgpack:"publicKey,omitempty"`
|
||||||
Signature string `msgpack:"signature,omitempty"`
|
Signature string `msgpack:"signature,omitempty"`
|
||||||
InMemory bool `msgpack:"inMemory,omitempty"`
|
InMemory bool `msgpack:"inMemory,omitempty"`
|
||||||
CPU string `msgpack:"cpu,omitempty"`
|
CPU string `msgpack:"cpu,omitempty"`
|
||||||
GPU string `msgpack:"gpu,omitempty"`
|
GPU string `msgpack:"gpu,omitempty"`
|
||||||
RAM string `msgpack:"ram,omitempty"`
|
RAM string `msgpack:"ram,omitempty"`
|
||||||
IsAdmin bool `msgpack:"isAdmin,omitempty"`
|
BatteryPercent *int `msgpack:"batteryPercent,omitempty"`
|
||||||
Elevation string `msgpack:"elevation,omitempty"`
|
BatteryCharging bool `msgpack:"batteryCharging,omitempty"`
|
||||||
Permissions map[string]bool `msgpack:"permissions,omitempty"`
|
IsAdmin bool `msgpack:"isAdmin,omitempty"`
|
||||||
PublicIP string `msgpack:"publicIP,omitempty"`
|
Elevation string `msgpack:"elevation,omitempty"`
|
||||||
|
Permissions map[string]bool `msgpack:"permissions,omitempty"`
|
||||||
|
PublicIP string `msgpack:"publicIP,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type EnrollmentChallenge struct {
|
type EnrollmentChallenge struct {
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ const FALLBACK_POLL_MS = 30000;
|
|||||||
const PREF_REFRESH_KEY = "overlord_refresh_interval_seconds";
|
const PREF_REFRESH_KEY = "overlord_refresh_interval_seconds";
|
||||||
let pollTimer = null;
|
let pollTimer = null;
|
||||||
let render = () => {};
|
let render = () => {};
|
||||||
|
let lastClientData = null;
|
||||||
|
const pageCache = new Map();
|
||||||
|
const PAGE_CACHE_LIMIT = 8;
|
||||||
let dashboardWs = null;
|
let dashboardWs = null;
|
||||||
let dashboardWsConnected = false;
|
let dashboardWsConnected = false;
|
||||||
let wsReconnectTimer = null;
|
let wsReconnectTimer = null;
|
||||||
@@ -42,6 +45,45 @@ export function registerRenderer(fn) {
|
|||||||
render = 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 = {}) {
|
export async function loadWithOptions(options = {}) {
|
||||||
const { force = false, reorder = false } = options;
|
const { force = false, reorder = false } = options;
|
||||||
if (state.isLoading) {
|
if (state.isLoading) {
|
||||||
@@ -51,19 +93,19 @@ export async function loadWithOptions(options = {}) {
|
|||||||
}
|
}
|
||||||
state.isLoading = true;
|
state.isLoading = true;
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = clientQueryParams();
|
||||||
page: String(state.page),
|
const cacheKey = params.toString();
|
||||||
pageSize: String(state.pageSize),
|
const cached = pageCache.get(cacheKey);
|
||||||
q: state.searchTerm,
|
if (force && cached) {
|
||||||
sort: state.sort,
|
lastClientData = cached;
|
||||||
status: state.filterStatus || "all",
|
render(cached, { reorder, fromCache: true });
|
||||||
os: state.filterOs || "all",
|
}
|
||||||
country: state.filterCountry || "all",
|
|
||||||
group: state.filterGroup || "all",
|
const res = await fetch(`/api/clients?${cacheKey}`);
|
||||||
});
|
|
||||||
const res = await fetch(`/api/clients?${params.toString()}`);
|
|
||||||
if (!res.ok) throw new Error(`status ${res.status}`);
|
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
lastClientData = data;
|
||||||
|
rememberPage(cacheKey, data);
|
||||||
|
|
||||||
updateOsFilter(data.items);
|
updateOsFilter(data.items);
|
||||||
|
|
||||||
@@ -73,6 +115,7 @@ export async function loadWithOptions(options = {}) {
|
|||||||
}
|
}
|
||||||
state.lastDigest = digest;
|
state.lastDigest = digest;
|
||||||
render(data, { reorder });
|
render(data, { reorder });
|
||||||
|
prefetchAdjacentPages(data);
|
||||||
const pag = document.getElementById("pagination");
|
const pag = document.getElementById("pagination");
|
||||||
if (pag) pag.style.visibility = "";
|
if (pag) pag.style.visibility = "";
|
||||||
} catch (err) {
|
} 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) {
|
function updateOsFilter(items) {
|
||||||
const osSelect = document.getElementById("filter-os");
|
const osSelect = document.getElementById("filter-os");
|
||||||
if (!osSelect) return;
|
if (!osSelect) return;
|
||||||
|
|||||||
@@ -1085,9 +1085,9 @@ article.card[data-bookmarked="true"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.input-row:focus-within {
|
.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);
|
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 {
|
.input-row i {
|
||||||
@@ -1096,7 +1096,7 @@ article.card[data-bookmarked="true"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.input-row:focus-within i {
|
.input-row:focus-within i {
|
||||||
color: #7a5bff;
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-row input {
|
.input-row input {
|
||||||
@@ -1252,6 +1252,25 @@ article.card[data-bookmarked="true"] {
|
|||||||
opacity: 0;
|
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 {
|
@keyframes cardFlipFall {
|
||||||
0% {
|
0% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -1885,6 +1904,7 @@ mark.search-current {
|
|||||||
|
|
||||||
#filter-status,
|
#filter-status,
|
||||||
#filter-os,
|
#filter-os,
|
||||||
|
#filter-group,
|
||||||
#sort {
|
#sort {
|
||||||
transition:
|
transition:
|
||||||
border-color 150ms ease,
|
border-color 150ms ease,
|
||||||
@@ -1894,10 +1914,11 @@ mark.search-current {
|
|||||||
|
|
||||||
#filter-status:focus,
|
#filter-status:focus,
|
||||||
#filter-os:focus,
|
#filter-os:focus,
|
||||||
|
#filter-group:focus,
|
||||||
#sort:focus {
|
#sort:focus {
|
||||||
border-color: rgba(99, 102, 241, 0.65) !important;
|
border-color: rgba(100, 116, 139, 0.82) !important;
|
||||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.18);
|
box-shadow: none !important;
|
||||||
outline: none;
|
outline: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
#search-shell {
|
#search-shell {
|
||||||
@@ -1908,14 +1929,207 @@ mark.search-current {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#search-shell:focus-within {
|
#search-shell:focus-within {
|
||||||
border-color: rgba(99, 102, 241, 0.65) !important;
|
border-color: rgba(100, 116, 139, 0.82) !important;
|
||||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.18);
|
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,
|
#select-all,
|
||||||
#clear-selection,
|
#clear-selection,
|
||||||
#prev,
|
#prev,
|
||||||
#next {
|
#next,
|
||||||
|
#page-jump-go {
|
||||||
transition:
|
transition:
|
||||||
transform 150ms ease,
|
transform 150ms ease,
|
||||||
border-color 150ms ease,
|
border-color 150ms ease,
|
||||||
@@ -1925,12 +2139,70 @@ mark.search-current {
|
|||||||
#select-all:hover,
|
#select-all:hover,
|
||||||
#clear-selection:hover,
|
#clear-selection:hover,
|
||||||
#prev:hover,
|
#prev:hover,
|
||||||
#next:hover {
|
#next:hover,
|
||||||
|
#page-jump-go:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
border-color: rgba(99, 102, 241, 0.45) !important;
|
border-color: rgba(99, 102, 241, 0.45) !important;
|
||||||
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.4);
|
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 {
|
#bulk-toolbar {
|
||||||
background:
|
background:
|
||||||
radial-gradient(
|
radial-gradient(
|
||||||
@@ -1969,8 +2241,8 @@ mark.search-current {
|
|||||||
--ui-surface-strong: rgba(11, 17, 33, 0.9);
|
--ui-surface-strong: rgba(11, 17, 33, 0.9);
|
||||||
--ui-surface-elevated: rgba(14, 22, 40, 0.94);
|
--ui-surface-elevated: rgba(14, 22, 40, 0.94);
|
||||||
--ui-border: rgba(148, 163, 184, 0.2);
|
--ui-border: rgba(148, 163, 184, 0.2);
|
||||||
--ui-border-strong: rgba(99, 102, 241, 0.4);
|
--ui-border-strong: rgba(100, 116, 139, 0.82);
|
||||||
--ui-ring: rgba(99, 102, 241, 0.28);
|
--ui-ring: transparent;
|
||||||
--ui-shadow: 0 14px 34px rgba(2, 6, 23, 0.35);
|
--ui-shadow: 0 14px 34px rgba(2, 6, 23, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2070,6 +2342,7 @@ textarea,
|
|||||||
#search,
|
#search,
|
||||||
#filter-status,
|
#filter-status,
|
||||||
#filter-os,
|
#filter-os,
|
||||||
|
#filter-group,
|
||||||
#sort,
|
#sort,
|
||||||
#console-input {
|
#console-input {
|
||||||
background-color: var(--ui-surface);
|
background-color: var(--ui-surface);
|
||||||
@@ -2089,11 +2362,12 @@ textarea:focus,
|
|||||||
.input-row:focus-within,
|
.input-row:focus-within,
|
||||||
#filter-status:focus,
|
#filter-status:focus,
|
||||||
#filter-os:focus,
|
#filter-os:focus,
|
||||||
|
#filter-group:focus,
|
||||||
#sort:focus,
|
#sort:focus,
|
||||||
#console-input:focus {
|
#console-input:focus {
|
||||||
border-color: var(--ui-border-strong) !important;
|
border-color: var(--ui-border-strong) !important;
|
||||||
box-shadow: 0 0 0 3px var(--ui-ring);
|
box-shadow: none !important;
|
||||||
outline: none;
|
outline: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pill,
|
.pill,
|
||||||
@@ -2691,6 +2965,14 @@ tbody tr:hover {
|
|||||||
.cv-tone-pill-arch { color: #67e8f9; }
|
.cv-tone-pill-arch { color: #67e8f9; }
|
||||||
.cv-tone-pill-kali { color: #7dd3fc; }
|
.cv-tone-pill-kali { color: #7dd3fc; }
|
||||||
.cv-tone-pill-fedora { color: #bfdbfe; }
|
.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-linux { color: #86efac; }
|
||||||
.cv-tone-pill-x64 { color: #5eead4; }
|
.cv-tone-pill-x64 { color: #5eead4; }
|
||||||
.cv-tone-pill-arm { color: #7dd3fc; }
|
.cv-tone-pill-arm { color: #7dd3fc; }
|
||||||
@@ -2698,6 +2980,48 @@ tbody tr:hover {
|
|||||||
.cv-tone-pill-unknown { color: var(--cv-text-mute); }
|
.cv-tone-pill-unknown { color: var(--cv-text-mute); }
|
||||||
.cv-os, .cv-arch { display: inline-flex; align-items: center; gap: 4px; }
|
.cv-os, .cv-arch { display: inline-flex; align-items: center; gap: 4px; }
|
||||||
.cv-os i { font-size: 11px; }
|
.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 */
|
/* Arch chip in table variant */
|
||||||
.clients-table .cv-arch-chip {
|
.clients-table .cv-arch-chip {
|
||||||
@@ -2720,7 +3044,7 @@ tbody tr:hover {
|
|||||||
.clients-table thead th {
|
.clients-table thead th {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 1;
|
z-index: 0;
|
||||||
background: var(--cv-bg) !important;
|
background: var(--cv-bg) !important;
|
||||||
color: var(--cv-text-mute);
|
color: var(--cv-text-mute);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -2826,7 +3150,7 @@ tbody tr:hover {
|
|||||||
.cv-card.is-bookmarked { border-color: rgba(202, 138, 4, 0.5); }
|
.cv-card.is-bookmarked { border-color: rgba(202, 138, 4, 0.5); }
|
||||||
.cv-card:has(.client-checkbox:checked) {
|
.cv-card:has(.client-checkbox:checked) {
|
||||||
border-color: var(--cv-accent-border);
|
border-color: var(--cv-accent-border);
|
||||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.18);
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cv-card-header {
|
.cv-card-header {
|
||||||
@@ -3024,6 +3348,103 @@ tbody tr:hover {
|
|||||||
}
|
}
|
||||||
.cv-card-hw-bit i { color: var(--cv-text-dim); font-size: 10px; }
|
.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-text-dim { color: var(--cv-text-dim); }
|
||||||
|
|
||||||
.cv-card-actions {
|
.cv-card-actions {
|
||||||
@@ -3293,8 +3714,7 @@ body.sb-collapsed .sb-collapse-btn i {
|
|||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
.sb-link:focus-visible {
|
.sb-link:focus-visible {
|
||||||
outline: 2px solid #3b82f6;
|
outline: none;
|
||||||
outline-offset: 1px;
|
|
||||||
}
|
}
|
||||||
/* Collapsed: center icon, fill the rail */
|
/* Collapsed: center icon, fill the rail */
|
||||||
body.sb-collapsed .sb-link {
|
body.sb-collapsed .sb-link {
|
||||||
@@ -3769,8 +4189,7 @@ body.sb-open #sb-backdrop { display: block; }
|
|||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
.sb-group-btn:focus-visible {
|
.sb-group-btn:focus-visible {
|
||||||
outline: 2px solid #3b82f6;
|
outline: none;
|
||||||
outline-offset: 1px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sb-chevron {
|
.sb-chevron {
|
||||||
@@ -3868,8 +4287,7 @@ body.sb-collapsed .sb-link-child {
|
|||||||
transform: scale(1.08);
|
transform: scale(1.08);
|
||||||
}
|
}
|
||||||
#nav-reveal-btn:focus-visible {
|
#nav-reveal-btn:focus-visible {
|
||||||
outline: 2px solid #3b82f6;
|
outline: none;
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#top-nav {
|
#top-nav {
|
||||||
@@ -3942,3 +4360,32 @@ body.sb-ready.nav-hidden #nav-reveal-btn {
|
|||||||
#nav-reveal-btn:hover::after {
|
#nav-reveal-btn:hover::after {
|
||||||
opacity: 1;
|
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 {
|
import {
|
||||||
registerRenderer,
|
registerRenderer,
|
||||||
loadWithOptions,
|
loadWithOptions,
|
||||||
|
renderCachedClients,
|
||||||
startAutoRefresh,
|
startAutoRefresh,
|
||||||
sendCommand,
|
sendCommand,
|
||||||
requestPreview,
|
requestPreview,
|
||||||
@@ -16,14 +17,18 @@ import { ThumbnailLoader } from "./thumbnail-loader.js";
|
|||||||
const grid = document.getElementById("grid");
|
const grid = document.getElementById("grid");
|
||||||
const totalPill = document.getElementById("total-pill");
|
const totalPill = document.getElementById("total-pill");
|
||||||
const pageLabel = document.getElementById("page-label");
|
const pageLabel = document.getElementById("page-label");
|
||||||
|
const pageTotal = document.getElementById("page-total");
|
||||||
const prevBtn = document.getElementById("prev");
|
const prevBtn = document.getElementById("prev");
|
||||||
const nextBtn = document.getElementById("next");
|
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 searchInput = document.getElementById("search");
|
||||||
const sortSelect = document.getElementById("sort");
|
const sortSelect = document.getElementById("sort");
|
||||||
const filterStatusSelect = document.getElementById("filter-status");
|
const filterStatusSelect = document.getElementById("filter-status");
|
||||||
const filterOsSelect = document.getElementById("filter-os");
|
const filterOsSelect = document.getElementById("filter-os");
|
||||||
const filterGroupSelect = document.getElementById("filter-group");
|
const filterGroupSelect = document.getElementById("filter-group");
|
||||||
const showOfflineToggle = document.getElementById("toggle-offline");
|
const showOfflineToggle = document.getElementById("toggle-offline");
|
||||||
|
const displayFieldsMenu = document.getElementById("display-fields-menu");
|
||||||
const selectAllBtn = document.getElementById("select-all");
|
const selectAllBtn = document.getElementById("select-all");
|
||||||
const clearSelectionBtn = document.getElementById("clear-selection");
|
const clearSelectionBtn = document.getElementById("clear-selection");
|
||||||
const logoutBtn = document.getElementById("logout-btn");
|
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_OS_KEY = "overlord_filter_os";
|
||||||
const PREF_FILTER_COUNTRY_KEY = "overlord_filter_country";
|
const PREF_FILTER_COUNTRY_KEY = "overlord_filter_country";
|
||||||
const PREF_FILTER_GROUP_KEY = "overlord_filter_group";
|
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 currentUser = null;
|
||||||
let contextCard = null;
|
let contextCard = null;
|
||||||
let availableOsList = new Set();
|
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) {
|
function setServerVersionLabel(version) {
|
||||||
if (!serverVersionText) return;
|
if (!serverVersionText) return;
|
||||||
@@ -667,13 +700,69 @@ function setLayout(layout) {
|
|||||||
applyLayoutToggleUI(next);
|
applyLayoutToggleUI(next);
|
||||||
if (rendererSetLayout) rendererSetLayout(next);
|
if (rendererSetLayout) rendererSetLayout(next);
|
||||||
state.lastDigest = "";
|
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) => {
|
document.querySelectorAll("#layout-toggle .layout-toggle-btn").forEach((btn) => {
|
||||||
btn.addEventListener("click", () => setLayout(btn.dataset.layout));
|
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;
|
let dashboardThumbnailLoader = null;
|
||||||
|
|
||||||
async function isDashboardThumbnailEnabled() {
|
async function isDashboardThumbnailEnabled() {
|
||||||
@@ -729,6 +818,8 @@ async function setupDashboardThumbnailLoader() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function initializeRenderer() {
|
function initializeRenderer() {
|
||||||
|
if (rendererInitialized) return;
|
||||||
|
rendererInitialized = true;
|
||||||
const savedLayout = localStorage.getItem(PREF_LAYOUT_KEY) || "rows";
|
const savedLayout = localStorage.getItem(PREF_LAYOUT_KEY) || "rows";
|
||||||
if (grid) grid.dataset.layout = ["rows", "table", "cards"].includes(savedLayout) ? savedLayout : "rows";
|
if (grid) grid.dataset.layout = ["rows", "table", "cards"].includes(savedLayout) ? savedLayout : "rows";
|
||||||
applyLayoutToggleUI(grid?.dataset.layout || "rows");
|
applyLayoutToggleUI(grid?.dataset.layout || "rows");
|
||||||
@@ -750,6 +841,7 @@ function initializeRenderer() {
|
|||||||
requestThumbnail,
|
requestThumbnail,
|
||||||
pingClient: (id) => sendCommand(id, "ping"),
|
pingClient: (id) => sendCommand(id, "ping"),
|
||||||
userRole: currentUser?.role,
|
userRole: currentUser?.role,
|
||||||
|
getDisplayFields: () => displayFields,
|
||||||
});
|
});
|
||||||
rendererSetLayout = rSetLayout;
|
rendererSetLayout = rSetLayout;
|
||||||
registerRenderer(renderMerge);
|
registerRenderer(renderMerge);
|
||||||
@@ -758,7 +850,7 @@ function initializeRenderer() {
|
|||||||
loadWithOptions();
|
loadWithOptions();
|
||||||
startAutoRefresh();
|
startAutoRefresh();
|
||||||
|
|
||||||
if (typeof anime !== "undefined") {
|
if (typeof anime !== "undefined" && !document.documentElement.classList.contains("large-client-set")) {
|
||||||
anime
|
anime
|
||||||
.timeline({ easing: "easeOutQuad" })
|
.timeline({ easing: "easeOutQuad" })
|
||||||
.add({
|
.add({
|
||||||
@@ -874,7 +966,7 @@ import("./country-picker.js").then(({ initCountryPicker }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const savedSort = localStorage.getItem(PREF_SORT_KEY);
|
const savedSort = localStorage.getItem(PREF_SORT_KEY);
|
||||||
const validSorts = ["stable", "last_seen_desc", "host_asc", "ping_asc", "ping_desc", "country_asc", "country_desc", "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)) {
|
if (savedSort && validSorts.includes(savedSort)) {
|
||||||
state.sort = savedSort;
|
state.sort = savedSort;
|
||||||
if (sortSelect) sortSelect.value = savedSort;
|
if (sortSelect) sortSelect.value = savedSort;
|
||||||
@@ -1448,6 +1540,17 @@ nextBtn?.addEventListener("click", () => {
|
|||||||
loadWithOptions({ force: true, reorder: true });
|
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) => {
|
window.addEventListener("click", (e) => {
|
||||||
const target = e.target;
|
const target = e.target;
|
||||||
if (target.closest && target.closest(".command-btn")) return;
|
if (target.closest && target.closest(".command-btn")) return;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { TabulatorFull as Tabulator } from "/vendor/tabulator/tabulator_esm.min.js";
|
const body = document.getElementById("enrollment-body");
|
||||||
|
const loadingRow = document.getElementById("enrollment-loading");
|
||||||
const enrollmentTableEl = document.getElementById("enrollment-table");
|
|
||||||
const emptyEl = document.getElementById("enrollment-empty");
|
const emptyEl = document.getElementById("enrollment-empty");
|
||||||
|
const selectAllEl = document.getElementById("select-all");
|
||||||
const bulkApproveBtn = document.getElementById("bulk-approve-btn");
|
const bulkApproveBtn = document.getElementById("bulk-approve-btn");
|
||||||
const bulkDenyBtn = document.getElementById("bulk-deny-btn");
|
const bulkDenyBtn = document.getElementById("bulk-deny-btn");
|
||||||
const statPending = document.getElementById("stat-pending");
|
const statPending = document.getElementById("stat-pending");
|
||||||
@@ -10,9 +10,9 @@ const statDenied = document.getElementById("stat-denied");
|
|||||||
const statBannedIps = document.getElementById("stat-banned-ips");
|
const statBannedIps = document.getElementById("stat-banned-ips");
|
||||||
const searchInput = document.getElementById("search-input");
|
const searchInput = document.getElementById("search-input");
|
||||||
const bannedIpsSection = document.getElementById("banned-ips-section");
|
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 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 addBanBtn = document.getElementById("add-ban-btn");
|
||||||
const manualBanForm = document.getElementById("manual-ban-form");
|
const manualBanForm = document.getElementById("manual-ban-form");
|
||||||
const banIpInput = document.getElementById("ban-ip-input");
|
const banIpInput = document.getElementById("ban-ip-input");
|
||||||
@@ -44,10 +44,8 @@ const denyReasonModalBackdrop = document.getElementById("deny-reason-modal-backd
|
|||||||
let currentFilter = "pending";
|
let currentFilter = "pending";
|
||||||
let searchQuery = "";
|
let searchQuery = "";
|
||||||
let clients = [];
|
let clients = [];
|
||||||
let enrollmentTable = null;
|
|
||||||
let bannedIpsTable = null;
|
|
||||||
const selectedIds = new Set();
|
|
||||||
const expandedCells = new Set(); // tracks "clientId:field" keys
|
const expandedCells = new Set(); // tracks "clientId:field" keys
|
||||||
|
let lastEnrollmentDigest = "";
|
||||||
|
|
||||||
const SUSPICIOUS_FLAG_LABELS = {
|
const SUSPICIOUS_FLAG_LABELS = {
|
||||||
hwid_flood: "HWID Flood (40+ same hardware ID)",
|
hwid_flood: "HWID Flood (40+ same hardware ID)",
|
||||||
@@ -103,8 +101,9 @@ async function loadStats() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadClients() {
|
async function loadClients() {
|
||||||
|
const hasRows = body.querySelector("tr[data-id]");
|
||||||
|
loadingRow.classList.toggle("hidden", !!hasRows);
|
||||||
emptyEl.classList.add("hidden");
|
emptyEl.classList.add("hidden");
|
||||||
if (enrollmentTable) enrollmentTable.replaceData([]);
|
|
||||||
|
|
||||||
const fetchFilter = currentFilter === "suspicious" ? "all" : currentFilter;
|
const fetchFilter = currentFilter === "suspicious" ? "all" : currentFilter;
|
||||||
try {
|
try {
|
||||||
@@ -136,26 +135,84 @@ async function loadClients() {
|
|||||||
})
|
})
|
||||||
: base;
|
: base;
|
||||||
|
|
||||||
|
loadingRow.classList.add("hidden");
|
||||||
|
|
||||||
const showBulkActions = (currentFilter === "pending" || currentFilter === "suspicious") &&
|
const showBulkActions = (currentFilter === "pending" || currentFilter === "suspicious") &&
|
||||||
filtered.some((c) => (c.enrollmentStatus || "pending") === "pending");
|
filtered.some((c) => (c.enrollmentStatus || "pending") === "pending");
|
||||||
approveAllBtn.classList.toggle("hidden", !showBulkActions);
|
approveAllBtn.classList.toggle("hidden", !showBulkActions);
|
||||||
denyAllBtn.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) {
|
if (filtered.length === 0) {
|
||||||
|
lastEnrollmentDigest = `empty:${currentFilter}:${q}`;
|
||||||
|
body.querySelectorAll("tr:not(#enrollment-loading)").forEach((r) => r.remove());
|
||||||
emptyEl.classList.remove("hidden");
|
emptyEl.classList.remove("hidden");
|
||||||
updateBulkButtons();
|
updateBulkButtons();
|
||||||
return;
|
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();
|
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) {
|
function statusBadge(status) {
|
||||||
const map = {
|
const map = {
|
||||||
pending:
|
pending:
|
||||||
@@ -232,129 +289,6 @@ function expandableCell(clientId, field, value) {
|
|||||||
`</span>`;
|
`</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) {
|
function timeAgo(ts) {
|
||||||
const diff = Date.now() - ts;
|
const diff = Date.now() - ts;
|
||||||
if (diff < 60000) return "just now";
|
if (diff < 60000) return "just now";
|
||||||
@@ -401,24 +335,26 @@ async function bulkAction(action) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (window.showToast) window.showToast(`Failed: ${e.message}`, "error");
|
if (window.showToast) window.showToast(`Failed: ${e.message}`, "error");
|
||||||
}
|
}
|
||||||
selectedIds.clear();
|
selectAllEl.checked = false;
|
||||||
await Promise.all([loadClients(), loadStats()]);
|
await Promise.all([loadClients(), loadStats()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSelectedIds() {
|
function getSelectedIds() {
|
||||||
return [...selectedIds];
|
return [...document.querySelectorAll(".row-check:checked")].map(
|
||||||
|
(el) => el.dataset.id,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateBulkButtons() {
|
function updateBulkButtons() {
|
||||||
const visibleIds = new Set((enrollmentTable?.getData() || []).map((row) => row.id));
|
const rowChecks = [...document.querySelectorAll(".row-check")];
|
||||||
for (const id of selectedIds) {
|
const selected = getSelectedIds();
|
||||||
if (!visibleIds.has(id)) selectedIds.delete(id);
|
const count = selected.length;
|
||||||
}
|
|
||||||
const count = selectedIds.size;
|
|
||||||
bulkApproveBtn.classList.toggle("hidden", count === 0);
|
bulkApproveBtn.classList.toggle("hidden", count === 0);
|
||||||
bulkDenyBtn.classList.toggle("hidden", count === 0);
|
bulkDenyBtn.classList.toggle("hidden", count === 0);
|
||||||
bulkBanBtn.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 ──────────────────────────────────────────────────
|
// ── Tab switching ──────────────────────────────────────────────────
|
||||||
@@ -454,7 +390,7 @@ document.querySelectorAll(".enrollment-tab").forEach((tab) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Table delegation ───────────────────────────────────────────────
|
// ── Table delegation ───────────────────────────────────────────────
|
||||||
enrollmentTableEl?.addEventListener("click", (e) => {
|
body.addEventListener("click", (e) => {
|
||||||
// Expandable CPU/GPU cells
|
// Expandable CPU/GPU cells
|
||||||
const expander = e.target.closest(".hw-expand");
|
const expander = e.target.closest(".hw-expand");
|
||||||
if (expander) {
|
if (expander) {
|
||||||
@@ -469,6 +405,19 @@ enrollmentTableEl?.addEventListener("click", (e) => {
|
|||||||
return;
|
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");
|
const btn = e.target.closest("button");
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
const id = btn.dataset.id;
|
const id = btn.dataset.id;
|
||||||
@@ -479,24 +428,11 @@ enrollmentTableEl?.addEventListener("click", (e) => {
|
|||||||
else if (btn.classList.contains("act-ban-ip")) banClientIp(id);
|
else if (btn.classList.contains("act-ban-ip")) banClientIp(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
enrollmentTableEl?.addEventListener("change", (e) => {
|
body.addEventListener("change", () => updateBulkButtons());
|
||||||
const selectAll = e.target.closest(".enrollment-select-all");
|
selectAllEl.addEventListener("change", () => {
|
||||||
if (selectAll) {
|
document.querySelectorAll(".row-check").forEach((cb) => {
|
||||||
const rows = enrollmentTable?.getData() || [];
|
cb.checked = selectAllEl.checked;
|
||||||
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);
|
|
||||||
updateBulkButtons();
|
updateBulkButtons();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -575,8 +511,8 @@ async function banClientIp(clientId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadBannedIps() {
|
async function loadBannedIps() {
|
||||||
|
bannedIpsBody.innerHTML = "";
|
||||||
bannedIpsEmpty.classList.add("hidden");
|
bannedIpsEmpty.classList.add("hidden");
|
||||||
if (bannedIpsTable) bannedIpsTable.replaceData([]);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await api("/api/enrollment/banned-ips");
|
const data = await api("/api/enrollment/banned-ips");
|
||||||
@@ -587,13 +523,27 @@ async function loadBannedIps() {
|
|||||||
return;
|
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 {
|
} catch {
|
||||||
bannedIpsEmpty.classList.remove("hidden");
|
bannedIpsEmpty.classList.remove("hidden");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bannedIpsTableEl?.addEventListener("click", async (e) => {
|
bannedIpsBody.addEventListener("click", async (e) => {
|
||||||
const btn = e.target.closest("button");
|
const btn = e.target.closest("button");
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
if (btn.classList.contains("act-unban")) {
|
if (btn.classList.contains("act-unban")) {
|
||||||
@@ -773,8 +723,6 @@ denyReasonModalConfirm.addEventListener("click", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Init ───────────────────────────────────────────────────────────
|
// ── Init ───────────────────────────────────────────────────────────
|
||||||
initEnrollmentTable();
|
|
||||||
initBannedIpsTable();
|
|
||||||
loadSettings();
|
loadSettings();
|
||||||
loadStats();
|
loadStats();
|
||||||
loadClients();
|
loadClients();
|
||||||
|
|||||||
@@ -150,10 +150,64 @@ function shortenCpu(raw = "") {
|
|||||||
.slice(0, 38);
|
.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 = "") {
|
function shortOsLabel(osRaw = "") {
|
||||||
|
const badge = osBadge(osRaw || "");
|
||||||
|
if (badge?.label && badge.label !== "Linux" && badge.label !== "Windows") return badge.label;
|
||||||
const o = String(osRaw).toLowerCase();
|
const o = String(osRaw).toLowerCase();
|
||||||
if (o.includes("windows 11")) return "Win 11";
|
const winVersion = o.match(/\bwindows\s+(10|11)\b/i);
|
||||||
if (o.includes("windows 10")) return "Win 10";
|
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("windows")) return "Win";
|
||||||
if (o.includes("ubuntu")) return "Ubuntu";
|
if (o.includes("ubuntu")) return "Ubuntu";
|
||||||
if (o.includes("debian")) return "Debian";
|
if (o.includes("debian")) return "Debian";
|
||||||
@@ -175,15 +229,31 @@ export function createRenderer({
|
|||||||
requestThumbnail,
|
requestThumbnail,
|
||||||
pingClient,
|
pingClient,
|
||||||
userRole,
|
userRole,
|
||||||
|
getDisplayFields,
|
||||||
}) {
|
}) {
|
||||||
const isViewer = userRole === "viewer";
|
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 INSERT_BATCH_SIZE = 40;
|
||||||
const TOUCH_LONG_PRESS_MS = 520;
|
const TOUCH_LONG_PRESS_MS = 520;
|
||||||
const TOUCH_MOVE_CANCEL_PX = 10;
|
const TOUCH_MOVE_CANCEL_PX = 10;
|
||||||
let renderToken = 0;
|
let renderToken = 0;
|
||||||
let gridDelegated = false;
|
let gridDelegated = false;
|
||||||
let currentLayout = (grid?.dataset.layout || "rows").toLowerCase();
|
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() {
|
function getCardContainer() {
|
||||||
if (currentLayout === "table") {
|
if (currentLayout === "table") {
|
||||||
@@ -194,7 +264,10 @@ export function createRenderer({
|
|||||||
|
|
||||||
function ensureLayoutScaffold() {
|
function ensureLayoutScaffold() {
|
||||||
if (currentLayout === "table") {
|
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 = `
|
grid.innerHTML = `
|
||||||
<table class="clients-table">
|
<table class="clients-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -205,9 +278,9 @@ export function createRenderer({
|
|||||||
<th>Client</th>
|
<th>Client</th>
|
||||||
<th class="cv-th-status">Status</th>
|
<th class="cv-th-status">Status</th>
|
||||||
<th class="cv-th-last">Last seen</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-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>
|
<th class="cv-th-actions">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -215,6 +288,7 @@ export function createRenderer({
|
|||||||
</table>
|
</table>
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
|
tableScaffoldDigest = "";
|
||||||
const t = grid.querySelector("table.clients-table");
|
const t = grid.querySelector("table.clients-table");
|
||||||
if (t) t.remove();
|
if (t) t.remove();
|
||||||
}
|
}
|
||||||
@@ -453,14 +527,16 @@ export function createRenderer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!needsReorder) return;
|
if (!needsReorder) return;
|
||||||
|
const byId = new Map();
|
||||||
|
cards.forEach((card) => byId.set(card.dataset.id, card));
|
||||||
items.forEach((client) => {
|
items.forEach((client) => {
|
||||||
const card = container.querySelector(`${ROW_SELECTOR}[data-id="${client.id}"]`);
|
const card = byId.get(client.id);
|
||||||
if (card) container.appendChild(card);
|
if (card) container.appendChild(card);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function cardDigest(c) {
|
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) {
|
function cardThumbDigest(c) {
|
||||||
@@ -482,31 +558,40 @@ export function createRenderer({
|
|||||||
|
|
||||||
function renderMerge(data, options = {}) {
|
function renderMerge(data, options = {}) {
|
||||||
setupGridDelegation();
|
setupGridDelegation();
|
||||||
|
currentDisplayDigest = displayDigest();
|
||||||
ensureLayoutScaffold();
|
ensureLayoutScaffold();
|
||||||
const container = getCardContainer();
|
const container = getCardContainer();
|
||||||
const { reorder = false } = options;
|
const { reorder = false } = options;
|
||||||
totalPill.textContent = `${data.online ?? data.total} online / ${data.total} total`;
|
totalPill.textContent = `${data.online ?? data.total} online / ${data.total} total`;
|
||||||
const totalPages = Math.max(1, Math.ceil(data.total / data.pageSize));
|
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);
|
prevBtnState(data.page, totalPages);
|
||||||
|
|
||||||
const items = data.items || [];
|
const items = data.items || [];
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
const newClients = [];
|
const newClients = [];
|
||||||
const renderId = ++renderToken;
|
const renderId = ++renderToken;
|
||||||
|
const largeClientSet = (Number(data.total) || 0) >= LARGE_CLIENT_THRESHOLD;
|
||||||
|
document.documentElement.classList.toggle("large-client-set", largeClientSet);
|
||||||
const prefersReducedMotion =
|
const prefersReducedMotion =
|
||||||
typeof window !== "undefined" &&
|
typeof window !== "undefined" &&
|
||||||
window.matchMedia &&
|
window.matchMedia &&
|
||||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
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;
|
const uninstallingIds = window.__uninstallingClientIds;
|
||||||
|
|
||||||
items.forEach((client) => {
|
items.forEach((client) => {
|
||||||
seen.add(client.id);
|
seen.add(client.id);
|
||||||
if (uninstallingIds && uninstallingIds.has(client.id)) return;
|
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) {
|
||||||
if (existing.classList.contains("card-uninstalling")) return;
|
if (existing.classList.contains("card-uninstalling")) return;
|
||||||
const digest = cardDigest(client);
|
const digest = cardDigest(client);
|
||||||
@@ -527,7 +612,7 @@ export function createRenderer({
|
|||||||
newClients.push(client);
|
newClients.push(client);
|
||||||
});
|
});
|
||||||
|
|
||||||
Array.from(container.querySelectorAll(ROW_SELECTOR))
|
Array.from(existingCards)
|
||||||
.filter((el) => !seen.has(el.dataset.id) && !el.classList.contains("card-uninstalling"))
|
.filter((el) => !seen.has(el.dataset.id) && !el.classList.contains("card-uninstalling"))
|
||||||
.forEach((el) => el.remove());
|
.forEach((el) => el.remove());
|
||||||
|
|
||||||
@@ -537,7 +622,7 @@ export function createRenderer({
|
|||||||
|
|
||||||
if (newClients.length === 0) return;
|
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);
|
const animateLimit = Math.min(newClients.length, MAX_ANIMATED_CARDS);
|
||||||
|
|
||||||
let idx = 0;
|
let idx = 0;
|
||||||
@@ -674,17 +759,21 @@ export function createRenderer({
|
|||||||
const displayName = nickname || client.host || deviceId;
|
const displayName = nickname || client.host || deviceId;
|
||||||
const userLine = client.user || client.host || deviceId;
|
const userLine = client.user || client.host || deviceId;
|
||||||
const hasTagNote = customTag.length > 0 && customTagNote.length > 0;
|
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 verLatest = String(client.version || "").startsWith("2.0.");
|
||||||
|
|
||||||
const metaParts = [
|
const metaParts = [
|
||||||
`<span class="cv-os cv-tone-${os.tone}"><i class="fa ${os.icon}"></i> ${escapeHtml(shortOsLabel(client.os))}</span>`,
|
showField("system") ? `<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>`,
|
showField("system") ? `<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>`,
|
showField("version") ? `<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("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>` : "",
|
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>` : "",
|
showField("ip") && 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("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);
|
].filter(Boolean);
|
||||||
const meta = metaParts.join(metaSeparator());
|
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.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>` : ""}
|
${client.notificationsMuted ? `<span class="cv-mini-pill cv-pill-muted" title="Notifications muted"><i class="fa-solid fa-bell-slash"></i></span>` : ""}
|
||||||
</div>
|
</div>
|
||||||
<div class="cv-user-line"><i class="fa-solid fa-user"></i> ${escapeHtml(userLine)}</div>
|
${showField("user") ? `<div class="cv-user-line"><i class="fa-solid fa-user"></i> ${escapeHtml(userLine)}</div>` : ""}
|
||||||
<div class="cv-meta-line">${meta}</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>` : ""}
|
${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>` : ""}
|
${hasTagNote ? `<div class="client-tag-note hidden">${escapeHtml(customTagNote)}</div>` : ""}
|
||||||
</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-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>
|
<span class="cv-ping-line ${pingTone(client.pingMs)}"><i class="fa-solid fa-satellite-dish"></i> ${formatPing(client.pingMs)}</span>
|
||||||
</div>
|
</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-spacer"></div>
|
||||||
<div class="cv-actions">
|
<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>`}
|
${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-panel hidden">
|
||||||
<div class="cv-expand-grid">
|
<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">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>
|
${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>` : ""}
|
||||||
<div class="cv-field"><span class="cv-field-label">IP</span><span class="cv-field-value cv-mono">${escapeHtml(client.ip || "—")}</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>` : ""}
|
||||||
<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>
|
${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>` : ""}
|
||||||
<div class="cv-field"><span class="cv-field-label">CPU</span><span class="cv-field-value">${escapeHtml(client.cpu || "—")}</span></div>
|
${showHardware ? `<div class="cv-field"><span class="cv-field-label">CPU</span><span class="cv-field-value">${cpuHtml}</span></div>` : ""}
|
||||||
<div class="cv-field"><span class="cv-field-label">RAM</span><span class="cv-field-value">${escapeHtml(client.ram || "—")}</span></div>
|
${showHardware ? `<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>` : ""}
|
${batteryIndicator ? `<div class="cv-field"><span class="cv-field-label">Battery</span><span class="cv-field-value">${batteryIndicator}</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>` : ""}
|
${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>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -754,6 +844,11 @@ export function createRenderer({
|
|||||||
const nickname = String(client.nickname || "").trim();
|
const nickname = String(client.nickname || "").trim();
|
||||||
const displayName = nickname || client.host || deviceId;
|
const displayName = nickname || client.host || deviceId;
|
||||||
const userLine = client.user || 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 = `
|
tr.innerHTML = `
|
||||||
<td class="cv-td-check">
|
<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.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>` : ""}
|
${client.notificationsMuted ? `<span class="cv-mini-pill cv-pill-muted" title="Notifications muted"><i class="fa-solid fa-bell-slash"></i></span>` : ""}
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -787,11 +882,11 @@ export function createRenderer({
|
|||||||
<span class="cv-status-cell">${statusDot(client)} ${client.online ? "Online" : "Offline"}</span>
|
<span class="cv-status-cell">${statusDot(client)} ${client.online ? "Online" : "Offline"}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="cv-td-last cv-tab-num">${formatAgo(client.lastSeen)}</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>
|
<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-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">
|
<td class="cv-td-actions">
|
||||||
<div class="cv-actions cv-actions-table">
|
<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>`}
|
${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 verLatest = String(client.version || "").startsWith("2.0.");
|
||||||
const hasGroup = !!String(client.groupName || "").trim();
|
const hasGroup = !!String(client.groupName || "").trim();
|
||||||
const showHost = nickname && client.host && nickname !== client.host;
|
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 = [
|
const metaParts = [
|
||||||
`<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-${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>`,
|
showField("system") ? `<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>`,
|
showField("monitors") ? `<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("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("");
|
].filter(Boolean).join("");
|
||||||
|
|
||||||
const elevationBadges = [
|
const elevationBadges = [
|
||||||
@@ -863,19 +960,20 @@ export function createRenderer({
|
|||||||
<span class="cv-name">${escapeHtml(displayName)}</span>
|
<span class="cv-name">${escapeHtml(displayName)}</span>
|
||||||
${elevationBadges}
|
${elevationBadges}
|
||||||
</div>
|
</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>` : ""}
|
${customTag ? `<div class="cv-card-tag"><i class="fa-solid fa-tag"></i> ${escapeHtml(customTag)}</div>` : ""}
|
||||||
<div class="cv-card-net">
|
<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>` : ""}
|
${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>` : ""}
|
||||||
${hwidShort ? `<span class="cv-card-net-bit"><i class="fa-solid fa-fingerprint"></i> <span class="cv-mono">${escapeHtml(hwidShort)}</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>
|
||||||
<div class="cv-card-meta">
|
${metaParts || (showField("group") && hasGroup) ? `<div class="cv-card-meta">
|
||||||
${metaParts}
|
${metaParts}
|
||||||
${hasGroup ? `<span class="cv-group-spacer">${groupPillHtml(client)}</span>` : ""}
|
${showField("group") && hasGroup ? `<span class="cv-group-spacer">${groupPillHtml(client)}</span>` : ""}
|
||||||
</div>
|
</div>` : ""}
|
||||||
${cpuShort || client.ram ? `<div class="cv-card-hw">
|
${cpuHtml || (showHardware && client.ram) || batteryIndicator ? `<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>` : ""}
|
${cpuHtml}
|
||||||
${client.ram ? `<span class="cv-card-hw-bit"><i class="fa-solid fa-memory"></i> ${escapeHtml(client.ram)}</span>` : ""}
|
${showHardware && client.ram ? `<span class="cv-card-hw-bit"><i class="fa-solid fa-memory"></i> ${escapeHtml(client.ram)}</span>` : ""}
|
||||||
|
${batteryIndicator}
|
||||||
</div>` : ""}
|
</div>` : ""}
|
||||||
<div class="cv-card-actions">
|
<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>`}
|
${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,
|
os: c.os,
|
||||||
monitors: c.monitors,
|
monitors: c.monitors,
|
||||||
elevation: c.elevation,
|
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({
|
return JSON.stringify({
|
||||||
page,
|
page,
|
||||||
|
|||||||
@@ -37,20 +37,40 @@ export function osBadge(osRaw = "") {
|
|||||||
icon: "fa-linux",
|
icon: "fa-linux",
|
||||||
tone: "pill-unknown",
|
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"))
|
if (os.includes("windows"))
|
||||||
return { label: "Windows", icon: "fa-brands fa-windows", tone: "pill-win" };
|
return { label: "Windows", icon: "fa-brands fa-windows", tone: "pill-win" };
|
||||||
if (os.includes("mac") || os.includes("darwin"))
|
if (os.includes("mac") || os.includes("darwin"))
|
||||||
return { label: "macOS", icon: "fa-brands fa-apple", tone: "pill-mac" };
|
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"))
|
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"))
|
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"))
|
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"))
|
if (os.includes("kali"))
|
||||||
return { label: "Kali", icon: "fa-brands fa-linux", tone: "pill-kali" };
|
return { label: "Kali", icon: "fa-brands fa-linux", tone: "pill-kali" };
|
||||||
if (os.includes("fedora"))
|
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"))
|
if (os.includes("linux"))
|
||||||
return { label: "Linux", icon: "fa-brands fa-linux", tone: "pill-linux" };
|
return { label: "Linux", icon: "fa-brands fa-linux", tone: "pill-linux" };
|
||||||
return base;
|
return base;
|
||||||
|
|||||||
@@ -38,10 +38,10 @@
|
|||||||
>0 online</span
|
>0 online</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-3 items-center">
|
<div class="dashboard-toolbar">
|
||||||
<label
|
<label
|
||||||
id="search-shell"
|
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>
|
<i class="fa-solid fa-magnifying-glass text-slate-500"></i>
|
||||||
<input
|
<input
|
||||||
@@ -51,99 +51,71 @@
|
|||||||
placeholder="Search by nickname, host, user, id"
|
placeholder="Search by nickname, host, user, id"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label
|
<div class="dashboard-toolbar-actions">
|
||||||
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"
|
<label class="dashboard-toggle" title="Show offline clients">
|
||||||
>
|
<input id="toggle-offline" type="checkbox" checked />
|
||||||
<input
|
<i class="fa-solid fa-power-off"></i>
|
||||||
id="toggle-offline"
|
</label>
|
||||||
type="checkbox"
|
<details class="dashboard-menu">
|
||||||
class="h-4 w-4 rounded border-slate-600 bg-slate-800 text-blue-600 focus:ring-blue-600"
|
<summary title="Filters"><i class="fa-solid fa-filter"></i></summary>
|
||||||
checked
|
<div class="dashboard-menu-panel dashboard-filter-panel">
|
||||||
/>
|
<select id="filter-status">
|
||||||
<span class="text-sm text-slate-300">Show offline</span>
|
<option value="all">All Status</option>
|
||||||
</label>
|
<option value="online">Online Only</option>
|
||||||
<select
|
<option value="offline">Offline Only</option>
|
||||||
id="filter-status"
|
</select>
|
||||||
class="bg-slate-800/70 border border-slate-700/60 rounded-lg px-3 py-2 text-slate-300"
|
<select id="filter-os">
|
||||||
>
|
<option value="all">All OS</option>
|
||||||
<option value="all">All Status</option>
|
</select>
|
||||||
<option value="online">Online Only</option>
|
<select id="filter-group">
|
||||||
<option value="offline">Offline Only</option>
|
<option value="all">All Groups</option>
|
||||||
</select>
|
<option value="none">No Group</option>
|
||||||
<select
|
</select>
|
||||||
id="filter-os"
|
<div id="country-picker-anchor" class="relative"></div>
|
||||||
class="bg-slate-800/70 border border-slate-700/60 rounded-lg px-3 py-2 text-slate-300"
|
</div>
|
||||||
>
|
</details>
|
||||||
<option value="all">All OS</option>
|
<details class="dashboard-menu">
|
||||||
</select>
|
<summary title="Sort"><i class="fa-solid fa-arrow-down-wide-short"></i></summary>
|
||||||
<select
|
<div class="dashboard-menu-panel">
|
||||||
id="filter-group"
|
<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="all">All Groups</option>
|
<option value="host_asc">Host A-Z</option>
|
||||||
<option value="none">No Group</option>
|
<option value="ping_asc">Ping up</option>
|
||||||
</select>
|
<option value="ping_desc">Ping down</option>
|
||||||
<div id="country-picker-anchor" class="relative"></div>
|
<option value="country_asc">Country A-Z</option>
|
||||||
<select
|
<option value="country_desc">Country Z-A</option>
|
||||||
id="sort"
|
<option value="group_asc">Group A-Z</option>
|
||||||
class="bg-slate-800/70 border border-slate-700/60 rounded-lg px-3 py-2 text-slate-300"
|
<option value="group_desc">Group Z-A</option>
|
||||||
>
|
<option value="admin_first">Admin first</option>
|
||||||
<option value="stable">Default</option>
|
</select>
|
||||||
<option value="last_seen_desc">Newest</option>
|
</div>
|
||||||
<option value="host_asc">Host A→Z</option>
|
</details>
|
||||||
<option value="ping_asc">Ping ↑</option>
|
<details class="dashboard-menu">
|
||||||
<option value="ping_desc">Ping ↓</option>
|
<summary title="Visible fields"><i class="fa-solid fa-sliders"></i></summary>
|
||||||
<option value="country_asc">Country A→Z</option>
|
<div id="display-fields-menu" class="dashboard-menu-panel dashboard-fields-panel">
|
||||||
<option value="country_desc">Country Z→A</option>
|
<label><input type="checkbox" data-field="user" checked /> User</label>
|
||||||
<option value="group_asc">Group A→Z</option>
|
<label><input type="checkbox" data-field="ip" checked /> IP</label>
|
||||||
<option value="group_desc">Group Z→A</option>
|
<label><input type="checkbox" data-field="hwid" /> HWID</label>
|
||||||
<option value="admin_first">Admin first</option>
|
<label><input type="checkbox" data-field="system" checked /> System</label>
|
||||||
</select>
|
<label><input type="checkbox" data-field="version" checked /> Version</label>
|
||||||
<button
|
<label><input type="checkbox" data-field="monitors" checked /> Monitors</label>
|
||||||
id="select-all"
|
<label><input type="checkbox" data-field="group" checked /> Group</label>
|
||||||
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"
|
<label><input type="checkbox" data-field="hardware" checked /> Hardware</label>
|
||||||
>
|
<label><input type="checkbox" data-field="battery" checked /> Battery</label>
|
||||||
<i class="fa-solid fa-check"></i> Select all
|
</div>
|
||||||
</button>
|
</details>
|
||||||
<button
|
<button id="select-all" class="dashboard-icon-btn" title="Select all">
|
||||||
id="clear-selection"
|
<i class="fa-solid fa-check"></i>
|
||||||
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>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button id="clear-selection" class="dashboard-icon-btn" title="Clear selection">
|
||||||
type="button"
|
<i class="fa-solid fa-xmark"></i>
|
||||||
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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<!-- Bulk Actions Toolbar -->
|
<!-- Bulk Actions Toolbar -->
|
||||||
@@ -211,7 +183,14 @@
|
|||||||
>
|
>
|
||||||
Prev
|
Prev
|
||||||
</button>
|
</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
|
<button
|
||||||
id="next"
|
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"
|
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="icon" type="image/x-icon" href="/assets/favicon.ico" />
|
||||||
<link rel="stylesheet" href="/assets/tailwind.css" />
|
<link rel="stylesheet" href="/assets/tailwind.css" />
|
||||||
<link rel="stylesheet" href="/vendor/fontawesome/css/all.min.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/main.css" />
|
||||||
<link rel="stylesheet" href="/assets/custom.css" />
|
<link rel="stylesheet" href="/assets/custom.css" />
|
||||||
<script defer src="/assets/toast.js"></script>
|
<script defer src="/assets/toast.js"></script>
|
||||||
@@ -114,8 +113,35 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Clients Table -->
|
<!-- Clients Table -->
|
||||||
<div id="clients-table-wrap" class="bg-slate-900/50 border border-slate-800 rounded-xl overflow-hidden">
|
<div class="bg-slate-900/50 border border-slate-800 rounded-xl overflow-x-auto">
|
||||||
<div id="enrollment-table" class="overlord-tabulator"></div>
|
<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">
|
<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>
|
<i class="fa-solid fa-inbox text-3xl mb-3 block"></i>
|
||||||
<p>No clients in this category.</p>
|
<p>No clients in this category.</p>
|
||||||
@@ -157,7 +183,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-slate-900/50 border border-slate-800 rounded-xl overflow-hidden">
|
<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">
|
<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>
|
<i class="fa-solid fa-shield-halved text-3xl mb-3 block"></i>
|
||||||
<p>No banned IPs.</p>
|
<p>No banned IPs.</p>
|
||||||
|
|||||||
@@ -21,13 +21,112 @@ CREATE TABLE IF NOT EXISTS clients (
|
|||||||
country TEXT,
|
country TEXT,
|
||||||
last_seen INTEGER,
|
last_seen INTEGER,
|
||||||
online 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"]
|
ROLES = ["client", "viewer"]
|
||||||
OSES = ["windows", "linux", "darwin", "ubuntu", "debian", "arch", "kali", "fedora"]
|
|
||||||
ARCHES = ["amd64", "arm64", "x86", "arm"]
|
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 = [
|
COUNTRIES = [
|
||||||
"US",
|
"US",
|
||||||
"GB",
|
"GB",
|
||||||
@@ -73,10 +172,11 @@ def random_version() -> str:
|
|||||||
def random_row(now_ms: int, online_rate: float) -> Tuple:
|
def random_row(now_ms: int, online_rate: float) -> Tuple:
|
||||||
client_id = uuid.uuid4().hex
|
client_id = uuid.uuid4().hex
|
||||||
hwid = 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()
|
host = random_host()
|
||||||
os_name = random.choice(OSES)
|
profile = random.choice(OS_PROFILES)
|
||||||
arch = random.choice(ARCHES)
|
os_name = profile["os"]
|
||||||
|
arch = random.choice(profile.get("arches") or ARCHES)
|
||||||
version = random_version()
|
version = random_version()
|
||||||
user = random_user()
|
user = random_user()
|
||||||
monitors = random.randint(1, 3)
|
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)
|
last_seen = now_ms - random.randint(0, 7 * 24 * 60 * 60 * 1000)
|
||||||
online = 1 if random.random() < online_rate else 0
|
online = 1 if random.random() < online_rate else 0
|
||||||
ping_ms = random.choice([None, random.randint(10, 400), random.randint(400, 2000)])
|
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 (
|
return (
|
||||||
client_id,
|
client_id,
|
||||||
hwid,
|
hwid,
|
||||||
@@ -98,6 +212,16 @@ def random_row(now_ms: int, online_rate: float) -> Tuple:
|
|||||||
last_seen,
|
last_seen,
|
||||||
online,
|
online,
|
||||||
ping_ms,
|
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:
|
def ensure_schema(conn: sqlite3.Connection) -> None:
|
||||||
conn.execute(SCHEMA)
|
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:
|
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)
|
conn = sqlite3.connect(db_path)
|
||||||
try:
|
try:
|
||||||
ensure_schema(conn)
|
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.execute("DELETE FROM clients")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
sql = (
|
sql = (
|
||||||
"INSERT INTO clients (id, hwid, role, host, os, arch, version, user, monitors, country, last_seen, online, ping_ms) "
|
"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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||||
)
|
)
|
||||||
total = 0
|
total = 0
|
||||||
for batch in batched_rows(count, online_rate=online_rate):
|
for batch in batched_rows(count, online_rate=online_rate):
|
||||||
@@ -164,7 +309,7 @@ def main() -> None:
|
|||||||
"--count",
|
"--count",
|
||||||
type=int,
|
type=int,
|
||||||
default=100000,
|
default=100000,
|
||||||
help="How many rows to insert (default: 10000)",
|
help="How many rows to insert (default: 100000)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--truncate", action="store_true", help="Delete existing rows before seeding"
|
"--truncate", action="store_true", help="Delete existing rows before seeding"
|
||||||
|
|||||||
@@ -219,8 +219,8 @@ export function upsertClientRow(
|
|||||||
) {
|
) {
|
||||||
const now = partial.lastSeen ?? Date.now();
|
const now = partial.lastSeen ?? Date.now();
|
||||||
db.run(
|
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)
|
`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), ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?, 0), ?, ?)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
hwid=COALESCE(excluded.hwid, clients.hwid),
|
hwid=COALESCE(excluded.hwid, clients.hwid),
|
||||||
role=COALESCE(excluded.role, clients.role),
|
role=COALESCE(excluded.role, clients.role),
|
||||||
@@ -246,6 +246,8 @@ export function upsertClientRow(
|
|||||||
cpu=COALESCE(excluded.cpu, clients.cpu),
|
cpu=COALESCE(excluded.cpu, clients.cpu),
|
||||||
gpu=COALESCE(excluded.gpu, clients.gpu),
|
gpu=COALESCE(excluded.gpu, clients.gpu),
|
||||||
ram=COALESCE(excluded.ram, clients.ram),
|
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),
|
is_admin=COALESCE(excluded.is_admin, clients.is_admin),
|
||||||
elevation=COALESCE(excluded.elevation, clients.elevation),
|
elevation=COALESCE(excluded.elevation, clients.elevation),
|
||||||
permissions=COALESCE(excluded.permissions, clients.permissions)
|
permissions=COALESCE(excluded.permissions, clients.permissions)
|
||||||
@@ -275,6 +277,8 @@ export function upsertClientRow(
|
|||||||
partial.cpu ?? null,
|
partial.cpu ?? null,
|
||||||
partial.gpu ?? null,
|
partial.gpu ?? null,
|
||||||
partial.ram ?? 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.isAdmin !== undefined ? (partial.isAdmin ? 1 : 0) : null,
|
||||||
partial.elevation ?? null,
|
partial.elevation ?? null,
|
||||||
partial.permissions ? JSON.stringify(partial.permissions) : 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));
|
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_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> {
|
export function getFloodedHwids(threshold = SUSPICIOUS_FLOOD_THRESHOLD): Set<string> {
|
||||||
const rows = db
|
const rows = db
|
||||||
@@ -853,6 +858,12 @@ export function listClients(filters: ListFilters): ListResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
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 orderBy = (() => {
|
||||||
const online = "c.online DESC";
|
const online = "c.online DESC";
|
||||||
@@ -884,27 +895,48 @@ export function listClients(filters: ListFilters): ListResult {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
const totalRow = db
|
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 };
|
.get(...params) ?? { c: 0 };
|
||||||
const onlineRow = db
|
const onlineRow = db
|
||||||
.query<{ c: number }>(
|
.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 };
|
.get(...params) ?? { c: 0 };
|
||||||
const offset = (page - 1) * pageSize;
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
const rows = db
|
const clientFields =
|
||||||
.query<any>(
|
"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";
|
||||||
`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 { 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 thumbnailSummaries = getThumbnailSummaries(rows.map((row: any) => row.id));
|
||||||
|
|
||||||
const items = rows.map((c: any) => {
|
const items = rows.map((c: any) => {
|
||||||
@@ -936,6 +968,8 @@ export function listClients(filters: ListFilters): ListResult {
|
|||||||
cpu: c.cpu || null,
|
cpu: c.cpu || null,
|
||||||
gpu: c.gpu || null,
|
gpu: c.gpu || null,
|
||||||
ram: c.ram || 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,
|
isAdmin: c.isAdmin === 1,
|
||||||
elevation: c.elevation || null,
|
elevation: c.elevation || null,
|
||||||
permissions: c.permissions ? (() => { try { return JSON.parse(c.permissions); } catch { return null; } })() : null,
|
permissions: c.permissions ? (() => { try { return JSON.parse(c.permissions); } catch { return null; } })() : null,
|
||||||
@@ -948,7 +982,7 @@ export function listClients(filters: ListFilters): ListResult {
|
|||||||
denyReason: c.denyReason || null,
|
denyReason: c.denyReason || null,
|
||||||
hasThumbnail: thumbnail?.hasThumbnail ?? false,
|
hasThumbnail: thumbnail?.hasThumbnail ?? false,
|
||||||
thumbnailVersion: thumbnail?.thumbnailVersion ?? 0,
|
thumbnailVersion: thumbnail?.thumbnailVersion ?? 0,
|
||||||
suspiciousFlags: computeClientSuspiciousFlags(c, { floodedHwids, floodedHardware, floodedIps }),
|
suspiciousFlags: shouldScanSuspicious ? computeClientSuspiciousFlags(c, suspiciousSets) : [],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,12 @@ try {
|
|||||||
try {
|
try {
|
||||||
db.run(`ALTER TABLE clients ADD COLUMN ram TEXT`);
|
db.run(`ALTER TABLE clients ADD COLUMN ram TEXT`);
|
||||||
} catch {}
|
} 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 {
|
try {
|
||||||
db.run(`ALTER TABLE clients ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0`);
|
db.run(`ALTER TABLE clients ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0`);
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -94,6 +100,12 @@ db.run(
|
|||||||
db.run(
|
db.run(
|
||||||
`CREATE INDEX IF NOT EXISTS idx_clients_online_last_seen ON clients(online, last_seen DESC);`,
|
`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(
|
db.run(
|
||||||
`CREATE INDEX IF NOT EXISTS idx_clients_os_last_seen ON clients(os, last_seen DESC);`,
|
`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;
|
monitors: number;
|
||||||
monitorInfo?: { width: number; height: number }[];
|
monitorInfo?: { width: number; height: number }[];
|
||||||
country?: string;
|
country?: string;
|
||||||
|
batteryPercent?: number;
|
||||||
|
batteryCharging?: boolean;
|
||||||
publicKey?: string;
|
publicKey?: string;
|
||||||
signature?: string;
|
signature?: string;
|
||||||
publicIP?: string;
|
publicIP?: string;
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ function acceptsGzip(req: Request): boolean {
|
|||||||
return (req.headers.get("accept-encoding") ?? "").includes("gzip");
|
return (req.headers.get("accept-encoding") ?? "").includes("gzip");
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATIC_ASSET_CACHE = "public, max-age=86400, stale-while-revalidate=604800";
|
const STATIC_ASSET_CACHE = "public, max-age=31536000, immutable";
|
||||||
const MUTABLE_ASSET_CACHE = "public, max-age=3600, stale-while-revalidate=86400";
|
const MUTABLE_ASSET_CACHE = "public, max-age=86400, stale-while-revalidate=604800";
|
||||||
const NO_CACHE = "no-cache";
|
const NO_CACHE = "no-cache";
|
||||||
|
|
||||||
function assetCacheControl(relativePath: string): string {
|
function assetCacheControl(relativePath: string): string {
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export async function handleClientRoutes(
|
|||||||
return new Response("Unauthorized", { status: 401 });
|
return new Response("Unauthorized", { status: 401 });
|
||||||
}
|
}
|
||||||
const page = Math.max(1, Number(url.searchParams.get("page") || 1));
|
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 search = (url.searchParams.get("q") || "").toLowerCase().trim();
|
||||||
const sort = url.searchParams.get("sort") || "last_seen_desc";
|
const sort = url.searchParams.get("sort") || "last_seen_desc";
|
||||||
const statusFilter = url.searchParams.get("status") || "all";
|
const statusFilter = url.searchParams.get("status") || "all";
|
||||||
|
|||||||
@@ -456,6 +456,12 @@ export async function handleWebSocketMessage(
|
|||||||
if (typeof v !== "string") return undefined;
|
if (typeof v !== "string") return undefined;
|
||||||
return v.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "").slice(0, max);
|
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({
|
upsertClientRow({
|
||||||
id: resolvedId,
|
id: resolvedId,
|
||||||
hwid: _s((payload as any).hwid) || resolvedId,
|
hwid: _s((payload as any).hwid) || resolvedId,
|
||||||
@@ -470,6 +476,8 @@ export async function handleWebSocketMessage(
|
|||||||
cpu: _s((payload as any).cpu),
|
cpu: _s((payload as any).cpu),
|
||||||
gpu: _s((payload as any).gpu),
|
gpu: _s((payload as any).gpu),
|
||||||
ram: _s((payload as any).ram, 64),
|
ram: _s((payload as any).ram, 64),
|
||||||
|
batteryPercent,
|
||||||
|
batteryCharging: batteryPercent !== undefined ? !!(payload as any).batteryCharging : undefined,
|
||||||
country,
|
country,
|
||||||
lastSeen: Date.now(),
|
lastSeen: Date.now(),
|
||||||
online: 0 as any,
|
online: 0 as any,
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export type ClientInfo = {
|
|||||||
cpu?: string;
|
cpu?: string;
|
||||||
gpu?: string;
|
gpu?: string;
|
||||||
ram?: string;
|
ram?: string;
|
||||||
|
batteryPercent?: number | null;
|
||||||
|
batteryCharging?: boolean | null;
|
||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
elevation?: string;
|
elevation?: string;
|
||||||
permissions?: Record<string, boolean>;
|
permissions?: Record<string, boolean>;
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ function sanitizeMonitorCount(val: unknown): number | undefined {
|
|||||||
return n;
|
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(
|
function sanitizeJsonField(
|
||||||
val: unknown,
|
val: unknown,
|
||||||
opts: { maxJsonBytes: number; maxArrayLen?: number; maxKeys?: number },
|
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.cpu = sanitizeInfoString((payload as any).cpu) || info.cpu;
|
||||||
info.gpu = sanitizeInfoString((payload as any).gpu) || info.gpu;
|
info.gpu = sanitizeInfoString((payload as any).gpu) || info.gpu;
|
||||||
info.ram = sanitizeInfoString((payload as any).ram, 64) || info.ram;
|
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 geoip = await getGeoip();
|
||||||
const geo = info.ip ? geoip.lookup(info.ip) : undefined;
|
const geo = info.ip ? geoip.lookup(info.ip) : undefined;
|
||||||
const countryRaw =
|
const countryRaw =
|
||||||
@@ -127,6 +139,8 @@ export async function handleHello(
|
|||||||
cpu: info.cpu,
|
cpu: info.cpu,
|
||||||
gpu: info.gpu,
|
gpu: info.gpu,
|
||||||
ram: info.ram,
|
ram: info.ram,
|
||||||
|
batteryPercent: info.batteryPercent,
|
||||||
|
batteryCharging: info.batteryCharging,
|
||||||
isAdmin: info.isAdmin,
|
isAdmin: info.isAdmin,
|
||||||
elevation: info.elevation,
|
elevation: info.elevation,
|
||||||
permissions: info.permissions,
|
permissions: info.permissions,
|
||||||
|
|||||||
Reference in New Issue
Block a user