diff --git a/.claude/skills/ipx-prod-check/SKILL.md b/.claude/skills/ipx-prod-check/SKILL.md index 0b35c44..e2e3f1d 100644 --- a/.claude/skills/ipx-prod-check/SKILL.md +++ b/.claude/skills/ipx-prod-check/SKILL.md @@ -70,7 +70,7 @@ Count those from the metrics, not the log. |---|---|---| | `ipx::http` | `method`, `path`, `route`, `status`, `duration_ms` | a warning for each request answered 4xx or 5xx; `route` is the pattern, empty for an unrouted path. Every request, as info, until #364; now those that went well are only in `ipx_http_request_duration_seconds` | | `ipx::scan` | `ev` and the event's own: `feed`, `new`, `downloaded`, `failed`, `bytes`, `msg`, `url`, `feeds`, `reason`, `from`, `to`; on a failure `error.type` and, from an HTTP error, `http.response.status_code` | the daemon's events, one line each, in words; warnings are feed and download failures | -| `ipx::io` | `ev`, `feeds`, `pending`, `downloaded` on the `status` reply | commands arriving on the socket (`-> {...}`) and the answer to `ipx status` | +| `ipx::io` | `ev`, `feeds`, `pending`, `downloaded` on the `status` reply | commands arriving on the socket (`-> {...}`) and the answer to `ipxd status` | | `ipx::daemon`, `ipx::cli`, `ipx::catalogue`, `ipx::scan`, ... | message, sometimes fields | start-up, shutdown, account and config messages, under the module that logs them; plain `ipx` before 2026-10-24, when main.rs was split (#356) | | `ipx::client` | `ev`, `user`, `feed`, `enclosure`, `deadair`; `msg`, `src`, `line` on an error; `ms` on a timing | what the page reports from the browser (#190): `page_error`, `rejection`, `player_error` and `scroll_stuck` (a drag that should have scrolled the reader and did not, its snapshot in `state`, #384) as warnings; `swipe` (a swipe in the reader: `msg` next, prev, list, end or short, `state` its position, `5/50 of 312, more to load`, #384), `play_start` (ms from play to sound), `stall` (ms waiting mid-play) and `list_shown` (ms from a click to its list drawn, with `feed` the view and `filter`, #246) as info. Lines carry the trace of the action in the page: its API requests send `traceparent`, one trace id per tap or key | @@ -78,7 +78,7 @@ Events (`ev`): `feed_start` (debug), `feed_done` (new, downloaded, failed, torre (not due, debug), `feed_error` (msg), `feed_moved` (from, to: a permanent redirect followed, the address updated), `download_done` (bytes), `download_error` (msg, url), `torrent_deferred`, `reaped`, `reap_done`, `scan_done` (feeds; debug), `status` (feeds, pending, downloaded: the answer to -`ipx status`, only when someone runs it since #218), `deadair_done` (feed, enclosure, `saved_s` at the +`ipxd status`, only when someone runs it since #218), `deadair_done` (feed, enclosure, `saved_s` at the default pause), `error` (msg). DeadAir also logs `DeadAir found the silences` (`cuts`, `saved_s`, `took_ms`) under `ipx::deadair`, and warns `DeadAir could not read a file` when ffmpeg fails on one, which it then passes over until the next restart. @@ -108,7 +108,7 @@ from 2026-10-11 15:15 UTC. Gauges, but for the counters (`_total`) and the one h | `ipx_process_resident_bytes`, `ipx_process_open_fds` | memory and open files and sockets; mimalloc gives back what is freed, so memory follows use and a rise that never comes down is a leak (#224) | | `ipx_feed_fetches_total`, `ipx_feed_bytes_total` | counters: feeds fetched (304s included) and the bytes of their bodies, read whole, so what scans allocate | | `ipx_uptime_seconds`, `ipx_build_info{version}` | since the daemon started; what runs | -| `ipx_worker_busy`, `ipx_worker_job_seconds{kind}` | the worker's job now (`scan`, `reap`, `download`, `status`, `reload`) and for how long; `ipx status` still answers while it is stuck | +| `ipx_worker_busy`, `ipx_worker_job_seconds{kind}` | the worker's job now (`scan`, `reap`, `download`, `status`, `reload`) and for how long; `ipxd status` still answers while it is stuck | | `ipx_jobs_total{kind}`, `ipx_job_seconds_total{kind}` | counters: jobs finished, and their seconds all told | | `ipx_last_scan_age_seconds` | since a scan last finished; absent until one has since starting | | `ipx_http_requests_in_flight`, `ipx_scan_feeds_fetching`, `ipx_downloads_active` | work in progress | @@ -260,7 +260,7 @@ a count says something happened, the lines and traces say why. reset of the uptime is a restart: match it with a deploy, or it is a crash (check 4). The worker: one job over 30 minutes (`ipx_worker_job_seconds`), or a scan last finished over - 15 minutes ago, is the worker stuck, which `ipx status` does not show; its trace and the + 15 minutes ago, is the worker stuck, which `ipxd status` does not show; its trace and the log lines of that time say on what. A kind whose share of `ipx_job_seconds_total` jumps is where the worker's time went (reap was most of it until #193). Web requests in flight near the pool's max mean the pool is what they wait on, and `ipx_db_wait_seconds` for how long. Live updates missed mean a page showed stale diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 4ed6e38..85edaff 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -10,7 +10,9 @@ if ! cargo fmt --all --check >/dev/null 2>&1; then exit 1 fi if echo "$staged" | grep -q -e '^Cargo\.toml$' -e '^Cargo\.lock$' -e '^crates/feed/'; then - pkgs="ipx-feed ipx-directory ipx" + pkgs="ipx-feed ipx-directory ipx ipx-tui" +elif echo "$staged" | grep -q '^crates/tui/'; then + pkgs="ipx-tui" elif echo "$staged" | grep -q '^crates/directory/'; then pkgs="ipx-directory ipx" elif echo "$staged" | grep -q '^crates/ipx/'; then diff --git a/CLAUDE.md b/CLAUDE.md index 696751f..2c4698b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,7 +112,7 @@ next build makes it again with the new config. The containers have no Docker healthcheck (#218): Docker runs one with `exec`, which on Tower fails whenever root is logged out, because elogind removes `/run/user/0`, where containerd puts each exec's state. Whether ipx is well is Prometheus's question (`up`, the worker's job and time) -and `ipx status` against the control socket, which says the daemon answers and can read its +and `ipxd status` against the control socket, which says the daemon answers and can read its database. The socket answers `status` itself instead of queuing it behind the worker's current job, so a worker stuck on one job still answers it. The containers restart on their own after a reboot. @@ -239,7 +239,8 @@ session was stopped. So: | `crates/ipx/` | `cargo test -p ipx`; the browser tests too if the API changed | | `crates/directory/` | `cargo test -p ipx-directory`, then `-p ipx` | | `crates/feed/` | `cargo test -p ipx-feed`, then `-p ipx-directory`, then `-p ipx` | - | `Cargo.toml`, `Cargo.lock` | all three, one at a time | + | `crates/tui/` | `cargo test -p ipx-tui`; it uses only the API, none of the other crates | + | `Cargo.toml`, `Cargo.lock` | all four, one at a time | The pre-commit hook's clippy follows the same table. The whole browser suite once, at the end of a change to the page or the API; never `cargo test` for the workspace in one command. @@ -287,7 +288,7 @@ seeking through files, a flood of wrong passwords. Things about them: 127.0.0.1, as production trusts Cloudflare Access's; only the sign-in test uses a password. * Each threshold is a budget well above what the request takes now: it is there to catch a query that has started asking once per feed, or writers queueing on a lock, not a slow minute. -* `ipx status` runs every second throughout, and a run fails if one takes 5s. +* `ipxd status` runs every second throughout, and a run fails if one takes 5s. * One at a time: `node tests/load/run.ts signin`. All four take about six minutes. * The daemon is on **SQLite** unless `--postgres`, which puts it in `IPX_TEST_DATABASE_URL`, in a schema of its own (`ipx_load`) dropped and made again each run. That URL, in `/src/.envrc`, is @@ -377,13 +378,13 @@ ms in ipx, the difference being new connections, 80-330 ms each to open (#241). go blank without an error; regenerate the dashboard to match. Routine lines (a request that went well, a scan's end) are debug: the admin page's Log view has them, Loki does not. * `/api/settings` answering `200` does **not** mean the daemon is well — the web server is a - different task. `ipx status` checks the control socket and the database; to see the worker + different task. `ipxd status` checks the control socket and the database; to see the worker getting through its jobs, watch for `scan complete` in the log. * **The database goes through SeaORM, and the entities in `crates/ipx/src/entity.rs` are the schema.** `Db::open` creates any missing table or index from them (`create_missing`), on every `ipx` - command, the healthcheck's `ipx status` included, so it must never write when nothing is + command, the healthcheck's `ipxd status` included, so it must never write when nothing is missing: SeaORM's experimental schema sync dropped and remade an index on every open, the - write lock that took made `ipx status` time out behind a busy daemon, and it was removed for + write lock that took made `ipxd status` time out behind a busy daemon, and it was removed for it. A new column on an existing table needs its own `ALTER`; nothing adds one for you. Look for the column first and alter only when it is missing, as `feeds.description` does. * **SQL written by hand in `db.rs` has to run on SQLite and Postgres both** (issue #18): `$1` diff --git a/Cargo.lock b/Cargo.lock index 27710da..1bf37f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -115,6 +115,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "arc-swap" version = "1.9.2" @@ -507,6 +516,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + [[package]] name = "byteorder" version = "1.5.0" @@ -519,6 +534,15 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.4.5" @@ -650,6 +674,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "compression-codecs" version = "0.4.41" @@ -679,6 +717,15 @@ dependencies = [ "typewit", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -768,6 +815,33 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.1", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -994,6 +1068,7 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ + "convert_case", "proc-macro2", "quote", "rustc_version", @@ -1063,6 +1138,15 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dontfrag" version = "1.0.1" @@ -1507,6 +1591,11 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "hashlink" @@ -1843,6 +1932,28 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20" +dependencies = [ + "darling 0.24.1", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "intervaltree" version = "0.2.7" @@ -1945,6 +2056,19 @@ dependencies = [ "url", ] +[[package]] +name = "ipx-tui" +version = "0.1.0-dev" +dependencies = [ + "anyhow", + "chrono", + "clap", + "ratatui", + "reqwest", + "serde", + "serde_json", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -2120,6 +2244,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "kasuari" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f14c90e40a6133f4805b83542a7ed7364f56070b447f56e7f4e90297122209" +dependencies = [ + "hashbrown 0.17.1", + "portable-atomic", + "thiserror 2.0.20", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -2483,12 +2618,33 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "line-clipping" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +[[package]] +name = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + [[package]] name = "lock_api" version = "0.4.14" @@ -2504,6 +2660,15 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lru" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -2639,6 +2804,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -2806,6 +2972,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_threads" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4" +dependencies = [ + "libc", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2921,6 +3096,39 @@ dependencies = [ "num-traits", ] +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + [[package]] name = "parking" version = "2.2.1" @@ -3364,6 +3572,72 @@ dependencies = [ "rustversion", ] +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.1", + "compact_str", + "hashbrown 0.17.1", + "itertools 0.14.0", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror 2.0.20", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.1", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools 0.14.0", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "raw-cpuid" version = "11.6.0" @@ -3563,6 +3837,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.46" @@ -4020,6 +4307,27 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -4298,6 +4606,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "string_cache" version = "0.11.0" @@ -4344,6 +4658,21 @@ name = "strum" version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "subtle" @@ -4496,7 +4825,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -4887,6 +5218,29 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools 0.14.0", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557" + [[package]] name = "unicode-xid" version = "0.2.6" diff --git a/Cargo.toml b/Cargo.toml index f6b60d8..80fc8c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/ipx", "crates/feed", "crates/directory"] +members = ["crates/ipx", "crates/feed", "crates/directory", "crates/tui"] resolver = "3" # Each service has its own version, in its own Cargo.toml (ipx, ipx-directory) or package.json diff --git a/Dockerfile b/Dockerfile index 690dd64..931f285 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,17 +13,19 @@ COPY Cargo.toml Cargo.lock ./ COPY crates/ipx/Cargo.toml crates/ipx/ COPY crates/feed/Cargo.toml crates/feed/ COPY crates/directory/Cargo.toml crates/directory/ -RUN mkdir -p crates/ipx/src crates/feed/src crates/directory/src \ +COPY crates/tui/Cargo.toml crates/tui/ +RUN mkdir -p crates/ipx/src crates/feed/src crates/directory/src crates/tui/src \ && echo 'fn main(){}' > crates/ipx/src/main.rs && touch crates/feed/src/lib.rs \ && echo 'fn main(){}' > crates/directory/src/main.rs && touch crates/directory/src/lib.rs \ + && echo 'fn main(){}' > crates/tui/src/main.rs \ && cargo build --release --locked -p ipx \ - && rm -rf crates/ipx/src crates/feed/src crates/directory/src + && rm -rf crates/ipx/src crates/feed/src crates/directory/src crates/tui/src COPY crates ./crates # ipx builds the directory crate in, whose public pages serve these (#215). COPY web/logo.svg web/logo-dark.svg web/favicon.png ./web/ # cargo skips a rebuild if mtimes look untouched; make sure it does not. -RUN touch crates/ipx/src/main.rs crates/feed/src/lib.rs && cargo build --release --locked -p ipx +RUN touch crates/ipx/src/main.rs crates/ipx/src/lib.rs crates/feed/src/lib.rs && cargo build --release --locked -p ipx FROM debian:trixie-slim # ffmpeg is DeadAir's (#171): it finds the silences and copies the frames kept. Debian's adds @@ -31,7 +33,7 @@ FROM debian:trixie-slim RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates gosu ffmpeg \ && rm -rf /var/lib/apt/lists/* -COPY --from=build /src/target/release/ipx /usr/local/bin/ipx +COPY --from=build /src/target/release/ipxd /usr/local/bin/ipxd COPY docker-entrypoint.sh /usr/local/bin/ RUN chmod +x /usr/local/bin/docker-entrypoint.sh @@ -46,4 +48,4 @@ VOLUME ["/config", "/data", "/downloads"] EXPOSE 8099/tcp 6881/tcp 6881/udp ENTRYPOINT ["docker-entrypoint.sh"] -CMD ["ipx", "daemon"] +CMD ["ipxd", "daemon"] diff --git a/README.md b/README.md index 1787d42..85d3cec 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ From source: ```sh cargo build --release -./target/release/ipx daemon +./target/release/ipxd daemon ``` The first start creates **admin** with a random password, written to `admin-password` beside @@ -52,7 +52,7 @@ config.toml (readable by ipx's user alone; the log says where, never what it is) `IPX_ADMIN_PASSWORD` to choose it instead. Sign in at `/login`, then change it: ```sh -echo -n 'a good password' | ipx user passwd admin +echo -n 'a good password' | ipxd user passwd admin ``` The UI is plain HTTP, so put TLS in front of it if it is reachable from outside your network. @@ -62,7 +62,7 @@ The UI is plain HTTP, so put TLS in front of it if it is reachable from outside | | | |---|---| | [docs/configuration.md](docs/configuration.md) | Every config key, path and environment variable | -| [docs/cli.md](docs/cli.md) | Every command, including `ipx user` | +| [docs/cli.md](docs/cli.md) | Every command, including `ipxd user` | | [docs/users.md](docs/users.md) | Accounts, and what several people share | | [docs/sso.md](docs/sso.md) | Signing in through Cloudflare Zero Trust or Authentik | | [docs/architecture.md](docs/architecture.md) | How it works: modules, schema, control socket, HTTP API | diff --git a/crates/ipx/CHANGELOG.md b/crates/ipx/CHANGELOG.md index 3706065..f92c120 100644 --- a/crates/ipx/CHANGELOG.md +++ b/crates/ipx/CHANGELOG.md @@ -23,6 +23,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- The server's command is `ipxd`, not `ipx`: `ipxd daemon`, `ipxd status`, `ipxd user add` and the rest, in the container as `docker exec iPX ipxd ...`. `ipx` is now the reader in a terminal (#437). + - The log no longer has a line for every request that went well, over half its size: `/metrics` counts each request and its time by route and status as `ipx_http_request_duration_seconds`, a request that failed keeps its warning, and the admin page's log view still shows them all (#364). - The log no longer has a line for every scan that finishes, a fifth of its lines; the metrics count scans, and the page's log view still shows them (#363). diff --git a/crates/ipx/Cargo.toml b/crates/ipx/Cargo.toml index dfb163d..259be04 100644 --- a/crates/ipx/Cargo.toml +++ b/crates/ipx/Cargo.toml @@ -4,6 +4,12 @@ version = "1.5.2-dev" edition.workspace = true publish = false +# ipxd: `ipx` is the reader in a terminal (crates/tui), which more people type (#437). The +# code is the library, still `ipx`, so its log targets keep their names (src/lib.rs). +[[bin]] +name = "ipxd" +path = "src/main.rs" + [lints] workspace = true diff --git a/crates/ipx/src/catalogue.rs b/crates/ipx/src/catalogue.rs index ffcf2c4..43bdfe0 100644 --- a/crates/ipx/src/catalogue.rs +++ b/crates/ipx/src/catalogue.rs @@ -44,7 +44,7 @@ pub(crate) async fn add( } /// Adds the feed at `url` to the catalogue unless it is there already, a feed an OPML lists -/// included, so one show is never added twice: its id, and whether it was there. For `ipx add` +/// included, so one show is never added twice: its id, and whether it was there. For `ipxd add` /// and the page's Add feed alike, which were the same steps written twice (#161); each finds the /// feed first, to say its own way why an address is not one. pub async fn add_to_catalogue( @@ -151,7 +151,7 @@ pub(crate) async fn import(ctx: &Ctx, file: &Path) -> Result<()> { .await? .into_iter() .find(|u| u.is_admin) - .ok_or_else(|| anyhow::anyhow!("no admin account to subscribe: ipx user add --admin"))?; + .ok_or_else(|| anyhow::anyhow!("no admin account to subscribe: ipxd user add --admin"))?; let doc = feed::read_opml(&text).map_err(|e| anyhow::anyhow!("{} is not OPML: {e}", file.display()))?; let (added, had) = lists::subscribe_opml(ctx, lists::opml_feeds(&doc)?, admin.id).await?; println!("subscribed {} to {added} feed(s); {had} already there", admin.name); @@ -168,7 +168,7 @@ pub(crate) async fn assemble_config(db: &db::Db, mut cfg: config::Config, path: if config::Config::file_holds_stored(path) { tracing::warn!( "config.toml still lists feeds or server settings; they are ignored, since the \ - database holds them now. Change them in the web UI, or with ipx add and rm." + database holds them now. Change them in the web UI, or with ipxd add and rm." ); } stored.apply(&mut cfg); @@ -213,13 +213,13 @@ pub(crate) async fn export(ctx: &Ctx, file: &std::path::Path) -> Result<()> { pub(crate) async fn list(ctx: &Ctx) -> Result<()> { let cfg = ctx.cfg(); if cfg.feeds.is_empty() { - println!("No feeds configured. Add one with `ipx add `, or in the web UI."); + println!("No feeds configured. Add one with `ipxd add `, or in the web UI."); return Ok(()); } for (id, feed) in &cfg.feeds { let s = ctx.db.feed_summary(id).await?; // The id on a line of its own, labelled: first on the title's line, antirez.com's id - // "feed" read as a heading and 'ipx fetch antirez' was tried instead (issue #82). + // "feed" read as a heading and 'ipxd fetch antirez' was tried instead (issue #82). println!("{}", s.title.as_deref().filter(|t| !t.is_empty()).unwrap_or(id)); println!(" id {id}"); println!(" url {}", feed.url); diff --git a/crates/ipx/src/cli.rs b/crates/ipx/src/cli.rs index 21e8642..b3da0b2 100644 --- a/crates/ipx/src/cli.rs +++ b/crates/ipx/src/cli.rs @@ -10,7 +10,7 @@ use std::path::PathBuf; use std::sync::Arc; #[derive(Parser)] -#[command(name = "ipx", version, about = "A headless podcatcher")] +#[command(name = "ipxd", version, about = "A headless podcatcher")] pub(crate) struct Cli { /// Config file (default: $XDG_CONFIG_HOME/ipx/config.toml) #[arg(long, global = true)] @@ -81,7 +81,7 @@ pub(crate) enum Command { #[derive(Subcommand)] pub(crate) enum UserCmd { - /// Create an account. The password is read from stdin: `echo -n hunter2 | ipx user add ray` + /// Create an account. The password is read from stdin: `echo -n hunter2 | ipxd user add ray` Add { name: String, /// May manage other accounts, and is who the shared web token signs in as @@ -110,7 +110,7 @@ pub(crate) async fn user_cmd(ctx: &Arc, cmd: UserCmd, mut stdin: impl std:: stdin.read_to_string(&mut buf)?; let pw = buf.trim_end_matches(['\n', '\r']).to_string(); if pw.is_empty() { - anyhow::bail!("no password on stdin: try `echo -n secret | ipx user ...`"); + anyhow::bail!("no password on stdin: try `echo -n secret | ipxd user ...`"); } Ok(pw) }; @@ -138,7 +138,7 @@ pub(crate) async fn user_cmd(ctx: &Arc, cmd: UserCmd, mut stdin: impl std:: UserCmd::List => { let users = ctx.db.users().await?; if users.is_empty() { - println!("no accounts yet: ipx user add "); + println!("no accounts yet: ipxd user add "); } for u in users { let added = u diff --git a/crates/ipx/src/config.rs b/crates/ipx/src/config.rs index 33dcea8..470fe64 100644 --- a/crates/ipx/src/config.rs +++ b/crates/ipx/src/config.rs @@ -219,7 +219,7 @@ pub struct Feed { /// Preferred over `password`: name of an env var holding the password. #[serde(default, skip_serializing_if = "Option::is_none")] pub password_env: Option, - /// Put in the Directory by an admin (`ipx add --list`): listed with no subscribers, and kept + /// Put in the Directory by an admin (`ipxd add --list`): listed with no subscribers, and kept /// in the catalogue when the last one leaves, where anyone else's feed is dropped. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub listed: bool, diff --git a/crates/ipx/src/ctx.rs b/crates/ipx/src/ctx.rs index 8e66016..8fee01e 100644 --- a/crates/ipx/src/ctx.rs +++ b/crates/ipx/src/ctx.rs @@ -48,7 +48,7 @@ pub struct Ctx { /// Held from taking a copy of the config to storing it (`edit_cfg`). Each change stores the /// whole catalogue from its copy, and two at once stored over each other: a feed added while /// another add spent a minute fetching was gone once that one stored (#153). - // ponytail: this process only. `ipx add`, `rm` and `import` write from a process of their + // ponytail: this process only. `ipxd add`, `rm` and `import` write from a process of their // own, and a change in flight here can still store over theirs; take the copy from the // database rather than memory if that ever happens. pub cfg_write: tokio::sync::Mutex<()>, diff --git a/crates/ipx/src/daemon.rs b/crates/ipx/src/daemon.rs index f46486b..f31a7e7 100644 --- a/crates/ipx/src/daemon.rs +++ b/crates/ipx/src/daemon.rs @@ -24,7 +24,7 @@ pub(crate) async fn reload(ctx: &Ctx) -> Result<()> { Ok(()) } -/// The counts `ipx status` prints, and /api/status serves. A running daemon's socket answers with +/// The counts `ipxd status` prints, and /api/status serves. A running daemon's socket answers with /// this directly rather than through the job queue. pub(crate) async fn status(ctx: &Ctx) -> Event { match tracing::Instrument::instrument(ctx.db.counts(), db::step("counts")).await { @@ -66,7 +66,7 @@ pub(crate) async fn daemon( let (password, told) = auth::first_password(dir, std::env::var("IPX_ADMIN_PASSWORD").ok())?; ctx.db.create_user("admin", Some(&crate::auth::hash_password(&password)?), /* admin */ true).await?; tracing::warn!( - "no accounts yet: created 'admin' {told}. Change it with `echo -n | ipx user passwd admin`" + "no accounts yet: created 'admin' {told}. Change it with `echo -n | ipxd user passwd admin`" ); } @@ -342,7 +342,7 @@ pub(crate) async fn shutdown() { /// How long the daemon may sleep before a feed is due: until the earliest one, at least 30 s and /// at most 10 minutes. It ticked every minute and ran a scan pass each time, 80% of them finding /// nothing due (#114). The floor keeps a feed that never gets a check time from spinning it; the -/// ceiling picks up within ten minutes what no command announces, such as `ipx add` or a +/// ceiling picks up within ten minutes what no command announces, such as `ipxd add` or a /// shorter schedule. A command, a refresh or a feed added on the page, wakes it at once anyway. pub(crate) async fn until_next_scan(ctx: &Ctx) -> std::time::Duration { let next = async { @@ -398,7 +398,7 @@ mod tests { let held = with("kept-in-memory"); let ctx = test_ctx(held.clone()).await; ctx.db.store_config(&held).await.unwrap(); - // `ipx add` and `ipx rm`, from a process of their own (#120). + // `ipxd add` and `ipxd rm`, from a process of their own (#120). ctx.db.store_config(&with("added-by-the-cli")).await.unwrap(); reload(&ctx).await.unwrap(); let urls: Vec = ctx.cfg().feeds.values().map(|f| f.url.clone()).collect(); diff --git a/crates/ipx/src/db.rs b/crates/ipx/src/db.rs index 9cf88ed..e5ba5a9 100644 --- a/crates/ipx/src/db.rs +++ b/crates/ipx/src/db.rs @@ -43,7 +43,7 @@ impl Drop for Db { /// /// Not SeaORM's schema sync, which is experimental and, despite its docs, drops a unique index /// the entities do not describe: it dropped users_name_lower on every open, so every `ipx` -/// command took a write lock, and the healthcheck's `ipx status` timed out behind a busy daemon. +/// command took a write lock, and the healthcheck's `ipxd status` timed out behind a busy daemon. /// On Postgres it would have failed outright, dropping that index as a constraint. /// /// ponytail: tables only, no columns. A column added to an existing table needs its own ALTER @@ -98,7 +98,7 @@ async fn create_missing(orm: &DatabaseConnection) -> Result<()> { } // A column added to a table that already exists, which CREATE TABLE IF NOT EXISTS leaves // as it was. Looked for first, and added only when it is missing: this runs on every open, - // the healthcheck's `ipx status` included, and an ALTER takes a lock that waits behind a + // the healthcheck's `ipxd status` included, and an ALTER takes a lock that waits behind a // busy daemon, which is what made the healthcheck time out once before. if orm.execute_unprepared("SELECT description FROM feeds LIMIT 0").await.is_err() { // A feed is read whole only when it has changed since the last read, so each one's @@ -272,7 +272,7 @@ async fn connect(location: &str, keep_open: bool) -> Result // The daemon keeps every connection open (#241): a Postgres connection takes 80-330 ms to // open, its password exchange most of it, and a page load asked for more than were idle, so // the feed list and item list waited on new ones, 300-900 ms where their queries took 5-30. - // Not a command such as `ipx status`, nor a test, which would hold twenty for one question. + // Not a command such as `ipxd status`, nor a test, which would hold twenty for one question. if keep_open && is_postgres(location) { opts.min_connections(20); } @@ -408,7 +408,7 @@ pub struct Managed { pub group_id: String, } -/// What `ipx list` shows next to each configured feed. +/// What `ipxd list` shows next to each configured feed. #[derive(Debug, Default)] pub struct FeedSummary { pub title: Option, @@ -438,7 +438,7 @@ impl Db { } let orm = connect(location, keep_open).await?; if !is_postgres(location) { - // WAL, so the healthcheck's `ipx status` reads while the daemon writes. It is a + // WAL, so the healthcheck's `ipxd status` reads while the daemon writes. It is a // setting of the file, kept once made, and making it takes a lock that cannot wait out // a busy daemon, so it is made only when the file is not already in WAL. let mode = diff --git a/crates/ipx/src/ipc.rs b/crates/ipx/src/ipc.rs index 9140221..4e664ac 100644 --- a/crates/ipx/src/ipc.rs +++ b/crates/ipx/src/ipc.rs @@ -212,7 +212,7 @@ pub enum Command { enclosure: i64, }, Status, - /// Read the catalogue and server settings again from the database, which `ipx add`, `rm` + /// Read the catalogue and server settings again from the database, which `ipxd add`, `rm` /// and `import` change in a process of their own. A daemon keeps them in memory, and with /// its old copy wrote a removed feed back, and dropped an added one, at its next change of /// its own (#120). Answered, as status is, with the counts after it. @@ -329,7 +329,7 @@ fn log_event(e: &Event, wire: bool) { ) }; } - // `ipx status`'s answer: a reply on the socket rather than work done, so it + // `ipxd status`'s answer: a reply on the socket rather than work done, so it // stays with the rest of the conversation, and the Scans tab stays about scans. if matches!(e, Event::Status { .. }) { return line!(info, "ipx::io", format!("<- {json}")); @@ -356,7 +356,7 @@ pub async fn daemon_is_live(path: &Path) -> bool { /// time, and a healthcheck left waiting behind a scan or a long download timed out and called a /// busy daemon dead; a reload left waiting would let the web UI write the old catalogue back in /// the meantime. The answer goes to the client that asked and no one else: broadcast, it ended -/// any `ipx fetch` that was watching a scan, since `status` is a terminal event. +/// any `ipxd fetch` that was watching a scan, since `status` is a terminal event. /// A future, since reading the counts is a database query. pub type StatusFn = Arc Pin + Send>> + Send + Sync>; diff --git a/crates/ipx/src/lib.rs b/crates/ipx/src/lib.rs new file mode 100644 index 0000000..cd94ff6 --- /dev/null +++ b/crates/ipx/src/lib.rs @@ -0,0 +1,130 @@ +//! ipx, the daemon and its command line. The code is in the modules below; `main` starts it. +//! A library under the binary `ipxd` (src/main.rs) so that its modules, and so its log targets +//! (`ipx::scan`, `ipx::http`), the filters (`ipx=info`) and the dashboard's queries on them, kept +//! their names when the command was renamed (#437). + +mod access; +mod art; +mod auth; +mod catalogue; +mod cli; +mod config; +mod ctx; +mod daemon; +mod db; +mod deadair; +mod directory; +mod download; +mod entity; +// The crate root's import, so `crate::feed` reaches it from every module as before. +use ipx_feed as feed; +mod ipc; +mod lists; +mod logbuf; +mod logging; +mod metrics; +mod push; +mod retention; +mod scan; +mod signals; +#[cfg(test)] +mod test_util; +mod torrent; +mod web; + +use crate::cli::{Cli, Command, UserCmd}; +use crate::ctx::Ctx; +use crate::ipc::Command as Cmd; +use anyhow::Result; +use clap::Parser; +use std::sync::Arc; +use tokio::sync::broadcast; +use tracing::Instrument; + +#[tokio::main] +pub async fn main() -> Result<()> { + let cli = Cli::parse(); + // The daemon only: a trace for every `ipxd status` a script runs would bury the ones worth + // looking at. + let otel = match cli.command { + Command::Daemon { .. } => ipx_directory::otel::provider("ipx")?, + _ => None, + }; + logging::init(otel.as_ref()); + // For the spans the page reports of itself (#376), which ipx builds with the page's own ids. + if let Some(p) = &otel { + opentelemetry::global::set_tracer_provider(p.clone()); + } + + let config_path = cli.config.clone().unwrap_or_else(config::config_path); + let cfg = config::Config::load(&config_path)?; + // The daemon holds its connections open; a one-off command opens what it uses. + let keep_open = matches!(cli.command, Command::Daemon { .. }); + // The daemon's start-up is a trace (#374), from opening the database to serving; daemon() + // carries it on. + let start = if keep_open { tracing::info_span!("start") } else { tracing::Span::none() }; + let db = async { db::Db::open(&db::location(), keep_open).instrument(db::step("open_database")).await } + .instrument(start.clone()) + .await?; + + // A daemon owns the state; don't have two processes downloading the same thing. + let wire_cmd = match &cli.command { + Command::Fetch { feed, force } => Some(Cmd::Fetch { feed: feed.clone(), force: *force, feeds: vec![] }), + Command::Reap { dry_run } => Some(Cmd::Reap { dry_run: *dry_run }), + Command::Status => Some(Cmd::Status), + Command::List + | Command::Daemon { .. } + | Command::User { .. } + | Command::Add { .. } + | Command::Rm { .. } + | Command::Import { .. } + | Command::Export { .. } => None, + }; + if let Some(cmd) = &wire_cmd + && !cli.local + && ipc::daemon_is_live(&cfg.general.socket).await + { + return ipc::proxy(&cfg.general.socket, cmd).await; + } + + let socket = cfg.general.socket.clone(); + let cfg = + async { catalogue::assemble_config(&db, cfg, &config_path).instrument(db::step("assemble_config")).await } + .instrument(start.clone()) + .await?; + + let is_daemon = matches!(cli.command, Command::Daemon { .. }); + // These write the catalogue from a process of their own, which a running daemon, holding its + // own copy, would write over at its next change (#120). + let changes_catalogue = matches!( + cli.command, + Command::Add { .. } | Command::Rm { .. } | Command::Import { .. } | Command::User { cmd: UserCmd::Rm { .. } } + ); + let (events, _) = broadcast::channel(1024); + let ctx = Arc::new(Ctx::new(cfg, db, config_path.clone(), is_daemon, &events)?); + + let result = match cli.command { + Command::List => catalogue::list(&ctx).await, + Command::Daemon { web } => daemon::daemon(ctx, config_path, web, events, start).await, + Command::Add { url, folder, keywords, category, list } => { + catalogue::add(&ctx, &url, folder, keywords, category, list).await + } + Command::Rm { feed } => catalogue::rm(&ctx, &feed).await, + Command::User { cmd } => cli::user_cmd(&ctx, cmd, std::io::stdin()).await, + Command::Import { file } => catalogue::import(&ctx, &file).await, + Command::Export { file } => catalogue::export(&ctx, &file).await, + _ => cli::run(&ctx, wire_cmd.expect("only List and Daemon have no wire form")).await, + }; + if result.is_ok() + && changes_catalogue + && ipc::daemon_is_live(&socket).await + && let Err(e) = ipc::reload(&socket).await + { + eprintln!("the running daemon did not take the change ({e:#}); restart it, or it may undo it"); + } + // The batch exporter holds the last few seconds of spans; without this they are lost. + if let Some(p) = otel { + let _ = p.shutdown(); + } + result +} diff --git a/crates/ipx/src/main.rs b/crates/ipx/src/main.rs index bfe44a2..296c090 100644 --- a/crates/ipx/src/main.rs +++ b/crates/ipx/src/main.rs @@ -1,127 +1,5 @@ -//! ipx, the daemon and its command line. The code is in the modules below; this only starts it. +//! `ipxd`, the daemon and its command line; everything is in the library (lib.rs). -mod access; -mod art; -mod auth; -mod catalogue; -mod cli; -mod config; -mod ctx; -mod daemon; -mod db; -mod deadair; -mod directory; -mod download; -mod entity; -// The crate root's import, so `crate::feed` reaches it from every module as before. -use ipx_feed as feed; -mod ipc; -mod lists; -mod logbuf; -mod logging; -mod metrics; -mod push; -mod retention; -mod scan; -mod signals; -#[cfg(test)] -mod test_util; -mod torrent; -mod web; - -use crate::cli::{Cli, Command, UserCmd}; -use crate::ctx::Ctx; -use crate::ipc::Command as Cmd; -use anyhow::Result; -use clap::Parser; -use std::sync::Arc; -use tokio::sync::broadcast; -use tracing::Instrument; - -#[tokio::main] -async fn main() -> Result<()> { - let cli = Cli::parse(); - // The daemon only: a trace for every `ipx status` a script runs would bury the ones worth - // looking at. - let otel = match cli.command { - Command::Daemon { .. } => ipx_directory::otel::provider("ipx")?, - _ => None, - }; - logging::init(otel.as_ref()); - // For the spans the page reports of itself (#376), which ipx builds with the page's own ids. - if let Some(p) = &otel { - opentelemetry::global::set_tracer_provider(p.clone()); - } - - let config_path = cli.config.clone().unwrap_or_else(config::config_path); - let cfg = config::Config::load(&config_path)?; - // The daemon holds its connections open; a one-off command opens what it uses. - let keep_open = matches!(cli.command, Command::Daemon { .. }); - // The daemon's start-up is a trace (#374), from opening the database to serving; daemon() - // carries it on. - let start = if keep_open { tracing::info_span!("start") } else { tracing::Span::none() }; - let db = async { db::Db::open(&db::location(), keep_open).instrument(db::step("open_database")).await } - .instrument(start.clone()) - .await?; - - // A daemon owns the state; don't have two processes downloading the same thing. - let wire_cmd = match &cli.command { - Command::Fetch { feed, force } => Some(Cmd::Fetch { feed: feed.clone(), force: *force, feeds: vec![] }), - Command::Reap { dry_run } => Some(Cmd::Reap { dry_run: *dry_run }), - Command::Status => Some(Cmd::Status), - Command::List - | Command::Daemon { .. } - | Command::User { .. } - | Command::Add { .. } - | Command::Rm { .. } - | Command::Import { .. } - | Command::Export { .. } => None, - }; - if let Some(cmd) = &wire_cmd - && !cli.local - && ipc::daemon_is_live(&cfg.general.socket).await - { - return ipc::proxy(&cfg.general.socket, cmd).await; - } - - let socket = cfg.general.socket.clone(); - let cfg = - async { catalogue::assemble_config(&db, cfg, &config_path).instrument(db::step("assemble_config")).await } - .instrument(start.clone()) - .await?; - - let is_daemon = matches!(cli.command, Command::Daemon { .. }); - // These write the catalogue from a process of their own, which a running daemon, holding its - // own copy, would write over at its next change (#120). - let changes_catalogue = matches!( - cli.command, - Command::Add { .. } | Command::Rm { .. } | Command::Import { .. } | Command::User { cmd: UserCmd::Rm { .. } } - ); - let (events, _) = broadcast::channel(1024); - let ctx = Arc::new(Ctx::new(cfg, db, config_path.clone(), is_daemon, &events)?); - - let result = match cli.command { - Command::List => catalogue::list(&ctx).await, - Command::Daemon { web } => daemon::daemon(ctx, config_path, web, events, start).await, - Command::Add { url, folder, keywords, category, list } => { - catalogue::add(&ctx, &url, folder, keywords, category, list).await - } - Command::Rm { feed } => catalogue::rm(&ctx, &feed).await, - Command::User { cmd } => cli::user_cmd(&ctx, cmd, std::io::stdin()).await, - Command::Import { file } => catalogue::import(&ctx, &file).await, - Command::Export { file } => catalogue::export(&ctx, &file).await, - _ => cli::run(&ctx, wire_cmd.expect("only List and Daemon have no wire form")).await, - }; - if result.is_ok() - && changes_catalogue - && ipc::daemon_is_live(&socket).await - && let Err(e) = ipc::reload(&socket).await - { - eprintln!("the running daemon did not take the change ({e:#}); restart it, or it may undo it"); - } - // The batch exporter holds the last few seconds of spans; without this they are lost. - if let Some(p) = otel { - let _ = p.shutdown(); - } - result +fn main() -> anyhow::Result<()> { + ipx::main() } diff --git a/crates/ipx/src/test_util.rs b/crates/ipx/src/test_util.rs index cec1290..7d65224 100644 --- a/crates/ipx/src/test_util.rs +++ b/crates/ipx/src/test_util.rs @@ -8,7 +8,7 @@ use std::path::PathBuf; use std::sync::Arc; pub(crate) fn feed() -> config::Feed { - // Whatever `ipx add` would write, which is the shape every code path sees. + // Whatever `ipxd add` would write, which is the shape every code path sees. let mut cfg = config::Config::default(); add_one_cfg(&mut cfg, "http://x/f.xml", None, vec![]) diff --git a/crates/ipx/src/web.rs b/crates/ipx/src/web.rs index 831add2..71739f0 100644 --- a/crates/ipx/src/web.rs +++ b/crates/ipx/src/web.rs @@ -671,7 +671,7 @@ fn require_admin(user: &crate::db::User) -> Result<(), ApiError> { } /// Demoting or removing this account would leave nobody able to manage anyone, and the only -/// way back would be `ipx user` on the box. +/// way back would be `ipxd user` on the box. fn last_admin(users: &[crate::db::User], id: i64) -> bool { let admins: Vec = users.iter().filter(|u| u.is_admin).map(|u| u.id).collect(); admins == [id] @@ -798,7 +798,7 @@ async fn add_user( if state.ctx.db.user_by_name(&name).await?.is_some() { return Err(ApiError::bad_request(format!("{name} already exists"))); } - // No password is someone the proxy signs in, as with `ipx user add --no-password`. + // No password is someone the proxy signs in, as with `ipxd user add --no-password`. let hash = if body.password.is_empty() { None } else { @@ -2737,7 +2737,7 @@ async fn art(State(state): State, Query(q): Query) -> Respon crate::metrics::STATS.art_hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed); return art_response(kind, body); } - // Only what a feed here or the directory names: anything else would make ipx fetch any + // Only what a feed here or the directory names: anything else would make ipxd fetch any // address it is given. The Directory shows podcasts nobody here subscribes to (#273). if !crate::directory::listed_art(&q.u) && !crate::art::named_in_text(&q.u) @@ -3008,7 +3008,7 @@ async fn import_opml( Ok(Json(serde_json::json!({ "added": added, "already": already }))) } -/// The numbers `ipx status` prints, and the version, for a dashboard such as Homepage's +/// The numbers `ipxd status` prints, and the version, for a dashboard such as Homepage's /// customapi widget. Behind sign-in like the rest of /api; Homepage sends the shared token. async fn status(State(state): State) -> Response { match crate::daemon::status(&state.ctx).await { diff --git a/crates/ipx/tests/daemon.rs b/crates/ipx/tests/daemon.rs index 61c2114..f4b963d 100644 --- a/crates/ipx/tests/daemon.rs +++ b/crates/ipx/tests/daemon.rs @@ -93,7 +93,7 @@ url = "http://127.0.0.1:{feed_port}/feed.xml" } fn ipx(&self, args: &[&str]) -> Command { - let mut c = Command::new(env!("CARGO_BIN_EXE_ipx")); + let mut c = Command::new(env!("CARGO_BIN_EXE_ipxd")); c.args(args) .env("IPX_CONFIG", self.root.join("config.toml")) .env("IPX_DATA_DIR", self.root.join("data")) diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md new file mode 100644 index 0000000..153a9ce --- /dev/null +++ b/crates/tui/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog: ipx-tui + +The reader in a terminal (README.md). ipx's changelog is [crates/ipx/CHANGELOG.md](../ipx/CHANGELOG.md). + +Its version is in `crates/tui/Cargo.toml`. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- `ipx`, iPX's reader in a terminal: your feeds, a feed's items and an item's text, read and flagged as on the page, signed in with an API token (#437). diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml new file mode 100644 index 0000000..2c1fca4 --- /dev/null +++ b/crates/tui/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "ipx-tui" +version = "0.1.0-dev" +edition.workspace = true +publish = false + +[lints] +workspace = true + +[[bin]] +name = "ipx" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +chrono.workspace = true +clap = { workspace = true, features = ["env"] } +ratatui = { version = "0.30.2", default-features = false, features = ["crossterm"] } +reqwest = { workspace = true, features = ["blocking"] } +serde.workspace = true +serde_json.workspace = true diff --git a/crates/tui/README.md b/crates/tui/README.md new file mode 100644 index 0000000..7e6c942 --- /dev/null +++ b/crates/tui/README.md @@ -0,0 +1,27 @@ +# ipx-tui + +The `ipx` command. The server, which had that name, is `ipxd`. + +iPX's reader in a terminal: your feeds on the left, a feed's items above, the item's text below. +It talks to iPX's API as the page does, as you, with an API token made in the page's Settings. + +```sh +cargo build --release -p ipx-tui +IPX_URL=http://192.168.1.130:8099 IPX_API_TOKEN=... target/release/ipx +``` + +`--url` and `--token` do the same as the two variables. + +| key | does | +|---|---| +| `j` `k`, arrows | move, or scroll the item's text | +| `Enter`, `l`, `Tab` | open the feed or the item; opening an item marks it read | +| `h`, `Esc` | back a pane; `Esc` on the feeds quits | +| `Space`, `PgUp` `PgDn` | page through the item's text | +| `m` | mark the item read or unread | +| `f` | flag or unflag it | +| `u` | unread items only, or every item (unread only to start) | +| `r` | reload | +| `q` | quit | + +It shows a feed's newest 200 items. diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs new file mode 100644 index 0000000..4f9f97d --- /dev/null +++ b/crates/tui/src/main.rs @@ -0,0 +1,451 @@ +//! A terminal reader for ipx (#437): your feeds, a feed's items and an item's text, through the +//! same API the page uses, as the person whose API token it is given. + +use anyhow::{Context, Result, bail}; +use clap::Parser; +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use ratatui::layout::{Constraint, Layout}; +use ratatui::style::{Modifier, Style, Stylize}; +use ratatui::text::Line; +use ratatui::widgets::{Block, List, ListItem, ListState, Paragraph, Wrap}; +use ratatui::{DefaultTerminal, Frame}; +use reqwest::Url; +use serde::Deserialize; +use std::time::Duration; + +#[derive(Parser)] +#[command(name = "ipx", version, about = "Read iPX in a terminal")] +struct Args { + /// Where iPX is, as a browser reaches it. + #[arg(long, env = "IPX_URL", default_value = "http://localhost:8099")] + url: Url, + /// An API token, made in the page's Settings. + #[arg(long, env = "IPX_API_TOKEN", hide_env_values = true)] + token: String, +} + +/// The part of web.rs's FeedRow this needs. +#[derive(Deserialize)] +struct Feed { + id: String, + url: String, + title: Option, + unread: i64, + /// The OPML it came from. + group: Option, + hide_from_all: bool, +} + +/// The part of db.rs's EntryRow this needs. +#[derive(Deserialize)] +struct Entry { + guid: String, + feed_id: String, + title: Option, + link: Option, + published: Option, + description: Option, + read: bool, + flagged: bool, +} + +#[derive(Deserialize)] +struct EntryPage { + total: i64, + entries: Vec, +} + +struct Api { + http: reqwest::blocking::Client, + base: Url, + token: String, +} + +impl Api { + /// The API's address for these path segments, each escaped: a guid is often a URL itself. + fn url(&self, segments: &[&str]) -> Url { + let mut url = self.base.clone(); + url.path_segments_mut().expect("an http URL has a path").pop_if_empty().extend(segments); + url + } + + fn send(&self, req: reqwest::blocking::RequestBuilder) -> Result { + let res = req.bearer_auth(&self.token).send()?; + if !res.status().is_success() { + bail!("{} {}", res.status(), res.text().unwrap_or_default()); + } + Ok(res) + } + + fn feeds(&self) -> Result> { + Ok(self.send(self.http.get(self.url(&["api", "feeds"])))?.json()?) + } + + /// The newest items of one feed, or of all of them when `feed` is None. + // shortcut: the newest 200 only, no paging; page through `offset` if someone wants older. + fn entries(&self, feed: Option<&str>, unread_only: bool) -> Result { + let mut url = match feed { + Some(id) => self.url(&["api", "feeds", id, "entries"]), + None => self.url(&["api", "entries"]), + }; + let filter = if unread_only { "unread" } else { "all" }; + url.query_pairs_mut().append_pair("limit", "200").append_pair("filter", filter); + Ok(self.send(self.http.get(url))?.json()?) + } + + /// Sets an item's read or flagged, and answers with its feed's row, for the new unread count. + fn set_flag(&self, e: &Entry, flag: &str, on: bool) -> Result> { + let url = self.url(&["api", "entries", &e.feed_id, &e.guid, "flags"]); + Ok(self.send(self.http.post(url).json(&serde_json::json!({ flag: on })))?.json()?) + } +} + +#[derive(PartialEq, Clone, Copy)] +enum Pane { + Feeds, + Items, + Text, +} + +struct App { + api: Api, + /// Your feeds, after All Subscriptions, which is index 0 of `feed_list`. + feeds: Vec, + feed_list: ListState, + entries: Vec, + total: i64, + entry_list: ListState, + pane: Pane, + scroll: u16, + unread_only: bool, + status: String, +} + +impl App { + fn feed_id(&self) -> Option<&str> { + let i = self.feed_list.selected()?; + self.feeds.get(i.checked_sub(1)?).map(|f| f.id.as_str()) + } + + fn entry(&self) -> Option<&Entry> { + self.entries.get(self.entry_list.selected()?) + } + + /// Runs an API call, putting what went wrong in the status line instead of ending the program. + fn attempt(&mut self, what: Result) -> Option { + what.map_err(|e| self.status = format!("{e:#}")).ok() + } + + fn load_feeds(&mut self) { + if let Some(feeds) = self.attempt(self.api.feeds()) { + self.feeds = feeds; + self.status.clear(); + } + } + + fn load_entries(&mut self) { + let page = self.api.entries(self.feed_id(), self.unread_only); + if let Some(page) = self.attempt(page) { + self.entries = page.entries; + self.total = page.total; + self.entry_list.select(if self.entries.is_empty() { None } else { Some(0) }); + self.scroll = 0; + self.status.clear(); + } + } + + /// Sets the selected item's read or flagged, as the page does. + fn flag(&mut self, flag: &str, on: impl Fn(&Entry) -> bool) { + let Some(e) = self.entry() else { return }; + let on = on(e); + let result = self.api.set_flag(e, flag, on); + let Some(row) = self.attempt(result) else { return }; + if let Some(i) = self.entry_list.selected() { + let e = &mut self.entries[i]; + if flag == "read" { e.read = on } else { e.flagged = on } + } + if let Some(row) = row + && let Some(f) = self.feeds.iter_mut().find(|f| f.id == row.id) + { + f.unread = row.unread; + } + } + + /// Opens the selected item, which marks it read, as opening one on the page does. + fn open(&mut self) { + self.scroll = 0; + self.pane = Pane::Text; + if self.entry().is_some_and(|e| !e.read) { + self.flag("read", |_| true); + } + } + + fn step(&mut self, down: bool) { + let list = match self.pane { + Pane::Feeds => &mut self.feed_list, + Pane::Items => &mut self.entry_list, + Pane::Text => { + self.scroll = if down { self.scroll.saturating_add(1) } else { self.scroll.saturating_sub(1) }; + return; + } + }; + if down { + list.select_next() + } else { + list.select_previous() + } + if self.pane == Pane::Items { + self.scroll = 0; + } + } + + /// Handles a key, answering false when it is time to quit. + fn key(&mut self, code: KeyCode) -> bool { + match code { + KeyCode::Char('q') => return false, + KeyCode::Esc if self.pane == Pane::Feeds => return false, + KeyCode::Char('j') | KeyCode::Down => self.step(true), + KeyCode::Char('k') | KeyCode::Up => self.step(false), + KeyCode::PageDown | KeyCode::Char(' ') if self.pane == Pane::Text => { + self.scroll = self.scroll.saturating_add(10) + } + KeyCode::PageUp if self.pane == Pane::Text => self.scroll = self.scroll.saturating_sub(10), + KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right | KeyCode::Tab => match self.pane { + Pane::Feeds => { + self.load_entries(); + self.pane = Pane::Items; + } + Pane::Items => self.open(), + Pane::Text => {} + }, + KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left | KeyCode::BackTab => { + self.pane = match self.pane { + Pane::Text => Pane::Items, + _ => Pane::Feeds, + } + } + KeyCode::Char('m') => self.flag("read", |e| !e.read), + KeyCode::Char('f') => self.flag("flagged", |e| !e.flagged), + KeyCode::Char('u') => { + self.unread_only = !self.unread_only; + self.load_entries(); + } + KeyCode::Char('r') => { + self.load_feeds(); + self.load_entries(); + } + _ => {} + } + true + } + + fn draw(&mut self, frame: &mut Frame) { + let [main, foot] = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(frame.area()); + let [left, right] = Layout::horizontal([Constraint::Percentage(28), Constraint::Fill(1)]).areas(main); + let [top, bottom] = Layout::vertical([Constraint::Percentage(40), Constraint::Fill(1)]).areas(right); + let block = |title: String, pane: Pane| { + let b = Block::bordered().title(title); + if self.pane == pane { b.border_style(Style::new().bold()) } else { b.dim() } + }; + let picked = Style::new().add_modifier(Modifier::REVERSED); + + // What the page counts (feeds.ts inAll): not a feed hidden from All Subscriptions, or + // one from an OPML that is (#288). + let hidden = |id: &str| self.feeds.iter().any(|g| g.id == id && g.hide_from_all); + let all: i64 = self + .feeds + .iter() + .filter(|f| !f.hide_from_all && !f.group.as_deref().is_some_and(hidden)) + .map(|f| f.unread) + .sum(); + let feeds = std::iter::once(format!("All Subscriptions ({all})")).chain(self.feeds.iter().map(|f| { + let name = f.title.as_deref().unwrap_or(&f.url); + if f.unread > 0 { format!("{name} ({})", f.unread) } else { name.to_string() } + })); + let feeds = List::new(feeds).block(block("Feeds".into(), Pane::Feeds)).highlight_style(picked); + frame.render_stateful_widget(feeds, left, &mut self.feed_list); + + let items = self.entries.iter().map(|e| { + let mark = format!("{}{}", if e.read { ' ' } else { '•' }, if e.flagged { '★' } else { ' ' }); + let line = format!("{mark} {:<10} {}", date(e.published), e.title.as_deref().unwrap_or("(untitled)")); + if e.read { ListItem::new(line) } else { ListItem::new(line).bold() } + }); + let shown = if self.unread_only { "unread" } else { "items" }; + let title = format!("{} of {} {shown}", self.entries.len(), self.total); + let items = List::new(items).block(block(title, Pane::Items)).highlight_style(picked); + frame.render_stateful_widget(items, top, &mut self.entry_list); + + let text = match self.entry() { + Some(e) => { + let mut lines = vec![Line::from(e.title.clone().unwrap_or_default()).bold()]; + if let Some(link) = &e.link { + lines.push(Line::from(link.clone()).dim()); + } + lines.push(Line::default()); + lines + .extend(text_of(e.description.as_deref().unwrap_or("")).lines().map(|l| Line::from(l.to_string()))); + lines + } + None => vec![], + }; + let text = Paragraph::new(text).wrap(Wrap { trim: false }).scroll((self.scroll, 0)); + frame.render_widget(text.block(block("Item".into(), Pane::Text)), bottom); + + let help = "j/k move enter open h back m read f flag u unread only r reload q quit"; + let foot_text = if self.status.is_empty() { help } else { &self.status }; + frame.render_widget(Paragraph::new(foot_text).dim(), foot); + } +} + +fn date(t: Option) -> String { + t.and_then(|t| chrono::DateTime::from_timestamp(t, 0)).map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or_default() +} + +/// An item's HTML as plain text for a terminal: tags dropped, blocks on lines of their own, +/// entities decoded. ipx has already cleaned it, so there is no script or style to skip. +fn text_of(html: &str) -> String { + let mut out = String::new(); + let mut rest = html; + loop { + let (text, tag) = match rest.find('<') { + Some(i) => (&rest[..i], Some(&rest[i + 1..])), + None => (rest, None), + }; + // HTML's own whitespace is not layout; the tags say where lines break. + if text.starts_with(char::is_whitespace) && !out.is_empty() && !out.ends_with(['\n', ' ']) { + out.push(' '); + } + out.push_str(&decode(&text.split_whitespace().collect::>().join(" "))); + if text.ends_with(char::is_whitespace) && !text.trim().is_empty() { + out.push(' '); + } + let Some(tag) = tag else { break }; + let Some(end) = tag.find('>') else { break }; + let (inner, closing) = match tag[..end].strip_prefix('/') { + Some(t) => (t, true), + None => (&tag[..end], false), + }; + let name = inner.split(|c: char| c.is_whitespace() || c == '/').next().unwrap_or("").to_ascii_lowercase(); + match name.as_str() { + "br" => out.push('\n'), + "li" => { + if !out.is_empty() && !out.ends_with('\n') { + out.push('\n'); + } + if !closing { + out.push_str("• "); + } + } + "p" | "div" | "ul" | "ol" | "blockquote" | "pre" | "tr" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" + if !out.is_empty() && !out.ends_with("\n\n") => + { + out.push_str(if out.ends_with('\n') { "\n" } else { "\n\n" }); + } + _ => {} + } + rest = &tag[end + 1..]; + } + out.lines().map(str::trim_end).collect::>().join("\n").trim().to_string() +} + +/// HTML's entities, the named ones feeds use and every numbered one. +fn decode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(i) = rest.find('&') { + out.push_str(&rest[..i]); + rest = &rest[i..]; + let end = rest[..rest.len().min(12)].find(';'); + let ch = end.and_then(|end| match &rest[1..end] { + "amp" => Some('&'), + "lt" => Some('<'), + "gt" => Some('>'), + "quot" => Some('"'), + "apos" => Some('\''), + "nbsp" => Some(' '), + "hellip" => Some('…'), + "mdash" => Some('—'), + "ndash" => Some('–'), + "rsquo" => Some('’'), + "lsquo" => Some('‘'), + "rdquo" => Some('”'), + "ldquo" => Some('“'), + n => { + let n = n.strip_prefix('#')?; + let code = match n.strip_prefix(['x', 'X']) { + Some(hex) => u32::from_str_radix(hex, 16).ok()?, + None => n.parse().ok()?, + }; + char::from_u32(code) + } + }); + match (ch, end) { + (Some(c), Some(end)) => { + out.push(c); + rest = &rest[end + 1..]; + } + _ => { + out.push('&'); + rest = &rest[1..]; + } + } + } + out.push_str(rest); + out +} + +fn run(terminal: &mut DefaultTerminal, app: &mut App) -> Result<()> { + loop { + terminal.draw(|f| app.draw(f))?; + // A poll, not a blocking read, so a resize redraws without waiting for a key. + if !event::poll(Duration::from_millis(500))? { + continue; + } + if let Event::Key(k) = event::read()? + && k.kind == KeyEventKind::Press + && !app.key(k.code) + { + return Ok(()); + } + } +} + +fn main() -> Result<()> { + let args = Args::parse(); + let http = reqwest::blocking::Client::builder().timeout(Duration::from_secs(30)).build()?; + let api = Api { http, base: args.url, token: args.token }; + // Before the terminal is taken over, so a wrong address or token is said plainly. + let feeds = api.feeds().context("could not read your feeds")?; + let mut app = App { + api, + feeds, + feed_list: ListState::default().with_selected(Some(0)), + entries: vec![], + total: 0, + entry_list: ListState::default(), + pane: Pane::Feeds, + scroll: 0, + unread_only: true, + status: String::new(), + }; + app.load_entries(); + let mut terminal = ratatui::init(); + let result = run(&mut terminal, &mut app); + ratatui::restore(); + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn text_of_html() { + let html = "

