Files
ubuntu-bare-metal/stacks/gpu-fan/static/index.html
T
tomasz-syn-grzegorza 359afb3a59 Initial import: bare-metal stacks, Server UI, GPU fan, tutorials.
Infrastructure configs for GMKtec K11 (Docker, vLLM, LocalAI, ComfyUI,
control-plane, gpu-fan agent, Server UI with CLI/file explorer/GPU fan curve).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 12:02:04 +00:00

756 lines
26 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GPU Fan Control — RTX 3090 Ti</title>
<style>
:root {
--bg: #0d1117;
--panel: #161b22;
--border: #30363d;
--text: #e6edf3;
--muted: #8b949e;
--accent: #58a6ff;
--green: #3fb950;
--orange: #d29922;
--red: #f85149;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
header {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
h1 { margin: 0; font-size: 1.25rem; font-weight: 600; }
.subtitle { color: var(--muted); font-size: 0.875rem; }
main { padding: 1.5rem; max-width: 1100px; margin: 0 auto; }
.grid {
display: grid;
grid-template-columns: 1fr 320px;
gap: 1rem;
}
@media (max-width: 900px) {
.grid { grid-template-columns: 1fr; }
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
}
.card h2 {
margin: 0 0 1rem;
font-size: 0.95rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stats { display: grid; gap: 0.75rem; }
.stat {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.stat-value { font-size: 1.5rem; font-weight: 600; }
.stat-label { color: var(--muted); font-size: 0.85rem; }
.temp-hot { color: var(--red); }
.temp-warm { color: var(--orange); }
.temp-cool { color: var(--green); }
.mode-badge {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
background: #21262d;
border: 1px solid var(--border);
}
#curve-svg {
width: 100%;
height: 360px;
background: #0d1117;
border-radius: 6px;
cursor: crosshair;
touch-action: none;
}
.axis-label { fill: var(--muted); font-size: 11px; }
.curve-line { stroke: var(--accent); stroke-width: 2; fill: none; }
.curve-fill { fill: rgba(88, 166, 255, 0.08); }
.curve-point {
fill: var(--accent);
stroke: #fff;
stroke-width: 2;
cursor: grab;
}
.curve-point:active { cursor: grabbing; }
table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
th, td {
padding: 0.4rem 0.5rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
th { color: var(--muted); font-weight: 500; }
input[type="number"] {
width: 4rem;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
border-radius: 4px;
padding: 0.25rem 0.4rem;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1rem;
}
button {
background: #21262d;
border: 1px solid var(--border);
color: var(--text);
padding: 0.5rem 0.9rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
}
button:hover { border-color: var(--accent); }
button.primary {
background: #238636;
border-color: #2ea043;
}
button.primary:hover { background: #2ea043; }
button.danger {
background: #da3633;
border-color: #f85149;
}
#toast {
position: fixed;
bottom: 1rem;
right: 1rem;
padding: 0.75rem 1rem;
border-radius: 6px;
background: var(--panel);
border: 1px solid var(--border);
display: none;
z-index: 100;
}
#toast.error { border-color: var(--red); color: var(--red); }
#toast.ok { border-color: var(--green); }
.hint { color: var(--muted); font-size: 0.8rem; margin-top: 0.5rem; }
.monitoring-section { margin-top: 1rem; }
.gauge-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin-bottom: 1rem;
}
@media (max-width: 700px) {
.gauge-grid { grid-template-columns: 1fr; }
}
.gauge {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.75rem 1rem;
}
.gauge-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.5rem;
}
.gauge-label { color: var(--muted); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; }
.gauge-value { font-size: 1.25rem; font-weight: 600; }
.gauge-bar {
height: 10px;
background: #21262d;
border-radius: 5px;
overflow: hidden;
}
.gauge-fill {
height: 100%;
border-radius: 5px;
transition: width 0.4s ease;
}
.gauge-fill.util { background: linear-gradient(90deg, var(--green), var(--orange), var(--red)); }
.gauge-fill.power { background: linear-gradient(90deg, var(--accent), var(--orange), var(--red)); }
.gauge-fill.vram { background: linear-gradient(90deg, #6e40c9, var(--accent)); }
.gauge-sub { color: var(--muted); font-size: 0.75rem; margin-top: 0.35rem; }
.chart-wrap {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
}
.chart-legend {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 0.5rem;
font-size: 0.8rem;
color: var(--muted);
}
.chart-legend span::before {
content: '';
display: inline-block;
width: 10px;
height: 3px;
margin-right: 0.35rem;
vertical-align: middle;
border-radius: 1px;
}
.legend-util::before { background: var(--accent); }
.legend-power::before { background: var(--orange); }
.legend-temp::before { background: var(--red); }
#history-canvas {
width: 100%;
height: 140px;
display: block;
}
.sensor-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
}
@media (max-width: 700px) {
.sensor-grid { grid-template-columns: repeat(2, 1fr); }
}
.sensor-tile {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.75rem;
text-align: center;
}
.sensor-value {
font-size: 1.35rem;
font-weight: 600;
line-height: 1.2;
}
.sensor-label {
color: var(--muted);
font-size: 0.75rem;
margin-top: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
</style>
</head>
<body>
<header>
<div>
<h1>GPU Fan Control</h1>
<div class="subtitle" id="gpu-name">Ładowanie…</div>
</div>
<span class="mode-badge" id="mode-badge"></span>
</header>
<main>
<div class="grid">
<div class="card">
<h2>Krzywa wentylatorów</h2>
<svg id="curve-svg" viewBox="0 0 600 360">
<defs>
<linearGradient id="tempGrad" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stop-color="#3fb950" stop-opacity="0.15"/>
<stop offset="50%" stop-color="#d29922" stop-opacity="0.15"/>
<stop offset="100%" stop-color="#f85149" stop-opacity="0.15"/>
</linearGradient>
</defs>
<rect x="50" y="20" width="520" height="300" fill="url(#tempGrad)" stroke="#30363d"/>
<text x="300" y="355" class="axis-label" text-anchor="middle">Temperatura (°C)</text>
<text x="15" y="170" class="axis-label" transform="rotate(-90 15 170)" text-anchor="middle">Prędkość (%)</text>
<path id="curve-fill" class="curve-fill"/>
<polyline id="curve-line" class="curve-line"/>
<g id="points-layer"></g>
<line id="live-temp" stroke="#f85149" stroke-width="1" stroke-dasharray="4 4" opacity="0.7"/>
<circle id="live-dot" r="5" fill="#f85149" opacity="0.9"/>
</svg>
<p class="hint">Przeciągnij punkty na wykresie. Prędkość: 30100% (API NVIDIA). Min. 3, max. 7 punktów.</p>
<table id="points-table">
<thead>
<tr><th>#</th><th>Temp (°C)</th><th>Speed (%)</th><th></th></tr>
</thead>
<tbody></tbody>
</table>
<div class="actions">
<button type="button" id="btn-add">+ Punkt</button>
<button type="button" class="primary" id="btn-save">Zapisz krzywą</button>
<button type="button" id="btn-reload">Przeładuj z pliku</button>
<button type="button" id="btn-auto">Tryb auto (driver)</button>
<button type="button" class="danger" id="btn-max">Manual 100%</button>
</div>
</div>
<div class="card">
<h2>Status GPU</h2>
<div class="stats">
<div class="stat">
<span class="stat-label">Wentylatory</span>
<span class="stat-value" id="stat-fans"></span>
</div>
<div class="stat">
<span class="stat-label">Cel (krzywa)</span>
<span class="stat-value" id="stat-target"></span>
</div>
</div>
</div>
</div>
<div class="card monitoring-section">
<h2>Monitoring GPU</h2>
<div class="gauge-grid">
<div class="gauge">
<div class="gauge-header">
<span class="gauge-label">Obciążenie GPU</span>
<span class="gauge-value" id="gauge-util-val"></span>
</div>
<div class="gauge-bar"><div class="gauge-fill util" id="gauge-util-bar" style="width:0%"></div></div>
</div>
<div class="gauge">
<div class="gauge-header">
<span class="gauge-label">Pobór mocy</span>
<span class="gauge-value" id="gauge-power-val"></span>
</div>
<div class="gauge-bar"><div class="gauge-fill power" id="gauge-power-bar" style="width:0%"></div></div>
<div class="gauge-sub" id="gauge-power-sub"></div>
</div>
<div class="gauge">
<div class="gauge-header">
<span class="gauge-label">Pamięć VRAM</span>
<span class="gauge-value" id="gauge-vram-val"></span>
</div>
<div class="gauge-bar"><div class="gauge-fill vram" id="gauge-vram-bar" style="width:0%"></div></div>
<div class="gauge-sub" id="gauge-vram-sub"></div>
</div>
</div>
<div class="chart-wrap">
<div class="chart-legend">
<span class="legend-util">Wykorzystanie GPU (%)</span>
<span class="legend-power">Moc (W)</span>
<span class="legend-temp">Temperatura (°C)</span>
</div>
<canvas id="history-canvas"></canvas>
</div>
<div class="sensor-grid">
<div class="sensor-tile">
<div class="sensor-value" id="sensor-temp"></div>
<div class="sensor-label">Temperatura</div>
</div>
<div class="sensor-tile">
<div class="sensor-value" id="sensor-fans"></div>
<div class="sensor-label">Wentylatory</div>
</div>
<div class="sensor-tile">
<div class="sensor-value" id="sensor-gpu-clock"></div>
<div class="sensor-label">Taktowanie GPU</div>
</div>
<div class="sensor-tile">
<div class="sensor-value" id="sensor-mem-clock"></div>
<div class="sensor-label">Taktowanie pamięci</div>
</div>
<div class="sensor-tile">
<div class="sensor-value" id="sensor-mem-util"></div>
<div class="sensor-label">Wykorzystanie pamięci</div>
</div>
<div class="sensor-tile">
<div class="sensor-value" id="sensor-mode"></div>
<div class="sensor-label">Tryb wentylatora</div>
</div>
</div>
</div>
</main>
<div id="toast"></div>
<script>
const PAD = { left: 50, top: 20, width: 520, height: 300 };
const TEMP_MIN = 20;
const TEMP_MAX = 90;
const SPEED_MIN = 30;
const SPEED_MAX = 100;
let points = [];
let liveTemp = null;
let dragging = null;
const history = [];
const HISTORY_MAX = 60;
let apiKey = localStorage.getItem('gpu-fan-api-key') || '';
const urlKey = new URLSearchParams(location.search).get('api_key');
if (urlKey) {
apiKey = urlKey;
localStorage.setItem('gpu-fan-api-key', urlKey);
history.replaceState({}, '', location.pathname);
}
function headers() {
const h = { 'Content-Type': 'application/json' };
if (apiKey) h['X-API-Key'] = apiKey;
return h;
}
function toast(msg, type = 'ok') {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = type;
el.style.display = 'block';
setTimeout(() => { el.style.display = 'none'; }, 3500);
}
async function api(path, opts = {}) {
const res = await fetch(path, { ...opts, headers: { ...headers(), ...opts.headers } });
if (res.status === 401) {
const key = prompt('Podaj API key (X-API-Key):');
if (key) {
apiKey = key;
localStorage.setItem('gpu-fan-api-key', key);
return api(path, opts);
}
throw new Error('Brak autoryzacji');
}
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || res.statusText);
return data;
}
function tempToX(t) {
return PAD.left + ((t - TEMP_MIN) / (TEMP_MAX - TEMP_MIN)) * PAD.width;
}
function speedToY(s) {
return PAD.top + PAD.height - ((s - SPEED_MIN) / (SPEED_MAX - SPEED_MIN)) * PAD.height;
}
function xToTemp(x) {
return Math.round(TEMP_MIN + ((x - PAD.left) / PAD.width) * (TEMP_MAX - TEMP_MIN));
}
function yToSpeed(y) {
const raw = SPEED_MIN + ((PAD.top + PAD.height - y) / PAD.height) * (SPEED_MAX - SPEED_MIN);
return Math.round(Math.max(SPEED_MIN, Math.min(SPEED_MAX, raw)));
}
function sortPoints() {
points.sort((a, b) => a.temp - b.temp);
}
function drawCurve() {
sortPoints();
const pts = points.map(p => `${tempToX(p.temp)},${speedToY(p.speed)}`).join(' ');
document.getElementById('curve-line').setAttribute('points', pts);
const fill = points.length
? `${PAD.left},${PAD.top + PAD.height} ` + pts + ` ${PAD.left + PAD.width},${PAD.top + PAD.height}`
: '';
document.getElementById('curve-fill').setAttribute('d', fill ? `M ${fill} Z` : '');
const layer = document.getElementById('points-layer');
layer.innerHTML = '';
points.forEach((p, i) => {
const c = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
c.setAttribute('cx', tempToX(p.temp));
c.setAttribute('cy', speedToY(p.speed));
c.setAttribute('r', 8);
c.classList.add('curve-point');
c.dataset.index = i;
c.addEventListener('mousedown', e => { dragging = i; e.preventDefault(); });
c.addEventListener('touchstart', e => { dragging = i; e.preventDefault(); }, { passive: false });
layer.appendChild(c);
});
if (liveTemp != null) {
const x = tempToX(liveTemp);
document.getElementById('live-temp').setAttribute('x1', x);
document.getElementById('live-temp').setAttribute('y1', PAD.top);
document.getElementById('live-temp').setAttribute('x2', x);
document.getElementById('live-temp').setAttribute('y2', PAD.top + PAD.height);
document.getElementById('live-dot').setAttribute('cx', x);
document.getElementById('live-dot').setAttribute('cy', speedToY(interpolate(liveTemp)));
document.getElementById('live-dot').style.display = '';
} else {
document.getElementById('live-dot').style.display = 'none';
}
renderTable();
}
function interpolate(temp) {
if (!points.length) return SPEED_MIN;
if (temp <= points[0].temp) return points[0].speed;
if (temp >= points[points.length - 1].temp) return points[points.length - 1].speed;
for (let i = 0; i < points.length - 1; i++) {
const a = points[i], b = points[i + 1];
if (temp >= a.temp && temp <= b.temp) {
const r = (temp - a.temp) / (b.temp - a.temp);
return Math.round(a.speed + r * (b.speed - a.speed));
}
}
return points[points.length - 1].speed;
}
function renderTable() {
const tbody = document.querySelector('#points-table tbody');
tbody.innerHTML = '';
points.forEach((p, i) => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${i + 1}</td>
<td><input type="number" min="0" max="120" value="${p.temp}" data-i="${i}" data-field="temp"></td>
<td><input type="number" min="30" max="100" value="${p.speed}" data-i="${i}" data-field="speed"></td>
<td><button type="button" data-del="${i}" ${points.length <= 3 ? 'disabled' : ''}>×</button></td>
`;
tbody.appendChild(tr);
});
tbody.querySelectorAll('input').forEach(inp => {
inp.addEventListener('change', () => {
const i = +inp.dataset.i;
const v = +inp.value;
if (inp.dataset.field === 'temp') points[i].temp = v;
else points[i].speed = Math.max(SPEED_MIN, Math.min(SPEED_MAX, v));
drawCurve();
});
});
tbody.querySelectorAll('button[data-del]').forEach(btn => {
btn.addEventListener('click', () => {
if (points.length <= 3) return;
points.splice(+btn.dataset.del, 1);
drawCurve();
});
});
}
function onPointerMove(clientX, clientY) {
if (dragging === null) return;
const svg = document.getElementById('curve-svg');
const rect = svg.getBoundingClientRect();
const scaleX = 600 / rect.width;
const scaleY = 360 / rect.height;
const x = (clientX - rect.left) * scaleX;
const y = (clientY - rect.top) * scaleY;
let temp = xToTemp(x);
let speed = yToSpeed(y);
temp = Math.max(TEMP_MIN, Math.min(TEMP_MAX, temp));
const prev = dragging > 0 ? points[dragging - 1].temp + 1 : TEMP_MIN;
const next = dragging < points.length - 1 ? points[dragging + 1].temp - 1 : TEMP_MAX;
points[dragging].temp = Math.max(prev, Math.min(next, temp));
points[dragging].speed = speed;
drawCurve();
}
document.addEventListener('mousemove', e => onPointerMove(e.clientX, e.clientY));
document.addEventListener('mouseup', () => { dragging = null; });
document.addEventListener('touchmove', e => {
if (e.touches[0]) onPointerMove(e.touches[0].clientX, e.touches[0].clientY);
}, { passive: false });
document.addEventListener('touchend', () => { dragging = null; });
async function loadCurve() {
const data = await api('/api/curve');
points = data.points;
drawCurve();
}
function fmtMb(mb) {
if (mb == null) return '—';
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`;
return `${mb} MB`;
}
function tempClass(temp) {
return temp >= 70 ? 'temp-hot' : temp >= 55 ? 'temp-warm' : 'temp-cool';
}
function pushHistory(s) {
history.push({
t: Date.now(),
util: s.utilization_pct ?? 0,
power: s.power_w ?? 0,
temp: s.temperature_c ?? 0,
});
if (history.length > HISTORY_MAX) history.shift();
}
function updateGauges(s) {
const util = s.utilization_pct ?? 0;
document.getElementById('gauge-util-val').textContent = `${util}%`;
document.getElementById('gauge-util-bar').style.width = `${Math.min(100, util)}%`;
const power = s.power_w ?? 0;
const powerLimit = s.power_limit_w;
document.getElementById('gauge-power-val').textContent = `${power} W`;
const powerPct = powerLimit ? Math.min(100, (power / powerLimit) * 100) : Math.min(100, (power / 450) * 100);
document.getElementById('gauge-power-bar').style.width = `${powerPct}%`;
document.getElementById('gauge-power-sub').textContent =
powerLimit != null ? `Limit: ${powerLimit} W` : 'Limit: —';
const used = s.memory_used_mb;
const total = s.memory_total_mb;
if (used != null && total) {
const pct = Math.min(100, (used / total) * 100);
document.getElementById('gauge-vram-val').textContent = fmtMb(used);
document.getElementById('gauge-vram-bar').style.width = `${pct}%`;
document.getElementById('gauge-vram-sub').textContent = `${fmtMb(used)} / ${fmtMb(total)}`;
} else {
document.getElementById('gauge-vram-val').textContent = '—';
document.getElementById('gauge-vram-bar').style.width = '0%';
document.getElementById('gauge-vram-sub').textContent = '—';
}
}
function drawSparklines() {
const canvas = document.getElementById('history-canvas');
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
const w = rect.width;
const h = rect.height;
const pad = { top: 8, right: 8, bottom: 8, left: 8 };
const plotW = w - pad.left - pad.right;
const plotH = h - pad.top - pad.bottom;
ctx.fillStyle = '#0d1117';
ctx.fillRect(0, 0, w, h);
if (history.length < 2) {
ctx.fillStyle = '#8b949e';
ctx.font = '12px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Zbieranie danych…', w / 2, h / 2);
return;
}
const maxPower = Math.max(...history.map(p => p.power), 1);
const maxTemp = Math.max(...history.map(p => p.temp), 1);
function drawSeries(key, color, maxVal) {
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
const n = history.length;
history.forEach((p, i) => {
const x = pad.left + (i / Math.max(1, n - 1)) * plotW;
const y = pad.top + plotH - (p[key] / maxVal) * plotH;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
}
drawSeries('util', '#58a6ff', 100);
drawSeries('power', '#d29922', maxPower);
drawSeries('temp', '#f85149', maxTemp);
}
function updateSensorTiles(s) {
const temp = s.temperature_c;
const tempEl = document.getElementById('sensor-temp');
tempEl.textContent = temp != null ? `${temp}°C` : '—';
tempEl.className = 'sensor-value ' + (temp != null ? tempClass(temp) : '');
document.getElementById('sensor-fans').textContent =
(s.fan_speeds_pct || []).map(f => f >= 0 ? `${f}%` : '—').join(' / ') || '—';
document.getElementById('sensor-gpu-clock').textContent =
s.clock_graphics_mhz != null ? `${s.clock_graphics_mhz} MHz` : '—';
document.getElementById('sensor-mem-clock').textContent =
s.clock_memory_mhz != null ? `${s.clock_memory_mhz} MHz` : '—';
document.getElementById('sensor-mem-util').textContent =
s.memory_utilization_pct != null ? `${s.memory_utilization_pct}%` : '—';
document.getElementById('sensor-mode').textContent = s.mode || '—';
}
async function refreshStatus() {
try {
const s = await api('/api/status');
document.getElementById('gpu-name').textContent = s.gpu_name || '—';
document.getElementById('mode-badge').textContent = `Tryb: ${s.mode}`;
liveTemp = s.temperature_c;
document.getElementById('stat-fans').textContent =
(s.fan_speeds_pct || []).map(f => f >= 0 ? `${f}%` : '—').join(' / ') || '—';
document.getElementById('stat-target').textContent =
s.target_speed_pct != null ? `${s.target_speed_pct}%` : 'auto';
pushHistory(s);
updateGauges(s);
updateSensorTiles(s);
drawSparklines();
drawCurve();
} catch (e) {
console.error(e);
}
}
document.getElementById('btn-save').addEventListener('click', async () => {
try {
await api('/api/curve', { method: 'PUT', body: JSON.stringify({ points }) });
toast('Krzywa zapisana');
refreshStatus();
} catch (e) { toast(e.message, 'error'); }
});
document.getElementById('btn-reload').addEventListener('click', async () => {
try {
await api('/api/reload', { method: 'POST' });
await loadCurve();
toast('Przeładowano z pliku');
} catch (e) { toast(e.message, 'error'); }
});
document.getElementById('btn-auto').addEventListener('click', async () => {
try {
await api('/api/mode', { method: 'POST', body: JSON.stringify({ mode: 'auto' }) });
toast('Tryb auto — sterowanie driverem NVIDIA');
refreshStatus();
} catch (e) { toast(e.message, 'error'); }
});
document.getElementById('btn-max').addEventListener('click', async () => {
try {
await api('/api/mode', { method: 'POST', body: JSON.stringify({ mode: 'manual', speed: 100 }) });
toast('Manual 100%');
refreshStatus();
} catch (e) { toast(e.message, 'error'); }
});
document.getElementById('btn-add').addEventListener('click', () => {
if (points.length >= 7) { toast('Max 7 punktów', 'error'); return; }
const last = points[points.length - 1];
const temp = Math.min(TEMP_MAX - 5, (last?.temp ?? 40) + 10);
const speed = Math.min(100, (last?.speed ?? 50) + 10);
points.push({ temp, speed });
drawCurve();
});
loadCurve().catch(e => toast(e.message, 'error'));
refreshStatus();
setInterval(refreshStatus, 2000);
window.addEventListener('resize', () => { if (history.length) drawSparklines(); });
</script>
</body>
</html>