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 \ sg3-utils \
lsscsi \ lsscsi \
util-linux \ util-linux \
ledmon \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app

View File

@@ -353,11 +353,35 @@ function TableView({ enclosure, onSelect, selectedSerial, t }) {
} }
function DriveDetail({ slot, onClose, t }) { function DriveDetail({ slot, onClose, t }) {
const [ledLoading, setLedLoading] = useState(false);
const [ledState, setLedState] = useState(null); // "locate" | "off" | null
if (!slot) return null; if (!slot) return null;
const d = slot.drive || {}; const d = slot.drive || {};
const healthStatus = d.health_status || "healthy"; const healthStatus = d.health_status || "healthy";
const c = t.health[healthStatus] || t.health.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 = [ const smartFields = [
["SMART Status", d.smart_healthy === true ? "PASSED" : d.smart_healthy === false ? "FAILED" : "Unknown"], ["SMART Status", d.smart_healthy === true ? "PASSED" : d.smart_healthy === false ? "FAILED" : "Unknown"],
["Temperature", d.temperature_c != null ? `${d.temperature_c}\u00B0C` : "\u2014"], ["Temperature", d.temperature_c != null ? `${d.temperature_c}\u00B0C` : "\u2014"],
@@ -504,7 +528,40 @@ function DriveDetail({ slot, onClose, t }) {
})} })}
</div> </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 <button
onClick={onClose} onClick={onClose}
style={{ style={{

View File

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