One & two,\n bold.

Next line
broken — ’s &bogus; &

\ +
  • a
  • b
"; + assert_eq!(text_of(html), "One & two, bold.\n\nNext line\nbroken — ’s &bogus; &\n\n• a\n• b"); + assert_eq!(text_of("plain"), "plain"); + assert_eq!(text_of("a b c"), "a b c"); + assert_eq!(text_of("x y"), "x y"); + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 2f9fae2..0010f08 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -5,7 +5,7 @@ Three services, each a container of its own with its own version and changelog ( | Service | What it is | Source | Image | |---|---|---|---| | ipx-web | Caddy: serves the page, a static site built from `web/`, and passes `/api` and `/media` to ipx on the same address. It asks ipx before it serves the app or the admin page (`/api/page-check`) | `web/`, `web/Caddyfile` | `Dockerfile.web` | -| ipx | The daemon and CLI, one binary. `ipx daemon` runs a scheduler, a Unix-socket control server and the HTTP API in one process; everything else is a CLI that either does the work itself or hands it to a running daemon | `crates/ipx` | `Dockerfile` | +| ipx | The daemon and CLI, one binary, `ipxd`. `ipxd daemon` runs a scheduler, a Unix-socket control server and the HTTP API in one process; everything else is a CLI that either does the work itself or hands it to a running daemon | `crates/ipx` | `Dockerfile` | | ipx-directory | The directory of public podcasts that ipx servers share. ipx asks it on its people's behalf; the page never talks to it ([directory.md](directory.md)) | `crates/directory` | `Dockerfile.directory` | The Rust source is a Cargo workspace (#202); `crates/feed` (`ipx-feed`) fetches and parses feeds @@ -27,7 +27,8 @@ id, and Caddy's request lines theirs. ipx also serves Prometheus metrics (`[web] | File | Responsibility | What it replaced in the Python | |---|---|---| -| `crates/ipx/src/main.rs` | The module list and `main`: reads the command line and hands it on | `iPXAgent.py` | +| `crates/ipx/src/lib.rs` | The module list and `main`: reads the command line and hands it on | `iPXAgent.py` | +| `crates/ipx/src/main.rs` | `ipxd`, which calls `main`: a library under it keeps the modules' log targets `ipx::...` | — | | `crates/ipx/src/cli.rs` | The commands, and what each does when no daemon takes it | `iPXAgent.py` | | `crates/ipx/src/ctx.rs` | `Ctx`, what every command shares: configuration, database, clients | — | | `crates/ipx/src/daemon.rs` | The daemon: worker loop, web server, shutdown, `status` | `iPXAgent.py` | @@ -68,6 +69,7 @@ id, and Caddy's request lines theirs. ipx also serves Prometheus metrics (`[web] | `crates/directory/src/store.rs` | The directory's database: feeds, snapshots, blocked | — | | `crates/directory/src/config.rs` | The directory's config file: its tokens and their access | — | | `crates/directory/src/otel.rs` | Traces for both Rust services: the exporter, `traceparent` in and out | — | +| `crates/tui/src/main.rs` | `ipx` (package `ipx-tui`), the reader in a terminal: ratatui over the API, with an API token | — | | `web/index.html` | The app's markup | — | | `web/admin.html` | The admin page's markup: server settings, accounts, the log. Sent to admins only | — | | `web/app.css` | The stylesheet both pages share | — | @@ -156,7 +158,7 @@ printf '{"cmd":"fetch","force":true}\n' | socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/ **Commands** — `fetch` (optional `feed`, `force`), `reap` (optional `dry_run`), `download` (`enclosure`), `status`, `reload` (read the catalogue and settings again from the database, which -`ipx add`, `rm` and `import` send after changing them in a process of their own; answered with +`ipxd add`, `rm` and `import` send after changing them in a process of their own; answered with `status`). **Events** — `feed_start`, `feed_skip`, `feed_done`, `feed_error`, `progress`, `download_done`, diff --git a/docs/cli.md b/docs/cli.md index e95abe0..d100242 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -10,15 +10,15 @@ processes must never download the same thing. `--local` forces the work to happe | Command | What it does | |---|---| -| `ipx list` | Subscriptions and their state | -| `ipx status` | Counts: feeds, pending, downloaded | -| `ipx fetch [FEED] [--force]` | Scan everything, or one feed. `--force` ignores the TTL | -| `ipx add [--folder X] [--keywords a,b] [--list] [--category C]` | Subscribe; the id comes from the feed title. `--list` subscribes no one but keeps it in the catalogue, and so in what this server shares with the directory, when its last subscriber leaves; `--category` is the category the directory files it under when the feed names none. Run for a feed already in the catalogue, these list it or set its category | -| `ipx rm ` | Unsubscribe; downloads and history are kept | -| `ipx import ` / `ipx export ` | Move subscriptions in or out. Import subscribes the first admin, as the shared web token does; in the web UI it subscribes whoever is signed in | -| `ipx reap [--dry-run]` | Run retention now | -| `ipx user ` | Accounts for the web UI | -| `ipx daemon [--web ADDR]` | Scheduler, control socket and the HTTP API (the page is ipx-web's) | +| `ipxd list` | Subscriptions and their state | +| `ipxd status` | Counts: feeds, pending, downloaded | +| `ipxd fetch [FEED] [--force]` | Scan everything, or one feed. `--force` ignores the TTL | +| `ipxd add [--folder X] [--keywords a,b] [--list] [--category C]` | Subscribe; the id comes from the feed title. `--list` subscribes no one but keeps it in the catalogue, and so in what this server shares with the directory, when its last subscriber leaves; `--category` is the category the directory files it under when the feed names none. Run for a feed already in the catalogue, these list it or set its category | +| `ipxd rm ` | Unsubscribe; downloads and history are kept | +| `ipxd import ` / `ipxd export ` | Move subscriptions in or out. Import subscribes the first admin, as the shared web token does; in the web UI it subscribes whoever is signed in | +| `ipxd reap [--dry-run]` | Run retention now | +| `ipxd user ` | Accounts for the web UI | +| `ipxd daemon [--web ADDR]` | Scheduler, control socket and the HTTP API (the page is ipx-web's) | `add`, `rm` and `import` change the catalogue in a process of their own. With a daemon running they then tell it to read the catalogue again; without that it kept its own copy and wrote it back at @@ -29,11 +29,11 @@ its next change, undoing them. If it does not answer they say so: restart it. Passwords are read from **stdin**, so they miss the shell history and any `ps` listing. ```sh -echo -n 'a good password' | ipx user add ray # local account -ipx user add ray@example.com --no-password # signs in through the proxy only -echo -n 'a good password' | ipx user passwd admin # change a password; signs the account out everywhere -ipx user list # who exists, and how each signs in -ipx user rm sam # account, subscriptions and read state +echo -n 'a good password' | ipxd user add ray # local account +ipxd user add ray@example.com --no-password # signs in through the proxy only +echo -n 'a good password' | ipxd user passwd admin # change a password; signs the account out everywhere +ipxd user list # who exists, and how each signs in +ipxd user rm sam # account, subscriptions and read state ``` The first account created is an admin; later ones are ordinary users. A database with no accounts @@ -43,14 +43,14 @@ config.toml (or `IPX_ADMIN_PASSWORD`), announced in the log — change it. To avoid even the command line, read it interactively: ```sh -read -s PW && echo -n "$PW" | ipx user passwd admin +read -s PW && echo -n "$PW" | ipxd user passwd admin ``` ## Scanning ```sh -ipx fetch # everything due -ipx fetch atp --force # one feed, ignoring its TTL and schedule +ipxd fetch # everything due +ipxd fetch atp --force # one feed, ignoring its TTL and schedule ``` A scan: conditional GET (`If-None-Match` / `If-Modified-Since`), parse, record new entries, apply @@ -60,8 +60,8 @@ answers `304` and costs one request. ## Retention ```sh -ipx reap --dry-run # what would go, oldest first -ipx reap # actually delete +ipxd reap --dry-run # what would go, oldest first +ipxd reap # actually delete ``` Files are deleted to get back under `max_total_gb`, oldest first, and items past `max_age_days` @@ -72,8 +72,8 @@ stops the next scan fetching it again. ## The daemon ```sh -ipx daemon # scheduler + socket + HTTP API -ipx daemon --web 0.0.0.0:8099 # override the configured bind for one run +ipxd daemon # scheduler + socket + HTTP API +ipxd daemon --web 0.0.0.0:8099 # override the configured bind for one run ``` One daemon per socket; a second refuses to start rather than fight over the database. It shuts down diff --git a/docs/configuration.md b/docs/configuration.md index c2a76c1..365bd5e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -4,7 +4,7 @@ Two places. **config.toml** holds what ipx needs before it reaches its database, who gets in: where things are (`download_dir`, `socket`, `organize`), `[torrent]` and `[web]`. **The database** holds the catalogue of feeds (`[feeds.]` below) and the server settings the admin page edits (`schedule`, `max_total_gb`, `max_age_days`, `max_new_per_check`, -`media_types`, `art_cache_mb`). Change those in the web UI, or with `ipx add`, `ipx rm` and `ipx import`; they +`media_types`, `art_cache_mb`). Change those in the web UI, or with `ipxd add`, `ipxd rm` and `ipxd import`; they take effect without a restart. The first time ipx meets a database that holds no catalogue, it takes the feeds and those @@ -164,8 +164,8 @@ The app registers each phone through the page as the person signed in there ## `[feeds.]` Kept in the database once ipx has moved them in: a feed's settings are changed in the web UI, and -feeds come and go with `ipx add`, `ipx rm` and `ipx import`. The table key is the feed id: stable, -human-readable, and used in paths and the API. `ipx add` derives it from the feed title. +feeds come and go with `ipxd add`, `ipxd rm` and `ipxd import`. The table key is the feed id: stable, +human-readable, and used in paths and the API. `ipxd add` derives it from the feed title. ```toml [feeds.atp] @@ -192,7 +192,7 @@ up in Admin, Directory: its address, this server's token and whether to share. T settings, kept in the database (`directory_url`, `directory_token`, `directory_share`), not here. With sharing on, this server sends it every feed in the catalogue that is not private, with how many people here subscribe, and the `category` above for a feed that names none. To offer one with -nobody subscribed, `ipx add --list --category News `: it subscribes no one, and a listed feed +nobody subscribed, `ipxd add --list --category News `: it subscribes no one, and a listed feed stays in the catalogue, and so in what is shared, when its last subscriber leaves, where another is dropped. A feed nobody subscribes to is checked once a day. One that is dead (failing for 30 days) or quiet (nothing diff --git a/docs/sso.md b/docs/sso.md index 6eacb8b..5981e18 100644 --- a/docs/sso.md +++ b/docs/sso.md @@ -63,7 +63,7 @@ Cloudflare and Authentik were already right. Three things on the ipx side were n see the next section. 3. **The account had the wrong name.** It was made by hand as `rays`, but the header carries `rays@sdf1.net`. With `auto_create_users` on, the first visit would have made a second, empty - account. `ipx user rename rays rays@sdf1.net` fixed that without losing anything. + account. `ipxd user rename rays rays@sdf1.net` fixed that without losing anything. ### The address to trust, and why it is 192.168.64.1 @@ -105,7 +105,7 @@ ordinary user with no feeds. An account made before the proxy can be given the n send: ```sh -docker exec iPX ipx user rename +docker exec iPX ipxd user rename ``` ### Signing out @@ -225,19 +225,19 @@ Check it: the busybox request under [Check it](#check-it), which sends the email token from the Docker bridge, now gets `sign in`, and the site still signs you in through Authentik. **Turning it off:** clear `trusted_header` and restart. Proxy-made accounts stay, but nobody can sign -in with them until they are given a password (`ipx user passwd `). +in with them until they are given a password (`ipxd user passwd `). --- ## Everyday administration ```sh -ipx user list # who exists, how each signs in, and when -echo -n 'secret123' | ipx user add sam # local account, password on stdin -ipx user add sam@example.com --no-password # proxy-only account, made ahead of time -ipx user rename sam sam@example.com # give an account the name the proxy sends -echo -n 'newsecret' | ipx user passwd sam # change a password -ipx user rm sam # remove the account +ipxd user list # who exists, how each signs in, and when +echo -n 'secret123' | ipxd user add sam # local account, password on stdin +ipxd user add sam@example.com --no-password # proxy-only account, made ahead of time +ipxd user rename sam sam@example.com # give an account the name the proxy sends +echo -n 'newsecret' | ipxd user passwd sam # change a password +ipxd user rm sam # remove the account ``` In the container, put `docker exec iPX` in front, and `docker exec -i iPX` for the ones @@ -276,4 +276,4 @@ rule is on Cloudflare, not in this repository; if the hostname is ever served wi whole of ipx is on the internet behind bearer tokens alone. See also [users.md](users.md) for what several people share, [configuration.md](configuration.md) -for every `[web]` key, and [cli.md](cli.md) for the `ipx user` commands. +for every `[web]` key, and [cli.md](cli.md) for the `ipxd user` commands. diff --git a/docs/users.md b/docs/users.md index 4977e05..333175b 100644 --- a/docs/users.md +++ b/docs/users.md @@ -56,13 +56,13 @@ A database with no accounts creates **admin** on the next daemon start, with the readable by ipx's user alone. The log says where the password is, never what it is. Change it: ```sh -echo -n 'a good password' | ipx user passwd admin +echo -n 'a good password' | ipxd user passwd admin ``` ## Adding someone ```sh -echo -n 'their password' | ipx user add sam +echo -n 'their password' | ipxd user add sam ``` They sign in at `/login` and start with **no feeds**: subscriptions are per person. Adding a feed @@ -107,5 +107,5 @@ and who else is an admin, and read the log, which names everyone's feeds and sig gets the Settings and Log buttons hidden and a `403` if they ask anyway. ```sh -ipx user list # the admin column says who +ipxd user list # the admin column says who ``` diff --git a/grafana/alerts.py b/grafana/alerts.py index 5f97a13..ae46c15 100644 --- a/grafana/alerts.py +++ b/grafana/alerts.py @@ -43,7 +43,7 @@ rules = [ "Prometheus cannot reach the directory's /metrics.", no_data="Alerting"), rule("web-down", "ipx-web is down", 'up{job="ipx-web"}', "lt", 1, "5m", "Prometheus cannot reach Caddy's /metrics: the page is likely down too.", no_data="Alerting"), - # The worker: the daemon answers `ipx status` while it is stuck, so only these say so. + # The worker: the daemon answers `ipxd status` while it is stuck, so only these say so. rule("scan-stale", "ipx's scans have stopped", 'max(ipx_last_scan_age_seconds{job="ipx"})', "gt", 900, "5m", "No scan has finished for over 15 minutes; scans run at least every 10. The worker is stuck: its job and trace say on what."), rule("job-long", "ipx's worker is stuck on one job", 'max(ipx_worker_job_seconds{job="ipx"})', "gt", 1800, "1m", diff --git a/grafana/dashboard.py b/grafana/dashboard.py index fb4ee87..2a0f487 100644 --- a/grafana/dashboard.py +++ b/grafana/dashboard.py @@ -19,7 +19,7 @@ PROM = {"type": "prometheus", "uid": "${prometheus}"} SEL = '{container="iPX"}' # ipx logs one JSON object a line (IPX_LOG_FORMAT=json). Each scan and download event carries its # fields (ev, feed, new, bytes, msg, error.type, ...); a failed request its method, path, route, -# status and duration_ms. Events are logged under ipx::scan, `ipx status`'s answer under ipx::io, +# status and duration_ms. Events are logged under ipx::scan, `ipxd status`'s answer under ipx::io, # so the filter is on the ev field, not the target. EV = SEL + ' |= "\\"ev\\":\\"" | json | __error__="" | ev != ""' # Each request ipx answers, the event stream aside, as a histogram (#364): its line in the log is diff --git a/scripts/release.sh b/scripts/release.sh index a23fbc5..bf31c9b 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -7,7 +7,7 @@ cd "$(dirname "$0")/.." case "${1:-}" in ipx) name=ipx; ver=$(sed -n 's/^version = "\(.*\)"/\1/p' crates/ipx/Cargo.toml | head -1) - tag=v$ver; log=crates/ipx/CHANGELOG.md; from=/usr/local/bin/ipx ;; + tag=v$ver; log=crates/ipx/CHANGELOG.md; from=/usr/local/bin/ipxd ;; directory) name=ipx-directory; ver=$(sed -n 's/^version = "\(.*\)"/\1/p' crates/directory/Cargo.toml | head -1) tag=directory-v$ver; log=crates/directory/CHANGELOG.md; from=/usr/local/bin/ipx-directory ;; web) name=ipx-web; ver=$(node -p 'require("./package.json").version') @@ -31,7 +31,7 @@ if [ "$name" = ipx-web ]; then arch=any else docker cp -q "$c:$from" "$dir/" - "$dir/$name" --version | grep -qF " $ver" || { echo "$image is not $name $ver" >&2; exit 1; } + "$dir/$(basename "$from")" --version | grep -qF " $ver" || { echo "$image is not $name $ver" >&2; exit 1; } arch=linux-$(uname -m) fi cp LICENSE README.md "$dir/"; cp "$log" "$dir/CHANGELOG.md" diff --git a/site/api.mts b/site/api.mts index 43bd9cf..1a6bd08 100644 --- a/site/api.mts +++ b/site/api.mts @@ -338,7 +338,7 @@ export const ipx: Api = { { method: 'GET', path: '/api/status', slug: 'get-status', summary: 'How many feeds, files waiting and files downloaded, and the version.', - doc: '

