add graph view for people
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { buildClientGraph, clientIpSubnet } from "./client-graph";
|
||||
|
||||
describe("client graph", () => {
|
||||
test("builds client relationship nodes and edges", () => {
|
||||
const graph = buildClientGraph([
|
||||
{
|
||||
id: "client-a",
|
||||
host: "alpha",
|
||||
user: "alice",
|
||||
os: "windows",
|
||||
ip: "10.20.30.40",
|
||||
country: "US",
|
||||
online: true,
|
||||
lastSeen: Date.now(),
|
||||
groupId: 7,
|
||||
groupName: "Ops",
|
||||
groupColor: "#38bdf8",
|
||||
buildTag: "prod-build",
|
||||
} as any,
|
||||
{
|
||||
id: "client-b",
|
||||
host: "beta",
|
||||
user: "alice",
|
||||
os: "windows",
|
||||
ip: "10.20.30.55",
|
||||
country: "US",
|
||||
online: false,
|
||||
lastSeen: Date.now(),
|
||||
groupId: 7,
|
||||
groupName: "Ops",
|
||||
groupColor: "#38bdf8",
|
||||
buildTag: "prod-build",
|
||||
} as any,
|
||||
]);
|
||||
|
||||
const nodeIds = new Set(graph.nodes.map((node) => node.data.id));
|
||||
expect(nodeIds.has("client:client-a")).toBe(true);
|
||||
expect(nodeIds.has("client:client-b")).toBe(true);
|
||||
expect(nodeIds.has("group:7")).toBe(true);
|
||||
expect(nodeIds.has("build:prod-build")).toBe(true);
|
||||
expect(nodeIds.has("os:windows")).toBe(true);
|
||||
expect(nodeIds.has("subnet:10.20.30.0/24")).toBe(true);
|
||||
expect(nodeIds.has("user:alice")).toBe(true);
|
||||
expect(graph.summary.clients).toBe(2);
|
||||
expect(graph.summary.online).toBe(1);
|
||||
expect(graph.edges.some((edge) => edge.data.source === "client:client-a" && edge.data.target === "group:7")).toBe(true);
|
||||
});
|
||||
|
||||
test("normalizes IPv4 and IPv6 subnets", () => {
|
||||
expect(clientIpSubnet("192.168.4.33")).toBe("192.168.4.0/24");
|
||||
expect(clientIpSubnet("2001:db8:abcd:12::45")).toBe("2001:db8:abcd:12::/64");
|
||||
expect(clientIpSubnet("not-an-ip")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import type { ListItem } from "../types";
|
||||
|
||||
export type GraphNodeKind =
|
||||
| "client"
|
||||
| "group"
|
||||
| "build"
|
||||
| "os"
|
||||
| "country"
|
||||
| "subnet"
|
||||
| "user"
|
||||
| "status";
|
||||
|
||||
export type ClientGraphNode = {
|
||||
data: {
|
||||
id: string;
|
||||
label: string;
|
||||
type: GraphNodeKind;
|
||||
weight: number;
|
||||
count?: number;
|
||||
onlineCount?: number;
|
||||
clientId?: string;
|
||||
online?: boolean;
|
||||
href?: string;
|
||||
color?: string | null;
|
||||
meta?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
export type ClientGraphEdge = {
|
||||
data: {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
label: string;
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ClientGraph = {
|
||||
nodes: ClientGraphNode[];
|
||||
edges: ClientGraphEdge[];
|
||||
summary: {
|
||||
clients: number;
|
||||
totalClients?: number;
|
||||
online: number;
|
||||
categories: number;
|
||||
relationships: number;
|
||||
generatedAt: number;
|
||||
};
|
||||
};
|
||||
|
||||
function compactLabel(value: unknown, fallback = "Unknown"): string {
|
||||
const text = String(value ?? "").trim();
|
||||
if (!text) return fallback;
|
||||
return text.length > 48 ? `${text.slice(0, 45)}...` : text;
|
||||
}
|
||||
|
||||
function safeKey(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/[^a-z0-9_.:/-]+/g, "-").slice(0, 96) || "unknown";
|
||||
}
|
||||
|
||||
function ipv4Subnet(ip: string): string | null {
|
||||
const parts = ip.trim().split(".");
|
||||
if (parts.length !== 4) return null;
|
||||
const octets = parts.map((part) => Number(part));
|
||||
if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) return null;
|
||||
return `${octets[0]}.${octets[1]}.${octets[2]}.0/24`;
|
||||
}
|
||||
|
||||
function ipv6Subnet(ip: string): string | null {
|
||||
if (!ip.includes(":")) return null;
|
||||
const parts = ip.split(":").filter(Boolean);
|
||||
if (parts.length < 2) return null;
|
||||
return `${parts.slice(0, 4).join(":")}::/64`;
|
||||
}
|
||||
|
||||
export function clientIpSubnet(ip: unknown): string | null {
|
||||
const value = String(ip ?? "").trim();
|
||||
if (!value) return null;
|
||||
return ipv4Subnet(value) || ipv6Subnet(value);
|
||||
}
|
||||
|
||||
export function buildClientGraph(clients: ListItem[]): ClientGraph {
|
||||
const nodes = new Map<string, ClientGraphNode>();
|
||||
const edges = new Map<string, ClientGraphEdge>();
|
||||
|
||||
const addNode = (
|
||||
id: string,
|
||||
label: string,
|
||||
type: GraphNodeKind,
|
||||
data: Partial<ClientGraphNode["data"]> = {},
|
||||
) => {
|
||||
const existing = nodes.get(id);
|
||||
if (existing) {
|
||||
existing.data.count = (existing.data.count || 0) + 1;
|
||||
if (data.online) existing.data.onlineCount = (existing.data.onlineCount || 0) + 1;
|
||||
existing.data.weight = Math.max(existing.data.weight, data.weight || 1);
|
||||
return existing;
|
||||
}
|
||||
const node: ClientGraphNode = {
|
||||
data: {
|
||||
id,
|
||||
label,
|
||||
type,
|
||||
weight: data.weight || 1,
|
||||
count: data.count,
|
||||
onlineCount: data.online ? 1 : data.onlineCount,
|
||||
...data,
|
||||
},
|
||||
};
|
||||
nodes.set(id, node);
|
||||
return node;
|
||||
};
|
||||
|
||||
const addEdge = (source: string, target: string, label: string, type: string) => {
|
||||
const id = `${source}->${target}:${type}`;
|
||||
if (edges.has(id)) return;
|
||||
edges.set(id, { data: { id, source, target, label, type } });
|
||||
};
|
||||
|
||||
for (const client of clients) {
|
||||
const clientNodeId = `client:${client.id}`;
|
||||
const clientLabel = compactLabel(client.nickname || client.host || client.id);
|
||||
addNode(clientNodeId, clientLabel, "client", {
|
||||
weight: client.online ? 5 : 3,
|
||||
clientId: client.id,
|
||||
online: client.online,
|
||||
href: `/?q=${encodeURIComponent(client.id)}`,
|
||||
meta: {
|
||||
id: client.id,
|
||||
host: client.host || "",
|
||||
user: client.user || "",
|
||||
os: client.os || "",
|
||||
ip: client.ip || "",
|
||||
country: client.country || "",
|
||||
groupName: client.groupName || "",
|
||||
buildTag: client.buildTag || "",
|
||||
lastSeen: client.lastSeen,
|
||||
},
|
||||
});
|
||||
|
||||
const statusNode = client.online
|
||||
? { id: "status:online", label: "Online", color: "#22c55e" }
|
||||
: { id: "status:offline", label: "Offline", color: "#64748b" };
|
||||
addNode(statusNode.id, statusNode.label, "status", {
|
||||
weight: 4,
|
||||
online: client.online,
|
||||
color: statusNode.color,
|
||||
});
|
||||
addEdge(clientNodeId, statusNode.id, client.online ? "online" : "offline", "status");
|
||||
|
||||
if (client.groupName || client.groupId) {
|
||||
const groupId = `group:${client.groupId || safeKey(client.groupName || "ungrouped")}`;
|
||||
addNode(groupId, compactLabel(client.groupName || "Group"), "group", {
|
||||
weight: 4,
|
||||
online: client.online,
|
||||
color: client.groupColor || null,
|
||||
});
|
||||
addEdge(clientNodeId, groupId, "group", "group");
|
||||
}
|
||||
|
||||
if (client.buildTag) {
|
||||
const id = `build:${safeKey(client.buildTag)}`;
|
||||
addNode(id, compactLabel(client.buildTag), "build", { weight: 3, online: client.online });
|
||||
addEdge(clientNodeId, id, "build", "build");
|
||||
}
|
||||
|
||||
if (client.os) {
|
||||
const id = `os:${safeKey(client.os)}`;
|
||||
addNode(id, compactLabel(client.os), "os", { weight: 3, online: client.online });
|
||||
addEdge(clientNodeId, id, "os", "os");
|
||||
}
|
||||
|
||||
if (client.country) {
|
||||
const label = String(client.country).toUpperCase();
|
||||
const id = `country:${safeKey(label)}`;
|
||||
addNode(id, label, "country", { weight: 3, online: client.online });
|
||||
addEdge(clientNodeId, id, "country", "country");
|
||||
}
|
||||
|
||||
const subnet = clientIpSubnet(client.ip);
|
||||
if (subnet) {
|
||||
const id = `subnet:${safeKey(subnet)}`;
|
||||
addNode(id, subnet, "subnet", { weight: 3, online: client.online });
|
||||
addEdge(clientNodeId, id, "subnet", "subnet");
|
||||
}
|
||||
|
||||
if (client.user) {
|
||||
const id = `user:${safeKey(client.user)}`;
|
||||
addNode(id, compactLabel(client.user), "user", { weight: 2, online: client.online });
|
||||
addEdge(clientNodeId, id, "user", "user");
|
||||
}
|
||||
}
|
||||
|
||||
const sortedNodes = Array.from(nodes.values()).map((node) => {
|
||||
if (node.data.type !== "client") {
|
||||
node.data.weight = Math.max(node.data.weight, Math.min(12, 2 + (node.data.count || 1)));
|
||||
}
|
||||
return node;
|
||||
});
|
||||
|
||||
return {
|
||||
nodes: sortedNodes,
|
||||
edges: Array.from(edges.values()),
|
||||
summary: {
|
||||
clients: clients.length,
|
||||
online: clients.filter((client) => client.online).length,
|
||||
categories: sortedNodes.filter((node) => node.data.type !== "client").length,
|
||||
relationships: edges.size,
|
||||
generatedAt: Date.now(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -2,12 +2,13 @@ import { authenticateRequest } from "../../auth";
|
||||
import { AuditAction, getAuditLogs, logAudit } from "../../auditLog";
|
||||
import { logger } from "../../logger";
|
||||
import { getConfig, updateSecurityConfig, updateTlsConfig, updateAppearanceConfig, updateChatConfig, getExportableConfig, importFullConfig, updateRegistrationConfig, updateBuildRateLimitConfig, updateThumbnailsConfig } from "../../config";
|
||||
import { getClientMetricsSummary, getClientMetricsSummaryForUser, getDatabaseFileSizeBytes } from "../../db";
|
||||
import { getClientMetricsSummary, getClientMetricsSummaryForUser, getDatabaseFileSizeBytes, listClients } from "../../db";
|
||||
import { getThumbnailStats } from "../../thumbnails";
|
||||
import { getClientCount, getOnlineClients } from "../../clientManager";
|
||||
import { metrics } from "../../metrics";
|
||||
import { requirePermission } from "../../rbac";
|
||||
import { getUserTelegramChatId, setUserTelegramChatId, getUserClientAccessScope, listUserClientRuleIdsByAccess, canUserAccessClient, getUserById } from "../../users";
|
||||
import { buildClientGraph } from "../client-graph";
|
||||
import { runCertbotSetup } from "../certbot-setup";
|
||||
import {
|
||||
getActiveProxies,
|
||||
@@ -336,6 +337,48 @@ export async function handleMiscRoutes(
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/client-graph") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
|
||||
const limit = Math.max(25, Math.min(500, Number(url.searchParams.get("limit") || 300)));
|
||||
const search = (url.searchParams.get("q") || "").trim().toLowerCase();
|
||||
const statusFilter = url.searchParams.get("status") || "all";
|
||||
const groupFilter = url.searchParams.get("group") || "all";
|
||||
let allowedClientIds: string[] | undefined;
|
||||
let deniedClientIds: string[] | undefined;
|
||||
|
||||
if (user.role !== "admin") {
|
||||
const scope = getUserClientAccessScope(user.userId);
|
||||
if (scope === "none") {
|
||||
return Response.json(buildClientGraph([]), { headers: deps.CORS_HEADERS });
|
||||
}
|
||||
if (scope === "allowlist") {
|
||||
allowedClientIds = listUserClientRuleIdsByAccess(user.userId, "allow");
|
||||
} else if (scope === "denylist") {
|
||||
deniedClientIds = listUserClientRuleIdsByAccess(user.userId, "deny");
|
||||
}
|
||||
}
|
||||
|
||||
const clients = listClients({
|
||||
page: 1,
|
||||
pageSize: limit,
|
||||
search,
|
||||
sort: "last_seen_desc",
|
||||
statusFilter,
|
||||
osFilter: "all",
|
||||
countryFilter: "all",
|
||||
enrollmentFilter: "approved",
|
||||
groupFilter,
|
||||
allowedClientIds,
|
||||
deniedClientIds,
|
||||
});
|
||||
|
||||
const graph = buildClientGraph(clients.items);
|
||||
graph.summary.totalClients = clients.total;
|
||||
return Response.json(graph, { headers: deps.CORS_HEADERS });
|
||||
}
|
||||
|
||||
if (url.pathname === "/health") {
|
||||
return new Response("ok", { headers: deps.CORS_HEADERS });
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ type QueryClientPageDef = {
|
||||
/** Static pages: each entry maps a URL path to its HTML file + access rules. */
|
||||
const STATIC_PAGES: StaticPageDef[] = [
|
||||
{ path: "/metrics", file: "metrics.html", access: "any", checkPasswordChange: true },
|
||||
{ path: "/graph", file: "graph.html", access: "any", checkPasswordChange: true },
|
||||
{ path: "/screenshots", file: "screenshots.html", access: "no-viewer", checkPasswordChange: true },
|
||||
{ path: "/settings", file: "settings.html", access: "any", checkPasswordChange: true },
|
||||
{ path: "/logs", file: "logs.html", access: "any", checkPasswordChange: true, permission: "audit:view" },
|
||||
|
||||
Reference in New Issue
Block a user