Add ledctl locate/off LED controls to drive detail modal

This commit is contained in:
2026-03-07 04:57:35 +00:00
parent 51e6b49830
commit 927a5ccf3a
5 changed files with 119 additions and 2 deletions

View File

@@ -15,6 +15,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
sg3-utils \
lsscsi \
util-linux \
ledmon \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app

View File

@@ -353,11 +353,35 @@ function TableView({ enclosure, onSelect, selectedSerial, t }) {
}
function DriveDetail({ slot, onClose, t }) {
const [ledLoading, setLedLoading] = useState(false);
const [ledState, setLedState] = useState(null); // "locate" | "off" | null
if (!slot) return null;
const d = slot.drive || {};
const healthStatus = d.health_status || "healthy";
const c = t.health[healthStatus] || t.health.healthy;
const setLed = async (state) => {
setLedLoading(true);
try {
const res = await fetch(`${API_BASE}/api/drives/${slot.device}/led`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${res.status}`);
}
setLedState(state);
} catch (err) {
console.error("LED control failed:", err);
setLedState(null);
} finally {
setLedLoading(false);
}
};
const smartFields = [
["SMART Status", d.smart_healthy === true ? "PASSED" : d.smart_healthy === false ? "FAILED" : "Unknown"],
["Temperature", d.temperature_c != null ? `${d.temperature_c}\u00B0C` : "\u2014"],
@@ -504,7 +528,40 @@ function DriveDetail({ slot, onClose, t }) {
})}
</div>
<div style={{ padding: "12px 24px 20px", textAlign: "right" }}>
<div style={{ padding: "12px 24px 20px", display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<button
onClick={() => setLed("locate")}
disabled={ledLoading}
style={{
background: ledState === "locate" ? t.health.warning.bg : t.btnBg,
border: `1px solid ${ledState === "locate" ? t.health.warning.border : t.cardBorder}`,
borderRadius: 10, padding: "8px 16px", fontSize: 13, fontWeight: 600,
cursor: ledLoading ? "wait" : "pointer",
color: ledState === "locate" ? t.health.warning.text : t.textSecondary,
transition: "all 0.15s", opacity: ledLoading ? 0.6 : 1,
}}
onMouseEnter={(e) => { if (!ledLoading) e.currentTarget.style.background = ledState === "locate" ? t.health.warning.bg : t.btnHover; }}
onMouseLeave={(e) => { e.currentTarget.style.background = ledState === "locate" ? t.health.warning.bg : t.btnBg; }}
>
{ledLoading && ledState !== "locate" ? "..." : "Locate"}
</button>
<button
onClick={() => setLed("off")}
disabled={ledLoading}
style={{
background: t.btnBg, border: `1px solid ${t.cardBorder}`,
borderRadius: 10, padding: "8px 16px", fontSize: 13, fontWeight: 600,
cursor: ledLoading ? "wait" : "pointer",
color: t.textSecondary, transition: "all 0.15s",
opacity: ledLoading ? 0.6 : 1,
}}
onMouseEnter={(e) => { if (!ledLoading) e.currentTarget.style.background = t.btnHover; }}
onMouseLeave={(e) => { e.currentTarget.style.background = t.btnBg; }}
>
{ledLoading && ledState === "locate" ? "..." : "LED Off"}
</button>
</div>
<button
onClick={onClose}
style={{

View File

@@ -8,7 +8,7 @@ from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from models.schemas import HealthCheck
from routers import drives, enclosures, overview
from routers import drives, enclosures, leds, overview
from services.smart import sg_ses_available, smartctl_available
logging.basicConfig(
@@ -32,6 +32,7 @@ app.add_middleware(
app.include_router(enclosures.router)
app.include_router(drives.router)
app.include_router(leds.router)
app.include_router(overview.router)
_tool_status: dict[str, bool] = {}

29
routers/leds.py Normal file
View File

@@ -0,0 +1,29 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, field_validator
from services.leds import set_led
router = APIRouter(prefix="/api/drives", tags=["leds"])
class LedRequest(BaseModel):
state: str
@field_validator("state")
@classmethod
def validate_state(cls, v: str) -> str:
if v not in ("locate", "off"):
raise ValueError("state must be 'locate' or 'off'")
return v
@router.post("/{device}/led")
async def set_drive_led(device: str, body: LedRequest):
"""Set the locate LED for a drive."""
try:
result = await set_led(device, body.state)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e:
raise HTTPException(status_code=502, detail=str(e))
return result

29
services/leds.py Normal file
View File

@@ -0,0 +1,29 @@
import asyncio
import re
async def set_led(device: str, state: str) -> dict:
"""Set the locate LED on a drive via ledctl.
Args:
device: Block device name (e.g. "sda"). Must be alphanumeric.
state: "locate" to turn on the locate LED, "off" to turn it off.
"""
if not re.fullmatch(r"[a-zA-Z0-9]+", device):
raise ValueError(f"Invalid device name: {device}")
if state not in ("locate", "off"):
raise ValueError(f"Invalid state: {state}")
proc = await asyncio.create_subprocess_exec(
"ledctl",
f"{state}=/dev/{device}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
err = stderr.decode().strip() or stdout.decode().strip()
raise RuntimeError(f"ledctl failed (rc={proc.returncode}): {err}")
return {"device": device, "state": state}