singularity-forge/web/components/sf/update-banner.tsx
Mikael Hugo 2d34d3a386 fix(web): resolve ESLint regressions from eslint-config-next upgrade
- Escape unescaped entities (react/no-unescaped-entities) in step-remote,
  step-welcome, projects-view, settings-panels
- Add targeted eslint-disable-next-line for react-hooks/set-state-in-effect
  on established async-fetch and prop-sync patterns in useEffect bodies:
  chat-mode, file-content-viewer, files-view, step-dev-root, projects-view,
  settings-panels, update-banner, visualizer-view, carousel, use-mobile
- Add targeted eslint-disable-next-line for react-hooks/purity on Date.now()
  display timestamps in streaming chat messages (chat-mode)
- Remove now-unused eslint-disable directives (projects-view, settings-panels)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-10 12:18:58 +02:00

199 lines
5.2 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { authFetch } from "@/lib/auth";
import { cn } from "@/lib/utils";
interface UpdateInfo {
currentVersion: string;
latestVersion: string;
updateAvailable: boolean;
updateStatus: string;
targetVersion?: string;
error?: string;
}
const POLL_INTERVAL = 3000;
export function UpdateBanner() {
const [info, setInfo] = useState<UpdateInfo | null>(null);
const [triggering, setTriggering] = useState(false);
const [dismissed, setDismissed] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fetchStatus = useCallback(async () => {
try {
const res = await authFetch("/api/update");
if (!res.ok) return;
const data: UpdateInfo = await res.json();
setInfo(data);
} catch {
// Network error — silently ignore, banner stays in last known state
}
}, []);
// Initial fetch on mount
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- async fetch, setState runs after await
void fetchStatus();
}, [fetchStatus]);
// Polling while update is running
useEffect(() => {
if (info?.updateStatus === "running") {
intervalRef.current = setInterval(
() => void fetchStatus(),
POLL_INTERVAL,
);
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [info?.updateStatus, fetchStatus]);
const handleUpdate = async () => {
setTriggering(true);
try {
const res = await authFetch("/api/update", { method: "POST" });
if (res.ok || res.status === 202) {
// Immediately poll to pick up the "running" status
await fetchStatus();
} else if (res.status === 409) {
// Already running — just refresh status
await fetchStatus();
}
} catch {
// Network error during trigger
} finally {
setTriggering(false);
}
};
// Don't render until we have data, or if no update is available and status is idle
if (!info) return null;
if (!info.updateAvailable && info.updateStatus === "idle") return null;
if (dismissed) return null;
const isRunning = info.updateStatus === "running";
const isSuccess = info.updateStatus === "success";
const isError = info.updateStatus === "error";
const targetLabel = info.targetVersion ?? info.latestVersion;
return (
<div
data-testid="update-banner"
className={cn(
"flex items-center gap-3 border-b px-4 py-2 text-xs",
isSuccess && "border-success/20 bg-success/10 text-success",
isError && "border-destructive/20 bg-destructive/10 text-destructive",
!isSuccess &&
!isError &&
"border-warning/20 bg-warning/10 text-warning",
)}
>
{isSuccess ? (
<span className="flex-1" data-testid="update-banner-message">
Update complete restart SF to use v{targetLabel}
</span>
) : isError ? (
<>
<span className="flex-1" data-testid="update-banner-message">
Update failed{info.error ? `: ${info.error}` : ""}
</span>
<button
type="button"
onClick={() => void handleUpdate()}
disabled={triggering}
className={cn(
"flex-shrink-0 rounded border border-destructive/30 bg-background px-2 py-0.5 text-xs font-medium text-destructive transition-colors hover:bg-destructive/10",
triggering && "cursor-not-allowed opacity-50",
)}
data-testid="update-banner-retry"
>
Retry
</button>
</>
) : (
<>
<span className="flex-1" data-testid="update-banner-message">
{isRunning ? (
<span className="flex items-center gap-2">
<Spinner />
Updating to v{targetLabel}
</span>
) : (
<>
Update available: v{info.currentVersion} v{info.latestVersion}
</>
)}
</span>
{!isRunning && (
<button
type="button"
onClick={() => void handleUpdate()}
disabled={triggering}
className={cn(
"flex-shrink-0 rounded border border-warning/30 bg-background px-2 py-0.5 text-xs font-medium text-warning transition-colors hover:bg-warning/10",
triggering && "cursor-not-allowed opacity-50",
)}
data-testid="update-banner-action"
>
Update
</button>
)}
</>
)}
<button
type="button"
onClick={() => setDismissed(true)}
aria-label="Dismiss update banner"
className="flex-shrink-0 rounded p-0.5 opacity-50 transition-opacity hover:opacity-100"
data-testid="update-banner-dismiss"
>
<svg
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
className="h-3.5 w-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
);
}
function Spinner() {
return (
<svg
aria-hidden="true"
className="h-3 w-3 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
);
}