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 \
|
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
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
3
main.py
3
main.py
@@ -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
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