Files
aiturk-hermes-ide/web/src/components/MemoryPressureBanner.tsx
T

187 lines
7.7 KiB
TypeScript

import { useState } from "react";
import { AlertTriangle, X } from "lucide-react";
import type { StatusResponse } from "@/lib/api";
import { useI18n } from "@/i18n";
/**
* App-wide warning banner for resource trouble (NS-656): memory pressure
* and disk exhaustion.
*
* Triggers, worst-first:
* 1. Disk critical — the HERMES_HOME volume is nearly full. Worst because
* the failure mode is silent data loss (SQLite writes failing, sessions
* and config not persisting), not just a restart (OOF-2/OOF-107).
* 2. Memory critical — the gateway's heartbeat shows system memory in the
* `critical` band right now.
* 3. Post-mortem — the previous gateway life died uncleanly and its last
* heartbeat showed near-exhausted memory (`last_boot_suspected_oom`).
* This is a heuristic, not proof the OOM killer acted — copy says so.
* 4. Disk elevated / 5. memory elevated — early warnings.
*
* All of this previously died in server-side log files; a hosted agent
* could be OOM-killed hourly or fill its disk completely while the
* dashboard looked healthy.
*
* Dismissal semantics (session-scoped, sessionStorage):
* - EVERY dismissal key embeds the reporting boot (`boot_id`), so a gateway
* restart invalidates all of them. Without this, dismissing `critical`,
* rebooting, and coming back still-critical would hide the NEW incident —
* and mask the OOM notice too, since critical takes precedence. Disk
* entries share the scheme: disk state has no boot relationship, but
* re-surfacing a still-full disk after a restart is the desired behavior.
* - Within one boot, dismissal masks only the dismissed trigger; escalation
* (elevated → critical, in either domain) re-opens immediately, and a
* confirmed recovery (pressure back to "ok", not "unknown") clears that
* domain's live dismissals so the NEXT episode in the same boot surfaces
* again.
*/
const STORAGE_KEY = "memoryBannerDismissed";
const MEMORY_LIVE_TRIGGERS = ["critical", "elevated"];
const DISK_LIVE_TRIGGERS = ["disk_critical", "disk_elevated"];
function readDismissed(): string[] {
try {
const parsed: unknown = JSON.parse(
sessionStorage.getItem(STORAGE_KEY) ?? "[]",
);
// Pre-incident-key builds stored a bare trigger string; JSON.parse
// throws on those, landing in the catch — a clean reset, not a crash.
return Array.isArray(parsed)
? parsed.filter((entry): entry is string => typeof entry === "string")
: [];
} catch {
return [];
}
}
function writeDismissed(entries: string[]) {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
} catch {
/* ignore */
}
}
function entryMatches(triggers: string[]) {
return (entry: string) =>
triggers.some((sev) => entry === sev || entry.startsWith(`${sev}:`));
}
export function MemoryPressureBanner({
status,
}: {
status: StatusResponse | null;
}) {
const { t } = useI18n();
const memory = status?.memory;
const disk = status?.disk;
const pressure = memory?.pressure;
const diskPressure = disk?.pressure;
const [dismissed, setDismissed] = useState<string[]>(readDismissed);
// Recovery reset (render-time state adjustment — the sanctioned React
// pattern for reacting to prop changes without an effect): once a
// domain's live pressure is demonstrably back to "ok", any dismissed
// live entries for that domain describe a PAST episode — drop them so
// the next one isn't silently hidden. "unknown" (stale/absent sample)
// is absence of evidence, not recovery, and clears nothing. Each domain
// recovers independently: a fixed disk must not un-dismiss a memory
// warning or vice versa. Cross-boot invalidation doesn't need handling
// here: boot_id is part of every dismissal key.
const [prevPressure, setPrevPressure] = useState(pressure);
const [prevDiskPressure, setPrevDiskPressure] = useState(diskPressure);
if (pressure !== prevPressure || diskPressure !== prevDiskPressure) {
setPrevPressure(pressure);
setPrevDiskPressure(diskPressure);
const recovered: Array<(entry: string) => boolean> = [];
if (pressure === "ok") recovered.push(entryMatches(MEMORY_LIVE_TRIGGERS));
if (diskPressure === "ok") recovered.push(entryMatches(DISK_LIVE_TRIGGERS));
if (recovered.length > 0) {
const isRecovered = (entry: string) =>
recovered.some((match) => match(entry));
if (dismissed.some(isRecovered)) {
const next = dismissed.filter((entry) => !isRecovered(entry));
writeDismissed(next);
setDismissed(next);
}
}
}
// Active triggers, worst-first. Disk critical outranks memory critical:
// imminent data loss beats imminent restart. Dismissal cascades — hiding
// the top trigger surfaces the next one rather than silencing everything.
const activeTriggers: string[] = [];
if (diskPressure === "critical") activeTriggers.push("disk_critical");
if (memory?.pressure === "critical") activeTriggers.push("critical");
if (memory?.last_boot_suspected_oom) activeTriggers.push("oom_restart");
if (diskPressure === "elevated") activeTriggers.push("disk_elevated");
if (memory?.pressure === "elevated") activeTriggers.push("elevated");
// Every dismissal is scoped to the reporting boot: `boot_id` changes on
// each gateway life, so restarts invalidate prior dismissals of ANY kind.
// A missing boot_id (degraded payload / pre-NS-656 image) degrades to a
// shared per-severity bucket — old behavior, never a crash.
const keyFor = (trig: string) => `${trig}:${memory?.boot_id ?? "unknown"}`;
const trigger =
activeTriggers.find((trig) => !dismissed.includes(keyFor(trig))) ?? null;
const dismissKey = trigger ? keyFor(trigger) : null;
if (!trigger || !dismissKey) return null;
const dismiss = () => {
setDismissed((prev) => {
const next = prev.includes(dismissKey) ? prev : [...prev, dismissKey];
writeDismissed(next);
return next;
});
};
const critical = trigger === "critical" || trigger === "disk_critical";
const diskFreeLabel =
disk?.free_mb != null ? ` (${Math.round(disk.free_mb)} MB free)` : "";
const message =
trigger === "disk_critical"
? `${
t.app.diskCriticalBanner ??
"Your agent's disk is almost full. New messages, memories, and settings may fail to save."
}${diskFreeLabel}`
: trigger === "disk_elevated"
? `${
t.app.diskElevatedBanner ??
"Your agent's disk is filling up. Consider clearing old sessions or expanding its storage."
}${diskFreeLabel}`
: trigger === "oom_restart"
? (t.app.memoryOomRestartBanner ??
"Your agent restarted unexpectedly, most likely because it ran out of memory. Long sessions and many concurrent tasks increase memory use.")
: critical
? (t.app.memoryCriticalBanner ??
"Your agent is almost out of memory and may restart. Consider closing idle sessions or upgrading its memory.")
: (t.app.memoryElevatedBanner ??
"Your agent is running low on memory.");
return (
<div
role="alert"
data-testid="memory-pressure-banner"
className={`flex items-center gap-2 border-b px-4 py-1.5 text-xs ${
critical
? "border-red-500/40 bg-red-500/10 text-red-300"
: "border-amber-500/40 bg-amber-500/10 text-amber-300"
}`}
>
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1">{message}</span>
<button
type="button"
aria-label={t.app.dismiss ?? "Dismiss"}
onClick={dismiss}
className="shrink-0 opacity-70 hover:opacity-100"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
);
}