Skip to content

Commit 0fa301a

Browse files
MTSistemiclaude
andcommitted
dashboard: auto-rules (thermal auto-throttle) + freeze screen snapshot + AI-Ops modules
Rules: background engine drops to Stock preset if temp stays over a settable limit; keeps a periodic desktop snapshot (last frame before a freeze) shown in the card. AI-Ops: feeds journal+freeze-log+telemetry to the local Ollama model for a diagnosis. 13 of 14 modules now functional (gamestream left). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5e1492a commit 0fa301a

3 files changed

Lines changed: 169 additions & 2 deletions

File tree

apps/dashboard/skillfish-dashboardd

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,111 @@ def power_schedule(action, minutes):
530530
return {"ok": False, "error": "azione sconosciuta"}
531531

532532

533+
# ---------------- auto rules + screen snapshot ----------------
534+
import urllib.request
535+
RULES_DEFAULT = {"enabled": True, "temp_limit": 92, "samples": 6}
536+
LASTFRAME = "/tmp/skillfish-lastframe.jpg" # captured as the desktop user, so must be user-writable
537+
_RULES = {"hot": 0, "last_action": "", "snap_ts": 0}
538+
539+
540+
def save_conf():
541+
try:
542+
with open(CONF, "w") as f:
543+
json.dump(CONFIG, f, indent=2)
544+
except Exception:
545+
pass
546+
547+
548+
def rules_cfg():
549+
return {**RULES_DEFAULT, **(CONFIG.get("rules_cfg") or {})}
550+
551+
552+
def rules_loop():
553+
while True:
554+
try:
555+
c = rules_cfg()
556+
if CONFIG.get("modules", {}).get("rules") and c["enabled"]:
557+
v = read_all()
558+
temps = [x for x in (v.get("gpu_temp"), v.get("cpu_temp")) if isinstance(x, (int, float))]
559+
t = max(temps) if temps else 0
560+
if t >= c["temp_limit"]:
561+
_RULES["hot"] += 1
562+
if _RULES["hot"] >= c["samples"]:
563+
apply_preset("Stock")
564+
_RULES["hot"] = 0
565+
_RULES["last_action"] = "%s — %d°C ≥ %d°C → preset Stock applicato" % (
566+
time.strftime("%H:%M:%S"), round(t), c["temp_limit"])
567+
else:
568+
_RULES["hot"] = 0
569+
except Exception:
570+
pass
571+
time.sleep(2)
572+
573+
574+
def snapshot_loop():
575+
u = CONFIG.get("user", "skillfish")
576+
# keep the screen awake so the "last frame" is the real desktop, not a blanked one
577+
for args in (["xset", "s", "off"], ["xset", "-dpms"]):
578+
try:
579+
subprocess.run(["sudo", "-u", u, "env", "DISPLAY=:0"] + args,
580+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5)
581+
except Exception:
582+
pass
583+
while True:
584+
try:
585+
if CONFIG.get("modules", {}).get("rules"):
586+
subprocess.run(["sudo", "-u", u, "env", "DISPLAY=:0",
587+
"import", "-window", "root", "-resize", "960x", LASTFRAME],
588+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=12)
589+
try:
590+
os.chmod(LASTFRAME, 0o644)
591+
except Exception:
592+
pass
593+
_RULES["snap_ts"] = int(time.time())
594+
except Exception:
595+
pass
596+
time.sleep(20)
597+
598+
599+
# ---------------- AI-Ops (local LLM log diagnosis) ----------------
600+
def _ollama_models():
601+
try:
602+
r = subprocess.run(["docker", "exec", AI_CONTAINER, "ollama", "list"],
603+
capture_output=True, text=True, timeout=8).stdout
604+
return [ln.split()[0] for ln in r.splitlines()[1:] if ln.strip()]
605+
except Exception:
606+
return []
607+
608+
609+
def aiops_diagnose(question):
610+
if not _port_open(11434):
611+
return {"ok": False, "error": "Motore AI spento: accendilo dal modulo AI."}
612+
models = _ollama_models()
613+
model = models[0] if models else "qwen3:14b"
614+
jrn = subprocess.run(["journalctl", "-p", "warning", "-n", "60", "--no-pager", "-o", "short"],
615+
capture_output=True, text=True, timeout=8).stdout
616+
try:
617+
frz = open("/var/log/skillfish-freeze.log").read()[-1500:]
618+
except Exception:
619+
frz = "(nessun freeze registrato)"
620+
v = read_all()
621+
tele = "GPU %s°C / CPU %s°C, GPU %s MHz, %s W, ventola %s RPM" % (
622+
v.get("gpu_temp"), v.get("cpu_temp"), v.get("gpu_freq"), v.get("gpu_power"), v.get("fan"))
623+
q = question or "Analizza i log e lo stato: ci sono problemi? Causa probabile e come risolvere?"
624+
prompt = ("Sei l'assistente di sistema di SkillFishOS su una scheda AMD BC-250. "
625+
"Rispondi in italiano, conciso e pratico.\n\n=== Telemetria ===\n%s\n\n"
626+
"=== Log freeze ===\n%s\n\n=== journalctl (warning+) ===\n%s\n\n=== Domanda ===\n%s\n" %
627+
(tele, frz, jrn[-3000:], q))
628+
try:
629+
req = urllib.request.Request("http://127.0.0.1:11434/api/generate",
630+
data=json.dumps({"model": model, "prompt": prompt, "stream": False}).encode(),
631+
headers={"Content-Type": "application/json"})
632+
out = json.loads(urllib.request.urlopen(req, timeout=120).read().decode())
633+
return {"ok": True, "model": model, "answer": out.get("response", "").strip()}
634+
except Exception as e:
635+
return {"ok": False, "error": "Ollama: %s" % e}
636+
637+
533638
# ---------------- HTTP ----------------
534639
CONFIG = load_conf()
535640
_login_fails = {} # ip -> (count, first_ts)
@@ -721,6 +826,21 @@ class Handler(BaseHTTPRequestHandler):
721826
if not self._guard("wol"):
722827
return
723828
return self._json(200, wol_info())
829+
if path == "/api/rules":
830+
if not self._guard("rules"):
831+
return
832+
c = rules_cfg()
833+
c["last_action"] = _RULES["last_action"]
834+
c["snap_age"] = int(time.time()) - _RULES["snap_ts"] if _RULES["snap_ts"] else None
835+
c["has_frame"] = os.path.isfile(LASTFRAME)
836+
return self._json(200, c)
837+
if path == "/api/rules/frame":
838+
if not self._guard("rules"):
839+
return
840+
if os.path.isfile(LASTFRAME):
841+
with open(LASTFRAME, "rb") as f:
842+
return self._send(200, f.read(), "image/jpeg")
843+
return self._json(404, {"error": "no frame"})
724844
return self._json(404, {"error": "not found"})
725845

