8e49d2b300
Adds POST /api/updates/apply (pull, rebuild, restart) plus an "Update now" button and hourly auto-check in the UI, for checkouts that are safely fast-forwardable. Reintroduces self-restart (previously removed in edc25ca for cross-environment reliability concerns) per explicit user request.
396 lines
15 KiB
TypeScript
396 lines
15 KiB
TypeScript
/**
|
|
* @file UpdateNotifier.tsx
|
|
* @description Modal surfaced when the dashboard's git checkout is behind its
|
|
* remote tracking branch. Shows how many commits behind, the exact terminal
|
|
* command to update with copy-to-clipboard, and — when the checkout is on a
|
|
* fast-forwardable branch — an "Update now" button that calls
|
|
* `POST /api/updates/apply` to pull, rebuild, and restart the server itself,
|
|
* then polls until it's back and reloads the page.
|
|
*
|
|
* ## State sources
|
|
* - Initial fetch via `api.updates.status()` on mount.
|
|
* - Background re-check via `api.updates.check()` every hour
|
|
* ({@link AUTO_CHECK_INTERVAL_MS}), plus the manual "Check now" button.
|
|
* - Live refresh from WebSocket `update_status` events on {@link eventBus}.
|
|
*
|
|
* ## Dismissal persistence
|
|
* Dismissals are keyed by `remote_sha` in `localStorage` so a new upstream
|
|
* commit re-opens the prompt. Settings can reset dismissal via the
|
|
* `dashboard:reset-update-dismissal` window event.
|
|
*
|
|
* @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.
|
|
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
|
*
|
|
* ## Related docs
|
|
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
|
* - `docs/API.md` — REST reference.
|
|
* - `.claude/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, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Download, X, Copy, Check, RefreshCw, Zap } from "lucide-react";
|
|
import { api } from "../lib/api";
|
|
import { eventBus } from "../lib/eventBus";
|
|
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
|
|
|
/** `localStorage` key storing the dismissed upstream SHA. */
|
|
const DISMISS_KEY = "agent-monitor-update-dismissed-sha";
|
|
|
|
/** How often to silently re-check for updates in the background. */
|
|
const AUTO_CHECK_INTERVAL_MS = 60 * 60 * 1000;
|
|
|
|
/** Situations `POST /api/updates/apply` will actually act on — mirrors the
|
|
* server-side check in `server/lib/update-check.js`'s `applyUpdate`. */
|
|
function isAutoApplicable(situation: UpdateStatusPayload["situation"]): boolean {
|
|
return situation === "tracking_canonical" || situation === "fork_or_diverged_tracking";
|
|
}
|
|
|
|
/** 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 upstream SHA from `localStorage`, or null. */
|
|
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 the current SHA was dismissed.
|
|
*/
|
|
export function UpdateNotifier() {
|
|
const { t } = useTranslation("updates");
|
|
const [status, setStatus] = useState<UpdateStatusPayload | null>(null);
|
|
const [dismissedSha, setDismissedSha] = useState<string | null>(loadDismissedSha);
|
|
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 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]);
|
|
|
|
// Background re-check every hour, on top of the initial mount fetch and the
|
|
// manual "Check now" button — so a long-lived tab notices an update without
|
|
// the user having to click anything.
|
|
useEffect(() => {
|
|
const id = setInterval(() => {
|
|
api.updates
|
|
.check()
|
|
.then(syncFromPayload)
|
|
.catch(() => {});
|
|
}, AUTO_CHECK_INTERVAL_MS);
|
|
return () => clearInterval(id);
|
|
}, [syncFromPayload]);
|
|
|
|
useEffect(() => {
|
|
const handler = () => setDismissedSha(null);
|
|
window.addEventListener("dashboard:reset-update-dismissal", handler);
|
|
return () => window.removeEventListener("dashboard:reset-update-dismissal", handler);
|
|
}, []);
|
|
|
|
const show = Boolean(
|
|
status?.update_available && status.remote_sha && dismissedSha !== status.remote_sha
|
|
);
|
|
|
|
const dismiss = useCallback(() => {
|
|
if (restarting || !status?.remote_sha) return;
|
|
try {
|
|
localStorage.setItem(DISMISS_KEY, status.remote_sha);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
setDismissedSha(status.remote_sha);
|
|
}, [restarting, status?.remote_sha]);
|
|
|
|
// Escape to dismiss - standard modal affordance.
|
|
useEffect(() => {
|
|
if (!show) return;
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") dismiss();
|
|
};
|
|
window.addEventListener("keydown", handler);
|
|
return () => window.removeEventListener("keydown", handler);
|
|
}, [show, dismiss]);
|
|
|
|
const copyCmd = async () => {
|
|
if (!status?.manual_command) return;
|
|
try {
|
|
await navigator.clipboard.writeText(status.manual_command);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
};
|
|
|
|
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.
|
|
const pollUntilBack = useCallback(() => {
|
|
const attempt = () => {
|
|
api.updates
|
|
.status()
|
|
.then(() => window.location.reload())
|
|
.catch(() => setTimeout(attempt, 1500));
|
|
};
|
|
setTimeout(attempt, 1500);
|
|
}, []);
|
|
|
|
const applyNow = async () => {
|
|
if (applying || restarting) return;
|
|
setError(null);
|
|
setApplying(true);
|
|
try {
|
|
const result = await api.updates.apply();
|
|
if (result.applied) {
|
|
setApplying(false);
|
|
setRestarting(true);
|
|
pollUntilBack();
|
|
return;
|
|
}
|
|
setError(
|
|
result.reason === "not_fast_forwardable" ? t("reasonNotFastForwardable") : t("applyError")
|
|
);
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : t("applyError"));
|
|
} finally {
|
|
setApplying(false);
|
|
}
|
|
};
|
|
|
|
if (!show || !status) return null;
|
|
|
|
const refLabel = status.remote_ref || "origin";
|
|
const behind = status.commits_behind ?? 0;
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="update-notifier-title"
|
|
onClick={(e) => {
|
|
if (e.target === e.currentTarget) dismiss();
|
|
}}
|
|
>
|
|
<div className="w-full max-w-lg 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"
|
|
>
|
|
{t("title")}
|
|
</h2>
|
|
<p className="text-[11px] text-fg-muted mt-0.5 font-mono truncate">
|
|
{t("commitsBehind", { count: behind, ref: refLabel })}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={dismiss}
|
|
aria-label={t("dismiss")}
|
|
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="px-5 py-4 space-y-3">
|
|
<p className="text-sm text-fg-secondary leading-relaxed">{t("lead")}</p>
|
|
|
|
{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 ? (
|
|
<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 === "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 />
|
|
{t("restarting")}
|
|
</div>
|
|
) : null}
|
|
|
|
{error ? (
|
|
<p className="text-xs text-status-danger" role="alert">
|
|
{error}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="flex items-center justify-end gap-2 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={restarting}
|
|
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
|
|
>
|
|
{t("dismiss")}
|
|
</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}
|
|
{isAutoApplicable(status.situation) ? (
|
|
<button
|
|
type="button"
|
|
onClick={applyNow}
|
|
disabled={applying || restarting}
|
|
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 />
|
|
)}
|
|
{applying ? t("updating") : restarting ? t("restarting") : t("updateNow")}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|