359afb3a59
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>
756 lines
26 KiB
HTML
756 lines
26 KiB
HTML
<!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ść: 30–100% (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>
|