webui: add persistent GPU settings management (ECC/MIG/CC/power limit)
Adds a GPU Settings card to /tools for the settings that actually persist on NVIDIA data-center GPUs: ECC mode, MIG mode, and Confidential Computing mode (all stored in the GPU's inforom/firmware, take effect after a GPU reset or reboot) plus power limit (does not persist — reapplied on demand). Includes a one-click "Reset All to Defaults" that restores factory settings across every visible GPU, touching only whatever has actually drifted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
2599d9c5e3
commit
53c46465d2
@@ -137,6 +137,12 @@ type satRunner interface {
|
||||
RunConfidentialComputingCheckPack(ctx context.Context, baseDir string, logFunc func(string)) (string, error)
|
||||
RunCPUAcceptancePack(ctx context.Context, baseDir string, durationSec int, logFunc func(string)) (string, error)
|
||||
ListNvidiaGPUs() ([]platform.NvidiaGPU, error)
|
||||
ListNvidiaGPUSettings() ([]platform.NvidiaGPUSetting, error)
|
||||
SetNvidiaGPUECC(index int, enabled bool) (string, error)
|
||||
SetNvidiaGPUMIG(index int, enabled bool) (string, error)
|
||||
SetNvidiaGPUCCMode(index int, enabled bool) (string, error)
|
||||
SetNvidiaGPUPowerLimit(index int, watts float64) (string, error)
|
||||
ResetNvidiaGPUDefaults() (string, error)
|
||||
DetectGPUVendor() string
|
||||
ListAMDGPUs() ([]platform.AMDGPUInfo, error)
|
||||
RunAMDAcceptancePack(ctx context.Context, baseDir string, logFunc func(string)) (string, error)
|
||||
|
||||
@@ -39,6 +39,47 @@ func (a *App) ResetNvidiaGPU(index int) (ActionResult, error) {
|
||||
return ActionResult{Title: fmt.Sprintf("Reset NVIDIA GPU %d", index), Body: strings.TrimSpace(out)}, err
|
||||
}
|
||||
|
||||
func (a *App) ListNvidiaGPUSettings() ([]platform.NvidiaGPUSetting, error) {
|
||||
return a.sat.ListNvidiaGPUSettings()
|
||||
}
|
||||
|
||||
func (a *App) SetNvidiaGPUECC(index int, enabled bool) (ActionResult, error) {
|
||||
out, err := a.sat.SetNvidiaGPUECC(index, enabled)
|
||||
title := fmt.Sprintf("Enable ECC on GPU %d", index)
|
||||
if !enabled {
|
||||
title = fmt.Sprintf("Disable ECC on GPU %d", index)
|
||||
}
|
||||
return ActionResult{Title: title, Body: strings.TrimSpace(out)}, err
|
||||
}
|
||||
|
||||
func (a *App) SetNvidiaGPUMIG(index int, enabled bool) (ActionResult, error) {
|
||||
out, err := a.sat.SetNvidiaGPUMIG(index, enabled)
|
||||
title := fmt.Sprintf("Enable MIG on GPU %d", index)
|
||||
if !enabled {
|
||||
title = fmt.Sprintf("Disable MIG on GPU %d", index)
|
||||
}
|
||||
return ActionResult{Title: title, Body: strings.TrimSpace(out)}, err
|
||||
}
|
||||
|
||||
func (a *App) SetNvidiaGPUCCMode(index int, enabled bool) (ActionResult, error) {
|
||||
out, err := a.sat.SetNvidiaGPUCCMode(index, enabled)
|
||||
title := fmt.Sprintf("Enable Confidential Computing on GPU %d", index)
|
||||
if !enabled {
|
||||
title = fmt.Sprintf("Disable Confidential Computing on GPU %d", index)
|
||||
}
|
||||
return ActionResult{Title: title, Body: strings.TrimSpace(out)}, err
|
||||
}
|
||||
|
||||
func (a *App) SetNvidiaGPUPowerLimit(index int, watts float64) (ActionResult, error) {
|
||||
out, err := a.sat.SetNvidiaGPUPowerLimit(index, watts)
|
||||
return ActionResult{Title: fmt.Sprintf("Set power limit on GPU %d", index), Body: strings.TrimSpace(out)}, err
|
||||
}
|
||||
|
||||
func (a *App) ResetNvidiaGPUDefaults() (ActionResult, error) {
|
||||
out, err := a.sat.ResetNvidiaGPUDefaults()
|
||||
return ActionResult{Title: "Reset all GPUs to factory defaults", Body: strings.TrimSpace(out)}, err
|
||||
}
|
||||
|
||||
func (a *App) RunNvidiaAcceptancePackWithOptions(ctx context.Context, baseDir string, diagLevel int, gpuIndices []int, logFunc func(string)) (ActionResult, error) {
|
||||
if strings.TrimSpace(baseDir) == "" {
|
||||
baseDir = DefaultSATBaseDir
|
||||
|
||||
@@ -235,6 +235,30 @@ func (f fakeSAT) ResetNvidiaGPU(index int) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (f fakeSAT) ListNvidiaGPUSettings() ([]platform.NvidiaGPUSetting, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f fakeSAT) SetNvidiaGPUECC(index int, enabled bool) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (f fakeSAT) SetNvidiaGPUMIG(index int, enabled bool) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (f fakeSAT) SetNvidiaGPUCCMode(index int, enabled bool) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (f fakeSAT) SetNvidiaGPUPowerLimit(index int, watts float64) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (f fakeSAT) ResetNvidiaGPUDefaults() (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (f fakeSAT) RunMemoryAcceptancePack(_ context.Context, baseDir string, _, _ int, _ func(string)) (string, error) {
|
||||
return f.runMemoryFn(baseDir)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NvidiaGPUSetting reports the persistent GPU settings that nvidia-smi
|
||||
// exposes: ECC mode, MIG mode, and Confidential Computing mode all persist
|
||||
// via the GPU's inforom/firmware and survive a reboot, but only take effect
|
||||
// after a GPU reset (nvidia-smi -r) or host reboot (hence separate
|
||||
// current/pending fields for ECC and MIG). Power limit does NOT persist
|
||||
// across reboot/driver reload — it is re-applied here on demand, same as
|
||||
// nvidia-smi's own behavior.
|
||||
type NvidiaGPUSetting struct {
|
||||
Index int `json:"index"`
|
||||
Name string `json:"name"`
|
||||
ECCCurrent string `json:"ecc_current"` // "Enabled" / "Disabled" / "N/A"
|
||||
ECCPending string `json:"ecc_pending"` // differs from current until a GPU reset/reboot
|
||||
MIGCurrent string `json:"mig_current"` // "Enabled" / "Disabled" / "N/A" (N/A = not MIG-capable)
|
||||
MIGPending string `json:"mig_pending"`
|
||||
CCState string `json:"cc_state"` // "ON" / "OFF" / "" (unsupported or query failed)
|
||||
PowerLimitW float64 `json:"power_limit_w"`
|
||||
PowerMinLimitW float64 `json:"power_min_limit_w"`
|
||||
PowerMaxLimitW float64 `json:"power_max_limit_w"`
|
||||
PowerDefaultLimitW float64 `json:"power_default_limit_w"`
|
||||
}
|
||||
|
||||
// ListNvidiaGPUSettings reports current ECC, MIG, Confidential Computing and
|
||||
// power-limit configuration per GPU.
|
||||
func (s *System) ListNvidiaGPUSettings() ([]NvidiaGPUSetting, error) {
|
||||
out, err := satExecCommand("nvidia-smi",
|
||||
"--query-gpu=index,name,ecc.mode.current,ecc.mode.pending,mig.mode.current,mig.mode.pending,power.limit,power.min_limit,power.max_limit,power.default_limit",
|
||||
"--format=csv,noheader,nounits").Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("nvidia-smi: %w", err)
|
||||
}
|
||||
var settings []NvidiaGPUSetting
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(line, ",")
|
||||
if len(parts) < 10 {
|
||||
continue
|
||||
}
|
||||
idx, err := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
g := NvidiaGPUSetting{
|
||||
Index: idx,
|
||||
Name: strings.TrimSpace(parts[1]),
|
||||
ECCCurrent: strings.TrimSpace(parts[2]),
|
||||
ECCPending: strings.TrimSpace(parts[3]),
|
||||
MIGCurrent: strings.TrimSpace(parts[4]),
|
||||
MIGPending: strings.TrimSpace(parts[5]),
|
||||
PowerLimitW: parseNvidiaFloat(parts[6]),
|
||||
PowerMinLimitW: parseNvidiaFloat(parts[7]),
|
||||
PowerMaxLimitW: parseNvidiaFloat(parts[8]),
|
||||
PowerDefaultLimitW: parseNvidiaFloat(parts[9]),
|
||||
}
|
||||
g.CCState = s.queryNvidiaGPUCCState(idx)
|
||||
settings = append(settings, g)
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// queryNvidiaGPUCCState reads the per-GPU Confidential Computing state via
|
||||
// `nvidia-smi conf-compute -i <index> -q`, reusing the same field-name
|
||||
// convention as RunConfidentialComputingCheckPack's parseConfComputeFields.
|
||||
// Returns "" if the driver/GPU doesn't support conf-compute at all.
|
||||
func (s *System) queryNvidiaGPUCCState(index int) string {
|
||||
out, err := satExecCommand("nvidia-smi", "conf-compute", "-i", strconv.Itoa(index), "-q").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return parseConfComputeFields(out)["CC State"]
|
||||
}
|
||||
|
||||
func parseNvidiaFloat(v string) float64 {
|
||||
f, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// SetNvidiaGPUECC sets pending ECC mode for one GPU. Takes effect after the
|
||||
// GPU is reset (nvidia-smi -r) or the host is rebooted.
|
||||
func (s *System) SetNvidiaGPUECC(index int, enabled bool) (string, error) {
|
||||
val := "0"
|
||||
if enabled {
|
||||
val = "1"
|
||||
}
|
||||
out, err := satExecCommand("nvidia-smi", "-i", strconv.Itoa(index), "-e", val).CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
// SetNvidiaGPUMIG sets pending MIG (Multi-Instance GPU) mode for one GPU.
|
||||
// Persists via inforom but takes effect only after a GPU reset (nvidia-smi
|
||||
// -r) or host reboot. Only supported on MIG-capable GPUs (Ampere/Hopper
|
||||
// data-center parts); fails harmlessly on others.
|
||||
func (s *System) SetNvidiaGPUMIG(index int, enabled bool) (string, error) {
|
||||
val := "0"
|
||||
if enabled {
|
||||
val = "1"
|
||||
}
|
||||
out, err := satExecCommand("nvidia-smi", "-i", strconv.Itoa(index), "-mig", val).CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
// SetNvidiaGPUCCMode sets Confidential Computing mode for one GPU
|
||||
// (nvidia-smi conf-compute -scc). Persists via inforom, reversible, but
|
||||
// takes effect only after a GPU reset (nvidia-smi -r) or host reboot.
|
||||
func (s *System) SetNvidiaGPUCCMode(index int, enabled bool) (string, error) {
|
||||
val := "0"
|
||||
if enabled {
|
||||
val = "1"
|
||||
}
|
||||
out, err := satExecCommand("nvidia-smi", "conf-compute", "-i", strconv.Itoa(index), "-scc", val).CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
// SetNvidiaGPUPowerLimit sets the power limit (watts) for one GPU. Does not
|
||||
// persist across reboot/driver reload — callers that want a durable change
|
||||
// must re-apply it (e.g. at boot), same as nvidia-smi's own semantics.
|
||||
func (s *System) SetNvidiaGPUPowerLimit(index int, watts float64) (string, error) {
|
||||
out, err := satExecCommand("nvidia-smi", "-i", strconv.Itoa(index), "-pl", strconv.FormatFloat(watts, 'f', 0, 64)).CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
// ResetNvidiaGPUDefaults restores factory-default settings on every visible
|
||||
// NVIDIA GPU that has drifted from them: power limit back to
|
||||
// power.default_limit (as reported by nvidia-smi), ECC re-enabled (NVIDIA
|
||||
// data-center GPUs ship with ECC on; nvidia-smi has no queryable "ECC
|
||||
// default" field, so this is the documented factory default, not a value
|
||||
// read back from hardware), and MIG/Confidential-Computing mode disabled
|
||||
// (both ship off from the factory). GPUs already at defaults are left
|
||||
// alone.
|
||||
func (s *System) ResetNvidiaGPUDefaults() (string, error) {
|
||||
settings, err := s.ListNvidiaGPUSettings()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var out strings.Builder
|
||||
for _, g := range settings {
|
||||
if g.PowerDefaultLimitW > 0 && g.PowerLimitW != g.PowerDefaultLimitW {
|
||||
res, err := s.SetNvidiaGPUPowerLimit(g.Index, g.PowerDefaultLimitW)
|
||||
out.WriteString(res)
|
||||
if err != nil {
|
||||
fmt.Fprintf(&out, "GPU %d: power limit reset failed: %v\n", g.Index, err)
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(g.ECCCurrent, "disabled") {
|
||||
res, err := s.SetNvidiaGPUECC(g.Index, true)
|
||||
out.WriteString(res)
|
||||
if err != nil {
|
||||
fmt.Fprintf(&out, "GPU %d: ECC re-enable failed: %v\n", g.Index, err)
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(g.MIGCurrent, "enabled") {
|
||||
res, err := s.SetNvidiaGPUMIG(g.Index, false)
|
||||
out.WriteString(res)
|
||||
if err != nil {
|
||||
fmt.Fprintf(&out, "GPU %d: MIG disable failed: %v\n", g.Index, err)
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(g.CCState, "on") {
|
||||
res, err := s.SetNvidiaGPUCCMode(g.Index, false)
|
||||
out.WriteString(res)
|
||||
if err != nil {
|
||||
fmt.Fprintf(&out, "GPU %d: CC mode disable failed: %v\n", g.Index, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.Len() == 0 {
|
||||
out.WriteString("All GPUs already at factory-default settings.\n")
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeNvidiaSMI stubs satExecCommand for nvidia-smi calls made by the
|
||||
// GPU-settings code: the CSV query (index,...,power.default_limit), the
|
||||
// per-GPU "conf-compute -q" read, and write commands (-e/-mig/-pl/-scc).
|
||||
// ccStateByIndex maps GPU index -> CC State text ("ON"/"OFF"); a missing
|
||||
// entry simulates conf-compute being unsupported (query fails).
|
||||
func fakeNvidiaSMI(t *testing.T, queryOut string, ccStateByIndex map[int]string, writeCalls *[]string) {
|
||||
t.Helper()
|
||||
old := satExecCommand
|
||||
satExecCommand = func(name string, args ...string) *exec.Cmd {
|
||||
if name != "nvidia-smi" {
|
||||
return exec.Command(name, args...)
|
||||
}
|
||||
if len(args) > 0 && strings.HasPrefix(args[0], "--query-gpu=") {
|
||||
return exec.Command("printf", "%s", queryOut)
|
||||
}
|
||||
if len(args) > 0 && args[0] == "conf-compute" {
|
||||
// args: conf-compute -i <index> -q
|
||||
idx := -1
|
||||
for i, a := range args {
|
||||
if a == "-i" && i+1 < len(args) {
|
||||
var n int
|
||||
fscanInt(args[i+1], &n)
|
||||
idx = n
|
||||
}
|
||||
}
|
||||
if len(args) > 0 && args[len(args)-1] == "-q" {
|
||||
state, ok := ccStateByIndex[idx]
|
||||
if !ok {
|
||||
return exec.Command("false")
|
||||
}
|
||||
return exec.Command("printf", "CC State : %s\\n", state)
|
||||
}
|
||||
if writeCalls != nil {
|
||||
*writeCalls = append(*writeCalls, strings.Join(args, " "))
|
||||
}
|
||||
return exec.Command("true")
|
||||
}
|
||||
if writeCalls != nil {
|
||||
*writeCalls = append(*writeCalls, strings.Join(args, " "))
|
||||
}
|
||||
return exec.Command("true")
|
||||
}
|
||||
t.Cleanup(func() { satExecCommand = old })
|
||||
}
|
||||
|
||||
func fscanInt(s string, out *int) {
|
||||
n := 0
|
||||
neg := false
|
||||
for i, c := range s {
|
||||
if i == 0 && c == '-' {
|
||||
neg = true
|
||||
continue
|
||||
}
|
||||
if c < '0' || c > '9' {
|
||||
return
|
||||
}
|
||||
n = n*10 + int(c-'0')
|
||||
}
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
*out = n
|
||||
}
|
||||
|
||||
func TestListNvidiaGPUSettingsParsesCSV(t *testing.T) {
|
||||
fakeNvidiaSMI(t,
|
||||
"0, H100 80GB HBM3, Enabled, Enabled, Disabled, Disabled, 350.00, 100.00, 700.00, 700.00\n"+
|
||||
"1, H100 80GB HBM3, Disabled, Enabled, N/A, N/A, 300.00, 100.00, 700.00, 700.00\n",
|
||||
map[int]string{0: "OFF", 1: "OFF"}, nil)
|
||||
|
||||
s := &System{}
|
||||
got, err := s.ListNvidiaGPUSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len=%d want 2 (%#v)", len(got), got)
|
||||
}
|
||||
if got[0].ECCCurrent != "Enabled" || got[0].PowerLimitW != 350 || got[0].PowerDefaultLimitW != 700 {
|
||||
t.Fatalf("gpu0=%#v", got[0])
|
||||
}
|
||||
if got[0].MIGCurrent != "Disabled" || got[0].CCState != "OFF" {
|
||||
t.Fatalf("gpu0 mig/cc=%#v", got[0])
|
||||
}
|
||||
if got[1].ECCCurrent != "Disabled" || got[1].ECCPending != "Enabled" || got[1].MIGCurrent != "N/A" {
|
||||
t.Fatalf("gpu1=%#v", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestListNvidiaGPUSettingsCCUnsupportedLeavesEmptyState(t *testing.T) {
|
||||
fakeNvidiaSMI(t,
|
||||
"0, H100, Enabled, Enabled, Disabled, Disabled, 350.00, 100.00, 700.00, 700.00\n",
|
||||
map[int]string{}, nil)
|
||||
|
||||
s := &System{}
|
||||
got, err := s.ListNvidiaGPUSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].CCState != "" {
|
||||
t.Fatalf("got=%#v want empty CCState when conf-compute query fails", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetNvidiaGPUECCPassesCorrectFlag(t *testing.T) {
|
||||
var calls []string
|
||||
fakeNvidiaSMI(t, "", nil, &calls)
|
||||
|
||||
s := &System{}
|
||||
if _, err := s.SetNvidiaGPUECC(2, true); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if len(calls) != 1 || calls[0] != "-i 2 -e 1" {
|
||||
t.Fatalf("calls=%v want [-i 2 -e 1]", calls)
|
||||
}
|
||||
|
||||
if _, err := s.SetNvidiaGPUECC(0, false); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if calls[1] != "-i 0 -e 0" {
|
||||
t.Fatalf("calls[1]=%q want -i 0 -e 0", calls[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetNvidiaGPUMIGPassesCorrectFlag(t *testing.T) {
|
||||
var calls []string
|
||||
fakeNvidiaSMI(t, "", nil, &calls)
|
||||
|
||||
s := &System{}
|
||||
if _, err := s.SetNvidiaGPUMIG(3, true); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if len(calls) != 1 || calls[0] != "-i 3 -mig 1" {
|
||||
t.Fatalf("calls=%v want [-i 3 -mig 1]", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetNvidiaGPUCCModePassesCorrectFlag(t *testing.T) {
|
||||
var calls []string
|
||||
fakeNvidiaSMI(t, "", nil, &calls)
|
||||
|
||||
s := &System{}
|
||||
if _, err := s.SetNvidiaGPUCCMode(1, false); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if len(calls) != 1 || calls[0] != "conf-compute -i 1 -scc 0" {
|
||||
t.Fatalf("calls=%v want [conf-compute -i 1 -scc 0]", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetNvidiaGPUPowerLimitFormatsWatts(t *testing.T) {
|
||||
var calls []string
|
||||
fakeNvidiaSMI(t, "", nil, &calls)
|
||||
|
||||
s := &System{}
|
||||
if _, err := s.SetNvidiaGPUPowerLimit(1, 450); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if len(calls) != 1 || calls[0] != "-i 1 -pl 450" {
|
||||
t.Fatalf("calls=%v want [-i 1 -pl 450]", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetNvidiaGPUDefaultsOnlyTouchesDriftedGPUs(t *testing.T) {
|
||||
var calls []string
|
||||
fakeNvidiaSMI(t,
|
||||
"0, H100, Enabled, Enabled, Enabled, Enabled, 350.00, 100.00, 700.00, 700.00\n"+
|
||||
"1, H100, Disabled, Disabled, Disabled, Disabled, 700.00, 100.00, 700.00, 700.00\n",
|
||||
map[int]string{0: "ON", 1: "OFF"}, &calls)
|
||||
|
||||
s := &System{}
|
||||
out, err := s.ResetNvidiaGPUDefaults()
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if out == "" {
|
||||
t.Fatalf("expected non-empty output")
|
||||
}
|
||||
// GPU0: power limit drifted (350 vs 700) -> reset; MIG enabled -> disable; CC ON -> disable.
|
||||
// GPU1: power limit already default, MIG already disabled, CC already off;
|
||||
// only ECC disabled -> re-enable.
|
||||
want := []string{
|
||||
"-i 0 -pl 700",
|
||||
"-i 0 -mig 0",
|
||||
"conf-compute -i 0 -scc 0",
|
||||
"-i 1 -e 1",
|
||||
}
|
||||
if len(calls) != len(want) {
|
||||
t.Fatalf("calls=%v want %v", calls, want)
|
||||
}
|
||||
for i := range want {
|
||||
if calls[i] != want[i] {
|
||||
t.Fatalf("calls[%d]=%q want %q (all calls: %v)", i, calls[i], want[i], calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetNvidiaGPUDefaultsNoOpWhenAlreadyDefault(t *testing.T) {
|
||||
var calls []string
|
||||
fakeNvidiaSMI(t,
|
||||
"0, H100, Enabled, Enabled, Disabled, Disabled, 700.00, 100.00, 700.00, 700.00\n",
|
||||
map[int]string{0: "OFF"}, &calls)
|
||||
|
||||
s := &System{}
|
||||
out, err := s.ResetNvidiaGPUDefaults()
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if len(calls) != 0 {
|
||||
t.Fatalf("calls=%v want no calls (already at factory defaults)", calls)
|
||||
}
|
||||
if !strings.Contains(out, "already at factory-default") {
|
||||
t.Fatalf("out=%q want no-op message", out)
|
||||
}
|
||||
}
|
||||
@@ -1169,6 +1169,125 @@ func (h *handler) handleAPIGNVIDIAReset(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, map[string]string{"status": status, "output": result.Body})
|
||||
}
|
||||
|
||||
// ── GPU settings (ECC / power limit) ──────────────────────────────────────────
|
||||
|
||||
func (h *handler) handleAPIGNVIDIAGPUSettings(w http.ResponseWriter, _ *http.Request) {
|
||||
if h.opts.App == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
||||
return
|
||||
}
|
||||
settings, err := h.opts.App.ListNvidiaGPUSettings()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if settings == nil {
|
||||
settings = []platform.NvidiaGPUSetting{}
|
||||
}
|
||||
writeJSON(w, settings)
|
||||
}
|
||||
|
||||
func (h *handler) handleAPIGNVIDIASetECC(w http.ResponseWriter, r *http.Request) {
|
||||
if h.opts.App == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Index int `json:"index"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
result, err := h.opts.App.SetNvidiaGPUECC(req.Index, req.Enabled)
|
||||
status := "ok"
|
||||
if err != nil {
|
||||
status = "error"
|
||||
}
|
||||
writeJSON(w, map[string]string{"status": status, "output": result.Body})
|
||||
}
|
||||
|
||||
func (h *handler) handleAPIGNVIDIASetMIG(w http.ResponseWriter, r *http.Request) {
|
||||
if h.opts.App == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Index int `json:"index"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
result, err := h.opts.App.SetNvidiaGPUMIG(req.Index, req.Enabled)
|
||||
status := "ok"
|
||||
if err != nil {
|
||||
status = "error"
|
||||
}
|
||||
writeJSON(w, map[string]string{"status": status, "output": result.Body})
|
||||
}
|
||||
|
||||
func (h *handler) handleAPIGNVIDIASetCCMode(w http.ResponseWriter, r *http.Request) {
|
||||
if h.opts.App == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Index int `json:"index"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
result, err := h.opts.App.SetNvidiaGPUCCMode(req.Index, req.Enabled)
|
||||
status := "ok"
|
||||
if err != nil {
|
||||
status = "error"
|
||||
}
|
||||
writeJSON(w, map[string]string{"status": status, "output": result.Body})
|
||||
}
|
||||
|
||||
func (h *handler) handleAPIGNVIDIASetPowerLimit(w http.ResponseWriter, r *http.Request) {
|
||||
if h.opts.App == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Index int `json:"index"`
|
||||
Watts float64 `json:"watts"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Watts <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "watts must be > 0")
|
||||
return
|
||||
}
|
||||
result, err := h.opts.App.SetNvidiaGPUPowerLimit(req.Index, req.Watts)
|
||||
status := "ok"
|
||||
if err != nil {
|
||||
status = "error"
|
||||
}
|
||||
writeJSON(w, map[string]string{"status": status, "output": result.Body})
|
||||
}
|
||||
|
||||
func (h *handler) handleAPIGNVIDIAResetDefaults(w http.ResponseWriter, _ *http.Request) {
|
||||
if h.opts.App == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
||||
return
|
||||
}
|
||||
result, err := h.opts.App.ResetNvidiaGPUDefaults()
|
||||
status := "ok"
|
||||
if err != nil {
|
||||
status = "error"
|
||||
}
|
||||
writeJSON(w, map[string]string{"status": status, "output": result.Body})
|
||||
}
|
||||
|
||||
func (h *handler) handleAPIGPUPresence(w http.ResponseWriter, r *http.Request) {
|
||||
if h.opts.App == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
||||
|
||||
@@ -404,11 +404,170 @@ loadNvidiaSelfHeal();
|
||||
func renderTools() string {
|
||||
return renderNVMeFormatCard() + `
|
||||
|
||||
` + renderGPUSettingsCard() + `
|
||||
|
||||
` + renderFRUEditorCard() + `
|
||||
|
||||
` + renderRAIDMgmtCard()
|
||||
}
|
||||
|
||||
func renderGPUSettingsCard() string {
|
||||
return `<div class="card"><div class="card-head card-head-actions">GPU Settings<div class="card-head-buttons">
|
||||
<button class="btn btn-sm btn-secondary" onclick="gpuSettingsRefresh()">↻ Refresh</button>
|
||||
<button class="btn btn-sm btn-secondary" onclick="gpuSettingsResetAll()">Reset All to Defaults</button>
|
||||
</div></div><div class="card-body">
|
||||
<p style="font-size:13px;color:var(--muted);margin-bottom:12px">ECC, MIG, and Confidential Computing modes persist across reboot but only take effect after a GPU reset (or host reboot). Power limit does NOT persist across reboot — it is reapplied here on demand.</p>
|
||||
<div id="gpu-settings-status" style="font-size:13px;color:var(--muted);margin-bottom:8px">Loading GPU settings...</div>
|
||||
<div id="gpu-settings-table"></div>
|
||||
<div id="gpu-settings-out" style="display:none;margin-top:12px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">
|
||||
<span id="gpu-settings-out-label" style="font-size:12px;font-weight:600;color:var(--muted)">Output</span>
|
||||
<span id="gpu-settings-out-status" style="font-size:12px"></span>
|
||||
</div>
|
||||
<div id="gpu-settings-terminal" class="terminal" style="max-height:220px;width:100%;box-sizing:border-box"></div>
|
||||
</div>
|
||||
</div></div>
|
||||
<script>
|
||||
function gpuSettingsShowResult(label, status, output) {
|
||||
var out = document.getElementById('gpu-settings-out');
|
||||
var term = document.getElementById('gpu-settings-terminal');
|
||||
var statusEl = document.getElementById('gpu-settings-out-status');
|
||||
var labelEl = document.getElementById('gpu-settings-out-label');
|
||||
out.style.display = 'block';
|
||||
labelEl.textContent = label;
|
||||
term.textContent = output || '(no output)';
|
||||
term.scrollTop = term.scrollHeight;
|
||||
if (status === 'ok') {
|
||||
statusEl.textContent = '✓ done';
|
||||
statusEl.style.color = 'var(--ok-fg, #2c662d)';
|
||||
} else {
|
||||
statusEl.textContent = '✗ failed';
|
||||
statusEl.style.color = 'var(--crit-fg, #9f3a38)';
|
||||
}
|
||||
}
|
||||
function gpuSettingsToggleMode(url, label, index, enable, btn) {
|
||||
var original = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = '...';
|
||||
gpuSettingsShowResult(label + ' gpu ' + index, 'ok', 'Running...');
|
||||
fetch(url, {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({index:index, enabled:enable})
|
||||
}).then(r=>r.json()).then(d => {
|
||||
gpuSettingsShowResult(label + ' gpu ' + index, d.status || 'error', d.output || '(no output)');
|
||||
setTimeout(gpuSettingsRefresh, 800);
|
||||
}).catch(e => {
|
||||
gpuSettingsShowResult(label + ' gpu ' + index, 'error', 'Request failed: ' + e);
|
||||
}).finally(() => {
|
||||
btn.disabled = false;
|
||||
btn.textContent = original;
|
||||
});
|
||||
}
|
||||
function gpuSettingsToggleECC(index, enable, btn) {
|
||||
gpuSettingsToggleMode('/api/gpu/nvidia-ecc', 'ecc', index, enable, btn);
|
||||
}
|
||||
function gpuSettingsToggleMIG(index, enable, btn) {
|
||||
gpuSettingsToggleMode('/api/gpu/nvidia-mig', 'mig', index, enable, btn);
|
||||
}
|
||||
function gpuSettingsToggleCC(index, enable, btn) {
|
||||
gpuSettingsToggleMode('/api/gpu/nvidia-cc', 'cc', index, enable, btn);
|
||||
}
|
||||
function gpuSettingsApplyPowerLimit(index, inputId, btn) {
|
||||
var input = document.getElementById(inputId);
|
||||
var watts = parseFloat(input.value);
|
||||
if (!watts || watts <= 0) {
|
||||
gpuSettingsShowResult('power limit gpu ' + index, 'error', 'Enter a valid wattage.');
|
||||
return;
|
||||
}
|
||||
var original = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Applying...';
|
||||
gpuSettingsShowResult('power limit gpu ' + index, 'ok', 'Running...');
|
||||
fetch('/api/gpu/nvidia-power-limit', {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({index:index, watts:watts})
|
||||
}).then(r=>r.json()).then(d => {
|
||||
gpuSettingsShowResult('power limit gpu ' + index, d.status || 'error', d.output || '(no output)');
|
||||
setTimeout(gpuSettingsRefresh, 800);
|
||||
}).catch(e => {
|
||||
gpuSettingsShowResult('power limit gpu ' + index, 'error', 'Request failed: ' + e);
|
||||
}).finally(() => {
|
||||
btn.disabled = false;
|
||||
btn.textContent = original;
|
||||
});
|
||||
}
|
||||
function gpuSettingsResetAll() {
|
||||
if (!confirm('Reset ECC, MIG, Confidential Computing, and power limit to factory defaults on ALL GPUs?')) return;
|
||||
gpuSettingsShowResult('reset all GPUs to defaults', 'ok', 'Running...');
|
||||
fetch('/api/gpu/nvidia-reset-defaults', {method:'POST'}).then(r=>r.json()).then(d => {
|
||||
gpuSettingsShowResult('reset all GPUs to defaults', d.status || 'error', d.output || '(no output)');
|
||||
setTimeout(gpuSettingsRefresh, 800);
|
||||
}).catch(e => {
|
||||
gpuSettingsShowResult('reset all GPUs to defaults', 'error', 'Request failed: ' + e);
|
||||
});
|
||||
}
|
||||
function gpuSettingsRefresh() {
|
||||
var status = document.getElementById('gpu-settings-status');
|
||||
var table = document.getElementById('gpu-settings-table');
|
||||
status.textContent = 'Loading GPU settings...';
|
||||
status.style.color = 'var(--muted)';
|
||||
fetch('/api/gpu/nvidia-settings').then(r=>r.json()).then(gpus => {
|
||||
if (!Array.isArray(gpus) || gpus.length === 0) {
|
||||
status.textContent = 'No NVIDIA GPUs detected or nvidia-smi is unavailable.';
|
||||
table.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
status.textContent = gpus.length + ' NVIDIA GPU(s) detected.';
|
||||
const rows = gpus.map(g => {
|
||||
const eccEnabled = /enabled/i.test(g.ecc_current);
|
||||
const eccPendingDiffers = g.ecc_pending && g.ecc_current && g.ecc_pending.toLowerCase() !== g.ecc_current.toLowerCase();
|
||||
const migSupported = g.mig_current && !/n\/a/i.test(g.mig_current);
|
||||
const migEnabled = /enabled/i.test(g.mig_current);
|
||||
const migPendingDiffers = g.mig_pending && g.mig_current && g.mig_pending.toLowerCase() !== g.mig_current.toLowerCase();
|
||||
const ccSupported = !!g.cc_state;
|
||||
const ccEnabled = /^on$/i.test(g.cc_state || '');
|
||||
const inputId = 'gpu-pl-' + g.index;
|
||||
return '<tr>'
|
||||
+ '<td style="white-space:nowrap">' + g.index + '</td>'
|
||||
+ '<td>' + (g.name || 'unknown') + '</td>'
|
||||
+ '<td style="white-space:nowrap">'
|
||||
+ (g.ecc_current || 'N/A')
|
||||
+ (eccPendingDiffers ? '<div style="font-size:11px;color:var(--warn-fg,#573a08)">pending: ' + g.ecc_pending + ' (reset required)</div>' : '')
|
||||
+ '<div style="margin-top:4px"><button class="btn btn-sm btn-secondary" onclick="gpuSettingsToggleECC(' + g.index + ', ' + (!eccEnabled) + ', this)">' + (eccEnabled ? 'Disable' : 'Enable') + '</button></div>'
|
||||
+ '</td>'
|
||||
+ '<td style="white-space:nowrap">'
|
||||
+ (migSupported ? (g.mig_current +
|
||||
(migPendingDiffers ? '<div style="font-size:11px;color:var(--warn-fg,#573a08)">pending: ' + g.mig_pending + ' (reset required)</div>' : '') +
|
||||
'<div style="margin-top:4px"><button class="btn btn-sm btn-secondary" onclick="gpuSettingsToggleMIG(' + g.index + ', ' + (!migEnabled) + ', this)">' + (migEnabled ? 'Disable' : 'Enable') + '</button></div>')
|
||||
: '<span style="color:var(--muted)">N/A</span>')
|
||||
+ '</td>'
|
||||
+ '<td style="white-space:nowrap">'
|
||||
+ (ccSupported ? (g.cc_state +
|
||||
'<div style="margin-top:4px"><button class="btn btn-sm btn-secondary" onclick="gpuSettingsToggleCC(' + g.index + ', ' + (!ccEnabled) + ', this)">' + (ccEnabled ? 'Disable' : 'Enable') + '</button></div>')
|
||||
: '<span style="color:var(--muted)">N/A</span>')
|
||||
+ '</td>'
|
||||
+ '<td style="white-space:nowrap">' + (g.power_limit_w ? g.power_limit_w + ' W' : 'N/A')
|
||||
+ (g.power_min_limit_w && g.power_max_limit_w ? '<div style="font-size:11px;color:var(--muted)">range ' + g.power_min_limit_w + '-' + g.power_max_limit_w + ' W, default ' + g.power_default_limit_w + ' W</div>' : '')
|
||||
+ '</td>'
|
||||
+ '<td style="white-space:nowrap">'
|
||||
+ '<input id="' + inputId + '" type="number" min="' + (g.power_min_limit_w||0) + '" max="' + (g.power_max_limit_w||0) + '" value="' + (g.power_limit_w||'') + '" style="width:80px;padding:3px 6px;border:1.5px solid #888;border-radius:3px;font-size:13px" /> '
|
||||
+ '<button class="btn btn-sm btn-secondary" onclick="gpuSettingsApplyPowerLimit(' + g.index + ', \'' + inputId + '\', this)">Apply</button>'
|
||||
+ '</td>'
|
||||
+ '</tr>';
|
||||
}).join('');
|
||||
table.innerHTML = '<table><tr><th>GPU</th><th>Model</th><th>ECC</th><th>MIG</th><th>Confidential Computing</th><th>Power Limit</th><th>Set Power Limit (W)</th></tr>' + rows + '</table>';
|
||||
}).catch(e => {
|
||||
status.textContent = 'Error loading GPU settings: ' + e;
|
||||
status.style.color = 'var(--crit-fg, #9f3a38)';
|
||||
table.innerHTML = '';
|
||||
});
|
||||
}
|
||||
gpuSettingsRefresh();
|
||||
</script>`
|
||||
}
|
||||
|
||||
func renderFRUEditorCard() string {
|
||||
return `<div class="card"><div class="card-head card-head-actions">FRU / Elabel<div class="card-head-buttons"><button class="btn btn-sm btn-secondary" onclick="fruAllRead()">Read All</button></div></div><div class="card-body">
|
||||
<p style="font-size:13px;color:var(--muted);margin-bottom:12px">Reads and edits hardware identity fields from all available sources. Each field shows its source method.</p>
|
||||
|
||||
@@ -331,6 +331,12 @@ func NewHandler(opts HandlerOptions) http.Handler {
|
||||
mux.HandleFunc("GET /api/gpu/nvidia", h.handleAPIGNVIDIAGPUs)
|
||||
mux.HandleFunc("GET /api/gpu/nvidia-status", h.handleAPIGNVIDIAGPUStatuses)
|
||||
mux.HandleFunc("POST /api/gpu/nvidia-reset", h.handleAPIGNVIDIAReset)
|
||||
mux.HandleFunc("GET /api/gpu/nvidia-settings", h.handleAPIGNVIDIAGPUSettings)
|
||||
mux.HandleFunc("POST /api/gpu/nvidia-ecc", h.handleAPIGNVIDIASetECC)
|
||||
mux.HandleFunc("POST /api/gpu/nvidia-mig", h.handleAPIGNVIDIASetMIG)
|
||||
mux.HandleFunc("POST /api/gpu/nvidia-cc", h.handleAPIGNVIDIASetCCMode)
|
||||
mux.HandleFunc("POST /api/gpu/nvidia-power-limit", h.handleAPIGNVIDIASetPowerLimit)
|
||||
mux.HandleFunc("POST /api/gpu/nvidia-reset-defaults", h.handleAPIGNVIDIAResetDefaults)
|
||||
mux.HandleFunc("GET /api/gpu/tools", h.handleAPIGPUTools)
|
||||
|
||||
// System
|
||||
|
||||
Reference in New Issue
Block a user