fix: fan settings persistence, profile indicator, and point removal
Address three fan-settings issues: 1. Profile view: show a fan icon next to a profile's name when it has a saved fan curve, so it's clear which profiles carry custom fans. 2. Fan curve persistence: the active fan curve was in-memory only and lost on every server restart. It is now persisted per-GPU in /etc/nvcurve/config.json (fan_curves) and re-applied at server startup, so an applied curve survives restarts. All fan-curve state changes route through _activate_fan_curve/_deactivate_fan_curve helpers that keep the persisted state in sync (apply, reset, and profile apply). 3. Point removal: the fan-curve remove button was nearly invisible. The chart remove control is now always faintly visible with an X glyph, the table remove button is larger with a tooltip, and a hint line explains how to add/remove points. Also includes a formatting pass over the two edited frontend files.
This commit is contained in:
1 parent
9cf2009d2c
commit
d9260007d1
5 files changed
+407
-167
No files matched your search
+96
-42
@@ -237,6 +237,58 @@ async def _fan_poller(gpu_index: int) -> None:
|
||||
await asyncio.sleep(2.0)
|
||||
|
||||
|
||||
async def _activate_fan_curve(gpu_index: int, curve: list) -> None:
|
||||
"""Set the active fan curve, (re)start the poller, and persist it.
|
||||
|
||||
Persistence (config.json) is what makes the curve survive server restarts:
|
||||
fan control is volatile, so the driver reverts to automatic mode on reboot
|
||||
and the saved curve is re-applied at the next server start.
|
||||
"""
|
||||
g_state = _get_gpu_state(gpu_index)
|
||||
|
||||
# Stop existing poller if running
|
||||
if g_state.get("fan_poller_task"):
|
||||
g_state["fan_poller_task"].cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await g_state["fan_poller_task"]
|
||||
|
||||
g_state["fan_curve"] = curve
|
||||
g_state["fan_curve_active"] = True
|
||||
g_state["fan_poller_task"] = asyncio.create_task(_fan_poller(gpu_index))
|
||||
|
||||
cfg: Config = _state["config"]
|
||||
cfg.fan_curves[_gpu_stable_key(gpu_index)] = curve
|
||||
_persist_fan_curves(cfg.fan_curves)
|
||||
|
||||
|
||||
async def _deactivate_fan_curve(gpu_index: int, reset_hardware: bool = True) -> None:
|
||||
"""Clear the active fan curve, stop the poller, and clear its persistence.
|
||||
|
||||
When reset_hardware is True the GPU is returned to automatic fan control.
|
||||
"""
|
||||
g_state = _get_gpu_state(gpu_index)
|
||||
|
||||
if g_state.get("fan_poller_task"):
|
||||
g_state["fan_poller_task"].cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await g_state["fan_poller_task"]
|
||||
g_state["fan_poller_task"] = None
|
||||
|
||||
g_state["fan_curve"] = None
|
||||
g_state["fan_curve_active"] = False
|
||||
|
||||
if reset_hardware:
|
||||
ok, msg = await _run(reset_fan, gpu_index)
|
||||
if not ok:
|
||||
log.warning("Fan reset warning: %s", msg)
|
||||
|
||||
cfg: Config = _state["config"]
|
||||
key = _gpu_stable_key(gpu_index)
|
||||
if key in cfg.fan_curves:
|
||||
del cfg.fan_curves[key]
|
||||
_persist_fan_curves(cfg.fan_curves)
|
||||
|
||||
|
||||
# ── Lifespan ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -326,6 +378,35 @@ async def lifespan(app: FastAPI):
|
||||
)
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# ── Restore persisted fan curves ──────────────────────────────────────────
|
||||
# Fan control is volatile: the driver reverts to automatic mode on reboot,
|
||||
# so a curve applied via the UI is persisted in config.json and re-applied
|
||||
# here at startup. Runs after the auto-load profile path so the user's
|
||||
# explicit fan curve setting takes precedence.
|
||||
for gpu_index, g_state in _state["gpus"].items():
|
||||
if g_state["gpu"] is None:
|
||||
continue
|
||||
if g_state.get("fan_curve_active"):
|
||||
continue # already activated by the auto-load profile path
|
||||
key = _gpu_stable_key(gpu_index)
|
||||
curve = cfg.fan_curves.get(key)
|
||||
if not curve:
|
||||
continue
|
||||
ok, msg = validate_curve(curve)
|
||||
if not ok:
|
||||
log.warning("Skipping persisted fan curve for GPU %d: %s", gpu_index, msg)
|
||||
continue
|
||||
log.info("Restoring persisted fan curve on GPU %d (%s)", gpu_index, key)
|
||||
try:
|
||||
await _activate_fan_curve(gpu_index, curve)
|
||||
except Exception as exc:
|
||||
log.warning(
|
||||
"Failed to restore persisted fan curve on GPU %d: %s",
|
||||
gpu_index,
|
||||
exc,
|
||||
)
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
yield # server is running
|
||||
|
||||
for task in poller_tasks:
|
||||
@@ -745,6 +826,11 @@ def _persist_auto_load_profiles(profiles: dict[str, str]) -> None:
|
||||
_persist_config_field("auto_load_profiles", profiles if profiles else None)
|
||||
|
||||
|
||||
def _persist_fan_curves(fan_curves: dict) -> None:
|
||||
"""Persist the per-GPU active fan curves dict to config.json."""
|
||||
_persist_config_field("fan_curves", fan_curves if fan_curves else None)
|
||||
|
||||
|
||||
@app.get("/api/profiles")
|
||||
async def api_profiles(gpu_index: int = 0):
|
||||
"""List saved native profiles, the active profile name, and the auto-load profile name."""
|
||||
@@ -931,31 +1017,16 @@ async def _apply_profile(name: str, gpu_index: int = 0) -> list[str]:
|
||||
|
||||
await _update_offsets_and_broadcast(gpu_index)
|
||||
|
||||
# Apply fan curve if present in profile
|
||||
# Apply fan curve if present in profile (persists it so it survives restarts);
|
||||
# otherwise deactivate any active fan curve (and clear its persistence).
|
||||
if profile.fan_curve:
|
||||
ok, msg = validate_curve(profile.fan_curve)
|
||||
if not ok:
|
||||
errs.append(f"Fan curve: {msg}")
|
||||
else:
|
||||
# Stop existing fan poller if running
|
||||
if g_state.get("fan_poller_task"):
|
||||
g_state["fan_poller_task"].cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await g_state["fan_poller_task"]
|
||||
|
||||
g_state["fan_curve"] = profile.fan_curve
|
||||
g_state["fan_curve_active"] = True
|
||||
g_state["fan_poller_task"] = asyncio.create_task(_fan_poller(gpu_index))
|
||||
await _activate_fan_curve(gpu_index, profile.fan_curve)
|
||||
elif g_state.get("fan_curve_active"):
|
||||
# Profile has no fan curve, deactivate any active fan curve
|
||||
if g_state.get("fan_poller_task"):
|
||||
g_state["fan_poller_task"].cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await g_state["fan_poller_task"]
|
||||
g_state["fan_poller_task"] = None
|
||||
g_state["fan_curve"] = None
|
||||
g_state["fan_curve_active"] = False
|
||||
await _run(reset_fan, gpu_index)
|
||||
await _deactivate_fan_curve(gpu_index, reset_hardware=True)
|
||||
|
||||
if not errs:
|
||||
g_state["active_profile"] = name
|
||||
@@ -1196,15 +1267,8 @@ async def api_fans_update(req: FanCurveRequest, gpu_index: int = 0):
|
||||
status_code=500, detail=f"Fan control not available: {fan_msg}"
|
||||
)
|
||||
|
||||
# Stop existing poller if running
|
||||
if g_state.get("fan_poller_task"):
|
||||
g_state["fan_poller_task"].cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await g_state["fan_poller_task"]
|
||||
|
||||
g_state["fan_curve"] = curve_data
|
||||
g_state["fan_curve_active"] = True
|
||||
g_state["fan_poller_task"] = asyncio.create_task(_fan_poller(gpu_index))
|
||||
# Activate the curve (starts the poller) and persist it so it survives restarts.
|
||||
await _activate_fan_curve(gpu_index, curve_data)
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
@@ -1212,21 +1276,11 @@ async def api_fans_update(req: FanCurveRequest, gpu_index: int = 0):
|
||||
@app.post("/api/fans/reset")
|
||||
async def api_fans_reset(gpu_index: int = 0):
|
||||
"""Deactivate fan curve control and restore automatic fan mode."""
|
||||
g_state = _get_gpu_state(gpu_index)
|
||||
_get_gpu_state(gpu_index)
|
||||
|
||||
# Stop poller
|
||||
if g_state.get("fan_poller_task"):
|
||||
g_state["fan_poller_task"].cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await g_state["fan_poller_task"]
|
||||
g_state["fan_poller_task"] = None
|
||||
|
||||
g_state["fan_curve"] = None
|
||||
g_state["fan_curve_active"] = False
|
||||
|
||||
ok, msg = await _run(reset_fan, gpu_index)
|
||||
if not ok:
|
||||
log.warning("Fan reset warning: %s", msg)
|
||||
# Stop the poller, clear state, restore automatic fan control, and clear
|
||||
# the persisted curve so it is not re-applied on the next server start.
|
||||
await _deactivate_fan_curve(gpu_index, reset_hardware=True)
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
Reference in new issue
Block a user