Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -34,6 +35,7 @@ function App() {
<BrowserRouter>
<SingleTabGuard>
<TeleopStopNotice />
<UpdateNotice />
<Routes>
<Route path="/" element={<Landing />} />
<Route path="/teleoperation" element={<Teleoperation />} />
Expand Down
194 changes: 194 additions & 0 deletions frontend/src/components/UpdateNotice.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<Dialog
open={open}
onOpenChange={(o) => {
if (!o && !updating) dismiss(dontAsk);
}}
>
<DialogContent
className="bg-slate-800 border-slate-700 text-white max-w-lg"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-3 text-white">
<Sparkles className="w-5 h-5 text-amber-400" />
LeLab update available
</DialogTitle>
<DialogDescription className="text-slate-300">
You're {behind} 😱.
<br />
Update to get the latest fixes and features 🤗.
{status.compare_url && (
<>
{" "}
<a
href={status.compare_url}
target="_blank"
rel="noreferrer"
className="text-sky-300 underline hover:text-sky-200"
>
See what changed
</a>
.
</>
)}
</DialogDescription>
</DialogHeader>

<div className="space-y-4">
<Collapsible>
<CollapsibleTrigger className="group flex items-center gap-1.5 text-xs font-medium text-slate-300 hover:text-white transition-colors">
<ChevronRight className="w-3.5 h-3.5 transition-transform group-data-[state=open]:rotate-90" />
Or update manually
</CollapsibleTrigger>
<CollapsibleContent className="pt-2">
<div className="flex items-start gap-2">
<code className="min-w-0 flex-1 px-2 py-1.5 rounded bg-slate-900 text-sky-300 text-xs break-all whitespace-pre-wrap">
{status.update_command}
</code>
<Button
variant="outline"
size="icon"
onClick={copyCommand}
title="Copy command"
className="shrink-0 bg-slate-900 border-slate-600 text-white hover:bg-slate-700"
>
<Copy className="w-4 h-4" />
</Button>
</div>
</CollapsibleContent>
</Collapsible>

{output && (
<pre className="max-h-40 overflow-auto rounded bg-slate-900 p-2 text-xs text-slate-300 whitespace-pre-wrap">
{output}
</pre>
)}

<div className="flex items-center justify-between gap-3 pt-1">
<label className="flex items-center gap-2 text-sm text-slate-300">
<Checkbox
checked={dontAsk}
onCheckedChange={(v) => setDontAsk(v === true)}
className="border-slate-300 data-[state=checked]:bg-slate-300 data-[state=checked]:text-slate-900"
/>
Don't ask me again
</label>
<div className="flex items-center gap-2">
<Button
variant="ghost"
onClick={() => dismiss(dontAsk)}
disabled={updating}
className="text-slate-300 hover:bg-slate-700 hover:text-white"
>
Later
</Button>
{status.can_auto_update && (
<Button onClick={runUpdate} disabled={updating}>
{updating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Updating…
</>
) : (
"Update now"
)}
</Button>
)}
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
};

export default UpdateNotice;
77 changes: 77 additions & 0 deletions frontend/src/hooks/useUpdateCheck.ts
Original file line number Diff line number Diff line change
@@ -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<UpdateStatus | null>(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 };
}
13 changes: 13 additions & 0 deletions lelab/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.


Expand Down
Loading
Loading