The numbers ipx status prints, for a dashboard.

', + doc: '

The numbers ipxd status prints, for a dashboard.

', returns: 'Status', example: ipxCurl('GET', '/api/status'), }, { diff --git a/site/guides/cli.html b/site/guides/cli.html index 44607b6..c4f661a 100644 --- a/site/guides/cli.html +++ b/site/guides/cli.html @@ -3,13 +3,13 @@
- - - - - - - + + + + + + +
CommandWhat it does
ipx listEvery podcast and its state
ipx statusHow many podcasts, how many files waiting and downloaded
ipx fetch [id] [--force]Check everything that is due, or one podcast; --force checks it even if it is not due
ipx add <url>, ipx rm <id>Add a podcast, or take it out
ipx import <file>, ipx export <file>OPML in or out
ipx reap [--dry-run]Clear old files now, to stay within the space you set
ipx user add|list|passwd|rmAccounts
ipxd listEvery podcast and its state
ipxd statusHow many podcasts, how many files waiting and downloaded
ipxd fetch [id] [--force]Check everything that is due, or one podcast; --force checks it even if it is not due
ipxd add <url>, ipxd rm <id>Add a podcast, or take it out
ipxd import <file>, ipxd export <file>OPML in or out
ipxd reap [--dry-run]Clear old files now, to stay within the space you set
ipxd user add|list|passwd|rmAccounts

