Files
jukebox_maker/web/templates/dashboard.html
T
mchusandClaude Sonnet 4.6 478f5928d4 Fix dashboard settings panel closing on auto-refresh
Replace full innerHTML re-render with incremental DOM updates:
- renderedDisks map tracks what's already in the DOM
- On refresh: only update diskProgress_KEY and diskBtns_KEY elements
- Full card re-render only on first appearance or state/disk_id change
- If an edited disk disconnects: toast error, panel removed cleanly
- openPanels Set persists panel open state across partial updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 22:03:58 +03:00

431 lines
19 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{define "content"}}
<section class="panel">
<h2>Disks</h2>
<div class="panel-body">
<div id="diskSummary" class="text-muted">Loading disks...</div>
</div>
</section>
<div class="disk-grid" id="diskGrid"></div>
<script>
let disks = [];
const taskState = new Map(); // taskID -> task
const taskPollers = new Map(); // taskID -> intervalID
const openPanels = new Set(); // disk key -> panel is open
const renderedDisks = new Map(); // disk key -> { state, disk_id } — what we last fully rendered
// ─── helpers ────────────────────────────────────────────────────────────────
function escapeHTML(v) {
return String(v || '').replace(/[&<>"']/g, c =>
({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
}
function diskKey(disk) { return disk.disk_id || disk.mount_path; }
function badgeClass(s) {
return ({ absent:'badge-unknown', foreign:'badge-warn', known:'badge-ok' })[s] || 'badge-unknown';
}
function badgeLabel(s) {
return ({ absent:'Not connected', foreign:'Uninitialized disk', known:'Ready' })[s] || '—';
}
function fmtSpeed(bps) {
if (!bps) return '';
if (bps >= 1e9) return (bps/1e9).toFixed(1)+' GB/s';
if (bps >= 1e6) return (bps/1e6).toFixed(1)+' MB/s';
if (bps >= 1e3) return (bps/1e3).toFixed(0)+' KB/s';
return bps+' B/s';
}
function fmtETA(sec) {
if (!sec || sec <= 0) return '';
if (sec >= 3600) return Math.floor(sec/3600)+' h '+Math.floor((sec%3600)/60)+' min';
if (sec >= 60) return Math.floor(sec/60)+' min';
return sec+' s';
}
function fmtBytes(b) {
if (!b) return '—';
if (b >= 1e12) return (b/1e12).toFixed(1)+' TB';
if (b >= 1e9) return (b/1e9).toFixed(1)+' GB';
if (b >= 1e6) return (b/1e6).toFixed(1)+' MB';
return b+' B';
}
function fmtDateTime(v) {
if (!v) return 'Never';
const d = new Date(v);
if (isNaN(d)) return v;
return d.toLocaleString('en-US', { year:'numeric', month:'short', day:'2-digit', hour:'2-digit', minute:'2-digit' });
}
function taskMeta(t) {
if (!t) return '';
return [fmtSpeed(t.speed_bps), t.eta_sec ? 'ETA: '+fmtETA(t.eta_sec) : ''].filter(Boolean).join(' · ');
}
// ─── progress section (dynamic) ─────────────────────────────────────────────
function progressHTML(disk) {
const task = disk.active_task_id ? taskState.get(disk.active_task_id) : null;
if (!task) return '';
const pct = task.progress || 0;
const message = task.message || 'Preparing...';
const meta = taskMeta(task);
return `
<div class="panel-body progress-wrap">
<div class="progress-bar-bg">
<div class="progress-bar-fill" style="width:${pct}%"></div>
</div>
<div class="progress-label">${escapeHTML(message)}</div>
${meta ? `<div class="progress-label">${escapeHTML(meta)}</div>` : ''}
</div>`;
}
function btnsHTML(disk) {
const task = disk.active_task_id ? taskState.get(disk.active_task_id) : null;
const key = escapeHTML(diskKey(disk));
const busy = !!task;
const isKnown = disk.state === 'known';
const isForeign = disk.state === 'foreign';
return `
${isKnown ? `
<button class="button-danger" data-action="start-copy" data-mode="replace" data-disk-id="${key}" ${busy?'disabled':''}>Replace media</button>
<button class="button-primary" data-action="start-copy" data-mode="add" data-disk-id="${key}" ${busy?'disabled':''}>Add media</button>
<button class="button-danger ${busy?'':'hidden'}" data-action="cancel-copy" data-disk-id="${key}">Cancel</button>
<button class="button-secondary" data-action="disk-settings" data-disk-key="${key}" style="margin-left:auto">⚙ Settings${openPanels.has(diskKey(disk))?' ✕':''}</button>
` : ''}
${isForeign ? `
<button class="button-secondary" data-action="init-disk" data-mount-path="${escapeHTML(disk.mount_path)}">Initialize disk</button>
` : ''}`;
}
// ─── full card render (only on first appearance or state change) ─────────────
function renderProfile(disk) {
const p = disk.profile || {};
const t = p.transcode || null;
const te = !!t;
const k = escapeHTML(diskKey(disk));
const sel = (name, value, opts) => {
const o = opts.map(([v,l]) =>
`<option value="${v}" ${v===value?'selected':''}>${escapeHTML(l)}</option>`
).join('');
return `<select class="form-input" id="prof_${name}_${k}">${o}</select>`;
};
return `
<section class="panel" id="profilePanel_${k}" style="display:${openPanels.has(diskKey(disk))?'':'none'}">
<h2>Профиль диска</h2>
<div class="panel-body">
<h3>Параметры копирования</h3>
<div class="form-group">
<label>Папка назначения</label>
<input class="form-input" type="text" id="prof_dest_folder_${k}" value="${escapeHTML(p.dest_folder||'media')}">
</div>
<div class="form-group">
<label>Режим перезаписи</label>
${sel('overwrite_mode', p.overwrite_mode||'skip', [['skip','Пропускать существующие'],['delete','Заменять всё']])}
</div>
<div class="form-group">
<label>Выбор файлов</label>
${sel('file_select_mode', p.file_select_mode||'new', [['new','Только новые'],['all','Все подходящие']])}
</div>
<div class="form-group">
<label>Порядок копирования</label>
${sel('shuffle_depth', String(p.shuffle_depth??-1), [['-1','По порядку (без перемешивания)'],['0','Случайный порядок файлов'],['1','Случайная папка 1-го уровня (жанр)'],['2','Случайная папка 2-го уровня (сериал)'],['3','Случайная папка 3-го уровня (сезон)']])}
<span class="form-hint">Уровень задаёт глубину вложения: все файлы выбранной папки копируются целиком.</span>
</div>
<div class="form-group">
<label>Резерв свободного места (ГБ)</label>
<input class="form-input" type="number" id="prof_reserve_free_gb_${k}" value="${p.reserve_free_gb??2}" min="0" step="0.5">
</div>
<div class="form-group">
<label><input type="checkbox" id="prof_auto_copy_${k}" ${p.auto_copy?'checked':''}> Автокопирование при подключении</label>
</div>
<h3 style="margin-top:1.5em">Транскодирование видео</h3>
<div class="form-group">
<label>
<input type="checkbox" id="prof_transcode_enabled_${k}" ${te?'checked':''}
onchange="document.getElementById('transcodeFields_${k}').style.display=this.checked?'':'none'">
Ограничить видео под устройство
</label>
</div>
<div id="transcodeFields_${k}" style="${te?'':'display:none'}">
<div class="form-group">
<label>Видеокодек</label>
${sel('video_codec', t?.video_codec||'h264', [['h264','H.264 (AVC)'],['h265','H.265 (HEVC)'],['mpeg4','MPEG-4']])}
</div>
<div class="form-group">
<label>Макс. разрешение</label>
${sel('max_resolution', t?.max_resolution||'720p', [['480p','480p'],['720p','720p (HD)'],['1080p','1080p (Full HD)']])}
</div>
<div class="form-group">
<label>Макс. битрейт видео</label>
${sel('max_video_bitrate', t?.max_video_bitrate||'2000k', [['','Без лимита'],['1000k','1000 кбит/с'],['2000k','2000 кбит/с'],['4000k','4000 кбит/с'],['8000k','8000 кбит/с']])}
</div>
<div class="form-group">
<label>Макс. FPS</label>
${sel('max_fps', String(t?.max_fps??0), [['0','Без лимита'],['24','24'],['25','25'],['30','30']])}
</div>
<hr>
<div class="form-group">
<label>Аудиокодек</label>
${sel('audio_codec', t?.audio_codec||'aac', [['aac','AAC'],['mp3','MP3']])}
</div>
<div class="form-group">
<label>Макс. битрейт аудио</label>
${sel('max_audio_bitrate', t?.max_audio_bitrate||'192k', [['','Без лимита'],['128k','128 кбит/с'],['192k','192 кбит/с'],['320k','320 кбит/с']])}
</div>
<div class="form-group">
<label>Каналы</label>
${sel('max_audio_channels', String(t?.max_audio_channels??0), [['0','Копировать'],['2','Стерео (2.0)'],['6','5.1']])}
</div>
<hr>
<div class="form-group">
<label>Формат контейнера</label>
${sel('output_format', t?.output_format||'mp4', [['mp4','MP4'],['mkv','MKV'],['avi','AVI']])}
</div>
</div>
<div class="btn-row" style="margin-top:1em">
<button class="button-primary" onclick="saveProfile('${escapeHTML(disk.mount_path)}','${k}')">Сохранить профиль</button>
</div>
</div>
</section>`;
}
function diskCardHTML(disk) {
const key = escapeHTML(diskKey(disk));
const hasCapacity = disk.state !== 'absent';
return `
<section class="panel disk-card" id="diskCard_${key}">
<h2>${escapeHTML(disk.mount_path)}</h2>
<table class="kv-table"><tbody>
<tr><th>Status</th><td><span class="badge ${badgeClass(disk.state)}">${badgeLabel(disk.state)}</span></td></tr>
<tr><th>Disk ID</th><td>${disk.disk_id ? `<span class="mono">${escapeHTML(disk.disk_id)}</span>` : '<span class="text-muted">not initialized yet</span>'}</td></tr>
<tr><th>Total capacity</th><td>${hasCapacity ? fmtBytes(disk.total_bytes) : '—'}</td></tr>
<tr><th>Free space</th><td>${hasCapacity ? fmtBytes(disk.free_bytes) : '—'}</td></tr>
<tr><th>Last copied</th><td>${fmtDateTime(disk.last_copied_at)}</td></tr>
</tbody></table>
<div id="diskProgress_${key}">${progressHTML(disk)}</div>
<div class="btn-row" id="diskBtns_${key}">${btnsHTML(disk)}</div>
</section>
${disk.state === 'known' ? renderProfile(disk) : ''}`;
}
// ─── incremental DOM update ──────────────────────────────────────────────────
function renderDisks() {
const grid = document.getElementById('diskGrid');
const summary = document.getElementById('diskSummary');
const knownCount = disks.filter(d => d.state === 'known').length;
summary.textContent = disks.length
? `Disks found: ${disks.length}. Ready to copy: ${knownCount}.`
: 'No disks found.';
const incoming = new Map(disks.map(d => [diskKey(d), d]));
// Remove disappeared disks
for (const [key] of renderedDisks) {
if (!incoming.has(key)) {
if (openPanels.has(key)) {
toast('Диск отключён — несохранённые изменения потеряны', 'error');
openPanels.delete(key);
}
document.getElementById('diskCard_' + key)?.remove();
document.getElementById('profilePanel_' + key)?.remove();
renderedDisks.delete(key);
}
}
// Add or update disks
for (const disk of disks) {
const key = diskKey(disk);
const prev = renderedDisks.get(key);
// Need full re-render: first appearance OR state / disk_id changed
const needFull = !prev || prev.state !== disk.state || prev.disk_id !== disk.disk_id;
if (needFull) {
const existing = document.getElementById('diskCard_' + key);
const tmp = document.createElement('div');
tmp.innerHTML = diskCardHTML(disk);
if (existing) {
// Replace in-place (profile panel follows immediately after)
const panel = document.getElementById('profilePanel_' + key);
existing.replaceWith(...tmp.childNodes);
panel?.remove();
// Profile panel was re-rendered inside tmp; it's already inserted
} else {
// Append new
while (tmp.firstChild) grid.appendChild(tmp.firstChild);
}
} else {
// Partial update: only progress and buttons
const progEl = document.getElementById('diskProgress_' + key);
if (progEl) progEl.innerHTML = progressHTML(disk);
const btnsEl = document.getElementById('diskBtns_' + key);
if (btnsEl) btnsEl.innerHTML = btnsHTML(disk);
}
renderedDisks.set(key, { state: disk.state, disk_id: disk.disk_id });
}
}
// ─── profile save ────────────────────────────────────────────────────────────
async function saveProfile(mountPath, key) {
const g = id => document.getElementById(id);
const te = g(`prof_transcode_enabled_${key}`)?.checked;
const profile = {
dest_folder: g(`prof_dest_folder_${key}`)?.value.trim() || 'media',
overwrite_mode: g(`prof_overwrite_mode_${key}`)?.value || 'skip',
file_select_mode: g(`prof_file_select_mode_${key}`)?.value || 'new',
reserve_free_gb: parseFloat(g(`prof_reserve_free_gb_${key}`)?.value || '2') || 0,
auto_copy: g(`prof_auto_copy_${key}`)?.checked || false,
shuffle_depth: parseInt(g(`prof_shuffle_depth_${key}`)?.value ?? '-1', 10),
};
if (te) {
profile.transcode = {
video_codec: g(`prof_video_codec_${key}`)?.value || 'h264',
max_resolution: g(`prof_max_resolution_${key}`)?.value || '720p',
max_video_bitrate: g(`prof_max_video_bitrate_${key}`)?.value || '',
max_fps: parseInt(g(`prof_max_fps_${key}`)?.value || '0', 10),
audio_codec: g(`prof_audio_codec_${key}`)?.value || 'aac',
max_audio_bitrate: g(`prof_max_audio_bitrate_${key}`)?.value || '',
max_audio_channels: parseInt(g(`prof_max_audio_channels_${key}`)?.value || '0', 10),
output_format: g(`prof_output_format_${key}`)?.value || 'mp4',
};
}
try {
const r = await fetch('/api/disks/profile?mount_path=' + encodeURIComponent(mountPath), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(profile),
});
const payload = await r.json();
if (!r.ok) { toast(payload.error || 'Ошибка сохранения профиля', 'error'); return; }
toast('Профиль сохранён', 'ok');
} catch { toast('Ошибка сети', 'error'); }
}
// ─── task polling ─────────────────────────────────────────────────────────────
function stopTaskPoll(id) {
if (!taskPollers.has(id)) return;
clearInterval(taskPollers.get(id));
taskPollers.delete(id);
}
function startTaskPoll(id) {
if (!id || taskPollers.has(id)) return;
taskPollers.set(id, setInterval(() => pollTask(id), 1500));
pollTask(id);
}
async function pollTask(taskID) {
try {
const r = await fetch('/api/tasks/' + taskID);
if (!r.ok) return;
const task = await r.json();
taskState.set(taskID, task);
renderDisks();
if (['success','failed','canceled'].includes(task.status)) {
stopTaskPoll(taskID);
taskState.delete(taskID);
if (task.status === 'success') toast(task.message || 'Done', 'ok');
if (task.status === 'failed') toast('Error: ' + task.error, 'error');
if (task.status === 'canceled') toast('Copy canceled', 'error');
refreshDisks();
}
} catch {}
}
// ─── disk list refresh ────────────────────────────────────────────────────────
async function refreshDisks() {
try {
const r = await fetch('/api/disks');
if (!r.ok) return;
const payload = await r.json();
disks = payload.items || [];
renderDisks();
const active = new Set(disks.map(d => d.active_task_id).filter(Boolean));
for (const id of taskPollers.keys()) {
if (!active.has(id)) { stopTaskPoll(id); taskState.delete(id); }
}
for (const id of active) startTaskPoll(id);
} catch {}
}
// ─── copy / init actions ─────────────────────────────────────────────────────
async function startCopy(diskID, mode) {
try {
const r = await fetch('/api/disks/' + encodeURIComponent(diskID) + '/copy/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode }),
});
const p = await r.json();
if (!r.ok) { toast(p.error || 'Failed to start copy', 'error'); return; }
startTaskPoll(p.task_id);
refreshDisks();
} catch { toast('Network error', 'error'); }
}
async function cancelCopy(diskID) {
try {
await fetch('/api/disks/' + encodeURIComponent(diskID) + '/copy/cancel', { method: 'POST' });
toast('Canceling...', 'ok');
} catch { toast('Network error', 'error'); }
}
async function initDisk(mountPath) {
try {
const r = await fetch('/api/disks/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mount_path: mountPath }),
});
const p = await r.json();
if (!r.ok) { toast(p.error || 'Failed to initialize disk', 'error'); return; }
toast('Disk initialized', 'ok');
refreshDisks();
} catch { toast('Network error', 'error'); }
}
// ─── event delegation ─────────────────────────────────────────────────────────
document.getElementById('diskGrid').addEventListener('click', (event) => {
const btn = event.target.closest('button[data-action]');
if (!btn) return;
const action = btn.dataset.action;
if (action === 'start-copy') startCopy(btn.dataset.diskId, btn.dataset.mode || 'add');
if (action === 'cancel-copy') cancelCopy(btn.dataset.diskId);
if (action === 'init-disk') initDisk(btn.dataset.mountPath);
if (action === 'disk-settings') {
const key = btn.dataset.diskKey;
const panel = document.getElementById('profilePanel_' + key);
if (!panel) return;
const open = panel.style.display !== 'none';
panel.style.display = open ? 'none' : '';
if (open) openPanels.delete(key); else openPanels.add(key);
btn.textContent = open ? '⚙ Settings' : '⚙ Settings ✕';
}
});
// ─── init ─────────────────────────────────────────────────────────────────────
refreshDisks();
setInterval(refreshDisks, 5000);
</script>
{{end}}