73e4fc005e
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.
2690 lines
100 KiB
HTML
2690 lines
100 KiB
HTML
<!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ść: 30–100% (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ą 1024–65535';
|
||
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, '&')
|
||
.replace(/"/g, '"')
|
||
.replace(/</g, '<');
|
||
}
|
||
|
||
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>
|