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:
co-authored by
Claude Opus 5
parent
03158b4edf
commit
b6fa5b177d
@@ -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(),
|
||||
}
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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(() => {});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 ── */
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user