Rearchitect so a single poller process is the sole owner of all SAS/SMART/ SES hardware I/O, serialized behind one gate and paced, writing results to Redis. The API/web and MQTT publisher become pure Redis readers — they no longer issue any subprocess and can restart freely without touching the bus. This addresses backplane/expander stress from concurrent + restart-triggered SMART/SES storms (the prior model re-ran a hardware sweep on every container start, and polled sg_ses 0x02+0x07 every 60s; 0x07 errored on the IOM6/ Xyratex expanders). - poller.py: paced sweep (inventory, SMART per-drive w/ gap, SES 0x02, ZFS, host/MegaRAID), startup jitter, single-instance Redis lock, LED-queue worker - services/hwgate.py: global serialization semaphore (POLL_CONCURRENCY=1) - services/store.py: Redis as the only producer<->consumer interface + LED queue - services/health.py: shared drive-health classifier (fixes overview double-count) - gate smartctl/sg_ses/zpool/ledctl; drop sg_ses 0x07 from the hot path - routers + temps read-only from the store; main.py drops the in-process poller - compose: 3 services (privileged poller + unprivileged app + redis) w/ pacing knobs
106 lines
3.1 KiB
Python
106 lines
3.1 KiB
Python
import asyncio
|
|
import logging
|
|
import os
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
|
|
from fastapi import FastAPI
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
from models.schemas import HealthCheck
|
|
from routers import drives, enclosures, leds, overview, temps
|
|
from services import store
|
|
from services.cache import close_cache, init_cache, redis_available
|
|
from services.mqtt_publisher import MqttPublisher, _PAHO_AVAILABLE, mqtt_enabled
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_tool_status: dict[str, bool] = {}
|
|
_mqtt_task: asyncio.Task | None = None
|
|
_mqtt_publisher: MqttPublisher | None = None
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
"""API/web + MQTT consumer. Reads everything from Redis; the dedicated
|
|
poller process is the only thing that touches hardware."""
|
|
global _mqtt_task, _mqtt_publisher
|
|
|
|
await init_cache()
|
|
_tool_status["redis"] = redis_available()
|
|
|
|
# Optional MQTT publisher for Home Assistant (opt-in via MQTT_HOST).
|
|
_tool_status["mqtt"] = False
|
|
if mqtt_enabled():
|
|
if not _PAHO_AVAILABLE:
|
|
logger.warning("MQTT_HOST set but paho-mqtt not installed — MQTT disabled")
|
|
else:
|
|
try:
|
|
_mqtt_publisher = MqttPublisher()
|
|
_mqtt_publisher.start()
|
|
_mqtt_task = asyncio.create_task(_mqtt_publisher.run())
|
|
_tool_status["mqtt"] = True
|
|
except Exception as e:
|
|
logger.error("Failed to start MQTT publisher: %s", e)
|
|
|
|
yield
|
|
|
|
if _mqtt_task is not None:
|
|
_mqtt_task.cancel()
|
|
try:
|
|
await _mqtt_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
if _mqtt_publisher is not None:
|
|
_mqtt_publisher.stop()
|
|
await close_cache()
|
|
|
|
|
|
app = FastAPI(
|
|
title="JBOD Monitor",
|
|
description="Drive health monitoring for JBOD enclosures",
|
|
version="0.2.0",
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
app.include_router(enclosures.router)
|
|
app.include_router(drives.router)
|
|
app.include_router(leds.router)
|
|
app.include_router(overview.router)
|
|
app.include_router(temps.router)
|
|
|
|
|
|
@app.get("/api/health", response_model=HealthCheck, tags=["health"])
|
|
async def health():
|
|
tools = dict(_tool_status)
|
|
tools["redis"] = redis_available()
|
|
|
|
# Poller freshness: is the producer keeping the store warm?
|
|
meta = await store.get_meta()
|
|
if meta and meta.get("last_sweep_ts"):
|
|
age = store.now() - meta["last_sweep_ts"]
|
|
tools["poller_fresh"] = age < meta.get("sweep_interval", 300) * 3
|
|
else:
|
|
tools["poller_fresh"] = False
|
|
|
|
return HealthCheck(status="ok", tools=tools)
|
|
|
|
|
|
# Serve built frontend static files — mounted last so /api routes take priority.
|
|
STATIC_DIR = Path(__file__).parent / "static"
|
|
|
|
if STATIC_DIR.exists():
|
|
app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
|