initial commit

This commit is contained in:
Adrian Zürcher
2025-08-04 16:59:29 +02:00
commit 21b41de886
10 changed files with 470 additions and 0 deletions

15
go.mod Normal file
View File

@@ -0,0 +1,15 @@
module processSupervisor
go 1.24.5
require (
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/shirou/gopsutil/v3 v3.24.5 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/sys v0.20.0 // indirect
)

24
go.sum Normal file
View File

@@ -0,0 +1,24 @@
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

67
handlers/htopHandler.go Normal file
View File

@@ -0,0 +1,67 @@
package handlers
import (
"html/template"
"net/http"
"processSupervisor/models"
)
func HtopHandler(w http.ResponseWriter, r *http.Request) {
table, err := models.GetTable()
if err != nil {
http.Error(w, "Failed to get processes", 500)
return
}
table.Sort(r)
funcMap := template.FuncMap{
"div": divide,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// Detect HTMX request via the HX-Request header
if r.Header.Get("HX-Request") == "true" {
tmpl := template.Must(
template.New("table.html").Funcs(funcMap).ParseFiles("templates/partials/table.html"),
)
tmpl.Execute(w, table)
return
}
tmpl := template.Must(
template.New("htop.html").Funcs(funcMap).ParseFiles(
"templates/htop.html",
"templates/partials/table.html",
),
)
tmpl.Execute(w, table)
}
func divide(a any, b any) float64 {
af := toFloat64(a)
bf := toFloat64(b)
if bf == 0 {
return 0
}
return af / bf
}
func toFloat64(v any) float64 {
switch val := v.(type) {
case int:
return float64(val)
case int32:
return float64(val)
case int64:
return float64(val)
case uint64:
return float64(val)
case float32:
return float64(val)
case float64:
return val
default:
return 0
}
}

40
handlers/killHandler.go Normal file
View File

@@ -0,0 +1,40 @@
package handlers
import (
"html/template"
"net/http"
"os"
"processSupervisor/models"
"strconv"
)
func KillHandler(w http.ResponseWriter, r *http.Request) {
pidStr := r.URL.Query().Get("pid")
pid, err := strconv.Atoi(pidStr)
if err != nil {
http.Error(w, "Invalid PID", http.StatusBadRequest)
return
}
proc, err := os.FindProcess(pid)
if err != nil {
http.Error(w, "Process not found", http.StatusNotFound)
return
}
err = proc.Kill()
if err != nil {
http.Error(w, "Failed to kill process", http.StatusInternalServerError)
return
}
// Return updated table partial (same as HTMX request)
processes, err := models.GetProcesses()
if err != nil {
http.Error(w, "Failed to get processes", http.StatusInternalServerError)
return
}
tmpl := template.Must(template.ParseFiles("templates/partials/table.html"))
tmpl.Execute(w, processes)
}

18
main.go Normal file
View File

@@ -0,0 +1,18 @@
package main
import (
"log"
"net/http"
"processSupervisor/handlers"
)
func main() {
fs := http.FileServer(http.Dir("static"))
http.Handle("/static/", http.StripPrefix("/static/", fs))
http.HandleFunc("/taskmanager/htop", handlers.HtopHandler)
http.HandleFunc("/taskmanager/kill", handlers.KillHandler)
log.Println("Listening on http://localhost:8080/taskmanager/htop")
log.Fatal(http.ListenAndServe(":8080", nil))
}

17
models/htopTable.go Normal file
View File

@@ -0,0 +1,17 @@
package models
type HtopTable struct {
Processes []Process
CurrentSort string
CurrentOrder string
}
func GetTable() (*HtopTable, error) {
processes, err := GetProcesses()
if err != nil {
return &HtopTable{}, err
}
return &HtopTable{
Processes: processes,
}, nil
}

108
models/process.go Normal file
View File

@@ -0,0 +1,108 @@
package models
import (
"fmt"
"net/http"
"sort"
"strconv"
ps "github.com/shirou/gopsutil/v3/process"
)
type Process struct {
PID string
User string
Cmd string
CPU float64
Memory uint64
}
func GetProcesses() ([]Process, error) {
processes, err := ps.Processes()
if err != nil {
return nil, err
}
var result []Process
for _, p := range processes {
pid := p.Pid
name, err := p.Name()
if err != nil {
continue
}
user, err := p.Username()
if err != nil {
user = "unknown"
}
cpu, err := p.CPUPercent()
if err != nil {
cpu = 0
}
memInfo, err := p.MemoryInfo()
if err != nil {
continue
}
result = append(result, Process{
PID: fmt.Sprintf("%d", pid),
User: user,
Cmd: name,
CPU: cpu,
Memory: memInfo.RSS,
})
}
return result, nil
}
func (t *HtopTable) Sort(r *http.Request) {
sortBy := r.URL.Query().Get("sort")
order := r.URL.Query().Get("order")
if order == "desc" {
t.CurrentOrder = "desc"
} else {
t.CurrentOrder = "asc"
}
t.CurrentSort = sortBy
sort.Slice(t.Processes, func(i, j int) bool {
switch sortBy {
case "pid":
pidI, _ := strconv.Atoi(t.Processes[i].PID)
pidJ, _ := strconv.Atoi(t.Processes[j].PID)
if t.CurrentOrder == "desc" {
return pidI > pidJ
}
return pidI < pidJ
case "user":
if t.CurrentOrder == "desc" {
return t.Processes[i].User > t.Processes[j].User
}
return t.Processes[i].User < t.Processes[j].User
case "cmd":
if t.CurrentOrder == "desc" {
return t.Processes[i].Cmd > t.Processes[j].Cmd
}
return t.Processes[i].Cmd < t.Processes[j].Cmd
case "cpu":
if t.CurrentOrder == "desc" {
return t.Processes[i].CPU > t.Processes[j].CPU
}
return t.Processes[i].CPU < t.Processes[j].CPU
case "memory":
if t.CurrentOrder == "desc" {
return t.Processes[i].Memory > t.Processes[j].Memory
}
return t.Processes[i].Memory < t.Processes[j].Memory
default:
return true
}
})
}

1
static/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

110
templates/htop.html Normal file
View File

@@ -0,0 +1,110 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Task Manager</title>
<script src="/static/htmx.min.js"></script>
<style>
:root {
--bg-color: #121212;
--text-color: #e0e0e0;
--accent-color: #007bff;
--danger-color: #dc3545;
--table-bg: #1e1e1e;
--hover-bg: #2a2a2a;
--border-color: #333;
}
body {
font-family: Arial, sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
margin: 2rem;
}
.page-title {
text-align: center;
font-size: 2.8rem;
margin: 2rem 0 1rem 0;
font-weight: bold;
letter-spacing: 1px;
background: linear-gradient(to right, #00c6ff, #00e6aa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: 0 0 5px rgba(0,0,0,0.5);
}
.card {
background-color: #1e1e2f; /* dark card background */
border-radius: 12px;
padding: 20px;
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.5);
margin: 3rem auto; /* vertical margin + horizontal centering */
max-width: 900px; /* limits width */
color: #e0e0ff;
}
table {
width: 100%;
border-collapse: collapse;
background-color: var(--table-bg);
box-shadow: 0 0 15px rgba(0, 0, 0, 0.3);
margin-top: 1rem;
color: var(--text-color);
}
th, td {
padding: 10px 15px;
border-bottom: 1px solid var(--border-color);
text-align: left;
font-size: 14px;
}
th {
background-color: var(--accent-color);
color: white;
position: sticky;
top: 0;
z-index: 1;
}
tr:hover {
background-color: var(--hover-bg);
}
button {
padding: 5px 10px;
background-color: var(--danger-color);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: #c82333;
}
a {
color: #80dfff;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<h1 class="page-title">🧠 Task Manager</h1>
<div
id="htop-wrapper"
hx-get="/taskmanager/htop?sort={{.CurrentSort}}&order={{.CurrentOrder}}"
hx-trigger="load, every 1s"
hx-target="#process-table"
hx-swap="innerHTML">
<div id="process-table" class="card">
{{template "table.html" .}}
</div>
</body>
</html>

View File

@@ -0,0 +1,70 @@
<div id="process-table" class="card">
<table >
<thead>
<tr>
<th>
<a hx-get="/taskmanager/htop?sort=pid&order={{if and (eq .CurrentSort "pid") (eq .CurrentOrder "asc")}}desc{{else}}asc{{end}}"
hx-target="#process-table"
hx-swap="outerHTML">
PID {{if eq .CurrentSort "pid"}}{{if eq .CurrentOrder "asc"}}↑{{else}}↓{{end}}{{end}}
</a>
</th>
<th>
<a hx-get="/taskmanager/htop?sort=user&order={{if and (eq .CurrentSort "user") (eq .CurrentOrder "asc")}}desc{{else}}asc{{end}}"
hx-target="#process-table"
hx-swap="outerHTML">
User {{if eq .CurrentSort "user"}}{{if eq .CurrentOrder "asc"}}↑{{else}}↓{{end}}{{end}}
</a>
</th>
<th>
<a hx-get="/taskmanager/htop?sort=cmd&order={{if and (eq .CurrentSort "cmd") (eq .CurrentOrder "asc")}}desc{{else}}asc{{end}}"
hx-target="#process-table"
hx-swap="outerHTML">
Command {{if eq .CurrentSort "cmd"}}{{if eq .CurrentOrder "asc"}}↑{{else}}↓{{end}}{{end}}
</a>
</th>
<th>
<a hx-get="/taskmanager/htop?sort=cpu&order={{if and (eq .CurrentSort "cpu") (eq .CurrentOrder "asc")}}desc{{else}}asc{{end}}"
hx-target="#process-table"
hx-swap="outerHTML">
CPU %{{if eq .CurrentSort "cpu"}}{{if eq .CurrentOrder "asc"}}↑{{else}}↓{{end}}{{end}}
</a>
</th>
<th>
<a hx-get="/taskmanager/htop?sort=memory&order={{if and (eq .CurrentSort "memory") (eq .CurrentOrder "asc")}}desc{{else}}asc{{end}}"
hx-target="#process-table"
hx-swap="outerHTML">
Memory {{if eq .CurrentSort "memory"}}{{if eq .CurrentOrder "asc"}}↑{{else}}↓{{end}}{{end}}
</a>
</th>
<th>Kill</th>
</tr>
</thead>
<tbody>
{{range .Processes}}
<tr>
<td class="pid">{{.PID}}</td>
<td class="user">{{.User}}</td>
<td class="cmd">{{.Cmd}}</td>
<td class="cpu">{{printf "%.2f" .CPU}}</td>
<td class="mem">
{{if ge .Memory 1073741824}}
{{printf "%.2f GB" (div .Memory 1073741824)}}
{{else}}
{{printf "%.0f MB" (div .Memory 1048576)}}
{{end}}
</td>
<td class="kill">
<button
hx-get="/taskmanager/kill?pid={{.PID}}"
hx-target="#process-table"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to kill this process?">
Kill
</button>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>