Adds an experimental power-cap mode using the undocumented RM ioctl interface (based on panchovix's LACT PR #1205) to set power limits below the VBIOS minimum (down to 30 W). - hal/rm_power.py: RM ioctl power-cap read/write/reset + runtime probe - limits.py: power_cap_mode (nvml/ioctl) with support detection - config.py: persist power_cap_mode per GPU - profiles: record/apply power_cap_mode - server.py: POST /api/limits validates ioctl support (409 on failure) - cli.py: profile save falls back to persisted mode - client.py: power_cap_mode in Limits - frontend: toggle + warning with panchovix attribution (LACT #1205) - tests: test_rm_power.py (unit) + integration coverage - Makefile: add test_rm_power.py to make test Also includes automated linter reformatting (prettier, ruff, shellcheck, isort, markdownlint) that the linter would apply anyway.
414 lines
16 KiB
TypeScript
414 lines
16 KiB
TypeScript
import { useState, useEffect, useRef } from "react";
|
|
import {
|
|
Save,
|
|
Trash2,
|
|
Check,
|
|
ChevronRight,
|
|
Pencil,
|
|
Star,
|
|
Fan,
|
|
} from "lucide-react";
|
|
import { api } from "../../api/client.js";
|
|
import type { ProfileData } from "../../types.js";
|
|
import { toast } from "sonner";
|
|
import { useCurveStore } from "../../store/curveStore.js";
|
|
|
|
function errMsg(e: unknown): string {
|
|
return e instanceof Error ? e.message : String(e);
|
|
}
|
|
|
|
interface ProfilePanelProps {
|
|
activeProfile: string | null;
|
|
onProfileApplied: (name: string | null) => void;
|
|
}
|
|
|
|
export function ProfilePanel({
|
|
activeProfile,
|
|
onProfileApplied,
|
|
}: ProfilePanelProps) {
|
|
const { selectedGpuIndex, gpuInfo } = useCurveStore();
|
|
const [profiles, setProfiles] = useState<ProfileData[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [autoLoadProfile, setAutoLoadProfile] = useState<string | null>(null);
|
|
|
|
// Save form
|
|
const [isSaveOpen, setIsSaveOpen] = useState(false);
|
|
const [newName, setNewName] = useState("");
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const saveInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Inline delete confirmation
|
|
const [deletingName, setDeletingName] = useState<string | null>(null);
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
|
|
// Inline rename
|
|
const [renamingName, setRenamingName] = useState<string | null>(null);
|
|
const [renameValue, setRenameValue] = useState("");
|
|
const [isRenaming, setIsRenaming] = useState(false);
|
|
const renameInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Apply in-flight
|
|
const [applyingName, setApplyingName] = useState<string | null>(null);
|
|
|
|
async function fetchProfiles() {
|
|
try {
|
|
const data = await api.profiles(selectedGpuIndex);
|
|
setProfiles(data.profiles);
|
|
onProfileApplied(data.active);
|
|
setAutoLoadProfile(data.auto_load);
|
|
} catch {
|
|
toast.error("Failed to load profiles");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleSetAutoLoad(name: string | null) {
|
|
try {
|
|
await api.setAutoLoadProfile(name, selectedGpuIndex);
|
|
setAutoLoadProfile(name);
|
|
if (name) toast.success(`"${name}" will load on server start`);
|
|
else toast.success("Auto-load cleared");
|
|
} catch (e: unknown) {
|
|
toast.error("Failed to update default profile: " + errMsg(e));
|
|
}
|
|
}
|
|
|
|
// Data fetch on GPU change — setState calls happen after the await, not
|
|
// synchronously in the effect body (rule false-positive on async fetch).
|
|
useEffect(() => {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
fetchProfiles();
|
|
}, [selectedGpuIndex]);
|
|
|
|
useEffect(() => {
|
|
if (isSaveOpen) saveInputRef.current?.focus();
|
|
}, [isSaveOpen]);
|
|
|
|
useEffect(() => {
|
|
if (renamingName) renameInputRef.current?.focus();
|
|
}, [renamingName]);
|
|
|
|
function closeSaveForm() {
|
|
setIsSaveOpen(false);
|
|
setNewName("");
|
|
}
|
|
|
|
async function handleSave(e: React.SubmitEvent) {
|
|
e.preventDefault();
|
|
if (!newName.trim()) return;
|
|
try {
|
|
setIsSaving(true);
|
|
await api.saveProfile(newName.trim(), selectedGpuIndex);
|
|
toast.success(`Profile "${newName.trim()}" saved`);
|
|
closeSaveForm();
|
|
await fetchProfiles();
|
|
} catch (e: unknown) {
|
|
toast.error("Failed to save: " + errMsg(e));
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleApply(name: string) {
|
|
try {
|
|
setApplyingName(name);
|
|
await api.applyProfile(name, selectedGpuIndex);
|
|
onProfileApplied(name);
|
|
toast.success(`"${name}" applied`);
|
|
} catch (e: unknown) {
|
|
toast.error(`Failed to apply "${name}": ` + errMsg(e));
|
|
} finally {
|
|
setApplyingName(null);
|
|
}
|
|
}
|
|
|
|
async function handleDeleteConfirm(name: string) {
|
|
try {
|
|
setIsDeleting(true);
|
|
await api.deleteProfile(name);
|
|
toast.success(`"${name}" deleted`);
|
|
if (activeProfile === name) onProfileApplied(null);
|
|
if (autoLoadProfile === name) setAutoLoadProfile(null);
|
|
setDeletingName(null);
|
|
await fetchProfiles();
|
|
} catch (e: unknown) {
|
|
toast.error("Failed to delete: " + errMsg(e));
|
|
} finally {
|
|
setIsDeleting(false);
|
|
}
|
|
}
|
|
|
|
async function handleRename(e: React.SubmitEvent, oldName: string) {
|
|
e.preventDefault();
|
|
if (!renameValue.trim() || renameValue.trim() === oldName) {
|
|
setRenamingName(null);
|
|
return;
|
|
}
|
|
try {
|
|
setIsRenaming(true);
|
|
await api.renameProfile(oldName, renameValue.trim());
|
|
toast.success(`Renamed to "${renameValue.trim()}"`);
|
|
if (activeProfile === oldName) onProfileApplied(renameValue.trim());
|
|
if (autoLoadProfile === oldName) setAutoLoadProfile(renameValue.trim());
|
|
setRenamingName(null);
|
|
await fetchProfiles();
|
|
} catch (e: unknown) {
|
|
toast.error("Failed to rename: " + errMsg(e));
|
|
} finally {
|
|
setIsRenaming(false);
|
|
}
|
|
}
|
|
|
|
if (loading && profiles.length === 0) {
|
|
return (
|
|
<div className="bg-zinc-900 rounded-lg p-6 flex flex-col gap-4 animate-pulse">
|
|
<div className="h-6 w-32 bg-zinc-800 rounded" />
|
|
<div className="h-20 w-full bg-zinc-800 rounded" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="bg-zinc-900 border border-zinc-800 rounded-lg overflow-hidden flex flex-col">
|
|
{/* Header */}
|
|
<div className="px-4 py-3 border-b border-zinc-800 bg-zinc-950/50 flex items-center gap-2">
|
|
<Save size={16} className="text-pink-500" />
|
|
<h2 className="text-zinc-100 font-medium">Profiles</h2>
|
|
</div>
|
|
|
|
{/* Save section */}
|
|
<div className="px-4 py-3 border-b border-zinc-800">
|
|
{isSaveOpen ? (
|
|
<form onSubmit={handleSave} className="flex gap-2">
|
|
<input
|
|
ref={saveInputRef}
|
|
type="text"
|
|
placeholder="Profile name..."
|
|
value={newName}
|
|
onChange={(e) => setNewName(e.target.value)}
|
|
disabled={isSaving}
|
|
onKeyDown={(e) => e.key === "Escape" && closeSaveForm()}
|
|
className="flex-1 min-w-0 bg-zinc-950 border border-zinc-700 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-pink-500 focus:ring-1 focus:ring-pink-500 disabled:opacity-50"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={!newName.trim() || isSaving}
|
|
className="px-3 py-1.5 bg-pink-600 hover:bg-pink-500 disabled:bg-zinc-800 disabled:text-zinc-500 text-white rounded text-sm font-medium transition shrink-0"
|
|
>
|
|
Save
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={closeSaveForm}
|
|
className="px-3 py-1.5 text-zinc-400 hover:text-zinc-200 rounded text-sm transition shrink-0"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</form>
|
|
) : (
|
|
<button
|
|
onClick={() => setIsSaveOpen(true)}
|
|
className="w-full flex items-center justify-center gap-2 px-3 py-1.5 rounded text-sm font-medium text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 transition border border-zinc-800 hover:border-zinc-700"
|
|
>
|
|
<Save size={14} />
|
|
Save Current State
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Profile list */}
|
|
<div className="overflow-y-auto max-h-72 py-2 scrollbar-thin scrollbar-thumb-zinc-700 scrollbar-track-transparent">
|
|
{profiles.length === 0 ? (
|
|
<p className="text-center text-zinc-500 text-sm py-8 px-4">
|
|
No profiles saved yet.
|
|
</p>
|
|
) : (
|
|
<div className="flex flex-col gap-0.5 px-2">
|
|
{profiles.map((p) => {
|
|
const pts = Object.keys(p.curve_deltas).length;
|
|
const badges = [
|
|
pts > 0 ? `${pts} pts` : null,
|
|
p.power_limit_w != null ? `${p.power_limit_w}W` : null,
|
|
p.mem_offset_mhz != null
|
|
? `${p.mem_offset_mhz > 0 ? "+" : ""}${p.mem_offset_mhz} MHz mem`
|
|
: null,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" · ");
|
|
|
|
const isActive = activeProfile === p.name;
|
|
const isAutoLoad = autoLoadProfile === p.name;
|
|
const isApplying = applyingName === p.name;
|
|
const isConfirmingDelete = deletingName === p.name;
|
|
const isRenaming_ = renamingName === p.name;
|
|
|
|
return (
|
|
<div
|
|
key={p.name}
|
|
className={`group rounded-md transition ${isActive ? "bg-zinc-800/60" : "hover:bg-zinc-800/50"}`}
|
|
>
|
|
{isConfirmingDelete ? (
|
|
<div className="flex items-center justify-between px-3 py-2 gap-2">
|
|
<span className="text-sm text-zinc-300 truncate min-w-0">
|
|
Delete "{p.name}"?
|
|
</span>
|
|
<div className="flex gap-1.5 shrink-0">
|
|
<button
|
|
onClick={() => handleDeleteConfirm(p.name)}
|
|
disabled={isDeleting}
|
|
className="px-2 py-1 text-xs rounded bg-red-600 hover:bg-red-500 text-white font-medium transition disabled:opacity-50"
|
|
>
|
|
Delete
|
|
</button>
|
|
<button
|
|
onClick={() => setDeletingName(null)}
|
|
className="px-2 py-1 text-xs rounded bg-zinc-700 hover:bg-zinc-600 text-zinc-300 transition"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : isRenaming_ ? (
|
|
<form
|
|
onSubmit={(e) => handleRename(e, p.name)}
|
|
className="flex items-center gap-2 px-3 py-2"
|
|
>
|
|
<input
|
|
ref={renameInputRef}
|
|
type="text"
|
|
value={renameValue}
|
|
onChange={(e) => setRenameValue(e.target.value)}
|
|
disabled={isRenaming}
|
|
onKeyDown={(e) =>
|
|
e.key === "Escape" && setRenamingName(null)
|
|
}
|
|
className="flex-1 min-w-0 bg-zinc-950 border border-zinc-600 rounded px-2 py-1 text-sm focus:outline-none focus:border-pink-500 focus:ring-1 focus:ring-pink-500 disabled:opacity-50"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={!renameValue.trim() || isRenaming}
|
|
className="px-2 py-1 text-xs rounded bg-zinc-700 hover:bg-zinc-600 text-zinc-200 font-medium transition disabled:opacity-50 shrink-0"
|
|
>
|
|
OK
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setRenamingName(null)}
|
|
className="px-2 py-1 text-xs rounded text-zinc-500 hover:text-zinc-300 transition shrink-0"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</form>
|
|
) : (
|
|
<div className="flex items-center justify-between px-3 py-2">
|
|
<div className="flex items-center gap-2 min-w-0 pr-2">
|
|
{isActive ? (
|
|
<Check
|
|
size={13}
|
|
className="text-emerald-400 shrink-0"
|
|
/>
|
|
) : (
|
|
<span className="w-[13px] shrink-0" />
|
|
)}
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-1.5">
|
|
<p
|
|
className={`text-sm font-medium truncate ${isActive ? "text-zinc-100" : "text-zinc-300"}`}
|
|
>
|
|
{p.name}
|
|
</p>
|
|
{isAutoLoad && (
|
|
<Star
|
|
size={11}
|
|
className="text-sky-400 shrink-0"
|
|
fill="currentColor"
|
|
/>
|
|
)}
|
|
{p.fan_curve && p.fan_curve.length > 0 && (
|
|
<span
|
|
className="shrink-0 text-cyan-400"
|
|
title="Custom fan curve saved"
|
|
>
|
|
<Fan size={11} />
|
|
</span>
|
|
)}
|
|
{gpuInfo && p.gpu_name !== gpuInfo.name && (
|
|
<span
|
|
className="text-xs text-zinc-600 truncate shrink-0"
|
|
title={`Saved from ${p.gpu_name}`}
|
|
>
|
|
{p.gpu_name}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{badges && (
|
|
<p className="text-xs text-zinc-500">{badges}</p>
|
|
)}
|
|
{p.power_cap_mode === "ioctl" && (
|
|
<p className="text-xs text-red-400 font-medium">
|
|
⚠ experimental power (below VBIOS min)
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-1 shrink-0 opacity-100 lg:opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<button
|
|
onClick={() => handleApply(p.name)}
|
|
disabled={isApplying}
|
|
className="flex items-center gap-1 px-2 py-1 text-xs rounded text-zinc-400 hover:text-emerald-400 hover:bg-zinc-700 transition disabled:opacity-50 font-medium"
|
|
>
|
|
{isApplying ? (
|
|
<span className="w-3 h-3 border border-zinc-500 border-t-emerald-400 rounded-full animate-spin" />
|
|
) : (
|
|
<ChevronRight size={13} />
|
|
)}
|
|
Apply
|
|
</button>
|
|
<button
|
|
onClick={() =>
|
|
handleSetAutoLoad(isAutoLoad ? null : p.name)
|
|
}
|
|
className={`p-1.5 rounded transition hover:bg-zinc-700 ${isAutoLoad ? "text-sky-400 hover:text-sky-300" : "text-zinc-600 hover:text-sky-400"}`}
|
|
title={
|
|
isAutoLoad
|
|
? "Clear default profile"
|
|
: "Set as default profile"
|
|
}
|
|
>
|
|
<Star
|
|
size={13}
|
|
fill={isAutoLoad ? "currentColor" : "none"}
|
|
/>
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setRenamingName(p.name);
|
|
setRenameValue(p.name);
|
|
}}
|
|
className="p-1.5 text-zinc-600 hover:text-zinc-300 hover:bg-zinc-700 rounded transition"
|
|
title="Rename"
|
|
>
|
|
<Pencil size={13} />
|
|
</button>
|
|
<button
|
|
onClick={() => setDeletingName(p.name)}
|
|
className="p-1.5 text-zinc-600 hover:text-red-400 hover:bg-zinc-700 rounded transition"
|
|
title="Delete"
|
|
>
|
|
<Trash2 size={13} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|