Add ledctl locate/off LED controls to drive detail modal
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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={{
|
||||
|
||||
3
main.py
3
main.py
@@ -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
29
routers/leds.py
Normal 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
29
services/leds.py
Normal 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}
|
||||
Reference in New Issue
Block a user