Naut-Plugin-WebUI/public/js/detail.js
ookami125 0b35571565 Make existing controls real + add robustness pass
Wire up controls that were rendered but inert, expose unused server
actions, and harden the live update path.

Controls now functional:
- File priority dropdowns (Content tab) now persist via new
  setFilePriority server action
- Context menu: Set location, Limit download/upload rate, Set share
  limit (wire setSavePath/setDownLimit/setUpLimit/setRatioLimit)
- Trackers sidebar filter actually filters (snapshot now carries
  per-torrent trackerHosts)

Robustness:
- Grid updates cells in place when row order/columns are unchanged
  (preserves text selection/scroll, no per-second listener re-bind)
- Boot retries until the server responds; SSE drops show a
  "Reconnecting" banner; failed actions/add/delete surface a toast
- Prune selection/detail for torrents that disappear
- Drop Backspace-as-delete (kept Delete)

Cleanup:
- Remove pseudo DHT/PeX/LSD names from the real-tracker host pool in
  mock data (they leaked into announce URLs and the sidebar)
- Remove dead detailTab import/export

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 21:24:20 -04:00

232 lines
10 KiB
JavaScript
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.

// Detail panel: General / Trackers / Peers / Content (files) / Pieces.
import { api } from './api.js';
import * as f from './format.js';
const TABS = ['general', 'trackers', 'peers', 'content', 'pieces'];
let activeTab = 'general';
let currentHash = null;
let refreshTimer = null;
let detailHeight = 300; // px, persisted across re-renders so resizing sticks
export function renderDetailShell(hash, host) {
currentHash = hash;
// Drive the container's grid track (persists across row clicks / re-renders),
// clamped so the panel always fits inside the available area.
const view = host.closest('.torrents-view');
if (view) {
detailHeight = clampDetailHeight(view, detailHeight);
view.style.setProperty('--detail-h', `${detailHeight}px`);
}
host.innerHTML = `
<div class="detail" id="detailPanel">
<div class="detail-resize" id="detailResize" title="Drag to resize"></div>
<div class="detail-tabs">
${TABS.map((t) => `<button class="dtab ${t === activeTab ? 'active' : ''}" data-dtab="${t}">${tabLabel(t)}</button>`).join('')}
<button class="dtab detail-close" data-act="closeDetail" title="Close detail (Esc)">✕</button>
</div>
<div class="detail-body" id="detailBody"></div>
</div>`;
host.querySelectorAll('[data-dtab]').forEach((b) =>
b.addEventListener('click', () => { activeTab = b.dataset.dtab; renderDetailShell(currentHash, host); }));
setupResize(host);
loadTab();
}
function tabLabel(t) {
return { general: 'General', trackers: 'Trackers', peers: 'Peers', content: 'Content', pieces: 'Pieces' }[t];
}
export function closeDetail() {
currentHash = null;
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = null;
}
async function loadTab() {
const body = document.getElementById('detailBody');
if (!body || !currentHash) return;
if (refreshTimer) clearInterval(refreshTimer);
const render = async () => {
if (!currentHash) return;
try {
if (activeTab === 'general') body.innerHTML = renderGeneral(await api.torrent(currentHash));
else if (activeTab === 'trackers') body.innerHTML = renderTrackers(await api.trackers(currentHash));
else if (activeTab === 'peers') body.innerHTML = renderPeers(await api.peers(currentHash));
else if (activeTab === 'content') { body.innerHTML = renderFiles(await api.files(currentHash)); bindFilePriorities(body); }
else if (activeTab === 'pieces') body.innerHTML = renderPieces(await api.pieces(currentHash));
} catch { /* ignore transient */ }
};
await render();
// live-refresh the dynamic tabs
if (['peers', 'pieces', 'general'].includes(activeTab)) refreshTimer = setInterval(render, 1500);
}
/* ---------- General ---------- */
function renderGeneral(t) {
const row = (k, v, mono) => `<div class="prop"><span class="k">${k}</span><span class="v ${mono ? 'mono' : ''}">${v}</span></div>`;
return `<div class="props">
<div class="section-h">Transfer</div>
${row('Status', `<span class="s-${t.state}">${f.stateLabel(t.state)}</span>`)}
${row('Progress', f.pct(t.progress))}
${row('Downloaded', f.bytes(t.downloaded))}
${row('Uploaded', f.bytes(t.uploaded))}
${row('Down speed', f.rate(t.dlspeed))}
${row('Up speed', f.rate(t.upspeed))}
${row('Share ratio', f.ratio(t.ratio))}
${row('ETA', f.eta(t.eta))}
${row('Availability', t.availability.toFixed(3))}
${row('Seeds', `${t.seeds} (${t.seedsTotal})`)}
${row('Peers', `${t.peers} (${t.peersTotal})`)}
${row('Down limit', t.downLimit ? f.rate(t.downLimit) : '∞')}
${row('Up limit', t.upLimit ? f.rate(t.upLimit) : '∞')}
${row('Ratio limit', t.ratioLimit > 0 ? t.ratioLimit.toFixed(2) : 'global')}
${row('Session DL / UL', `${f.bytes(t.downloadedSession)} / ${f.bytes(t.uploadedSession)}`)}
${row('Time active', f.duration(t.timeActive))}
<div class="section-h">Information</div>
${row('Name', f.esc(t.name), true)}
${row('Total size', f.bytes(t.size))}
${row('Pieces', `${t.pieceCount} × ${f.bytes(t.pieceSize)}`)}
${row('Save path', f.esc(t.savePath), true)}
${row('Content path', f.esc(t.contentPath), true)}
${row('Category', t.category ? `<span class="cat-chip">${f.esc(t.category)}</span>` : '—')}
${row('Tags', t.tags.length ? t.tags.map((x) => `<span class="tag">${f.esc(x)}</span>`).join(' ') : '—')}
${row('Added on', f.date(t.addedOn))}
${row('Completed on', f.date(t.completionOn))}
${row('Last activity', f.ago(t.lastActivity))}
${row('Hash (v1)', t.hash, true)}
${row('Privacy', t.private ? '<span class="priv-badge">PRIVATE</span>' : 'Public (DHT/PeX/LSD)')}
${row('Created by', f.esc(t.createdBy), true)}
${row('Creation date', f.date(t.creationDate))}
${row('Sequential', t.seqDl ? 'On' : 'Off')}
${row('Super seeding', t.superSeeding ? 'On' : 'Off')}
${row('Auto TMM', t.autoTMM ? 'On' : 'Off')}
${row('Force start', t.forceStart ? 'On' : 'Off')}
${t.comment ? row('Comment', f.esc(t.comment)) : ''}
</div>`;
}
/* ---------- Trackers ---------- */
function renderTrackers(trackers) {
return `<table class="dtbl"><thead><tr>
<th>Tier</th><th>URL</th><th>Status</th><th class="num">Seeds</th><th class="num">Peers</th>
<th class="num">Leeches</th><th class="num">Downloaded</th><th>Message</th>
</tr></thead><tbody>
${trackers.map((tr) => `<tr>
<td class="dim">${tr.tier < 0 ? '—' : tr.tier}</td>
<td>${f.esc(tr.url)}</td>
<td class="status-${tr.status.replace(/\s/g, '.')}">${tr.status}</td>
<td class="num">${tr.seeds < 0 ? '—' : tr.seeds}</td>
<td class="num">${tr.peers < 0 ? '—' : tr.peers}</td>
<td class="num">${tr.leeches < 0 ? '—' : tr.leeches}</td>
<td class="num">${tr.downloaded < 0 ? '—' : tr.downloaded}</td>
<td class="dim">${f.esc(tr.message)}</td>
</tr>`).join('')}
</tbody></table>`;
}
/* ---------- Peers ---------- */
function renderPeers(peers) {
if (!peers.length) return '<div class="empty">No peers connected</div>';
const sorted = [...peers].sort((a, b) => (b.dlspeed + b.upspeed) - (a.dlspeed + a.upspeed));
return `<table class="dtbl"><thead><tr>
<th>Country</th><th>IP : Port</th><th>Client</th><th>Conn</th><th>Flags</th>
<th class="num">Progress</th><th class="num">Down</th><th class="num">Up</th>
<th class="num">Downloaded</th><th class="num">Uploaded</th><th class="num">Rel.</th>
</tr></thead><tbody>
${sorted.map((p) => `<tr>
<td class="dim">${p.country}</td>
<td class="mono">${p.ip}:${p.port}</td>
<td>${f.esc(p.client)}</td>
<td class="dim">${p.connection}</td>
<td class="flagchip" title="D down U up O optimistic I incoming E encrypted X PEX H DHT">${p.flags || '·'}</td>
<td class="num">${f.pct(p.progress)}</td>
<td class="num" style="color:var(--dl)">${p.dlspeed ? f.rate(p.dlspeed) : ''}</td>
<td class="num" style="color:var(--up)">${p.upspeed ? f.rate(p.upspeed) : ''}</td>
<td class="num dim">${f.bytes(p.downloaded)}</td>
<td class="num dim">${f.bytes(p.uploaded)}</td>
<td class="num">${f.pct(p.relevance)}</td>
</tr>`).join('')}
</tbody></table>`;
}
/* ---------- Content / files ---------- */
function renderFiles(files) {
return `<table class="dtbl"><thead><tr>
<th>Name</th><th class="num">Size</th><th>Progress</th><th class="num">%</th>
<th>Priority</th><th class="num">Availability</th>
</tr></thead><tbody>
${files.map((file, i) => `<tr class="file-row">
<td>${f.esc(file.name)}</td>
<td class="num">${f.bytes(file.size)}</td>
<td><span class="mini-bar"><i style="width:${(file.progress * 100).toFixed(0)}%"></i></span></td>
<td class="num">${f.pct(file.progress)}</td>
<td><select class="prio-sel" data-file="${i}">
${[[0, 'Skip'], [1, 'Normal'], [6, 'High'], [7, 'Max']].map(([v, l]) =>
`<option value="${v}" ${file.priority === v ? 'selected' : ''}>${l}</option>`).join('')}
</select></td>
<td class="num">${file.availability.toFixed(2)}</td>
</tr>`).join('')}
</tbody></table>`;
}
function bindFilePriorities(body) {
const hash = currentHash;
body.querySelectorAll('.prio-sel').forEach((sel) =>
sel.addEventListener('change', () =>
api.action('setFilePriority', [hash], { index: +sel.dataset.file, priority: +sel.value })));
}
/* ---------- Pieces ---------- */
function renderPieces(data) {
const { pieces, pieceSize, pieceCount } = data;
let done = 0, dl = 0;
for (const p of pieces) { if (p === 2) done++; else if (p === 1) dl++; }
const cells = pieces.map((p) => `<i class="piece ${p === 2 ? 'done' : p === 1 ? 'dl' : ''}"></i>`).join('');
return `
<div class="piece-legend">
<span><i class="swatch" style="background:var(--dl)"></i> Have (${done})</span>
<span><i class="swatch" style="background:var(--warn)"></i> Downloading (${dl})</span>
<span><i class="swatch" style="background:var(--bg-3)"></i> Missing (${pieceCount - done - dl})</span>
<span class="dim">${pieceCount} pieces · ${f.bytes(pieceSize)} each</span>
</div>
<div class="piecemap">${cells}</div>`;
}
/* ---------- resize ---------- */
// Keep the panel between 120px and (container height 80px) so the list above
// always keeps a usable strip and the panel never overflows/clips.
function clampDetailHeight(view, h) {
const maxH = Math.max(120, view.clientHeight - 80);
return Math.round(Math.max(120, Math.min(maxH, h)));
}
function setupResize(host) {
const handle = host.querySelector('#detailResize');
const view = host.closest('.torrents-view');
if (!handle || !view) return;
handle.addEventListener('pointerdown', (e) => {
e.preventDefault();
handle.setPointerCapture(e.pointerId);
const startY = e.clientY;
const startH = detailHeight;
document.body.style.userSelect = 'none';
document.body.style.cursor = 'ns-resize';
const move = (ev) => {
detailHeight = clampDetailHeight(view, startH + (startY - ev.clientY));
view.style.setProperty('--detail-h', `${detailHeight}px`);
};
const up = () => {
handle.removeEventListener('pointermove', move);
handle.removeEventListener('pointerup', up);
document.body.style.userSelect = '';
document.body.style.cursor = '';
};
handle.addEventListener('pointermove', move);
handle.addEventListener('pointerup', up);
});
}