File size: 7,836 Bytes
6aa76ed | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 | 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>
);
}
|