feat(editor): check custom node packs for updates
Fetches each pack's origin four at a time and reports behind/ahead on the row. Anything uncomparable says why rather than reading as up to date.
This commit is contained in:
@@ -12,6 +12,11 @@
|
||||
the name must be a single path segment that resolves to a direct child directory, so a
|
||||
symlink cannot redirect it, and FunPack cannot delete itself. Node packs register at
|
||||
import, so the panel says a restart is needed rather than restarting under you.
|
||||
**Check for updates** fetches each pack's origin and shows how far behind it is, on the
|
||||
row and on its Update button. It is a button rather than part of the listing because it
|
||||
costs a network round trip per pack; the fetches run four at a time, and a pack that
|
||||
cannot be compared says why (detached HEAD, no remote, origin unreachable) instead of
|
||||
quietly reading as up to date.
|
||||
|
||||
- **FunPack's loaders take `.gguf` files**, for diffusion models and text encoders alike, and
|
||||
a text-encoder list may mix a `.gguf` with `.safetensors` slots (the usual LTX-2.3 shape).
|
||||
|
||||
@@ -205,3 +205,66 @@ def remove(name: str) -> dict:
|
||||
path = node_dir(name)
|
||||
shutil.rmtree(path)
|
||||
return {"name": name, "removed": True, "path": str(path)}
|
||||
|
||||
|
||||
# ── checking for updates ──────────────────────────────────────────────────────
|
||||
|
||||
def _behind_ahead(path: Path) -> dict:
|
||||
"""How far one pack is from its upstream, after fetching it.
|
||||
|
||||
Costs a network round trip per pack, which is why it is a button rather than part of
|
||||
the listing: on a tunnelled instance with a dozen packs, doing this on open would make
|
||||
the panel feel broken.
|
||||
"""
|
||||
if not (path / ".git").exists():
|
||||
return {"checked": False, "reason": "not a git checkout"}
|
||||
br = _git(path, "rev-parse", "--abbrev-ref", "HEAD", timeout=30)
|
||||
branch = (br.stdout or "").strip()
|
||||
if br.returncode != 0 or not branch or branch == "HEAD":
|
||||
return {"checked": False, "reason": "not on a branch (detached HEAD)"}
|
||||
remote = _git(path, "config", "--get", "remote.origin.url", timeout=30)
|
||||
if remote.returncode != 0 or not (remote.stdout or "").strip():
|
||||
return {"checked": False, "reason": "no origin remote"}
|
||||
fetched = _git(path, "fetch", "--quiet", "origin", branch, timeout=180)
|
||||
if fetched.returncode != 0:
|
||||
return {"checked": False,
|
||||
"reason": (fetched.stderr or "could not reach origin").strip()[-200:]}
|
||||
counts = _git(path, "rev-list", "--left-right", "--count",
|
||||
f"HEAD...origin/{branch}", timeout=60)
|
||||
if counts.returncode != 0:
|
||||
return {"checked": False, "reason": "no upstream branch to compare against"}
|
||||
parts = (counts.stdout or "").split()
|
||||
if len(parts) != 2:
|
||||
return {"checked": False, "reason": "could not compare with origin"}
|
||||
ahead, behind = int(parts[0]), int(parts[1])
|
||||
return {"checked": True, "branch": branch, "ahead": ahead, "behind": behind}
|
||||
|
||||
|
||||
def check_updates() -> dict:
|
||||
"""Fetch every git pack and report how far behind each one is.
|
||||
|
||||
Fetches run concurrently and bounded: they are network-bound, so serial would be as slow
|
||||
as the sum of them, and unbounded would open a connection per pack at once.
|
||||
"""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
packs = [n for n in list_nodes()["nodes"]]
|
||||
targets = [p for p in packs if p.get("git")]
|
||||
results: dict[str, dict] = {}
|
||||
if targets:
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
futures = {}
|
||||
for p in targets:
|
||||
try:
|
||||
futures[pool.submit(_behind_ahead, node_dir(p["name"]))] = p["name"]
|
||||
except CustomNodeError as e:
|
||||
results[p["name"]] = {"checked": False, "reason": str(e)}
|
||||
for fut, name in futures.items():
|
||||
try:
|
||||
results[name] = fut.result()
|
||||
except Exception as e: # noqa: BLE001 — one bad pack must not sink the sweep
|
||||
results[name] = {"checked": False, "reason": str(e)[-200:]}
|
||||
for p in packs:
|
||||
if p["name"] not in results:
|
||||
results[p["name"]] = {"checked": False, "reason": "not a git checkout"}
|
||||
return {"checked": results}
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
clearFiles: (group) => j("POST", API(`/files/${encodeURIComponent(group)}/clear`), {}),
|
||||
nleLibrary: () => j("GET", API("/library/nle")),
|
||||
customNodes: () => j("GET", API("/custom-nodes")),
|
||||
customNodesCheck: () => j("POST", API("/custom-nodes/check"), {}),
|
||||
customNodeInstall: (url) => j("POST", API("/custom-nodes/install"), { url }),
|
||||
customNodeUpdate: (name) => j("POST", API("/custom-nodes/update"), { name }),
|
||||
customNodeRemove: (name) => j("POST", API("/custom-nodes/remove"), { name }),
|
||||
|
||||
@@ -16,6 +16,24 @@
|
||||
let busy = ""; // name (or "__install__") of whatever is running
|
||||
let dirty = false; // something changed → a restart is needed
|
||||
let error = "";
|
||||
// name -> {checked, behind, ahead, reason}. Empty until the user asks: each entry costs a
|
||||
// network round trip, and doing that on open would make the panel feel broken.
|
||||
let checked = {};
|
||||
let checking = false;
|
||||
|
||||
async function checkUpdates() {
|
||||
if (checking || busy) return;
|
||||
checking = true;
|
||||
render();
|
||||
try {
|
||||
checked = (await API.customNodesCheck()).checked || {};
|
||||
} catch (e) {
|
||||
alert("Could not check for updates:\n\n" + (e?.message || e));
|
||||
} finally {
|
||||
checking = false;
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
@@ -47,6 +65,9 @@
|
||||
const res = await fn();
|
||||
reportRequirements(res, what);
|
||||
dirty = true;
|
||||
// Whatever this was, its behind-count is now stale. Dropping it beats showing a
|
||||
// number that was true a moment ago.
|
||||
if (key !== "__install__") delete checked[key];
|
||||
} catch (e) {
|
||||
alert(`${what} failed:\n\n${e?.message || e}`);
|
||||
} finally {
|
||||
@@ -107,12 +128,23 @@
|
||||
if (n.is_funpack) bits.push("FunPack itself");
|
||||
if (n.git) bits.push(`${n.branch || "?"} · ${n.commit || "?"}`);
|
||||
else bits.push("not a git checkout");
|
||||
const chk = checked[n.name];
|
||||
if (chk && chk.checked) {
|
||||
if (chk.behind > 0) bits.push(`${chk.behind} behind`);
|
||||
else if (chk.ahead > 0) bits.push(`${chk.ahead} ahead of origin`);
|
||||
else bits.push("up to date");
|
||||
} else if (chk && chk.reason) {
|
||||
bits.push(`not compared — ${chk.reason}`);
|
||||
}
|
||||
left.append(el("div", "cn-sub", bits.join(" — ")));
|
||||
r.append(left);
|
||||
|
||||
const acts = el("div", "cn-row-acts");
|
||||
if (chk && chk.checked && chk.behind > 0) r.classList.add("behind");
|
||||
if (!n.is_funpack) {
|
||||
const up = el("button", "btn ghost tiny", busy === n.name ? "…" : "Update");
|
||||
const label = busy === n.name ? "…"
|
||||
: (chk && chk.checked && chk.behind > 0) ? `Update (${chk.behind})` : "Update";
|
||||
const up = el("button", "btn ghost tiny" + (chk?.behind > 0 ? " primary" : ""), label);
|
||||
up.type = "button";
|
||||
up.disabled = !!busy || !n.git;
|
||||
up.title = n.git ? `git pull in ${n.name}` : "Not a git checkout — nothing to pull";
|
||||
@@ -142,8 +174,16 @@
|
||||
add.disabled = !!busy;
|
||||
add.onclick = addDialog;
|
||||
bar.append(add);
|
||||
const chkBtn = el("button", "btn ghost tiny", checking ? "Checking…" : "Check for updates");
|
||||
chkBtn.type = "button";
|
||||
chkBtn.disabled = checking || !!busy;
|
||||
chkBtn.title = "Fetch each pack's origin and report how far behind it is";
|
||||
chkBtn.onclick = checkUpdates;
|
||||
bar.append(chkBtn);
|
||||
if (busy) bar.append(el("span", "cn-busy", busy === "__install__"
|
||||
? "Cloning and installing…" : `Working on ${busy}…`));
|
||||
else if (checking) bar.append(el("span", "cn-busy",
|
||||
"Fetching each pack — this talks to the network."));
|
||||
host.append(bar);
|
||||
|
||||
if (dirty) {
|
||||
@@ -177,6 +217,7 @@
|
||||
host = el("div", "cn-mount");
|
||||
body.append(host);
|
||||
dirty = false;
|
||||
checked = {};
|
||||
render();
|
||||
refresh();
|
||||
return () => { host = null; };
|
||||
|
||||
@@ -2704,6 +2704,8 @@ body.tour-mode { overflow: hidden; }
|
||||
}
|
||||
.cn-row.self { background: color-mix(in srgb, var(--accent) 10%, var(--ink-2));
|
||||
border-color: color-mix(in srgb, var(--accent) 30%, var(--line)); }
|
||||
/* Behind origin — the one row state worth spotting without reading. */
|
||||
.cn-row.behind { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); }
|
||||
.cn-row-text { min-width: 0; }
|
||||
.cn-name { font-size: 12px; font-weight: 600; color: var(--text);
|
||||
overflow-wrap: anywhere; }
|
||||
|
||||
@@ -2544,6 +2544,11 @@ if web is not None and PromptServer is not None:
|
||||
import asyncio
|
||||
return web.json_response(await asyncio.to_thread(custom_nodes.list_nodes))
|
||||
|
||||
@routes.post(UI_PREFIX + "/api/custom-nodes/check")
|
||||
async def _custom_nodes_check(_req):
|
||||
import asyncio
|
||||
return web.json_response(await asyncio.to_thread(custom_nodes.check_updates))
|
||||
|
||||
@routes.post(UI_PREFIX + "/api/custom-nodes/install")
|
||||
async def _custom_nodes_install(req):
|
||||
import asyncio
|
||||
|
||||
@@ -188,3 +188,94 @@ def test_listing_skips_dotfiles_and_disabled_packs(root, monkeypatch):
|
||||
names = [n["name"] for n in cn.list_nodes()["nodes"]]
|
||||
assert "__pycache__" not in names and ".git" not in names
|
||||
assert "Old.disabled" not in names
|
||||
|
||||
|
||||
# ── check for updates ─────────────────────────────────────────────────────────
|
||||
# A network round trip per pack, which is why it is a button. Every failure mode has to
|
||||
# produce a reason rather than an absent answer, or a pack silently reads as "fine".
|
||||
|
||||
def test_behind_ahead_parses_the_counts(root, monkeypatch):
|
||||
(root / "SomePack" / ".git").mkdir()
|
||||
|
||||
def fake_git(cwd, *args, **kw):
|
||||
if args[0] == "rev-parse":
|
||||
return types.SimpleNamespace(returncode=0, stdout="main\n", stderr="")
|
||||
if args[0] == "config":
|
||||
return types.SimpleNamespace(returncode=0, stdout="https://x/y\n", stderr="")
|
||||
if args[0] == "fetch":
|
||||
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
return types.SimpleNamespace(returncode=0, stdout="2\t7\n", stderr="")
|
||||
|
||||
monkeypatch.setattr(cn, "_git", fake_git)
|
||||
out = cn._behind_ahead(root / "SomePack")
|
||||
assert out == {"checked": True, "branch": "main", "ahead": 2, "behind": 7}
|
||||
|
||||
|
||||
def test_a_detached_head_says_so(root, monkeypatch):
|
||||
(root / "SomePack" / ".git").mkdir()
|
||||
monkeypatch.setattr(cn, "_git", lambda cwd, *a, **k: types.SimpleNamespace(
|
||||
returncode=0, stdout="HEAD\n", stderr=""))
|
||||
out = cn._behind_ahead(root / "SomePack")
|
||||
assert out["checked"] is False and "detached" in out["reason"]
|
||||
|
||||
|
||||
def test_an_unreachable_origin_says_so(root, monkeypatch):
|
||||
(root / "SomePack" / ".git").mkdir()
|
||||
|
||||
def fake_git(cwd, *args, **kw):
|
||||
if args[0] == "rev-parse":
|
||||
return types.SimpleNamespace(returncode=0, stdout="main\n", stderr="")
|
||||
if args[0] == "config":
|
||||
return types.SimpleNamespace(returncode=0, stdout="https://x/y\n", stderr="")
|
||||
return types.SimpleNamespace(returncode=128, stdout="",
|
||||
stderr="fatal: could not read from remote")
|
||||
|
||||
monkeypatch.setattr(cn, "_git", fake_git)
|
||||
out = cn._behind_ahead(root / "SomePack")
|
||||
assert out["checked"] is False and "remote" in out["reason"]
|
||||
|
||||
|
||||
def test_a_pack_with_no_remote_is_not_compared(root, monkeypatch):
|
||||
(root / "SomePack" / ".git").mkdir()
|
||||
|
||||
def fake_git(cwd, *args, **kw):
|
||||
if args[0] == "rev-parse":
|
||||
return types.SimpleNamespace(returncode=0, stdout="main\n", stderr="")
|
||||
return types.SimpleNamespace(returncode=1, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(cn, "_git", fake_git)
|
||||
assert cn._behind_ahead(root / "SomePack")["reason"] == "no origin remote"
|
||||
|
||||
|
||||
def test_a_non_git_pack_is_reported_not_omitted(root):
|
||||
out = cn._behind_ahead(root / "SomePack")
|
||||
assert out == {"checked": False, "reason": "not a git checkout"}
|
||||
|
||||
|
||||
def test_every_pack_appears_in_the_result(root, monkeypatch):
|
||||
"""Including the ones that could not be checked — an absent entry would read as
|
||||
'nothing to say', which is not the same as 'could not tell'."""
|
||||
monkeypatch.setattr(cn, "_git_info", lambda p: {
|
||||
"git": p.name == "ComfyUI-GGUF", "branch": "main", "commit": "abc", "remote": "x"})
|
||||
monkeypatch.setattr(cn, "_behind_ahead",
|
||||
lambda p: {"checked": True, "branch": "main", "ahead": 0, "behind": 3})
|
||||
got = cn.check_updates()["checked"]
|
||||
assert set(got) == {"ComfyUI-FunPack", "ComfyUI-GGUF", "SomePack"}
|
||||
assert got["ComfyUI-GGUF"]["behind"] == 3
|
||||
assert got["SomePack"]["checked"] is False
|
||||
|
||||
|
||||
def test_one_failing_pack_does_not_sink_the_sweep(root, monkeypatch):
|
||||
monkeypatch.setattr(cn, "_git_info", lambda p: {
|
||||
"git": True, "branch": "main", "commit": "abc", "remote": "x"})
|
||||
|
||||
def flaky(path):
|
||||
if path.name == "SomePack":
|
||||
raise RuntimeError("git exploded")
|
||||
return {"checked": True, "branch": "main", "ahead": 0, "behind": 1}
|
||||
|
||||
monkeypatch.setattr(cn, "_behind_ahead", flaky)
|
||||
got = cn.check_updates()["checked"]
|
||||
assert got["SomePack"]["checked"] is False
|
||||
assert "exploded" in got["SomePack"]["reason"]
|
||||
assert got["ComfyUI-GGUF"]["behind"] == 1
|
||||
|
||||
Reference in New Issue
Block a user