webui: automation settings panel, set-location, responsive layout

- Automation tab: settings form (define_settings) beside the editor,
  stacking above it when narrow; script/settings save split.
- Set location modal: current location + reset checkbox.
- saveScriptSettings/setSavePath API wiring; plugins panel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ookami125 2026-06-21 23:22:00 -04:00
parent 5a5b5f4330
commit 9a1d67acd4
11 changed files with 741 additions and 13 deletions

View file

@ -14,6 +14,17 @@ npm start
PORT=9000 npm start # custom port
```
The UI requires a login. If you do not configure one, the server prints a
generated password at startup for the default `admin` user. For a stable public
deployment, set credentials explicitly:
```bash
NAUT_AUTH_USER=admin NAUT_AUTH_PASSWORD='change-me' npm start
```
You can also set `NAUT_AUTH_PASSWORD_HASH` to a `pbkdf2-sha256$...` hash emitted
by the same password hashing format used by the server.
The server ships 24 mock torrents in varied states and runs a 1-second
**simulator** that fluctuates speeds, advances downloads, fills the piece map,
drifts the swarm, and accumulates session/ratio stats — so the UI is genuinely
@ -86,11 +97,49 @@ public/
detail.js Detail panel tabs + live refresh + resize
api.js fetch wrappers + EventSource live stream
format.js bytes/rate/eta/ratio/date/state formatters
plugins.js Frontend plugin registry and extension-point loader
plugins/
plugins.json Plugin manifest
health.js Example plugin
```
### Plugin system
Frontend plugins are ES modules listed in `public/plugins/plugins.json`. Each
module calls `window.Naut.registerPlugin()` and can contribute:
- `views` — new top-level tabs in the main tab bar.
- `sidebarSections` — new sections in the left sidebar, with optional mount code.
- `detailTabs` — new tabs in the selected torrent detail panel.
- `detailPanels` — additional information panels appended to the General detail tab.
Minimal plugin:
```js
window.Naut.registerPlugin({
id: 'example',
name: 'Example',
views: [{
id: 'example',
label: 'Example',
render(host, { state, f }) {
host.innerHTML = `<div class="pane">Loaded ${state.snapshot.torrents.length} torrents</div>`;
},
}],
});
```
Add the module path to `public/plugins/plugins.json`:
```json
{ "modules": ["/plugins/example.js"] }
```
### API (stub)
| Method | Path | Purpose |
|---|---|---|
| GET | `/api/auth/status` | Session state and login metadata |
| POST | `/api/login`, `/api/logout` | Create or clear a session |
| GET | `/api/plugins` | Authenticated plugin manifest |
| GET | `/api/stream` | **SSE** — pushes a compact snapshot (~1/s) |
| GET | `/api/snapshot` | One-shot snapshot (grid + server stats) |
| GET | `/api/meta` | Categories, tags, tracker hosts, prefs, search plugins |

View file

