|
1 | 1 | import { useEffect, useState } from "react"; |
2 | 2 | import { formatUptime } from "../formatUptime"; |
3 | 3 | import { IconActivity } from "../icons"; |
4 | | -import { useI18n, type Locale } from "../i18n/shared"; |
| 4 | +import { useI18n, type Locale, type TFn } from "../i18n/shared"; |
5 | 5 | import { createBoundedFetch, type BoundedFetch } from "../bounded-fetch"; |
6 | 6 |
|
7 | 7 | /** |
@@ -123,11 +123,68 @@ function observedGrowthPerHour(samples: MemorySample[]): number | null { |
123 | 123 | } |
124 | 124 |
|
125 | 125 | /** One labelled monospace metric cell inside a stat-row. */ |
126 | | -function Stat({ label, value }: { label: string; value: string }) { |
| 126 | +function Stat({ label, value, sub, tone }: { label: string; value: string; sub?: string; tone?: "warn" | "danger" }) { |
127 | 127 | return ( |
128 | 128 | <div className="stat"> |
129 | 129 | <div className="label">{label}</div> |
130 | | - <div className="value mono">{value}</div> |
| 130 | + <div className={`value mono${tone ? ` value--${tone}` : ""}`}>{value}</div> |
| 131 | + {sub && <div className="stat-sub mono">{sub}</div>} |
| 132 | + </div> |
| 133 | + ); |
| 134 | +} |
| 135 | + |
| 136 | +/** |
| 137 | + * Headline pressure gauge: observed memory against the watchdog warn threshold. |
| 138 | + * The scalar cells below answer "what is allocated"; this answers "is that a |
| 139 | + * problem", which is the only question a glance at this panel should need. |
| 140 | + */ |
| 141 | +function MemoryPressure({ |
| 142 | + observedBytes, |
| 143 | + thresholdBytes, |
| 144 | + metric, |
| 145 | + locale, |
| 146 | + t, |
| 147 | +}: { |
| 148 | + observedBytes: number | null; |
| 149 | + thresholdBytes: number | null; |
| 150 | + metric: MemoryMetric | null; |
| 151 | + locale: Locale; |
| 152 | + t: TFn; |
| 153 | +}) { |
| 154 | + const ratio = |
| 155 | + observedBytes !== null && thresholdBytes !== null && thresholdBytes > 0 |
| 156 | + ? observedBytes / thresholdBytes |
| 157 | + : null; |
| 158 | + // Warn before the watchdog fires, not at the same instant: 75% is the point |
| 159 | + // where a still-climbing process is worth looking at. |
| 160 | + const tone = ratio === null ? "unknown" : ratio >= 1 ? "over" : ratio >= 0.75 ? "warn" : "ok"; |
| 161 | + const pct = ratio === null ? null : Math.round(ratio * 100); |
| 162 | + return ( |
| 163 | + <div className={`mem-pressure mem-pressure--${tone}`}> |
| 164 | + <div className="mem-pressure-head"> |
| 165 | + <span className="mem-pressure-label"> |
| 166 | + {t("dash.mem.pressure")} |
| 167 | + {metric ? <span className="mem-pressure-metric mono">{metric}</span> : null} |
| 168 | + </span> |
| 169 | + <span className="mem-pressure-figure mono"> |
| 170 | + {observedBytes === null ? "—" : formatBytes(observedBytes, locale)} |
| 171 | + {thresholdBytes !== null && ( |
| 172 | + <span className="mem-pressure-limit"> |
| 173 | + {" / "} |
| 174 | + {formatBytes(thresholdBytes, locale)} |
| 175 | + </span> |
| 176 | + )} |
| 177 | + </span> |
| 178 | + </div> |
| 179 | + <div className="mem-pressure-track" role="presentation"> |
| 180 | + <span |
| 181 | + className="mem-pressure-fill" |
| 182 | + style={{ ["--mem-scale" as string]: String(ratio === null ? 0 : Math.min(1, Math.max(0.01, ratio))) }} |
| 183 | + /> |
| 184 | + </div> |
| 185 | + <div className="mem-pressure-foot"> |
| 186 | + {pct === null ? t("dash.mem.pressureUnknown") : t("dash.mem.pressureOf", { pct })} |
| 187 | + </div> |
131 | 188 | </div> |
132 | 189 | ); |
133 | 190 | } |
@@ -322,25 +379,76 @@ export default function MemoryObservabilityCard({ apiBase }: { apiBase: string } |
322 | 379 | const growth = data?.watchdog ? observedGrowthPerHour(data.watchdog.samples) : null; |
323 | 380 | const observedBytes = data ? data.observedBytes ?? data.watchdog?.observedBytes ?? observedMemory(data) : null; |
324 | 381 | const observedBy = data ? observedMetric(data) : null; |
| 382 | + // Drift only matters relative to the headroom it eats. Flag it when the current |
| 383 | + // climb would reach the warn threshold within an hour (danger) or a shift (warn); |
| 384 | + // a flat or falling process stays neutral no matter how large it already is. |
| 385 | + const growthTone = ((): "warn" | "danger" | undefined => { |
| 386 | + const threshold = data?.watchdog?.warnThresholdBytes; |
| 387 | + if (growth === null || growth <= 0 || observedBytes === null || !threshold) return undefined; |
| 388 | + const headroom = threshold - observedBytes; |
| 389 | + if (headroom <= 0) return "danger"; |
| 390 | + const hoursLeft = headroom / growth; |
| 391 | + if (hoursLeft <= 1) return "danger"; |
| 392 | + if (hoursLeft <= 8) return "warn"; |
| 393 | + return undefined; |
| 394 | + })(); |
325 | 395 | // Optional on purpose: a 200 from an older proxy may lack the responseState field. |
326 | 396 | const responseState = data?.responseState; |
327 | 397 | const activeTurns = data?.activeTurnCount; |
328 | 398 | const busy = restartPhase === "draining" || restartPhase === "reconnecting"; |
329 | 399 |
|
330 | 400 | return ( |
331 | 401 | <div className="panel" style={{ marginBottom: 24 }}> |
332 | | - <div className="font-semibold" style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}> |
333 | | - <IconActivity width={16} height={16} aria-hidden="true" /> |
334 | | - {t("dash.mem.title")} |
| 402 | + {/* Title row carries the in-flight count and the restart action: as its own |
| 403 | + block below the stats it cost ~87px of near-empty panel height. */} |
| 404 | + <div className="mem-head"> |
| 405 | + <div className="font-semibold mem-head-title"> |
| 406 | + <IconActivity width={16} height={16} aria-hidden="true" /> |
| 407 | + {t("dash.mem.title")} |
| 408 | + </div> |
| 409 | + {supportsRestart && ( |
| 410 | + <div className="mem-head-actions"> |
| 411 | + <span className="mem-inflight"> |
| 412 | + <span className="mem-inflight-label">{t("dash.mem.inFlight")}</span> |
| 413 | + <span className="mem-inflight-value mono"> |
| 414 | + {typeof activeTurns === "number" ? plainNumberFormat(locale).format(activeTurns) : "—"} |
| 415 | + </span> |
| 416 | + </span> |
| 417 | + <button |
| 418 | + type="button" |
| 419 | + className="btn btn-ghost btn-sm" |
| 420 | + disabled={busy} |
| 421 | + onClick={confirmRestart} |
| 422 | + > |
| 423 | + {t("dash.mem.restart")} |
| 424 | + </button> |
| 425 | + </div> |
| 426 | + )} |
335 | 427 | </div> |
336 | 428 |
|
337 | | - <div className="stat-row"> |
| 429 | + <MemoryPressure |
| 430 | + observedBytes={observedBytes} |
| 431 | + thresholdBytes={data?.watchdog?.warnThresholdBytes ?? null} |
| 432 | + metric={observedBy} |
| 433 | + locale={locale} |
| 434 | + t={t} |
| 435 | + /> |
| 436 | + |
| 437 | + <div className="stat-row mem-stats"> |
338 | 438 | <Stat label={t("dash.mem.rss")} value={data ? formatBytes(data.rss, locale) : "—"} /> |
339 | | - <Stat label={t("dash.mem.jsHeap")} value={data ? `${formatBytes(data.heapUsed, locale)} / ${formatBytes(data.heapTotal, locale)}` : "—"} /> |
| 439 | + {/* heapUsed can exceed heapTotal on Bun (external buffers are counted in |
| 440 | + used but not in the JS-object arena), so a "used / total" label reads |
| 441 | + as a contradiction. Show the live figure and demote the arena size. */} |
| 442 | + <Stat |
| 443 | + label={t("dash.mem.jsHeap")} |
| 444 | + value={data ? formatBytes(data.heapUsed, locale) : "—"} |
| 445 | + sub={data ? t("dash.mem.jsHeapArena", { total: formatBytes(data.heapTotal, locale) }) : undefined} |
| 446 | + /> |
340 | 447 | <Stat label={t("dash.mem.jscHeap")} value={data?.jscHeap ? formatBytes(data.jscHeap.heapSize, locale) : "—"} /> |
341 | 448 | <Stat |
342 | 449 | label={t("dash.mem.growth")} |
343 | | - value={growth === null ? "—" : `${growth >= 0 ? "+" : "-"}${formatBytes(Math.abs(growth), locale)}${t("dash.mem.perHour")}`} |
| 450 | + value={growth === null ? "—" : `${growth >= 0 ? "+" : "−"}${formatBytes(Math.abs(growth), locale)}${t("dash.mem.perHour")}`} |
| 451 | + tone={growthTone} |
344 | 452 | /> |
345 | 453 | </div> |
346 | 454 |
|
@@ -380,20 +488,10 @@ export default function MemoryObservabilityCard({ apiBase }: { apiBase: string } |
380 | 488 | )} |
381 | 489 | </details> |
382 | 490 |
|
| 491 | + {/* Status line only: it stays out of the layout entirely while idle so the |
| 492 | + panel does not reserve a row for a message that is usually absent. */} |
383 | 493 | {supportsRestart && ( |
384 | | - <div style={{ marginTop: 14, display: "flex", flexWrap: "wrap", alignItems: "center", gap: 12 }} aria-live="polite"> |
385 | | - <Stat |
386 | | - label={t("dash.mem.inFlight")} |
387 | | - value={typeof activeTurns === "number" ? plainNumberFormat(locale).format(activeTurns) : "—"} |
388 | | - /> |
389 | | - <button |
390 | | - type="button" |
391 | | - className="btn btn-ghost btn-sm" |
392 | | - disabled={busy} |
393 | | - onClick={confirmRestart} |
394 | | - > |
395 | | - {t("dash.mem.restart")} |
396 | | - </button> |
| 494 | + <div className="mem-status" aria-live="polite"> |
397 | 495 | {restartPhase === "draining" && ( |
398 | 496 | <span className="muted text-control"> |
399 | 497 | {t("dash.mem.draining", { count: typeof activeTurns === "number" ? activeTurns : 0 })} |
|
0 commit comments