feat: About panel reports the host's hardware and software

About showed only version/commit/branch. Add the facts you actually want to
check on a fresh rental: chip, memory, GPU (name, VRAM, compute capability),
free disk, OS, ComfyUI, Python, torch (+CUDA), and which fast-attention
backend is importable — the last one answers "is sage actually installed
here" without reading the launch args.

Everything describes the machine ComfyUI runs on, not the browser. On a
rental those differ, and the host is the one worth looking at, so the panel
says so in a line under the facts.

Every probe is individually guarded and degrades to a dash: About is the one
screen that should still render when the install is broken, so no missing
psutil, torch, or shell binary may raise. The frontend optional-chains the
call, so a frontend sharing settings_window.js without the endpoint keeps the
old version-only panel instead of erroring.

Facts move to a two-column grid; values now range from "3.14" to a full GPU
name, and only a shared column keeps the keys aligned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-08-11 23:15:30 +03:00
co-authored by Claude Opus 5
parent 03158b4edf
commit b6fa5b177d
6 changed files with 345 additions and 6 deletions
+182
View File
@@ -0,0 +1,182 @@
"""Host facts for the About panel — the machine ComfyUI runs on, not the browser's.
That distinction is the whole point on a rental: the frontend already knows the laptop it
is displayed on, and nobody cares. What matters is the box doing the sampling — which GPU,
how much VRAM, which torch, how much disk is left for outputs.
Everything here is best-effort and individually guarded: a missing probe returns None and
the panel renders a dash. Nothing in this module may raise, because the About panel is the
one screen that should still work when the rest of the install is broken.
"""
from __future__ import annotations
import os
import platform
import shutil
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
def _bytes_gb(n):
"""Bytes -> GB as a float, using the 1024 base the OS's own About panels use."""
try:
return round(float(n) / (1024 ** 3), 2)
except Exception:
return None
def _cpu_name():
"""A human CPU model string. platform.processor() is famously empty on Linux and
just 'arm' on Apple silicon, so ask the OS directly first."""
try:
if sys.platform == "darwin":
out = subprocess.run(["sysctl", "-n", "machdep.cpu.brand_string"],
capture_output=True, text=True, timeout=3)
if out.returncode == 0 and out.stdout.strip():
return out.stdout.strip()
elif sys.platform.startswith("linux"):
cpuinfo = Path("/proc/cpuinfo")
if cpuinfo.exists():
for line in cpuinfo.read_text(errors="replace").splitlines():
if line.lower().startswith("model name"):
return line.split(":", 1)[1].strip()
except Exception:
pass
return platform.processor() or platform.machine() or None
def _cpu():
info = {"name": _cpu_name(), "arch": platform.machine() or None,
"cores": None, "threads": os.cpu_count()}
try:
import psutil # a ComfyUI requirement, so normally present
info["cores"] = psutil.cpu_count(logical=False)
info["threads"] = psutil.cpu_count(logical=True) or info["threads"]
except Exception:
pass
return info
def _memory():
try:
import psutil
vm = psutil.virtual_memory()
return {"total_gb": _bytes_gb(vm.total), "available_gb": _bytes_gb(vm.available)}
except Exception:
pass
try: # stdlib fallback (Linux/macOS)
total = os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES")
return {"total_gb": _bytes_gb(total), "available_gb": None}
except Exception:
return {"total_gb": None, "available_gb": None}
def _gpus():
"""Every visible CUDA/ROCm device. Empty list on a CPU-only box (the local Mac)."""
out = []
try:
import torch
if not torch.cuda.is_available():
return out
for i in range(torch.cuda.device_count()):
entry = {"name": None, "vram_gb": None, "capability": None}
try:
entry["name"] = torch.cuda.get_device_name(i)
except Exception:
pass
try:
props = torch.cuda.get_device_properties(i)
entry["vram_gb"] = _bytes_gb(props.total_memory)
entry["capability"] = f"sm_{props.major}{props.minor}"
except Exception:
pass
out.append(entry)
except Exception:
pass
return out
def _mps():
"""Apple-silicon GPU, which reports through MPS rather than CUDA."""
try:
import torch
if getattr(torch.backends, "mps", None) is not None and torch.backends.mps.is_available():
return True
except Exception:
pass
return False
def _disk():
"""Free space where FunPack lives — the volume that fills up with renders."""
try:
usage = shutil.disk_usage(str(REPO_ROOT))
return {"total_gb": _bytes_gb(usage.total), "free_gb": _bytes_gb(usage.free)}
except Exception:
return {"total_gb": None, "free_gb": None}
def _os_name():
try:
if sys.platform == "darwin":
return f"macOS {platform.mac_ver()[0]}".strip()
if sys.platform.startswith("win"):
return f"Windows {platform.release()}"
# Prefer the distro's own pretty name over the kernel version.
osr = Path("/etc/os-release")
if osr.exists():
for line in osr.read_text(errors="replace").splitlines():
if line.startswith("PRETTY_NAME="):
return line.split("=", 1)[1].strip().strip('"')
return f"{platform.system()} {platform.release()}"
except Exception:
return None
def _torch():
info = {"version": None, "cuda": None, "attention": None}
try:
import torch
info["version"] = torch.__version__
info["cuda"] = getattr(torch.version, "cuda", None)
except Exception:
pass
# Which fast-attention backend is actually importable. This is the line that answers
# "is sage actually installed on this rental?" without digging through the launch args.
for mod, label in (("sageattention", "SageAttention"), ("flash_attn", "FlashAttention"),
("xformers", "xformers")):
try:
m = __import__(mod)
info["attention"] = f"{label} {getattr(m, '__version__', '')}".strip()
break
except Exception:
continue
return info
def _comfy_version():
try:
import comfyui_version
return comfyui_version.__version__
except Exception:
return None
def collect():
"""The whole About payload. Static enough that the frontend caches it per load."""
gpus = _gpus()
return {
"host": platform.node() or None,
"os": _os_name(),
"cpu": _cpu(),
"memory": _memory(),
"gpus": gpus,
"mps": _mps() if not gpus else False,
"disk": _disk(),
"python": platform.python_version(),
"torch": _torch(),
"comfyui": _comfy_version(),
}
+1
View File
@@ -135,6 +135,7 @@
applyWorkflow: (pid, workflow, bindings) =>
j("POST", API(`/projects/${pid}/workflow/apply`), { workflow, bindings }),
restart: () => j("POST", API("/restart")),
systemInfo: () => j("GET", API("/system/info")),
gitStatus: () => j("GET", API("/git/status")),
gitUpdate: (branch) => j("POST", API("/git/update"), branch ? { branch } : {}),
gitCheckout: (branch) => j("POST", API("/git/checkout"), { branch }),
+69 -1
View File
@@ -184,7 +184,8 @@
register({
id: "about", group: "", order: 0, title: "About FunPack",
subtitle: "",
keywords: "about version commit branch copyright info funpack cutting room",
keywords: "about version commit branch copyright info funpack cutting room "
+ "cpu chip memory ram gpu graphics vram disk storage system os python torch cuda specs hardware",
iconBg: "linear-gradient(180deg,#ffc36b,#e0891f)",
icon: '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="#fff" stroke-width="1.5"><circle cx="8" cy="8" r="6.2"/><circle cx="8" cy="8" r="2.4" fill="#fff" stroke="none"/></svg>',
mount(body, ctx) {
@@ -192,6 +193,54 @@
const wrap = el("div", "sw-about");
body.append(wrap);
// Host facts describe the machine ComfyUI runs on, which on a rental is NOT the
// machine showing this window. Fetched once per mount; nothing here changes while
// the window is open. Null until it arrives (or forever, if the backend is older).
let sys = null;
const gb = (n) => (n == null ? null : `${n} GB`);
function hardwareFacts(fact) {
if (!sys) return;
const cpu = sys.cpu || {};
const cores = cpu.cores && cpu.threads && cpu.cores !== cpu.threads
? `${cpu.cores}C/${cpu.threads}T`
: (cpu.threads ? `${cpu.threads}C` : null);
fact("Chip", [cpu.name, cores].filter(Boolean).join(" · ") || cpu.arch);
const mem = sys.memory || {};
fact("Memory", mem.available_gb != null && mem.total_gb != null
? `${mem.available_gb} GB free of ${mem.total_gb} GB`
: gb(mem.total_gb));
const gpus = sys.gpus || [];
if (gpus.length) {
gpus.forEach((g, i) => {
const label = gpus.length > 1 ? `Graphics ${i}` : "Graphics";
fact(label, [g.name, gb(g.vram_gb), g.capability].filter(Boolean).join(" · "));
});
} else {
// No CUDA device: say which kind of nothing, since "—" reads like a bug.
fact("Graphics", sys.mps ? "Apple GPU (MPS)" : "CPU only (no CUDA device)");
}
const disk = sys.disk || {};
fact("Storage", disk.free_gb != null && disk.total_gb != null
? `${disk.free_gb} GB available of ${disk.total_gb} GB`
: gb(disk.total_gb));
}
function softwareFacts(fact) {
if (!sys) return;
fact("System", sys.os);
fact("ComfyUI", sys.comfyui);
fact("Python", sys.python);
const t = sys.torch || {};
fact("Torch", [t.version, t.cuda ? `CUDA ${t.cuda}` : null].filter(Boolean).join(" · "));
if (t.attention) fact("Attention", t.attention);
if (sys.host) fact("Host", sys.host);
}
function render() {
clear(wrap);
const git = G ? G.get() : null;
@@ -208,11 +257,25 @@
r.append(el("span", "sw-about-k", k), el("span", "sw-about-v", v || "—"));
facts.append(r);
};
const group = (label) => facts.append(el("div", "sw-about-group", label));
fact("Version", git?.version);
fact("Commit", git?.ok ? git.commit + (git.dirty ? " (local changes)" : "") : null);
fact("Branch", git?.ok ? git.branch : null);
if (sys) {
group("Hardware");
hardwareFacts(fact);
group("Software");
softwareFacts(fact);
}
wrap.append(facts);
if (sys) {
wrap.append(el("div", "sw-about-hint",
"The machine ComfyUI runs on — not this browser."));
}
const upd = el("button", "btn ghost tiny", "Software Update…");
upd.onclick = () => ctx.openSection("system");
wrap.append(upd);
@@ -222,6 +285,11 @@
render();
if (G?.refresh) G.refresh().then(() => { if (wrap.isConnected) render(); }).catch(() => {});
// Optional-chained: a frontend sharing this file without the endpoint (or an older
// backend) simply keeps the version-only About instead of erroring.
window.MovieEditorAPI?.systemInfo?.()
.then((info) => { sys = info; if (wrap.isConnected) render(); })
.catch(() => {});
},
});
+15 -4
View File
@@ -1724,10 +1724,21 @@ body.tour-mode { overflow: hidden; }
text-shadow: 0 0 24px rgba(243,169,60,.45); margin-bottom: 10px; }
.sw-about-name { font-family: var(--font-display); font-weight: 700; font-size: 28px; letter-spacing: -.02em; }
.sw-about-sub { font-size: 13px; color: var(--muted); margin-bottom: 18px; }
.sw-about-facts { display: flex; flex-direction: column; gap: 4px; margin-bottom: 18px; }
.sw-about-fact { display: flex; gap: 14px; justify-content: center; font-size: 12.5px; }
.sw-about-k { color: var(--faint); min-width: 70px; text-align: right; }
.sw-about-v { color: var(--text); font-family: var(--font-mono); text-align: left; min-width: 70px; }
/* Two-column grid rather than centred rows: values now run from short ("3.14") to long
("NVIDIA RTX PRO 6000 Blackwell · 95.5 GB · sm_120"), and only a shared column keeps
the keys aligned. `display: contents` lets each fact's two spans land in that grid. */
.sw-about-facts { display: inline-grid; grid-template-columns: auto auto; gap: 4px 14px;
align-items: baseline; margin-bottom: 14px; font-size: 12.5px; }
.sw-about-fact { display: contents; }
.sw-about-k { color: var(--faint); text-align: right; white-space: nowrap; }
.sw-about-v { color: var(--text); font-family: var(--font-mono); text-align: left;
max-width: 320px; overflow-wrap: anywhere; }
.sw-about-group { grid-column: 1 / -1; margin-top: 12px; padding-bottom: 3px;
border-bottom: 1px solid var(--line, rgba(255,255,255,.08));
font-size: 10.5px; letter-spacing: .08em; text-transform: uppercase;
color: var(--faint); text-align: left; }
.sw-about-hint { max-width: 340px; margin: 0 0 16px; font-size: 11px; color: var(--faint);
line-height: 1.45; text-wrap: balance; }
.sw-about-copy { margin-top: 22px; font-size: 11px; color: var(--faint); }
/* ── Models & Pipeline: inner sidebar layout inside the Settings window ── */
+7 -1
View File
@@ -19,7 +19,7 @@ except Exception: # pragma: no cover - only available inside ComfyUI
web = None
PromptServer = None
from .backend import bridge, builder, config, git_update, media, nodes, pipeline_caps, pipeline_deps, pipeline_wiring, projects, workflow_import
from .backend import bridge, builder, config, git_update, media, nodes, pipeline_caps, pipeline_deps, pipeline_wiring, projects, sysinfo, workflow_import
from .backend.nle_effects import zoompan_z_expr
from .backend.nle_overlays import build_overlay_video_filter, prepare_overlay_export
from .backend.timeline import (
@@ -2427,6 +2427,12 @@ if web is not None and PromptServer is not None:
raise web.HTTPNotFound(reason="Unknown node class")
return web.json_response(spec)
@routes.get(UI_PREFIX + "/api/system/info")
async def _system_info(_req):
# Describes the ComfyUI host, not the browser — on a rental those are different
# machines and the host is the one worth looking at.
return web.json_response(sysinfo.collect())
@routes.get(UI_PREFIX + "/api/git/status")
async def _git_status(_req):
return web.json_response(git_update.status())
+71
View File
@@ -0,0 +1,71 @@
"""Host facts for the About panel.
The contract that matters is robustness, not accuracy: About is the one screen that should
still render on a half-broken install, so every probe must degrade to None rather than
raise. These tests therefore break the probes on purpose and assert the shape survives.
"""
import builtins
import pytest
from movie_editor.backend import sysinfo
def test_collect_returns_the_full_shape():
info = sysinfo.collect()
for key in ("host", "os", "cpu", "memory", "gpus", "disk", "python", "torch", "comfyui"):
assert key in info, f"About reads {key!r} directly"
assert isinstance(info["gpus"], list)
assert isinstance(info["cpu"], dict)
# python is the one fact that can always be determined.
assert info["python"]
def test_collect_survives_every_import_failing(monkeypatch):
"""psutil and torch are both optional from this module's point of view."""
real_import = builtins.__import__
def _boom(name, *args, **kwargs):
if name in {"psutil", "torch", "comfyui_version"} or name.startswith("torch."):
raise ImportError(f"no {name}")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", _boom)
info = sysinfo.collect()
assert info["gpus"] == []
assert info["mps"] is False
assert info["torch"]["version"] is None
assert info["comfyui"] is None
assert info["python"], "python version must survive a torch-less install"
def test_collect_survives_a_failing_subprocess(monkeypatch):
"""_cpu_name shells out on macOS/Linux; a missing binary must not kill the panel."""
def _boom(*args, **kwargs):
raise OSError("no such binary")
monkeypatch.setattr(sysinfo.subprocess, "run", _boom)
assert "cpu" in sysinfo.collect()
def test_collect_survives_an_unreadable_disk(monkeypatch):
monkeypatch.setattr(sysinfo.shutil, "disk_usage", lambda _p: (_ for _ in ()).throw(OSError))
assert sysinfo.collect()["disk"] == {"total_gb": None, "free_gb": None}
@pytest.mark.parametrize("value,expected", [
(1024 ** 3, 1.0),
(0, 0.0),
(None, None),
("nonsense", None),
])
def test_bytes_gb(value, expected):
assert sysinfo._bytes_gb(value) == expected
def test_mps_is_not_reported_when_a_cuda_gpu_exists(monkeypatch):
"""`mps` exists to explain an EMPTY graphics row; with CUDA present it must stay off
so the panel never claims both."""
monkeypatch.setattr(sysinfo, "_gpus", lambda: [{"name": "Fake", "vram_gb": 8.0, "capability": "sm_120"}])
monkeypatch.setattr(sysinfo, "_mps", lambda: True)
assert sysinfo.collect()["mps"] is False