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>
This commit is contained in:
tomasz-syn-grzegorza
2026-07-05 12:02:04 +00:00
commit 359afb3a59
153 changed files with 18169 additions and 0 deletions
+755
View File
@@ -0,0 +1,755 @@
<!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>