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:
ARIA committed 2026-09-02 17:25:09 +02:00
1 parent 9cf2009d2c
commit d9260007d1
5 files changed
+407 -167

No files matched your search

+3
View File
@@ -2092,6 +2092,9 @@ def main():
elif "auto_load_profile" in data:
# Migrate old single-string format — GPU 0, no UUID known at this point
cfg.auto_load_profiles = {"idx:0": data["auto_load_profile"]}
if "fan_curves" in data:
# Per-GPU active fan curves, restored on server startup.
cfg.fan_curves = dict(data["fan_curves"])
except Exception:
pass
+7
View File
@@ -30,6 +30,13 @@ class Config:
# Value = profile name (str).
auto_load_profiles: dict[str, str] = field(default_factory=dict)
# Per-GPU active fan curves: restored automatically on server startup so a
# curve applied via the UI survives server restarts (fan control itself is
# volatile — the driver reverts to automatic mode on reboot).
# Key = stable GPU identifier (same as auto_load_profiles).
# Value = list of {"temp_c": int, "fan_pct": int} sorted by temp_c.
fan_curves: dict[str, list] = field(default_factory=dict)
# Module-level default config instance.
default_config = Config()
+96 -42
View File
@@ -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}