Add enclosure health details (PSUs, fans, temps, voltages) via SES

Parse sg_ses --page=0x02 output to surface enclosure-level health data
including power supply status, fan RPMs, temperature sensors, and voltage
rails. Failed/critical components are reflected in the overview totals
and shown as status pills in the enclosure card header with an expandable
detail panel.
This commit is contained in:
2026-03-07 06:03:26 +00:00
parent 8ea8fdef08
commit 0112875894
4 changed files with 379 additions and 4 deletions

View File

@@ -709,7 +709,181 @@ function HostDrivesCard({ drives, onSelect, t }) {
);
}
function EnclosureHealthSummary({ health, t }) {
if (!health) return null;
const statusColors = {
CRITICAL: t.health.error,
WARNING: t.health.warning,
OK: t.health.healthy,
};
const sc = statusColors[health.overall_status] || statusColors.OK;
const failedPsus = health.psus.filter((p) => p.fail || p.status.toLowerCase() === "critical");
const failedFans = health.fans.filter((f) => f.fail);
const temps = health.temps.filter((s) => s.temperature_c != null);
const tempMin = temps.length > 0 ? Math.min(...temps.map((s) => s.temperature_c)) : null;
const tempMax = temps.length > 0 ? Math.max(...temps.map((s) => s.temperature_c)) : null;
return (
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap", marginTop: 6 }}>
{/* Overall badge */}
<span style={{
display: "inline-flex", alignItems: "center", gap: 5,
padding: "2px 10px", borderRadius: 99,
background: sc.bg, border: `1px solid ${sc.border}`,
fontSize: 11, fontWeight: 700, color: sc.text, letterSpacing: 0.3,
}}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: sc.dot }} />
{health.overall_status}
</span>
{/* PSU pills */}
{health.psus.map((psu) => {
const bad = psu.fail || psu.status.toLowerCase() === "critical";
const pc = bad ? t.health.error : t.health.healthy;
return (
<span key={psu.index} style={{
display: "inline-flex", alignItems: "center", gap: 4,
padding: "2px 8px", borderRadius: 99,
background: pc.bg, border: `1px solid ${pc.border}`,
fontSize: 10, fontWeight: 600, color: pc.text,
}}>
<span style={{ width: 5, height: 5, borderRadius: "50%", background: pc.dot }} />
PSU {psu.index} {bad ? "FAIL" : "OK"}
</span>
);
})}
{/* Fans summary */}
{health.fans.length > 0 && (
<span style={{
fontSize: 11, color: failedFans.length > 0 ? t.health.error.text : t.textSecondary,
fontWeight: 600,
}}>
{failedFans.length > 0
? `${failedFans.length}/${health.fans.length} fans failed`
: `${health.fans.length} fans OK`}
</span>
)}
{/* Temp range */}
{tempMin != null && (
<span style={{
fontSize: 11, color: tempMax >= 45 ? t.health.warning.text : t.textSecondary,
fontWeight: 600, fontFamily: "'JetBrains Mono', monospace",
}}>
{tempMin === tempMax ? `${tempMin}\u00B0C` : `${tempMin}\u2013${tempMax}\u00B0C`}
</span>
)}
</div>
);
}
function EnclosureHealthDetail({ health, t }) {
if (!health) return null;
const sectionStyle = { marginBottom: 12 };
const headerStyle = {
fontSize: 10, fontWeight: 700, color: t.textMuted,
textTransform: "uppercase", letterSpacing: 1, marginBottom: 6,
};
const rowStyle = {
display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "4px 0", borderBottom: `1px solid ${t.divider}`, fontSize: 12,
};
return (
<div style={{
padding: "12px 16px", background: t.surface,
borderTop: `1px solid ${t.divider}`, borderBottom: `1px solid ${t.divider}`,
}}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 16 }}>
{/* PSUs */}
{health.psus.length > 0 && (
<div style={sectionStyle}>
<div style={headerStyle}>Power Supplies</div>
{health.psus.map((psu) => {
const bad = psu.fail || psu.status.toLowerCase() === "critical";
return (
<div key={psu.index} style={rowStyle}>
<span style={{ color: t.textSecondary }}>PSU {psu.index}</span>
<span style={{
fontWeight: 600, color: bad ? t.health.error.text : t.health.healthy.text,
fontFamily: "'JetBrains Mono', monospace",
}}>
{psu.status}{psu.ac_fail ? " (AC fail)" : ""}{psu.dc_fail ? " (DC fail)" : ""}
</span>
</div>
);
})}
</div>
)}
{/* Fans */}
{health.fans.length > 0 && (
<div style={sectionStyle}>
<div style={headerStyle}>Fans</div>
{health.fans.map((fan) => (
<div key={fan.index} style={rowStyle}>
<span style={{ color: t.textSecondary }}>Fan {fan.index}</span>
<span style={{
fontWeight: 600,
color: fan.fail ? t.health.error.text : t.health.healthy.text,
fontFamily: "'JetBrains Mono', monospace",
}}>
{fan.rpm != null ? `${fan.rpm} RPM` : fan.status}
{fan.fail ? " FAIL" : ""}
</span>
</div>
))}
</div>
)}
{/* Temps */}
{health.temps.length > 0 && (
<div style={sectionStyle}>
<div style={headerStyle}>Temperature Sensors</div>
{health.temps.map((ts) => (
<div key={ts.index} style={rowStyle}>
<span style={{ color: t.textSecondary }}>Sensor {ts.index}</span>
<span style={{
fontWeight: 600,
color: ts.temperature_c >= 45 ? t.health.warning.text : t.text,
fontFamily: "'JetBrains Mono', monospace",
}}>
{ts.temperature_c != null ? `${ts.temperature_c}\u00B0C` : ts.status}
</span>
</div>
))}
</div>
)}
{/* Voltages */}
{health.voltages.length > 0 && (
<div style={sectionStyle}>
<div style={headerStyle}>Voltage Rails</div>
{health.voltages.map((vs) => (
<div key={vs.index} style={rowStyle}>
<span style={{ color: t.textSecondary }}>Rail {vs.index}</span>
<span style={{
fontWeight: 600, color: t.text,
fontFamily: "'JetBrains Mono', monospace",
}}>
{vs.voltage != null ? `${vs.voltage} V` : vs.status}
</span>
</div>
))}
</div>
)}
</div>
</div>
);
}
function EnclosureCard({ enclosure, view, onSelect, selectedSerial, t }) {
const [healthExpanded, setHealthExpanded] = useState(false);
return (
<div style={{
background: t.cardBg, borderRadius: 16,
@@ -720,7 +894,7 @@ function EnclosureCard({ enclosure, view, onSelect, selectedSerial, t }) {
<div style={{
padding: "16px 20px",
borderBottom: `1px solid ${t.divider}`,
display: "flex", alignItems: "center", justifyContent: "space-between",
display: "flex", alignItems: "flex-start", justifyContent: "space-between",
flexWrap: "wrap", gap: 8,
}}>
<div>
@@ -730,11 +904,29 @@ function EnclosureCard({ enclosure, view, onSelect, selectedSerial, t }) {
<div style={{ fontSize: 12, color: t.textSecondary, marginTop: 2 }}>
{enclosure.sg_device} &middot; {enclosure.populated_slots}/{enclosure.total_slots} slots populated
</div>
{enclosure.health && (
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<EnclosureHealthSummary health={enclosure.health} t={t} />
<button
onClick={() => setHealthExpanded(!healthExpanded)}
style={{
background: "none", border: "none", cursor: "pointer",
fontSize: 11, color: t.accent, fontWeight: 600,
padding: "2px 6px", marginTop: 6,
}}
>
{healthExpanded ? "Hide details" : "Details"}
</button>
</div>
)}
</div>
<div style={{ fontSize: 11, color: t.textMuted, fontFamily: "'JetBrains Mono', monospace" }}>
ID {enclosure.id}
</div>
</div>
{healthExpanded && enclosure.health && (
<EnclosureHealthDetail health={enclosure.health} t={t} />
)}
<div style={{ padding: 16 }}>
{view === "grid" ? (
<GridView enclosure={enclosure} onSelect={onSelect} t={t} />