@ -40,6 +40,7 @@ body {
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
[hidden] { display: none !important; }
button, input, select { font-family: inherit; font-size: inherit; color: inherit; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: #2a3342; border-radius: 6px; border: 2px solid var(--bg-1); }
@ -51,6 +52,28 @@ button, input, select { font-family: inherit; font-size: inherit; color: inherit
height: 100vh;
}
/* ---------------- Login ---------------- */
.login-screen {
position: fixed; inset: 0; display: grid; place-items: center;
background: var(--bg); z-index: 500;
}
.login-box {
width: min(360px, calc(100vw - 32px));
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: 8px;
padding: 20px;
box-shadow: 0 20px 60px rgba(0,0,0,.45);
}
.login-brand { margin-bottom: 18px; }
.login-submit { width: 100%; padding: 8px 12px; }
.login-error {
color: var(--err); border: 1px solid rgba(240,97,109,.45);
background: rgba(240,97,109,.08); border-radius: 6px;
padding: 8px 10px; margin-bottom: 12px;
}
.login-note { margin: 12px 0 0; font-size: 12px; line-height: 1.4; }
/* ---------------- Toolbar ---------------- */
.toolbar {
display: flex; align-items: center; gap: 10px;
@ -283,6 +306,80 @@ table.dtbl td { padding: 3px 8px; border-bottom: 1px solid var(--line-soft); whi
.kvrow b { color: var(--txt); font-weight: 500; }
code.inline { background: var(--bg-3); border: 1px solid var(--line); border-radius: 4px; padding: 1px 6px; font-family: var(--mono); font-size: 12px; color: var(--accent); }
/* ---------------- Automation view ---------------- */
.automation-layout { display: flex; gap: 12px; align-items: flex-start; }
.automation-main { flex: 1 1 auto; min-width: 0; }
.automation-settings { flex: 0 0 300px; align-self: stretch; }
/* Below this the side-by-side editor would be too narrow to read (the app also
* spends 232px on the sidebar), so stack settings ABOVE the script. */
@media (max-width: 1100px) {
/* Stack vertically, settings above the script. (The base .pane height:100%
* would otherwise make each pane fill the viewport and bury the editor.) */
.automation-layout {
flex-direction: column; align-items: stretch;
flex: 1 1 auto; min-height: 0;
}
/* Settings shrink to their content, but never take more than half the
* height scroll internally if there are a lot of them. */
.automation-settings {
order: -1; align-self: auto; width: 100%;
flex: 0 1 auto; height: auto; max-height: 50%; overflow: auto;
}
/* The script gets the rest (>= half) and its editor fills that space. */
.automation-main {
width: 100%; flex: 1 1 0; min-height: 0;
display: flex; flex-direction: column;
}
.automation-main .script-editor { flex: 1 1 auto; min-height: 180px; }
}
.settings-form { display: flex; flex-direction: column; gap: 12px; }
.setting-field { display: flex; flex-direction: column; gap: 5px; }
.setting-field.bool { flex-direction: row; align-items: center; gap: 8px; }
.setting-field .setting-label { color: var(--txt-dim); font-size: 12px; }
.setting-field input[type="text"],
.setting-field input[type="number"] {
background: var(--bg-1); border: 1px solid var(--line); border-radius: 6px;
padding: 7px 9px; color: var(--txt); font-size: 13px; outline: none;
}
.setting-field input[type="text"]:focus,
.setting-field input[type="number"]:focus { border-color: var(--accent); }
.setting-field.bool input { width: 15px; height: 15px; accent-color: var(--accent); }
.automation-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 12px; }
.automation-head h3 { margin: 0 0 4px; font-size: 15px; }
.script-actions { display: flex; align-items: center; gap: 8px; }
.script-save-status { min-width: 112px; text-align: right; color: var(--txt-faint); font-size: 12px; }
.script-save-status.dirty { color: var(--warn); }
.script-save-status.ok { color: var(--ok); }
.script-save-status.err { color: var(--err); }
.script-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 8px; margin-bottom: 12px; }
.script-stat { background: var(--bg-1); border: 1px solid var(--line); border-radius: 6px; padding: 8px 10px; }
.script-stat span { display: block; color: var(--txt-dim); font-size: 11px; margin-bottom: 4px; }
.script-stat b { font-size: 18px; font-variant-numeric: tabular-nums; }
.script-error { border: 1px solid var(--err); border-radius: 6px; padding: 8px 10px; margin-bottom: 12px; color: var(--err); }
.script-error code { display: block; margin-top: 4px; color: var(--txt); font-family: var(--mono); font-size: 12px; white-space: pre-wrap; }
.script-editor {
position: relative; min-height: 420px; background: var(--bg-1);
border: 1px solid var(--line); border-radius: 8px; overflow: hidden;
}
.script-editor.disabled { opacity: .75; }
.script-highlight,
.script-editor textarea {
position: absolute; inset: 0; margin: 0; padding: 12px; border: 0;
font: 12px/1.45 var(--mono); tab-size: 4; white-space: pre;
}
.script-highlight { overflow: auto; color: var(--txt); pointer-events: none; }
.script-highlight code { white-space: pre; }
.script-editor textarea {
width: 100%; height: 100%; resize: none; outline: none; overflow: auto;
background: transparent; color: transparent; caret-color: var(--txt);
}
.script-editor textarea::selection { background: rgba(73, 157, 253, .28); color: transparent; }
.tok-comment { color: #697586; }
.tok-string { color: #8bd49c; }
.tok-keyword { color: #7fb4ff; }
.tok-number { color: #d8b4fe; }
.tok-api { color: #f6c177; }
/* ---------------- Search view ---------------- */
.search-bar { display: flex; gap: 8px; margin-bottom: 12px; }
.search-bar input { flex: 1; background: var(--bg); border: 1px solid var(--line); border-radius: 6px; padding: 8px 12px; }
@ -290,6 +387,7 @@ code.inline { background: var(--bg-3); border: 1px solid var(--line); border-rad
/* ---------------- Settings ---------------- */
.settings-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
.plugin-metric { font-size: 28px; line-height: 1; font-weight: 700; color: var(--accent); font-variant-numeric: tabular-nums; }
/* ---------------- Status bar ---------------- */
.statusbar {
@ -312,10 +410,10 @@ code.inline { background: var(--bg-3); border: 1px solid var(--line); border-rad
.modal .mfoot { padding: 12px 18px; border-top: 1px solid var(--line); display: flex; justify-content: flex-end; gap: 8px; }
.field { margin-bottom: 12px; }
.field label { display: block; color: var(--txt-dim); margin-bottom: 4px; font-size: 12px; }
.field input[type=text], .field textarea, .field select {
.field input[type=text], .field input[type=password], .field textarea, .field select {
width: 100%; background: var(--bg); border: 1px solid var(--line); border-radius: 6px; padding: 7px 10px;
}
.field input[type=text]:focus, .field textarea:focus, .field select:focus,
.field input[type=text]:focus, .field input[type=password]:focus, .field textarea:focus, .field select:focus,
.field input[type=file]:focus { outline: none; border-color: var(--accent); }
.field textarea { min-height: 70px; font-family: var(--mono); font-size: 12px; resize: vertical; }

View file

@ -50,6 +50,8 @@
</div>
<button class="btn alt-toggle" data-act="altspeed" title="Toggle alternative speed limits">
<svg viewBox="0 0 24 24" class="ic-svg"><path d="M4 18a8 8 0 0 1 16 0M12 18l3.5-5"/><circle cx="12" cy="18" r="1.3" fill="currentColor" stroke="none"/></svg> Alt</button>
<button class="btn icon" data-act="logout" title="Sign out">
<svg viewBox="0 0 24 24" class="ic-svg"><path d="M10 6H6.5a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 6.5 18H10"/><path d="M14 8l4 4-4 4M18 12H9"/></svg></button>
</header>
<div class="main">
@ -60,7 +62,8 @@
<section class="content">
<nav class="viewtabs" id="viewtabs">
<button class="vtab active" data-view="torrents">Torrents</button>
<button class="vtab" data-view="rss">RSS &amp; Automation</button>
<button class="vtab" data-view="rss">RSS</button>
<button class="vtab" data-view="automation">Automation</button>
<button class="vtab" data-view="search">Search</button>
<button class="vtab" data-view="settings">Engine</button>
</nav>

View file

@ -11,10 +11,16 @@ async function jpost(url, body) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {}),
});
if (!r.ok) throw new Error(`${url}${r.status}`);
return r.json();
}
export const api = {
authStatus: () => jget('/api/auth/status'),
login: (username, password) => jpost('/api/login', { username, password }),
logout: () => jpost('/api/logout', {}),
plugins: () => jget('/api/plugins'),
meta: () => jget('/api/meta'),
snapshot: () => jget('/api/snapshot'),
torrent: (hash) => jget(`/api/torrents/${hash}`),
@ -35,6 +41,9 @@ export const api = {
rss: () => jget('/api/rss'),
rssRules: () => jget('/api/rss/rules'),
script: () => jget('/api/script'),
saveScript: (source) => jpost('/api/script', { source }),
saveScriptSettings: (settings) => jpost('/api/script/settings', { settings }),
search: (q) => jget(`/api/search?q=${encodeURIComponent(q)}`),
// Live snapshot stream. onSnapshot(snapshot) called ~1/s.

View file

@ -1,6 +1,7 @@
// NAUT — main application controller.
// Wires the live stream into the sidebar, torrent grid, detail panel,
// status bar, RSS/Search/Engine views, selection, context menu, hotkeys.
// status bar, RSS/Automation/Search/Engine views, selection, context menu,
// hotkeys.
import { api } from './api.js';
import * as f from './format.js';
@ -30,6 +31,8 @@ const state = {
searchQuery: '',
};
let viewRenderSeq = 0;
const COLUMNS = {
name: { label: 'Name' }, // flexible: absorbs remaining width
size: { label: 'Size', num: true, w: '90px' },
@ -281,12 +284,15 @@ async function refreshMeta() {
/* ===================== views ===================== */
function renderView() {
const seq = ++viewRenderSeq;
const host = document.getElementById('viewHost');
if (state.view === 'torrents') return renderTorrentsView(host);
if (state.view === 'rss') return renderRssView(host);
if (state.view === 'rss') return renderRssView(host, seq);
if (state.view === 'automation') return renderAutomationView(host, seq);
if (state.view === 'search') return renderSearchView(host);
if (state.view === 'settings') return renderSettingsView(host);
if (getPluginView(state.view)) return renderPluginView(state.view, host, appContext());
host.innerHTML = '<div class="pane"><div class="empty">Unknown view</div></div>';
}
/* ---------- torrents view ---------- */
@ -541,12 +547,20 @@ function promptLocation() {
if (!hashes.length) return;
const cur = state.snapshot.torrents.find((t) => t.hash === hashes[0]);
openModal('Set location', `
<div class="field"><label>Save path</label><input type="text" id="locPath" value="${f.esc(cur?.savePath || '')}" /></div>
<p class="dim" style="font-size:12px">Applies to ${hashes.length} torrent(s).</p>`,
<div class="field"><label>Current location</label>
<div class="dim" style="font-family:var(--mono);font-size:12px;word-break:break-all">${f.esc(cur?.savePath || '—')}</div></div>
<div class="field"><label>New save path</label><input type="text" id="locPath" value="${f.esc(cur?.savePath || '')}" /></div>
<label class="field" style="flex-direction:row;align-items:center;gap:8px">
<input type="checkbox" id="locReset" />
<span>Reset files to their original paths</span></label>
<p class="dim" style="font-size:12px">Moves the torrent's files now. Unchecked, files you
moved individually keep their relative path (or stay put if moved elsewhere).
Applies to ${hashes.length} torrent(s).</p>`,
[{ label: 'Cancel', act: closeModal }, {
label: 'Apply', primary: true, act: () => guard(async () => {
const savePath = document.getElementById('locPath').value.trim();
if (savePath) await api.action('setSavePath', hashes, { savePath });
const reset = document.getElementById('locReset').checked;
if (savePath) await api.action('setSavePath', hashes, { savePath, reset });
closeModal();
}, 'Failed to set location'),
}]);
@ -802,9 +816,10 @@ function showContextMenu(x, y, items) {
function closeContextMenu() { document.getElementById('ctxmenu')?.remove(); }
/* ===================== RSS view ===================== */
async function renderRssView(host) {
async function renderRssView(host, seq) {
host.innerHTML = '<div class="pane"><div class="empty">Loading feeds…</div></div>';
const [feeds, rules] = await Promise.all([api.rss(), api.rssRules()]);
if (seq !== viewRenderSeq || state.view !== 'rss') return;
host.innerHTML = `<div class="pane"><div class="split2">
<div>
<div class="card"><h3>Feeds</h3>
@ -842,6 +857,213 @@ function renderRule(r) {
</div>`;
}
/* ===================== Automation view ===================== */
function highlightLua(source) {
const tokenRe = /--\[\[[\s\S]*?\]\]|\[\[[\s\S]*?\]\]|--[^\n]*|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\b(?:and|break|do|else|elseif|end|false|for|function|goto|if|in|local|nil|not|or|repeat|return|then|true|until|while)\b|\b\d+(?:\.\d+)?\b|\b(?:naut|event)\b/g;
let out = '';
let last = 0;
let m;
while ((m = tokenRe.exec(source)) !== null) {
const token = m[0];
out += f.esc(source.slice(last, m.index));
let cls = 'tok-keyword';
if (token.startsWith('--')) cls = 'tok-comment';
else if (token[0] === '"' || token[0] === "'" || token.startsWith('[[')) cls = 'tok-string';
else if (/^\d/.test(token)) cls = 'tok-number';
else if (token === 'naut' || token === 'event') cls = 'tok-api';
out += `<span class="${cls}">${f.esc(token)}</span>`;
last = tokenRe.lastIndex;
}
out += f.esc(source.slice(last));
return out;
}
function syncScriptHighlight(editor, highlight) {
highlight.innerHTML = `${highlightLua(editor.value)}\n`;
highlight.parentElement.scrollTop = editor.scrollTop;
highlight.parentElement.scrollLeft = editor.scrollLeft;
}
// One form control per declared setting, keyed for collection on save.
function settingField(s) {
const key = f.esc(s.key);
const label = f.esc(s.label || s.key);
const val = s.value ?? s.default ?? '';
if (s.type === 'bool') {
const on = String(val) === 'true' || String(val) === '1';
return `<label class="setting-field bool">
<input type="checkbox" data-skey="${key}" data-stype="bool" ${on ? 'checked' : ''}>
<span class="setting-label">${label}</span>
</label>`;
}
const inputType = s.type === 'number' ? 'number' : 'text';
return `<label class="setting-field">
<span class="setting-label">${label}</span>
<input type="${inputType}" data-skey="${key}" data-stype="${f.esc(s.type || 'string')}"
value="${f.esc(String(val))}" spellcheck="false">
</label>`;
}
function settingsPanel(settings) {
const list = Array.isArray(settings) ? settings : [];
const body = list.length
? list.map(settingField).join('')
: `<div class="dim" style="padding:4px 0">This script exposes no settings.
Call <code>naut.define_settings{}</code> in the script to add configurable
variables here.</div>`;
return `<aside class="pane automation-settings">
<div class="automation-head">
<div>
<h3>Settings</h3>
<div class="dim">Configure the script without editing it</div>
</div>
<div class="script-actions">
<span class="script-save-status" id="settingsSaveStatus"></span>
<button class="btn primary" id="saveSettings" ${list.length ? '' : 'disabled'}>Save</button>
</div>
</div>
<div class="settings-form" id="settingsForm">${body}</div>
</aside>`;
}
async function renderAutomationView(host, seq) {
host.innerHTML = '<div class="pane"><div class="empty">Loading automation script…</div></div>';
let script;
try {
script = await api.script();
} catch (e) {
if (seq !== viewRenderSeq || state.view !== 'automation') return;
host.innerHTML = '<div class="pane"><div class="empty">Unable to load script status</div></div>';
return;
}
if (seq !== viewRenderSeq || state.view !== 'automation') return;
const stat = (label, value) => `<div class="script-stat"><span>${label}</span><b>${value ?? 0}</b></div>`;
const source = script.source || '';
host.innerHTML = `<div class="automation-layout">
<div class="pane automation-main">
<div class="automation-head">
<div>
<h3>Automation Script</h3>
<div class="dim">${script.loaded ? f.esc(script.path || 'loaded') : 'No script loaded'}</div>
</div>
<div class="script-actions">
<span class="script-save-status" id="scriptSaveStatus"></span>
<button class="btn primary" id="saveScript" ${script.loaded ? '' : 'disabled'}>Save</button>
<button class="btn" id="refreshScript">Revert</button>
</div>
</div>
<div class="script-stats">
${stat('Queued', script.queued)}
${stat('Handled', script.handled)}
${stat('Dropped', script.dropped)}
${stat('Errors', script.errors)}
${stat('Move requests', script.move_requests)}
</div>
${script.last_error ? `<div class="script-error"><b>Last error</b><code>${f.esc(script.last_error)}</code></div>` : ''}
<div class="script-editor ${script.loaded ? '' : 'disabled'}">
<pre class="script-highlight" aria-hidden="true"><code id="scriptHighlight"></code></pre>
<textarea id="scriptEditor" spellcheck="false" ${script.loaded ? '' : 'disabled'}>${f.esc(script.loaded ? source : '-- no script loaded')}</textarea>
</div>
</div>
${settingsPanel(script.settings)}
</div>`;
bindSettingsPanel(script);
const editor = document.getElementById('scriptEditor');
const highlight = document.getElementById('scriptHighlight');
const saveBtn = document.getElementById('saveScript');
const refreshBtn = document.getElementById('refreshScript');
const status = document.getElementById('scriptSaveStatus');
let initialSource = script.loaded ? source : editor.value;
let saving = false;
const setStatus = (message, kind = '') => {
status.textContent = message;
status.className = `script-save-status ${kind}`;
};
const updateDirty = (preserveStatus = false) => {
const dirty = editor.value !== initialSource;
saveBtn.disabled = !script.loaded || !dirty || saving;
if (!saving && !preserveStatus) setStatus(dirty ? 'Unsaved changes' : '', dirty ? 'dirty' : '');
};
syncScriptHighlight(editor, highlight);
editor.addEventListener('input', () => {
syncScriptHighlight(editor, highlight);
updateDirty();
});
editor.addEventListener('scroll', () => syncScriptHighlight(editor, highlight));
editor.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
e.preventDefault();
editor.setRangeText(' ', editor.selectionStart, editor.selectionEnd, 'end');
editor.dispatchEvent(new Event('input'));
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
e.preventDefault();
if (!saveBtn.disabled) saveBtn.click();
}
});
saveBtn.addEventListener('click', async () => {
saving = true;
saveBtn.disabled = true;
setStatus('Saving…');
try {
const updated = await api.saveScript(editor.value);
initialSource = updated.source || editor.value;
editor.value = initialSource;
syncScriptHighlight(editor, highlight);
script = updated;
saving = false;
updateDirty(true);
setStatus('Saved', 'ok');
toast('Script saved', 'ok');
} catch (e) {
console.error(e);
saving = false;
updateDirty(true);
setStatus('Save failed', 'err');
toast('Script save failed', 'err');
}
});
refreshBtn.addEventListener('click', () => renderAutomationView(host, viewRenderSeq));
updateDirty();
}
// Wire the script-settings form: collect values by key and save them, without
// touching the script source.
function bindSettingsPanel(script) {
const form = document.getElementById('settingsForm');
const saveBtn = document.getElementById('saveSettings');
const status = document.getElementById('settingsSaveStatus');
if (!form || !saveBtn) return;
const setStatus = (msg, kind = '') => {
status.textContent = msg;
status.className = `script-save-status ${kind}`;
};
const collect = () => {
const out = {};
form.querySelectorAll('[data-skey]').forEach((el) => {
out[el.dataset.skey] = el.dataset.stype === 'bool'
? (el.checked ? 'true' : 'false')
: el.value;
});
return out;
};
form.addEventListener('input', () => setStatus('Unsaved changes', 'dirty'));
saveBtn.addEventListener('click', async () => {
saveBtn.disabled = true;
setStatus('Saving…');
try {
await api.saveScriptSettings(collect());
setStatus('Saved', 'ok');
toast('Settings saved', 'ok');
} catch (e) {
console.error(e);
setStatus('Save failed', 'err');
toast('Settings save failed', 'err');
} finally {
saveBtn.disabled = false;
}
});
}
/* ===================== Search view ===================== */
function renderSearchView(host) {
const plugins = state.meta.searchPlugins.map((p) =>
@ -997,8 +1219,10 @@ function bindGlobal() {
doAction(a);
}));
document.querySelectorAll('.vtab').forEach((b) =>
b.addEventListener('click', () => switchViewTab(b.dataset.view)));
document.getElementById('viewtabs').addEventListener('click', (e) => {
const tab = e.target.closest('.vtab[data-view]');
if (tab) switchViewTab(tab.dataset.view);
});
const qf = document.getElementById('quickFilter');
qf.addEventListener('input', () => { state.quick = qf.value; if (state.view === 'torrents') renderGrid(); });

View file

@ -2,8 +2,9 @@
import { api } from './api.js';
import * as f from './format.js';
import { pluginDetailTabs, renderPluginDetailPanels, renderPluginDetailTab } from './plugins.js';
const TABS = ['general', 'trackers', 'peers', 'content', 'pieces'];
const BUILTIN_TABS = ['general', 'trackers', 'peers', 'content', 'pieces'];
let activeTab = 'general';
let currentHash = null;
let refreshTimer = null;
@ -22,7 +23,7 @@ export function renderDetailShell(hash, host) {
<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('')}
${detailTabs().map((t) => `<button class="dtab ${t.id === activeTab ? 'active' : ''}" data-dtab="${t.id}">${t.label}</button>`).join('')}
<button class="dtab detail-close" data-act="closeDetail" title="Close detail (Esc)"></button>
</div>
<div class="detail-body" id="detailBody"></div>
@ -39,6 +40,13 @@ function tabLabel(t) {
return { general: 'General', trackers: 'Trackers', peers: 'Peers', content: 'Content', pieces: 'Pieces' }[t];
}
function detailTabs() {
return [
...BUILTIN_TABS.map((id) => ({ id, label: tabLabel(id) })),
...pluginDetailTabs().map((tab) => ({ id: tab.id, label: tab.label })),
];
}
export function closeDetail() {
currentHash = null;
if (refreshTimer) clearInterval(refreshTimer);
@ -58,6 +66,7 @@ async function loadTab() {
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));
else await renderPluginDetailTab(activeTab, body, { api, f, hash: currentHash });
} catch { /* ignore transient */ }
};
await render();
@ -107,6 +116,7 @@ function renderGeneral(t) {
${row('Auto TMM', t.autoTMM ? 'On' : 'Off')}
${row('Force start', t.forceStart ? 'On' : 'Off')}
${t.comment ? row('Comment', f.esc(t.comment)) : ''}
${renderPluginDetailPanels(t, { api, f, hash: t.hash })}
</div>`;
}

99
public/js/plugins.js Normal file
View file

@ -0,0 +1,99 @@
// Frontend plugin registry.
// Plugin modules call window.Naut.registerPlugin({...}) after they are imported.
const registry = {
plugins: [],
views: [],
sidebarSections: [],
detailTabs: [],
detailPanels: [],
};
let sharedContext = {};
function normalizeList(value) {
return Array.isArray(value) ? value : [];
}
function registerPlugin(plugin) {
if (!plugin || !plugin.id || registry.plugins.some((p) => p.id === plugin.id)) return;
registry.plugins.push(plugin);
for (const view of normalizeList(plugin.views)) registry.views.push({ plugin, ...view });
for (const section of normalizeList(plugin.sidebarSections)) registry.sidebarSections.push({ plugin, ...section });
for (const tab of normalizeList(plugin.detailTabs)) registry.detailTabs.push({ plugin, ...tab });
for (const panel of normalizeList(plugin.detailPanels)) registry.detailPanels.push({ plugin, ...panel });
}
window.Naut = {
...(window.Naut || {}),
registerPlugin,
plugins: registry,
context: () => sharedContext,
};
export async function loadPlugins(api, context) {
sharedContext = context || {};
let manifest;
try { manifest = await api.plugins(); } catch { manifest = { modules: [] }; }
for (const modulePath of manifest.modules || []) {
try { await import(modulePath); }
catch (e) { console.error(`Failed to load plugin ${modulePath}`, e); }
}
return registry.plugins;
}
export function pluginViews() {
return registry.views.filter((v) => v.id && v.label && typeof v.render === 'function');
}
export function getPluginView(id) {
return pluginViews().find((v) => v.id === id);
}
export function renderPluginView(id, host, context) {
const view = getPluginView(id);
if (!view) return false;
view.render(host, context);
return true;
}
export function renderPluginSidebarSections(context) {
return registry.sidebarSections
.filter((s) => s.id && s.title && typeof s.render === 'function')
.map((s) => `<div class="side-group plugin-side" data-plugin-sidebar="${s.plugin.id}:${s.id}">
<div class="side-head">${context.f.esc(s.title)}</div>${s.render(context) || ''}
</div>`)
.join('');
}
export function mountPluginSidebarSections(root, context) {
for (const section of registry.sidebarSections) {
const el = root.querySelector(`[data-plugin-sidebar="${CSS.escape(`${section.plugin.id}:${section.id}`)}"]`);
if (el && typeof section.onMount === 'function') section.onMount(el, context);
}
}
export function pluginDetailTabs() {
return registry.detailTabs.filter((t) => t.id && t.label && typeof t.render === 'function');
}
export async function renderPluginDetailTab(id, host, context) {
const tab = pluginDetailTabs().find((t) => t.id === id);
if (!tab) return false;
await tab.render(host, context);
return true;
}
export function renderPluginDetailPanels(torrent, context) {
return registry.detailPanels
.filter((p) => p.title && typeof p.render === 'function')
.map((p) => {
try {
return `<div class="section-h">${context.f.esc(p.title)}</div>${p.render(torrent, context) || ''}`;
} catch (e) {
console.error(`Failed to render plugin panel ${p.plugin.id}:${p.id || p.title}`, e);
return '';
}
})
.join('');
}

95
public/plugins/health.js Normal file
View file

@ -0,0 +1,95 @@
window.Naut.registerPlugin({
id: 'health',
name: 'Health Overview',
views: [{
id: 'health',
label: 'Health',
render(host, { state, f }) {
const torrents = state.snapshot.torrents;
const lowAvailability = torrents.filter((t) => t.availability < 1.05);
const idleComplete = torrents.filter((t) => t.progress >= 1 && t.upspeed === 0);
const slowDownloads = torrents.filter((t) => t.progress < 1 && t.dlspeed < 32 * 1024);
const rows = [...lowAvailability, ...slowDownloads]
.filter((t, i, arr) => arr.findIndex((x) => x.hash === t.hash) === i)
.sort((a, b) => a.availability - b.availability)
.slice(0, 18);
host.innerHTML = `<div class="pane">
<div class="settings-grid">
${metric('Low availability', lowAvailability.length)}
${metric('Idle completed', idleComplete.length)}
${metric('Slow downloads', slowDownloads.length)}
</div>
<div class="card">
<h3>Torrents needing attention</h3>
${rows.length ? table(rows, f) : '<div class="empty" style="height:120px">No health issues in the current snapshot</div>'}
</div>
</div>`;
},
}],
sidebarSections: [{
id: 'health',
title: 'Health',
render({ state }) {
const torrents = state.snapshot.torrents;
const attention = torrents.filter((t) => t.availability < 1.05 || (t.progress < 1 && t.dlspeed < 32 * 1024)).length;
return `<div class="side-item" data-health-open>
<span class="ic">+</span><span class="lbl">Needs attention</span><span class="cnt">${attention}</span>
</div>`;
},
onMount(root, { setView }) {
root.querySelector('[data-health-open]')?.addEventListener('click', () => setView('health'));
},
}],
detailTabs: [{
id: 'health-detail',
label: 'Health',
async render(host, { api, hash, f }) {
const t = await api.torrent(hash);
host.innerHTML = `<div class="props">
<div class="section-h">Health</div>
${prop('Availability', t.availability.toFixed(3))}
${prop('Connected seeds', `${t.seeds} / ${t.seedsTotal}`)}
${prop('Connected peers', `${t.peers} / ${t.peersTotal}`)}
${prop('Last activity', f.ago(t.lastActivity))}
${prop('Transfer state', t.dlspeed || t.upspeed ? 'active' : 'idle')}
</div>`;
},
}],
detailPanels: [{
id: 'health-summary',
title: 'Plugin: Health',
render(t, { f }) {
const flags = [];
if (t.availability < 1.05) flags.push('low availability');
if (t.progress < 1 && t.dlspeed < 32 * 1024) flags.push('slow download');
if (t.progress >= 1 && t.upspeed === 0) flags.push('idle seeding');
return `${prop('Attention', flags.length ? flags.join(', ') : 'none')}
${prop('Last activity', f.ago(t.lastActivity))}`;
},
}],
});
function metric(label, value) {
return `<div class="card"><h3>${label}</h3><div class="plugin-metric">${value}</div></div>`;
}
function prop(k, v) {
return `<div class="prop"><span class="k">${k}</span><span class="v">${v}</span></div>`;
}
function table(rows, f) {
return `<table class="dtbl"><thead><tr>
<th>Name</th><th class="num">Done</th><th class="num">Availability</th><th class="num">Down</th><th class="num">Up</th>
</tr></thead><tbody>${rows.map((t) => `<tr>
<td>${f.esc(t.name)}</td>
<td class="num">${f.pct(t.progress)}</td>
<td class="num">${t.availability.toFixed(2)}</td>
<td class="num">${f.rate(t.dlspeed)}</td>
<td class="num">${f.rate(t.upspeed)}</td>
</tr>`).join('')}</tbody></table>`;
}

View file

@ -0,0 +1,5 @@
{
"modules": [
"/plugins/health.js"
]
}

View file

@ -430,6 +430,20 @@ export const db = {
rssRules,
searchPlugins,
runSearch,
script: {
loaded: true,
path: 'examples/anime_sort.lua',
source: `function on_file_complete(event)
naut.move_file(event.torrent_id, event.index, event.path .. ".sorted")
end
`,
queued: 12,
handled: 12,
dropped: 0,
errors: 0,
move_requests: 3,
last_error: '',
},
// server preferences (subset, advanced)
preferences: {
dl_limit: 0,

View file

@ -3,6 +3,7 @@
// Server-Sent Events stream that pushes a live snapshot every second.
import http from 'node:http';
import { pbkdf2Sync, randomBytes, timingSafeEqual } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
@ -13,6 +14,16 @@ import { tick, globalStats } from './simulator.js';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const PUBLIC_DIR = join(__dirname, '..', 'public');
const PORT = process.env.PORT || 8088;
const AUTH_USER = process.env.NAUT_AUTH_USER || process.env.NAUT_USER || 'admin';
const SESSION_COOKIE = 'naut_session';
const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 7;
const SESSION_SECRET = process.env.NAUT_SESSION_SECRET || randomBytes(32).toString('base64url');
const generatedPassword = !process.env.NAUT_AUTH_PASSWORD && !process.env.NAUT_PASSWORD && !process.env.NAUT_AUTH_PASSWORD_HASH
? randomBytes(12).toString('base64url')
: '';
const AUTH_PASSWORD = process.env.NAUT_AUTH_PASSWORD || process.env.NAUT_PASSWORD || generatedPassword;
const AUTH_PASSWORD_HASH = process.env.NAUT_AUTH_PASSWORD_HASH || hashPassword(AUTH_PASSWORD);
const sessions = new Map();
const MIME = {
'.html': 'text/html; charset=utf-8',
@ -99,6 +110,67 @@ function findMany(hashes) {
return db.torrents.filter((t) => set.has(t.hash));
}
function hashPassword(password, salt = randomBytes(16).toString('base64url')) {
const iterations = 210000;
const digest = pbkdf2Sync(String(password), salt, iterations, 32, 'sha256').toString('base64url');
return `pbkdf2-sha256$${iterations}$${salt}$${digest}`;
}
function verifyPassword(password, stored) {
const [scheme, iterRaw, salt, digest] = String(stored || '').split('$');
if (scheme !== 'pbkdf2-sha256' || !iterRaw || !salt || !digest) return false;
const actual = pbkdf2Sync(String(password), salt, Number(iterRaw), 32, 'sha256');
const expected = Buffer.from(digest, 'base64url');
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
function parseCookies(req) {
const out = {};
for (const part of (req.headers.cookie || '').split(';')) {
const idx = part.indexOf('=');
if (idx > -1) out[part.slice(0, idx).trim()] = decodeURIComponent(part.slice(idx + 1).trim());
}
return out;
}
function cookie(res, value, req) {
const secure = req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https';
res.setHeader('Set-Cookie', `${SESSION_COOKIE}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}${secure ? '; Secure' : ''}`);
}
function clearCookie(res) {
res.setHeader('Set-Cookie', `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`);
}
function currentUser(req) {
const token = parseCookies(req)[SESSION_COOKIE];
if (!token) return null;
const sess = sessions.get(token);
if (!sess || sess.expires < Date.now()) {
if (sess) sessions.delete(token);
return null;
}
sess.expires = Date.now() + SESSION_TTL_MS;
return sess.user;
}
function createSession(res, req) {
const token = `${randomBytes(24).toString('base64url')}.${randomBytes(8).toString('base64url')}.${SESSION_SECRET.slice(0, 8)}`;
sessions.set(token, { user: AUTH_USER, expires: Date.now() + SESSION_TTL_MS });
cookie(res, token, req);
}
async function readPluginManifest() {
try {
const raw = await readFile(join(PUBLIC_DIR, 'plugins', 'plugins.json'), 'utf8');
const manifest = JSON.parse(raw);
const modules = Array.isArray(manifest.modules) ? manifest.modules : [];
return { modules: modules.filter((m) => typeof m === 'string' && m.startsWith('/plugins/') && m.endsWith('.js')) };
} catch {
return { modules: [] };
}
}
// ---- action handlers (mutate the stub state) ----
const ACTIONS = {
pause: (t) => { if (!t.state.startsWith('paused')) { t.state = t.progress >= 1 ? 'pausedUP' : 'pausedDL'; t.dlspeed = 0; t.upspeed = 0; t.forceStart = false; } },
@ -126,6 +198,36 @@ async function handleApi(req, res, url) {
const parts = url.pathname.split('/').filter(Boolean); // ['api', ...]
const seg = parts.slice(1); // drop 'api'
// Auth endpoints stay outside the protected API surface.
if (seg[0] === 'auth' && seg[1] === 'status' && req.method === 'GET') {
return json(res, {
authenticated: !!currentUser(req),
user: AUTH_USER,
generatedPassword: !!generatedPassword,
});
}
if (seg[0] === 'login' && req.method === 'POST') {
const body = await readBody(req);
if ((body.username || '') !== AUTH_USER || !verifyPassword(body.password || '', AUTH_PASSWORD_HASH)) {
return json(res, { ok: false, error: 'invalid credentials' }, 401);
}
createSession(res, req);
return json(res, { ok: true, user: AUTH_USER });
}
if (seg[0] === 'logout' && req.method === 'POST') {
const token = parseCookies(req)[SESSION_COOKIE];
if (token) sessions.delete(token);
clearCookie(res);
return json(res, { ok: true });
}
if (!currentUser(req)) return json(res, { error: 'authentication required' }, 401);
// GET /api/plugins (frontend plugin manifest)
if (seg[0] === 'plugins' && req.method === 'GET') return json(res, await readPluginManifest());
// GET /api/stream (SSE)
if (seg[0] === 'stream') {
res.writeHead(200, {
@ -169,6 +271,21 @@ async function handleApi(req, res, url) {
return json(res, { alt_speed_enabled: db.preferences.alt_speed_enabled });
}
if (seg[0] === 'script') {
if (req.method === 'GET') return json(res, db.script);
if (req.method === 'POST') {
const body = await readBody(req);
if (typeof body.source !== 'string') {
return json(res, { error: 'missing source' }, 400);
}
db.script.loaded = true;
db.script.source = body.source;
db.script.handled += 1;
db.script.last_error = '';
return json(res, db.script);
}
}
// Categories: create (POST /api/categories) / delete (POST /api/categories/delete)
if (seg[0] === 'categories' && req.method === 'POST') {
const body = await readBody(req);
@ -338,5 +455,10 @@ const server = http.createServer(async (req, res) => {
server.listen(PORT, () => {
console.log(`\n torrent-ui stub server running`);
console.log(` → http://localhost:${PORT}\n`);
console.log(` auth user: ${AUTH_USER}`);
if (generatedPassword) {
console.log(` generated password: ${generatedPassword}`);
console.log(` set NAUT_AUTH_PASSWORD or NAUT_AUTH_PASSWORD_HASH for a stable public deployment\n`);
}
console.log(` ${db.torrents.length} mock torrents, live simulator ticking every 1s\n`);
});