feedBack/lib/diagnostics_hardware.py
Bret Mogilefsky af2949677a
rename: slopsmith → feedBack, byron → got-feedBack (#537)
* Update GitHub repo references from feedback* to feedBack*

* rename: slopsmith -> feedBack, byron -> got-feedBack

Renames across the entire codebase:
- slopsmith/Slopsmith/SLOPSMITH/SlopSmith -> feedBack/FeedBack/FEEDBACK/FeedBack
- byron/Byron/Byrongamatos -> got-feedBack/got-feedBack/got-feedBack
- /home/byron/ -> /opt/got-feedBack/
- byron@ougsoft.com -> hi@got-feedBack.org
- github.com/byrongamatos/ -> github.com/got-feedback/
- com.byron. -> com.got-feedback.
- SLOPSMITH_ env vars -> FEEDBACK_ with backward-compat fallback
- Protocol/storage strings migrated with read-old/write-new pattern
- window.slopsmith JS API -> window.feedBack (canonical) + backward-compat alias

Refs: #rename-slopsmith

* rename: complete regen against current main + fix backward-compat alias

Regenerated the slopsmith->feedBack / byron->got-feedBack rename on top of
current main (3 commits had landed since the branch: #572/#554/#574),
resolving the four content conflicts in favour of main's newer content
(autoplay/auto-exit, accuracy-badge, Virtuoso re-home, feedpak badge).

Completion fixes on top of the mechanical rename:
- Re-apply rename to post-branch content the original rename never saw:
  window.slopsmith(.Tour) consumers in lessons.js / notifications.js /
  onboarding-tour.js, and the matching JS + python tests (autoplay_exit,
  progression_*, test_feedpak_extension FEEDBACK_* env vars). The test env
  vars now match server.py (which reads FEEDBACK_SYNC_STARTUP /
  FEEDBACK_SKIP_STARTUP_TASKS), so the sync-startup test exercises the real
  path again.
- Restore the window.slopsmith backward-compat alias dropped during conflict
  resolution, and move the bus aliases to AFTER the _feedBackExisting merge
  block so they reference the fully-assembled object (also fixes the
  loop_api.test.js API-surface regex, which the original PR latently broke).
- Drop the stray empty data/web_library.db (runtime DB lives in CONFIG_DIR)
  and gitignore it.
- Fix stale tone-source test: feed[dB]ack -> fee[dB]ack to match shipped
  source labels.

Verified locally (org CI billing-blocked): JS 819/819 pass; pytest 1669
passed / 1683 collected with 0 import errors; zero residual slopsmith/byron
except the two intentional window.slopsmith aliases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* rename: implement advertised backward-compat + prune dead community plugins

Address gaps where PR #537's "Backward compatibility" section was advertised
but not implemented, and clean up the community plugin list.

Env vars (FEEDBACK_* canonical, legacy SLOPSMITH_* honoured):
- New lib/env_compat.py (getenv_compat / env_flag_compat) + tests. server.py
  (_env_flag + all FEEDBACK_* reads), diagnostics_hardware, gp2midi and
  tailwind_rebuild now resolve the legacy alias, so existing SLOPSMITH_UI /
  SLOPSMITH_PLUGINS_DIR / etc. deployments keep working.
- Fix the rename collapsing plugins/__init__.py and minigames/routes.py from
  `FEEDBACK_PLUGINS_DIR or SLOPSMITH_PLUGINS_DIR` into a redundant
  `FEEDBACK_ or FEEDBACK_` (the fallback was silently lost).

Storage (app.js update-channel):
- Read feedBack-update-channel, fall back to legacy slopsmith-update-channel,
  and clear the legacy key on write — so a user's update-channel preference
  survives the rename instead of resetting to "stable".

Community plugin list (README): the rename rewrote third-party repo URLs we
don't own. Probed every one; their owners never renamed, so:
- Restore the 13 live community plugins to their real slopsmith-* names.
- Prune 6 that are 404 to the public (topkoa splitscreen/stems, OmikronApex
  tuner, Jafz2001 nam-rig-builder, DeathlySin song-preview, Erikcb91 shuffle).
- Fix a pre-existing Guitar Theory clone-command typo (nam-tone -> guitar-theory).

Verified: env_compat 7/7, JS 819/819, pytest 1690 collected / 0 import errors,
rename-sensitive + startup suites green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:03:01 +02:00

242 lines
7.2 KiB
Python

"""Backend hardware probe for diagnostic bundles.
Produces a `system.hardware.v1`-shaped dict — see
docs/diagnostics-bundle-spec.md.
All probes are best-effort and never raise. Missing tools, missing
permissions, container masking — every case yields a structured note in
the output rather than a 500 on the export endpoint.
"""
from __future__ import annotations
import json
import os
import platform
import subprocess
from pathlib import Path
from env_compat import getenv_compat
SCHEMA = "system.hardware.v1"
def _safe_run(cmd: list[str], timeout: float = 2.0) -> tuple[int, str, str]:
try:
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
return proc.returncode, proc.stdout or "", proc.stderr or ""
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
return 127, "", ""
def detect_runtime() -> dict:
"""Cheap runtime-kind detection (env var + cgroup checks, no subprocess).
Exported as a public function so callers that don't need the full
hardware probe can still obtain the runtime kind without paying for
nvidia-smi / psutil CPU probes.
"""
out: dict = {"kind": "bare", "in_docker": False, "in_kubernetes": False}
env_runtime = (getenv_compat("FEEDBACK_RUNTIME", "") or "").strip().lower()
if env_runtime in ("electron", "docker", "bare"):
out["kind"] = env_runtime
if Path("/.dockerenv").exists():
out["in_docker"] = True
if out["kind"] == "bare":
out["kind"] = "docker"
cgroup = Path("/proc/1/cgroup")
if cgroup.exists():
try:
txt = cgroup.read_text(errors="ignore")
if "docker" in txt or "containerd" in txt or "kubepods" in txt:
out["in_docker"] = True
if out["kind"] == "bare":
out["kind"] = "docker"
except OSError:
pass
if os.environ.get("KUBERNETES_SERVICE_HOST"):
out["in_kubernetes"] = True
if out["kind"] == "bare":
try:
import psutil # type: ignore
parent = psutil.Process(os.getppid()).name().lower()
if "electron" in parent or "feedBack" in parent:
out["kind"] = "electron"
except Exception:
pass
return out
def _probe_os() -> dict:
return {
"system": platform.system(),
"release": platform.release(),
"version": platform.version(),
"machine": platform.machine(),
}
def _probe_cpu(notes: list[str]) -> dict:
out: dict = {
"brand": None,
"arch": platform.machine(),
"cores_logical": os.cpu_count(),
"cores_physical": None,
"freq_mhz_current": None,
"freq_mhz_max": None,
}
try:
import psutil # type: ignore
out["cores_physical"] = psutil.cpu_count(logical=False)
freq = psutil.cpu_freq()
if freq:
out["freq_mhz_current"] = round(freq.current) if freq.current else None
out["freq_mhz_max"] = round(freq.max) if freq.max else None
except Exception as e:
notes.append(f"psutil cpu probe failed: {e}")
try:
import cpuinfo # type: ignore
info = cpuinfo.get_cpu_info() or {}
out["brand"] = info.get("brand_raw") or info.get("brand") or None
except Exception as e:
notes.append(f"py-cpuinfo probe failed: {e}")
# Fallback: platform.processor() is reliable on Windows + some Linux,
# useless on macOS Apple Silicon (returns 'arm').
proc = platform.processor()
if proc and proc.lower() not in ("arm", "i386"):
out["brand"] = proc
return out
def _probe_memory(notes: list[str]) -> dict:
out: dict = {"total_bytes": None, "available_bytes": None}
try:
import psutil # type: ignore
vm = psutil.virtual_memory()
out["total_bytes"] = int(vm.total)
out["available_bytes"] = int(vm.available)
except Exception as e:
notes.append(f"psutil memory probe failed: {e}")
return out
def _probe_gpu_nvidia() -> list[dict]:
rc, stdout, _ = _safe_run(
[
"nvidia-smi",
"--query-gpu=name,driver_version,memory.total",
"--format=csv,noheader,nounits",
]
)
if rc != 0 or not stdout.strip():
return []
gpus: list[dict] = []
for line in stdout.strip().splitlines():
parts = [p.strip() for p in line.split(",")]
if len(parts) < 3:
continue
try:
mem_mb = int(parts[2])
except ValueError:
mem_mb = None
gpus.append({
"source": "nvidia-smi",
"name": parts[0],
"driver": parts[1],
"memory_total_mb": mem_mb,
})
return gpus
def _probe_gpu_rocm() -> list[dict]:
rc, stdout, _ = _safe_run(
["rocm-smi", "--showproductname", "--showdriverversion", "--json"]
)
if rc != 0 or not stdout.strip():
return []
try:
data = json.loads(stdout)
except json.JSONDecodeError:
return []
gpus: list[dict] = []
for card_id, card in (data or {}).items():
if not isinstance(card, dict):
continue
gpu: dict = {
"source": "rocm-smi",
"id": card_id,
"name": card.get("Card series") or card.get("Card model") or "AMD GPU",
}
driver = card.get("Driver version") or card.get("driver_version")
if driver:
gpu["driver"] = driver
gpus.append(gpu)
return gpus
def _probe_gpu_macos() -> list[dict]:
if platform.system() != "Darwin":
return []
rc, stdout, _ = _safe_run(
["system_profiler", "SPDisplaysDataType", "-json"], timeout=4.0
)
if rc != 0 or not stdout.strip():
return []
try:
data = json.loads(stdout)
except json.JSONDecodeError:
return []
gpus: list[dict] = []
for card in data.get("SPDisplaysDataType", []) or []:
gpus.append({
"source": "system_profiler",
"name": card.get("sppci_model") or card.get("_name") or "GPU",
"vendor": card.get("spdisplays_vendor"),
"metal_support": card.get("spdisplays_metalfamily"),
})
return gpus
def _probe_gpus(notes: list[str]) -> list[dict]:
gpus: list[dict] = []
gpus.extend(_probe_gpu_nvidia())
gpus.extend(_probe_gpu_rocm())
gpus.extend(_probe_gpu_macos())
if not gpus:
notes.append(
"no GPU probes succeeded — nvidia-smi/rocm-smi/system_profiler absent or denied"
)
return gpus
def collect() -> dict:
"""Build a `system.hardware.v1` dict. Never raises."""
notes: list[str] = []
runtime = detect_runtime()
cpu = _probe_cpu(notes)
memory = _probe_memory(notes)
gpu = _probe_gpus(notes)
if runtime["in_docker"]:
notes.append(
"container masks host CPU/RAM — values reflect container limits, not host"
)
return {
"schema": SCHEMA,
"runtime": runtime,
"os": _probe_os(),
"cpu": cpu,
"memory": memory,
"gpu": gpu,
"notes": notes,
}