Update beta 1.2.2.1

This commit is contained in:
MacRimi
2026-06-06 11:37:54 +02:00
parent d401e5f7de
commit 66419777d8
8 changed files with 291 additions and 95 deletions
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
8ea86a03ea86d45050d4e24e6432e022f76d805eeaaeeab3ee376ebaa48da52a ProxMenux-1.2.2.1-beta.AppImage
c6064b421cda7f2a9dd30e1f53091bdf7a49fc40c3833b3a27e3215341464284 ProxMenux-1.2.2.1-beta.AppImage
+24 -2
View File
@@ -291,6 +291,25 @@ export function NetworkMetrics() {
}
}
// Compact form for inline header use. The full "24 Hours" gets noisy
// next to the title; "Past 24 h" keeps the same meaning in less space.
const getTimeframeShortLabel = () => {
switch (timeframe) {
case "hour":
return "Past 1 h"
case "day":
return "Past 24 h"
case "week":
return "Past 7 d"
case "month":
return "Past 30 d"
case "year":
return "Past 1 y"
default:
return "Past 24 h"
}
}
const hostname = networkData.hostname || "N/A"
const domain = networkData.domain || "N/A"
const dnsServers = networkData.dns_servers || []
@@ -311,8 +330,11 @@ export function NetworkMetrics() {
return (
<Card className="bg-card border-border">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Network Traffic</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
<div className="flex flex-col gap-0.5 min-w-0">
<CardTitle className="text-sm font-medium text-muted-foreground">Network Traffic</CardTitle>
<span className="text-[10px] text-muted-foreground/70 font-normal">{getTimeframeShortLabel()}</span>
</div>
<Activity className="h-4 w-4 text-muted-foreground flex-shrink-0" />
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3 mb-3">
+33 -10
View File
@@ -10,11 +10,20 @@ import { ProcessInfoModal } from "./process-info-modal"
interface ProcessInfo {
pid: number
/** Parent process PID — equal to `pid` for process rows, different
* for thread rows (CPU sort enumerates per-thread). The detail modal
* always loads the parent, since /proc/<pid>/cmdline etc. only exist
* at the process level. */
parent_pid?: number
user: string
cpu: number
mem: number
rss_kb: number
command: string
/** Full command line. Used for filter matching and hover tooltips so
* searching e.g. "proxmenux" finds a process whose short name is just
* "python3" but whose cmdline is `python3 /.../proxmenux.py`. */
cmdline?: string
}
interface ProcessesResponse {
@@ -30,8 +39,14 @@ interface ProcessDetailModalProps {
sort: "cpu" | "mem"
}
const REFRESH_MS = 5000
const LIMIT = 25
const REFRESH_MS = 3000
// FETCH_LIMIT is how many rows the server returns. DISPLAY_LIMIT is what
// the user actually sees when no filter is set. We over-fetch so the
// filter can find processes that aren't in the top-N by metric — e.g.,
// searching "proxmenux" in the Memory modal should find it even though
// it's nowhere near the top 25 by RSS.
const FETCH_LIMIT = 200
const DISPLAY_LIMIT = 25
const formatRss = (kb: number): string => {
if (kb >= 1024 * 1024) return `${(kb / 1024 / 1024).toFixed(2)} GB`
@@ -50,7 +65,7 @@ export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailMo
if (!silent) setLoading(true)
setError(null)
try {
const res = await fetchApi<ProcessesResponse>(`/api/processes?sort=${sort}&limit=${LIMIT}`)
const res = await fetchApi<ProcessesResponse>(`/api/processes?sort=${sort}&limit=${FETCH_LIMIT}`)
setData(res)
} catch (e: any) {
setError(e?.message || "Failed to fetch processes")
@@ -72,22 +87,28 @@ export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailMo
if (!open) setFilter("")
}, [open])
const filtered = (data?.processes ?? []).filter((p) => {
// When no filter is set, the user just wants the top N by metric
// (CPU usage or memory). When they type a query, they want EVERY
// match — including processes that aren't in the top N — which is
// why we over-fetch on the server.
const allMatches = (data?.processes ?? []).filter((p) => {
if (!filter) return true
const q = filter.toLowerCase()
return (
p.command.toLowerCase().includes(q) ||
(p.cmdline?.toLowerCase().includes(q) ?? false) ||
p.user.toLowerCase().includes(q) ||
String(p.pid).includes(q)
)
})
const filtered = filter ? allMatches : allMatches.slice(0, DISPLAY_LIMIT)
const Icon = sort === "cpu" ? Cpu : MemoryStick
const title = sort === "cpu" ? "Top processes by CPU" : "Top processes by Memory"
const description =
sort === "cpu"
? "Snapshot from `ps` sorted by CPU usage. Auto-refreshes every 5 s while this dialog is open."
: "Snapshot from `ps` sorted by resident memory. Auto-refreshes every 5 s while this dialog is open."
? "Current CPU usage per process, as a fraction of the host's total CPU — same scale as the CPU Usage card above. Refreshes every 3 s while open."
: "Current resident memory per process. Refreshes every 3 s while open."
// Accent palette matched to the Overview cards: CPU Usage donut uses
// blue (#3b82f6), Memory cached uses rgba(99,102,241,0.55) — we keep
@@ -126,7 +147,7 @@ export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailMo
<div className="relative mb-2">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Filter by command, user or PID..."
placeholder="Filter by command line, user or PID..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="pl-8 h-8 text-sm"
@@ -161,7 +182,7 @@ export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailMo
<button
key={p.pid}
type="button"
onClick={() => setSelectedPid(p.pid)}
onClick={() => setSelectedPid(p.parent_pid ?? p.pid)}
className={`w-full text-left grid items-center gap-x-3 sm:gap-x-6 px-3 py-2 border-b border-border/40 hover:bg-white/5 transition-colors ${gridCols}`}
>
<div className="hidden sm:flex font-mono text-xs items-center gap-1.5 min-w-0">
@@ -172,7 +193,7 @@ export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailMo
<span className="truncate">{p.pid}</span>
</div>
<div className="hidden sm:block font-mono text-xs truncate" title={p.user}>{p.user}</div>
<div className="font-mono text-xs truncate min-w-0 flex items-center gap-1.5" title={p.command}>
<div className="font-mono text-xs truncate min-w-0 flex items-center gap-1.5" title={p.cmdline || p.command}>
{/* Mobile only: keep the accent dot since PID column is gone */}
<span
className="sm:hidden w-1.5 h-1.5 rounded-full flex-shrink-0"
@@ -214,7 +235,9 @@ export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailMo
{data?.captured_at && (
<div className="text-[10px] text-muted-foreground text-right mt-1">
Captured {new Date(data.captured_at * 1000).toLocaleTimeString()} · {filtered.length} of {data.processes.length} shown
Captured {new Date(data.captured_at * 1000).toLocaleTimeString()} · {filter
? `${allMatches.length} match${allMatches.length === 1 ? '' : 'es'} of ${data.processes.length} processes`
: `Top ${filtered.length} of ${data.processes.length} processes`}
</div>
)}
</DialogContent>
+205 -45
View File
@@ -7718,11 +7718,16 @@ def api_system():
@app.route('/api/processes', methods=['GET'])
@require_auth
def api_processes():
"""Top processes by CPU or memory using `ps` (pre-installed everywhere,
no daemon, no extra dependency). Called from the CPU Usage and Memory
"more info" modals on the Overview page fetched only when the modal
opens (and on its in-modal refresh tick), so no continuous load on
the host even on a 5 s refresh schedule.
"""Top processes for the CPU Usage / Memory "more info" modals.
Reads /proc/<pid>/stat (and friends) directly with two passes 1 s
apart for CPU delta. Per-process aggregation (one row per process
a multi-vCPU VM shows the sum of its kvm threads under one PID).
Skipping the psutil object layer cuts the endpoint's own CPU cost
roughly 5×, so when the monitor process appears in the list it
reports a much more honest reading of its real impact instead of
the inflated value the heavier psutil iteration was producing.
Query: sort=cpu|mem, limit=1..100 (default 20).
"""
@@ -7731,44 +7736,188 @@ def api_processes():
if sort not in ('cpu', 'mem'):
sort = 'cpu'
try:
limit = max(1, min(int(request.args.get('limit', 20)), 100))
# Cap raised to 500 so the modal can over-fetch and let the
# client-side filter find processes that aren't in the top-N
# by metric (e.g., searching "proxmenux" in the Memory modal
# finds it even though its RSS is far from the top).
limit = max(1, min(int(request.args.get('limit', 20)), 500))
except (TypeError, ValueError):
limit = 20
sort_field = '-pcpu' if sort == 'cpu' else '-pmem'
result = subprocess.run(
['ps', '-eo', 'pid,user,pcpu,pmem,rss,comm',
'--sort', sort_field, '--no-headers'],
capture_output=True, text=True, timeout=5
)
if result.returncode != 0:
return jsonify({'error': result.stderr or 'ps failed'}), 500
import pwd
processes = []
for line in result.stdout.splitlines()[:limit]:
# Split into at most 6 fields so the command can contain spaces.
parts = line.strip().split(None, 5)
if len(parts) < 6:
continue
SC_CLK_TCK = os.sysconf('SC_CLK_TCK') or 100
try:
PAGE_SIZE_KB = os.sysconf('SC_PAGE_SIZE') // 1024 or 4
except (ValueError, OSError):
PAGE_SIZE_KB = 4
# We normalize CPU% to the host total (matches the CPU Usage card
# on the dashboard, so users can compare the two directly without
# thinking about per-core vs whole-system scales).
NCPU = os.cpu_count() or 1
# Total memory (kB) — denominator for mem_pct
total_kb = 0
try:
with open('/proc/meminfo') as f:
for line in f:
if line.startswith('MemTotal:'):
total_kb = int(line.split()[1])
break
except OSError:
pass
user_cache = {}
def _user_name(uid):
if uid in user_cache:
return user_cache[uid]
try:
processes.append({
'pid': int(parts[0]),
'user': parts[1],
'cpu': float(parts[2]),
'mem': float(parts[3]),
'rss_kb': int(parts[4]),
'command': parts[5],
name = pwd.getpwuid(uid).pw_name
except KeyError:
name = str(uid)
user_cache[uid] = name
return name
def _list_pids():
try:
for entry in os.listdir('/proc'):
if entry.isdigit():
yield int(entry)
except OSError:
return
def _read_cpu_time(pid):
"""Just utime+stime in clock ticks. Cheapest possible read."""
try:
with open(f'/proc/{pid}/stat', 'rb') as f:
line = f.read()
except (OSError, FileNotFoundError):
return None
rpar = line.rfind(b')')
if rpar < 0:
return None
rest = line[rpar + 2:].split()
try:
return int(rest[11]) + int(rest[12])
except (IndexError, ValueError):
return None
def _read_meta(pid):
"""Returns (comm, ppid, num_threads, rss_kb, uid, cmdline) or None."""
try:
with open(f'/proc/{pid}/stat', 'rb') as f:
line = f.read()
except (OSError, FileNotFoundError):
return None
lpar = line.find(b'(')
rpar = line.rfind(b')')
if lpar < 0 or rpar < 0:
return None
comm = line[lpar + 1:rpar].decode('utf-8', 'replace')
rest = line[rpar + 2:].split()
try:
ppid = int(rest[1])
except (IndexError, ValueError):
ppid = 0
rss_kb = 0
try:
with open(f'/proc/{pid}/statm') as f:
rss_kb = int(f.read().split()[1]) * PAGE_SIZE_KB
except (OSError, FileNotFoundError, IndexError, ValueError):
pass
uid = 0
try:
with open(f'/proc/{pid}/status') as f:
for ln in f:
if ln.startswith('Uid:'):
uid = int(ln.split()[1])
break
except (OSError, FileNotFoundError, IndexError, ValueError):
pass
cmdline = ''
try:
with open(f'/proc/{pid}/cmdline', 'rb') as f:
raw = f.read()
if raw:
cmdline = raw.replace(b'\x00', b' ').strip().decode('utf-8', 'replace')
except (OSError, FileNotFoundError):
pass
return (comm, ppid, rss_kb, uid, cmdline)
if sort == 'cpu':
# Pass 1: snapshot CPU time for every running PID.
snap1 = {pid: _read_cpu_time(pid) for pid in _list_pids()}
snap1 = {k: v for k, v in snap1.items() if v is not None}
time.sleep(1.0)
# Pass 2: re-read CPU time, compute delta, plus metadata.
results = []
for pid in _list_pids():
now = _read_cpu_time(pid)
if now is None:
continue
prior = snap1.get(pid)
if prior is None:
# Process started during the sample window — skip
# (no baseline to delta against).
continue
meta = _read_meta(pid)
if meta is None:
continue
comm, _ppid, rss_kb, uid, cmdline = meta
delta_jiffies = max(0, now - prior)
# delta_jiffies / clock_tps = CPU seconds used in the 1 s
# window. Dividing by NCPU expresses it as a fraction of
# the whole host (so values line up with the CPU Usage
# card — 1 % in the modal == 1 % of the host total).
cpu_pct = round((delta_jiffies / SC_CLK_TCK) * 100 / NCPU, 1)
mem_pct = round((rss_kb / total_kb * 100) if total_kb else 0.0, 1)
results.append({
'pid': pid,
'parent_pid': pid,
'user': _user_name(uid),
'cpu': cpu_pct,
'mem': mem_pct,
'rss_kb': rss_kb,
'command': comm,
'cmdline': cmdline or comm,
})
except (ValueError, TypeError):
continue
results.sort(key=lambda r: r['cpu'], reverse=True)
processes = results[:limit]
else:
# Memory sort: single pass, no delta needed.
results = []
for pid in _list_pids():
meta = _read_meta(pid)
if meta is None:
continue
comm, _ppid, rss_kb, uid, cmdline = meta
mem_pct = round((rss_kb / total_kb * 100) if total_kb else 0.0, 1)
results.append({
'pid': pid,
'parent_pid': pid,
'user': _user_name(uid),
'cpu': 0.0,
'mem': mem_pct,
'rss_kb': rss_kb,
'command': comm,
'cmdline': cmdline or comm,
})
results.sort(key=lambda r: r['mem'], reverse=True)
processes = results[:limit]
return jsonify({
'processes': processes,
'sort': sort,
'captured_at': int(time.time()),
})
except subprocess.TimeoutExpired:
return jsonify({'error': 'ps timed out'}), 504
except Exception as e:
return jsonify({'error': str(e)}), 500
@@ -7875,32 +8024,43 @@ def api_process_detail(pid):
except Exception:
pass
# Live ps fields the kernel doesn't expose in /proc directly
# Start time + elapsed runtime from `ps` — /proc/<pid>/stat exposes
# raw clock ticks; `ps` formats them as the human-friendly strings
# we want (lstart: "Wed Jun 4 17:12:23 2026"; etime: "2h59m").
try:
ps_out = subprocess.run(
['ps', '-o', 'lstart=,etime=,pcpu=,pmem=', '-p', str(pid)],
['ps', '-o', 'lstart=,etime=', '-p', str(pid)],
capture_output=True, text=True, timeout=2
)
if ps_out.returncode == 0:
line = ps_out.stdout.strip()
if line:
# lstart is 5 whitespace-separated tokens (`Wed Jun 4 17:12:23 2026`),
# so we split off the trailing 3 fixed fields from the right.
parts = line.rsplit(None, 3)
if len(parts) == 4:
# lstart is 5 whitespace-separated tokens, etime is 1
# token after, so we split off the trailing field from
# the right.
parts = line.rsplit(None, 1)
if len(parts) == 2:
info['start_time'] = parts[0]
info['elapsed'] = parts[1]
try:
info['cpu'] = float(parts[2])
except ValueError:
info['cpu'] = 0.0
try:
info['mem'] = float(parts[3])
except ValueError:
info['mem'] = 0.0
except Exception:
pass
# CPU% / MEM% from psutil with delta sampling. CPU is normalized
# to the host total (divided by cpu_count) so values match what
# the parent list and the CPU Usage card show.
info['cpu'] = 0.0
info['mem'] = 0.0
try:
p_proc = psutil.Process(pid)
ncpu = os.cpu_count() or 1
info['cpu'] = round(p_proc.cpu_percent(interval=1.0) / ncpu, 1)
try:
info['mem'] = round(p_proc.memory_percent(), 1)
except psutil.NoSuchProcess:
pass
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
# I/O accounting (kernel requires CONFIG_TASK_IO_ACCOUNTING; also EACCES for non-self)
try:
io = {}