726846
def do_POST(self):
@@ -812,6 +932,21 @@ class Handler(BaseHTTPRequestHandler):
812932
if not self._guard("wol"):
813933
return
814934
return self._json(200, power_schedule(data.get("action"), data.get("minutes", 1)))
935+
if path == "/api/rules":
936+
if not self._guard("rules"):
937+
return
938+
cfg = rules_cfg()
939+
if "enabled" in data:
940+
cfg["enabled"] = bool(data["enabled"])
941+
if "temp_limit" in data:
942+
cfg["temp_limit"] = max(70, min(100, int(data["temp_limit"])))
943+
CONFIG["rules_cfg"] = cfg
944+
save_conf()
945+
return self._json(200, {"ok": True, **cfg})
946+
if path == "/api/aiops/diagnose":
947+
if not self._guard("aiops"):
948+
return
949+
return self._json(200, aiops_diagnose(data.get("question")))
815950
return self._json(404, {"error": "not found"})
816951

817952
def _login(self):
@@ -895,6 +1030,8 @@ def main():
8951030
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
8961031
sys.stderr.write("SkillFish Remote on https://%s:%d (PAM=%s)\n" % (bind, port, _PAM_OK))
8971032
sys.stderr.flush()
1033+
threading.Thread(target=rules_loop, daemon=True).start()
1034+
threading.Thread(target=snapshot_loop, daemon=True).start()
8981035
httpd.serve_forever()
8991036

9001037

apps/dashboard/skillfish-remote-manager

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ MODULES = [
3030
("terminal", "⌨️", L("Terminale", "Terminal"), True),
3131
("ai", "🧠", "AI / OpenWebUI", True),
3232
("gamestream", "🎮", "Game streaming", False),
33-
("aiops", "🩺", "AI-Ops", False),
34-
("rules", "⚙️", L("Regole auto", "Auto rules"), False),
33+
("aiops", "🩺", "AI-Ops", True),
34+
("rules", "⚙️", L("Regole auto", "Auto rules"), True),
3535
("wol", "🔋", L("Power schedule", "Power schedule"), True),
3636
]
3737

apps/dashboard/web/app.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,36 @@ const RENDER = {
244244
};
245245
refresh();
246246
},
247+
rules(card) {
248+
card.classList.add("span2");
249+
card.innerHTML = '<h3>⚙️ Regole auto</h3><div id="ru">…</div>';
250+
const refresh = async () => {
251+
let s; try { s = await (await api("/api/rules")).json(); } catch (e) { return; }
252+
$("#ru", card).innerHTML =
253+
'<div class="rows"><div class="r"><span>Auto-throttle a Stock se troppo caldo</span><span>' + (s.enabled ? "● attivo" : "○ spento") + '</span></div>' +
254+
'<div class="r"><span>Soglia</span><span>' + s.temp_limit + ' °C</span></div>' +
255+
(s.last_action ? '<div class="r"><span>Ultima azione</span><span>' + s.last_action + '</span></div>' : "") + '</div>' +
256+
'<div class="brow" style="margin-top:10px"><button class="dbtn" id="rtog">' + (s.enabled ? "Disattiva" : "Attiva") + '</button>' +
257+
'<input id="rlim" class="dsel" type="number" min="70" max="100" value="' + s.temp_limit + '" style="width:64px"> °C <button class="dbtn" id="rset">Imposta</button></div>' +
258+
'<div class="gl" style="margin-top:12px">Ultimo fotogramma dello schermo' + (s.snap_age != null ? " (" + s.snap_age + "s fa)" : "") + '</div>' +
259+
(s.has_frame ? '<img src="/api/rules/frame?t=' + Date.now() + '" style="width:100%;border-radius:10px;border:1px solid var(--line);margin-top:6px">' : '<div class="stub">Nessun fotogramma ancora (attiva il modulo e attendi ~20s).</div>');
260+
$("#rtog", card).onclick = async () => { await action("/api/rules", { enabled: !s.enabled }, "Regola aggiornata"); setTimeout(refresh, 500); };
261+
$("#rset", card).onclick = async () => { await action("/api/rules", { temp_limit: +$("#rlim", card).value }, "Soglia impostata"); setTimeout(refresh, 500); };
262+
};
263+
refresh(); card._iv = setInterval(refresh, 15000);
264+
},
265+
aiops(card) {
266+
card.classList.add("span2");
267+
card.innerHTML = '<h3>🩺 AI-Ops</h3><div class="brow"><input id="aq" class="dsel" placeholder="Domanda (opzionale): perché si è bloccata?" style="flex:1"><button class="dbtn" id="adg">Diagnostica</button></div>' +
268+
'<div class="logbox" id="aout" style="margin-top:8px;display:none"></div>' +
269+
'<div class="stub" style="margin-top:8px">Il modello locale (Ollama) legge log e telemetria e spiega cosa succede. Richiede il motore AI acceso.</div>';
270+
$("#adg", card).onclick = async () => {
271+
const out = $("#aout", card); out.style.display = "block";
272+
out.textContent = "Analisi in corso col modello locale… (può richiedere un minuto)";
273+
const j = await (await post("/api/aiops/diagnose", { question: $("#aq", card).value })).json().catch(() => ({}));
274+
out.textContent = j.ok ? (j.answer || "(nessuna risposta)") : ("Errore: " + (j.error || ""));
275+
};
276+
},
247277
_stub(card, mod) {
248278
card.innerHTML = `<h3>${mod.icon} ${mod.name}</h3><div class="stub">Modulo attivo — interfaccia in arrivo.</div>`;
249279
},

0 commit comments

Comments
 (0)