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:
parent
5a5b5f4330
commit
9a1d67acd4
11 changed files with 741 additions and 13 deletions
49
README.md
49
README.md
|
|
@ -14,6 +14,17 @@ npm start
|
||||||
PORT=9000 npm start # custom port
|
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
|
The server ships 24 mock torrents in varied states and runs a 1-second
|
||||||
**simulator** that fluctuates speeds, advances downloads, fills the piece map,
|
**simulator** that fluctuates speeds, advances downloads, fills the piece map,
|
||||||
drifts the swarm, and accumulates session/ratio stats — so the UI is genuinely
|
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
|
detail.js Detail panel tabs + live refresh + resize
|
||||||
api.js fetch wrappers + EventSource live stream
|
api.js fetch wrappers + EventSource live stream
|
||||||
format.js bytes/rate/eta/ratio/date/state formatters
|
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)
|
### API (stub)
|
||||||
| Method | Path | Purpose |
|
| 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/stream` | **SSE** — pushes a compact snapshot (~1/s) |
|
||||||
| GET | `/api/snapshot` | One-shot snapshot (grid + server stats) |
|
| GET | `/api/snapshot` | One-shot snapshot (grid + server stats) |
|
||||||
| GET | `/api/meta` | Categories, tags, tracker hosts, prefs, search plugins |
|
| GET | `/api/meta` | Categories, tags, tracker hosts, prefs, search plugins |
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ body {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
[hidden] { display: none !important; }
|
||||||
button, input, select { font-family: inherit; font-size: inherit; color: inherit; }
|
button, input, select { font-family: inherit; font-size: inherit; color: inherit; }
|
||||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||||
::-webkit-scrollbar-thumb { background: #2a3342; border-radius: 6px; border: 2px solid var(--bg-1); }
|
::-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;
|
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 ---------------- */
|
||||||
.toolbar {
|
.toolbar {
|
||||||
display: flex; align-items: center; gap: 10px;
|
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; }
|
.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); }
|
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 view ---------------- */
|
||||||
.search-bar { display: flex; gap: 8px; margin-bottom: 12px; }
|
.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; }
|
.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 ---------------- */
|
||||||
.settings-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
|
.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 ---------------- */
|
/* ---------------- Status bar ---------------- */
|
||||||
.statusbar {
|
.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; }
|
.modal .mfoot { padding: 12px 18px; border-top: 1px solid var(--line); display: flex; justify-content: flex-end; gap: 8px; }
|
||||||
.field { margin-bottom: 12px; }
|
.field { margin-bottom: 12px; }
|
||||||
.field label { display: block; color: var(--txt-dim); margin-bottom: 4px; font-size: 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;
|
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 input[type=file]:focus { outline: none; border-color: var(--accent); }
|
||||||
.field textarea { min-height: 70px; font-family: var(--mono); font-size: 12px; resize: vertical; }
|
.field textarea { min-height: 70px; font-family: var(--mono); font-size: 12px; resize: vertical; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,8 @@
|
||||||
</div>
|
</div>
|
||||||
<button class="btn alt-toggle" data-act="altspeed" title="Toggle alternative speed limits">
|
<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>
|
<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>
|
</header>
|
||||||
|
|
||||||
<div class="main">
|
<div class="main">
|
||||||
|
|
@ -60,7 +62,8 @@
|
||||||
<section class="content">
|
<section class="content">
|
||||||
<nav class="viewtabs" id="viewtabs">
|
<nav class="viewtabs" id="viewtabs">
|
||||||
<button class="vtab active" data-view="torrents">Torrents</button>
|
<button class="vtab active" data-view="torrents">Torrents</button>
|
||||||
<button class="vtab" data-view="rss">RSS & 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="search">Search</button>
|
||||||
<button class="vtab" data-view="settings">Engine</button>
|
<button class="vtab" data-view="settings">Engine</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,16 @@ async function jpost(url, body) {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body || {}),
|
body: JSON.stringify(body || {}),
|
||||||
});
|
});
|
||||||
|
if (!r.ok) throw new Error(`${url} → ${r.status}`);
|
||||||
return r.json();
|
return r.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
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'),
|
meta: () => jget('/api/meta'),
|
||||||
snapshot: () => jget('/api/snapshot'),
|
snapshot: () => jget('/api/snapshot'),
|
||||||
torrent: (hash) => jget(`/api/torrents/${hash}`),
|
torrent: (hash) => jget(`/api/torrents/${hash}`),
|
||||||
|
|
@ -35,6 +41,9 @@ export const api = {
|
||||||
|
|
||||||
rss: () => jget('/api/rss'),
|
rss: () => jget('/api/rss'),
|
||||||
rssRules: () => jget('/api/rss/rules'),
|
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)}`),
|
search: (q) => jget(`/api/search?q=${encodeURIComponent(q)}`),
|
||||||
|
|
||||||
// Live snapshot stream. onSnapshot(snapshot) called ~1/s.
|
// Live snapshot stream. onSnapshot(snapshot) called ~1/s.
|
||||||
|
|
|
||||||
240
public/js/app.js
240
public/js/app.js
|
|
@ -1,6 +1,7 @@
|
||||||
// NAUT — main application controller.
|
// NAUT — main application controller.
|
||||||
// Wires the live stream into the sidebar, torrent grid, detail panel,
|
// 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 { api } from './api.js';
|
||||||
import * as f from './format.js';
|
import * as f from './format.js';
|
||||||
|
|
@ -30,6 +31,8 @@ const state = {
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let viewRenderSeq = 0;
|
||||||
|
|
||||||
const COLUMNS = {
|
const COLUMNS = {
|
||||||
name: { label: 'Name' }, // flexible: absorbs remaining width
|
name: { label: 'Name' }, // flexible: absorbs remaining width
|
||||||
size: { label: 'Size', num: true, w: '90px' },
|
size: { label: 'Size', num: true, w: '90px' },
|
||||||
|
|
@ -281,12 +284,15 @@ async function refreshMeta() {
|
||||||
|
|
||||||
/* ===================== views ===================== */
|
/* ===================== views ===================== */
|
||||||
function renderView() {
|
function renderView() {
|
||||||
|
const seq = ++viewRenderSeq;
|
||||||
const host = document.getElementById('viewHost');
|
const host = document.getElementById('viewHost');
|
||||||
if (state.view === 'torrents') return renderTorrentsView(host);
|
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 === 'search') return renderSearchView(host);
|
||||||
if (state.view === 'settings') return renderSettingsView(host);
|
if (state.view === 'settings') return renderSettingsView(host);
|
||||||
if (getPluginView(state.view)) return renderPluginView(state.view, host, appContext());
|
if (getPluginView(state.view)) return renderPluginView(state.view, host, appContext());
|
||||||
|
host.innerHTML = '<div class="pane"><div class="empty">Unknown view</div></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- torrents view ---------- */
|
/* ---------- torrents view ---------- */
|
||||||
|
|
@ -541,12 +547,20 @@ function promptLocation() {
|
||||||
if (!hashes.length) return;
|
if (!hashes.length) return;
|
||||||
const cur = state.snapshot.torrents.find((t) => t.hash === hashes[0]);
|
const cur = state.snapshot.torrents.find((t) => t.hash === hashes[0]);
|
||||||
openModal('Set location', `
|
openModal('Set location', `
|
||||||
<div class="field"><label>Save path</label><input type="text" id="locPath" value="${f.esc(cur?.savePath || '')}" /></div>
|
<div class="field"><label>Current location</label>
|
||||||
<p class="dim" style="font-size:12px">Applies to ${hashes.length} torrent(s).</p>`,
|
<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: 'Cancel', act: closeModal }, {
|
||||||
label: 'Apply', primary: true, act: () => guard(async () => {
|
label: 'Apply', primary: true, act: () => guard(async () => {
|
||||||
const savePath = document.getElementById('locPath').value.trim();
|
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();
|
closeModal();
|
||||||
}, 'Failed to set location'),
|
}, 'Failed to set location'),
|
||||||
}]);
|
}]);
|
||||||
|
|
@ -802,9 +816,10 @@ function showContextMenu(x, y, items) {
|
||||||
function closeContextMenu() { document.getElementById('ctxmenu')?.remove(); }
|
function closeContextMenu() { document.getElementById('ctxmenu')?.remove(); }
|
||||||
|
|
||||||
/* ===================== RSS view ===================== */
|
/* ===================== RSS view ===================== */
|
||||||
async function renderRssView(host) {
|
async function renderRssView(host, seq) {
|
||||||
host.innerHTML = '<div class="pane"><div class="empty">Loading feeds…</div></div>';
|
host.innerHTML = '<div class="pane"><div class="empty">Loading feeds…</div></div>';
|
||||||
const [feeds, rules] = await Promise.all([api.rss(), api.rssRules()]);
|
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">
|
host.innerHTML = `<div class="pane"><div class="split2">
|
||||||
<div>
|
<div>
|
||||||
<div class="card"><h3>Feeds</h3>
|
<div class="card"><h3>Feeds</h3>
|
||||||
|
|
@ -842,6 +857,213 @@ function renderRule(r) {
|
||||||
</div>`;
|
</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 ===================== */
|
/* ===================== Search view ===================== */
|
||||||
function renderSearchView(host) {
|
function renderSearchView(host) {
|
||||||
const plugins = state.meta.searchPlugins.map((p) =>
|
const plugins = state.meta.searchPlugins.map((p) =>
|
||||||
|
|
@ -997,8 +1219,10 @@ function bindGlobal() {
|
||||||
doAction(a);
|
doAction(a);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
document.querySelectorAll('.vtab').forEach((b) =>
|
document.getElementById('viewtabs').addEventListener('click', (e) => {
|
||||||
b.addEventListener('click', () => switchViewTab(b.dataset.view)));
|
const tab = e.target.closest('.vtab[data-view]');
|
||||||
|
if (tab) switchViewTab(tab.dataset.view);
|
||||||
|
});
|
||||||
|
|
||||||
const qf = document.getElementById('quickFilter');
|
const qf = document.getElementById('quickFilter');
|
||||||
qf.addEventListener('input', () => { state.quick = qf.value; if (state.view === 'torrents') renderGrid(); });
|
qf.addEventListener('input', () => { state.quick = qf.value; if (state.view === 'torrents') renderGrid(); });
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@
|
||||||
|
|
||||||
import { api } from './api.js';
|
import { api } from './api.js';
|
||||||
import * as f from './format.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 activeTab = 'general';
|
||||||
let currentHash = null;
|
let currentHash = null;
|
||||||
let refreshTimer = null;
|
let refreshTimer = null;
|
||||||
|
|
@ -22,7 +23,7 @@ export function renderDetailShell(hash, host) {
|
||||||
<div class="detail" id="detailPanel">
|
<div class="detail" id="detailPanel">
|
||||||
<div class="detail-resize" id="detailResize" title="Drag to resize"></div>
|
<div class="detail-resize" id="detailResize" title="Drag to resize"></div>
|
||||||
<div class="detail-tabs">
|
<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>
|
<button class="dtab detail-close" data-act="closeDetail" title="Close detail (Esc)">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-body" id="detailBody"></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];
|
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() {
|
export function closeDetail() {
|
||||||
currentHash = null;
|
currentHash = null;
|
||||||
if (refreshTimer) clearInterval(refreshTimer);
|
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 === 'peers') body.innerHTML = renderPeers(await api.peers(currentHash));
|
||||||
else if (activeTab === 'content') { body.innerHTML = renderFiles(await api.files(currentHash)); bindFilePriorities(body); }
|
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 if (activeTab === 'pieces') body.innerHTML = renderPieces(await api.pieces(currentHash));
|
||||||
|
else await renderPluginDetailTab(activeTab, body, { api, f, hash: currentHash });
|
||||||
} catch { /* ignore transient */ }
|
} catch { /* ignore transient */ }
|
||||||
};
|
};
|
||||||
await render();
|
await render();
|
||||||
|
|
@ -107,6 +116,7 @@ function renderGeneral(t) {
|
||||||
${row('Auto TMM', t.autoTMM ? 'On' : 'Off')}
|
${row('Auto TMM', t.autoTMM ? 'On' : 'Off')}
|
||||||
${row('Force start', t.forceStart ? 'On' : 'Off')}
|
${row('Force start', t.forceStart ? 'On' : 'Off')}
|
||||||
${t.comment ? row('Comment', f.esc(t.comment)) : ''}
|
${t.comment ? row('Comment', f.esc(t.comment)) : ''}
|
||||||
|
${renderPluginDetailPanels(t, { api, f, hash: t.hash })}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
99
public/js/plugins.js
Normal file
99
public/js/plugins.js
Normal 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
95
public/plugins/health.js
Normal 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>`;
|
||||||
|
}
|
||||||
5
public/plugins/plugins.json
Normal file
5
public/plugins/plugins.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"modules": [
|
||||||
|
"/plugins/health.js"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -430,6 +430,20 @@ export const db = {
|
||||||
rssRules,
|
rssRules,
|
||||||
searchPlugins,
|
searchPlugins,
|
||||||
runSearch,
|
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)
|
// server preferences (subset, advanced)
|
||||||
preferences: {
|
preferences: {
|
||||||
dl_limit: 0,
|
dl_limit: 0,
|
||||||
|
|
|
||||||
122
server/index.js
122
server/index.js
|
|
@ -3,6 +3,7 @@
|
||||||
// Server-Sent Events stream that pushes a live snapshot every second.
|
// Server-Sent Events stream that pushes a live snapshot every second.
|
||||||
|
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
|
import { pbkdf2Sync, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { extname, join, normalize } from 'node:path';
|
import { extname, join, normalize } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
@ -13,6 +14,16 @@ import { tick, globalStats } from './simulator.js';
|
||||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||||
const PUBLIC_DIR = join(__dirname, '..', 'public');
|
const PUBLIC_DIR = join(__dirname, '..', 'public');
|
||||||
const PORT = process.env.PORT || 8088;
|
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 = {
|
const MIME = {
|
||||||
'.html': 'text/html; charset=utf-8',
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
|
@ -99,6 +110,67 @@ function findMany(hashes) {
|
||||||
return db.torrents.filter((t) => set.has(t.hash));
|
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) ----
|
// ---- action handlers (mutate the stub state) ----
|
||||||
const ACTIONS = {
|
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; } },
|
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 parts = url.pathname.split('/').filter(Boolean); // ['api', ...]
|
||||||
const seg = parts.slice(1); // drop '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)
|
// GET /api/stream (SSE)
|
||||||
if (seg[0] === 'stream') {
|
if (seg[0] === 'stream') {
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
|
|
@ -169,6 +271,21 @@ async function handleApi(req, res, url) {
|
||||||
return json(res, { alt_speed_enabled: db.preferences.alt_speed_enabled });
|
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)
|
// Categories: create (POST /api/categories) / delete (POST /api/categories/delete)
|
||||||
if (seg[0] === 'categories' && req.method === 'POST') {
|
if (seg[0] === 'categories' && req.method === 'POST') {
|
||||||
const body = await readBody(req);
|
const body = await readBody(req);
|
||||||
|
|
@ -338,5 +455,10 @@ const server = http.createServer(async (req, res) => {
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
console.log(`\n torrent-ui stub server running`);
|
console.log(`\n torrent-ui stub server running`);
|
||||||
console.log(` → http://localhost:${PORT}\n`);
|
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`);
|
console.log(` ${db.torrents.length} mock torrents, live simulator ticking every 1s\n`);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue