Files
ubuntu-bare-metal/stacks/server-ui/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

1943 lines
70 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>GMKtec K11 — Server UI</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.75rem;
}
h1 { margin: 0; font-size: 1.25rem; }
.subtitle { color: var(--muted); font-size: 0.875rem; }
header a { color: var(--accent); text-decoration: none; }
header a:hover { text-decoration: underline; }
main { padding: 1.5rem; max-width: 1200px; margin: 0 auto; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem; }
@media (max-width: 900px) { .row { grid-template-columns: 1fr; } }
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem 1.25rem;
}
.card h2 { margin: 0 0 0.75rem; font-size: 1rem; font-weight: 600; }
.gpu-bar {
height: 12px;
background: var(--border);
border-radius: 6px;
overflow: hidden;
margin: 0.5rem 0;
}
.gpu-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--green), var(--orange), var(--red));
transition: width 0.3s;
}
.muted { color: var(--muted); font-size: 0.875rem; }
.stacks-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.stack-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
cursor: pointer;
transition: border-color 0.15s;
}
.stack-card:hover, .stack-card.selected { border-color: var(--accent); }
.stack-card h3 { margin: 0 0 0.5rem; font-size: 1rem; }
.badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
}
.badge.running { background: rgba(63, 185, 80, 0.2); color: var(--green); }
.badge.stopped { background: rgba(139, 148, 158, 0.2); color: var(--muted); }
.badge.gpu { background: rgba(88, 166, 255, 0.15); color: var(--accent); margin-left: 0.35rem; }
.actions { display: flex; gap: 0.5rem; margin-top: 0.75rem; flex-wrap: wrap; }
button, .btn-link {
font: inherit;
padding: 0.35rem 0.75rem;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
cursor: pointer;
}
button:hover { border-color: var(--accent); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
button.primary { background: var(--accent); color: #0d1117; border-color: var(--accent); }
button.danger { border-color: var(--red); color: var(--red); }
.btn-link {
display: inline-block;
text-decoration: none;
font-size: 0.875rem;
}
.port-row {
display: flex;
gap: 0.35rem;
align-items: center;
width: 100%;
margin-top: 0.5rem;
}
.port-input {
width: 5.5rem;
font: inherit;
padding: 0.3rem 0.45rem;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
}
.port-save { font-size: 0.8rem; padding: 0.3rem 0.55rem; }
.port-readonly {
margin-top: 0.5rem;
font-size: 0.8rem;
}
.port-pending {
color: var(--orange);
font-size: 0.75rem;
margin-top: 0.25rem;
}
#portSuccess {
color: var(--green);
font-size: 0.875rem;
margin-top: 0.5rem;
}
.logs-panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
}
.logs-panel h2 { margin: 0 0 0.5rem; font-size: 1rem; }
pre {
margin: 0;
padding: 0.75rem;
background: var(--bg);
border-radius: 6px;
font-size: 0.75rem;
line-height: 1.4;
max-height: 320px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
}
.toolbar {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
margin-bottom: 1rem;
}
input[type="password"] {
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
padding: 0.35rem 0.5rem;
border-radius: 6px;
min-width: 200px;
}
#apiKeyStatus { font-size: 0.8rem; }
#apiKeyStatus.ok { color: var(--green); }
#apiKeyStatus.error { color: var(--red); }
#apiKeyStatus.muted { color: var(--muted); }
.error { color: var(--red); font-size: 0.875rem; margin-top: 0.5rem; }
.warn { color: var(--orange); font-size: 0.875rem; margin: 0.5rem 0; }
.tabs { display: flex; gap: 0.5rem; }
.tab {
font: inherit;
padding: 0.4rem 0.9rem;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--muted);
cursor: pointer;
}
.tab:hover { border-color: var(--accent); color: var(--text); }
.tab.active { border-color: var(--accent); color: var(--accent); background: rgba(88, 166, 255, 0.1); }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* File explorer */
.files-layout {
display: grid;
grid-template-columns: minmax(220px, 340px) 1fr;
gap: 1rem;
min-height: 520px;
}
@media (max-width: 900px) { .files-layout { grid-template-columns: 1fr; } }
.files-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.75rem;
}
.files-breadcrumb {
flex: 1;
min-width: 200px;
font-size: 0.875rem;
color: var(--muted);
word-break: break-all;
}
.files-breadcrumb a {
color: var(--accent);
text-decoration: none;
cursor: pointer;
}
.files-breadcrumb a:hover { text-decoration: underline; }
.files-list {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
overflow: auto;
max-height: 480px;
}
.files-entry {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.75rem;
border-bottom: 1px solid var(--border);
cursor: pointer;
font-size: 0.875rem;
}
.files-entry:last-child { border-bottom: none; }
.files-entry:hover { background: rgba(88, 166, 255, 0.08); }
.files-entry.selected { background: rgba(88, 166, 255, 0.15); border-left: 3px solid var(--accent); }
.files-entry .icon { width: 1.1rem; text-align: center; flex-shrink: 0; }
.files-entry .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.files-entry .meta { color: var(--muted); font-size: 0.75rem; }
.files-editor {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.75rem;
display: flex;
flex-direction: column;
min-height: 480px;
}
.files-editor-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.files-editor-path {
font-size: 0.8rem;
color: var(--muted);
word-break: break-all;
}
.files-textarea {
flex: 1;
min-height: 360px;
font-family: ui-monospace, monospace;
font-size: 0.8rem;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.6rem;
resize: vertical;
width: 100%;
}
.files-textarea:disabled { opacity: 0.6; }
.files-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.75rem; }
.files-status { font-size: 0.8rem; color: var(--muted); margin-top: 0.35rem; }
.files-status.error { color: var(--red); }
.files-status.ok { color: var(--green); }
.badge.ro { background: rgba(248, 81, 73, 0.15); color: var(--red); }
/* CLI terminal */
.cli-wrap {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.75rem;
min-height: 70vh;
display: flex;
flex-direction: column;
}
.cli-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
gap: 0.5rem;
flex-wrap: wrap;
}
.cli-terminal-wrap {
flex: 1;
min-height: 60vh;
background: #0d1117;
border-radius: 6px;
padding: 0.35rem;
overflow: hidden;
}
#cli-terminal { width: 100%; height: 100%; min-height: 360px; }
.cli-status { font-size: 0.8rem; }
.cli-status.ok { color: var(--green); }
.cli-status.error { color: var(--red); }
.cli-status.muted { color: var(--muted); }
/* GPU Fan panel */
.gpu-fan-grid {
display: grid;
grid-template-columns: 1fr 320px;
gap: 1rem;
}
@media (max-width: 900px) {
.gpu-fan-grid { grid-template-columns: 1fr; }
}
.gpu-fan-card h2 {
margin: 0 0 1rem;
font-size: 0.95rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.gpu-fan-stats { display: grid; gap: 0.75rem; }
.gpu-fan-stat { display: flex; justify-content: space-between; align-items: baseline; }
.gpu-fan-stat-value { font-size: 1.5rem; font-weight: 600; }
.gpu-fan-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%;
max-width: 600px;
height: auto;
aspect-ratio: 600 / 360;
display: block;
margin: 0 auto;
background: #0d1117;
border-radius: 6px;
cursor: crosshair;
touch-action: none;
}
.axis-label { fill: var(--muted); font-size: 11px; }
.grid-line { stroke: #30363d; stroke-width: 1; opacity: 0.45; }
.grid-line-major { opacity: 0.65; }
.tick-line { stroke: #8b949e; stroke-width: 1; }
.tick-label { fill: #8b949e; font-size: 10px; }
.ruler-spine { stroke: #8b949e; stroke-width: 1; }
.ruler-tick { stroke: #8b949e; stroke-width: 1; }
.ruler-tick-major { stroke: #8b949e; stroke-width: 1; }
.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; }
.gpu-fan-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.gpu-fan-table th, .gpu-fan-table td {
padding: 0.4rem 0.5rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
.gpu-fan-table th { color: var(--muted); font-weight: 500; }
.gpu-fan-table input[type="number"] {
width: 4rem;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
border-radius: 4px;
padding: 0.25rem 0.4rem;
}
.gpu-fan-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1rem; }
.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;
}
#gpuFanToast {
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;
}
#gpuFanToast.error { border-color: var(--red); color: var(--red); }
#gpuFanToast.ok { border-color: var(--green); }
.gpu-fan-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
.gpu-fan-header h2 { margin: 0; font-size: 1.1rem; font-weight: 600; }
#gpu-fan-status {
padding: 0.6rem 0.9rem;
border-radius: 6px;
font-size: 0.875rem;
margin-bottom: 1rem;
border: 1px solid var(--border);
background: var(--bg);
}
#gpu-fan-status.ok { border-color: var(--green); color: var(--green); }
#gpu-fan-status.warn { border-color: var(--orange); color: var(--orange); }
#gpu-fan-status.error { border-color: var(--red); color: var(--red); }
#gpu-fan-status.muted { color: var(--muted); }
</style>
<link rel="stylesheet" href="/static/vendor/xterm/xterm.css">
<script src="/static/vendor/xterm/xterm.min.js"></script>
<script src="/static/vendor/xterm/addon-fit.min.js"></script>
</head>
<body>
<header>
<div>
<h1>GMKtec K11 — Server UI</h1>
<div class="subtitle">Docker compose stacks · RTX 3090 Ti</div>
</div>
<nav class="tabs">
<button type="button" class="tab" data-tab="cli">CLI</button>
<button type="button" class="tab" data-tab="files">Pliki</button>
<button type="button" class="tab active" data-tab="stacks">Stacki</button>
<button type="button" class="tab" data-tab="gpu-fan">GPU Fan</button>
</nav>
</header>
<main>
<div class="toolbar">
<label class="muted">API Key:</label>
<input type="password" id="apiKey" placeholder="X-API-Key" autocomplete="off">
<button type="button" id="saveKey">Zapisz</button>
<button type="button" id="verifyKey">Sprawdź klucz</button>
<span id="apiKeyStatus" class="muted"></span>
<button type="button" id="refreshBtn">Odśwież</button>
<span class="muted" id="lastUpdate"></span>
</div>
<div id="panel-cli" class="tab-panel">
<div class="cli-wrap">
<div class="cli-header">
<div>
<h2 style="margin:0 0 0.25rem;font-size:1rem">Terminal</h2>
<div class="muted" style="font-size:0.8rem">Shell na serwerze (bash przez PTY) — wymaga API Key przy dostępie z LAN</div>
</div>
<div style="display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap">
<span class="cli-status muted" id="cli-status">Niepołączono</span>
<button type="button" id="cli-reconnect">Połącz ponownie</button>
</div>
</div>
<div class="cli-terminal-wrap">
<div id="cli-terminal"></div>
</div>
</div>
</div>
<div id="panel-files" class="tab-panel">
<div class="files-toolbar">
<div class="files-breadcrumb" id="files-breadcrumb">/</div>
<button type="button" id="files-refresh">Odśwież</button>
<button type="button" id="files-up">↑ Wyżej</button>
</div>
<div class="files-layout">
<div class="files-list" id="files-list">
<div class="files-entry muted" style="cursor:default">Ładowanie…</div>
</div>
<div class="files-editor">
<div class="files-editor-header">
<div class="files-editor-path" id="files-editor-path">Wybierz plik z listy</div>
<span class="badge ro" id="files-ro-badge" hidden>tylko odczyt</span>
</div>
<textarea class="files-textarea" id="files-editor" placeholder="Zawartość pliku…" disabled></textarea>
<div class="files-actions">
<button type="button" class="primary" id="files-save" disabled>Zapisz</button>
<button type="button" id="files-cancel" disabled>Anuluj</button>
</div>
<div class="files-status" id="files-status"></div>
</div>
</div>
<div class="files-actions" style="margin-top:1rem">
<button type="button" id="files-mkdir">+ Folder</button>
<button type="button" id="files-newfile">+ Plik</button>
<button type="button" id="files-rename">Zmień nazwę</button>
<button type="button" class="danger" id="files-delete">Usuń</button>
</div>
</div>
<div id="panel-stacks" class="tab-panel active">
<div id="gpuWarn" class="warn" hidden></div>
<div class="row">
<div class="card" id="gpuCard">
<h2>GPU</h2>
<div id="gpuName" class="muted">Ładowanie…</div>
<div class="gpu-bar"><div class="gpu-bar-fill" id="gpuBar" style="width:0%"></div></div>
<div id="gpuStats" class="muted"></div>
</div>
<div class="card">
<h2>Polityka GPU</h2>
<p class="muted" style="margin:0">Tylko jeden duży workload GPU naraz (LocalAI, ComfyUI, vLLM).</p>
<p id="gpuRunning" class="warn" style="margin:0.5rem 0 0"></p>
</div>
</div>
<div class="stacks-grid" id="stacksGrid"></div>
<div class="logs-panel">
<h2>Logi: <span id="logsTitle"></span></h2>
<pre id="logsContent">Wybierz stack, aby zobaczyć logi.</pre>
</div>
<div id="actionError" class="error" hidden></div>
<div id="portSuccess" hidden></div>
</div>
<div id="panel-gpu-fan" class="tab-panel">
<div class="gpu-fan-header">
<div>
<h2>GPU Fan Control</h2>
<div class="subtitle" id="gpu-fan-name">Ładowanie…</div>
</div>
<span class="mode-badge" id="mode-badge"></span>
</div>
<div id="gpu-fan-status" class="muted">Sprawdzanie połączenia z agentem…</div>
<div class="gpu-fan-grid">
<div class="card gpu-fan-card">
<h2>Krzywa wentylatorów</h2>
<svg id="curve-svg" viewBox="0 0 600 360" preserveAspectRatio="xMidYMid meet">
<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>
<g id="grid-layer"></g>
<rect x="50" y="20" width="520" height="300" fill="url(#tempGrad)" stroke="#30363d"/>
<text x="300" y="352" 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 class="gpu-fan-table" id="points-table">
<thead>
<tr><th>#</th><th>Temp (°C)</th><th>Speed (%)</th><th></th></tr>
</thead>
<tbody></tbody>
</table>
<div class="gpu-fan-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 gpu-fan-card">
<h2>Status GPU</h2>
<div class="gpu-fan-stats">
<div class="gpu-fan-stat">
<span class="gpu-fan-stat-label">Wentylatory</span>
<span class="gpu-fan-stat-value" id="stat-fans"></span>
</div>
<div class="gpu-fan-stat">
<span class="gpu-fan-stat-label">Cel (krzywa)</span>
<span class="gpu-fan-stat-value" id="stat-target"></span>
</div>
</div>
</div>
</div>
<div class="card gpu-fan-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>
</div>
</main>
<div id="gpuFanToast"></div>
<script>
const apiKeyInput = document.getElementById('apiKey');
const savedKey = localStorage.getItem('server-ui-api-key');
if (savedKey) apiKeyInput.value = savedKey;
const urlKey = new URLSearchParams(location.search).get('api_key');
if (urlKey) {
apiKeyInput.value = urlKey;
localStorage.setItem('server-ui-api-key', urlKey);
history.replaceState({}, '', location.pathname + location.hash);
}
const apiKeyStatusEl = document.getElementById('apiKeyStatus');
function setApiKeyStatus(text, level = 'muted') {
if (!apiKeyStatusEl) return;
apiKeyStatusEl.textContent = text;
apiKeyStatusEl.className = level;
}
function authErrorHint(msg) {
if (!msg || !/invalid|missing|api key/i.test(String(msg))) return String(msg);
return String(msg) + ' — kliknij „Sprawdź klucz” obok pola API Key. Klucz: sudo grep ^API_KEY= /opt/control-plane/.env';
}
async function verifyApiKey() {
if (!apiKeyInput.value) {
setApiKeyStatus('Wpisz klucz w pole powyżej', 'error');
return false;
}
setApiKeyStatus('Sprawdzanie…', 'muted');
try {
const r = await fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ api_key: apiKeyInput.value }),
});
if (r.ok) {
setApiKeyStatus('Klucz poprawny', 'ok');
if (typeof activeTab !== 'undefined' && activeTab === 'cli' && typeof connectCli === 'function') {
connectCli();
}
return true;
}
setApiKeyStatus('Klucz odrzucony — sudo grep ^API_KEY= /opt/control-plane/.env', 'error');
return false;
} catch (e) {
setApiKeyStatus('Błąd połączenia z panelem', 'error');
return false;
}
}
document.getElementById('verifyKey').onclick = () => verifyApiKey();
document.getElementById('saveKey').onclick = async () => {
localStorage.setItem('server-ui-api-key', apiKeyInput.value);
await verifyApiKey();
if (activeTab === 'gpu-fan') checkGpuFanHealth();
if (activeTab === 'files') loadFilesDir(filesCurrentPath);
if (activeTab === 'cli') connectCli();
};
if (apiKeyInput.value) verifyApiKey();
// GPU Fan state (must be declared before tab init — TDZ fix for #gpu-fan)
const GPU_FAN_API = '/api/gpu-fan';
let gpuFanInitialized = false;
let gpuFanOnline = false;
let gpuFanInterval = null;
let gpuFanLastError = '';
function setGpuFanStatus(text, level = 'muted') {
const el = document.getElementById('gpu-fan-status');
if (!el) return;
el.textContent = text;
el.className = level;
}
function setGpuFanButtonsEnabled(enabled) {
['btn-add', 'btn-save', 'btn-reload', 'btn-auto', 'btn-max'].forEach(id => {
const btn = document.getElementById(id);
if (btn) btn.disabled = !enabled;
});
}
// Tab navigation
const hashTab = location.hash.replace('#', '');
let activeTab = ['cli', 'files', 'gpu-fan'].includes(hashTab) ? hashTab : 'stacks';
const tabButtons = document.querySelectorAll('.tab');
const tabPanels = {
cli: document.getElementById('panel-cli'),
files: document.getElementById('panel-files'),
stacks: document.getElementById('panel-stacks'),
'gpu-fan': document.getElementById('panel-gpu-fan'),
};
function showTab(name) {
if (activeTab === 'cli' && name !== 'cli') disconnectCli();
activeTab = name;
tabButtons.forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === name);
});
Object.entries(tabPanels).forEach(([key, panel]) => {
panel.classList.toggle('active', key === name);
});
if (name === 'gpu-fan' && !gpuFanInitialized) initGpuFan();
if (name === 'files' && !filesInitialized) initFiles();
if (name === 'cli') initCli();
}
tabButtons.forEach(btn => {
btn.onclick = () => {
const tab = btn.dataset.tab;
showTab(tab);
location.hash = tab === 'stacks' ? '' : '#' + tab;
};
});
if (activeTab !== 'stacks') showTab(activeTab);
// CLI terminal (xterm.js + WebSocket PTY)
let cliInitialized = false;
let cliTerm = null;
let cliFitAddon = null;
let cliWs = null;
let cliResizeObserver = null;
function setCliStatus(text, level = 'muted') {
const el = document.getElementById('cli-status');
if (!el) return;
el.textContent = text;
el.className = 'cli-status ' + level;
}
function createFitAddon() {
if (typeof FitAddon === 'function') return new FitAddon();
if (typeof FitAddon !== 'undefined' && FitAddon && typeof FitAddon.FitAddon === 'function') {
return new FitAddon.FitAddon();
}
return null;
}
function cliWsCloseMessage(ev) {
const reason = (ev.reason || '').trim();
if (ev.code === 1008) {
if (/invalid|missing|api key/i.test(reason)) {
return 'Zły klucz API — Zapisz i Sprawdź klucz w toolbarze';
}
if (/CLI disabled/i.test(reason)) {
return 'CLI wyłączone (CLI_ENABLED=0)';
}
if (/Limit sesji/i.test(reason)) {
return reason;
}
return reason || 'Połączenie odrzucone (sprawdź API Key)';
}
return reason || (ev.wasClean ? 'Rozłączono' : 'Błąd połączenia WebSocket');
}
function disconnectCli() {
if (cliWs) {
cliWs.close();
cliWs = null;
}
if (cliResizeObserver) {
cliResizeObserver.disconnect();
cliResizeObserver = null;
}
if (cliTerm) {
cliTerm.dispose();
cliTerm = null;
cliFitAddon = null;
}
}
function sendCliResize() {
if (!cliWs || cliWs.readyState !== WebSocket.OPEN || !cliTerm) return;
if (cliFitAddon) cliFitAddon.fit();
cliWs.send(JSON.stringify({ type: 'resize', cols: cliTerm.cols, rows: cliTerm.rows }));
}
function connectCli() {
const container = document.getElementById('cli-terminal');
if (!container) return;
if (!apiKeyInput.value) {
setCliStatus('Wpisz i zapisz API Key w toolbarze', 'error');
return;
}
if (typeof Terminal !== 'function') {
setCliStatus('Brak xterm.js — odśwież stronę (Ctrl+F5)', 'error');
return;
}
disconnectCli();
try {
cliTerm = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: 'ui-monospace, Menlo, monospace',
theme: { background: '#0d1117', foreground: '#e6edf3', cursor: '#58a6ff' },
cols: 80,
rows: 24,
});
cliFitAddon = createFitAddon();
if (cliFitAddon) {
cliTerm.loadAddon(cliFitAddon);
}
cliTerm.open(container);
if (cliFitAddon) {
cliFitAddon.fit();
}
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${location.host}/api/cli/ws?api_key=${encodeURIComponent(apiKeyInput.value)}`;
setCliStatus('Łączenie…', 'muted');
cliWs = new WebSocket(url);
cliWs.binaryType = 'arraybuffer';
cliWs.onopen = () => {
setCliStatus('Połączono', 'ok');
sendCliResize();
};
cliWs.onclose = (ev) => {
setCliStatus(cliWsCloseMessage(ev), ev.wasClean && ev.code !== 1008 ? 'muted' : 'error');
cliWs = null;
};
cliWs.onerror = () => setCliStatus('Błąd połączenia WebSocket', 'error');
cliWs.onmessage = (evt) => {
if (!cliTerm) return;
if (typeof evt.data === 'string') {
if (evt.data.startsWith('{')) return;
cliTerm.write(evt.data);
return;
}
if (evt.data instanceof ArrayBuffer) {
cliTerm.write(new Uint8Array(evt.data));
}
};
cliTerm.onData(data => {
if (cliWs && cliWs.readyState === WebSocket.OPEN) cliWs.send(data);
});
cliTerm.onResize(({ cols, rows }) => {
if (cliWs && cliWs.readyState === WebSocket.OPEN) {
cliWs.send(JSON.stringify({ type: 'resize', cols, rows }));
}
});
const wrap = container.parentElement;
if (wrap) {
cliResizeObserver = new ResizeObserver(() => sendCliResize());
cliResizeObserver.observe(wrap);
}
} catch (e) {
disconnectCli();
setCliStatus(e.message || String(e), 'error');
}
}
function initCli() {
if (!cliInitialized) {
cliInitialized = true;
document.getElementById('cli-reconnect').addEventListener('click', connectCli);
}
connectCli();
}
let selectedStack = null;
let stacksData = [];
function headers(mutate = false) {
const h = { 'Content-Type': 'application/json' };
if (apiKeyInput.value) h['X-API-Key'] = apiKeyInput.value;
return h;
}
function hostIp() {
return window.location.hostname || '192.168.100.90';
}
async function fetchGpu() {
try {
const r = await fetch('/api/gpu');
const d = await r.json();
const el = document.getElementById('gpuCard');
if (!d.available || !d.gpus.length) {
document.getElementById('gpuName').textContent = d.error || 'Brak GPU';
return;
}
const g = d.gpus[0];
document.getElementById('gpuName').textContent = g.name;
document.getElementById('gpuBar').style.width = g.memory_used_pct + '%';
document.getElementById('gpuStats').textContent =
`VRAM: ${g.memory_used_mb} / ${g.memory_total_mb} MB · ${g.temperature_c ?? '—'}°C · ${g.utilization_pct ?? '—'}%`;
} catch (e) {
document.getElementById('gpuName').textContent = 'Błąd GPU';
}
}
function renderStacks(stacks, gpuRunning) {
stacksData = stacks;
const grid = document.getElementById('stacksGrid');
grid.innerHTML = '';
const warn = document.getElementById('gpuWarn');
if (gpuRunning.length > 1) {
warn.hidden = false;
warn.textContent = 'Uwaga: wiele stacków GPU działa równocześnie: ' + gpuRunning.join(', ');
} else {
warn.hidden = true;
}
document.getElementById('gpuRunning').textContent = gpuRunning.length
? 'Aktywny GPU: ' + gpuRunning.join(', ')
: 'Brak aktywnego stacku GPU.';
for (const s of stacks) {
const card = document.createElement('div');
card.className = 'stack-card' + (selectedStack === s.id ? ' selected' : '');
card.dataset.id = s.id;
const badge = s.running ? 'running' : 'stopped';
const uiScheme = s.ui_scheme || 'http';
const portVal = s.ui_port ?? '';
const uiLink = portVal
? `<a class="btn-link" href="${uiScheme}://${hostIp()}:${portVal}" target="_blank" rel="noopener" onclick="event.stopPropagation()">Otwórz :${portVal}</a>`
: '';
const portEditor = s.port_editable
? `<div class="port-row" onclick="event.stopPropagation()">
<input type="number" class="port-input" min="1024" max="65535" value="${portVal}" data-port-input="${s.id}" aria-label="Port ${s.name}">
<button type="button" class="port-save" data-port-save="${s.id}">Zapisz port</button>
</div>`
: portVal
? `<div class="port-readonly muted">Port :${portVal} (host network, tylko odczyt)</div>`
: '';
const portPending = s.port_pending_restart
? '<div class="port-pending">Wymaga recreate — zapisz port ponownie lub zrestartuj stack</div>'
: '';
card.innerHTML = `
<h3>${s.name} ${s.gpu ? '<span class="badge gpu">GPU</span>' : ''}</h3>
<span class="badge ${badge}">${s.status}</span>
<div class="muted" style="margin-top:0.35rem">${s.container}</div>
${portEditor}
${portPending}
<div class="actions" onclick="event.stopPropagation()">
<button class="primary" data-action="start" data-id="${s.id}">Start</button>
<button class="danger" data-action="stop" data-id="${s.id}">Stop</button>
<button data-action="restart" data-id="${s.id}">Restart</button>
${uiLink}
</div>`;
card.onclick = () => selectStack(s.id);
grid.appendChild(card);
}
grid.querySelectorAll('button[data-action]').forEach(btn => {
btn.onclick = () => stackAction(btn.dataset.action, btn.dataset.id);
});
grid.querySelectorAll('button[data-port-save]').forEach(btn => {
btn.onclick = () => saveStackPort(btn.dataset.portSave);
});
}
async function saveStackPort(id) {
const errEl = document.getElementById('actionError');
const okEl = document.getElementById('portSuccess');
errEl.hidden = true;
okEl.hidden = true;
const input = document.querySelector(`input[data-port-input="${id}"]`);
if (!input) return;
const port = parseInt(input.value, 10);
if (!Number.isFinite(port) || port < 1024 || port > 65535) {
errEl.hidden = false;
errEl.textContent = 'Port musi być liczbą 102465535';
return;
}
try {
const r = await fetch(`/api/stacks/${id}/port`, {
method: 'PATCH',
headers: headers(true),
body: JSON.stringify({ port, recreate: true }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.detail || r.statusText);
okEl.hidden = false;
okEl.textContent = d.recreated
? `Port ${d.port} zapisany i stack przeładowany`
: d.requires_restart
? `Port ${d.port} zapisany — uruchom stack ponownie`
: `Port ${d.port} zapisany`;
setTimeout(() => { okEl.hidden = true; }, 4000);
await fetchStacks();
} catch (e) {
errEl.hidden = false;
errEl.textContent = authErrorHint(e.message || String(e));
}
}
async function fetchStacks() {
const r = await fetch('/api/stacks');
const d = await r.json();
renderStacks(d.stacks, d.gpu_running || []);
document.getElementById('lastUpdate').textContent = 'Odświeżono ' + new Date().toLocaleTimeString();
}
async function selectStack(id) {
selectedStack = id;
const s = stacksData.find(x => x.id === id);
document.getElementById('logsTitle').textContent = s ? s.name : id;
await fetchStacks();
await loadLogs(id);
}
async function loadLogs(id) {
try {
const r = await fetch(`/api/stacks/${id}/logs?tail=100`);
const d = await r.json();
document.getElementById('logsContent').textContent = d.logs || '(puste)';
} catch (e) {
document.getElementById('logsContent').textContent = 'Błąd pobierania logów';
}
}
async function stackAction(action, id) {
const errEl = document.getElementById('actionError');
errEl.hidden = true;
try {
const r = await fetch(`/api/stacks/${id}/${action}`, {
method: 'POST',
headers: headers(true),
});
const d = await r.json();
if (!r.ok) throw new Error(d.detail || r.statusText);
await fetchStacks();
if (selectedStack === id) await loadLogs(id);
} catch (e) {
errEl.hidden = false;
errEl.textContent = authErrorHint(e.message || String(e));
}
}
document.getElementById('refreshBtn').onclick = async () => {
await fetchGpu();
await fetchStacks();
if (selectedStack) await loadLogs(selectedStack);
if (activeTab === 'gpu-fan') {
await checkGpuFanHealth();
if (gpuFanOnline) await refreshGpuFanStatus();
}
if (activeTab === 'files') await loadFilesDir(filesCurrentPath);
};
// --- File explorer ---
let filesInitialized = false;
let filesCurrentPath = '/';
let filesSelectedPath = null;
let filesOriginalContent = '';
let filesDirty = false;
let filesIsBinary = false;
function setFilesStatus(msg, level = '') {
const el = document.getElementById('files-status');
el.textContent = msg || '';
el.className = 'files-status' + (level ? ' ' + level : '');
}
async function filesApi(url, opts = {}) {
if (!apiKeyInput.value) {
setFilesStatus('Wymagany API Key — wpisz klucz z /opt/control-plane/.env', 'error');
throw new Error('Brak API Key');
}
const res = await fetch(url, { ...opts, headers: { ...headers(true), ...opts.headers } });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || res.statusText);
return data;
}
function renderBreadcrumb(path) {
const el = document.getElementById('files-breadcrumb');
const parts = path.split('/').filter(Boolean);
let html = '<a data-path="/">/</a>';
let acc = '';
for (const p of parts) {
acc += '/' + p;
const full = acc;
html += ' <a data-path="' + full + '">' + p + '</a>';
}
el.innerHTML = html;
el.querySelectorAll('a').forEach(a => {
a.onclick = (e) => { e.preventDefault(); loadFilesDir(a.dataset.path); };
});
}
function formatSize(n) {
if (n == null) return '';
if (n < 1024) return n + ' B';
if (n < 1048576) return (n / 1024).toFixed(1) + ' KB';
return (n / 1048576).toFixed(1) + ' MB';
}
async function loadFilesDir(path) {
filesCurrentPath = path;
filesSelectedPath = null;
setFilesStatus('');
const list = document.getElementById('files-list');
list.innerHTML = '<div class="files-entry muted" style="cursor:default">Ładowanie…</div>';
try {
const d = await filesApi('/api/files?path=' + encodeURIComponent(path));
renderBreadcrumb(d.path);
list.innerHTML = '';
if (!d.entries.length) {
list.innerHTML = '<div class="files-entry muted" style="cursor:default">(pusty katalog)</div>';
}
for (const e of d.entries) {
const row = document.createElement('div');
row.className = 'files-entry';
row.dataset.path = e.path;
row.innerHTML =
'<span class="icon">' + (e.is_dir ? '📁' : '📄') + '</span>' +
'<span class="name" title="' + e.path + '">' + e.name + '</span>' +
'<span class="meta">' + (e.is_dir ? '' : formatSize(e.size)) + '</span>';
row.onclick = () => {
list.querySelectorAll('.files-entry').forEach(x => x.classList.remove('selected'));
row.classList.add('selected');
filesSelectedPath = e.path;
if (e.is_dir) loadFilesDir(e.path);
else openFile(e.path);
};
list.appendChild(row);
}
clearEditor();
} catch (err) {
list.innerHTML = '<div class="files-entry muted" style="cursor:default">' + err.message + '</div>';
setFilesStatus(err.message, 'error');
}
}
function clearEditor() {
const ta = document.getElementById('files-editor');
ta.value = '';
ta.disabled = true;
filesDirty = false;
filesIsBinary = false;
document.getElementById('files-editor-path').textContent = 'Wybierz plik z listy';
document.getElementById('files-ro-badge').hidden = true;
document.getElementById('files-save').disabled = true;
document.getElementById('files-cancel').disabled = true;
}
async function openFile(path) {
setFilesStatus('Ładowanie pliku…');
try {
const d = await filesApi('/api/files/read?path=' + encodeURIComponent(path));
const ta = document.getElementById('files-editor');
document.getElementById('files-editor-path').textContent = d.path;
filesSelectedPath = d.path;
filesIsBinary = !!d.binary;
const ro = !d.writable || d.binary;
document.getElementById('files-ro-badge').hidden = !ro;
if (d.binary) {
ta.value = '[Plik binarny — tylko podgląd base64]\n\n' + (d.content_base64 || '');
ta.disabled = true;
filesOriginalContent = '';
filesDirty = false;
document.getElementById('files-save').disabled = true;
document.getElementById('files-cancel').disabled = true;
setFilesStatus('Plik binarny (' + formatSize(d.size) + ') — edycja wyłączona', '');
} else {
ta.value = d.content ?? '';
ta.disabled = !d.writable;
filesOriginalContent = ta.value;
filesDirty = false;
document.getElementById('files-save').disabled = !d.writable;
document.getElementById('files-cancel').disabled = !d.writable;
setFilesStatus(formatSize(d.size) + (d.writable ? ' · edytowalny' : ' · tylko odczyt'), d.writable ? 'ok' : '');
}
} catch (err) {
setFilesStatus(err.message, 'error');
clearEditor();
}
}
async function saveFile() {
if (!filesSelectedPath || filesIsBinary) return;
try {
await filesApi('/api/files/write', {
method: 'PUT',
body: JSON.stringify({ path: filesSelectedPath, content: document.getElementById('files-editor').value }),
});
filesOriginalContent = document.getElementById('files-editor').value;
filesDirty = false;
setFilesStatus('Zapisano', 'ok');
await loadFilesDir(filesCurrentPath);
await openFile(filesSelectedPath);
} catch (err) {
setFilesStatus(err.message, 'error');
}
}
function initFiles() {
if (filesInitialized) return;
filesInitialized = true;
document.getElementById('files-refresh').onclick = () => loadFilesDir(filesCurrentPath);
document.getElementById('files-up').onclick = () => {
const p = filesCurrentPath.replace(/\/$/, '');
const parent = p.lastIndexOf('/') > 0 ? p.slice(0, p.lastIndexOf('/')) : '/';
loadFilesDir(parent);
};
document.getElementById('files-save').onclick = saveFile;
document.getElementById('files-cancel').onclick = () => {
if (filesSelectedPath && !filesIsBinary) {
document.getElementById('files-editor').value = filesOriginalContent;
filesDirty = false;
setFilesStatus('Przywrócono', '');
}
};
document.getElementById('files-editor').oninput = () => {
filesDirty = document.getElementById('files-editor').value !== filesOriginalContent;
};
document.getElementById('files-mkdir').onclick = async () => {
const name = prompt('Nazwa nowego folderu:');
if (!name) return;
const base = filesCurrentPath.endsWith('/') ? filesCurrentPath.slice(0, -1) : filesCurrentPath;
const path = base + '/' + name.replace(/^\/+/, '');
try {
await filesApi('/api/files/mkdir', { method: 'POST', body: JSON.stringify({ path }) });
setFilesStatus('Utworzono folder', 'ok');
await loadFilesDir(filesCurrentPath);
} catch (err) { setFilesStatus(err.message, 'error'); }
};
document.getElementById('files-newfile').onclick = async () => {
const name = prompt('Nazwa nowego pliku:');
if (!name) return;
const base = filesCurrentPath.endsWith('/') ? filesCurrentPath.slice(0, -1) : filesCurrentPath;
const path = base + '/' + name.replace(/^\/+/, '');
try {
await filesApi('/api/files/write', { method: 'PUT', body: JSON.stringify({ path, content: '' }) });
setFilesStatus('Utworzono plik', 'ok');
await loadFilesDir(filesCurrentPath);
await openFile(path);
} catch (err) { setFilesStatus(err.message, 'error'); }
};
document.getElementById('files-rename').onclick = async () => {
if (!filesSelectedPath) { setFilesStatus('Wybierz plik lub folder z listy', 'error'); return; }
const baseName = filesSelectedPath.split('/').pop();
const newName = prompt('Nowa nazwa:', baseName);
if (!newName || newName === baseName) return;
const parent = filesSelectedPath.slice(0, filesSelectedPath.lastIndexOf('/')) || '/';
const newPath = (parent === '/' ? '' : parent) + '/' + newName.replace(/^\/+/, '');
try {
await filesApi('/api/files/rename', {
method: 'POST',
body: JSON.stringify({ old_path: filesSelectedPath, new_path: newPath }),
});
setFilesStatus('Zmieniono nazwę', 'ok');
filesSelectedPath = newPath;
await loadFilesDir(filesCurrentPath);
} catch (err) { setFilesStatus(err.message, 'error'); }
};
document.getElementById('files-delete').onclick = async () => {
if (!filesSelectedPath) { setFilesStatus('Wybierz plik lub folder z listy', 'error'); return; }
if (!confirm('Usunąć?\n' + filesSelectedPath)) return;
try {
await filesApi('/api/files?path=' + encodeURIComponent(filesSelectedPath), { method: 'DELETE' });
setFilesStatus('Usunięto', 'ok');
filesSelectedPath = null;
clearEditor();
await loadFilesDir(filesCurrentPath);
} catch (err) { setFilesStatus(err.message, 'error'); }
};
loadFilesDir('/');
}
fetchGpu();
fetchStacks();
setInterval(fetchGpu, 5000);
setInterval(async () => {
await fetchStacks();
if (selectedStack) await loadLogs(selectedStack);
}, 10000);
// --- GPU Fan panel ---
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;
const GRID_TEMP_STEP = 10;
const GRID_SPEED_STEP = 10;
let gpuFanPoints = [];
let gpuFanLiveTemp = null;
let gpuFanDragging = null;
const gpuFanHistory = [];
const HISTORY_MAX = 60;
function gpuFanToast(msg, type = 'ok') {
const el = document.getElementById('gpuFanToast');
el.textContent = msg;
el.className = type;
el.style.display = 'block';
setTimeout(() => { el.style.display = 'none'; }, 3500);
}
async function gpuFanApi(path, opts = {}) {
if (!apiKeyInput.value) {
setGpuFanStatus('Wymagany API Key — wpisz klucz z /opt/control-plane/.env i kliknij Zapisz', 'warn');
apiKeyInput.focus();
throw new Error('Brak API Key');
}
const res = await fetch(`${GPU_FAN_API}${path}`, {
...opts,
headers: { ...headers(true), ...opts.headers },
});
if (res.status === 401) {
setGpuFanStatus('Nieprawidłowy API Key', 'error');
gpuFanToast('Podaj poprawny API Key w toolbarze', 'error');
throw new Error('Brak autoryzacji');
}
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || res.statusText);
return data;
}
async function checkGpuFanHealth() {
try {
if (!apiKeyInput.value) {
gpuFanOnline = false;
setGpuFanStatus('Wymagany API Key — wpisz klucz z /opt/control-plane/.env i kliknij Zapisz', 'warn');
setGpuFanButtonsEnabled(false);
return false;
}
const h = await gpuFanApi('/health');
if (h.ok) {
gpuFanOnline = true;
gpuFanLastError = '';
setGpuFanStatus(`Połączono z agentem (${h.agent_url || 'localhost'})`, 'ok');
setGpuFanButtonsEnabled(true);
return true;
}
gpuFanOnline = false;
gpuFanLastError = h.error || 'Agent niedostępny';
setGpuFanStatus(`Brak agenta: ${gpuFanLastError}`, 'error');
setGpuFanButtonsEnabled(false);
return false;
} catch (e) {
gpuFanOnline = false;
gpuFanLastError = e.message || String(e);
setGpuFanStatus(`Błąd połączenia: ${gpuFanLastError}`, 'error');
setGpuFanButtonsEnabled(false);
return false;
}
}
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 sortGpuFanPoints() {
gpuFanPoints.sort((a, b) => a.temp - b.temp);
}
function interpolateGpuFan(temp) {
if (!gpuFanPoints.length) return SPEED_MIN;
if (temp <= gpuFanPoints[0].temp) return gpuFanPoints[0].speed;
if (temp >= gpuFanPoints[gpuFanPoints.length - 1].temp) return gpuFanPoints[gpuFanPoints.length - 1].speed;
for (let i = 0; i < gpuFanPoints.length - 1; i++) {
const a = gpuFanPoints[i], b = gpuFanPoints[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 gpuFanPoints[gpuFanPoints.length - 1].speed;
}
function drawGpuFanGrid() {
const layer = document.getElementById('grid-layer');
const ns = 'http://www.w3.org/2000/svg';
layer.innerHTML = '';
function svgEl(name, attrs, classes) {
const el = document.createElementNS(ns, name);
Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, v));
(classes || []).forEach(c => el.classList.add(c));
return el;
}
const plotBottom = PAD.top + PAD.height;
const plotRight = PAD.left + PAD.width;
for (let t = TEMP_MIN; t <= TEMP_MAX; t += GRID_TEMP_STEP) {
const x = tempToX(t);
const major = t % 20 === 0;
layer.appendChild(svgEl('line', {
x1: x, y1: PAD.top, x2: x, y2: plotBottom
}, ['grid-line', ...(major ? ['grid-line-major'] : [])]));
}
for (let s = SPEED_MIN; s <= SPEED_MAX; s += GRID_SPEED_STEP) {
const y = speedToY(s);
const major = s % 20 === 0;
layer.appendChild(svgEl('line', {
x1: PAD.left, y1: y, x2: plotRight, y2: y
}, ['grid-line', ...(major ? ['grid-line-major'] : [])]));
}
const xLabelY = plotBottom + 23;
for (let t = TEMP_MIN; t <= TEMP_MAX; t += GRID_TEMP_STEP) {
const x = tempToX(t);
layer.appendChild(svgEl('line', {
x1: x, y1: plotBottom, x2: x, y2: plotBottom + 5
}, ['tick-line']));
const label = svgEl('text', { x, y: xLabelY, 'text-anchor': 'middle' }, ['tick-label']);
label.textContent = t;
layer.appendChild(label);
}
const rulerLeftX = PAD.left - 6;
const yLabelX = rulerLeftX - 12;
for (let s = SPEED_MIN; s <= SPEED_MAX; s += GRID_SPEED_STEP) {
const y = speedToY(s);
layer.appendChild(svgEl('line', {
x1: PAD.left - 5, y1: y, x2: PAD.left, y2: y
}, ['tick-line']));
const label = svgEl('text', { x: yLabelX, y: y + 3, 'text-anchor': 'end' }, ['tick-label']);
label.textContent = s;
layer.appendChild(label);
}
layer.appendChild(svgEl('line', {
x1: rulerLeftX, y1: PAD.top, x2: rulerLeftX, y2: plotBottom
}, ['ruler-spine']));
for (let s = SPEED_MIN; s <= SPEED_MAX; s += GRID_SPEED_STEP) {
const y = speedToY(s);
const major = s % 20 === 0;
const tickLen = major ? 8 : 4;
layer.appendChild(svgEl('line', {
x1: rulerLeftX, y1: y, x2: rulerLeftX - tickLen, y2: y
}, ['ruler-tick', ...(major ? ['ruler-tick-major'] : [])]));
}
const rulerBottomY = plotBottom + 6;
layer.appendChild(svgEl('line', {
x1: PAD.left, y1: rulerBottomY, x2: plotRight, y2: rulerBottomY
}, ['ruler-spine']));
for (let t = TEMP_MIN; t <= TEMP_MAX; t += GRID_TEMP_STEP) {
const x = tempToX(t);
const major = t % 20 === 0;
const tickLen = major ? 8 : 4;
layer.appendChild(svgEl('line', {
x1: x, y1: rulerBottomY, x2: x, y2: rulerBottomY + tickLen
}, ['ruler-tick', ...(major ? ['ruler-tick-major'] : [])]));
}
}
function drawGpuFanCurve() {
drawGpuFanGrid();
sortGpuFanPoints();
const pts = gpuFanPoints.map(p => `${tempToX(p.temp)},${speedToY(p.speed)}`).join(' ');
document.getElementById('curve-line').setAttribute('points', pts);
const fill = gpuFanPoints.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 = '';
gpuFanPoints.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 => { gpuFanDragging = i; e.preventDefault(); });
c.addEventListener('touchstart', e => { gpuFanDragging = i; e.preventDefault(); }, { passive: false });
layer.appendChild(c);
});
if (gpuFanLiveTemp != null) {
const x = tempToX(gpuFanLiveTemp);
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(interpolateGpuFan(gpuFanLiveTemp)));
document.getElementById('live-dot').style.display = '';
} else {
document.getElementById('live-dot').style.display = 'none';
}
renderGpuFanTable();
}
function renderGpuFanTable() {
const tbody = document.querySelector('#points-table tbody');
tbody.innerHTML = '';
gpuFanPoints.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}" ${gpuFanPoints.length <= 3 ? 'disabled' : ''}>×</button></td>
`;
tbody.appendChild(tr);
});
tbody.querySelectorAll('input').forEach(inp => {
inp.addEventListener('change', () => {
const idx = +inp.dataset.i;
const v = +inp.value;
if (inp.dataset.field === 'temp') gpuFanPoints[idx].temp = v;
else gpuFanPoints[idx].speed = Math.max(SPEED_MIN, Math.min(SPEED_MAX, v));
drawGpuFanCurve();
});
});
tbody.querySelectorAll('button[data-del]').forEach(btn => {
btn.addEventListener('click', () => {
if (gpuFanPoints.length <= 3) return;
gpuFanPoints.splice(+btn.dataset.del, 1);
drawGpuFanCurve();
});
});
}
function onGpuFanPointerMove(clientX, clientY) {
if (gpuFanDragging === 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 = gpuFanDragging > 0 ? gpuFanPoints[gpuFanDragging - 1].temp + 1 : TEMP_MIN;
const next = gpuFanDragging < gpuFanPoints.length - 1 ? gpuFanPoints[gpuFanDragging + 1].temp - 1 : TEMP_MAX;
gpuFanPoints[gpuFanDragging].temp = Math.max(prev, Math.min(next, temp));
gpuFanPoints[gpuFanDragging].speed = speed;
drawGpuFanCurve();
}
document.addEventListener('mousemove', e => onGpuFanPointerMove(e.clientX, e.clientY));
document.addEventListener('mouseup', () => { gpuFanDragging = null; });
document.addEventListener('touchmove', e => {
if (e.touches[0]) onGpuFanPointerMove(e.touches[0].clientX, e.touches[0].clientY);
}, { passive: false });
document.addEventListener('touchend', () => { gpuFanDragging = null; });
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 pushGpuFanHistory(s) {
gpuFanHistory.push({
t: Date.now(),
util: s.utilization_pct ?? 0,
power: s.power_w ?? 0,
temp: s.temperature_c ?? 0,
});
if (gpuFanHistory.length > HISTORY_MAX) gpuFanHistory.shift();
}
function updateGpuFanGauges(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 drawGpuFanSparklines() {
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 (gpuFanHistory.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(...gpuFanHistory.map(p => p.power), 1);
const maxTemp = Math.max(...gpuFanHistory.map(p => p.temp), 1);
function drawSeries(key, color, maxVal) {
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
const n = gpuFanHistory.length;
gpuFanHistory.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 updateGpuFanSensorTiles(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 refreshGpuFanStatus() {
if (!gpuFanOnline) return;
try {
const s = await gpuFanApi('/status');
document.getElementById('gpu-fan-name').textContent = s.gpu_name || '—';
document.getElementById('mode-badge').textContent = `Tryb: ${s.mode}`;
gpuFanLiveTemp = 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';
pushGpuFanHistory(s);
updateGpuFanGauges(s);
updateGpuFanSensorTiles(s);
drawGpuFanSparklines();
drawGpuFanCurve();
} catch (e) {
gpuFanOnline = false;
gpuFanLastError = e.message || String(e);
setGpuFanStatus(`Utracono połączenie: ${gpuFanLastError}`, 'error');
setGpuFanButtonsEnabled(false);
gpuFanToast(gpuFanLastError, 'error');
}
}
async function loadGpuFanCurve() {
const data = await gpuFanApi('/curve');
gpuFanPoints = data.points;
drawGpuFanCurve();
}
function initGpuFan() {
if (gpuFanInitialized) return;
gpuFanInitialized = true;
setGpuFanButtonsEnabled(false);
document.getElementById('btn-save').addEventListener('click', async () => {
if (!gpuFanOnline) { gpuFanToast('Agent offline', 'error'); return; }
try {
await gpuFanApi('/curve', { method: 'PUT', body: JSON.stringify({ points: gpuFanPoints }) });
gpuFanToast('Krzywa zapisana');
refreshGpuFanStatus();
} catch (e) { gpuFanToast(e.message, 'error'); }
});
document.getElementById('btn-reload').addEventListener('click', async () => {
if (!gpuFanOnline) { gpuFanToast('Agent offline', 'error'); return; }
try {
await gpuFanApi('/reload', { method: 'POST' });
await loadGpuFanCurve();
gpuFanToast('Przeładowano z pliku');
} catch (e) { gpuFanToast(e.message, 'error'); }
});
document.getElementById('btn-auto').addEventListener('click', async () => {
if (!gpuFanOnline) { gpuFanToast('Agent offline', 'error'); return; }
try {
await gpuFanApi('/mode', { method: 'POST', body: JSON.stringify({ mode: 'auto' }) });
gpuFanToast('Tryb auto — sterowanie driverem NVIDIA');
refreshGpuFanStatus();
} catch (e) { gpuFanToast(e.message, 'error'); }
});
document.getElementById('btn-max').addEventListener('click', async () => {
if (!gpuFanOnline) { gpuFanToast('Agent offline', 'error'); return; }
try {
await gpuFanApi('/mode', { method: 'POST', body: JSON.stringify({ mode: 'manual', speed: 100 }) });
gpuFanToast('Manual 100%');
refreshGpuFanStatus();
} catch (e) { gpuFanToast(e.message, 'error'); }
});
document.getElementById('btn-add').addEventListener('click', () => {
if (!gpuFanOnline) { gpuFanToast('Agent offline', 'error'); return; }
if (gpuFanPoints.length >= 7) { gpuFanToast('Max 7 punktów', 'error'); return; }
const last = gpuFanPoints[gpuFanPoints.length - 1];
const temp = Math.min(TEMP_MAX - 5, (last?.temp ?? 40) + 10);
const speed = Math.min(100, (last?.speed ?? 50) + 10);
gpuFanPoints.push({ temp, speed });
drawGpuFanCurve();
});
(async () => {
const ok = await checkGpuFanHealth();
if (!ok) return;
try {
await loadGpuFanCurve();
await refreshGpuFanStatus();
gpuFanInterval = setInterval(refreshGpuFanStatus, 2000);
} catch (e) {
gpuFanToast(e.message, 'error');
}
})();
window.addEventListener('resize', () => { if (gpuFanHistory.length) drawGpuFanSparklines(); });
}
</script>
</body>
</html>