Files
AgentHub/client/src/components/UpdateNotifier.tsx
T

701 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file UpdateNotifier.tsx
* @description Modal surfaced when a newer RELEASE of the dashboard has been
* published (or, when no release can be read, when the checkout is behind its
* remote tracking branch). Shows the release version against the running one
* with a link to its notes, the exact terminal command with copy-to-clipboard,
* and — when the install is fast-forwardable — an "Update now" button that
* calls `POST /api/updates/apply` to move onto the release tag, rebuild, and
* restart the server itself, then polls until it's back and reloads the page.
*
* A `plugin_managed` install (running out of `~/.claude/plugins/cache/`) gets
* the same button, wired to a different mechanism: the server hands it to a
* detached chain (`claude plugin update` then `plugin-bootstrap.js --force`)
* which pulls the new cache directory, reinstalls the runtime and restarts the
* dashboard. It takes minutes rather than seconds, so the progress text differs
* — and the poll waits for the old server to actually go DOWN first, because it
* keeps answering while the chain installs. New skills/slash commands still
* need `/reload-plugins` in an already-open Claude Code session; nothing a
* background process can do for it. Escape, the backdrop and × close it for
* this page load only — also while an update applies: it keeps running in the
* background (the page still reloads itself once the new server is up), and a
* failure re-opens the modal with its error. While hidden — or once the status
* stops offering the update mid-run — a small chip in the corner keeps showing
* it, with the plugin chain's current step from `plugin_update_progress`, and a
* click on it shows the modal again. A chain the server reports as running
* (started before a reload or from another tab) is adopted the same way;
* "Skip this version" is the one
* persistent, per-version dismiss (never while applying). After a failed update, "Auto fix" hands the
* error to an AI agent: it opens (or adopts) a workspace on the install folder
* and starts a Claude Code session there with the error as its prompt — the
* agent fixes whatever blocked the update (a dirty lockfile, a failed build…)
* and runs the update command; the dashboard itself still decides nothing.
*
* ## State sources
* - Initial fetch via `api.updates.status()` on mount.
* - No client-side background re-check: the server checks on its own
* schedule and broadcasts `update_status`; "Check now" stays manual.
* - Live refresh from WebSocket `update_status` events on {@link eventBus}.
*
* ## Dismissal persistence
* Dismissals are keyed in `localStorage` by the release tag when there is one,
* else the upstream `remote_sha`, so a newer release re-opens the prompt while
* a dismissed one stays dismissed. The connection modal's "Check for updates"
* fires `dashboard:reset-update-dismissal`, which clears both the skip and the
* page-load close; `z-[110]` keeps this modal above that `z-[100]` one.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
*
* ## Design constraints
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
*
* ## Remote data & SSH
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Internal dependencies
* - `../lib/api`
* - `../lib/eventBus`
* - `../lib/types`
*
* ## Public surface
* - `UpdateNotifier` — exported API; see TSDoc on the symbol for behavior.
*
* ## Testing pointers
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
* - Server contract changes require `npm run test:server` and OpenAPI sync.
*
* ## Related docs
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
* - `docs/API.md` — REST reference.
* - `skills/file-headers/` — mandatory `@author` header policy.
* ============================================================================= */
/* -----------------------------------------------------------------------------
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
* -----------------------------------------------------------------------------
* **UpdateNotifier**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* ----------------------------------------------------------------------------- */
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import {
Download,
X,
Copy,
Check,
RefreshCw,
Zap,
ExternalLink,
SkipForward,
Wrench,
} from "lucide-react";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { copyText } from "../lib/clipboard";
import { toast } from "../lib/toast";
import { useDialog } from "../hooks/useDialog";
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
import { usePermissions } from "./AuthGate";
import { startInFreeTab } from "./lanes/startSession";
/** `localStorage` key storing the dismissed release tag (or upstream SHA). */
const DISMISS_KEY = "agent-monitor-update-dismissed-sha";
/** Situations `POST /api/updates/apply` will actually act on. Mirrors the
* server: `release_available` / `tracking_canonical` / `fork_or_diverged_tracking`
* go through a git fast-forward, and `plugin_managed` goes through the detached
* `plugin-update.js` chain (`claude plugin update` then `plugin-bootstrap --force`),
* which is why it belongs here even though `applyUpdate` itself refuses it. */
function isAutoApplicable(situation: UpdateStatusPayload["situation"]): boolean {
return (
situation === "plugin_managed" ||
situation === "release_available" ||
situation === "tracking_canonical" ||
situation === "fork_or_diverged_tracking"
);
}
/** The prompt the Auto fix session starts with: what failed, where, and the
* limits that keep the agent from taking down the dashboard it runs under. */
function autoFixPrompt(status: UpdateStatusPayload, error: string): string {
const target = status.latest_release || status.remote_ref || "the newest version";
const plugin = status.situation === "plugin_managed";
return [
`The AgentHub dashboard's self-update to ${target} failed${status.local_version ? ` (running ${status.local_version})` : ""}.`,
plugin
? "AgentHub is installed as a Claude Code plugin (the `agenthub` plugin from the `agenthub` marketplace)."
: `The install is the git checkout at ${status.repo_root}.`,
"",
"Error:",
"```",
error,
"```",
...(status.manual_command ? ["", "Update command:", "```", status.manual_command, "```"] : []),
"",
"Find the cause, fix it, then finish the update" +
(plugin
? " (`claude plugin marketplace update agenthub` then `claude plugin update agenthub@agenthub`)."
: " by running the update command."),
"Rules: never lose the user's work — before discarding any local change, save it (e.g. `git diff > ~/agenthub-update-backup.patch`) and say where; do not stop or restart the AgentHub server, and never kill tmux sessions or the tmux server (this session runs inside one).",
"When done, tell me what you changed; I will restart the dashboard from its update prompt.",
].join("\n");
}
/** Narrow unknown WebSocket payloads to {@link UpdateStatusPayload}. */
function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
return typeof x === "object" && x !== null && "git_repo" in x && "update_available" in x;
}
/** Read the last dismissed release tag / upstream SHA from `localStorage`. */
function loadDismissedSha(): string | null {
try {
return localStorage.getItem(DISMISS_KEY);
} catch {
return null;
}
}
/**
* Git update availability modal — mounted once in {@link Layout}.
* @returns `null` when no update is available or this release was dismissed.
*/
export function UpdateNotifier() {
const { isAdmin } = usePermissions();
const { t } = useTranslation("updates");
const [status, setStatus] = useState<UpdateStatusPayload | null>(null);
const [dismissedSha, setDismissedSha] = useState<string | null>(loadDismissedSha);
// Closed for THIS page load only (Escape, backdrop, ×) — the
// persistent per-version dismiss is the explicit "Skip this version".
const [closedKey, setClosedKey] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [checking, setChecking] = useState(false);
const [applying, setApplying] = useState(false);
const [restarting, setRestarting] = useState(false);
const [fixing, setFixing] = useState(false);
/** The running plugin chain's step, from the polls while it runs. */
const [progress, setProgress] = useState<UpdateStatusPayload["plugin_update_progress"]>(null);
const navigate = useNavigate();
const syncFromPayload = useCallback((s: UpdateStatusPayload) => {
setStatus(s);
if (!s.fetch_error) setError(null);
}, []);
useEffect(() => {
let cancelled = false;
api.updates
.status()
.then((s) => {
if (cancelled) return;
syncFromPayload(s);
eventBus.publish({
type: "update_status",
data: s,
timestamp: new Date().toISOString(),
});
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [syncFromPayload]);
useEffect(() => {
return eventBus.subscribe((msg: WSMessage) => {
if (msg.type !== "update_status") return;
if (isUpdatePayload(msg.data)) syncFromPayload(msg.data);
});
}, [syncFromPayload]);
useEffect(() => {
// An explicit "Check for updates" undoes both kinds of close.
const handler = () => {
setDismissedSha(null);
setClosedKey(null);
};
window.addEventListener("dashboard:reset-update-dismissal", handler);
return () => window.removeEventListener("dashboard:reset-update-dismissal", handler);
}, []);
// Keyed by the release tag when there is one, so dismissing v1.5.0 doesn't
// also swallow v1.6.0 — and so the prompt survives the tag pointing at a
// commit the user already has. Falls back to the upstream SHA for the
// commit-count path (no release readable).
// A build installed from a terminal while this server kept running has no
// tag or sha when the forge is unreachable — key it by its directory.
const installed = status?.installed_not_running ?? null;
const dismissKey =
status?.latest_release ||
status?.remote_sha ||
(installed ? `installed:${installed.root}` : null);
// Updating replaces the host's install: only an admin is offered it. A
// running update stays shown (unless closed) even once the status stops
// offering it — the new version may already be on disk before it restarts.
const show = Boolean(
isAdmin &&
dismissKey &&
closedKey !== dismissKey &&
(applying || restarting || (status?.update_available && dismissedSha !== dismissKey))
);
const busy = applying || restarting || fixing;
// Closing while an update applies only hides it: the apply request and the
// poll that reloads the page keep running, and a failure re-opens it.
const closeForNow = useCallback(() => {
if (!dismissKey) return;
setClosedKey(dismissKey);
}, [dismissKey]);
const dismiss = useCallback(() => {
if (busy || !dismissKey) return;
try {
localStorage.setItem(DISMISS_KEY, dismissKey);
} catch {
/* ignore */
}
setDismissedSha(dismissKey);
}, [busy, dismissKey]);
// Escape closes for now — an applying update carries on in the background.
useDialog(show, panelRef, closeForNow);
const copyCmd = async () => {
if (!status?.manual_command) return;
if (await copyText(status.manual_command, true)) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const checkNow = async () => {
if (checking) return;
setError(null);
setChecking(true);
try {
const fresh = await api.updates.check();
syncFromPayload(fresh);
} catch (e) {
setError(e instanceof Error ? e.message : t("checkError"));
} finally {
setChecking(false);
}
};
// Polls `status()` until the restarted server answers again, then reloads
// so the tab picks up the new client bundle too — the server can't push
// this over its own WebSocket since it's mid-restart.
/**
* @param requireDownFirst wait for the server to actually disappear before
* treating a success as "it's back". The git path rebuilds and restarts
* within seconds, but the plugin chain pulls a new cache directory and
* reinstalls dependencies first — the OLD server stays up for that whole
* stretch, so polling naively would get a 200 from the process we are
* waiting to replace and reload onto the same old bundle.
*/
const pollUntilBack = useCallback(
(requireDownFirst = false, fromVersion: string | null = null) => {
let sawDown = !requireDownFirst;
// Its own verdicts only: an older plugin-update.json is still on disk
// until this chain writes one (5 s slack for a clock a little behind).
const since = Date.now() - 5000;
const attempt = () => {
api.updates
.status()
.then((s) => {
setProgress(s.plugin_update_progress ?? null);
const v = s.last_plugin_update;
const mine = !!v?.finished_at && Date.parse(v.finished_at) >= since;
// A failed step, or nothing to pull, leaves the OLD server up: it
// never goes down, so waiting for that spun "Restarting…" forever.
if (requireDownFirst && v && mine && (v.ok === false || v.step === "already_current")) {
syncFromPayload(s);
setRestarting(false);
if (v.ok) toast.info(t("pluginAlreadyCurrent"));
else setClosedKey(null); // closed while it ran: show why it failed
return;
}
// A new version answering is back too, even if a poll never
// landed in the short gap between the old server and the new one.
const moved = !!fromVersion && !!s.local_version && s.local_version !== fromVersion;
if (sawDown || moved) window.location.reload();
else setTimeout(attempt, 1500);
})
.catch(() => {
sawDown = true;
setTimeout(attempt, 1500);
});
};
setTimeout(attempt, 1500);
},
[syncFromPayload, t]
);
// A chain already running on the server (started before a reload, in another
// tab, or from the old bundle) is adopted: this tab shows its steps, then
// reloads onto the new server like the tab that started it.
const running = status?.plugin_update_progress ?? null;
const adoptedRef = useRef(false);
useEffect(() => {
if (!running || !isAdmin || applying || restarting || adoptedRef.current) return;
adoptedRef.current = true;
setProgress(running);
setRestarting(true);
pollUntilBack(true, status?.local_version ?? null);
}, [running, isAdmin, applying, restarting, pollUntilBack, status?.local_version]);
const applyNow = async () => {
if (applying || restarting) return;
setError(null);
setProgress(null);
setApplying(true);
try {
const result = await api.updates.apply();
if (result.applied) {
setApplying(false);
setRestarting(true);
// mode "plugin": the old server lingers while the chain installs, so
// only treat a reachable server as "back" after it has gone away once.
pollUntilBack(result.mode === "plugin", status?.local_version ?? null);
return;
}
setError(
result.reason === "plugin_managed"
? t("reasonPluginManaged")
: result.reason === "not_fast_forwardable"
? t("reasonNotFastForwardable")
: t("applyError")
);
} catch (e) {
setError(e instanceof Error ? e.message : t("applyError"));
} finally {
setApplying(false);
}
// Not applied: re-open a modal closed while it ran, so the error is seen.
setClosedKey(null);
};
// Hand the failure to an AI agent: a workspace on the install folder (the
// home for a plugin install — never a lane inside the plugin cache) and a
// Claude Code session there with the error as its first prompt.
const autoFix = async () => {
if (busy || !status || !error) return;
setFixing(true);
try {
const plugin = status.situation === "plugin_managed";
const { lane } = await api.lanes.ensure({
...(plugin || !status.repo_root ? {} : { cwd: status.repo_root, exact: true }),
title: t("autoFixWorkspace"),
tool: "claude",
});
const runId = await startInFreeTab(lane.id, { prompt: autoFixPrompt(status, error) });
if (dismissKey) setClosedKey(dismissKey);
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : t("autoFixError"));
} finally {
setFixing(false);
}
};
const stepText = progress?.step
? t(`pluginStep.${progress.step}`, {
index: progress.index,
total: progress.total,
defaultValue: progress.step,
})
: null;
// Hidden while it runs — closed, or the status no longer offers the update
// (the new version is already on disk): a chip keeps it in sight.
if (!show && isAdmin && (applying || restarting)) {
return (
<button
type="button"
data-testid="update-chip"
onClick={() => setClosedKey(null)}
title={t("chipHint")}
className="fixed bottom-24 right-4 z-[60] w-72 rounded-lg border border-accent/30 bg-surface-1 p-2.5 text-left shadow-xl shadow-black/40 hover:bg-surface-2"
>
<span className="flex items-center gap-2 text-xs font-medium text-fg-primary">
<RefreshCw className="h-3.5 w-3.5 shrink-0 animate-spin text-accent" aria-hidden />
<span className="truncate">
{status?.latest_release
? t("chipTitle", { version: status.latest_release })
: t("updating")}
</span>
</span>
<span className="mt-1 block truncate text-[11px] text-fg-muted">
{stepText || (applying ? t("updating") : t("restartingShort"))}
</span>
{progress && progress.total > 0 ? (
<span
className="mt-1.5 block h-1 overflow-hidden rounded bg-surface-2"
role="progressbar"
aria-valuenow={progress.index}
aria-valuemin={0}
aria-valuemax={progress.total}
>
<span
className="block h-full bg-accent-solid transition-all"
style={{ width: `${(Math.max(progress.index - 1, 0) / progress.total) * 100}%` }}
/>
</span>
) : null}
</button>
);
}
if (!show || !status) return null;
const refLabel = status.remote_ref || "origin";
const behind = status.commits_behind ?? 0;
const release = status.latest_release || null;
const pluginManaged = status.situation === "plugin_managed";
return (
<div
className="fixed inset-0 z-[110] flex items-center justify-center p-4 bg-surface-0/70 backdrop-blur-sm animate-fade-in"
role="dialog"
aria-modal="true"
aria-labelledby="update-notifier-title"
onClick={(e) => {
if (e.target === e.currentTarget) closeForNow();
}}
>
{/* max-w-2xl, not lg: the footer carries up to four buttons and the
longest label is a whole sentence ("restarting the dashboard…"). */}
<div
ref={panelRef}
className="w-full max-w-2xl max-h-full flex flex-col card shadow-2xl animate-slide-up overflow-hidden"
>
{/* Header */}
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border">
<div className="flex items-center gap-3 min-w-0">
<div className="w-9 h-9 rounded-lg bg-accent-muted border border-accent/30 flex items-center justify-center flex-shrink-0">
<Download className="w-4 h-4 text-accent" aria-hidden />
</div>
<div className="min-w-0">
<h2
id="update-notifier-title"
className="text-sm font-semibold text-fg-primary truncate"
>
{installed
? t("installedTitle", { version: installed.version ?? "?" })
: release
? t("releaseTitle", { version: release })
: t("title")}
</h2>
<p className="text-[11px] text-fg-muted mt-0.5 font-mono truncate">
{/* Never print a commit count on a shallow clone — the number
is a graft artefact, not a distance. */}
{(release || installed) && !status.shallow_clone && status.local_version
? t("releaseSubtitle", { current: status.local_version })
: t("commitsBehind", { count: behind, ref: refLabel })}
</p>
</div>
</div>
<button
type="button"
onClick={closeForNow}
aria-label={t("later")}
className="p-1.5 -m-1 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors flex-shrink-0"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Body */}
<div className="min-h-0 overflow-y-auto px-5 py-4 space-y-3">
<p className="text-sm text-fg-secondary leading-relaxed">
{pluginManaged ? t("pluginAutoNote") : release ? t("releaseLead") : t("lead")}
</p>
{status.latest_release_url ? (
<a
href={status.latest_release_url}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1.5 text-xs text-accent hover:underline"
>
<ExternalLink className="w-3.5 h-3.5" aria-hidden />
{t("viewRelease")}
{status.latest_release_name ? ` — ${status.latest_release_name}` : null}
</a>
) : null}
{/* Release lookup failed and detection fell back to commit counting:
say so, so "up to date" is never read as "release checked". */}
{status.release_check && status.release_check !== "ok" ? (
<div className="text-xs text-fg-secondary bg-surface-2 border border-border rounded-lg px-3 py-2">
{t("releaseUnavailable", { reason: status.release_check })}
</div>
) : null}
{status.fetch_error ? (
<div className="text-xs text-status-warning/90 bg-status-warning/5 border border-status-warning/20 rounded-lg px-3 py-2">
{t("fetchError")}
</div>
) : null}
{!status.git_repo && status.install_kind !== "plugin" ? (
<div className="text-xs text-fg-secondary bg-surface-2 border border-border rounded-lg px-3 py-2">
{t("notGit")}
</div>
) : null}
{status.situation_note ? (
<div className="text-xs text-status-warning/90 bg-status-warning/5 border border-status-warning/20 rounded-lg px-3 py-2 leading-relaxed">
{status.situation_note}
</div>
) : null}
{status.manual_command ? (
<pre
className="bg-surface-1 border border-border rounded-lg px-3 py-2.5 text-[11px] font-mono text-fg-secondary whitespace-pre-wrap break-all leading-relaxed"
aria-label={t("commandLabel")}
>
{status.manual_command}
</pre>
) : null}
{/* The restart hint only applies when the printed command actually
* rewrites the working tree. Feature-branch / detached-HEAD commands
* are fetch-only - restarting the dashboard would change nothing. */}
{status.situation === "release_available" ||
status.situation === "tracking_canonical" ||
status.situation === "fork_or_diverged_tracking" ? (
<p className="text-[11px] text-fg-muted leading-relaxed">{t("restartNote")}</p>
) : null}
{restarting ? (
<div className="text-xs text-accent bg-accent-muted border border-accent/30 rounded-lg px-3 py-2 flex items-center gap-2">
<RefreshCw className="w-3.5 h-3.5 animate-spin flex-shrink-0" aria-hidden />
{/* The plugin chain pulls a new cache directory and reinstalls
dependencies before it restarts, so it takes minutes, not
seconds — saying "restarting" there would look hung. */}
<span>
{pluginManaged ? t("pluginUpdating") : t("restarting")}
{stepText ? (
<span className="block mt-0.5 font-medium" data-testid="update-step">
{stepText}
</span>
) : null}
</span>
</div>
) : null}
{/* A previous chain that failed: the only place the user would ever
learn why, since the server it would have reported from is the one
that never came back. */}
{status.last_plugin_update && status.last_plugin_update.ok === false ? (
<div className="text-xs text-status-danger bg-status-danger/5 border border-status-danger/20 rounded-lg px-3 py-2 leading-relaxed">
{t("lastPluginUpdateFailed", {
step: status.last_plugin_update.step,
error: status.last_plugin_update.error || "",
})}
</div>
) : null}
{error ? (
<p
className="text-xs text-status-danger whitespace-pre-wrap break-words max-h-48 overflow-auto"
role="alert"
>
{error}
</p>
) : null}
</div>
{/* Footer */}
{/* `whitespace-nowrap` inherits, so one class keeps every label on its
own single line; `flex-wrap` then breaks the ROW instead of the
words if a translation still outgrows the modal. */}
<div className="flex flex-wrap items-center justify-end gap-2 whitespace-nowrap px-5 py-3 border-t border-border bg-surface-2/40">
<button
type="button"
onClick={checkNow}
disabled={checking || applying || restarting}
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
>
<RefreshCw className={`w-3.5 h-3.5 ${checking ? "animate-spin" : ""}`} aria-hidden />
{checking ? t("checking") : t("checkNow")}
</button>
<button
type="button"
onClick={dismiss}
disabled={busy}
title={t("skipVersionHint")}
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
>
<SkipForward className="w-3.5 h-3.5" aria-hidden />
{t("skipVersion")}
</button>
{status.manual_command ? (
<button
type="button"
onClick={copyCmd}
disabled={copied}
className="btn-ghost disabled:opacity-70"
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
{copied ? t("copied") : t("copy")}
</button>
) : null}
{/* Only after a failed update: an AI agent takes the error from here. */}
{error ? (
<button
type="button"
onClick={autoFix}
disabled={busy}
title={t("autoFixHint")}
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
>
{fixing ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" aria-hidden />
) : (
<Wrench className="w-3.5 h-3.5" aria-hidden />
)}
{t("autoFix")}
</button>
) : null}
{isAutoApplicable(status.situation) ? (
<button
type="button"
onClick={applyNow}
disabled={busy}
className="btn-primary disabled:opacity-70"
>
{applying || restarting ? (
<RefreshCw className="w-4 h-4 animate-spin" aria-hidden />
) : (
<Zap className="w-4 h-4" aria-hidden />
)}
{/* `restartingShort`, not `restarting`: the full sentence is
already in the banner above, and a whole sentence on a button
is what forced this footer onto two lines. */}
{applying ? t("updating") : restarting ? t("restartingShort") : t("updateNow")}
</button>
) : null}
</div>
</div>
</div>
);
}