diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 496e1f17..cc3cf52c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,7 @@ import Upload from "@/pages/Upload"; import NotFound from "@/pages/NotFound"; import SingleTabGuard from "@/components/SingleTabGuard"; import TeleopStopNotice from "@/components/TeleopStopNotice"; +import UpdateNotice from "@/components/UpdateNotice"; import { TooltipProvider } from "@radix-ui/react-tooltip"; import { ApiProvider } from "./contexts/ApiContext"; import { HfAuthProvider } from "./contexts/HfAuthContext"; @@ -34,6 +35,7 @@ function App() { + } /> } /> diff --git a/frontend/src/components/UpdateNotice.tsx b/frontend/src/components/UpdateNotice.tsx new file mode 100644 index 00000000..a9c026f3 --- /dev/null +++ b/frontend/src/components/UpdateNotice.tsx @@ -0,0 +1,194 @@ +import { useState } from "react"; +import { Loader2, Copy, Sparkles, ChevronRight } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { useApi } from "@/contexts/ApiContext"; +import { useToast } from "@/hooks/use-toast"; +import { useUpdateCheck } from "@/hooks/useUpdateCheck"; + +/** + * App-level popup that notifies the user when a newer LeLab is available on + * GitHub. Offers a copy-able upgrade command, a best-effort "Update now" button + * (runs the pip upgrade on the backend), and a "don't ask again" opt-out. + */ +const UpdateNotice = () => { + const { status, open, dismiss } = useUpdateCheck(); + const { baseUrl, fetchWithHeaders } = useApi(); + const { toast } = useToast(); + const [dontAsk, setDontAsk] = useState(false); + const [updating, setUpdating] = useState(false); + const [output, setOutput] = useState(null); + + if (!status) return null; + + const behind = + typeof status.commits_behind === "number" && status.commits_behind > 0 + ? `${status.commits_behind} commit${status.commits_behind === 1 ? "" : "s"} behind` + : "A new version is available"; + + const copyCommand = async () => { + if (!status.update_command) return; + try { + await navigator.clipboard.writeText(status.update_command); + toast({ + title: "Copied", + description: "Update command copied to clipboard.", + }); + } catch { + toast({ + title: "Copy failed", + description: "Select and copy the command manually.", + variant: "destructive", + }); + } + }; + + const runUpdate = async () => { + setUpdating(true); + setOutput(null); + try { + const r = await fetchWithHeaders(`${baseUrl}/system/update`, { + method: "POST", + }); + const body: { success: boolean; message: string; output: string } = + await r.json(); + if (body.success) { + toast({ title: "Updated", description: body.message }); + dismiss(false); + } else { + setOutput(body.output || body.message); + toast({ + title: "Update failed", + description: body.message, + variant: "destructive", + }); + } + } catch (e) { + toast({ + title: "Update failed", + description: e instanceof Error ? e.message : String(e), + variant: "destructive", + }); + } finally { + setUpdating(false); + } + }; + + return ( + { + if (!o && !updating) dismiss(dontAsk); + }} + > + e.preventDefault()} + > + + + + LeLab update available + + + You're {behind} 😱. +
+ Update to get the latest fixes and features πŸ€—. + {status.compare_url && ( + <> + {" "} + + See what changed + + . + + )} +
+
+ +
+ + + + Or update manually + + +
+ + {status.update_command} + + +
+
+
+ + {output && ( +
+              {output}
+            
+ )} + +
+ +
+ + {status.can_auto_update && ( + + )} +
+
+
+
+
+ ); +}; + +export default UpdateNotice; diff --git a/frontend/src/hooks/useUpdateCheck.ts b/frontend/src/hooks/useUpdateCheck.ts new file mode 100644 index 00000000..ab4e2d84 --- /dev/null +++ b/frontend/src/hooks/useUpdateCheck.ts @@ -0,0 +1,77 @@ +import { useCallback, useEffect, useState } from "react"; +import { useApi } from "@/contexts/ApiContext"; +import { isHostedSpace } from "@/lib/isHostedSpace"; + +export interface UpdateStatus { + update_available: boolean; + current_commit: string | null; + latest_commit: string | null; + commits_behind: number | null; + compare_url: string | null; + update_command: string | null; + can_auto_update: boolean; +} + +// Stores the latest SHA the user chose to ignore via "don't ask again". A newer +// release has a different SHA, so the popup naturally returns β€” which clears the +// previous opt-out, exactly as intended. +const DISMISS_KEY = "lelab:update-dismissed-sha"; + +interface UseUpdateCheckResult { + status: UpdateStatus | null; + open: boolean; + /** Close the popup. `dontAskAgain` persists the current SHA so it stays hidden. */ + dismiss: (dontAskAgain: boolean) => void; +} + +/** + * Checks GitHub (via the backend) once on load for a newer LeLab and decides + * whether to surface the update popup. Skipped on the hosted HF Space (a + * different runtime that can't be updated this way) and silent on any failure. + */ +export function useUpdateCheck(): UseUpdateCheckResult { + const { baseUrl, fetchWithHeaders } = useApi(); + const [status, setStatus] = useState(null); + const [open, setOpen] = useState(false); + + useEffect(() => { + if (isHostedSpace()) return; + let cancelled = false; + fetchWithHeaders(`${baseUrl}/system/update-check`) + .then((r) => (r.ok ? r.json() : null)) + .then((data: UpdateStatus | null) => { + if (cancelled || !data || !data.update_available) return; + let dismissed: string | null = null; + try { + dismissed = localStorage.getItem(DISMISS_KEY); + } catch { + /* localStorage unavailable β€” show the popup anyway */ + } + if (dismissed && dismissed === data.latest_commit) return; + setStatus(data); + setOpen(true); + }) + .catch(() => { + /* backend/GitHub unreachable β€” stay silent */ + }); + return () => { + cancelled = true; + }; + }, [baseUrl, fetchWithHeaders]); + + const dismiss = useCallback( + (dontAskAgain: boolean) => { + if (dontAskAgain && status?.latest_commit) { + try { + localStorage.setItem(DISMISS_KEY, status.latest_commit); + } catch { + /* localStorage unavailable β€” nothing to persist */ + } + } + setOpen(false); + }, + [status] + ); + + return { status, open, dismiss }; +} diff --git a/lelab/server.py b/lelab/server.py index ff495930..43e7d711 100644 --- a/lelab/server.py +++ b/lelab/server.py @@ -80,6 +80,7 @@ # Training is now job-based; see app/jobs.py. from .train import TrainingRequest +from .update import handle_run_update, handle_update_check from .utils import config from .utils.config import ( FOLLOWER_CONFIG_PATH, @@ -749,6 +750,18 @@ def install_wandb_extra_status(): return handle_install_wandb_extra_status() +@app.get("/system/update-check") +def update_check(): + """Report whether a newer LeLab commit exists on GitHub (cached, silent on failure).""" + return handle_update_check() + + +@app.post("/system/update") +def run_update(): + """Run the pip upgrade in-process; the user must restart lelab afterwards.""" + return handle_run_update() + + # Replay is rendered by the embedded lerobot/visualize_dataset Space; no backend routes needed. diff --git a/lelab/update.py b/lelab/update.py new file mode 100644 index 00000000..75644b1d --- /dev/null +++ b/lelab/update.py @@ -0,0 +1,236 @@ +# Copyright 2025 The HuggingFace Inc. team. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Notify users when a newer LeLab is available on GitHub. + +LeLab installs from git (`pip install git+https://github.com/.../leLab.git`), +so "newer" means the default branch has moved past the installed commit. We +read the installed commit from pip's `direct_url.json` and compare it to the +repo HEAD via the GitHub API. Editable/local clones have no commit_id, so they +are deliberately excluded β€” developers shouldn't be nagged to update. +""" + +from __future__ import annotations + +import json +import logging +import shlex +import shutil +import subprocess +import sys +import time +import urllib.request +from importlib.metadata import PackageNotFoundError, distribution +from typing import Any + +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + +GITHUB_API = "https://api.github.com" +_HTTP_TIMEOUT = 5 # seconds β€” GitHub check must never stall the UI +_CACHE_TTL = 600 # seconds β€” avoid hammering the GitHub API on every page load + +# Cached UpdateStatus dict + monotonic timestamp of when it was computed. +_cache: dict[str, Any] | None = None +_cache_time: float = 0.0 + + +class UpdateStatus(BaseModel): + update_available: bool + current_commit: str | None = None + latest_commit: str | None = None + commits_behind: int | None = None + compare_url: str | None = None + update_command: str | None = None + can_auto_update: bool = False + + +class UpdateResult(BaseModel): + success: bool + message: str + output: str = "" + + +def _parse_github_repo(url: str) -> tuple[str, str] | None: + """Extract ``(owner, repo)`` from a GitHub clone URL, or None if not GitHub.""" + if not url or "github.com" not in url: + return None + u = url.strip().removeprefix("git+") + if u.startswith("git@"): + # git@github.com:owner/repo.git + _, _, path = u.partition(":") + else: + # https://github.com/owner/repo(.git) + path = u.split("github.com", 1)[1].lstrip("/:") + path = path.removesuffix(".git").strip("/") + parts = path.split("/") + if len(parts) < 2 or not parts[0] or not parts[1]: + return None + return parts[0], parts[1] + + +def get_installed_source() -> dict[str, str] | None: + """Read the installed git commit and origin repo from pip metadata. + + Returns ``{"commit", "owner", "repo"}`` for a git install, or None for + editable/local/non-git installs (where there's nothing meaningful to + compare against). + """ + try: + dist = distribution("lelab") + except PackageNotFoundError: + return None + raw = dist.read_text("direct_url.json") + if not raw: + return None + try: + data = json.loads(raw) + except (ValueError, TypeError): + return None + commit = (data.get("vcs_info") or {}).get("commit_id") + if not commit: + return None + repo = _parse_github_repo(data.get("url", "")) + if repo is None: + return None + owner, name = repo + return {"commit": commit, "owner": owner, "repo": name} + + +def _github_json(path: str) -> Any | None: + """GET a GitHub API path and parse JSON. Any failure returns None (silent).""" + req = urllib.request.Request( + f"{GITHUB_API}{path}", + headers={ + "User-Agent": "lelab-update-check", + "Accept": "application/vnd.github+json", + }, + ) + try: + # URL is always GITHUB_API (a fixed https:// constant) plus an + # internally-built path; no user-controlled scheme. + with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT) as resp: # noqa: S310 # nosec B310 + return json.loads(resp.read().decode()) + except Exception as exc: # noqa: BLE001 β€” network check must never raise + logger.debug("GitHub API call failed for %s: %s", path, exc) + return None + + +def _build_update_cmd(owner: str, repo: str) -> list[str]: + """Pick the installer for the running Python. + + The hosted/standard install uses uv (uv venvs ship no pip), so prefer + `uv pip install` pinned to this interpreter when uv is on PATH, matching + the extra-install flow in utils/system.py. Fall back to `python -m pip`. + """ + target = f"git+https://github.com/{owner}/{repo}.git" + flags = ["--upgrade", "--force-reinstall", target] + if shutil.which("uv"): + return ["uv", "pip", "install", "--python", sys.executable, *flags] + return [sys.executable, "-m", "pip", "install", *flags] + + +def _update_command(owner: str, repo: str) -> str: + """The exact command the Update button runs β€” shown so manual copy matches.""" + return shlex.join(_build_update_cmd(owner, repo)) + + +def _compute_status() -> dict[str, Any]: + source = get_installed_source() + if source is None: + return UpdateStatus(update_available=False).model_dump() + + current = source["commit"] + owner, repo = source["owner"], source["repo"] + base = UpdateStatus( + update_available=False, + current_commit=current, + update_command=_update_command(owner, repo), + can_auto_update=True, + ) + + head = _github_json(f"/repos/{owner}/{repo}/commits/HEAD") + latest = head.get("sha") if isinstance(head, dict) else None + if not latest: + return base.model_dump() # GitHub unreachable β€” stay silent + + base.latest_commit = latest + if latest == current: + base.commits_behind = 0 + return base.model_dump() + + compare = _github_json(f"/repos/{owner}/{repo}/compare/{current}...{latest}") + if isinstance(compare, dict): + base.commits_behind = compare.get("ahead_by") + base.compare_url = compare.get("html_url") + base.update_available = True + return base.model_dump() + + +def check_for_update(force: bool = False) -> dict[str, Any]: + """Return the cached update status, refreshing past the TTL or when forced.""" + global _cache, _cache_time + now = time.monotonic() + if not force and _cache is not None and (now - _cache_time) < _CACHE_TTL: + return _cache + _cache = _compute_status() + _cache_time = now + return _cache + + +def handle_update_check() -> dict[str, Any]: + return check_for_update() + + +def handle_run_update() -> UpdateResult: + """Run the pip upgrade in-process (best-effort). User must restart after. + + Unlike the /system extra-installs (Popen + background thread + log polling + via InstallManager), this is a single blocking subprocess.run: the update is + a one-shot, fire-and-restart action with no live log UI, so streaming + machinery would be unused complexity. FastAPI runs this sync handler in a + threadpool, and the frontend shows a spinner for the duration. + """ + source = get_installed_source() + if source is None: + return UpdateResult( + success=False, + message="This install can't auto-update (editable or non-git). " + "Update it the way you installed it.", + ) + try: + proc = subprocess.run( + _build_update_cmd(source["owner"], source["repo"]), + capture_output=True, + text=True, + timeout=600, + ) + except Exception as exc: # noqa: BLE001 β€” surface failure to the UI + logger.exception("Auto-update subprocess failed") + return UpdateResult(success=False, message=f"Update failed: {exc}") + + output = (proc.stdout or "") + (proc.stderr or "") + if proc.returncode == 0: + global _cache + _cache = None # bust cache so the next check reflects the new install + return UpdateResult( + success=True, + message="Updated. Restart lelab to apply the new version.", + output=output, + ) + return UpdateResult( + success=False, + message="Update failed. See the output, or run the command manually.", + output=output, + ) diff --git a/tests/test_update.py b/tests/test_update.py new file mode 100644 index 00000000..7821a13a --- /dev/null +++ b/tests/test_update.py @@ -0,0 +1,149 @@ +# Copyright 2025 The HuggingFace Inc. team. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for the GitHub update-notifier module.""" + +from __future__ import annotations + +import json + +import pytest + +from lelab import update + + +@pytest.fixture(autouse=True) +def _clear_cache(): + """Each test starts with a cold update cache.""" + update._cache = None + update._cache_time = 0.0 + yield + update._cache = None + update._cache_time = 0.0 + + +@pytest.mark.parametrize( + "url,expected", + [ + ("https://github.com/huggingface/leLab.git", ("huggingface", "leLab")), + ("https://github.com/huggingface/leLab", ("huggingface", "leLab")), + ("git+https://github.com/huggingface/leLab.git", ("huggingface", "leLab")), + ("git@github.com:huggingface/leLab.git", ("huggingface", "leLab")), + ("https://gitlab.com/foo/bar.git", None), + ("file:///home/me/leLab", None), + ("", None), + ], +) +def test_parse_github_repo(url, expected): + assert update._parse_github_repo(url) == expected + + +def _fake_dist(direct_url: dict | None): + class _Dist: + def read_text(self, name): + if name != "direct_url.json" or direct_url is None: + return None + return json.dumps(direct_url) + + return _Dist() + + +def test_installed_source_from_vcs(monkeypatch): + monkeypatch.setattr( + update, + "distribution", + lambda name: _fake_dist( + { + "url": "https://github.com/huggingface/leLab.git", + "vcs_info": {"vcs": "git", "commit_id": "abc123"}, + } + ), + ) + src = update.get_installed_source() + assert src == {"commit": "abc123", "owner": "huggingface", "repo": "leLab"} + + +def test_installed_source_editable_returns_none(monkeypatch): + """Editable / local installs have no commit_id β€” no nagging developers.""" + monkeypatch.setattr( + update, + "distribution", + lambda name: _fake_dist({"url": "file:///home/me/leLab", "dir_info": {"editable": True}}), + ) + assert update.get_installed_source() is None + + +def test_installed_source_no_direct_url_returns_none(monkeypatch): + monkeypatch.setattr(update, "distribution", lambda name: _fake_dist(None)) + assert update.get_installed_source() is None + + +def test_check_no_source_means_no_update(monkeypatch): + monkeypatch.setattr(update, "get_installed_source", lambda: None) + status = update.check_for_update() + assert status["update_available"] is False + assert status["current_commit"] is None + + +def test_check_up_to_date(monkeypatch): + monkeypatch.setattr( + update, + "get_installed_source", + lambda: {"commit": "abc123", "owner": "huggingface", "repo": "leLab"}, + ) + monkeypatch.setattr(update, "_github_json", lambda path: {"sha": "abc123"}) + status = update.check_for_update() + assert status["update_available"] is False + assert status["latest_commit"] == "abc123" + assert status["commits_behind"] == 0 + + +def test_check_update_available(monkeypatch): + monkeypatch.setattr( + update, + "get_installed_source", + lambda: {"commit": "abc123", "owner": "huggingface", "repo": "leLab"}, + ) + + def fake_github(path: str): + if path.endswith("/commits/HEAD"): + return {"sha": "def456"} + if "/compare/" in path: + return {"ahead_by": 7, "html_url": "https://github.com/huggingface/leLab/compare/abc123...def456"} + return None + + monkeypatch.setattr(update, "_github_json", fake_github) + status = update.check_for_update() + assert status["update_available"] is True + assert status["latest_commit"] == "def456" + assert status["commits_behind"] == 7 + assert status["compare_url"].endswith("abc123...def456") + assert "git+https://github.com/huggingface/leLab.git" in status["update_command"] + assert status["can_auto_update"] is True + + +def test_check_github_unreachable_is_silent(monkeypatch): + monkeypatch.setattr( + update, + "get_installed_source", + lambda: {"commit": "abc123", "owner": "huggingface", "repo": "leLab"}, + ) + monkeypatch.setattr(update, "_github_json", lambda path: None) + status = update.check_for_update() + assert status["update_available"] is False + + +def test_run_update_no_source(monkeypatch): + monkeypatch.setattr(update, "get_installed_source", lambda: None) + result = update.handle_run_update() + assert result.success is False