From 8b21d19365794f1e4de339885fbc5da9ff2289ac Mon Sep 17 00:00:00 2001 From: rays Date: Thu, 10 Sep 2026 17:07:19 +0000 Subject: [PATCH] Log tabs with a daemon I/O view, and Playwright UI tests The log view splits into All / Daemon I/O / Scans / HTTP. Daemon I/O is the control protocol itself, logged where every command funnels through so it covers socket clients, the CLI and the web UI alike. stderr and the in-app buffer now have separate filters, so the UI can keep debug detail the terminal should not carry. Playwright drives a real browser against a daemon on fixture feeds. Eight tests, each mapping to a bug that reached a user -- the Rust tests and the stub-DOM smoke test cannot see a wrong selector or a dead handler. It immediately found one: OPML folders rendered expanded by default, because the code stored closed groups, so any folder never toggled counted as open. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh --- .gitignore | 3 + PROGRESS.md | 33 ++++++++++ README.md | 27 +++++++- docker-compose.yml | 4 +- package-lock.json | 58 ++++++++++++++++ package.json | 13 ++++ playwright.config.js | 43 ++++++++++++ src/ipc.rs | 9 +++ src/logbuf.rs | 2 +- src/main.rs | 25 +++++-- tests/ui/app.spec.js | 127 ++++++++++++++++++++++++++++++++++++ tests/ui/fixtures/ep1.mp3 | Bin 0 -> 40000 bytes tests/ui/fixtures/other.xml | 5 ++ tests/ui/fixtures/serve.js | 19 ++++++ tests/ui/fixtures/show.xml | 15 +++++ tests/ui/fixtures/subs.opml | 4 ++ tests/ui/global-setup.js | 43 ++++++++++++ web/index.html | 47 ++++++++++--- 18 files changed, 457 insertions(+), 20 deletions(-) create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 playwright.config.js create mode 100644 tests/ui/app.spec.js create mode 100644 tests/ui/fixtures/ep1.mp3 create mode 100644 tests/ui/fixtures/other.xml create mode 100644 tests/ui/fixtures/serve.js create mode 100644 tests/ui/fixtures/show.xml create mode 100644 tests/ui/fixtures/subs.opml create mode 100644 tests/ui/global-setup.js diff --git a/.gitignore b/.gitignore index ea8c4bf..1a0a581 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ /target +/node_modules +/test-results +/playwright-report diff --git a/PROGRESS.md b/PROGRESS.md index b5c9504..66305fe 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -56,6 +56,39 @@ and until now nothing set them. --- +## 2026-09-10 — Daemon I/O log tab, and Playwright + +**Log tabs.** All / Daemon I/O / Scans / HTTP. "Daemon I/O" is the control protocol itself: every +command arriving (`-> {"cmd":...}`) and every event leaving (`<- {"ev":...}`), logged under +`ipx::io` at the one point every command funnels through, so it catches socket clients, the CLI +proxying and the web UI alike. Rendered in that tab as in/out rather than level+target. + +The two logging destinations now have **separate filters**: stderr follows `IPX_LOG` (default +`ipx=info`), the in-app buffer follows `IPX_UI_LOG` (default `ipx=debug`) and holds 5000 lines. The +UI can therefore show protocol traffic and routine skips that would be noise on a terminal. + +**Playwright.** `npm install` plus `npx playwright install --with-deps chromium`, added to +`/src/install.sh`. `playwright.config.js` starts a fixture feed server and a daemon on a scratch +config, and eight tests drive a real browser. Each maps to a bug that actually reached Ray. + +Two things the harness taught, both worth keeping: + +- **Playwright starts `webServer` before `globalSetup`.** Writing the scratch config in globalSetup + meant the daemon launched with no config, fell back to defaults, and tried to seize the live + daemon's socket. The config is written at config-load time instead. +- Ports 8097/8771 looked free but `ss` showed 8097 held by a process outside this container (shared + host network). Moved to 8791/8792. + +**The suite found a real bug on its first green-ish run:** OPML folders rendered *expanded* by +default. The code stored the set of closed groups, so a folder never toggled — every folder in a +fresh browser — counted as open, the exact opposite of the comment above it. It stores the open ones +now. Two other failures were my tests' fault, not the code's: asserting `S1E1` on `.ep.first()` when +newest-first put a different episode there, and an unscoped `getByText` matching both the sidebar +entry and the page heading. The `SE` in that first failure was the artwork placeholder's initials, +which I nearly misread as a broken chip. + +--- + ## 2026-09-10 — Regression: derived feeds looked "unsubscribed" to half the code Reported as `error: enclosure 235 belongs to unsubscribed feed "abort-retry-fail"`. Moving OPML diff --git a/README.md b/README.md index bf2254e..004baae 100644 --- a/README.md +++ b/README.md @@ -163,11 +163,32 @@ Put it behind a reverse proxy with TLS if that matters to you. ## Log view -The **Log** button in the sidebar shows the running daemon's output live: feed scans, downloads, +The **Log** button in the sidebar shows the running daemon's output live, in four tabs: +**Daemon I/O** is the control protocol itself — every command in and every event out, as JSON; +**Scans** is feed and download activity; **HTTP** is web requests; **All** is everything: feed scans, downloads, torrent activity and every HTTP request, with level and text filters and a copy button. It reads a 2000-line ring buffer held inside the process (`/api/logs`), not a file — so it works the same under -Docker, where logs go to stdout and there is no file to tail. `IPX_LOG=ipx=debug` adds detail; -`IPX_LOG=ipx=info,librqbit=info` shows what torrents are doing. +Docker, where logs go to stdout and there is no file to tail. The buffer keeps `debug` even when the terminal does not, so protocol traffic and routine +skips are there without making stderr unreadable — `IPX_UI_LOG` changes what it captures and +`IPX_LOG` what reaches stderr. + +## Tests + +```sh +cargo test # the server: parsing, filters, retention, schedules, SQL +node tests/page-smoke.js # the page script loads without throwing +npx playwright test # a real browser against a real daemon +``` + +The Rust tests cover the server and the smoke test catches a script that fails to load, but neither +can see a wrong selector, a handler that runs and does nothing, or a page that renders empty — which +is what has actually slipped through. The Playwright suite drives a headless browser against a +daemon started on fixture feeds, and each test maps to a bug that reached a user: the page rendering +empty, a dead theme toggle, settings not persisting, episode metadata, filter tabs, the feed URL +field and its copy button, the log tabs, and OPML folders. + +`npm install` gets the test runner; the browser itself comes from +`npx playwright install --with-deps chromium` (in `install.sh`). ## Docker diff --git a/docker-compose.yml b/docker-compose.yml index a93f5a1..c3a2fcb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,8 @@ services: - ipx: + iPodderX: build: . # image: ipx:latest # swap build: for image: once you have published one - container_name: ipx + container_name: iPodderX restart: unless-stopped environment: # Unraid shares expect these; downloads land owned by nobody:users. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..28544fb --- /dev/null +++ b/package-lock.json @@ -0,0 +1,58 @@ +{ + "name": "ipx-ui-tests", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ipx-ui-tests", + "devDependencies": { + "@playwright/test": "^1.56.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2483bb1 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "ipx-ui-tests", + "private": true, + "description": "Browser tests for the ipx web UI. The Rust tests cover the server; these cover the page.", + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed", + "smoke": "node tests/page-smoke.js" + }, + "devDependencies": { + "@playwright/test": "^1.56.0" + } +} diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..6e2dab3 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,43 @@ +const { defineConfig } = require('@playwright/test'); +const setup = require('./tests/ui/global-setup'); + +// Before anything else, including the servers below. +setup.prepare(); + +// Real browser against a real daemon. The stub-DOM smoke test catches a script that +// fails to load; it cannot catch a wrong selector, a handler that runs but does nothing, +// or a page that renders empty -- which is exactly what has slipped through before. +module.exports = defineConfig({ + testDir: './tests/ui', + timeout: 30_000, + expect: { timeout: 10_000 }, + fullyParallel: false, // one daemon, one database + workers: 1, + reporter: process.env.CI ? 'line' : [['list']], + use: { + baseURL: 'http://127.0.0.1:8791', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + webServer: [ + { + command: 'node tests/ui/fixtures/serve.js', + port: 8792, + reuseExistingServer: false, + stdout: 'ignore', + }, + { + // Build first so the tests always run against current source. + command: 'cargo build -q && exec ./target/debug/ipx daemon', + port: 8791, + reuseExistingServer: false, + timeout: 180_000, + stdout: 'pipe', + env: { + IPX_CONFIG: `${setup.root}/config/config.toml`, + IPX_DATA_DIR: `${setup.root}/data`, + IPX_LOG: 'ipx=info', + }, + }, + ], +}); diff --git a/src/ipc.rs b/src/ipc.rs index f1337e6..1adef43 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -149,6 +149,15 @@ impl Emitter { } if let Some(tx) = &self.tx { + // The outbound half of the protocol, as it goes on the wire. Progress is the + // high-volume one, so it sits at debug. + if let Ok(json) = serde_json::to_string(&e) { + if matches!(e, Event::Progress { .. }) { + tracing::debug!(target: "ipx::io", "<- {json}"); + } else { + tracing::info!(target: "ipx::io", "<- {json}"); + } + } // An error here only means nobody is listening yet. let _ = tx.send(e.clone()); } diff --git a/src/logbuf.rs b/src/logbuf.rs index 4eb7bc0..443ec3f 100644 --- a/src/logbuf.rs +++ b/src/logbuf.rs @@ -10,7 +10,7 @@ use tracing_subscriber::Layer; use tracing_subscriber::layer::Context; /// Kept small enough to be cheap to hold and to serialise in one response. -const CAPACITY: usize = 2000; +const CAPACITY: usize = 5000; #[derive(Clone, Debug, serde::Serialize)] pub struct LogLine { diff --git a/src/main.rs b/src/main.rs index 97ea866..77fe69c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -126,13 +126,21 @@ async fn main() -> Result<()> { { use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; + use tracing_subscriber::Layer; + // Two filters, deliberately different. stderr follows IPX_LOG; the in-app buffer + // keeps debug as well, so the log view can show protocol traffic and routine + // skips that would be noise on a terminal. IPX_UI_LOG overrides it. + let stderr_filter = tracing_subscriber::EnvFilter::try_from_env("IPX_LOG") + .unwrap_or_else(|_| "ipx=info".into()); + let ui_filter = tracing_subscriber::EnvFilter::try_from_env("IPX_UI_LOG") + .unwrap_or_else(|_| "ipx=debug".into()); tracing_subscriber::registry() .with( - tracing_subscriber::EnvFilter::try_from_env("IPX_LOG") - .unwrap_or_else(|_| "ipx=info".into()), + tracing_subscriber::fmt::layer() + .with_writer(std::io::stderr) + .with_filter(stderr_filter), ) - .with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr)) - .with(logbuf::RingLayer) + .with(logbuf::RingLayer.with_filter(ui_filter)) .init(); } @@ -282,7 +290,14 @@ async fn daemon( biased; _ = stop.changed() => break, Some(cmd) = rx_cmd.recv() => { - tracing::info!(?cmd, "command from a client"); + // Both halves of the protocol are logged under one target so the UI can + // show the conversation on its own: this is everything arriving, whatever + // the source -- a socket client, the CLI proxying, or the web UI. + tracing::info!( + target: "ipx::io", + "-> {}", + serde_json::to_string(&cmd).unwrap_or_else(|_| format!("{cmd:?}")) + ); if !until_stopped(&ctx, &rx_stop, run(&ctx, cmd)).await { break; } diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js new file mode 100644 index 0000000..52c1c7d --- /dev/null +++ b/tests/ui/app.spec.js @@ -0,0 +1,127 @@ +const { test, expect } = require('@playwright/test'); +const { TOKEN } = require('./global-setup'); + +// The token sets a cookie, so every test starts by presenting it once. +test.beforeEach(async ({ page }) => { + await page.goto(`/?token=${TOKEN}`); + await expect(page.locator('#feedlist')).toBeVisible(); +}); + +test('the page loads and lists the configured feeds', async ({ page }) => { + // Regression: a ReferenceError in the script left the shell rendered and the sidebar + // empty, with every handler below the error dead. Server-side checks all passed. + await expect(page.locator('.feed')).toHaveCount(2, { timeout: 15_000 }); + await expect(page.getByText('Test Show')).toBeVisible(); + const errors = []; + page.on('pageerror', e => errors.push(e.message)); + await page.reload(); + await expect(page.locator('.feed').first()).toBeVisible(); + expect(errors, 'the page script must not throw at load').toEqual([]); +}); + +test('the theme toggle actually changes the theme', async ({ page }) => { + // Regression: this button was wired after a line that threw, so it did nothing. + const before = await page.evaluate(() => document.documentElement.dataset.theme || 'system'); + await page.locator('#theme').click(); + await expect + .poll(() => page.evaluate(() => document.documentElement.dataset.theme)) + .not.toBe(before); +}); + +test('settings opens and saves the global schedule', async ({ page }) => { + await page.locator('#prefs').click(); + await expect(page.locator('#modal.on')).toBeVisible(); + await expect(page.locator('#gnum')).toBeVisible(); + await page.locator('#gnum').fill('4'); + await page.locator('#gunit').selectOption('h'); + await page.locator('#gsave').click(); + await expect(page.locator('#modal.on')).toBeHidden(); + + // It must survive a reload, i.e. actually reach the config. + await page.locator('#prefs').click(); + await expect(page.locator('#gnum')).toHaveValue('4'); + await expect(page.locator('#gunit')).toHaveValue('h'); +}); + +test('episodes show with their metadata, and notes expand', async ({ page }) => { + await page.getByText('Test Show').click(); + await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText('First Episode')).toBeVisible(); + // Newest first, so target the episode by name rather than by position. + const first = page.locator('.ep', { hasText: 'First Episode' }); + await expect(first).toContainText('S1E1'); + await expect(first).toContainText('30:30'); // itunes:duration 1830 + await expect(page.locator('.ep', { hasText: 'Second Episode' })).toContainText('15:00'); + + await first.locator('.t').click(); + await expect(page.locator('.notes').first()).toContainText('Show notes for the first one'); +}); + +test('the filter tabs change what is listed', async ({ page }) => { + await page.getByText('Test Show').click(); + await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 }); + const unread = await page.locator('.ep').count(); + + await page.locator('.tabs button', { hasText: 'All' }).first().click(); + await expect(page.locator('#count')).toContainText('episode'); + expect(await page.locator('.ep').count()).toBeGreaterThanOrEqual(unread); + + await page.locator('.tabs button', { hasText: 'Flagged' }).first().click(); + await expect(page.locator('#count')).toContainText('0 episodes'); +}); + +test('a feed URL is editable and has a copy button', async ({ page }) => { + await page.getByText('Test Show').click(); + await page.locator('.btn', { hasText: 'Settings' }).first().click(); + await expect(page.locator('#surl')).toHaveValue(/show\.xml/); + await expect(page.locator('#scopy')).toBeVisible(); + + // navigator.clipboard is absent over plain http, so the button must not throw. + const errors = []; + page.on('pageerror', e => errors.push(e.message)); + await page.locator('#scopy').click(); + await expect(page.locator('#scopy')).toHaveText(/Copied|Failed/); + expect(errors).toEqual([]); +}); + +test('the log view has tabs and shows daemon traffic', async ({ page }) => { + await page.locator('#logs').click(); + await expect(page.locator('#logbox')).toBeVisible(); + await expect(page.locator('#logtabs button')).toHaveCount(4); + + // Generate traffic, then check the Daemon I/O tab shows both directions. + await page.locator('#logtabs button', { hasText: 'Daemon I/O' }).click(); + await page.evaluate(() => + fetch('/api/fetch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ force: true }), + })); + await expect(page.locator('#logbox .l').first()).toBeVisible({ timeout: 15_000 }); + await expect(page.locator('#logbox')).toContainText('"cmd":"fetch"', { timeout: 15_000 }); + await expect(page.locator('#logbox')).toContainText('"ev":', { timeout: 15_000 }); +}); + +test('an OPML subscription is a collapsible folder', async ({ page }) => { + // Read the subscription so its feeds exist. + await page.evaluate(() => + fetch('/api/fetch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ feed: 'test-subscriptions', force: true }), + })); + + const chev = page.locator('.feed .chev'); + await expect(chev).toBeVisible({ timeout: 20_000 }); + + // Closed by default: the child is not listed until the folder is opened. + const before = await page.locator('.feed').count(); + await chev.click(); + await expect(page.locator('.feed')).toHaveCount(before + 1); + // Scoped to the sidebar: the name also appears as the page heading once selected. + await expect(page.locator('#feedlist').getByText('Grouped Show')).toBeVisible(); + + // The subscription's own page lists what is inside it. + await page.locator('.feed', { hasText: 'Test Subscriptions' }).first().click(); + await expect(page.locator('.childrow')).toHaveCount(1); +}); diff --git a/tests/ui/fixtures/ep1.mp3 b/tests/ui/fixtures/ep1.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..239bc1bf9cbd76294edc71fa5c2092db59c8a54a GIT binary patch literal 40000 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 literal 0 HcmV?d00001 diff --git a/tests/ui/fixtures/other.xml b/tests/ui/fixtures/other.xml new file mode 100644 index 0000000..13977bb --- /dev/null +++ b/tests/ui/fixtures/other.xml @@ -0,0 +1,5 @@ + +Grouped Showhttp://127.0.0.1:8792/ +Inside the OPML. +Grouped Epg-1x + \ No newline at end of file diff --git a/tests/ui/fixtures/serve.js b/tests/ui/fixtures/serve.js new file mode 100644 index 0000000..a435b2a --- /dev/null +++ b/tests/ui/fixtures/serve.js @@ -0,0 +1,19 @@ +// Serves the fixture feeds so the daemon under test has something real to scan. +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +const dir = __dirname; +const port = Number(process.env.FIXTURE_PORT || 8792); + +http.createServer((req, res) => { + const name = decodeURIComponent(req.url.split('?')[0].replace(/^\//, '')) || 'index'; + const file = path.join(dir, path.basename(name)); + fs.readFile(file, (err, body) => { + if (err) { res.writeHead(404).end('no'); return; } + const type = file.endsWith('.mp3') ? 'audio/mpeg' + : file.endsWith('.opml') ? 'text/x-opml' : 'application/xml'; + res.writeHead(200, { 'content-type': type, 'content-length': body.length }); + res.end(body); + }); +}).listen(port, '127.0.0.1', () => console.log(`fixtures on ${port}`)); diff --git a/tests/ui/fixtures/show.xml b/tests/ui/fixtures/show.xml new file mode 100644 index 0000000..fc28031 --- /dev/null +++ b/tests/ui/fixtures/show.xml @@ -0,0 +1,15 @@ + + +Test Showhttp://127.0.0.1:8792/A fixture feed. + +First Episodeui-1 + Mon, 01 Sep 2026 10:00:00 +0000 + <p>Show notes for the first one.</p> + 183011 + +Second Episodeui-2 + Mon, 08 Sep 2026 10:00:00 +0000 + Notes for the second. + 900 + + \ No newline at end of file diff --git a/tests/ui/fixtures/subs.opml b/tests/ui/fixtures/subs.opml new file mode 100644 index 0000000..298a518 --- /dev/null +++ b/tests/ui/fixtures/subs.opml @@ -0,0 +1,4 @@ +Test Subscriptions + + + \ No newline at end of file diff --git a/tests/ui/global-setup.js b/tests/ui/global-setup.js new file mode 100644 index 0000000..fce51bc --- /dev/null +++ b/tests/ui/global-setup.js @@ -0,0 +1,43 @@ +// Builds a scratch config and data dir so the browser tests drive a real daemon with +// known feeds, rather than whatever happens to be on the machine. +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const root = path.join(os.tmpdir(), 'ipx-ui-test'); +const TOKEN = 'testtokentesttokentesttoken12345'; // fixed, so tests need not scrape a log + +// Called from playwright.config.js at load time, NOT as globalSetup: Playwright starts +// webServer *before* globalSetup, so a config written there does not exist yet when the +// daemon launches -- it would fall back to the real config and fight the live daemon. +function prepare() { + fs.rmSync(root, { recursive: true, force: true }); + for (const d of ['config', 'data', 'downloads']) { + fs.mkdirSync(path.join(root, d), { recursive: true }); + } + fs.writeFileSync(path.join(root, 'config', 'config.toml'), ` +[general] +download_dir = "${path.join(root, 'downloads')}" +socket = "${path.join(root, 'ipx.sock')}" +schedule = "every 60m" +max_new_per_check = 1 + +[torrent] +enabled = false + +[web] +enabled = true +bind = "127.0.0.1:8791" +token = "${TOKEN}" + +[feeds.test-show] +url = "http://127.0.0.1:8792/show.xml" +auto_download = true + +[feeds.test-subscriptions] +url = "http://127.0.0.1:8792/subs.opml" +auto_download = false +`); +} + +module.exports = { prepare, root, TOKEN }; diff --git a/web/index.html b/web/index.html index b0586b7..0463908 100644 --- a/web/index.html +++ b/web/index.html @@ -432,14 +432,15 @@ function renderFeeds(){ for(const f of shown){ if(f.group && byId[f.group]) continue; // drawn under its parent instead order.push([f,0]); - // A subscription can hold dozens of feeds, so the folder starts closed. - if(!collapsed.has(f.id) || q) for(const c of shown) if(c.group===f.id) order.push([c,1]); + // A subscription can hold dozens of feeds, so a folder starts closed. Searching + // opens them all, or matches inside a closed folder would be invisible. + if(expanded.has(f.id) || q) for(const c of shown) if(c.group===f.id) order.push([c,1]); } for(const [f,depth] of order){ const kids=shown.filter(c=>c.group===f.id).length; const el=document.createElement('div'); el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':''); - const open = kids && (!collapsed.has(f.id) || q); + const open = kids && (expanded.has(f.id) || q); el.innerHTML = (kids?``:'')+ artHTML(f.image,f.title||f.id)+ @@ -770,13 +771,26 @@ function closeModal(){ } /* ---------------- log view ---------------- */ -let logTimer=null, logSeq=0, logLines=[], logFilter='', logLevel=''; +let logTimer=null, logSeq=0, logLines=[], logFilter='', logLevel='', logTab='all'; +// Which sources belong to each tab. "daemon" is the control protocol itself: every +// command in and every event out, whatever sent it. +const LOG_TABS={ + all: null, + daemon: t=>t==='ipx::io', + scan: t=>t==='ipx::scan', + web: t=>t==='ipx::http', +}; const LEVELS={ERROR:3,WARN:2,INFO:1,DEBUG:0,TRACE:0}; function logsModal(){ logSeq=0; logLines=[]; openModal(`

Log

+
+ ${Object.keys(LOG_TABS).map(t=> + ``).join('')} +