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

View File

@@ -1 +1 @@
8ea86a03ea86d45050d4e24e6432e022f76d805eeaaeeab3ee376ebaa48da52a ProxMenux-1.2.2.1-beta.AppImage
c6064b421cda7f2a9dd30e1f53091bdf7a49fc40c3833b3a27e3215341464284 ProxMenux-1.2.2.1-beta.AppImage

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">

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>

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 = {}