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:
@@ -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} · {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} />
|
||||
|
||||
Reference in New Issue
Block a user