The control socket

diff --git a/site/guides/install.html b/site/guides/install.html index 201afad..4db9026 100644 --- a/site/guides/install.html +++ b/site/guides/install.html @@ -35,12 +35,12 @@ docker build -f Dockerfile.web -t ipx-web . - ipx
docker compose up -d

ipx has no port of its own: everything reaches it through ipx-web, on port 8099. Open http://<your server>:8099 and sign in as admin with the password ipodderx, then change it:

-
echo -n 'a good password' | docker exec -i ipx ipx user passwd admin
+
echo -n 'a good password' | docker exec -i ipx ipxd user passwd admin

The password is read from what you pipe in, so it stays out of your shell's history.

Adding people

An admin adds people in Admin, Accounts, or from the command line:

-
echo -n 'their password' | docker exec -i ipx ipx user add sam
+
echo -n 'their password' | docker exec -i ipx ipxd user add sam

Each person starts with no podcasts and subscribes to their own. Two people subscribing to the same show cost nothing extra: it is fetched once and each file is kept once.

Settings

diff --git a/tests/load/run.ts b/tests/load/run.ts index d88c09c..6ce1ade 100644 --- a/tests/load/run.ts +++ b/tests/load/run.ts @@ -8,7 +8,7 @@ // It builds a release binary (debug Argon2 alone takes a second a sign-in, which would measure // the build, not ipx), serves generated feeds from this process, starts a daemon on its own // config and data under /tmp/ipx-load, wiped first, seeds listeners, then runs each k6 script. -// While each runs, `ipx status` is run every second, and the run fails if any answer takes 5s. +// While each runs, `ipxd status` is run every second, and the run fails if any answer takes 5s. // // The daemon is on SQLite unless --postgres, which puts it in IPX_TEST_DATABASE_URL, the // database the Rust tests use on Postgres too (ipodderx_test on the server production's is on, in @@ -30,7 +30,7 @@ const USERS = Number(process.env.IPX_LOAD_USERS || 100); const MEDIA = 4; const ITEMS = 20; const BASE = `http://127.0.0.1:${WEB}`; -const bin = path.join(repo, 'target/release/ipx'); +const bin = path.join(repo, 'target/release/ipxd'); const SCRIPTS = ['browse', 'listening', 'media', 'signin']; const POSTGRES = process.argv.includes('--postgres'); const PG_URL = process.env.IPX_TEST_DATABASE_URL || ''; @@ -202,13 +202,13 @@ async function seed() { if (entries.flatMap(e => e.enclosures).filter(x => x.path).length >= MEDIA) break; if (Date.now() - t > 120_000) throw new Error(`the listener's ${MEDIA} files did not download in 120s`); } - // A password, for the sign-in test; the CLI hashes it as `ipx user add` always does. + // A password, for the sign-in test; the CLI hashes it as `ipxd user add` always does. execFileSync(bin, ['user', 'add', 'piper'], { input: 'piperpassword', env }); } // ---- the run -------------------------------------------------------------------------------- -/// `ipx status` once a second until stopped: the slowest answer, and any that failed. Not +/// `ipxd status` once a second until stopped: the slowest answer, and any that failed. Not /// spawnSync: blocked in it, this process stops draining the daemon's log, the pipe fills, and /// the daemon stalls writing its next line, which is the test measuring itself. function watchHealth() { @@ -269,7 +269,7 @@ function k6(script: string) { const stop = watchHealth(); const code = await k6(script); const health = stop(); - console.log(`ipx status: slowest ${health.slowest}ms, ${health.failed} failed`); + console.log(`ipxd status: slowest ${health.slowest}ms, ${health.failed} failed`); if (code !== 0) { failed++; console.log(`${script}: thresholds failed (k6 exit ${code})`); } if (health.failed || health.slowest >= 5000) { failed++; console.log(`${script}: the healthcheck would have failed`); } } diff --git a/tests/ui/app.spec.ts b/tests/ui/app.spec.ts index 6fb8175..cf9a4aa 100644 --- a/tests/ui/app.spec.ts +++ b/tests/ui/app.spec.ts @@ -1028,7 +1028,7 @@ test('an export from one account imports into another', async ({ page, browser } await ctx.close(); }); -test('ipx import subscribes the admin, and ipx export writes the feeds out', async () => { +test('ipxd import subscribes the admin, and ipxd export writes the feeds out', async () => { // Its own config and database. The CLI works in-process, and the suite's running daemon // reads config.toml once at start, so it would not see what the CLI added anyway. const dir = path.join(setup.root, 'cli'); @@ -1232,7 +1232,7 @@ test('the feed list keeps the icons it has drawn when a feed\'s new row comes in expect(kept).toEqual({ drawn: true, redrawn: true, same: true }); }); -test('ipx add and rm while the daemon runs reach it, and its next change keeps them (#120)', async ({ page }) => { +test('ipxd add and rm while the daemon runs reach it, and its next change keeps them (#120)', async ({ page }) => { const env = { ...process.env, IPX_CONFIG: `${setup.root}/config/config.toml`, IPX_DATA_DIR: `${setup.root}/data` }; const ipx = (...args: string[]) => execFileSync(setup.IPX_BIN, args, { env, encoding: 'utf8' }); // By title: the directory names a feed by its address, not by its id here (#205). diff --git a/tests/ui/fixtures/cli.xml b/tests/ui/fixtures/cli.xml index 10426c6..9f34284 100644 --- a/tests/ui/fixtures/cli.xml +++ b/tests/ui/fixtures/cli.xml @@ -1,5 +1,5 @@ Command Line Showhttp://127.0.0.1:8792/ -Added with ipx add while the daemon runs. +Added with ipxd add while the daemon runs. From the Command Linecli-1x diff --git a/tests/ui/global-setup.ts b/tests/ui/global-setup.ts index 25e6848..741847c 100644 --- a/tests/ui/global-setup.ts +++ b/tests/ui/global-setup.ts @@ -92,7 +92,7 @@ token_sha256 = "${crypto.createHash('sha256').update(DIRECTORY_TOKEN).digest('he /// suite runs that build's ipx and ipx-directory, which write their coverage profiles there, the /// tests' own `ipx` commands included; without it, the debug build it makes itself. const API_COVERAGE = process.env.IPX_API_COVERAGE ?? ''; -const IPX_BIN = API_COVERAGE ? path.join(API_COVERAGE, 'debug', 'ipx') : './target/debug/ipx'; +const IPX_BIN = API_COVERAGE ? path.join(API_COVERAGE, 'debug', 'ipxd') : './target/debug/ipxd'; const DIRECTORY_BIN = API_COVERAGE ? path.join(API_COVERAGE, 'debug', 'ipx-directory') : './target/debug/ipx-directory'; export { prepare, root, TOKEN, DIRECTORY, DIRECTORY_TOKEN, API_COVERAGE, IPX_BIN, DIRECTORY_BIN };