mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-06-11 19:07:01 +00:00
Update beta 1.2.2.1
This commit is contained in:
Binary file not shown.
@@ -1 +1 @@
|
||||
8ea86a03ea86d45050d4e24e6432e022f76d805eeaaeeab3ee376ebaa48da52a ProxMenux-1.2.2.1-beta.AppImage
|
||||
c6064b421cda7f2a9dd30e1f53091bdf7a49fc40c3833b3a27e3215341464284 ProxMenux-1.2.2.1-beta.AppImage
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
Reference in New Issue
Block a user