[client/ui-wails] Make /update page faithful to the legacy auto-update dialog

Adds the missing info line ("Your client version is older than the
auto-update version set in Management. Updating client to: <version>.")
and replaces the spinner with the legacy 1-second dot animation
(Updating./.../...). Terminal-state wording now matches the Fyne UI
exactly: 15 min timeout, canceled, and "Update failed: <err>".

Ports mapInstallError from client/ui/update.go so daemon errors that
embed "deadline exceeded" / "canceled" hit the right branch instead of
falling through as a generic failure.

Detects the daemon dropping mid-upgrade (the legacy success signal):
if GetInstallerResult fails for 5s straight, call the new Update.Quit
service method to exit, mirroring app.Quit() in showInstallerResult.
This commit is contained in:
Zoltán Papp
2026-05-07 10:35:18 +02:00
parent d324a5ff48
commit 5b70989e3e
3 changed files with 131 additions and 31 deletions

View File

@@ -20,6 +20,17 @@ export function GetInstallerResult(): $CancellablePromise<$models.UpdateResult>
});
}
/**
* Quit asks the host application to exit. The /update page calls this once
* the daemon-side installer has reported success, mirroring the legacy
* Fyne UI's app.Quit() in showInstallerResult. Schedules the actual exit
* off the calling goroutine so the JS-side caller's response can return
* before the runtime tears down.
*/
export function Quit(): $CancellablePromise<void> {
return $Call.ByID(27817640);
}
export function Trigger(): $CancellablePromise<$models.UpdateResult> {
return $Call.ByID(2415339649).then(($result: any) => {
return $$createType0($result);

View File

@@ -1,61 +1,135 @@
import { useEffect, useState } from "react";
import { Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Update as UpdateSvc } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services";
const TIMEOUT_MS = 15 * 60 * 1000;
const POLL_INTERVAL_MS = 2000;
// How long the daemon is allowed to be unreachable before we treat it as
// "daemon went down for the upgrade, treat as success and quit". Mirrors
// the legacy Fyne UI's branch in client/ui/update.go where a connection
// failure during polling is taken as the success signal.
const DAEMON_DOWN_GRACE_MS = 5000;
type Phase =
| { kind: "running"; dots: number }
| { kind: "timeout" }
| { kind: "canceled" }
| { kind: "failed"; message: string };
export default function Update() {
const [done, setDone] = useState(false);
const [error, setError] = useState<string | null>(null);
const [phase, setPhase] = useState<Phase>({ kind: "running", dots: 1 });
const phaseRef = useRef(phase);
phaseRef.current = phase;
const version = new URLSearchParams(
window.location.hash.split("?")[1] ?? "",
).get("version");
useEffect(() => {
let cancelled = false;
UpdateSvc.Trigger().catch((e) => !cancelled && setError(String(e)));
const start = Date.now();
const timer = setInterval(async () => {
let firstUnreachableAt: number | null = null;
UpdateSvc.Trigger().catch(() => {
// The daemon may already be down (installer launched, daemon shutting
// down). Don't treat as failure here; the poll loop's daemon-down
// detection handles it.
});
const dotTimer = setInterval(() => {
if (cancelled) return;
setPhase((p) =>
p.kind === "running" ? { kind: "running", dots: (p.dots % 3) + 1 } : p,
);
}, 1000);
const pollTimer = setInterval(async () => {
if (cancelled) return;
if (phaseRef.current.kind !== "running") return;
if (Date.now() - start > TIMEOUT_MS) {
setError("Update timed out.");
clearInterval(timer);
clearInterval(pollTimer);
clearInterval(dotTimer);
setPhase({ kind: "timeout" });
return;
}
try {
const r = await UpdateSvc.GetInstallerResult();
firstUnreachableAt = null;
if (r.success) {
setDone(true);
clearInterval(timer);
} else if (r.errorMsg) {
setError(r.errorMsg);
clearInterval(timer);
clearInterval(pollTimer);
clearInterval(dotTimer);
UpdateSvc.Quit();
return;
}
if (r.errorMsg) {
clearInterval(pollTimer);
clearInterval(dotTimer);
setPhase(mapInstallError(r.errorMsg));
}
} catch {
// installer not finished yet
// RPC failed. The daemon often goes away mid-upgrade — treat a
// sustained outage as success and quit, matching the legacy UI.
const now = Date.now();
if (firstUnreachableAt === null) {
firstUnreachableAt = now;
} else if (now - firstUnreachableAt >= DAEMON_DOWN_GRACE_MS) {
clearInterval(pollTimer);
clearInterval(dotTimer);
UpdateSvc.Quit();
}
}
}, 2000);
}, POLL_INTERVAL_MS);
return () => {
cancelled = true;
clearInterval(timer);
clearInterval(dotTimer);
clearInterval(pollTimer);
};
}, []);
const versionLine = version
? `Updating client to: ${version}.`
: "Updating client.";
return (
<div className="flex h-full items-center justify-center p-6">
<div className="text-center">
{done ? (
<h1 className="text-xl font-semibold text-green-500">Update complete</h1>
) : error ? (
<h1 className="text-xl font-semibold text-red-500">{error}</h1>
) : (
<>
<Loader2 className="mx-auto mb-3 h-8 w-8 animate-spin text-netbird" strokeWidth={1.5} />
<h1 className="text-xl font-semibold">Updating</h1>
<p className="mt-1 text-sm text-nb-gray-500">
Please don't close this window.
</p>
</>
)}
<div className="space-y-3 text-center">
<p className="whitespace-pre-line text-sm text-nb-gray-700 dark:text-nb-gray-200">
{`Your client version is older than the auto-update version set in Management.\n${versionLine}`}
</p>
<p className="text-base font-medium">{statusText(phase)}</p>
</div>
</div>
);
}
function statusText(p: Phase): string {
switch (p.kind) {
case "running":
return "Updating" + ".".repeat(p.dots);
case "timeout":
return "Update timed out. Please try again.";
case "canceled":
return "Update canceled.";
case "failed":
return "Update failed: " + p.message;
}
}
// Mirrors mapInstallError in client/ui/update.go. The daemon's installer
// surfaces error strings rather than typed errors, so the UI sniffs the
// message to decide whether to show the timeout/canceled wording.
function mapInstallError(msg: string): Phase {
const m = msg.trim().toLowerCase();
if (m === "") {
return { kind: "failed", message: "unknown update error" };
}
if (m.includes("deadline exceeded") || m.includes("timeout")) {
return { kind: "timeout" };
}
if (m.includes("canceled") || m.includes("cancelled")) {
return { kind: "canceled" };
}
return { kind: "failed", message: msg };
}

View File

@@ -4,6 +4,9 @@ package services
import (
"context"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/netbirdio/netbird/client/proto"
)
@@ -24,6 +27,18 @@ func NewUpdate(conn DaemonConn) *Update {
return &Update{conn: conn}
}
// Quit asks the host application to exit. The /update page calls this once
// the daemon-side installer has reported success, mirroring the legacy
// Fyne UI's app.Quit() in showInstallerResult. Schedules the actual exit
// off the calling goroutine so the JS-side caller's response can return
// before the runtime tears down.
func (s *Update) Quit() {
go func() {
time.Sleep(100 * time.Millisecond)
application.Get().Quit()
}()
}
func (s *Update) Trigger(ctx context.Context) (UpdateResult, error) {
cli, err := s.conn.Client()
if err != nil {