Files
ubuntu-bare-metal/stacks/server-ui/static/index.html
T
tomasz-syn-grzegorza 73e4fc005e Add stack Update, ComfyUI model manager, and slim ComfyUI stack.
Server UI gains Update on stack cards, ComfyUI Models tab with workflow
scan and downloads, and centralized comfyui_config. Model catalog and
download scripts move from stacks/comfyui to server-ui so ComfyUI stays a
minimal Docker wrapper for easier image updates.
2026-07-05 18:45:17 +00:00

2690 lines
100 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); }
button.btn-update { border-color: var(--accent); color: var(--accent); }
.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); }
.models-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
.models-table th, .models-table td {
text-align: left;
padding: 0.55rem 0.65rem;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
.models-table th { color: var(--muted); font-weight: 500; }
.models-row-bar { height: 6px; background: var(--border); border-radius: 3px; margin-top: 4px; overflow: hidden; }
.models-row-bar .fill { height: 100%; background: var(--green); border-radius: 3px; transition: width 0.3s ease; width: 0%; }
.models-actions { display: flex; gap: 0.35rem; flex-wrap: wrap; align-items: center; }
.models-url-row { margin-top: 0.35rem; display: flex; gap: 0.35rem; align-items: center; }
.models-url-input {
flex: 1;
min-width: 0;
font-size: 0.75rem;
padding: 0.25rem 0.4rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
color: inherit;
}
.models-table tbody tr { cursor: pointer; }
.models-table tbody tr.selected { background: rgba(88, 166, 255, 0.12); outline: 1px solid var(--accent); }
.models-table tbody tr:hover:not(.selected) { background: rgba(255, 255, 255, 0.03); }
.models-badge {
display: inline-block;
font-size: 0.65rem;
padding: 0.1rem 0.35rem;
border-radius: 3px;
background: rgba(88, 166, 255, 0.15);
color: var(--accent);
margin-left: 0.35rem;
vertical-align: middle;
}
.models-badge.muted { background: rgba(255, 255, 255, 0.06); color: var(--muted); }
.models-section-title { margin: 1.25rem 0 0.5rem; font-size: 0.95rem; }
.models-logs-card { margin-top: 1rem; }
.models-log-panel {
max-height: 280px;
overflow: auto;
font-family: ui-monospace, monospace;
font-size: 0.75rem;
white-space: pre-wrap;
margin: 0;
padding: 0.75rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--muted);
}
.models-disk { font-size: 0.85rem; margin-bottom: 1rem; }
</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>
<button type="button" class="tab" data-tab="comfyui-models">ComfyUI Modele</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>
<button type="button" id="clearKey">Wyczyść 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>
<div id="panel-comfyui-models" class="tab-panel">
<div class="gpu-fan-header">
<div>
<h2>ComfyUI — pobieranie modeli</h2>
<div class="subtitle muted">Modele zapisywane na dysku danych hosta (<code>/data/apps/comfyui/models</code>)</div>
</div>
<a class="btn-link" id="comfyui-open-link" href="#" target="_blank" rel="noopener" hidden>Otwórz ComfyUI :8188</a>
</div>
<div class="warn" style="margin-bottom:1rem">
Duży checkpoint + LocalAI = konflikt VRAM. Zatrzymaj LocalAI przed generowaniem obrazów w ComfyUI.
</div>
<div class="models-disk muted" id="comfyui-disk-info">Ładowanie miejsca na dysku…</div>
<div class="card">
<h3 class="models-section-title">Katalog repo</h3>
<table class="models-table" id="comfyui-models-table">
<thead>
<tr>
<th>Model</th>
<th>Kategoria</th>
<th>Rozmiar</th>
<th>Status</th>
<th>Akcje</th>
</tr>
</thead>
<tbody id="comfyui-models-body">
<tr><td colspan="5" class="muted">Ładowanie…</td></tr>
</tbody>
</table>
</div>
<div class="card" style="margin-top:1rem">
<h3 class="models-section-title">Wymagane przez workflow</h3>
<div class="muted" id="comfyui-workflow-meta" style="font-size:0.8rem;margin-bottom:0.5rem">Ładowanie…</div>
<table class="models-table" id="comfyui-workflow-table">
<thead>
<tr>
<th>Plik</th>
<th>Kategoria</th>
<th>Workflow</th>
<th>Status</th>
<th>Akcje</th>
</tr>
</thead>
<tbody id="comfyui-workflow-body">
<tr><td colspan="5" class="muted">Ładowanie…</td></tr>
</tbody>
</table>
</div>
<div class="card models-logs-card" id="comfyui-logs-card" hidden>
<h2 style="margin:0 0 0.5rem;font-size:1rem" id="comfyui-logs-title">Log pobierania</h2>
<div class="muted" id="comfyui-logs-meta" style="font-size:0.8rem;margin-bottom:0.5rem"></div>
<pre class="models-log-panel" id="comfyui-logs-content">Wybierz wiersz modelu lub rozpocznij pobieranie.</pre>
</div>
<div id="comfyui-models-error" class="error" hidden style="margin-top:1rem"></div>
</div>
</main>
<div id="gpuFanToast"></div>
<script>
const apiKeyInput = document.getElementById('apiKey');
function getApiKey() {
return (apiKeyInput?.value || '').trim();
}
const savedKey = localStorage.getItem('server-ui-api-key');
if (savedKey) apiKeyInput.value = savedKey;
const urlKey = new URLSearchParams(location.search).get('api_key');
let autoVerifyFromUrl = false;
if (urlKey) {
apiKeyInput.value = urlKey.trim();
localStorage.setItem('server-ui-api-key', getApiKey());
history.replaceState({}, '', location.pathname + location.hash);
autoVerifyFromUrl = true;
}
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() {
const key = getApiKey();
if (!key) {
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: key }),
});
if (r.ok) {
setApiKeyStatus('Klucz poprawny', 'ok');
if (typeof activeTab !== 'undefined' && activeTab === 'cli' && typeof connectCli === 'function') {
connectCli();
}
return true;
}
setApiKeyStatus(
'Klucz odrzucony — kliknij „Wyczyść klucz”, potem: bash stacks/server-ui/scripts/show-api-key.sh',
'error'
);
return false;
} catch (e) {
setApiKeyStatus('Błąd połączenia z panelem', 'error');
return false;
}
}
document.getElementById('verifyKey').onclick = () => verifyApiKey();
document.getElementById('clearKey').onclick = () => {
localStorage.removeItem('server-ui-api-key');
apiKeyInput.value = '';
setApiKeyStatus('Wklej klucz z: bash stacks/server-ui/scripts/show-api-key.sh', 'muted');
};
document.getElementById('saveKey').onclick = async () => {
const key = getApiKey();
if (!key) {
setApiKeyStatus('Wpisz klucz przed zapisem', 'error');
return;
}
apiKeyInput.value = key;
localStorage.setItem('server-ui-api-key', key);
await verifyApiKey();
if (activeTab === 'gpu-fan') checkGpuFanHealth();
if (activeTab === 'files') loadFilesDir(filesCurrentPath);
if (activeTab === 'cli') connectCli();
};
if (autoVerifyFromUrl) {
verifyApiKey();
} else if (getApiKey()) {
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', 'comfyui-models'].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'),
'comfyui-models': document.getElementById('panel-comfyui-models'),
};
function showTab(name) {
if (activeTab === 'cli' && name !== 'cli') pauseCli();
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();
if (name === 'comfyui-models') {
if (!comfyuiModelsInitialized) initComfyuiModels();
loadComfyuiModels();
startComfyuiPoll();
} else {
stopComfyuiPoll();
}
}
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)
const CLI_SESSION_KEY = 'server-ui-cli-session-id';
let cliInitialized = false;
let cliTerm = null;
let cliFitAddon = null;
let cliWs = null;
let cliResizeObserver = null;
let cliReconnecting = false;
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 (/Session expired/i.test(reason)) {
return 'Sesja wygasła';
}
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 pauseCli() {
// Zachowaj WebSocket i xterm przy przełączaniu zakładek.
}
function resumeCli() {
if (cliFitAddon && cliTerm) cliFitAddon.fit();
sendCliResize();
}
function disconnectCli(killSession = false) {
if (killSession) {
if (cliWs && cliWs.readyState === WebSocket.OPEN) {
try {
cliWs.send(JSON.stringify({ type: 'close' }));
} catch (_) {}
}
sessionStorage.removeItem(CLI_SESSION_KEY);
}
if (cliWs) {
cliWs.onclose = null;
cliWs.close();
cliWs = null;
}
if (cliResizeObserver) {
cliResizeObserver.disconnect();
cliResizeObserver = null;
}
if (killSession && 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 handleCliWsMessage(evt) {
if (!cliTerm) return;
if (typeof evt.data === 'string') {
if (evt.data.startsWith('{')) {
try {
const msg = JSON.parse(evt.data);
if (msg.type === 'session' && msg.id) {
sessionStorage.setItem(CLI_SESSION_KEY, msg.id);
return;
}
if (msg.type === 'replay' && msg.complete) {
setCliStatus('Połączono (przywrócono sesję)', 'ok');
sendCliResize();
return;
}
} catch (_) {}
return;
}
cliTerm.write(evt.data);
return;
}
if (evt.data instanceof ArrayBuffer) {
cliTerm.write(new Uint8Array(evt.data));
}
}
function ensureCliTerminal(container) {
if (cliTerm) return;
cliTerm = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: 'ui-monospace, Menlo, monospace',
theme: { background: '#0d1117', foreground: '#e6edf3', cursor: '#58a6ff' },
cols: 80,
rows: 24,
scrollback: 5000,
});
cliFitAddon = createFitAddon();
if (cliFitAddon) cliTerm.loadAddon(cliFitAddon);
cliTerm.open(container);
if (cliFitAddon) cliFitAddon.fit();
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 }));
}
});
}
function connectCli(forceNew = false) {
const container = document.getElementById('cli-terminal');
if (!container) return;
if (!getApiKey()) {
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;
}
if (cliWs && cliWs.readyState === WebSocket.OPEN) {
resumeCli();
setCliStatus('Połączono', 'ok');
return;
}
if (forceNew) disconnectCli(true);
const savedSessionId = forceNew ? null : sessionStorage.getItem(CLI_SESSION_KEY);
try {
ensureCliTerminal(container);
if (savedSessionId) cliTerm.clear();
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
let url = `${proto}//${location.host}/api/cli/ws?api_key=${encodeURIComponent(getApiKey())}`;
if (savedSessionId) {
url += `&session_id=${encodeURIComponent(savedSessionId)}`;
}
setCliStatus(savedSessionId ? 'Przywracanie sesji…' : 'Łączenie…', 'muted');
cliWs = new WebSocket(url);
cliWs.binaryType = 'arraybuffer';
cliWs.onopen = () => {
if (!savedSessionId) {
setCliStatus('Połączono', 'ok');
sendCliResize();
}
};
cliWs.onclose = (ev) => {
cliWs = null;
if (ev.code === 1008 && /Session expired/i.test(ev.reason || '')) {
sessionStorage.removeItem(CLI_SESSION_KEY);
setCliStatus('Sesja wygasła — nowe połączenie…', 'muted');
if (activeTab === 'cli' && !cliReconnecting) {
cliReconnecting = true;
connectCli(false);
cliReconnecting = false;
}
return;
}
setCliStatus(cliWsCloseMessage(ev), ev.wasClean && ev.code !== 1008 ? 'muted' : 'error');
};
cliWs.onerror = () => setCliStatus('Błąd połączenia WebSocket', 'error');
cliWs.onmessage = handleCliWsMessage;
if (!cliResizeObserver) {
const wrap = container.parentElement;
if (wrap) {
cliResizeObserver = new ResizeObserver(() => sendCliResize());
cliResizeObserver.observe(wrap);
}
}
} catch (e) {
disconnectCli(true);
setCliStatus(e.message || String(e), 'error');
}
}
function initCli() {
if (!cliInitialized) {
cliInitialized = true;
document.getElementById('cli-reconnect').addEventListener('click', () => {
disconnectCli(true);
connectCli(true);
});
}
connectCli();
}
let selectedStack = null;
let stacksData = [];
function headers(mutate = false) {
const h = { 'Content-Type': 'application/json' };
if (getApiKey()) h['X-API-Key'] = getApiKey();
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.';
updateComfyuiOpenLink();
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>
<button class="btn-update" data-action="update" data-id="${s.id}">Update</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) {
if (action === 'update') {
const s = stacksData.find(x => x.id === id);
const name = s?.name || id;
const msg = s?.running
? `Update ${name}: pobierze nowy obraz Docker i przeładuje kontener (krótka przerwa). Dane na /data/apps/* pozostają. Kontynuować?`
: `Update ${name}: pobierze nowy obraz Docker. Stack jest zatrzymany — po update użyj Start. Kontynuować?`;
if (!confirm(msg)) return;
}
const errEl = document.getElementById('actionError');
errEl.hidden = true;
const card = document.querySelector(`.stack-card[data-id="${CSS.escape(id)}"]`);
const buttons = card ? card.querySelectorAll('button[data-action]') : [];
const updateBtn = card?.querySelector('button[data-action="update"]');
const prevUpdateText = updateBtn?.textContent;
buttons.forEach(b => { b.disabled = true; });
if (action === 'update' && updateBtn) updateBtn.textContent = 'Aktualizuję…';
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));
} finally {
buttons.forEach(b => { b.disabled = false; });
if (updateBtn && prevUpdateText) updateBtn.textContent = prevUpdateText;
}
}
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);
if (activeTab === 'comfyui-models') await loadComfyuiModels();
};
// --- 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 (!getApiKey()) {
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 (!getApiKey()) {
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 (!getApiKey()) {
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(); });
}
// --- ComfyUI models ---
let comfyuiModelsInitialized = false;
let comfyuiModelsData = [];
let comfyuiWorkflowData = [];
let comfyuiWorkflowsScanned = 0;
const comfyuiActiveJobs = new Map();
const comfyuiJobLogs = new Map();
const comfyuiCustomUrls = new Map();
let comfyuiPollTimer = null;
let comfyuiSelectedModelId = null;
function findComfyuiModel(modelId) {
return comfyuiModelsData.find(x => x.id === modelId)
|| comfyuiWorkflowData.find(x => x.id === modelId);
}
function comfyuiSourceBadge(source) {
if (source === 'manager') return '<span class="models-badge">Manager</span>';
if (source === 'catalog') return '<span class="models-badge">Repo</span>';
if (source === 'none') return '<span class="models-badge muted">Brak URL</span>';
return '';
}
function escapeHtmlAttr(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;');
}
function isComfyuiHttpUrl(url) {
const u = (url || '').trim();
return u.startsWith('http://') || u.startsWith('https://');
}
function comfyuiWorkflowCanDownload(m, customUrl, running) {
if (running || m.installed) return false;
const url = (customUrl || '').trim();
return m.downloadable || isComfyuiHttpUrl(url);
}
function syncWorkflowDownloadButton(row, m) {
const dlBtn = row.querySelector('button[data-download]');
if (!dlBtn) return;
const input = row.querySelector('[data-url-input]');
const running = m.download?.status === 'running' || comfyuiActiveJobs.get(m.id)?.status === 'running';
const url = input?.value ?? comfyuiCustomUrls.get(m.id) ?? '';
dlBtn.disabled = !comfyuiWorkflowCanDownload(m, url, running);
}
function saveComfyuiCustomUrlsFromTable() {
document.querySelectorAll('#comfyui-workflow-body [data-url-input]').forEach(input => {
const id = input.dataset.urlInput;
if (id) comfyuiCustomUrls.set(id, input.value);
});
}
function formatGb(bytes) {
if (!bytes && bytes !== 0) return '—';
return (bytes / 1073741824).toFixed(1) + ' GB';
}
function formatMb(bytes) {
if (!bytes && bytes !== 0) return '0 MB';
if (bytes < 1048576) return (bytes / 1024).toFixed(0) + ' KB';
return (bytes / 1048576).toFixed(1) + ' MB';
}
function comfyuiRowStatus(m) {
const job = comfyuiActiveJobs.get(m.id);
if (job && job.status === 'running') {
return { text: `Pobieranie ${job.progress || 0}%`, cls: 'warn', progress: job.progress || 0 };
}
if (m.download && m.download.status === 'running') {
return { text: `Pobieranie ${m.download.progress || 0}%`, cls: 'warn', progress: m.download.progress || 0 };
}
if (m.installed) return { text: 'Zainstalowany', cls: 'ok', progress: 0 };
if (m.partial) return { text: 'Częściowy', cls: 'warn', progress: 0 };
return { text: 'Brak', cls: 'muted', progress: 0 };
}
function comfyuiSizeLabel(m) {
if (m.partial && m.size_bytes > 0) {
const total = m.size_gb ? `~${m.size_gb} GB` : '—';
return `${formatMb(m.size_bytes)} / ${total}`;
}
return m.size_gb ? `~${m.size_gb} GB` : '—';
}
function selectComfyuiRow(modelId) {
comfyuiSelectedModelId = modelId;
document.querySelectorAll('#comfyui-models-body tr[data-model-id], #comfyui-workflow-body tr[data-model-id]').forEach(row => {
row.classList.toggle('selected', row.dataset.modelId === modelId);
});
renderComfyuiLogPanel();
}
function renderComfyuiLogPanel() {
const card = document.getElementById('comfyui-logs-card');
const titleEl = document.getElementById('comfyui-logs-title');
const metaEl = document.getElementById('comfyui-logs-meta');
const contentEl = document.getElementById('comfyui-logs-content');
if (!card || !titleEl || !metaEl || !contentEl) return;
if (!comfyuiSelectedModelId) {
card.hidden = true;
return;
}
const m = findComfyuiModel(comfyuiSelectedModelId);
if (!m) {
card.hidden = true;
return;
}
card.hidden = false;
const job = comfyuiActiveJobs.get(comfyuiSelectedModelId);
const st = comfyuiRowStatus({ ...m, download: job ? { status: job.status, progress: job.progress } : m.download });
titleEl.textContent = `Log: ${m.name}`;
metaEl.textContent = `${m.id} · ${st.text}`;
const log = comfyuiJobLogs.get(comfyuiSelectedModelId);
if (log) {
contentEl.textContent = log;
contentEl.scrollTop = contentEl.scrollHeight;
} else if (job?.status === 'running') {
contentEl.textContent = 'Oczekiwanie na logi pobierania…';
} else if (m.installed) {
contentEl.textContent = 'Model zainstalowany na dysku. Brak aktywnego pobierania.';
} else {
contentEl.textContent = 'Brak logów. Rozpocznij pobieranie przyciskiem Pobierz.';
}
}
function updateComfyuiRow(modelId, job) {
if (job) {
comfyuiActiveJobs.set(modelId, job);
if (job.log) comfyuiJobLogs.set(modelId, job.log);
}
const row = document.querySelector(`tr[data-model-id="${modelId}"]`);
if (!row) return;
const m = findComfyuiModel(modelId);
if (!m) return;
const st = comfyuiRowStatus({ ...m, download: job ? { status: job.status, progress: job.progress } : m.download });
const statusCell = row.querySelector('[data-status-cell]');
if (statusCell) {
statusCell.className = st.cls;
let html = st.text;
if (st.progress > 0 && (job?.status === 'running' || m.download?.status === 'running')) {
html += `<div class="models-row-bar"><div class="fill" style="width:${st.progress}%"></div></div>`;
}
statusCell.innerHTML = html;
}
const dlBtn = row.querySelector('button[data-download]');
const cancelBtn = row.querySelector('button[data-cancel]');
const delBtn = row.querySelector('button[data-delete]');
const running = job?.status === 'running' || m.download?.status === 'running';
if (dlBtn) {
if (modelId.startsWith('wf:')) {
const input = row.querySelector('[data-url-input]');
syncWorkflowDownloadButton(row, { ...m, download: job ? { status: job.status } : m.download });
} else {
dlBtn.disabled = running || m.installed || m.downloadable === false;
}
}
if (cancelBtn) cancelBtn.disabled = !running;
if (delBtn) delBtn.disabled = running || (!m.installed && !m.partial);
if (modelId === comfyuiSelectedModelId) renderComfyuiLogPanel();
}
async function comfyuiApi(url, opts = {}) {
if (!getApiKey()) {
throw new Error('Wymagany API Key — wpisz klucz z /opt/control-plane/.env');
}
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 updateComfyuiOpenLink() {
const link = document.getElementById('comfyui-open-link');
const comfy = stacksData.find(s => s.id === 'comfyui');
if (comfy && comfy.running && comfy.ui_port) {
link.href = `http://${hostIp()}:${comfy.ui_port}`;
link.hidden = false;
} else {
link.hidden = true;
}
}
function renderComfyuiDisk(disk) {
const el = document.getElementById('comfyui-disk-info');
if (!disk) {
el.textContent = '';
return;
}
el.textContent = `Dysk ${disk.path}: wolne ${formatGb(disk.free_bytes)} / ${formatGb(disk.total_bytes)}`;
}
function bindComfyuiRowActions(tbody) {
tbody.querySelectorAll('button[data-download]').forEach(btn => {
btn.onclick = () => startComfyuiDownload(btn.dataset.download);
});
tbody.querySelectorAll('button[data-cancel]').forEach(btn => {
btn.onclick = () => cancelComfyuiDownload(btn.dataset.cancel);
});
tbody.querySelectorAll('button[data-delete]').forEach(btn => {
btn.onclick = () => deleteComfyuiModel(btn.dataset.delete);
});
}
function renderComfyuiWorkflowModels(models, meta) {
comfyuiWorkflowData = models;
const metaEl = document.getElementById('comfyui-workflow-meta');
if (metaEl) {
if (meta.workflows_scanned === 0) {
metaEl.textContent = `Brak zapisanych workflow w ${meta.workflows_dir || '/data/apps/comfyui/workflows'} — zapisz workflow w ComfyUI (:8188).`;
} else if (!models.length) {
metaEl.textContent = `Przeskanowano ${meta.workflows_scanned} workflow — wszystkie wymagane modele są już w katalogu repo lub brak referencji do plików.`;
} else {
metaEl.textContent = `Przeskanowano ${meta.workflows_scanned} workflow · ${meta.workflows_dir || ''}`;
}
}
const tbody = document.getElementById('comfyui-workflow-body');
saveComfyuiCustomUrlsFromTable();
tbody.innerHTML = '';
if (!models.length) {
tbody.innerHTML = '<tr><td colspan="5" class="muted">Brak dodatkowych modeli wymaganych przez workflow</td></tr>';
return;
}
for (const m of models) {
const tr = document.createElement('tr');
tr.dataset.modelId = m.id;
if (m.id === comfyuiSelectedModelId) tr.classList.add('selected');
tr.onclick = (e) => {
if (e.target.closest('button, input')) return;
selectComfyuiRow(m.id);
};
const st = comfyuiRowStatus(m);
const running = m.download?.status === 'running' || comfyuiActiveJobs.get(m.id)?.status === 'running';
const canDelete = m.installed || m.partial;
const savedUrl = comfyuiCustomUrls.has(m.id)
? comfyuiCustomUrls.get(m.id)
: (m.download_url || '');
const canDownload = comfyuiWorkflowCanDownload(m, savedUrl, running);
const wfList = (m.workflows || []).join(', ') || '—';
tr.innerHTML = `
<td>
<div>${m.filename}${comfyuiSourceBadge(m.source)}</div>
<div class="muted" style="font-size:0.75rem">${m.note || m.relative_path || ''}</div>
<div class="models-url-row">
<input type="url" class="models-url-input" data-url-input="${escapeHtmlAttr(m.id)}"
placeholder="Custom URL (https://…)"
value="${escapeHtmlAttr(savedUrl)}" />
</div>
</td>
<td>${m.category || '—'}</td>
<td style="font-size:0.8rem">${wfList}</td>
<td data-status-cell class="${st.cls}">${st.text}${st.progress > 0 && running ? `<div class="models-row-bar"><div class="fill" style="width:${st.progress}%"></div></div>` : ''}</td>
<td>
<div class="models-actions">
<button type="button" class="primary" data-download="${m.id}" ${canDownload ? '' : 'disabled'}>Pobierz</button>
<button type="button" class="danger" data-cancel="${m.id}" ${running ? '' : 'disabled'}>Anuluj</button>
<button type="button" data-delete="${m.id}" ${canDelete && !running ? '' : 'disabled'}>Usuń</button>
</div>
</td>`;
tbody.appendChild(tr);
const urlInput = tr.querySelector('[data-url-input]');
if (urlInput) {
urlInput.addEventListener('click', (e) => e.stopPropagation());
urlInput.addEventListener('input', () => {
comfyuiCustomUrls.set(m.id, urlInput.value);
syncWorkflowDownloadButton(tr, m);
});
}
}
bindComfyuiRowActions(tbody);
for (const [modelId, job] of comfyuiActiveJobs) {
if (job.status === 'running' && modelId.startsWith('wf:')) updateComfyuiRow(modelId, job);
}
renderComfyuiLogPanel();
}
function renderComfyuiModels(models) {
comfyuiModelsData = models;
const tbody = document.getElementById('comfyui-models-body');
tbody.innerHTML = '';
if (!models.length) {
tbody.innerHTML = '<tr><td colspan="5" class="muted">Brak wpisów w katalogu</td></tr>';
return;
}
for (const m of models) {
const tr = document.createElement('tr');
tr.dataset.modelId = m.id;
if (m.id === comfyuiSelectedModelId) tr.classList.add('selected');
tr.onclick = (e) => {
if (e.target.closest('button')) return;
selectComfyuiRow(m.id);
};
const st = comfyuiRowStatus(m);
const running = m.download?.status === 'running' || comfyuiActiveJobs.get(m.id)?.status === 'running';
const canDelete = m.installed || m.partial;
tr.innerHTML = `
<td>
<div>${m.name}</div>
<div class="muted" style="font-size:0.75rem">${m.id}${m.note ? ' — ' + m.note : ''}</div>
</td>
<td>${m.category || '—'}</td>
<td>${comfyuiSizeLabel(m)}</td>
<td data-status-cell class="${st.cls}">${st.text}${st.progress > 0 && running ? `<div class="models-row-bar"><div class="fill" style="width:${st.progress}%"></div></div>` : ''}</td>
<td>
<div class="models-actions">
<button type="button" class="primary" data-download="${m.id}" ${running || m.installed ? 'disabled' : ''}>Pobierz</button>
<button type="button" class="danger" data-cancel="${m.id}" ${running ? '' : 'disabled'}>Anuluj</button>
<button type="button" data-delete="${m.id}" ${canDelete && !running ? '' : 'disabled'}>Usuń</button>
</div>
</td>`;
tbody.appendChild(tr);
}
bindComfyuiRowActions(tbody);
for (const [modelId, job] of comfyuiActiveJobs) {
if (job.status === 'running') updateComfyuiRow(modelId, job);
}
renderComfyuiLogPanel();
}
function startComfyuiPoll() {
if (comfyuiPollTimer) return;
comfyuiPollTimer = setInterval(() => pollComfyuiJobs(), 1500);
}
function stopComfyuiPoll() {
if (comfyuiPollTimer) {
clearInterval(comfyuiPollTimer);
comfyuiPollTimer = null;
}
}
async function pollComfyuiJobs() {
if (activeTab !== 'comfyui-models') return;
const running = [...comfyuiActiveJobs.entries()].filter(([, j]) => j.status === 'running');
if (!running.length) {
const allModels = [...comfyuiModelsData, ...comfyuiWorkflowData];
for (const m of allModels) {
if (m.download?.status === 'running' && m.download.job_id) {
running.push([m.id, { id: m.download.job_id, status: 'running', model_id: m.id }]);
comfyuiActiveJobs.set(m.id, { id: m.download.job_id, status: 'running', progress: m.download.progress || 0, model_id: m.id });
}
}
}
if (!running.length) return;
let needsReload = false;
for (const [modelId, jobRef] of running) {
try {
const d = await comfyuiApi(`/api/comfyui/models/jobs/${jobRef.id}`);
const job = d.job;
updateComfyuiRow(modelId, job);
if (job.status !== 'running') {
if (job.log) comfyuiJobLogs.set(modelId, job.log);
comfyuiActiveJobs.delete(modelId);
needsReload = true;
if (job.status === 'failed') {
const errEl = document.getElementById('comfyui-models-error');
errEl.hidden = false;
errEl.textContent = job.error || 'Pobieranie nie powiodło się';
}
}
} catch (e) {
comfyuiActiveJobs.delete(modelId);
needsReload = true;
}
}
if (needsReload) await loadComfyuiModels();
}
async function cancelComfyuiDownload(modelId) {
const errEl = document.getElementById('comfyui-models-error');
errEl.hidden = true;
const job = comfyuiActiveJobs.get(modelId);
const jobId = job?.id || findComfyuiModel(modelId)?.download?.job_id;
if (!jobId) return;
if (!confirm('Anulować pobieranie i usunąć częściowy plik?')) return;
try {
const d = await comfyuiApi(`/api/comfyui/models/jobs/${jobId}?cleanup=true`, { method: 'DELETE' });
if (d.job?.log) comfyuiJobLogs.set(modelId, d.job.log);
comfyuiActiveJobs.delete(modelId);
await loadComfyuiModels();
if (comfyuiSelectedModelId === modelId) renderComfyuiLogPanel();
} catch (e) {
errEl.hidden = false;
errEl.textContent = authErrorHint(e.message || String(e));
}
}
async function deleteComfyuiModel(modelId) {
const errEl = document.getElementById('comfyui-models-error');
errEl.hidden = true;
const m = findComfyuiModel(modelId);
const size = m?.size_bytes ? formatMb(m.size_bytes) : '';
const label = m?.name || m?.filename || modelId;
if (!confirm(`Usunąć model „${label}${size ? ' (' + size + ')' : ''} z dysku?`)) return;
try {
if (modelId.startsWith('wf:') && m?.relative_path) {
await comfyuiApi(`/api/comfyui/models/file?relative_path=${encodeURIComponent(m.relative_path)}`, { method: 'DELETE' });
} else {
await comfyuiApi(`/api/comfyui/models/${encodeURIComponent(modelId)}`, { method: 'DELETE' });
}
comfyuiActiveJobs.delete(modelId);
await loadComfyuiModels();
} catch (e) {
errEl.hidden = false;
errEl.textContent = authErrorHint(e.message || String(e));
}
}
async function startComfyuiDownload(modelId) {
const errEl = document.getElementById('comfyui-models-error');
errEl.hidden = true;
const model = findComfyuiModel(modelId);
const row = document.querySelector(`tr[data-model-id="${modelId}"]`);
const customUrl = (
row?.querySelector('[data-url-input]')?.value
?? comfyuiCustomUrls.get(modelId)
?? ''
).trim();
if (model && model.size_gb && model.size_gb > 8) {
const ok = confirm(`Model „${model.name || model.filename}” ma ~${model.size_gb} GB. Kontynuować pobieranie?`);
if (!ok) return;
}
try {
let d;
if (modelId.startsWith('wf:') && isComfyuiHttpUrl(customUrl)) {
if (!model?.relative_path) {
throw new Error('Brak ścieżki docelowej (relative_path) dla tego modelu');
}
d = await comfyuiApi('/api/comfyui/models/download-file', {
method: 'POST',
body: JSON.stringify({
url: customUrl,
relative_path: model.relative_path,
}),
});
} else {
d = await comfyuiApi('/api/comfyui/models/download', {
method: 'POST',
body: JSON.stringify({ model_id: modelId }),
});
}
const job = d.job;
comfyuiActiveJobs.set(modelId, job);
if (job.log) comfyuiJobLogs.set(modelId, job.log);
selectComfyuiRow(modelId);
updateComfyuiRow(modelId, job);
renderComfyuiModels(comfyuiModelsData);
renderComfyuiWorkflowModels(comfyuiWorkflowData, {
workflows_scanned: comfyuiWorkflowsScanned,
workflows_dir: '',
});
startComfyuiPoll();
if (job.status === 'completed') await loadComfyuiModels();
} catch (e) {
errEl.hidden = false;
errEl.textContent = authErrorHint(e.message || String(e));
}
}
async function loadComfyuiModels() {
const errEl = document.getElementById('comfyui-models-error');
errEl.hidden = true;
try {
const d = await comfyuiApi('/api/comfyui/models');
renderComfyuiDisk(d.disk);
const allItems = [...(d.models || []), ...(d.workflow_required || [])];
for (const m of allItems) {
if (m.download?.status === 'running' && m.download.job_id) {
comfyuiActiveJobs.set(m.id, {
id: m.download.job_id,
model_id: m.id,
status: 'running',
progress: m.download.progress || 0,
});
}
}
renderComfyuiModels(d.models || []);
renderComfyuiWorkflowModels(d.workflow_required || [], {
workflows_scanned: d.workflows_scanned || 0,
workflows_dir: d.workflows_dir || '',
});
comfyuiWorkflowsScanned = d.workflows_scanned || 0;
updateComfyuiOpenLink();
if ([...comfyuiActiveJobs.values()].some(j => j.status === 'running')) startComfyuiPoll();
} catch (e) {
document.getElementById('comfyui-models-body').innerHTML =
`<tr><td colspan="5" class="muted">${authErrorHint(e.message || String(e))}</td></tr>`;
document.getElementById('comfyui-workflow-body').innerHTML =
`<tr><td colspan="5" class="muted">${authErrorHint(e.message || String(e))}</td></tr>`;
}
}
function initComfyuiModels() {
comfyuiModelsInitialized = true;
}
</script>
</body>
</html>