Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78e7e4aa2c | ||
|
|
6b0fa51e87 | ||
|
|
e719e031cf | ||
|
|
ca910d99e5 | ||
|
|
16244a6a70 | ||
|
|
a4c338a9f9 | ||
|
|
29c66da865 | ||
|
|
12134f3596 | ||
|
|
f410df47c0 | ||
|
|
61ed37fc20 | ||
|
|
9b1c137c80 | ||
|
|
eb960f1b57 | ||
|
|
10cbe9ed79 | ||
|
|
f6e317a9fe | ||
|
|
aa3de51d9c | ||
|
|
d404946c78 | ||
|
|
073a85f3c7 | ||
|
|
572056ce3b | ||
|
|
d36f203e4c | ||
|
|
63eee6bbd3 | ||
|
|
da065437c0 | ||
|
|
849231e49a | ||
|
|
4995a8bd5d | ||
|
|
69cd736455 | ||
|
|
a70de4af06 | ||
|
|
bf2ab8441e | ||
|
|
89423f01fa |
No files matched your search
@@ -3,3 +3,5 @@ target/
|
|||||||
*.sqlite
|
*.sqlite
|
||||||
out/
|
out/
|
||||||
.env
|
.env
|
||||||
|
dev/
|
||||||
|
node_modules/
|
||||||
Generated
+511
-22
@@ -138,6 +138,18 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "argon2"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"blake2",
|
||||||
|
"cpufeatures 0.2.17",
|
||||||
|
"password-hash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arrayref"
|
name = "arrayref"
|
||||||
version = "0.3.9"
|
version = "0.3.9"
|
||||||
@@ -224,6 +236,17 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-trait"
|
||||||
|
version = "0.1.92"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atoi"
|
name = "atoi"
|
||||||
version = "2.0.0"
|
version = "2.0.0"
|
||||||
@@ -269,7 +292,7 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
"pastey",
|
"pastey",
|
||||||
"rayon",
|
"rayon",
|
||||||
"thiserror",
|
"thiserror 2.0.20",
|
||||||
"v_frame",
|
"v_frame",
|
||||||
"y4m",
|
"y4m",
|
||||||
]
|
]
|
||||||
@@ -372,6 +395,26 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum-login"
|
||||||
|
version = "0.18.0"
|
||||||
|
source = "git+https://github.com/maxcountryman/axum-login.git?rev=151c72d7a1b4646830f86b4332e6bd6e34d719a7#151c72d7a1b4646830f86b4332e6bd6e34d719a7"
|
||||||
|
dependencies = [
|
||||||
|
"axum",
|
||||||
|
"form_urlencoded",
|
||||||
|
"pin-project",
|
||||||
|
"serde",
|
||||||
|
"subtle",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
"tokio",
|
||||||
|
"tower-cookies",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tower-sessions",
|
||||||
|
"tracing",
|
||||||
|
"urlencoding",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
@@ -384,6 +427,12 @@ version = "0.23.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64ct"
|
||||||
|
version = "1.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "basic-toml"
|
name = "basic-toml"
|
||||||
version = "0.1.10"
|
version = "0.1.10"
|
||||||
@@ -438,6 +487,15 @@ dependencies = [
|
|||||||
"no_std_io2",
|
"no_std_io2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blake2"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||||
|
dependencies = [
|
||||||
|
"digest 0.10.7",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -654,6 +712,17 @@ version = "0.10.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cookie"
|
||||||
|
version = "0.18.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
|
||||||
|
dependencies = [
|
||||||
|
"percent-encoding",
|
||||||
|
"time",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.9.4"
|
version = "0.9.4"
|
||||||
@@ -815,12 +884,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "daily-epub"
|
name = "daily-epub"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ammonia",
|
"ammonia",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"askama",
|
"askama",
|
||||||
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
|
"axum-login",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
"clap",
|
"clap",
|
||||||
"dom_smoothie",
|
"dom_smoothie",
|
||||||
@@ -832,26 +903,48 @@ dependencies = [
|
|||||||
"image",
|
"image",
|
||||||
"jiff",
|
"jiff",
|
||||||
"libc",
|
"libc",
|
||||||
|
"password-auth",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"resvg",
|
"resvg",
|
||||||
|
"roxmltree 0.21.1",
|
||||||
|
"rpassword",
|
||||||
"scraper",
|
"scraper",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror 2.0.20",
|
||||||
|
"time",
|
||||||
"tiny-skia",
|
"tiny-skia",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
|
"toml 1.1.4+spec-1.1.0",
|
||||||
|
"toml_edit 0.25.13+spec-1.1.0",
|
||||||
|
"tower",
|
||||||
"tower-http 0.7.0",
|
"tower-http 0.7.0",
|
||||||
|
"tower_governor",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"url",
|
"url",
|
||||||
"zip",
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dashmap"
|
||||||
|
version = "6.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"crossbeam-utils",
|
||||||
|
"hashbrown 0.14.5",
|
||||||
|
"lock_api",
|
||||||
|
"once_cell",
|
||||||
|
"parking_lot_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "data-url"
|
name = "data-url"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -886,7 +979,7 @@ version = "1.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
|
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror",
|
"thiserror 2.0.20",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -894,6 +987,9 @@ name = "deranged"
|
|||||||
version = "0.5.8"
|
version = "0.5.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_arbitrary"
|
name = "derive_arbitrary"
|
||||||
@@ -935,6 +1031,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer 0.10.4",
|
"block-buffer 0.10.4",
|
||||||
"crypto-common 0.1.7",
|
"crypto-common 0.1.7",
|
||||||
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -990,7 +1087,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"phf",
|
"phf",
|
||||||
"tendril",
|
"tendril",
|
||||||
"thiserror",
|
"thiserror 2.0.20",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1056,7 +1153,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror 2.0.20",
|
||||||
"upon",
|
"upon",
|
||||||
"uuid",
|
"uuid",
|
||||||
"zip",
|
"zip",
|
||||||
@@ -1176,7 +1273,7 @@ dependencies = [
|
|||||||
"pear",
|
"pear",
|
||||||
"serde",
|
"serde",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"toml",
|
"toml 0.8.23",
|
||||||
"uncased",
|
"uncased",
|
||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
@@ -1273,6 +1370,16 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "forwarded-header-value"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9"
|
||||||
|
dependencies = [
|
||||||
|
"nonempty",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fs_extra"
|
name = "fs_extra"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@@ -1361,6 +1468,12 @@ version = "0.3.34"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-timer"
|
||||||
|
version = "3.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-util"
|
name = "futures-util"
|
||||||
version = "0.3.34"
|
version = "0.3.34"
|
||||||
@@ -1417,9 +1530,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 5.3.0",
|
"r-efi 5.3.0",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1458,6 +1573,29 @@ version = "0.3.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
|
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "governor"
|
||||||
|
version = "0.10.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"dashmap",
|
||||||
|
"futures-sink",
|
||||||
|
"futures-timer",
|
||||||
|
"futures-util",
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
"hashbrown 0.16.1",
|
||||||
|
"nonzero_ext",
|
||||||
|
"parking_lot",
|
||||||
|
"portable-atomic",
|
||||||
|
"quanta",
|
||||||
|
"rand 0.9.5",
|
||||||
|
"smallvec",
|
||||||
|
"spinning_top",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h2"
|
name = "h2"
|
||||||
version = "0.4.15"
|
version = "0.4.15"
|
||||||
@@ -1500,6 +1638,12 @@ dependencies = [
|
|||||||
"smallvec",
|
"smallvec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.14.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.16.1"
|
version = "0.16.1"
|
||||||
@@ -1669,6 +1813,19 @@ dependencies = [
|
|||||||
"tower-service",
|
"tower-service",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-timeout"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0"
|
||||||
|
dependencies = [
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
@@ -1987,7 +2144,7 @@ dependencies = [
|
|||||||
"jni-sys",
|
"jni-sys",
|
||||||
"log",
|
"log",
|
||||||
"simd_cesu8",
|
"simd_cesu8",
|
||||||
"thiserror",
|
"thiserror 2.0.20",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
@@ -2282,6 +2439,18 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nonempty"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nonzero_ext"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "noop_proc_macro"
|
name = "noop_proc_macro"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -2410,6 +2579,29 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "password-auth"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1a2a4764cc1f8d961d802af27193c6f4f0124bd0e76e8393cf818e18880f0524"
|
||||||
|
dependencies = [
|
||||||
|
"argon2",
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"password-hash",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "password-hash"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "paste"
|
name = "paste"
|
||||||
version = "1.0.15"
|
version = "1.0.15"
|
||||||
@@ -2510,6 +2702,26 @@ version = "0.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
|
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-project"
|
||||||
|
version = "1.1.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
|
||||||
|
dependencies = [
|
||||||
|
"pin-project-internal",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-project-internal"
|
||||||
|
version = "1.1.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -2668,6 +2880,21 @@ dependencies = [
|
|||||||
"bytemuck",
|
"bytemuck",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quanta"
|
||||||
|
version = "0.12.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-utils",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"raw-cpuid",
|
||||||
|
"wasi",
|
||||||
|
"web-sys",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-error"
|
name = "quick-error"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
@@ -2688,7 +2915,7 @@ dependencies = [
|
|||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
"rustls",
|
"rustls",
|
||||||
"socket2",
|
"socket2",
|
||||||
"thiserror",
|
"thiserror 2.0.20",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"web-time",
|
"web-time",
|
||||||
@@ -2711,7 +2938,7 @@ dependencies = [
|
|||||||
"rustls",
|
"rustls",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"slab",
|
"slab",
|
||||||
"thiserror",
|
"thiserror 2.0.20",
|
||||||
"tinyvec",
|
"tinyvec",
|
||||||
"tracing",
|
"tracing",
|
||||||
"web-time",
|
"web-time",
|
||||||
@@ -2783,6 +3010,15 @@ dependencies = [
|
|||||||
"rand_core 0.9.5",
|
"rand_core 0.9.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_core"
|
name = "rand_core"
|
||||||
version = "0.9.5"
|
version = "0.9.5"
|
||||||
@@ -2837,7 +3073,7 @@ dependencies = [
|
|||||||
"rand 0.9.5",
|
"rand 0.9.5",
|
||||||
"rand_chacha",
|
"rand_chacha",
|
||||||
"simd_helpers",
|
"simd_helpers",
|
||||||
"thiserror",
|
"thiserror 2.0.20",
|
||||||
"v_frame",
|
"v_frame",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
@@ -3026,6 +3262,27 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rpassword"
|
||||||
|
version = "7.5.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rtoolbox",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rtoolbox"
|
||||||
|
version = "0.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.3"
|
version = "2.1.3"
|
||||||
@@ -3292,6 +3549,15 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_spanned"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_urlencoded"
|
name = "serde_urlencoded"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
@@ -3470,6 +3736,15 @@ dependencies = [
|
|||||||
"lock_api",
|
"lock_api",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "spinning_top"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300"
|
||||||
|
dependencies = [
|
||||||
|
"lock_api",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlx"
|
name = "sqlx"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -3511,7 +3786,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"thiserror",
|
"thiserror 2.0.20",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -3553,7 +3828,7 @@ dependencies = [
|
|||||||
"sqlx-postgres",
|
"sqlx-postgres",
|
||||||
"sqlx-sqlite",
|
"sqlx-sqlite",
|
||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
"thiserror",
|
"thiserror 2.0.20",
|
||||||
"tokio",
|
"tokio",
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
@@ -3580,7 +3855,7 @@ dependencies = [
|
|||||||
"sha1",
|
"sha1",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
"sqlx-core",
|
"sqlx-core",
|
||||||
"thiserror",
|
"thiserror 2.0.20",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3614,7 +3889,7 @@ dependencies = [
|
|||||||
"smallvec",
|
"smallvec",
|
||||||
"sqlx-core",
|
"sqlx-core",
|
||||||
"stringprep",
|
"stringprep",
|
||||||
"thiserror",
|
"thiserror 2.0.20",
|
||||||
"tracing",
|
"tracing",
|
||||||
"whoami",
|
"whoami",
|
||||||
]
|
]
|
||||||
@@ -3638,7 +3913,7 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"serde",
|
"serde",
|
||||||
"sqlx-core",
|
"sqlx-core",
|
||||||
"thiserror",
|
"thiserror 2.0.20",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
@@ -3800,13 +4075,33 @@ dependencies = [
|
|||||||
"new_debug_unreachable",
|
"new_debug_unreachable",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.20"
|
version = "2.0.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl",
|
"thiserror-impl 2.0.20",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3854,6 +4149,7 @@ dependencies = [
|
|||||||
"powerfmt",
|
"powerfmt",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
"time-core",
|
"time-core",
|
||||||
|
"time-macros",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3862,6 +4158,16 @@ version = "0.1.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time-macros"
|
||||||
|
version = "0.2.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
|
||||||
|
dependencies = [
|
||||||
|
"num-conv",
|
||||||
|
"time-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tiny-skia"
|
name = "tiny-skia"
|
||||||
version = "0.12.0"
|
version = "0.12.0"
|
||||||
@@ -3983,9 +4289,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_spanned",
|
"serde_spanned 0.6.9",
|
||||||
"toml_datetime",
|
"toml_datetime 0.6.11",
|
||||||
"toml_edit",
|
"toml_edit 0.22.27",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml"
|
||||||
|
version = "1.1.4+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"serde_core",
|
||||||
|
"serde_spanned 1.1.1",
|
||||||
|
"toml_datetime 1.1.1+spec-1.1.0",
|
||||||
|
"toml_parser",
|
||||||
|
"toml_writer",
|
||||||
|
"winnow 1.0.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3997,6 +4318,15 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_datetime"
|
||||||
|
version = "1.1.1+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_edit"
|
name = "toml_edit"
|
||||||
version = "0.22.27"
|
version = "0.22.27"
|
||||||
@@ -4005,18 +4335,75 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_spanned",
|
"serde_spanned 0.6.9",
|
||||||
"toml_datetime",
|
"toml_datetime 0.6.11",
|
||||||
"toml_write",
|
"toml_write",
|
||||||
"winnow 0.7.15",
|
"winnow 0.7.15",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_edit"
|
||||||
|
version = "0.25.13+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"toml_datetime 1.1.1+spec-1.1.0",
|
||||||
|
"toml_parser",
|
||||||
|
"toml_writer",
|
||||||
|
"winnow 1.0.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_parser"
|
||||||
|
version = "1.1.3+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
|
||||||
|
dependencies = [
|
||||||
|
"winnow 1.0.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_write"
|
name = "toml_write"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_writer"
|
||||||
|
version = "1.1.2+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tonic"
|
||||||
|
version = "0.14.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"axum",
|
||||||
|
"base64 0.22.1",
|
||||||
|
"bytes",
|
||||||
|
"h2",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-timeout",
|
||||||
|
"hyper-util",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project",
|
||||||
|
"socket2",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tokio-stream",
|
||||||
|
"tower",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -4025,14 +4412,33 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"indexmap",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"slab",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-cookies"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36"
|
||||||
|
dependencies = [
|
||||||
|
"axum-core",
|
||||||
|
"cookie",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"parking_lot",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower-http"
|
name = "tower-http"
|
||||||
version = "0.6.11"
|
version = "0.6.11"
|
||||||
@@ -4080,6 +4486,7 @@ dependencies = [
|
|||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4094,6 +4501,60 @@ version = "0.3.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-sessions"
|
||||||
|
version = "0.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "518dca34b74a17cadfcee06e616a09d2bd0c3984eff1769e1e76d58df978fc78"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"http",
|
||||||
|
"time",
|
||||||
|
"tokio",
|
||||||
|
"tower-cookies",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tower-sessions-core",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-sessions-core"
|
||||||
|
version = "0.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "568531ec3dfcf3ffe493de1958ae5662a0284ac5d767476ecdb6a34ff8c6b06c"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"base64 0.22.1",
|
||||||
|
"futures",
|
||||||
|
"http",
|
||||||
|
"parking_lot",
|
||||||
|
"rand 0.9.5",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
"time",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower_governor"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "44de9b94d849d3c46e06a883d72d408c2de6403367b39df2b1c9d9e7b6736fe6"
|
||||||
|
dependencies = [
|
||||||
|
"axum",
|
||||||
|
"forwarded-header-value",
|
||||||
|
"governor",
|
||||||
|
"http",
|
||||||
|
"pin-project",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
"tonic",
|
||||||
|
"tower",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing"
|
name = "tracing"
|
||||||
version = "0.1.44"
|
version = "0.1.44"
|
||||||
@@ -4264,6 +4725,12 @@ dependencies = [
|
|||||||
"serde_derive",
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urlencoding"
|
||||||
|
version = "2.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "usvg"
|
name = "usvg"
|
||||||
version = "0.48.1"
|
version = "0.48.1"
|
||||||
@@ -4495,6 +4962,22 @@ version = "2.1.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c"
|
checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||||
|
dependencies = [
|
||||||
|
"winapi-i686-pc-windows-gnu",
|
||||||
|
"winapi-x86_64-pc-windows-gnu",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-i686-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winapi-util"
|
name = "winapi-util"
|
||||||
version = "0.1.11"
|
version = "0.1.11"
|
||||||
@@ -4504,6 +4987,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-x86_64-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.62.2"
|
version = "0.62.2"
|
||||||
|
|||||||
+13
-3
@@ -1,13 +1,15 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "daily-epub"
|
name = "daily-epub"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
ammonia = "4.1.4"
|
ammonia = "4.1.4"
|
||||||
anyhow = "1.0.104"
|
anyhow = "1.0.104"
|
||||||
askama = "0.16.0"
|
askama = "0.16.0"
|
||||||
|
async-trait = "0.1.92"
|
||||||
axum = "0.8.9"
|
axum = "0.8.9"
|
||||||
|
axum-login = { git = "https://github.com/maxcountryman/axum-login.git", rev = "151c72d7a1b4646830f86b4332e6bd6e34d719a7", version = "0.18.0" }
|
||||||
base64 = "0.23.1"
|
base64 = "0.23.1"
|
||||||
clap = { version = "4.6.6", features = ["derive"] }
|
clap = { version = "4.6.6", features = ["derive"] }
|
||||||
dom_smoothie = "0.18.0"
|
dom_smoothie = "0.18.0"
|
||||||
@@ -19,6 +21,7 @@ hmac = "0.13.0"
|
|||||||
image = "0.25.10"
|
image = "0.25.10"
|
||||||
jiff = { version = "0.2.35", features = ["serde"] }
|
jiff = { version = "0.2.35", features = ["serde"] }
|
||||||
libc = "0.2.189"
|
libc = "0.2.189"
|
||||||
|
password-auth = "1.0.0"
|
||||||
rand = "0.10.2"
|
rand = "0.10.2"
|
||||||
reqwest = { version = "0.13.4", default-features = false, features = [
|
reqwest = { version = "0.13.4", default-features = false, features = [
|
||||||
"rustls",
|
"rustls",
|
||||||
@@ -30,6 +33,7 @@ reqwest = { version = "0.13.4", default-features = false, features = [
|
|||||||
"system-proxy",
|
"system-proxy",
|
||||||
] }
|
] }
|
||||||
resvg = "0.48.1"
|
resvg = "0.48.1"
|
||||||
|
rpassword = "7.5.4"
|
||||||
scraper = "0.27.0"
|
scraper = "0.27.0"
|
||||||
serde = { version = "1.0.229", features = ["derive"] }
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
serde_json = "1.0.151"
|
serde_json = "1.0.151"
|
||||||
@@ -44,15 +48,21 @@ sqlx = { version = "0.9.0", default-features = false, features = [
|
|||||||
"tls-rustls-ring",
|
"tls-rustls-ring",
|
||||||
] }
|
] }
|
||||||
thiserror = "2.0.20"
|
thiserror = "2.0.20"
|
||||||
|
time = "0.3.55"
|
||||||
tiny-skia = "0.12.0"
|
tiny-skia = "0.12.0"
|
||||||
tokio = { version = "1.53.1", features = ["full"] }
|
tokio = { version = "1.53.1", features = ["full"] }
|
||||||
tokio-util = { version = "0.7.19", features = ["io"] }
|
tokio-util = { version = "0.7.19", features = ["io"] }
|
||||||
tower-http = { version = "0.7.0", features = ["trace", "fs"] }
|
toml = "1.1.4"
|
||||||
|
toml_edit = "0.25"
|
||||||
|
tower-http = { version = "0.7.0", features = ["trace", "fs", "request-id"] }
|
||||||
|
tower_governor = { version = "0.8.0", features = ["axum"] }
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||||
url = { version = "2.5.8", features = ["serde"] }
|
url = { version = "2.5.8", features = ["serde"] }
|
||||||
|
zip = { version = "6", default-features = false, features = ["deflate"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
figment = { version = "0.10.19", features = ["test", "toml", "env"] }
|
figment = { version = "0.10.19", features = ["test", "toml", "env"] }
|
||||||
|
roxmltree = "0.21.1"
|
||||||
tempfile = "3.27.0"
|
tempfile = "3.27.0"
|
||||||
zip = { version = "6", default-features = false, features = ["deflate"] }
|
tower = { version = "0.5.3", features = ["util"] }
|
||||||
@@ -30,6 +30,7 @@ Gemini (or anything OpenAI-compatible) is a config line plus an API key.
|
|||||||
|
|
||||||
- Full design: [`docs/plans/2026-08-15-the-daily-epub.md`](docs/plans/2026-08-15-the-daily-epub.md)
|
- Full design: [`docs/plans/2026-08-15-the-daily-epub.md`](docs/plans/2026-08-15-the-daily-epub.md)
|
||||||
- Implementation decisions: [`docs/plans/2026-08-15-implementation-notes.md`](docs/plans/2026-08-15-implementation-notes.md)
|
- Implementation decisions: [`docs/plans/2026-08-15-implementation-notes.md`](docs/plans/2026-08-15-implementation-notes.md)
|
||||||
|
- Web dashboard rollout: [`docs/runbooks/web-dashboard-rollout.md`](docs/runbooks/web-dashboard-rollout.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -110,11 +111,18 @@ cargo test # everything is offline; no keys needed
|
|||||||
sudo install -m0755 target/release/daily-epub /usr/local/bin/
|
sudo install -m0755 target/release/daily-epub /usr/local/bin/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The web UI uses Tailwind CSS v4, but Node is only a development dependency: the
|
||||||
|
compiled stylesheet is committed and embedded in the Rust binary. After editing
|
||||||
|
`src/web/tailwind.css`, a web template, or `src/web/static/app.js`, run
|
||||||
|
`npm install` once and then `npm run css`. Commit both the source changes and
|
||||||
|
`src/web/static/app.css`; `npm run css:check` verifies that the committed output
|
||||||
|
is current.
|
||||||
|
|
||||||
### Commands
|
### Commands
|
||||||
|
|
||||||
```
|
```
|
||||||
daily-epub generate [--date YYYY-MM-DD] [--dry-run] [--out DIR] [--max-articles N] [--skip-llm] [--skip-embeddings] [--rescore]
|
daily-epub generate [--date YYYY-MM-DD] [--dry-run] [--out DIR] [--max-articles N] [--skip-llm] [--skip-embeddings] [--rescore]
|
||||||
daily-epub serve # rating endpoints + OPDS catalog + downloads
|
daily-epub serve # public site, private dashboard, OPDS, ratings, downloads
|
||||||
daily-epub profile rebuild # regenerate learned profile adjustments
|
daily-epub profile rebuild # regenerate learned profile adjustments
|
||||||
daily-epub ratings list [--days 90] [--label loved|good|down|cleared]
|
daily-epub ratings list [--days 90] [--label loved|good|down|cleared]
|
||||||
daily-epub ratings set --article 42 --label loved --note "excellent"
|
daily-epub ratings set --article 42 --label loved --note "excellent"
|
||||||
@@ -127,6 +135,14 @@ daily-epub features prune # stale embeddings, old telemetry and assessment
|
|||||||
daily-epub backfill-social [--days 7] # re-poll social scores for recent articles
|
daily-epub backfill-social [--days 7] # re-poll social scores for recent articles
|
||||||
daily-epub db migrate # run migrations (also automatic on every start)
|
daily-epub db migrate # run migrations (also automatic on every start)
|
||||||
daily-epub config check # validate the config, print the resolved roles, keys and paths
|
daily-epub config check # validate the config, print the resolved roles, keys and paths
|
||||||
|
daily-epub users add USER [--admin] [--password-stdin]
|
||||||
|
daily-epub users passwd USER [--password-stdin]
|
||||||
|
daily-epub users role USER user|admin
|
||||||
|
daily-epub users disable USER
|
||||||
|
daily-epub users enable USER
|
||||||
|
daily-epub users list
|
||||||
|
daily-epub users logout USER # revoke all of USER's sessions
|
||||||
|
daily-epub job run NAME # systemd job-unit entry point; normally not run by hand
|
||||||
```
|
```
|
||||||
|
|
||||||
`--dry-run` does everything except deliver: it still ingests, persists entries and
|
`--dry-run` does everything except deliver: it still ingests, persists entries and
|
||||||
@@ -177,6 +193,60 @@ start with `!`. It exits non-zero only on a validation error — a missing key o
|
|||||||
file is a warning, since the run degrades rather than fails — and never opens
|
file is a warning, since the run degrades rather than fails — and never opens
|
||||||
the database or takes the lock, so it is safe to run next to a live `generate`.
|
the database or takes the lock, so it is safe to run next to a live `generate`.
|
||||||
|
|
||||||
|
## Web site and dashboard
|
||||||
|
|
||||||
|
The server is both the public newspaper index and the private operator UI. An
|
||||||
|
anonymous visitor sees titles, authors, sources, metadata, AI summaries, why
|
||||||
|
lines and outbound comment links; article bodies, the Brief, the World Briefing
|
||||||
|
and comments stay private. A signed-in `user`
|
||||||
|
sees complete issues and article chapters and can download artifacts. An
|
||||||
|
`admin` can additionally rate articles and use every `/dashboard/*` page,
|
||||||
|
including settings and jobs. Personalization is shared across accounts for now.
|
||||||
|
|
||||||
|
Accounts are deliberately managed on the host, not in the browser. Usernames
|
||||||
|
are case-insensitive and passwords must be 12–1024 characters. Bootstrap with
|
||||||
|
`daily-epub users add <username> --admin`; use `users passwd`, `role`,
|
||||||
|
`disable`/`enable`, `list`, and `logout` for later administration. Password
|
||||||
|
changes and disabling a user revoke that user's sessions. `/dashboard/users`
|
||||||
|
is a read-only view of roles, status, login times, and open sessions.
|
||||||
|
|
||||||
|
The Jobs page starts only the fixed job catalogue as
|
||||||
|
`daily-epub-job@<name>.service`; the web server never runs the pipeline inside
|
||||||
|
its own process. Install `systemd/daily-epub-job@.service` and the narrowly
|
||||||
|
scoped `systemd/50-daily-epub.rules` polkit rule, and put the server user in
|
||||||
|
`systemd-journal` so the page can show status and its configured journal tail.
|
||||||
|
Set `server.jobs_enabled = false` to make starts unavailable.
|
||||||
|
|
||||||
|
The Settings page derives its fields from `Config`, rewrites `config.toml` in
|
||||||
|
place with `toml_edit`, preserves comments/order and file permissions, validates
|
||||||
|
before an atomic rename, and records attributed history. It re-reads hand edits
|
||||||
|
on the next view. `DAILY_EPUB_*` overrides appear locked, and secrets are shown
|
||||||
|
only as present or absent. Shipped providers (`deepseek`, `anthropic`,
|
||||||
|
`gemini`) cannot be removed because defaults would restore them; leave one
|
||||||
|
unreferenced or edit it. Custom providers can be removed after no `[llm]` role
|
||||||
|
references them. The service needs `/etc/daily-epub` in `ReadWritePaths` for
|
||||||
|
these writes.
|
||||||
|
|
||||||
|
### Web routes
|
||||||
|
|
||||||
|
| Route | Access | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /`, `/issues`, `/issues/{date}`, `/feed.xml` | Public | Latest issue, archive, stripped issue index, and equivalent Atom feed. Signed-in issue views expand to the complete issue. |
|
||||||
|
| `GET /issues/{date}/articles/{id}`, `/world`, `/behind` | User or admin | Private article, World Briefing, and Behind the paper chapters. |
|
||||||
|
| `GET /robots.txt`, `/static/{file}` | Public | Crawler policy and embedded CSS, JavaScript, and favicon. |
|
||||||
|
| `GET/POST /login`, `POST /logout` | Public/session | Sign in and out; login attempts are throttled per client IP. |
|
||||||
|
| `GET /account`, `POST /account/password`, `/account/logout-all` | User or admin | Change the current password or revoke sessions. |
|
||||||
|
| `POST /rate` | Admin | Append an attributed dashboard rating event. |
|
||||||
|
| `GET /dashboard` | Admin | Run, budget, rating, job, and config overview. |
|
||||||
|
| `GET /dashboard/runs[/{id}]`, `/articles[/{id}]`, `/ratings`, `/stats` | Admin | Pipeline history, article explanations, rating contributions/history, and evaluation stats. |
|
||||||
|
| `GET/POST /dashboard/profile`, `POST /dashboard/profile/restore` | Admin | Edit `profile.md`, inspect prompts/adjustments, and restore a version. |
|
||||||
|
| `GET/POST /dashboard/settings`, `POST /dashboard/settings/providers`, `GET /dashboard/settings/history` | Admin | Edit validated configuration and inspect its audit log. |
|
||||||
|
| `GET /dashboard/jobs`, `GET /dashboard/jobs/{id}`, `POST /dashboard/jobs/{name}` | Admin | Start fixed systemd jobs and inspect status and logs. |
|
||||||
|
| `GET /dashboard/users` | Admin | Read-only users and open-session list; edits use the CLI. |
|
||||||
|
| `GET /files/epub/{name}`, `/files/xtc/{name}` | Public if Basic auth is unset; otherwise session or Basic auth | Published downloads. Keeping them public when Basic auth is absent preserves existing OPDS acquisition links. |
|
||||||
|
| `GET /opds`, `/opds/`, `/opds/daily.xml` | Existing optional Basic auth | OPDS acquisition feed. |
|
||||||
|
| `GET /r/...`, `/healthz`, `/issues.json` | Existing policy | HMAC rating links, health, and issue reports. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
@@ -293,7 +363,12 @@ prints what resolved.
|
|||||||
| `server.bind` | `127.0.0.1:3499` | Listen address. |
|
| `server.bind` | `127.0.0.1:3499` | Listen address. |
|
||||||
| `server.public_url` | `https://daily.hallada.net` | Base URL the rating links inside the EPUB are built from. |
|
| `server.public_url` | `https://daily.hallada.net` | Base URL the rating links inside the EPUB are built from. |
|
||||||
| `server.hmac_secret` | — | **`DAILY_EPUB_SERVER__HMAC_SECRET`** (or `DAILY_EPUB_SECRET`). Without it, generated links are rejected with 403. |
|
| `server.hmac_secret` | — | **`DAILY_EPUB_SERVER__HMAC_SECRET`** (or `DAILY_EPUB_SECRET`). Without it, generated links are rejected with 403. |
|
||||||
| `server.basic_auth_user` / `_pass` | unset | Optional Basic auth for `/opds/*` and `/files/*`. |
|
| `server.basic_auth_user` / `_pass` | unset | Optional Basic auth for `/opds/*` and `/files/*`; signed-in web users may download from `/files/*` without Basic auth. |
|
||||||
|
| `server.session_days` | `30` | Sliding lifetime for dashboard login sessions. |
|
||||||
|
| `server.login_attempts` | `10` | Login attempts allowed per IP in one throttle window. |
|
||||||
|
| `server.login_window_minutes` | `15` | Length of the login throttle window. |
|
||||||
|
| `server.jobs_enabled` | `true` | Allow the dashboard to start the fixed systemd job catalogue. |
|
||||||
|
| `server.journal_lines` | `300` | Journal lines shown on a dashboard job page (10–5000). |
|
||||||
|
|
||||||
`[curation.ranking]` holds the ranker's tunables. The learned signals are
|
`[curation.ranking]` holds the ranker's tunables. The learned signals are
|
||||||
gated: `knn` (rated-neighbour preference) ramps from `knn_floor` (8) to
|
gated: `knn` (rated-neighbour preference) ramps from `knn_floor` (8) to
|
||||||
@@ -361,15 +436,21 @@ sudo setfacl -m u:daily-epub:rwx /srv/bookorbit/libraries/daily-epub # or chow
|
|||||||
|
|
||||||
# units
|
# units
|
||||||
sudo install -m0644 systemd/daily-epub.service systemd/daily-epub-generate.service \
|
sudo install -m0644 systemd/daily-epub.service systemd/daily-epub-generate.service \
|
||||||
systemd/daily-epub-generate.timer /etc/systemd/system/
|
systemd/daily-epub-generate.timer systemd/daily-epub-job@.service \
|
||||||
|
/etc/systemd/system/
|
||||||
|
sudo install -m0644 systemd/50-daily-epub.rules /etc/polkit-1/rules.d/
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo systemctl enable --now daily-epub.service daily-epub-generate.timer
|
sudo systemctl enable --now daily-epub.service daily-epub-generate.timer
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Keep `ReadWritePaths` in sync.** Both units run under `ProtectSystem=strict`
|
> **Keep `ReadWritePaths` in sync.** The units run under `ProtectSystem=strict`
|
||||||
> and list the publish directories explicitly:
|
> and list the publish directories explicitly; the server additionally lists
|
||||||
|
> `/etc/daily-epub` so Settings can replace `config.toml`:
|
||||||
> ```
|
> ```
|
||||||
|
> # generate and job units
|
||||||
> ReadWritePaths=/home/thallada/bookorbit/books/daily-epub /var/lib/daily-epub/xtc
|
> ReadWritePaths=/home/thallada/bookorbit/books/daily-epub /var/lib/daily-epub/xtc
|
||||||
|
> # server unit
|
||||||
|
> ReadWritePaths=/home/thallada/bookorbit/books/daily-epub /var/lib/daily-epub/xtc /etc/daily-epub
|
||||||
> ```
|
> ```
|
||||||
> If you change `publish.epub_dir` or `publish.xtc_dir` in the config, change
|
> If you change `publish.epub_dir` or `publish.xtc_dir` in the config, change
|
||||||
> these lines too and `systemctl daemon-reload`, or publishing fails with
|
> these lines too and `systemctl daemon-reload`, or publishing fails with
|
||||||
@@ -383,6 +464,9 @@ Node for the XTC converter, whose JIT needs W+X pages.
|
|||||||
The rating links baked into every article chapter point at
|
The rating links baked into every article chapter point at
|
||||||
`server.public_url`, so `daily.hallada.net` must resolve and serve TLS from the
|
`server.public_url`, so `daily.hallada.net` must resolve and serve TLS from the
|
||||||
internet (e-readers tap these links). The OPDS catalog rides on the same host.
|
internet (e-readers tap these links). The OPDS catalog rides on the same host.
|
||||||
|
The login throttle's `SmartIpKeyExtractor` trusts `X-Forwarded-For`; this is
|
||||||
|
safe only because the configured bind address is loopback and nginx is the
|
||||||
|
only process that can reach it.
|
||||||
With an existing certificate, a minimal nginx site is:
|
With an existing certificate, a minimal nginx site is:
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
@@ -698,5 +782,6 @@ From spec §7, plus what implementation turned up:
|
|||||||
`triage:` / `assess:` log lines (`… 3 rejected (2 recovered on gemini)`), the
|
`triage:` / `assess:` log lines (`… 3 rejected (2 recovered on gemini)`), the
|
||||||
` · N rejected` suffix on the `curation:` line, or
|
` · N rejected` suffix on the `curation:` line, or
|
||||||
`sqlite3 /var/lib/daily-epub/daily-epub.db "select stage, count(*) from article_assessments where kind = 'provider_rejected' group by stage"`.
|
`sqlite3 /var/lib/daily-epub/daily-epub.db "select stage, count(*) from article_assessments where kind = 'provider_rejected' group by stage"`.
|
||||||
- **One reader, one issue per day.** There is no multi-user support and no
|
- **One shared reader profile, one issue per day.** Multiple login accounts and
|
||||||
weekly/retrospective edition (spec §6).
|
`user`/`admin` roles are supported, but they share one personalization model;
|
||||||
|
there is no per-user issue or weekly/retrospective edition (spec §6).
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
[general]
|
[general]
|
||||||
# EPUB chapter templates live next to the builder that renders them
|
# EPUB chapter templates live next to the builder that renders them
|
||||||
# (implementation notes §11).
|
# (implementation notes §11).
|
||||||
dirs = ["src/epub/templates"]
|
dirs = ["src/epub/templates", "src/web/templates"]
|
||||||
+9
-3
@@ -208,9 +208,15 @@ format = "xtch" # xtc (1-bit) | xtch (grayscale)
|
|||||||
settings = "/etc/daily-epub/xtc-settings.json"
|
settings = "/etc/daily-epub/xtc-settings.json"
|
||||||
|
|
||||||
[server]
|
[server]
|
||||||
bind = "127.0.0.1:3499"
|
bind = "127.0.0.1:3499" # listen address; keep loopback when trusting proxy IP headers
|
||||||
public_url = "https://daily.hallada.net"
|
public_url = "https://daily.hallada.net" # base URL for EPUB rating links and same-origin checks
|
||||||
|
session_days = 30 # sliding lifetime for web login sessions
|
||||||
|
login_attempts = 10 # login POSTs allowed per client IP in one throttle window
|
||||||
|
login_window_minutes = 15 # length of the login throttle window
|
||||||
|
jobs_enabled = true # let the dashboard start daily-epub-job@<name>.service
|
||||||
|
journal_lines = 300 # job-page journal tail; valid range 10..=5000
|
||||||
# hmac_secret via DAILY_EPUB_SERVER__HMAC_SECRET env (32+ random bytes)
|
# hmac_secret via DAILY_EPUB_SERVER__HMAC_SECRET env (32+ random bytes)
|
||||||
# Optional Basic auth for the XTC OPDS feed and file downloads:
|
# Optional Basic auth for /opds/* and /files/*; without it those existing
|
||||||
|
# routes remain public. A signed-in web user can download without Basic auth.
|
||||||
# basic_auth_user = "daily"
|
# basic_auth_user = "daily"
|
||||||
# basic_auth_pass = "..."
|
# basic_auth_pass = "..."
|
||||||
@@ -221,3 +221,59 @@ implementer needs that are easy to get wrong:
|
|||||||
`assessment_reuse_days`, `--rescore` ignores it, and `admit::hygiene` never treats it as a low
|
`assessment_reuse_days`, `--rescore` ignores it, and `admit::hygiene` never treats it as a low
|
||||||
score. Because a recovered article's row carries the editor's model, reuse accepts rows whose
|
score. Because a recovered article's row carries the editor's model, reuse accepts rows whose
|
||||||
`model` is either configured model (`triage::reusable_models`).
|
`model` is either configured model (`triage::reusable_models`).
|
||||||
|
|
||||||
|
## Web dashboard (2026-09-03)
|
||||||
|
|
||||||
|
Verified on the production host and against the implemented dependency graph on 2026-09-03:
|
||||||
|
|
||||||
|
- **Host authorization and units:** polkit 124 supports JavaScript rules. The installed rule must
|
||||||
|
grant user `daily-epub` only `org.freedesktop.systemd1.manage-units`, verb `start`, for units
|
||||||
|
matching `^daily-epub-job@[a-z0-9-]+\.service$`. `daily-epub.service` now needs
|
||||||
|
`SupplementaryGroups=systemd-journal` to read job logs and `/etc/daily-epub` in
|
||||||
|
`ReadWritePaths` to atomically replace `config.toml`. The job template deliberately omits
|
||||||
|
`MemoryDenyWriteExecute` because generate jobs can launch Node's JIT; the server retains it.
|
||||||
|
- **Settings writes:** direct `toml_edit 0.25` performs typed, comment/order-preserving updates.
|
||||||
|
A candidate file is loaded through `Config::load` before its permissions are copied and it is
|
||||||
|
renamed over the original. The process therefore needs write access to the containing
|
||||||
|
directory, not only the file. Every changed dotted key is written to `config_changes`.
|
||||||
|
- **Authentication stack:** `axum-login` is pinned to git revision
|
||||||
|
`151c72d7a1b4646830f86b4332e6bd6e34d719a7`, whose graph contains `tower-sessions 0.15`.
|
||||||
|
The local `SqliteSessionStore` uses this crate's existing sqlx 0.9 pool because the published
|
||||||
|
tower SQLx store is incompatible. `password-auth 1.0` supplies Argon2id PHC hashes;
|
||||||
|
`tower_governor 0.8` throttles login POSTs. Its smart IP extractor trusts forwarded headers,
|
||||||
|
so production must bind to loopback and accept traffic only from the configured reverse proxy.
|
||||||
|
- **Test environment:** router tests use `tower::ServiceExt::oneshot`, temp SQLite databases and
|
||||||
|
`MockRunner`; they do not bind or invoke systemd. This sandbox forbids loopback listeners, so
|
||||||
|
the four `curate::llm::tests::anthropic_*` tests, the three OpenAI tests using the same fake
|
||||||
|
listener, `extract::tests::relative_urls_resolve_against_the_url_we_landed_on`, the five
|
||||||
|
listener-based `server::tests::*`, and `tests/m7_server.rs` are filtered only for sandbox runs.
|
||||||
|
The complete suite is expected to run outside the sandbox.
|
||||||
|
|
||||||
|
Implementation-time decisions recorded while landing dashboard steps 1–7:
|
||||||
|
|
||||||
|
- `/files/*` remains public when Basic auth is not configured, preserving existing OPDS
|
||||||
|
acquisition behavior. With Basic auth configured, either valid Basic credentials or any valid
|
||||||
|
web session authorizes a download. This intentionally resolves the plan's conflicting request
|
||||||
|
to redirect unauthenticated downloads in favor of its compatibility acceptance criterion.
|
||||||
|
- Final reports are attached to an issue after `finish_run`, when publish timing and status are
|
||||||
|
complete. Issue snapshots omit article bodies and rehydrate them from `articles`; old rows use
|
||||||
|
the reduced fallback renderer. The CLI password prompt uses `rpassword` after it became
|
||||||
|
available to the orchestrator.
|
||||||
|
- Flash handlers extract the exact tower session installed by the auth layer through
|
||||||
|
`Extension<Session>`. Dashboard `down` forms persist the established `not_for_me` label.
|
||||||
|
Ratings-page feed credit includes rating decay because that is what `signals::feed_rates`
|
||||||
|
actually uses; the stored prompt verdict count is inferred from the prompt text because
|
||||||
|
`TasteProfile.verdicts` is not persisted.
|
||||||
|
- Dynamic list SQL is assembled only from fixed fragments and allow-listed sort/filter names,
|
||||||
|
wrapped in sqlx 0.9's `AssertSqlSafe`; all user values remain bound parameters. Funnel bars
|
||||||
|
count rows that reached each stage because a row stores the stage where it stopped. SVG/meter
|
||||||
|
attributes replace inline styles under the CSP. Article pages omit the nominal extract method
|
||||||
|
because database reconstruction currently hard-codes it and would display misleading data.
|
||||||
|
- Shipped providers cannot be removed: deleting one would cause `Config::default()` to restore
|
||||||
|
it. They remain editable and may be unreferenced; custom providers are removable. An absent
|
||||||
|
setting already equal to its submitted default stays absent. Settings that are captured while
|
||||||
|
building the server, session, or throttle layers carry restart notices.
|
||||||
|
- A job start reloads a hand-edited config before inserting and starting the unit, retaining the
|
||||||
|
last-good config if reload fails. The offline lifecycle test uses `features-prune`; generate
|
||||||
|
run-id linkage is covered separately. CLI duration statistics retain finished dry runs for
|
||||||
|
byte-identical output, while dashboard run series and overview sparklines exclude them.
|
||||||
@@ -0,0 +1,815 @@
|
|||||||
|
# Web site and operator dashboard
|
||||||
|
|
||||||
|
**Date:** 2026-09-03
|
||||||
|
**Repository:** `thallada/the-daily-epub`
|
||||||
|
**Status:** implementation plan, ready to execute
|
||||||
|
**Builds on:** `docs/plans/2026-09-02-personalized-curation-v2.md` (the ranker this dashboard inspects; its §23 deferred a "web dashboard for ratings and knobs" — this is that plan)
|
||||||
|
|
||||||
|
This plan is written so that a fresh implementation agent can execute it end to end. It records the decisions made in the 2026-09-03 brainstorming session with the operator, the verified facts about the current code and the production host, the target design in enough detail to code from, and the order in which to land it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Decisions recorded from the brainstorm (2026-09-03)
|
||||||
|
|
||||||
|
These are settled. Do not reopen them during implementation.
|
||||||
|
|
||||||
|
| Topic | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Purpose | Publicly, `https://daily.hallada.net` is an aggregator: every issue as a stripped HTML page of titles linking out, authors, sources and comment links. Privately, after login, it is the operator's dashboard: every article ever considered and why it was or was not in the paper, the full rating history with edits, every issue in full HTML with downloads, all config knobs, and pipeline actions. |
|
||||||
|
| Frontend | Server-rendered **askama** templates plus one hand-written CSS file and a few hundred lines of plain JavaScript. No node build, no SPA, no framework; assets are embedded in the binary with `include_str!`. |
|
||||||
|
| Auth | Username + password, sessions in a SQLite table, one `HttpOnly; Secure; SameSite=Lax` cookie. Users are created from the CLI (`daily-epub users add`). No third-party identity, no passkeys. **Use the ecosystem, not hand-rolled code**: `axum-login` over `tower-sessions` for login/logout/session lifecycle and route guards, `password-auth` for argon2 hashing, `tower_governor` for the login throttle. axum-login is taken from **git at the pinned rev `151c72d7a1b4646830f86b4332e6bd6e34d719a7`** (`main`, 2026-05-07: tower-sessions 0.15 and the finalized `Require` API), not from the 0.18.0 crates.io release; §2 records why and what was verified. The only auth code we write is a ~60-line `SessionStore` over our own sqlx pool (the official store pins sqlx 0.8; we are on 0.9) and the `AuthnBackend` glue (§6). |
|
||||||
|
| Roles | `user` and `admin`. **Signed-in users of either role** see every issue in full HTML (Brief, summaries, article bodies, discussions, World Briefing, Behind the paper) and can download the EPUB/XTC files. **Admins** additionally rate, browse the dashboard, edit settings and the profile, and start jobs. |
|
||||||
|
| Copyright boundary | Anonymous visitors see titles, metadata, AI summaries and `why` lines, but never the Brief, article bodies, comments or World Briefing. The public renderer takes a dedicated `PublicIssue` type that cannot carry those private fields. |
|
||||||
|
| Personalization | One shared algorithm for now. `rating_events` gains a nullable `user_id` so a per-user algorithm is possible later; nothing else is per user. HMAC links from the EPUB stay unattributed (`user_id NULL`, `source = 'epub'`). |
|
||||||
|
| Settings | Every key of `config.toml`, grouped by table, editable from the UI and written back **in place with comments preserved** (`toml_edit`). A key overridden by a `DAILY_EPUB_*` environment variable is shown locked. API keys and the HMAC secret are shown as present/absent only. The page re-reads the file on every view, so hand edits show up. `data/profile.md` gets an editor with version history. |
|
||||||
|
| Actions | A Jobs page starts pipeline work through **systemd**: `systemctl start daily-epub-job@<name>.service`, permitted by a polkit rule for the `daily-epub` user. The server never runs the pipeline in-process (its unit has `MemoryDenyWriteExecute=yes`, which the XTC converter's Node JIT cannot live with). |
|
||||||
|
| Feed | A public Atom feed at `/feed.xml` with one entry per issue, carrying the same stripped list as the public page. |
|
||||||
|
| Existing routes | `/r/…`, `/opds…`, `/files/…`, `/healthz`, `/issues.json` keep working unchanged. `/files/*` additionally accepts a session cookie. |
|
||||||
|
| Retention | HTML issue pages are built from the database, so they outlive the EPUB files (`retention_days`); download links appear only while the file exists. The operator should raise `curation.ranking.telemetry_retention_days` if they want candidate history beyond 180 days; this plan does not change the default. |
|
||||||
|
| Design | Newspaper masthead in a serif, dashboard tables in the system sans stack, no external fonts or CDNs, light and dark via `prefers-color-scheme`, every wide table scrolls inside its own container. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Read these first
|
||||||
|
|
||||||
|
- `docs/plans/2026-08-15-implementation-notes.md` — conventions (runtime sqlx queries, `jiff`, error style, askama, no network in tests). All of it applies here.
|
||||||
|
- `docs/plans/2026-09-02-personalized-curation-v2.md` §6 (feedback), §7 (tables), §9–§13 (signals, admission, utility, editor — the numbers the dashboard explains), §15 (explain/stats), §19 (config).
|
||||||
|
- `src/server.rs` — the existing axum router, `AppState`, `handle_rating`, Basic auth, `safe_join`, the tiny e-ink pages. This plan extends this file's router and moves nothing.
|
||||||
|
- `src/auth.rs` — HMAC rating links (unchanged).
|
||||||
|
- `src/config.rs` — `Config`, `Config::load`, `Config::resolve_path`, `validate`, `check_report`, `providers_redacted`, `stale_toml_error`, `ENV_PREFIX`/`ENV_SPLIT`.
|
||||||
|
- `src/db.rs` — `Db`, `current_ratings`, `append_rating_event`, `upsert_issue`, `replace_issue_articles`, `start_run`/`finish_run`, `provider_spend_for_utc_day`, `get_article`.
|
||||||
|
- `src/curate/telemetry.rs` — `ExplainRow`, `resolve_run`, `explain_row`, `near_misses`, `render_explain`, `stats`, `SignalsJson`. The dashboard renders the same data as HTML.
|
||||||
|
- `src/curate/signals.rs` — `decay`, `gate`, `direct_feeds`, `PreferenceState::load/summary`, used by the ratings page.
|
||||||
|
- `src/curate/profile/mod.rs` — `parse_profile_str`, `load_profile`, `load_or_build`, `rebuild`, `KV_LEARNED_ADJUSTMENTS`.
|
||||||
|
- `src/epub/chapters.rs`, `src/epub/templates/` — what an issue contains and how it is rendered; `prepare_body`, `social_line`, `behind_*_line`, `render_in_this_issue`.
|
||||||
|
- `src/epub/fixtures.rs` — `fixtures::issue()` builds a full `Issue` for tests; the web tests use it.
|
||||||
|
- `src/main.rs` — CLI layout (`clap` derive), `lock_holder`, `cmd_*` helpers.
|
||||||
|
- `systemd/`, `README.md` §"Reverse proxy", `docs/runbooks/curation-v2-migration.md` — how the host is set up.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Verified facts (2026-09-03)
|
||||||
|
|
||||||
|
Host and deployment (checked on the production box, which is also the dev checkout):
|
||||||
|
|
||||||
|
- nginx site `daily` proxies `/` → `http://127.0.0.1:3499` with `Host`, `X-Forwarded-For` and `X-Forwarded-Proto` set, TLS from Let's Encrypt, HTTP→HTTPS redirect. `snippets/security-headers.conf` adds `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`, a `Permissions-Policy`, and HSTS. **No Content-Security-Policy**, so inline `<script>`/`<style>` would work, but this plan serves CSS/JS from `/static/` anyway and adds its own CSP header from the app (§16).
|
||||||
|
- `daily-epub.service` and `daily-epub-generate.timer` are active. Both units run as `daily-epub` (uid 995, no supplementary groups) with `ProtectSystem=strict`, `ProtectHome=read-only`, `ReadWritePaths=/home/thallada/bookorbit/books/daily-epub /var/lib/daily-epub/xtc`, `StateDirectory=daily-epub`. **`/etc/daily-epub` is therefore read-only inside the server** — the settings writer needs `ReadWritePaths=/etc/daily-epub` (§18). The server unit alone has `MemoryDenyWriteExecute=yes`.
|
||||||
|
- `/etc/daily-epub/config.toml` is `0640 daily-epub:daily-epub` (per the unit header and runbook); `/etc/daily-epub/env` holds the API keys and `DAILY_EPUB_SERVER__HMAC_SECRET`.
|
||||||
|
- polkit is version 124 (JavaScript rules in `/etc/polkit-1/rules.d/` are supported). `journalctl -u <unit>` as `daily-epub` requires membership in `systemd-journal` (gid 999).
|
||||||
|
- `systemctl show -p ActiveState,SubState,Result,ExecMainStatus,ExecMainStartTimestamp,ExecMainExitTimestamp <unit>` works unprivileged and is the status source for jobs.
|
||||||
|
|
||||||
|
Code:
|
||||||
|
|
||||||
|
- `issues.report_json` is **never written**: `pipeline::record_issue` passes `None` to `upsert_issue`, and nothing else sets it, so `GET /issues.json` returns `"report": null` for every issue. `runs` keeps only counters. This plan fixes both (§5.3).
|
||||||
|
- The World Briefing and the discussion chapters are built at run time and never persisted; `Issue` (with `Lineup`, `Editorial`, `WorldBriefing`, `Colophon`, `BehindThePaper`, `Pick.discussion`) derives `Serialize`/`Deserialize`. Article bodies live in `articles.content_html` for every article ever ingested; nothing prunes `articles`.
|
||||||
|
- `Config::load(explicit)` reads `--config` or `./config.toml`, then `DAILY_EPUB_*` env (`ENV_PREFIX = "DAILY_EPUB_"`, `ENV_SPLIT = "__"`), accepts `DAILY_EPUB_SECRET` as an alias for `server.hmac_secret`, then `validate()`. `Config::resolve_path` returns the path `load` used. `Config` and every sub-struct derive `Serialize`/`Deserialize` with `deny_unknown_fields`; `Config::default()` is the shipped example key for key (a test enforces it).
|
||||||
|
- `main::serve` receives `(config, db)` only; the config path is not passed through. `AppState { db, config }` is cloned per request.
|
||||||
|
- Crates already present: `axum 0.8`, `askama 0.16`, `tower-http 0.7` (`trace`, `fs`), `sqlx 0.9` (sqlite, runtime queries), `jiff`, `serde_json`, `sha2`, `hex`, `hmac`, `base64`, `rand 0.10`, `ammonia`, `figment`, `libc`. `toml_edit` and `toml` are in `Cargo.lock` transitively (via figment) but not direct dependencies; `tower` is transitive only.
|
||||||
|
- Current crates.io versions (checked with `cargo search` on 2026-09-03): `toml_edit 0.25.13`, `tower 0.5.3`, `askama 0.16.0`. Add with `cargo add` and let Cargo resolve; do not hand-pin older versions.
|
||||||
|
|
||||||
|
Auth ecosystem (crates.io dependency metadata and docs.rs, checked 2026-09-03):
|
||||||
|
|
||||||
|
- **`axum-login` 0.18.0** (crates.io, released 2025-07-20, ~1M downloads): `axum ^0.8.1`, `tower-sessions ^0.14` (re-exported as `axum_login::tower_sessions`), `tower-cookies ^0.11`. Exports `AuthUser` (`id()`, `session_auth_hash()`), `AuthnBackend` (`authenticate`, `get_user`), `AuthzBackend` (`get_user_permissions`), the `AuthSession` extractor, `AuthManagerLayerBuilder::new(backend, session_layer).build()`, and the `login_required!`, `permission_required!`, `predicate_required!` route-layer macros. Login cycles the session id (fixation defence) and stores the user's `session_auth_hash`, so changing a password invalidates every session of that user.
|
||||||
|
- **`axum-login` `main` at `151c72d7a1b4646830f86b4332e6bd6e34d719a7`** (2026-05-07, "Upgrade tower-sessions to 0.15.0 (#315)"; 13 commits past `v0.18.0`, none since; the default branch is `main`, there is no `master`). What changed since 0.18.0 (CHANGELOG "Unreleased" + diff): `tower-sessions ^0.15`; the new builder-based `require` module (`Require::<Backend>::builder().decision(PermissionsPredicate).unauthenticated(RedirectHandler::new().login_url(..).redirect_field(..)).build()`) with the macros rewritten as thin wrappers over it; `permission_required!` answers 401 when unauthenticated (302 to `login_url` when one is given) and 403 only when authenticated without the permission; `AuthSession` is now `Clone` over an `Arc<Mutex<_>>` with `login(&self, &user)`, `logout(&self)`, `authenticate(&self, creds)`, **`user().await -> Option<User>` (a method, no longer a public field)** and `backend()`; the `session` field is no longer public (use tower-sessions' own `Session` extractor for flashes); the `async-trait` dependency is gone (native async fn in traits). The user id is stored in the session under the key `"axum-login.data"` (configurable with `AuthManagerLayerBuilder::with_data_key`).
|
||||||
|
**Verified locally on 2026-09-03** (nightly 1.100, 2026-09-01) at that rev: `cargo clippy --lib --all-features` clean; `cargo test --doc` 15/15; `cargo test --lib` 79/79 once the unit-test modules use `MemoryStore` instead of `tower_sessions_sqlx_store::SqliteStore`; `tests/feature_gates.rs` passes. A scratch consumer crate depending on the git rev together with `tower-sessions 0.15`, `sqlx 0.9` (sqlite, single copy, single `libsqlite3-sys 0.37`), `axum 0.8.9`, `tower_governor 0.8`, `password-auth 1.0` and the §6.2 store implementation compiles (`cargo check`, `cargo tree -d` shows no second sqlx or sqlite).
|
||||||
|
**Why the repository's CI is red** (the push run for that commit fails at the `check` job's clippy step and skips the tests): the crate's dev-dependency and its examples use `tower-sessions-sqlx-store 0.15.0`, which depends on `tower-sessions-core 0.14`; against `tower-sessions 0.15` that store no longer implements the (0.15) `SessionStore` trait, so the test targets and the examples fail to compile and the integration tests (which spawn the example binaries) fail. That is a harness/examples problem in the stores repository lagging behind, not a library defect; our own store is written against whatever `tower-sessions` axum-login re-exports, so it is unaffected either way.
|
||||||
|
**Maintenance status** (discussion #330, opened 2026-07-12 by the author): "I don't have the time I would like to maintain axum-login"; three volunteers (one collaborator offering co-maintenance, one asking about updating the stores for 0.15 on 2026-08-06) and no release or new commit since 2026-06. Expect a 0.19 eventually; pinning the rev means the plan's API is the one 0.19 will ship, and a later switch to the crates.io release is a one-line `Cargo.toml` change.
|
||||||
|
**Fallback** if the git dependency is ever unwanted: `axum-login = "0.18"` from crates.io with `tower-sessions 0.14`; the only source differences are `auth.user` (field) instead of `auth.user().await`, `&mut auth` for `login`/`logout`, and `pub session` on `AuthSession`.
|
||||||
|
- **`tower-sessions` 0.15.0** (`SessionManagerLayer::new(store).with_secure(..).with_same_site(..).with_name(..).with_expiry(Expiry::OnInactivity(time::Duration))`): lazy — no cookie is set unless a handler touches the session, so anonymous pages stay cookie-free. `SessionStore` is a trait with `save`, `load`, `delete` (and a provided `create`) over `Record { id: Id, data: HashMap<String, serde_json::Value>, expiry_date: time::OffsetDateTime }`; the trait is unchanged from 0.14. 0.15 over 0.14 is only the `rand 0.9` bump and a memory-ordering fix in the session's modified-flag handling (#254), which is the other reason to take the git rev of axum-login rather than 0.18.0.
|
||||||
|
- **`tower-sessions-sqlx-store` 0.15.0** pins **`sqlx ^0.8`** (and `tower-sessions-core 0.14`). This crate is on `sqlx 0.9`; two sqlx versions would each bundle SQLite and collide on the `links = "sqlite3"` native key, so the official store **cannot** be used. A store over our own pool is ~60 lines (§6.2).
|
||||||
|
- **`password-auth` 1.0.0** (`generate_hash(password) -> String` PHC/argon2id, `verify_password(password, hash) -> Result<(), VerifyError>`; `argon2 ^0.5` inside). The crate axum-login's own SQLite example uses.
|
||||||
|
- **`tower_governor` 0.8.0** (`governor 0.10`, `axum` feature for `axum ^0.8`): `GovernorConfigBuilder::default().per_second(n).burst_size(n).key_extractor(SmartIpKeyExtractor)`, `GovernorLayer::new(config)`, a periodic `limiter.retain_recent()` cleanup, and `into_make_service_with_connect_info::<SocketAddr>()` on the server. `SmartIpKeyExtractor` reads `x-forwarded-for`, `x-real-ip`, `forwarded`, then the peer address.
|
||||||
|
- **`axum_csrf` 0.11.0** (`axum-core ^0.5`, `cookie 0.18`): a signed-cookie token with a `CsrfToken` extractor (`authenticity_token()`, `verify()`). Compatible, but it is a second cookie plus per-form plumbing that `SameSite=Lax` and a fetch-metadata origin check already cover; kept as the fallback option (§6.4).
|
||||||
|
- **`axum_session` 0.21 / `axum_session_sqlx` 0.11 / `axum_session_auth` 0.21** (released 2026-08-10): match `sqlx ^0.9` and `axum ^0.8.9` exactly and would need no custom store, but they add `chrono`, `dashmap` and `async-recursion`, create their own table outside our migrations, cache users in memory (`cache_clear_user` after a role change), and set a cookie for every visitor unless the session mode is changed. Rejected in favour of the larger and lazier `axum-login`/`tower-sessions` pair; recorded here so the choice is not re-litigated.
|
||||||
|
- `askama.toml` has `dirs = ["src/epub/templates"]`; the EPUB templates are `.xhtml` with `escape = "html"` declared per template. `.html` templates are HTML-escaped by default.
|
||||||
|
- `telemetry::resolve_run` treats the latest run with `status != 'dry_run'` as the run of a date; `RunStatus` is `running | ok | degraded | failed | dry_run`.
|
||||||
|
- `db::current_ratings_including_cleared(lookback_days)` returns the latest explicit event per article joined with title, feed, latest summary and deep facets. `profile::MAX_RATINGS_IN_REBUILD` (200) and `RATINGS_LOOKBACK_DAYS` (36,500) are private constants; make the first `pub`.
|
||||||
|
- `signals::decay(age_days, half_life)`, `signals::gate(n, floor, full)`, `signals::direct_feeds(&article)` and `PreferenceState::load(..)` + `summary()` exist and are `pub`.
|
||||||
|
- Rating attribution today: `RatingEvent { source: "epub" | "cli" | "migration", .. }`; `"dashboard"` is already reserved in the plan's `source` comment.
|
||||||
|
- `publish::issue_filename(date, edition, "epub")` gives the EPUB names the OPDS feed and `/files/epub/{name}` use; `issues.epub_path/x4_path/xtc_path` hold full paths.
|
||||||
|
- `server.rs` unit tests call handlers and helpers directly (12 tests); `tests/m7_server.rs` drives the real binary over TCP with a hand-rolled HTTP client and an env-only configuration. Both patterns continue.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Site map and access levels
|
||||||
|
|
||||||
|
| Route | Anonymous | Signed in (user) | Admin |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `GET /` | latest issue, public rendering | latest issue, full rendering | same + rating buttons |
|
||||||
|
| `GET /issues` | archive by month | same | same |
|
||||||
|
| `GET /issues/{date}` | public issue page | full issue page + downloads | same + rating buttons |
|
||||||
|
| `GET /issues/{date}/articles/{article_id}` | 302 → `/login?next=` | article chapter (body, discussion) | same + rating buttons |
|
||||||
|
| `GET /issues/{date}/world`, `/behind` | 302 → login | the chapter | same |
|
||||||
|
| `GET /feed.xml` | Atom, public content | same | same |
|
||||||
|
| `GET /robots.txt` | allow `/`, `/issues`; disallow `/dashboard`, `/login`, `/files`, `/r`, `/opds` | | |
|
||||||
|
| `GET /static/{file}` | `app.css`, `app.js`, `favicon.svg` | | |
|
||||||
|
| `GET /login`, `POST /login`, `POST /logout` | login form | | |
|
||||||
|
| `GET /account`, `POST /account/password` | 302 → login | change own password, sign out everywhere | same |
|
||||||
|
| `GET /files/epub/{name}`, `/files/xtc/{name}` | Basic auth (existing) | session cookie **or** Basic auth | same |
|
||||||
|
| `POST /rate` | 403 | 403 | append a rating event |
|
||||||
|
| `GET /dashboard` | 302 → login | 403 page | overview |
|
||||||
|
| `GET /dashboard/runs`, `/dashboard/runs/{id}` | | 403 | runs list, run detail with candidate table |
|
||||||
|
| `GET /dashboard/articles`, `/dashboard/articles/{id}` | | 403 | every article, article detail |
|
||||||
|
| `GET /dashboard/ratings`, `POST /rate` | | 403 | rating history, contributions, edit |
|
||||||
|
| `GET /dashboard/profile`, `POST /dashboard/profile`, `POST /dashboard/profile/restore` | | 403 | `profile.md` editor, prompt, history |
|
||||||
|
| `GET /dashboard/stats` | | 403 | the `stats` numbers as tables + sparklines |
|
||||||
|
| `GET /dashboard/settings`, `POST /dashboard/settings`, `POST /dashboard/settings/providers`, `GET /dashboard/settings/history` | | 403 | every config key |
|
||||||
|
| `GET /dashboard/jobs`, `POST /dashboard/jobs/{name}`, `GET /dashboard/jobs/{id}` | | 403 | start and watch jobs |
|
||||||
|
| `GET /dashboard/users` | | 403 | list users and sessions (read-only; CLI edits) |
|
||||||
|
| `GET /r/{date}/{article_id}/{vote}?t=` | HMAC (existing) | | |
|
||||||
|
| `GET /opds…`, `/healthz`, `/issues.json` | existing behaviour | | |
|
||||||
|
|
||||||
|
Every `/dashboard/*` page is `Cache-Control: no-store`. Every HTML response carries `Vary: Cookie`.
|
||||||
|
|
||||||
|
Dates in paths are `YYYY-MM-DD` issue dates; ids are database ids. An unknown date or id is a 404 page in the site layout, not a bare string.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Architecture
|
||||||
|
|
||||||
|
### 4.1 Module layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/server.rs existing router + handlers; `router()` now merges `web::router()`
|
||||||
|
src/web/
|
||||||
|
├── mod.rs WebState, `Html<T>` response helper, WebError, layout context, pagination, time formatting
|
||||||
|
├── session.rs `SqliteSessionStore` (tower-sessions `SessionStore` over our pool), `Backend` (axum-login `AuthnBackend` + `AuthzBackend`), `Viewer` helper, origin check middleware
|
||||||
|
├── users.rs User/Role types, password-auth hash/verify wrappers, `daily-epub users …` implementation
|
||||||
|
├── public.rs `/`, `/issues`, `/issues/{date}` (public branch), `/feed.xml`, `/robots.txt`, `PublicIssue`
|
||||||
|
├── issue.rs IssueView loader (issue_json → fallback), full issue page, article/world/behind pages, downloads gate
|
||||||
|
├── rate.rs `POST /rate` (form + JSON), rating widget data
|
||||||
|
├── dashboard/
|
||||||
|
│ ├── mod.rs overview
|
||||||
|
│ ├── runs.rs runs list, run detail, candidate table, config diff
|
||||||
|
│ ├── articles.rs articles list, article detail
|
||||||
|
│ ├── ratings.rs ratings page, contributions
|
||||||
|
│ ├── profile.rs profile editor, history, prompt view
|
||||||
|
│ ├── stats.rs stats page (renders `telemetry::StatsData`)
|
||||||
|
│ ├── settings.rs settings schema, form, toml_edit writer, providers, history
|
||||||
|
│ ├── jobs.rs jobs page, JobRunner trait, systemctl/journalctl runner
|
||||||
|
│ └── users.rs users list page
|
||||||
|
├── static/
|
||||||
|
│ ├── app.css
|
||||||
|
│ ├── app.js
|
||||||
|
│ └── favicon.svg
|
||||||
|
└── templates/ askama `.html` templates (§4.3)
|
||||||
|
src/jobs.rs job catalogue (name → CLI equivalent), `daily-epub job run`, jobs table lifecycle
|
||||||
|
migrations/0004_web.sql
|
||||||
|
systemd/daily-epub-job@.service
|
||||||
|
systemd/50-daily-epub.rules polkit rule
|
||||||
|
docs/runbooks/web-dashboard-rollout.md
|
||||||
|
```
|
||||||
|
|
||||||
|
`askama.toml` becomes `dirs = ["src/epub/templates", "src/web/templates"]`. Web templates end in `.html`; there is no name collision with the `.xhtml` EPUB templates.
|
||||||
|
|
||||||
|
### 4.2 State
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct AppState {
|
||||||
|
pub db: Db,
|
||||||
|
pub config: Arc<RwLock<Arc<Config>>>, // swapped after a settings save or when the file's mtime changes
|
||||||
|
pub config_path: Option<PathBuf>, // Config::resolve_path(cli.config); None ⇒ settings read-only
|
||||||
|
pub web: Arc<WebState>,
|
||||||
|
}
|
||||||
|
pub struct WebState {
|
||||||
|
pub jobs: Arc<dyn JobRunner>, // SystemdRunner in production, MockRunner in tests
|
||||||
|
pub started_at: Timestamp,
|
||||||
|
pub config_mtime: Mutex<Option<SystemTime>>,
|
||||||
|
}
|
||||||
|
impl AppState { pub fn config(&self) -> Arc<Config> } // read-lock + clone the Arc
|
||||||
|
```
|
||||||
|
|
||||||
|
`main::serve` gains the config path: `server::serve(config, config_path, db)`. Handlers that today read `state.config.server.*` call `state.config()` instead (mechanical change; keep the tests).
|
||||||
|
|
||||||
|
**Config reload.** `WebState::reload_if_changed(&AppState)` compares the file's mtime with the cached one on every `/dashboard/settings` GET and every job start, re-runs `Config::load(config_path)` and swaps the Arc when it changed and validates. A file that fails validation is reported on the settings page (`! config.toml on disk does not load: <error>`) and the previous config stays live.
|
||||||
|
|
||||||
|
### 4.3 Templates and rendering
|
||||||
|
|
||||||
|
- `layout.html`: `<head>` (charset, viewport, `<title>`, `/static/app.css?v={VERSION}`, Atom `<link rel="alternate">`, favicon), masthead ("The Daily EPUB", serif), primary nav (Latest · Archive · Feed · Sign in / account name), admin nav bar when the viewer is an admin (Overview · Runs · Articles · Ratings · Profile · Stats · Jobs · Settings · Users), `<main>`, footer (`daily-epub {VERSION}`), `/static/app.js` at the end.
|
||||||
|
- Every page template `{% extends "layout.html" %}` and receives a `Page` context: `title`, `viewer: Option<Viewer { username, role }>`, `flash: Option<Flash>`, `active_nav`.
|
||||||
|
- `Html<T: Template>` implements `IntoResponse`: renders, sets `text/html; charset=utf-8`, and on a render error logs and returns the 500 page.
|
||||||
|
- `WebError { NotFound, Forbidden, Unauthenticated { next }, BadRequest(String), Csrf, Db(sqlx::Error), Internal(anyhow::Error) }` → `IntoResponse`. `Unauthenticated` redirects HTML requests to `/login?next=<path>` and returns 401 JSON to `Accept: application/json`.
|
||||||
|
- Flash messages live in the tower-sessions session (`session.insert("flash", Flash { kind, text })`, removed when the next page reads it), so POST-redirect-GET can confirm "Saved 3 settings" or "Rated: Loved it". Only signed-in flows set flashes, so anonymous visitors never get a session cookie from this.
|
||||||
|
- Templates (all under `src/web/templates/`): `layout.html`, `error.html`, `login.html`, `account.html`, `issue_public.html`, `issue_full.html`, `issue_list.html`, `article.html`, `world.html`, `behind.html`, `dashboard/overview.html`, `dashboard/runs.html`, `dashboard/run.html`, `dashboard/articles.html`, `dashboard/article.html`, `dashboard/ratings.html`, `dashboard/profile.html`, `dashboard/stats.html`, `dashboard/settings.html`, `dashboard/settings_history.html`, `dashboard/jobs.html`, `dashboard/job.html`, `dashboard/users.html`, plus partials `_rating_widget.html`, `_pagination.html`, `_signals_table.html`, `_candidate_row.html`, `_sparkline.html`.
|
||||||
|
- Static assets: `include_str!` the three files; `GET /static/{file}` sets `Content-Type`, `Cache-Control: public, max-age=86400`, and an `ETag` of the sha256 (respond 304 to `If-None-Match`). The `?v={VERSION}` query busts caches across releases.
|
||||||
|
|
||||||
|
### 4.4 Design system (the one CSS file)
|
||||||
|
|
||||||
|
- Tokens: `--bg`, `--fg`, `--muted`, `--rule`, `--accent`, `--loved`, `--good`, `--down`, redefined under `@media (prefers-color-scheme: dark)`.
|
||||||
|
- Masthead and issue pages: Georgia/serif headings, `max-width: 72ch` reading column, hairline rules like the EPUB front page.
|
||||||
|
- Dashboard: system sans, `max-width: 1200px`, dense tables (`font-size: .9rem`, sticky `<thead>`), every `<table>` wrapped in `.scroll-x { overflow-x: auto }`.
|
||||||
|
- Components: `.badge` per stage/reason/label (`selected`, `shortlisted`, `assessed`, `admitted`, `triaged`, `eligible`, `excluded`, `loved`, `good`, `down`, `cleared`), `.kv` definition lists, `.funnel` (a horizontal bar per stage, widths proportional to counts), `.spark` (inline SVG polyline, server-rendered), `.rating` button group with the active verdict filled.
|
||||||
|
- No layout JavaScript. `app.js` does exactly: intercept `.rating` forms and `POST` with `fetch` + `Accept: application/json`, then update the button state; confirm dialogs on `data-confirm` forms (jobs, provider removal, clear rating); auto-refresh `dashboard/job.html` every 5 s while the status is `requested`/`running`; a "filter as you type" on tables with `data-filter` (client-side, current page only); collapse/expand for `<details>` state kept in `localStorage` (guard with try/catch).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Data model — migration `0004_web.sql`
|
||||||
|
|
||||||
|
Do not edit earlier migrations. Plain SQL, no Rust bootstrap.
|
||||||
|
|
||||||
|
### 5.1 Tables
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||||
|
password_hash TEXT NOT NULL, -- PHC string from argon2
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('user', 'admin')),
|
||||||
|
disabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
last_login_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- tower-sessions records (§6.2). `id` is the session Id's string form (a random
|
||||||
|
-- 128-bit value the cookie carries), `data` the JSON-encoded record map.
|
||||||
|
-- `expiry` is unix seconds because tower-sessions hands us a `time::OffsetDateTime`
|
||||||
|
-- and the store compares it in SQL; `user_id` is denormalized from the
|
||||||
|
-- axum-login entry in `data` so the Users page and `users logout` can find a
|
||||||
|
-- user's sessions without decoding every row.
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
expiry INTEGER NOT NULL,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_sessions_user ON sessions(user_id);
|
||||||
|
CREATE INDEX idx_sessions_expiry ON sessions(expiry);
|
||||||
|
|
||||||
|
ALTER TABLE rating_events ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE TABLE config_changes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
key TEXT NOT NULL, -- dotted path, e.g. curation.ranking.deep_keep
|
||||||
|
old_value TEXT, -- TOML literal as it was in the file (NULL = absent)
|
||||||
|
new_value TEXT, -- NULL = removed
|
||||||
|
changed_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_config_changes_at ON config_changes(changed_at);
|
||||||
|
|
||||||
|
CREATE TABLE profile_versions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
content TEXT NOT NULL, -- the profile.md text *before* the save (and one row for the first save's original)
|
||||||
|
saved_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
saved_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE jobs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL, -- catalogue name, e.g. generate, dry-run, generate-2026-09-01
|
||||||
|
unit TEXT NOT NULL, -- daily-epub-job@<name>.service
|
||||||
|
requested_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
requested_at TEXT NOT NULL,
|
||||||
|
started_at TEXT,
|
||||||
|
finished_at TEXT,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('requested', 'running', 'ok', 'failed')),
|
||||||
|
message TEXT, -- error text or one-line summary
|
||||||
|
run_id INTEGER REFERENCES runs(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_jobs_requested_at ON jobs(requested_at);
|
||||||
|
|
||||||
|
ALTER TABLE runs ADD COLUMN report_json TEXT;
|
||||||
|
ALTER TABLE issues ADD COLUMN issue_json TEXT;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 `issues.issue_json`
|
||||||
|
|
||||||
|
The full `Issue` the pipeline assembled, serialized with `serde_json`, **with every `pick.article.content_html` replaced by an empty string** (bodies are already in `articles.content_html`; the discussion threads and the World Briefing are the parts that exist nowhere else). Written by `pipeline::record_issue` alongside the lineup. A regenerated date overwrites it. Expected size: 100–300 KB per issue.
|
||||||
|
|
||||||
|
The web loader (`web::issue::load`) rehydrates each pick's body from `articles` by id. Issues published before this change have `issue_json IS NULL`; the loader then builds a reduced `Issue` from `issues` (`front_page_html` as the Brief, `issue_number`, `generated_at`), `issue_articles` (section, position, `is_lead`, `summary`, `why`) and `articles` — with `world_briefing = None`, `discussion = None`, `behind = Default`. Section order for the fallback is `config.curation.sections` order, then any unknown section names in first-seen order.
|
||||||
|
|
||||||
|
### 5.3 `runs.report_json` and `issues.report_json`
|
||||||
|
|
||||||
|
`db::finish_run` writes `serde_json::to_string(report)` into `runs.report_json`. `pipeline::record_issue` passes the same string as `report_json` to `upsert_issue` (fixing the never-written column; `GET /issues.json` starts returning real reports). `RunReport` already serializes; nothing in it is secret (`config_json` is `providers_redacted()`).
|
||||||
|
|
||||||
|
### 5.4 Job attribution and rating attribution
|
||||||
|
|
||||||
|
- `POST /rate` appends `RatingEvent { source: "dashboard", user_id: Some(viewer.id), .. }`. Extend `RatingEvent` with `pub user_id: Option<i64>` (default `None`; the `epub`/`cli` writers leave it `None`) and `db::append_rating_event` binds it. `db::current_ratings*` and `rated_article_from_row` gain the column so the ratings page can show who rated.
|
||||||
|
- `jobs.run_id` is set by `daily-epub job run` for the `generate*` jobs from `GenerateOutcome` (add `pub run_id: i64` to `GenerateOutcome`; `pipeline::generate` already has it as `run_id`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Authentication and sessions
|
||||||
|
|
||||||
|
Sessions, login, logout and route guards come from **`axum-login` at the pinned git rev** (§2) over **`tower-sessions` 0.15** (the version it re-exports; use `axum_login::tower_sessions`, do not add `tower-sessions` separately). Passwords go through **`password-auth` 1.0**. The login throttle is **`tower_governor` 0.8**. What we write ourselves is limited to: the session store over our sqlx pool (§6.2), the `AuthnBackend`/`AuthzBackend` impl over the `users` table (§6.3), the origin check (§6.4), and the CLI.
|
||||||
|
|
||||||
|
### 6.1 Users and passwords (`src/web/users.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum Role { User, Admin } // as_str: "user" | "admin"; parse; Display
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct User { id: i64, username: String, password_hash: String, role: Role, disabled: bool, created_at, last_login_at }
|
||||||
|
impl std::fmt::Debug for User { /* redact password_hash */ }
|
||||||
|
pub fn hash_password(plain: &str) -> String // password_auth::generate_hash (argon2id, random salt, PHC string)
|
||||||
|
pub fn verify_password(hash: &str, plain: &str) -> bool // password_auth::verify_password(..).is_ok()
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules: usernames are 1–32 chars of `[A-Za-z0-9._-]`, compared case-insensitively (`COLLATE NOCASE`); passwords are at least 12 characters and at most 1,024. `password_auth::verify_password` is a blocking argon2 computation; call it inside `tokio::task::spawn_blocking` from the backend.
|
||||||
|
|
||||||
|
CLI (`src/main.rs` + `web::users`), none of which take the run lock:
|
||||||
|
|
||||||
|
```text
|
||||||
|
daily-epub users add <username> [--admin] [--password-stdin] # hidden double prompt (rpassword), or one line from stdin
|
||||||
|
daily-epub users passwd <username> [--password-stdin] # also deletes the user's sessions (session_auth_hash changes anyway)
|
||||||
|
daily-epub users role <username> user|admin
|
||||||
|
daily-epub users disable <username> | enable <username> # disable also deletes the user's sessions
|
||||||
|
daily-epub users list # username · role · created · last login · open sessions
|
||||||
|
daily-epub users logout <username> # delete that user's sessions
|
||||||
|
```
|
||||||
|
|
||||||
|
`users add` refuses a duplicate name; `--admin` on the very first user is the intended bootstrap (§18).
|
||||||
|
|
||||||
|
### 6.2 Session store (`src/web/session.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct SqliteSessionStore { pool: SqlitePool }
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl tower_sessions::SessionStore for SqliteSessionStore {
|
||||||
|
async fn save(&self, record: &Record) -> session_store::Result<()>; // INSERT … ON CONFLICT(id) DO UPDATE SET data, expiry, user_id, updated_at
|
||||||
|
async fn load(&self, id: &Id) -> session_store::Result<Option<Record>>; // SELECT data FROM sessions WHERE id = ? AND expiry > strftime('%s','now')
|
||||||
|
async fn delete(&self, id: &Id) -> session_store::Result<()>;
|
||||||
|
}
|
||||||
|
impl SqliteSessionStore {
|
||||||
|
pub async fn delete_expired(&self) -> Result<u64>; // on server start and once an hour from a tokio task
|
||||||
|
pub async fn delete_for_user(&self, user_id: i64) -> Result<u64>; // users passwd/disable/logout, "sign out everywhere"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`data` is `serde_json::to_string(&record.data)`; `expiry` is `record.expiry_date.unix_timestamp()`. `user_id` is read best-effort from `record.data["axum-login.data"]["user_id"]` (the key axum-login uses) and stored alongside, so the Users page and the CLI can list and delete a user's sessions without decoding every row; a record without that key (an anonymous session that set flash data) stores `NULL`. Map every sqlx error to `session_store::Error::Backend(e.to_string())`. This is the whole store: ~60 lines plus tests. It mirrors what `tower-sessions-sqlx-store` does, which we cannot link because it pins sqlx 0.8 (§2).
|
||||||
|
|
||||||
|
Layer, built in `server::router`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let session_layer = SessionManagerLayer::new(SqliteSessionStore::new(db.pool().clone()))
|
||||||
|
.with_name("daily_session")
|
||||||
|
.with_http_only(true)
|
||||||
|
.with_same_site(SameSite::Lax)
|
||||||
|
.with_secure(config.server.public_url.starts_with("https://"))
|
||||||
|
.with_expiry(Expiry::OnInactivity(time::Duration::days(config.server.session_days as i64)));
|
||||||
|
let auth_layer = AuthManagerLayerBuilder::new(Backend::new(db.clone()), session_layer).build();
|
||||||
|
```
|
||||||
|
|
||||||
|
`Expiry::OnInactivity` is the sliding lifetime. tower-sessions only writes a cookie when a handler touches the session, so anonymous page views create no rows and no cookie. Flash messages (§4.3) use tower-sessions' own `Session` extractor (`session.insert("flash", ..).await` / `session.remove(..).await`), since `AuthSession` no longer exposes its inner session.
|
||||||
|
|
||||||
|
### 6.3 Backend (`src/web/session.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Clone)] pub struct Backend { db: Db }
|
||||||
|
pub struct Credentials { username: String, password: String, next: Option<String> }
|
||||||
|
|
||||||
|
impl AuthUser for User {
|
||||||
|
type Id = i64;
|
||||||
|
fn id(&self) -> i64 { self.id }
|
||||||
|
fn session_auth_hash(&self) -> &[u8] { self.password_hash.as_bytes() } // password change ⇒ every session of the user is invalid
|
||||||
|
}
|
||||||
|
impl AuthnBackend for Backend { // native async fn; no async-trait at this rev
|
||||||
|
type User = User; type Credentials = Credentials; type Error = BackendError;
|
||||||
|
async fn authenticate(&self, creds) -> Result<Option<User>> {
|
||||||
|
// SELECT by username COLLATE NOCASE; verify against the row's hash, or against
|
||||||
|
// a fixed dummy hash when the row is missing (equal timing); None when disabled.
|
||||||
|
}
|
||||||
|
async fn get_user(&self, id: &i64) -> Result<Option<User>> { /* None when disabled */ }
|
||||||
|
}
|
||||||
|
impl AuthzBackend for Backend {
|
||||||
|
type Permission = Role; // Hash + Eq + Clone
|
||||||
|
async fn get_user_permissions(&self, user: &User) -> Result<HashSet<Role>> {
|
||||||
|
Ok(match user.role { Role::Admin => [Role::User, Role::Admin].into(), Role::User => [Role::User].into() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub type AuthSession = axum_login::AuthSession<Backend>;
|
||||||
|
```
|
||||||
|
|
||||||
|
Guards, applied as `route_layer` on the sub-routers (§3):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// any signed-in user: full issues, article/world/behind pages, /account
|
||||||
|
.route_layer(login_required!(Backend, login_url = "/login", redirect_field = "next"))
|
||||||
|
// admins: everything under /dashboard and POST /rate
|
||||||
|
.route_layer(permission_required!(Backend, login_url = "/login", redirect_field = "next", Role::Admin))
|
||||||
|
```
|
||||||
|
|
||||||
|
`permission_required!` redirects anonymous requests to `/login?next=…` and answers 403 to a signed-in user without the permission; the 403 is turned into the site's error page by a small `map_response` on the dashboard router. Pages that render differently for anonymous and signed-in viewers (`/`, `/issues/{date}`) take `AuthSession` and branch on `auth.user().await`. `/files/*` (§8) checks `auth.user().await.is_some()` before falling back to Basic auth.
|
||||||
|
|
||||||
|
`Viewer { user: User }` is derived from `auth.user().await` for templates; the layout shows the username and role from it.
|
||||||
|
|
||||||
|
Login: `GET /login?next=` renders the form. `POST /login` calls `auth.authenticate(creds)`; `Ok(Some(user))` → `auth.login(&user).await` (axum-login cycles the session id), `UPDATE users SET last_login_at`, redirect to `next` when it is a same-site absolute path (starts with `/`, not `//`), else `/`; `Ok(None)` → the form again with "invalid username or password" (status 401); `Err` → 500 page. `POST /logout` → `auth.logout().await`, redirect `/`. `/account` shows the viewer and offers "change password" (verify current, `hash_password`, update the row; the new `session_auth_hash` logs out every other session automatically) and "sign out everywhere" (`delete_for_user`).
|
||||||
|
|
||||||
|
### 6.4 CSRF: SameSite + origin check
|
||||||
|
|
||||||
|
The session cookie is `SameSite=Lax`, so browsers do not attach it to cross-site `POST`s at all. On top of that, one middleware (`web::session::require_same_origin`, applied to every `POST` route except `/r/*` which has no session) rejects with 403 when: `Sec-Fetch-Site` is present and is neither `same-origin` nor `none`; or, when it is absent, the `Origin` header (or the origin of `Referer`) does not equal the origin of `server.public_url` or of the request `Host`. That is the OWASP "fetch metadata + origin verification" defence and is enough for cookie-authenticated forms; there is no per-form token to render or check. `app.js` `fetch` calls send `credentials: "same-origin"` and get `Sec-Fetch-Site: same-origin` from the browser automatically.
|
||||||
|
|
||||||
|
If a per-form token is ever wanted (e.g. for a very old browser without fetch metadata), add `axum_csrf` 0.11 as a second layer; nothing in this plan precludes it.
|
||||||
|
|
||||||
|
### 6.5 Login throttle
|
||||||
|
|
||||||
|
`tower_governor` on the `POST /login` route only:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let governor = Arc::new(GovernorConfigBuilder::default()
|
||||||
|
.per_second(config.server.login_window_minutes * 60 / config.server.login_attempts) // e.g. 90 s per token
|
||||||
|
.burst_size(config.server.login_attempts) // e.g. 10
|
||||||
|
.key_extractor(SmartIpKeyExtractor)
|
||||||
|
.finish().expect("governor config"));
|
||||||
|
// route_layer(GovernorLayer::new(governor.clone())) on POST /login; a tokio task calls
|
||||||
|
// governor.limiter().retain_recent() every 60 s.
|
||||||
|
```
|
||||||
|
|
||||||
|
`SmartIpKeyExtractor` trusts `X-Forwarded-For`; that is acceptable because `server.bind` is loopback and nginx is the only peer (README §"Reverse proxy" says so; add a sentence). `server::serve` binds with `into_make_service_with_connect_info::<SocketAddr>()` so the peer address is available as the fallback key. Over the limit the layer answers 429; the login page template is not involved. Note the limiter counts attempts, not failures — ten login POSTs per fifteen minutes per IP is plenty for one operator.
|
||||||
|
|
||||||
|
## 7. Public site
|
||||||
|
|
||||||
|
### 7.1 `PublicIssue` — the only thing the public templates receive
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct PublicIssue {
|
||||||
|
pub date: Date, pub issue_number: i64, pub display_date: String,
|
||||||
|
pub article_count: i64, pub reading_minutes: i64,
|
||||||
|
pub sections: Vec<PublicSection>,
|
||||||
|
pub generated_at: Timestamp,
|
||||||
|
}
|
||||||
|
pub struct PublicSection { pub name: String, pub entries: Vec<PublicEntry> }
|
||||||
|
pub struct PublicEntry {
|
||||||
|
pub title: String, pub url: String, // canonical_url; the link target
|
||||||
|
pub author: Option<String>,
|
||||||
|
pub source: String, // feed_title
|
||||||
|
pub domain: String, // host of `url`, "www." stripped
|
||||||
|
pub reading_minutes: i64, pub word_count: i64,
|
||||||
|
pub summary: Option<String>, pub why: Option<String>,
|
||||||
|
pub comment_links: Vec<CommentLink>, // {label, url, meta}
|
||||||
|
pub is_lead: bool,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`PublicIssue::from(&Issue)` is the only constructor, and it copies exactly these fields. There is deliberately no `body`, `brief`, or `world` field, so the template cannot render them. Test `public_issue_shows_summaries_and_why_but_no_bodies` (§17) renders the fixture issue publicly and asserts that summaries and `why` lines are present while the Brief, every article body sentence and every comment string are absent from the HTML.
|
||||||
|
|
||||||
|
**2026-09-03:** the operator chose to publish summaries and why lines; bodies/Brief/World/comments remain private.
|
||||||
|
|
||||||
|
`comment_links`: one per `SocialRef` with an `item_url` — label `Hacker News` / `Lobsters` / `Reddit`, meta `"342 points · 210 comments"` — plus `Comments` for `entries.comments_url` when it is set and differs from the article URL. Links carry `rel="noopener"` and `target="_blank"`; article title links are plain `<a href>` (the aggregator's purpose is to send readers to the source, so no `nofollow`).
|
||||||
|
|
||||||
|
### 7.2 Pages
|
||||||
|
|
||||||
|
- `GET /` — the latest issue by date, rendered as `issue_public.html` (or `issue_full.html` when signed in, §8), with a strap line ("A personal morning paper, assembled daily from N feeds; the selection is the reader's, the words are the authors'") and a link to the archive. When no issue exists: an empty-state page.
|
||||||
|
- `GET /issues` — every issue date, newest first, grouped by month: date, weekday, `issue_number`, article count. Anonymous and signed-in alike.
|
||||||
|
- `GET /issues/{date}` — `issue_public.html`: masthead, dateline `Friday, September 3, 2026 · No. 20`, `stats_line` (articles · read time · sections), then sections in `Lineup.section_order` with entries: title → source link, `author · source (domain) · 8 min`, comment links. The World Briefing is **not** listed publicly (its entries are Wikipedia text). The lead story is marked with `.lead`.
|
||||||
|
- `GET /feed.xml` — Atom 1.0, last 30 issues: `<id>tag:daily.hallada.net,2026:issue/{date}</id>` (host from `server.public_url`), `<title>The Daily EPUB — {date}</title>`, `<updated>` = `generated_at`, `<link rel="alternate" href="{public_url}/issues/{date}">`, `<content type="html">` = the public section list as escaped HTML (the same `PublicIssue`, rendered by `feed_entry.html`). `Content-Type: application/atom+xml; charset=utf-8`, `Cache-Control: public, max-age=300`.
|
||||||
|
- `GET /robots.txt` — as in §3.
|
||||||
|
|
||||||
|
Public pages send `Cache-Control: public, max-age=300` only when no session cookie is present; with a cookie they are `private, no-store`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Full issue views for signed-in users
|
||||||
|
|
||||||
|
`web::issue::load(state, date) -> Result<Option<IssueView>>` (§5.2). `IssueView` carries the reconstructed `Issue`, downloads, snapshot provenance, and an optional trusted World Briefing HTML fragment recovered from a legacy EPUB. `Download { label, href, size_bytes }` lists the standard EPUB, the X4 EPUB and the XTC file **only when the file at `issues.*_path` (or `publish::issue_filename` in `epub_dir`) exists**; `href` is the existing `/files/epub/{name}` or `/files/xtc/{name}`.
|
||||||
|
|
||||||
|
- `GET /issues/{date}` (signed in) — `issue_full.html`: masthead and dateline; **The Brief** (`editorial.front_page_html`, already sanitized XHTML, rendered `|safe`); download buttons; the index exactly like the EPUB's In-this-issue page: per section, title (→ `/issues/{date}/articles/{id}`), `source · N min read`, summary, `Why it's here`, the rating widget for admins; then links to World Briefing and Behind the paper; the colophon facts (models, cost, counts) in a footer block.
|
||||||
|
- `GET /issues/{date}/articles/{article_id}` — `article.html`: `article-header` (title linking to the source, byline, meta line `feed · 1,850 words · ~8 min`, `Why it's here`, social line via `chapters::social_line`, summary), the body (`ammonia::clean(articles.content_html)` — reuse the same ammonia configuration the EPUB uses; do **not** run `to_xhtml`; `<img>` tags keep their remote `src` and get `loading="lazy" referrerpolicy="no-referrer"`), the discussion (`comments::render_xhtml(discussion, title)` when `pick.discussion` is present; it is XHTML and renders fine as HTML), prev/next links in issue order, "Read online ↗", and the rating widget for admins. 404 when the article is not in that issue.
|
||||||
|
- `GET /issues/{date}/world` — `world::render_xhtml` in the layout for snapshots, or trusted `OEBPS/world.xhtml` body content recovered from our own sanitized legacy EPUB; 404 when neither source has a briefing.
|
||||||
|
- `GET /issues/{date}/behind` — the same lines as the EPUB chapter (`chapters::behind_*_line`, near misses linking to `/dashboard/articles/{id}` for admins), reconstructed from the stored report/run for legacy issues.
|
||||||
|
|
||||||
|
`/files/epub/{name}` and `/files/xtc/{name}`: `serve_file` accepts either a valid session (any role) **or** the existing Basic auth; when neither is present and Basic auth is configured, it challenges as today; when Basic auth is not configured it redirects HTML clients to `/login?next=` and returns 401 to others. OPDS clients are unaffected.
|
||||||
|
|
||||||
|
The HMAC confirmation page (`server::confirmation_page`) gains one line: `<a href="{public_url}/issues/{date}">Open this issue on the site</a>`.
|
||||||
|
|
||||||
|
### 8.1 Rating widget (`_rating_widget.html`, `web::rate`)
|
||||||
|
|
||||||
|
A `<form class="rating" method="post" action="/rate">` with hidden `article_id`, `issue_date`, `next`, and four buttons `label=loved|good|down|cleared` (the last styled as a link "clear"); the current verdict button is filled. An optional `note` text field appears on the dashboard variants (`/dashboard/articles/{id}`, `/dashboard/ratings`), not on issue pages. `POST /rate`:
|
||||||
|
|
||||||
|
1. Admin route layer (§6.3) and the origin check (§6.4) have already passed; read the viewer from `AuthSession`.
|
||||||
|
2. Validate `article_id` exists; `issue_date` optional (issue pages pass it; the dashboard passes the latest `issue_articles` date for the article, if any, like the CLI does).
|
||||||
|
3. Append `RatingEvent { kind: "explicit", source: "dashboard", label, value: vote.value(&cfg.curation.feedback) (0.0 for cleared), note, user_id, event_at: now }`.
|
||||||
|
4. `Accept: application/json` → `200 {"article_id", "label", "event_id"}`; otherwise set a flash and `303 See Other` to `next` (validated same-site path).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Dashboard: overview, runs, articles
|
||||||
|
|
||||||
|
All dashboard queries live in `src/web/dashboard/*.rs` as runtime `sqlx` queries with manual row mapping. Page size 50 (articles) / 100 (candidates), `?page=N`, `_pagination.html`. Sort and filter parameters are query-string, validated against allow-lists (never interpolated into SQL).
|
||||||
|
|
||||||
|
### 9.1 Overview (`/dashboard`)
|
||||||
|
|
||||||
|
- **Last run** card: date, status badge, started/duration, the four-line block (`curation:`, `admission:`, `preference:`, `providers:`) built from `runs.report_json` (fall back to the counters when NULL), warnings count with a link to the run.
|
||||||
|
- **Budget today** card: per provider (from `config.referenced_providers()` + voyage): spent today (`db::provider_spend_for_utc_day(now)` + the last run if it is today) vs `max_daily_usd`, as a bar.
|
||||||
|
- **Sparklines** (server-rendered SVG, last 30 non-dry runs): cost per run, selected per run, generation seconds.
|
||||||
|
- **Ratings this week**: counts by label, and **"Unrated picks"** — the last three issues' picks with no explicit event, title + feed + inline rating widget. (The operator forgets to rate; this is the nudge.)
|
||||||
|
- **Jobs**: any `requested`/`running` job, and the last five finished.
|
||||||
|
- **Config on disk**: the `Config::check_report` lines that start with `! ` (missing keys/files), if any.
|
||||||
|
|
||||||
|
### 9.2 Runs (`/dashboard/runs`, `/dashboard/runs/{id}`)
|
||||||
|
|
||||||
|
List: one row per `runs` row, newest first, 50 per page: id, date (→ issue page), status badge, started (config tz), duration, `considered → eligible → triaged → assessed → shortlisted → selected` from `report_json.counts` (or `candidates`/`selected` when NULL), cost per provider, total, dry-run marker, warnings count. Filter `?status=`.
|
||||||
|
|
||||||
|
Detail:
|
||||||
|
|
||||||
|
1. Header: date, status, started/finished/duration, link to the issue, link to the previous and next run of that date.
|
||||||
|
2. **Funnel**: `SELECT stage, excluded_reason, COUNT(*) FROM candidate_runs WHERE run_id = ? GROUP BY 1, 2` rendered as bars in pipeline order (`excluded` → `eligible` → `triaged` → `admitted` → `assessed` → `shortlisted` → `selected`) with the reason breakdown under each.
|
||||||
|
3. **Admission mix** (`report_json.counts.admitted_by`), **preference state** (`rated_with_embeddings`, `knn_gate`, `feed_gate`, `verdicts_in_prompt`), **timings** table (`report_json.timings`), **provider usage** (tokens in/cached/write/out, cost), **warnings** list, **per-feed counts** top 20.
|
||||||
|
4. **Config diff**: flatten `runs.config_json` of this run and of the previous non-dry run into dotted keys; show keys whose values differ (`key · before → after`). This is how a tuning change is tied to its first paper.
|
||||||
|
5. **Near misses**: `telemetry::near_misses(db, run_id, 10)`.
|
||||||
|
6. **Candidates** table: every `candidate_runs` row joined to `articles`/`entries`/`article_assessments`: title (→ article detail), feed, words, stage badge, reason, `admitted_by`, utility, rank, cluster (`id · rank`), triage `interest`, deep `quality`/`fit`, exploration/auto-include flags, `editor_why`. Filters: `stage`, `reason`, `admitted_by` (prefix match on the JSON array's first element via `json_extract(admitted_by, '$[0]')`), `q` (title LIKE), `flag=exploration|auto`. Sort: utility desc (default), rank, triage, quality, fit, title. A `<details>` per row expands `_signals_table.html` (raw · norm · weight · present for each signal, top interests, neighbours, notes) from `signals_json`.
|
||||||
|
|
||||||
|
### 9.3 Articles (`/dashboard/articles`, `/dashboard/articles/{id}`)
|
||||||
|
|
||||||
|
List: `articles` LEFT JOIN `entries` (best entry) LEFT JOIN the article's **latest** `candidate_runs` row (`MAX(run_id)` subquery) LEFT JOIN triage/deep `article_assessments` LEFT JOIN the current rating (latest explicit event) LEFT JOIN the latest `issue_articles` row. Columns: first seen, title, feed, words, last stage/reason (with run date), utility, triage, quality/fit, rating badge, published date. Filters: `q` (title or canonical_url LIKE), `feed` (feed_id), `stage`, `reason`, `rated=any|loved|good|down|cleared|none`, `published=yes|no`, `from`/`to` (first_seen dates), `kind` (triage kind). Sort: first_seen desc (default), utility, quality, fit, triage, words, title. Add index `CREATE INDEX idx_candidate_runs_article_run ON candidate_runs(article_id, run_id DESC)` in the migration so the latest-row join stays fast.
|
||||||
|
|
||||||
|
Detail — everything the system knows, in this order:
|
||||||
|
|
||||||
|
1. **Article**: title → source, canonical URL, feed (+ category), author, published, first seen, words, excerpt-only, image count, extract method (from `sources_json`? no — from the best entry; show what `Article` carries), sources list (`SourceRef` kind + feed), social refs with links and scores, "in issues" (date · section · position · lead), current rating badge, the rating widget with note field, "Explain (text)" `<details>` containing `telemetry::render_explain` for the latest run row, verbatim in `<pre>`.
|
||||||
|
2. **Assessments**: triage (interest, kind, why, model, prompt version, assessed_at) and deep (quality, fit, category, rationale, paywalled guess, facets as a table, model, assessed_at); a `provider_rejected` row is shown as such.
|
||||||
|
3. **Run history**: every `candidate_runs` row for the article, newest first: run (→ run detail), date, stage, reason, admitted_by, utility, rank, cluster, editor why. Clicking a row expands its `_signals_table.html`.
|
||||||
|
4. **Neighbours and interests** from the latest row's `signals_json`: top interests (z, cos) and nearest rated neighbours (label, cos, → their article page).
|
||||||
|
5. **Embedding**: model, dimension, created_at, input hash — never the vector.
|
||||||
|
6. **Rating events**: all events for this article (including superseded ones), with source, user, note, value.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Ratings page (`/dashboard/ratings`)
|
||||||
|
|
||||||
|
Two tabs (query `?tab=current|events`).
|
||||||
|
|
||||||
|
**Header** (from `PreferenceState::load(db, &config.curation.ranking, now).summary()` — reuse whatever the pipeline calls): `N rated articles with embeddings → neighbour signal at {knn_gate:.0%} (floor {knn_floor}, full {knn_full}) · {attributable_feed_ratings} attributable feed ratings → feed affinity at {feed_gate:.0%} · {verdicts_in_prompt} verdicts in the prompt · {n} in the weekly rebuild set`. When a rated article has no embedding, a notice: "M rated articles have no embedding and cannot act as neighbours — run the `features backfill --rated-only` job."
|
||||||
|
|
||||||
|
**Current** tab: `db::current_ratings_including_cleared(36_500)`, one row per article, newest first, filter by label/source/feed/q. Columns:
|
||||||
|
|
||||||
|
| Column | Definition |
|
||||||
|
|---|---|
|
||||||
|
| Verdict | badge; the rating widget inline (with note) to change it |
|
||||||
|
| Article | title → article detail, feed, issue date if any |
|
||||||
|
| When · by | `event_at` (config tz), `source`, username |
|
||||||
|
| Note | `note` |
|
||||||
|
| Age → decay | `age_days` and `decay = 0.5^(age/half_life)` (`signals::decay`) |
|
||||||
|
| Neighbour weight | `value × decay` when the article has an embedding; "no embedding" otherwise. This is exactly the `w_i` of plan §9.2 |
|
||||||
|
| Feed credit | each direct feed (`signals::direct_feeds`, else the best entry's feed) and `value / n` credited to it (plan §9.3) |
|
||||||
|
| In prompt | ✓ when its rank among current non-cleared verdicts (newest first) is `< curation.feedback.verdicts_in_prompt` |
|
||||||
|
| In rebuild | ✓ when that rank is `< profile::MAX_RATINGS_IN_REBUILD` |
|
||||||
|
| Used last run | from the latest non-dry run: the number of candidates whose `signals_json.neighbours` include this article id (`WHERE signals_json LIKE '%"article_id":<id>,%'` then parsed to confirm), and how many of those were selected |
|
||||||
|
|
||||||
|
A "How ratings enter the algorithm" `<details>` block at the top explains the four paths in prose (prompt verdict block, weekly learned adjustments, neighbour signal with decay and gate, feed affinity with gate) with the current config values inlined, and links to the settings groups that tune them.
|
||||||
|
|
||||||
|
**Events** tab: every `rating_events` row, newest first, 100 per page, with `superseded` marked when a later explicit event exists for the same article; filters by label, source, user, date range. Nothing here is editable — history is append-only, and the "clear" verdict is the way to retract.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Profile page (`/dashboard/profile`)
|
||||||
|
|
||||||
|
- **profile.md editor**: a monospace `<textarea>` with the current file (`config.profile_path`), a Save button (an origin-checked POST, §6.4), and a live preview of what the loader parses: the sections that pass through verbatim and the `## Interests` lines it extracted (`profile::parse_profile_str`). Saving: validate the text parses (it always does; reject only empty or > 64 KB), insert the **previous** content into `profile_versions`, write atomically (`<path>.tmp` + rename, preserve mode), flash "Saved; the next run rebuilds the system prompt". The server unit needs the profile's directory writable (it is under `/var/lib/daily-epub`, the `StateDirectory`; §18 confirms).
|
||||||
|
- **History**: `profile_versions` newest first with a diff-less preview (first 200 chars) and a "Restore" button (writes that content back, recording the current one as a new version).
|
||||||
|
- **Standing interests**: the OPML interests (`profile::parse_interests`) grouped by `themes::group_into_themes` — read-only, with the file path and count; a note that the union with `## Interests` is what the prompt uses.
|
||||||
|
- **System prompt**: `kv.taste_profile` (`TasteProfile { text, version, built_at, verdicts }`) in a collapsed `<pre>`, the learned adjustments block (`kv.taste_profile_learned`) shown separately with its age and whether a weekly rebuild is due (`profile::is_stale`), and a "Rebuild profile now" button that starts the `profile-rebuild` job (§14).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Stats page (`/dashboard/stats`)
|
||||||
|
|
||||||
|
Refactor `telemetry::stats(db, days, now) -> String` into `telemetry::stats_data(db, days, now) -> StatsData` plus `telemetry::render_stats_text(&StatsData) -> String`; the CLI output is byte-identical (a test pins it against the current fixture). `StatsData` carries: issues, articles published, ratings by label, ratings per issue, per-retriever up/down, exploration yield, mean issue size, cost per day per provider (as a date → provider → usd map), mean generation time, and the per-run series used by the sparklines.
|
||||||
|
|
||||||
|
The page: `?days=14|30|90`, the same figures as tables, three sparklines (cost per day stacked per provider as bars, selected per issue, ratings per week by label), and the retriever yield as a small table with the up/down ratio.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Settings (`/dashboard/settings`)
|
||||||
|
|
||||||
|
### 13.1 The schema is derived, not hand-listed
|
||||||
|
|
||||||
|
`settings::schema(config: &Config, file: Option<&toml_edit::DocumentMut>) -> Vec<SettingGroup>`:
|
||||||
|
|
||||||
|
1. `let defaults = toml::Value::try_from(Config::default())` and `let current = toml::Value::try_from(config)`; walk both in parallel, depth-first, producing one `SettingField` per leaf:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct SettingField {
|
||||||
|
pub path: String, // "curation.ranking.deep_keep"
|
||||||
|
pub group: String, // "curation.ranking" (the enclosing table)
|
||||||
|
pub kind: FieldKind, // Bool | Integer | Float | Text | TextList | Enum(&'static [&'static str]) | Secret | Path
|
||||||
|
pub current: String, // rendered value (TextList: one per line)
|
||||||
|
pub default: String,
|
||||||
|
pub source: Source, // Default | File | Env(String /* the var name */)
|
||||||
|
pub help: Option<&'static str>,
|
||||||
|
pub restart_required: bool, // server.bind, database_path
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. `source`: `Env` when `std::env::var_os(env_name(path))` is set, where `env_name` = `ENV_PREFIX + path.to_uppercase().replace('.', ENV_SPLIT)` (`DAILY_EPUB_SECRET` also counts for `server.hmac_secret`); else `File` when the key exists in the parsed document; else `Default`.
|
||||||
|
3. `kind`: from the default's TOML type; `TextList` for arrays of strings; `Enum` from a static table keyed by path suffix — `providers.*.kind` → `openai|anthropic`, `providers.*.effort` → `low|medium|high|xhigh|max` (anthropic) or free text (openai), `editorial.summary_model` → `editor|bulk`, `xtc.format` → `xtc|xtch`, `llm.bulk`/`llm.editor` → the provider names (plus `""` for editor); `Secret` for paths ending in `api_key`, `hmac_secret`, `basic_auth_pass`; `Path` for `database_path`, `out_dir`, `profile_path`, `interests_opml`, `publish.*_dir`, `xtc.settings`.
|
||||||
|
4. `help`: a static `SETTINGS_HELP: &[(&str, &str)]` seeded from the README's configuration table and the comments in `config.example.toml`. Every key in the shipped example must have an entry (test).
|
||||||
|
5. Groups render in this order: top level, `llm`, `providers.<name>` (one card each), `voyage`, `curation`, `curation.feedback`, `curation.ranking`, `curation.ranking.quotas`, `curation.ranking.weights.preliminary`, `curation.ranking.weights.utility`, `curation.ranking.diversity`, `editorial`, `publish`, `xtc`, `server`, `miniflux`.
|
||||||
|
|
||||||
|
`Secret` fields render as "set (from `DAILY_EPUB_…`)" / "not set" with no input. `Env` fields render disabled with "locked by `DAILY_EPUB_…` in the env file". Everything else is an input of its kind, showing the default beside it and a "reset to default" affordance. Weights render with a note that they are renormalized.
|
||||||
|
|
||||||
|
### 13.2 Saving
|
||||||
|
|
||||||
|
`POST /dashboard/settings` (admin, origin-checked) carries every editable field. The handler:
|
||||||
|
|
||||||
|
1. `reload_if_changed`; load `config_path` into `toml_edit::DocumentMut` (a missing file starts from an empty document; `config_path == None` → 400 "no config file is configured; start the server with --config").
|
||||||
|
2. For each posted field whose parsed value differs from the current **file** value (compare TOML values; a field equal to the default but absent from the file is still written when the user submitted a change to it — "explicit beats implicit"; a field unchanged from what the file holds is skipped): set it in the document at its path, creating intermediate tables as implicit tables; typed by `FieldKind` (integers → `i64`, floats → `f64` formatted with at most 6 decimals, bools, strings, `TextList` → a multi-line array with one item per line, trailing comma). Parsing failures (letters in a number, unknown enum value) collect into a field-error list and abort before writing.
|
||||||
|
3. Render the document to a string, write to `<path>.tmp.<pid>`, and validate by `Config::load(Some(&tmp))` (the same loader the CLI uses, env included). On error: remove the temp file, re-render the form with the message. On success: copy the original file's permissions onto the temp file, `rename` it over the original, and swap `state.config`.
|
||||||
|
4. Insert one `config_changes` row per changed key with the TOML literal before/after (`NULL` when absent/removed) and the viewer's id; flash "Saved N settings — they apply to the next run" (plus "restart the server for: …" when a `restart_required` key changed).
|
||||||
|
|
||||||
|
Because `generate` runs as its own process and reads `config.toml` at start, a saved setting reaches the next paper with no further plumbing. `runs.config_json` continues to snapshot what each run actually used, and the run page's config diff (§9.2) shows it.
|
||||||
|
|
||||||
|
### 13.3 Providers
|
||||||
|
|
||||||
|
The `providers.*` cards each have a "Remove" button (`POST /dashboard/settings/providers` with `action=remove&name=`; refused when an `[llm]` role names it) and the page has an "Add provider" form (`action=add&name=&kind=`; name validated as `[a-z0-9_]+`, must not exist). Adding inserts a `[providers.<name>]` table whose keys are `ProviderConfig::default()` with the chosen `kind` and a `base_url`/`model` placeholder, via `toml::to_string` → parsed into `toml_edit::Item`. Both go through the same validate-and-rename path and log `config_changes` rows (`providers.<name>` with the whole table as the literal).
|
||||||
|
|
||||||
|
### 13.4 History
|
||||||
|
|
||||||
|
`GET /dashboard/settings/history`: `config_changes` newest first, 100 per page — when, who, key, before → after. Linked from the settings page and from the run page's config diff.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Jobs
|
||||||
|
|
||||||
|
### 14.1 Catalogue (`src/jobs.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum Job {
|
||||||
|
Generate { date: Option<Date> }, // "generate" | "generate-YYYY-MM-DD"
|
||||||
|
DryRun, // "dry-run" → generate --dry-run
|
||||||
|
ProfileRebuild, // "profile-rebuild" → profile rebuild
|
||||||
|
FeaturesBackfill, // "features-backfill" → features backfill --days 30 --yes
|
||||||
|
BackfillSocial, // "backfill-social" → backfill-social --days 7
|
||||||
|
FeaturesPrune, // "features-prune" → features prune
|
||||||
|
}
|
||||||
|
impl Job {
|
||||||
|
pub fn parse(name: &str) -> Option<Job>; // names match ^[a-z0-9-]+$ and the forms above
|
||||||
|
pub fn name(&self) -> String;
|
||||||
|
pub fn unit(&self) -> String; // format!("daily-epub-job@{}.service", name)
|
||||||
|
pub fn description(&self) -> &'static str; // shown on the Jobs page
|
||||||
|
pub fn takes_lock(&self) -> Option<&'static str>; // same names lock_holder uses
|
||||||
|
pub fn dangerous(&self) -> bool; // Generate (republishes an issue) → confirm dialog
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 14.2 `daily-epub job run <name>`
|
||||||
|
|
||||||
|
A new subcommand in `main.rs`. It:
|
||||||
|
|
||||||
|
1. Parses the name (unknown → exit 2 with the catalogue).
|
||||||
|
2. Takes the run lock when `takes_lock()` says so (via `lock_holder`, so "generate is already running" behaves as today).
|
||||||
|
3. Finds the newest `jobs` row with this `name` and `status = 'requested'`; if none, inserts one (`requested_by NULL` — the operator started the unit by hand). Sets `running` + `started_at`.
|
||||||
|
4. Runs the mapped command **in-process** by calling the same functions `main` uses (`pipeline::generate`, `cmd_profile_rebuild`, `cmd_features`, `cmd_backfill_social`, `telemetry::prune`), with the same logging.
|
||||||
|
5. On success sets `ok`, `finished_at`, `run_id` (for generate) and a one-line `message` (the `curation:` line for generate; counts otherwise). On error sets `failed` and `message = error`, exits non-zero so systemd records `Result=exit-code`.
|
||||||
|
|
||||||
|
### 14.3 Unit, polkit, groups
|
||||||
|
|
||||||
|
`systemd/daily-epub-job@.service` — a copy of `daily-epub-generate.service` with `Description=The Daily EPUB job %i` and `ExecStart=/usr/local/bin/daily-epub --config /etc/daily-epub/config.toml job run %i`. Same hardening, same `ReadWritePaths`, `TimeoutStartSec=45min`. The timer keeps driving `daily-epub-generate.service` unchanged.
|
||||||
|
|
||||||
|
`systemd/50-daily-epub.rules` (installed to `/etc/polkit-1/rules.d/`):
|
||||||
|
|
||||||
|
```js
|
||||||
|
polkit.addRule(function (action, subject) {
|
||||||
|
if (action.id == "org.freedesktop.systemd1.manage-units" &&
|
||||||
|
subject.user == "daily-epub" &&
|
||||||
|
action.lookup("verb") == "start" &&
|
||||||
|
/^daily-epub-job@[a-z0-9-]+\.service$/.test(action.lookup("unit"))) {
|
||||||
|
return polkit.Result.YES;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
`systemd/daily-epub.service` gains `SupplementaryGroups=systemd-journal` (journal reads) and `ReadWritePaths=/etc/daily-epub` (settings writes). Nothing else in the hardening changes; `systemctl` talks to systemd over the system bus (`AF_UNIX`, already allowed).
|
||||||
|
|
||||||
|
### 14.4 Runner and page
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[async_trait] pub trait JobRunner: Send + Sync {
|
||||||
|
async fn start(&self, unit: &str) -> Result<(), String>; // systemctl start --no-block <unit>
|
||||||
|
async fn status(&self, unit: &str) -> Result<UnitStatus, String>; // systemctl show -p ActiveState,SubState,Result,ExecMainStatus,ExecMainStartTimestamp,ExecMainExitTimestamp
|
||||||
|
async fn log(&self, unit: &str, lines: usize) -> Result<String, String>; // journalctl -u <unit> -n <lines> --no-pager -o short-iso
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`SystemdRunner` spawns the commands with `tokio::process::Command` (10 s timeout, stderr captured into the error). `MockRunner` records calls and returns scripted statuses for tests. `server.jobs_enabled = false` swaps in a `DisabledRunner` whose page says so.
|
||||||
|
|
||||||
|
`GET /dashboard/jobs`: the catalogue as cards (name, description, Start button with `data-confirm` for `dangerous()`; the `generate-<date>` card has a date input), then the jobs table newest first (name, requested by/at, started, finished, duration, status badge, message, run → run detail).
|
||||||
|
|
||||||
|
`POST /dashboard/jobs/{name}` (admin, origin-checked): parse; refuse when a job with the same unit is `requested`/`running` (409 with a flash); insert `requested`; `runner.start(unit)`; on error mark the row `failed` with the message; redirect to `/dashboard/jobs/{id}`.
|
||||||
|
|
||||||
|
`GET /dashboard/jobs/{id}`: the row, the live `UnitStatus`, and the journal tail (`server.journal_lines`, 300) in a `<pre>`. If the row is still `requested` but the unit is `inactive` with `Result=exit-code`/`failed` 30 s after `requested_at`, mark it `failed` ("unit exited before the job started; see the log"). `app.js` refreshes while `requested`/`running`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Configuration additions
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[server]
|
||||||
|
bind = "127.0.0.1:3499"
|
||||||
|
public_url = "https://daily.hallada.net"
|
||||||
|
session_days = 30 # web login lifetime, sliding
|
||||||
|
login_attempts = 10 # failed logins per IP per window before a 429
|
||||||
|
login_window_minutes = 15
|
||||||
|
jobs_enabled = true # the Jobs page starts daily-epub-job@<name>.service via systemctl
|
||||||
|
journal_lines = 300 # log tail shown on a job page
|
||||||
|
# hmac_secret / basic_auth_* unchanged
|
||||||
|
```
|
||||||
|
|
||||||
|
Validation: `session_days ≥ 1`, `login_attempts ≥ 1`, `login_window_minutes ≥ 1`, `journal_lines ∈ 10..=5000`. Update `config.example.toml`, `Config::default()` (the key-for-key test enforces both), the README table, and `SETTINGS_HELP`. These keys appear on the settings page automatically.
|
||||||
|
|
||||||
|
New dependencies: `axum-login = { git = "https://github.com/maxcountryman/axum-login.git", rev = "151c72d7a1b4646830f86b4332e6bd6e34d719a7" }` (brings `tower-sessions` 0.15 and `tower-cookies`; use the re-exports, do not add `tower-sessions` separately — a second copy would be a different version; the server builds from source with GitHub access, as it does today), `password-auth`, `tower_governor` with the `axum` feature, `time` (the type tower-sessions' `Expiry` and `Record` use; no `formatting` feature needed since the store keeps unix seconds), `async-trait` (only for our `SessionStore` impl — tower-sessions' trait still uses it; axum-login's own traits no longer do), `rpassword`, `toml_edit`, `toml` (already transitive; promote to direct for `toml::Value`), dev: `tower` with `util` (router `oneshot` tests). Do **not** add `argon2` or `tower-sessions-sqlx-store` (§2). Confirm the sqlx SQLite build has `json_extract` (it does; bundled SQLite ships JSON1).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Security checklist
|
||||||
|
|
||||||
|
- Password hashes: `password-auth` argon2id PHC strings; `User`'s `Debug` redacts the hash; never logged. Session ids are tower-sessions' random ids stored server-side; the cookie is `HttpOnly`, `Secure` on https, `SameSite=Lax`; login cycles the id; a password change or `disable` invalidates the user's sessions (`session_auth_hash` + `delete_for_user`).
|
||||||
|
- CSRF: `SameSite=Lax` plus the fetch-metadata/origin middleware on every POST (§6.4). Redirect targets validated to same-site paths.
|
||||||
|
- Login: `tower_governor` per-IP limit on `POST /login` (§6.5); equal-timing dummy verification for unknown users; no username enumeration in messages ("invalid username or password").
|
||||||
|
- Authorization: `login_required!`/`permission_required!` route layers on whole sub-routers (§6.3), so a forgotten check in one handler still redirects or 403s; `POST /rate` sits under the admin layer.
|
||||||
|
- Headers on every app response (via a `tower-http` `SetResponseHeader` layer or a small middleware): `Content-Security-Policy: default-src 'self'; img-src * data:; style-src 'self'; script-src 'self'; frame-ancestors 'none'; form-action 'self'`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`. (`img-src *` because article pages show remote images for signed-in users.) nginx keeps adding its own.
|
||||||
|
- All user text is escaped by askama; the only `|safe` inputs are `front_page_html` (already sanitized by the pipeline), `ammonia::clean` output, `comments::render_xhtml`, `world::render_xhtml` (built from sanitized comment/portal HTML), a recovered World Briefing body read from our own sanitized EPUB XHTML, and the server-rendered SVG sparklines.
|
||||||
|
- Settings: secrets never rendered, never written; validation through `Config::load` before rename; permissions preserved; every change attributed. `Path` fields are written as given — the operator is the admin, and the config validates paths on load.
|
||||||
|
- Jobs: unit names come only from the fixed catalogue regex; the polkit rule allows `start` only, on that regex only, for that user only.
|
||||||
|
- Files: `safe_join` unchanged. `robots.txt` disallows private paths. Public caching only without a cookie.
|
||||||
|
- Logs: never log passwords, tokens, cookies, or `Authorization`. The `TraceLayer` stays; add a request-id.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Tests
|
||||||
|
|
||||||
|
No test touches the network or systemd. Router-level tests use `tower::ServiceExt::oneshot` against `server::router(state)` with a temp database (`Db::open_and_migrate` on a `tempfile` path), a `MockRunner`, and `fixtures::issue()` written through `record_issue`'s helpers.
|
||||||
|
|
||||||
|
- **Migration**: temp DB through `0001`…`0004`; the new tables exist; `rating_events.user_id` present; old rows keep `user_id NULL`.
|
||||||
|
- **Users/passwords**: hash round-trips through `password-auth`; wrong password fails; malformed hash fails safely; `users add` rejects short passwords and duplicate names (case-insensitive); `role`, `disable`, `logout`, `passwd` behave and the last three delete sessions.
|
||||||
|
- **Session store**: `save`/`load`/`delete` round-trip a `Record`; `load` returns `None` past `expiry`; `user_id` is denormalized from the axum-login key and `NULL` otherwise; `delete_expired` and `delete_for_user` count correctly; a store error surfaces as `session_store::Error::Backend`.
|
||||||
|
- **Login flow (router oneshot)**: `POST /login` sets `daily_session` with `HttpOnly`, `SameSite=Lax`, and `Secure` iff the public URL is https; the cookie then reaches `/account`; a disabled user's cookie does not; a password change logs the other session out; logout clears; anonymous `GET /` sets no cookie at all; `next` is honoured only for same-site paths.
|
||||||
|
- **Guards**: anonymous `/dashboard` → 302 `/login?next=/dashboard`; signed-in `user` role → 403 site page; admin → 200; `POST /rate` follows the same three outcomes.
|
||||||
|
- **Origin check**: POST with `Sec-Fetch-Site: cross-site` → 403; absent fetch metadata and a foreign `Origin` → 403; same-origin passes; the HMAC rating route (GET) is untouched.
|
||||||
|
- **Throttle**: with a test governor config of burst 3, the fourth `POST /login` from one address → 429; another address still passes.
|
||||||
|
- **Public rendering**: `public_issue_shows_summaries_and_why_but_no_bodies` (§7.1); comment links built from social refs and `comments_url`; the Brief, article bodies, comments and World Briefing are absent publicly; the feed validates as XML, has one entry per issue, and its content is the public list with summaries and why lines; `robots.txt` content; `Cache-Control` public without a cookie, `no-store` with one.
|
||||||
|
- **Full rendering**: signed-in `/issues/{date}` contains the Brief, summaries and `why`; article page contains the body and the discussion; the fallback loader (no `issue_json`) reconstructs colophon/Behind facts and recovers the World chapter from an available EPUB while discussion links remain unavailable; downloads listed only for files that exist; `/files/epub` accepts a session, still challenges Basic auth when configured and no session.
|
||||||
|
- **issue_json**: `record_issue` writes it with empty bodies; the loader rehydrates bodies; `runs.report_json` and `issues.report_json` written; `/issues.json` returns real reports.
|
||||||
|
- **Rating**: `POST /rate` as admin appends a `dashboard` event with `user_id`; as user → 403; anonymous → redirect/401; `cleared` writes value 0; JSON and form variants; `next` validated.
|
||||||
|
- **Dashboard queries**: funnel counts match a seeded `candidate_runs` set; candidate filters and sorts are allow-listed (an unknown sort falls back, never errors); config diff finds changed dotted keys and ignores unchanged; articles list filters; article detail shows assessments, run history and rating events.
|
||||||
|
- **Ratings contributions**: decay/weight/feed-credit/in-prompt/in-rebuild computed against hand-checked values; "used last run" counts neighbours in `signals_json`.
|
||||||
|
- **Profile**: save writes the file and a `profile_versions` row; restore swaps; oversized rejected; the parsed preview matches `parse_profile_str`.
|
||||||
|
- **Settings schema**: every leaf of `Config::default()` appears exactly once; every shipped key has help text; secrets are `Secret` and never carry a value; env detection uses the derived name; enum options match `validate()`'s accepted values (a round-trip test over each option).
|
||||||
|
- **Settings writer**: starting from `config.example.toml`, changing three keys preserves every comment and the original key order (string compare of untouched lines); a new key in an absent table creates the table; `TextList` writes a multi-line array; an invalid value (e.g. `deep_keep < shortlist_keep`) is rejected by `Config::load` and the file is untouched; permissions preserved; `config_changes` rows written; provider add/remove; removing a referenced provider refused.
|
||||||
|
- **Stats refactor**: `render_stats_text(stats_data(..))` equals the previous `stats(..)` output for a seeded DB.
|
||||||
|
- **Jobs**: `Job::parse` accepts the catalogue and dated form, rejects `../x`, uppercase, and unknown names; `POST /dashboard/jobs/{name}` inserts a row and calls `MockRunner::start` with the right unit; a running duplicate is refused; a failed start marks the row; `job run` (in-process, `--skip-llm`-style config, mocked providers) flips `requested → running → ok` and sets `run_id`; the polkit rule file is present in the repo and matches the unit regex (string test).
|
||||||
|
- **Binary** (`tests/m7_server.rs` style): the shipped binary serves `/`, `/issues`, `/feed.xml`, `/login` from env-only config; `/dashboard` redirects to `/login`; `users add` then a real login over TCP works.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. Deployment — `docs/runbooks/web-dashboard-rollout.md`
|
||||||
|
|
||||||
|
Write this runbook as part of step 7; its content:
|
||||||
|
|
||||||
|
1. Build and install the binary; `daily-epub --help` shows `users`, `job`.
|
||||||
|
2. `sudo install -m0644 systemd/daily-epub-job@.service /etc/systemd/system/`; `sudo install -m0644 systemd/50-daily-epub.rules /etc/polkit-1/rules.d/`; update `/etc/systemd/system/daily-epub.service` with `SupplementaryGroups=systemd-journal` and `ReadWritePaths=… /etc/daily-epub`; `daemon-reload`.
|
||||||
|
3. Confirm `/etc/daily-epub` and `config.toml` are owned by `daily-epub` (settings writes rename into that directory) and that `profile.md` lives under `/var/lib/daily-epub` (writable `StateDirectory`).
|
||||||
|
4. Add the new `[server]` keys to `config.toml` if overriding defaults (optional).
|
||||||
|
5. Restart `daily-epub.service`; the migration `0004` applies on start. Verify `curl -s https://daily.hallada.net/` shows the latest issue and `/feed.xml` parses.
|
||||||
|
6. `sudo -u daily-epub daily-epub --config /etc/daily-epub/config.toml users add tyler --admin` (hidden prompt). Log in, open `/dashboard`.
|
||||||
|
7. Smoke test jobs: start `features-prune` from the Jobs page; watch the log tail; `systemctl status daily-epub-job@features-prune.service`. If polkit refuses, `journalctl -u polkit` shows the denied action.
|
||||||
|
8. Smoke test settings: change `curation.ranking.utility_protected` by one, confirm `config.toml` kept its comments, read `/dashboard/settings/history`, change it back.
|
||||||
|
9. Optional: raise `curation.ranking.telemetry_retention_days` for longer article history.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 19. Implementation sequence
|
||||||
|
|
||||||
|
Each step is a shippable commit or small series; `cargo fmt --check`, `cargo clippy --all-targets`, `cargo test` green at each. Do not combine steps.
|
||||||
|
|
||||||
|
1. **Foundation.** Migration `0004`, `RatingEvent.user_id`, `runs.report_json` + `issues.report_json` writes, `issues.issue_json` write and loader, `GenerateOutcome.run_id`. `web/` skeleton: `WebState`, `AppState.config()` refactor (config path passed into `serve`), layout, error pages, static assets with ETag, security headers layer. `SqliteSessionStore`, `Backend`, the axum-login/tower-sessions/tower_governor layers, CLI `users …`, `/login`, `/logout`, `/account`, the origin-check middleware. Public site: `/`, `/issues`, `/issues/{date}` (public branch), `/feed.xml`, `/robots.txt`, `PublicIssue` with its no-leak test. `/files/*` accepts sessions. README route table updated.
|
||||||
|
2. **Full issues.** `issue_full.html`, article/world/behind pages, downloads, prev/next, the rating widget and `POST /rate`, the HMAC confirmation link. `app.js` rating enhancement.
|
||||||
|
3. **Dashboard reads.** Overview, runs list/detail (funnel, admission, timings, providers, warnings, config diff, near misses, candidate table with filters and the signals partial), articles list/detail (with the run history and the text explain). Index `idx_candidate_runs_article_run`.
|
||||||
|
4. **Ratings and profile.** Ratings page with contributions and the events tab; profile editor, versions, prompt view. `MAX_RATINGS_IN_REBUILD` made `pub`.
|
||||||
|
5. **Settings.** Schema derivation, help table, form rendering, `toml_edit` writer with validate-and-rename, env locks, providers add/remove, history, config reload-on-mtime. Unit file `ReadWritePaths` change in `systemd/`.
|
||||||
|
6. **Jobs and stats.** `jobs.rs` catalogue, `job run` subcommand, `JobRunner` + `SystemdRunner`/`MockRunner`, unit template, polkit rule, `SupplementaryGroups`, the Jobs pages; the `stats_data` refactor and the stats page; overview sparklines.
|
||||||
|
7. **Docs and polish.** README (site, roles, users CLI, jobs, settings), `config.example.toml`, `docs/plans/2026-08-15-implementation-notes.md` (new verified facts with dates: polkit, unit changes, toml_edit, axum-login/tower-sessions, password-auth, tower_governor), the rollout runbook (§18), users page, `robots.txt`/feed links in the layout, a final pass over dark mode and narrow screens.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 20. Acceptance criteria
|
||||||
|
|
||||||
|
1. Anonymous `GET /issues/{date}` shows every pick's title (linking to the source), author, source, comment links, section and reading time — and contains **no** Brief, summary, `why`, body, comment text or World Briefing; a test enforces it. `/feed.xml` is a valid Atom feed with the same content.
|
||||||
|
2. A signed-in user sees the complete issue in HTML — Brief, summaries, `why` lines, article bodies, discussions, World Briefing, Behind the paper — and can download the EPUB, X4 and XTC files while they exist. Issues from before `issue_json` still render (without world/discussion).
|
||||||
|
3. Only admins can rate; a dashboard rating appends a `rating_events` row with `source = 'dashboard'` and the admin's `user_id`; the EPUB's HMAC links keep working unchanged.
|
||||||
|
4. Every article the pipeline ever recorded in `candidate_runs` is reachable from `/dashboard/articles` with its stage, reason, signals, assessments, facets, utility, cluster, admission path, editor `why`, rating history and issue appearances — the same facts `explain` prints, plus history across runs.
|
||||||
|
5. Every run is listed with its funnel, admission mix, timings, provider costs, warnings, near misses, and the config keys that changed since the previous run.
|
||||||
|
6. The ratings page shows every current verdict with its decayed neighbour weight, feed credit, prompt/rebuild membership and last-run neighbour usage, and lets the admin change, clear or annotate it.
|
||||||
|
7. The settings page lists every key in `config.toml` (derived from `Config`, so a new key appears without UI work); saving writes the file with comments preserved, validates through `Config::load`, records the change, and a hand edit on disk shows up on the next page view. Secrets are never displayed; env-overridden keys are locked.
|
||||||
|
8. `profile.md` is editable with version history; the prompt and learned adjustments are viewable.
|
||||||
|
9. The Jobs page starts `daily-epub-job@<name>.service` through systemd + polkit, shows status and the journal tail, and links a generate job to its run; the server never runs the pipeline in-process.
|
||||||
|
10. Sessions come from axum-login/tower-sessions with password-auth hashes, `SameSite=Lax` cookies plus an origin check on every POST, a tower_governor login throttle, and a CSP; no password, token or API key is ever logged, rendered or stored in plain form.
|
||||||
|
11. Existing routes (`/r`, `/opds`, `/files`, `/healthz`, `/issues.json`) behave as before, and `/issues.json` now carries real reports.
|
||||||
|
12. `cargo test` covers all of §17 without network or systemd.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 21. Deferred, with triggers
|
||||||
|
|
||||||
|
| Item | Trigger |
|
||||||
|
|---|---|
|
||||||
|
| Per-user personalization (per-user `rating_events`, profiles, issues) | A second real reader. `user_id` on `rating_events` is the hook; nothing else is prepared on purpose. |
|
||||||
|
| Editing `data/scour-interests.opml` from the UI | The operator asks; `## Interests` in `profile.md` already covers additions. |
|
||||||
|
| Search over article bodies (FTS5) | Title/URL `LIKE` feels slow or insufficient. |
|
||||||
|
| Live log streaming (SSE) on the job page | The 5-second refresh feels slow. |
|
||||||
|
| Passkeys / WebAuthn | A second admin or a phishing concern. |
|
||||||
|
| Backfilling `issue_json` for old issues from the EPUB files on disk | Only the last `retention_days` of EPUBs exist; the fallback renderer covers the rest. |
|
||||||
|
| Charts beyond sparklines | `stats` grows a question the tables cannot answer. |
|
||||||
|
|
||||||
|
**2026-09-03:** the operator chose to publish summaries and why lines; bodies/Brief/World/comments remain private. Public per-article why lines are therefore no longer deferred.
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
# Shared brief — web dashboard v2 (design pass + fixes)
|
||||||
|
|
||||||
|
The web site and operator dashboard planned in `docs/plans/2026-09-03-web-dashboard.md`
|
||||||
|
is implemented and deployed (see `docs/plans/briefs/web-dashboard/handoff-step*.md`).
|
||||||
|
This second pass, requested by the operator on 2026-09-03, does six things:
|
||||||
|
|
||||||
|
1. Replaces the hand-written `src/web/static/app.css` with a **Tailwind CSS v4**
|
||||||
|
setup and a fresh, more refined design that keeps the simple newspaper feel.
|
||||||
|
2. Adds a **light/dark theme** that follows the system by default and can be
|
||||||
|
overridden by the reader (persisted in `localStorage`).
|
||||||
|
3. **Public** issue pages now show each article's AI summary and its
|
||||||
|
"Why it's here" line (a deliberate reversal of plan §7.1's "no generated text";
|
||||||
|
article bodies, The Brief, the World Briefing and comments stay private).
|
||||||
|
4. Signed-in issue pages get a **table-of-contents sidebar** (chapters = articles)
|
||||||
|
that collapses into a hamburger menu on mobile and shows where the reader is.
|
||||||
|
5. Signed-in issue pages always offer the **World Briefing** and **Behind the paper**
|
||||||
|
chapters, including for issues published before `issues.issue_json` existed.
|
||||||
|
6. The **Colophon** on those legacy issues shows real numbers instead of zeros.
|
||||||
|
|
||||||
|
Items 5–6 have one root cause: `web::issue::load` falls back to a reduced
|
||||||
|
`Issue` (default `Colophon`, no `world_briefing`, default `BehindThePaper`,
|
||||||
|
`from_json == false`) whenever `issues.issue_json` is NULL — which is every
|
||||||
|
issue published before migration `0004_web.sql` ran (2026-09-03).
|
||||||
|
|
||||||
|
Each step below has its own brief (`01`…`04`). Read the original plan's §4, §7,
|
||||||
|
§8 and §16 and `docs/plans/2026-08-15-implementation-notes.md` for conventions:
|
||||||
|
runtime sqlx queries with manual row mapping, `jiff` timestamps, `thiserror`/`anyhow`
|
||||||
|
errors, askama 0.16 templates, no network in tests, rustfmt defaults, no `unwrap()`
|
||||||
|
outside tests.
|
||||||
|
|
||||||
|
## Ground rules
|
||||||
|
|
||||||
|
- You work in the git worktree named in your brief. **Never touch the main
|
||||||
|
checkout** at `/home/thallada/workspace/the-daily-epub`. Finish by committing
|
||||||
|
**one** commit on your worktree's branch (message prefix `Web dashboard v2 step N:`).
|
||||||
|
- Do not edit migrations. Do not change routes, auth, or the security headers
|
||||||
|
(`web::security_headers`) — in particular the CSP stays
|
||||||
|
`default-src 'self'; img-src * data:; style-src 'self'; script-src 'self'; …`,
|
||||||
|
so **no inline `<style>` or `<script>`, no `style=""` attributes, no CDN**.
|
||||||
|
Fonts and scripts are served from `/static/…` (`include_str!`/`include_bytes!`).
|
||||||
|
- Every `|safe` in a template must be one of the sanitized inputs listed in plan
|
||||||
|
§16 (plus, after step 1, the world chapter body recovered from our own EPUB).
|
||||||
|
- Keep every hook `src/web/static/app.js` relies on: `form.rating`,
|
||||||
|
`button[data-label]`, the `.active` class and `aria-pressed` on rating
|
||||||
|
buttons, `form[data-confirm]`, `details[id]`, `table[data-filter]`,
|
||||||
|
`.table-filter`, `button[data-reset]`/`data-default`, `[data-refresh]`,
|
||||||
|
`.scroll-x`. Keep all `id`, `name`, `action` and `aria-*` attributes that
|
||||||
|
handlers or tests use. Router tests assert on text and some class names; when
|
||||||
|
a test breaks only because a purely cosmetic class was renamed, update the test.
|
||||||
|
- Tests: unit tests inline; router tests with `tower::ServiceExt::oneshot`
|
||||||
|
against `server::router(...)` over a temp DB, never binding a socket.
|
||||||
|
- Keep `cargo fmt`, `cargo clippy --all-targets -- -D warnings` and `cargo test`
|
||||||
|
green. Finish with those three commands and report their output.
|
||||||
|
- **Sandbox note:** inside the Codex sandbox `bind()` on 127.0.0.1 is forbidden,
|
||||||
|
so exactly these pre-existing tests fail there and must be ignored:
|
||||||
|
`curate::llm::tests::anthropic_*` (4), `extract::tests::relative_urls_resolve_against_the_url_we_landed_on`,
|
||||||
|
`server::tests::*` (5 that bind a listener), and `tests/m7_server.rs`. Do not
|
||||||
|
"fix" them. The orchestrator runs the full suite outside the sandbox. If you
|
||||||
|
cannot run the dev server for the same reason, rely on router tests and on
|
||||||
|
rendering templates in tests; say so in the handoff.
|
||||||
|
- This host has 4 cores and 7 GB; another agent builds Rust at the same time.
|
||||||
|
Prefer `cargo test --lib web::` style narrow runs while iterating and one full
|
||||||
|
`cargo test` at the end.
|
||||||
|
- Don't gold-plate. Where this brief and the code disagree, follow the code and
|
||||||
|
note the deviation in your handoff.
|
||||||
|
- When you finish, write `docs/plans/briefs/web-dashboard-v2/handoff-step<N>.md`:
|
||||||
|
what landed, deviations and why, anything left for later, test counts, and
|
||||||
|
(for design steps) which pages you screenshotted.
|
||||||
|
|
||||||
|
## Previewing the site locally
|
||||||
|
|
||||||
|
`examples/seed_dev_db.rs` builds a throwaway database with two issues (No. 18 on
|
||||||
|
2026-09-01 is a *legacy* issue without `issue_json`; No. 19 on 2026-09-02 has the
|
||||||
|
full snapshot), nine articles across four sections, an `admin` and a `reader`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo run --example seed_dev_db -- ./dev # ./dev is git-ignored
|
||||||
|
DAILY_EPUB_SERVER__HMAC_SECRET=$(head -c 48 /dev/urandom | base64) \
|
||||||
|
cargo run -- --config ./dev/config.toml serve # http://127.0.0.1:3599
|
||||||
|
```
|
||||||
|
|
||||||
|
Accounts: `admin` / `adminpassword123` (dashboard, ratings), `reader` /
|
||||||
|
`readerpassword123` (full issues only). Anonymous visitors get the public pages.
|
||||||
|
Port 3499 belongs to the production server on this host — never use it.
|
||||||
|
|
||||||
|
Screenshots: a Playwright script that signs in, sets the theme and shoots a
|
||||||
|
list of paths lives at
|
||||||
|
`/tmp/claude-1000/-home-thallada-workspace-the-daily-epub/e963db53-510f-4312-ac87-460291a92781/scratchpad/shoot/shoot.mjs`
|
||||||
|
(`node shoot.mjs <outdir> [--user admin|reader|none] [--theme light|dark] [--mobile] [--full] /path …`,
|
||||||
|
`BASE=http://127.0.0.1:3599`). If that path is unreadable from your sandbox, the
|
||||||
|
bare headless Chromium at
|
||||||
|
`~/.cache/ms-playwright/chromium_headless_shell-1208/chrome-headless-shell-linux64/chrome-headless-shell --headless --no-sandbox --screenshot=out.png --window-size=1280,1400 URL`
|
||||||
|
works for anonymous pages. **Look at your screenshots** (the Read tool renders
|
||||||
|
PNGs) before calling a design step done.
|
||||||
|
|
||||||
|
## Design language (steps 2, 3 and 4 must follow this)
|
||||||
|
|
||||||
|
The look: a quiet, well-set morning paper. Warm off-white paper, near-black ink,
|
||||||
|
one red accent, hairline rules instead of boxes, generous whitespace, real
|
||||||
|
typographic hierarchy. Reader pages are serif; the dashboard is a sans-serif
|
||||||
|
instrument panel that shares the same paper, ink, rules and accent so the two
|
||||||
|
halves feel like one publication.
|
||||||
|
|
||||||
|
**Tailwind v4 conventions.** One source file `src/web/tailwind.css`:
|
||||||
|
`@import "tailwindcss"; @source "../web/templates"; @source "./static/app.js";`
|
||||||
|
(paths relative to the file — check they resolve), design tokens in `@theme`,
|
||||||
|
the dark variant as `@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));`.
|
||||||
|
Colours are **semantic CSS variables swapped per theme**, exposed to Tailwind
|
||||||
|
through `@theme inline`, so templates write `bg-paper text-ink border-rule`
|
||||||
|
once and both themes work without `dark:` on every element:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root { --paper:#f6f3ec; --paper-2:#ece7db; --ink:#1c1b18; --ink-2:#4f4b43; --muted:#7a7568;
|
||||||
|
--rule:#d8d2c4; --rule-strong:#1c1b18; --accent:#a3231f; --accent-hover:#7c1a16;
|
||||||
|
--loved:#2f6f46; --good:#2f6a8f; --down:#9c3f36; --warn:#8a6d1f; color-scheme:light; }
|
||||||
|
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { /* dark values */ } }
|
||||||
|
:root[data-theme="dark"] { --paper:#151513; --paper-2:#1e1d1a; --ink:#ebe6da; --ink-2:#bfb9aa; --muted:#8f8a7d;
|
||||||
|
--rule:#3a3833; --rule-strong:#ebe6da; --accent:#ef8c82; --accent-hover:#f7aaa2;
|
||||||
|
--loved:#7fcb92; --good:#7fb8d8; --down:#ea8f84; --warn:#d9b45a; color-scheme:dark; }
|
||||||
|
@theme inline { --color-paper: var(--paper); --color-paper-2: var(--paper-2); --color-ink: var(--ink); … }
|
||||||
|
```
|
||||||
|
|
||||||
|
Put the dark values in **both** the media block and the attribute block (a
|
||||||
|
small `@mixin`-free duplication is fine). Tinted badge backgrounds use
|
||||||
|
`color-mix(in oklab, var(--loved) 14%, transparent)`.
|
||||||
|
|
||||||
|
**Type.** Reader pages: *Newsreader* (variable, optical size axis, self-hosted
|
||||||
|
from `src/web/static/fonts/Newsreader.woff2` + `Newsreader-italic.woff2`,
|
||||||
|
`font-display: swap`, fallback `"Iowan Old Style", "Palatino Linotype", Georgia, serif`,
|
||||||
|
`font-optical-sizing: auto`). Body `1.0625rem/1.65`, max measure `max-w-[68ch]`.
|
||||||
|
Headlines weight 600, `leading-[1.1]`, `tracking-[-0.01em]`: article h1
|
||||||
|
`text-4xl md:text-5xl`; index headlines `text-2xl`, lead story `text-3xl`.
|
||||||
|
UI chrome (nav, datelines, bylines, meta lines, labels, buttons, forms, the
|
||||||
|
whole dashboard): the system sans stack `font-sans` (`ui-sans-serif, system-ui,
|
||||||
|
-apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif`), small
|
||||||
|
labels `text-[0.72rem] uppercase tracking-[0.12em] text-muted`. Code:
|
||||||
|
`ui-monospace, SFMono-Regular, Menlo, monospace`. Tabular numbers in tables.
|
||||||
|
|
||||||
|
**Masthead and chrome.** Centred masthead "The Daily EPUB" in Newsreader 600,
|
||||||
|
`text-5xl` (`text-3xl` on phones), `tracking-[-0.02em]`, a 1px rule above and a
|
||||||
|
3px double rule below. An "ears" row above it (sans, tiny): the dateline /
|
||||||
|
issue number on the left, the theme toggle and sign-in/account on the right.
|
||||||
|
Primary nav below the masthead as a rule-bounded row of uppercase tracked
|
||||||
|
links; the active link (`aria-current="page"`) carries a 2px accent underline.
|
||||||
|
The admin nav is a second, denser row in the same style. Footer: hairline rule,
|
||||||
|
tiny muted sans. The theme toggle is one button cycling *System → Light → Dark*
|
||||||
|
(icon + short label, `aria-label`), no dropdown.
|
||||||
|
|
||||||
|
**Reader pages.** Section headings are tracked uppercase sans labels sitting on
|
||||||
|
a rule, not big serif headings. The Brief gets a drop cap on its first paragraph
|
||||||
|
(`first-letter:` utilities, Newsreader 600, ~3 lines tall) — and nothing else
|
||||||
|
gets a drop cap. Each index entry: headline (link, no underline, hover accent),
|
||||||
|
`source · N min` meta in sans, the summary in serif at body size, then the
|
||||||
|
"Why it's here" line in italic with a 2px accent bar on its left (`border-l-2
|
||||||
|
border-accent pl-3`), then the rating control for admins. Entries are separated
|
||||||
|
by hairlines; the lead story is set larger. Article pages: h1 linking to the
|
||||||
|
source, byline/meta in sans, the summary as an italic standfirst, the body with
|
||||||
|
comfortable paragraph spacing, images `rounded-sm` with a caption-style alt
|
||||||
|
fallback, code blocks on `bg-paper-2`, blockquotes with a rule on the left,
|
||||||
|
`prev/next` as two bordered cards with "Previous"/"Next" labels. Download links
|
||||||
|
are `.btn` buttons. Links inside prose: ink text, `underline decoration-rule
|
||||||
|
underline-offset-4 hover:decoration-accent`.
|
||||||
|
|
||||||
|
**Controls.** `.btn` (sans `text-sm`, 1px `border-rule-strong`, `px-3 py-1.5`,
|
||||||
|
hover fills ink/paper, `focus-visible:ring-2 ring-accent ring-offset-2
|
||||||
|
ring-offset-paper`), `.btn-primary` filled ink. The rating widget is a
|
||||||
|
segmented control: buttons joined edge to edge, the active verdict filled ink
|
||||||
|
on paper, "clear" a muted text button. Inputs: `bg-paper border-rule rounded-sm
|
||||||
|
px-2.5 py-1.5 focus:border-ink`. Flash: `border-l-4 border-accent bg-paper-2`.
|
||||||
|
Badges: pill, tinted background + coloured text per state. Transitions 150ms on
|
||||||
|
colour only; honour `prefers-reduced-motion`.
|
||||||
|
|
||||||
|
**Dashboard.** `max-w-7xl`, sans throughout, a page header (title `text-2xl
|
||||||
|
font-semibold`, one-line description, actions on the right), stat tiles (big
|
||||||
|
tabular number, small label, optional muted delta), tables `text-sm` with sticky
|
||||||
|
`thead` on `bg-paper`, hairline row rules, `hover:bg-paper-2`, right-aligned
|
||||||
|
`tabular-nums` numerics, every table inside `.scroll-x`; filters as one inline
|
||||||
|
form of labelled controls; funnels and sparklines keep their SVG classes.
|
||||||
|
Settings keep their sticky save bar. Nothing boxed unless it is a card with a
|
||||||
|
purpose (stat tiles, job cards, settings groups).
|
||||||
|
|
||||||
|
**Layout components shared by reader and dashboard** live in
|
||||||
|
`@layer components` in `tailwind.css`: `.btn`, `.btn-primary`, `.badge` and its
|
||||||
|
state colours, `.kv` (definition-list grid), `.scroll-x`, `.prose-body`
|
||||||
|
(article/world/discussion body styling), base `table`/`th`/`td`, form controls.
|
||||||
|
Prefer utilities in templates; use a component class only when the same
|
||||||
|
composite repeats in three or more places or when server-rendered HTML we do
|
||||||
|
not control (article bodies, discussions, world chapter) needs styling.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Step 1 — backend data: public summaries, legacy issue fallback
|
||||||
|
|
||||||
|
Worktree: `/home/thallada/workspace/the-daily-epub-v2a` (branch `v2-backend`).
|
||||||
|
Read `00-shared.md` first. This step is Rust plus tiny template edits; a design
|
||||||
|
step runs in parallel in another worktree and will restyle every template, so
|
||||||
|
keep your template changes minimal and semantic (no new CSS, no new classes
|
||||||
|
beyond what you need for tests).
|
||||||
|
|
||||||
|
## A. Public issue pages show summary and "why" (shared brief item 3)
|
||||||
|
|
||||||
|
- `src/web/public.rs`: add `summary: Option<String>` and `why: Option<String>`
|
||||||
|
to `PublicEntry`, filled by `PublicIssue::from(&Issue)` the same way the
|
||||||
|
signed-in index does (`web::issue::summary_for`-style: `pick.summary`, else
|
||||||
|
`editorial.summaries[article_id]`, trimmed, empty → `None`; `why = pick.why`).
|
||||||
|
- `issue_public.html`: after the byline, render `<p class="summary">…</p>` when
|
||||||
|
present and `<p class="why"><em>Why it's here: …</em></p>` when present.
|
||||||
|
`feed_entry.html` (the Atom `<content>`): include the summary and the why line
|
||||||
|
per entry too, so feed readers see the same public presentation.
|
||||||
|
- Tests: rewrite `public_issue_carries_no_generated_text` (and any sibling
|
||||||
|
asserting summaries/why are absent, including in the feed tests) into
|
||||||
|
`public_issue_shows_summaries_and_why_but_no_bodies`: summaries and why lines
|
||||||
|
present; The Brief text, every article body sentence, the World Briefing text
|
||||||
|
and every comment string still absent. Keep the existing assertions that
|
||||||
|
titles, authors, sources and comment links are present.
|
||||||
|
- Docs: in `docs/plans/2026-09-03-web-dashboard.md` §7.1 and §21 add a dated
|
||||||
|
note ("2026-09-03: the operator chose to publish summaries and why lines;
|
||||||
|
bodies/Brief/World/comments remain private"). Update `README.md` if it
|
||||||
|
describes the public page.
|
||||||
|
|
||||||
|
## B. Legacy issues: rebuild colophon, Behind the paper and the World Briefing (items 5–6)
|
||||||
|
|
||||||
|
Context: `web::issue::load` (src/web/issue.rs) takes the fallback branch when
|
||||||
|
`issues.issue_json` is NULL. Production has such issues (everything before
|
||||||
|
2026-09-03). What *does* exist for them:
|
||||||
|
|
||||||
|
- `issues.report_json` (column since `0001_init.sql`) — the run's
|
||||||
|
`report::RunReport` (counts, `provider_costs`, `cost_usd`, `voyage_cost_usd`,
|
||||||
|
`config_json` from `pipeline::resolved_run_config`, `started_at`/`finished_at`,
|
||||||
|
timings). Verify which model names `resolved_run_config` records and read
|
||||||
|
them from there; if it lacks a name, fall back to the current `Config`
|
||||||
|
(`state.config`) the way `pipeline.rs` builds `Models` (look at how
|
||||||
|
`curator.llms.bulk/editor` and `summary_model` are resolved) and say so in a
|
||||||
|
`generator_version`-style note only if unavoidable.
|
||||||
|
- `runs` row(s) for the date (`entries_fetched`, `candidates`, `selected`,
|
||||||
|
tokens, `cost_usd`, `status`, `provider_costs_json`, `config_json`) — use the
|
||||||
|
latest finished run for the date when `issues.report_json` is NULL.
|
||||||
|
- `candidate_runs` rows for that run (curation v2) — `curate::telemetry::paper_near_misses(db, run_id, 10)`
|
||||||
|
gives the near misses exactly as the pipeline does (`pipeline::behind_the_paper`).
|
||||||
|
- The EPUB on disk (`issues.epub_path`, else `publish::issue_filename(date, Edition::Standard, "epub")`
|
||||||
|
under `config.publish.epub_dir`) contains `OEBPS/world.xhtml` when the issue
|
||||||
|
had a World Briefing, and `OEBPS/colophon.xhtml` / `OEBPS/behind.xhtml`.
|
||||||
|
|
||||||
|
Implement, in the fallback branch of `load` (or a helper module
|
||||||
|
`src/web/legacy.rs` called from it):
|
||||||
|
|
||||||
|
1. **Colophon**: `Colophon { provider_costs, models, entries_fetched, feeds_seen,
|
||||||
|
candidates, cost_usd, generator_version }` from the report (or run row). Where
|
||||||
|
a number is genuinely unknown leave 0 but make the template say "n/a" rather
|
||||||
|
than "0 from 0 feeds" — add `Option`s to `ColophonView` where needed and
|
||||||
|
render "n/a". `generated_at` already comes from the `issues` row.
|
||||||
|
2. **Behind the paper**: `BehindThePaper` built like `pipeline::behind_the_paper`
|
||||||
|
from the report counts + `paper_near_misses` + models + total cost +
|
||||||
|
`generation_secs` from `started_at`/`finished_at`. Set `has_behind` in
|
||||||
|
`issue_full` to "a report or run exists", not `from_json`, and make
|
||||||
|
`GET /issues/{date}/behind` render for legacy issues.
|
||||||
|
3. **World Briefing**: when `world_briefing` is `None` and the EPUB exists, open
|
||||||
|
it with the `zip` crate (already a dependency), read `OEBPS/world.xhtml`, and
|
||||||
|
take the inner HTML of `<body>` minus the chapter's own leading `<h1>` (the
|
||||||
|
template supplies the heading). Store it on `IssueView` as
|
||||||
|
`world_html: Option<String>` (or an enum `WorldSource { Briefing(WorldBriefing), Recovered(String) }`)
|
||||||
|
and have the `world` handler render either `world::render_xhtml(&briefing)` or
|
||||||
|
the recovered body `|safe` — it is our own sanitized XHTML, note that in the
|
||||||
|
§16 list in the plan. Set `has_world` accordingly. Missing/corrupt EPUB → no
|
||||||
|
world chapter, never an error (log at debug).
|
||||||
|
4. Keep the JSON branch untouched. Do not backfill `issue_json` in the database.
|
||||||
|
|
||||||
|
Also make the dev seed mirror production: in `examples/seed_dev_db.rs` the
|
||||||
|
legacy issue (2026-09-01) must end with `issues.issue_json = NULL`, an
|
||||||
|
`issues.report_json` present, and — so the world fallback can be exercised — a
|
||||||
|
real EPUB for it written into `dev/epubs/` with the fixture World Briefing
|
||||||
|
(`epub::build_edition`/`build_all` or whatever the builder's public entry is;
|
||||||
|
read `src/epub/mod.rs`) and its path stored in `issues.epub_path`. Add a few
|
||||||
|
`candidate_runs` rows for the legacy run via `db` helpers or SQL so near
|
||||||
|
misses render (look at `web::dashboard::tests::seed()` for the shape of
|
||||||
|
`signals_json`).
|
||||||
|
|
||||||
|
Tests (router tests over a temp DB; see the existing `seeded_issue(false)`
|
||||||
|
harness in `src/web/issue.rs`): fallback colophon shows the report's counts and
|
||||||
|
models and no "0 from 0 feeds"; `/issues/{date}/behind` renders for the legacy
|
||||||
|
issue with the near misses; `/issues/{date}/world` renders the recovered
|
||||||
|
chapter from a small EPUB built in the test with `epub::build` on the fixture
|
||||||
|
issue; `/issues/{date}` links to both chapters; `PublicIssue` never gains a
|
||||||
|
body/brief/world field (extend the existing negative test).
|
||||||
|
|
||||||
|
## Handoff
|
||||||
|
|
||||||
|
`docs/plans/briefs/web-dashboard-v2/handoff-step1.md`. Commit once on
|
||||||
|
`v2-backend`: `Web dashboard v2 step 1: public summaries, legacy issue fallback`.
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Step 2 — Tailwind v4 design system, theme toggle, reader pages
|
||||||
|
|
||||||
|
Worktree: `/home/thallada/workspace/the-daily-epub-v2b` (branch `v2-frontend`).
|
||||||
|
Read `00-shared.md` first, especially the design language — it is the spec.
|
||||||
|
You own the design system and the **reader-facing** templates. Do not restyle
|
||||||
|
`src/web/templates/dashboard/*` (step 3 does that, on top of your system); they
|
||||||
|
may look plain until then, which is expected. Do not implement the sidebar
|
||||||
|
table of contents (step 4).
|
||||||
|
|
||||||
|
## 1. Tailwind toolchain (no Node at build or run time)
|
||||||
|
|
||||||
|
- `package.json` at the repo root with `devDependencies`
|
||||||
|
`tailwindcss` and `@tailwindcss/cli` pinned to `4.3.3`, scripts
|
||||||
|
`css` (`tailwindcss -i src/web/tailwind.css -o src/web/static/app.css --minify`),
|
||||||
|
`css:watch`, and `css:check` (build to a temp file and `diff` against the
|
||||||
|
committed `app.css`, non-zero on drift). `package-lock.json` committed;
|
||||||
|
`node_modules/` and `dev/` added to `.gitignore`.
|
||||||
|
- The compiled `src/web/static/app.css` is **committed** and embedded with
|
||||||
|
`include_str!` exactly as today, so `cargo build` and the server never need
|
||||||
|
Node. Document the workflow (edit templates/`tailwind.css` → `npm run css` →
|
||||||
|
commit both) in `README.md` and in `docs/runbooks/web-dashboard-rollout.md`.
|
||||||
|
- Fonts: `src/web/static/fonts/Newsreader.woff2`, `Newsreader-italic.woff2` and
|
||||||
|
`OFL.txt` are already in your worktree. Serve them from
|
||||||
|
`web::static_asset` (`include_bytes!`, `font/woff2`, same ETag/304 logic,
|
||||||
|
long cache) — extend the route so `/static/fonts/Newsreader.woff2` or
|
||||||
|
`/static/Newsreader.woff2` works (pick one, keep `safe` matching: a fixed
|
||||||
|
match arm per file, no path joins). `@font-face` in `tailwind.css` with
|
||||||
|
`font-weight: 200 800; font-stretch: normal; font-display: swap;` and the
|
||||||
|
`unicode-range` for latin from Google's CSS
|
||||||
|
(`U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD`).
|
||||||
|
Add a `<link rel="preload" as="font" type="font/woff2" crossorigin>` for the
|
||||||
|
regular face in the layout.
|
||||||
|
|
||||||
|
## 2. Theme toggle (item 2)
|
||||||
|
|
||||||
|
- `src/web/static/theme.js` (new, served like `app.js`, `include_str!`): a
|
||||||
|
handful of lines run **synchronously in `<head>`** before paint: read
|
||||||
|
`localStorage.theme` (`"light"`/`"dark"`, anything else = system), set
|
||||||
|
`document.documentElement.dataset.theme` accordingly (remove the attribute for
|
||||||
|
system). Wrap storage access in try/catch. No inline script (CSP).
|
||||||
|
- The toggle button in the layout's "ears" row: `<button type="button"
|
||||||
|
data-theme-toggle aria-label="Theme: system">` with an inline SVG icon (sun /
|
||||||
|
moon / auto) and a short visible label. `app.js` handles clicks: cycle
|
||||||
|
system → light → dark → system, persist (`removeItem` for system), update the
|
||||||
|
attribute, icon and label, and re-evaluate on `matchMedia("(prefers-color-scheme: dark)")`
|
||||||
|
changes while in system mode. The three icons can all be in the markup with
|
||||||
|
`hidden` toggled — remember `[hidden]` must win over any `display` utility.
|
||||||
|
- Everything themed goes through the semantic tokens; `color-scheme` follows
|
||||||
|
the theme so native controls and scrollbars match. Check both themes in
|
||||||
|
screenshots: contrast of muted text on paper, accent on dark paper, rating
|
||||||
|
buttons, flash, focus rings, images in dark mode.
|
||||||
|
|
||||||
|
## 3. Design system + layout
|
||||||
|
|
||||||
|
`src/web/tailwind.css` per the shared brief (tokens, dark variant, fonts,
|
||||||
|
`@layer base` for body/typography defaults, `@layer components` for `.btn`,
|
||||||
|
`.btn-primary`, `.badge` + states (`selected loved good down excluded
|
||||||
|
shortlisted admitted eligible cleared assessed triaged reason running requested
|
||||||
|
ok degraded failed dry_run`), `.kv`, `.scroll-x`, `.prose-body`, base tables
|
||||||
|
and form controls — step 3 will consume these). Rewrite `layout.html`: ears
|
||||||
|
row, masthead, primary nav with `aria-current="page"` derived from
|
||||||
|
`page.active_nav` (check what values handlers set), the admin nav row, flash,
|
||||||
|
`<main>`, footer, scripts. Add an accessible "skip to content" link.
|
||||||
|
|
||||||
|
## 4. Reader templates (restyle in place; keep every handler field)
|
||||||
|
|
||||||
|
`issue_public.html`, `issue_full.html`, `issue_list.html`, `article.html`,
|
||||||
|
`world.html`, `behind.html`, `login.html`, `account.html`, `error.html`,
|
||||||
|
`_rating_widget.html`. The public and full issue pages are the showcase — set
|
||||||
|
them like a front page: dateline, stats line, The Brief with its drop cap,
|
||||||
|
download buttons, sections as tracked labels on rules, entries per the spec,
|
||||||
|
lead story larger, the chapter links (World Briefing · Behind the paper) as a
|
||||||
|
centred rule-bounded row, the colophon as a two-column `.kv` in small sans.
|
||||||
|
Note: the backend step adds `summary`/`why` to the public entries and it will
|
||||||
|
merge after you; leave a clear place for them in `issue_public.html` (the same
|
||||||
|
summary/why treatment as the full issue) guarded with `{% if let Some(...) %}`
|
||||||
|
**only if the field exists in your tree** — otherwise just leave a comment
|
||||||
|
`{# step 1 adds summary/why here #}` so the merge is trivial.
|
||||||
|
|
||||||
|
Article page: h1 → source link, byline/meta, "Why it's here" callout, social
|
||||||
|
line, the summary as standfirst, the body in `.prose-body`, discussion with
|
||||||
|
nested comment styling (`blockquote.reply` indent, `.comment-meta` small
|
||||||
|
sans), rating control, "Read online ↗" button, prev/next cards, back link.
|
||||||
|
Login/account: a narrow centred card-free form, labelled inputs, primary button.
|
||||||
|
Archive: months as tracked labels, issues as a hairline list with date, number,
|
||||||
|
article count.
|
||||||
|
|
||||||
|
Phones (390px): masthead scales down, nav wraps as a centred row, everything
|
||||||
|
single-column, tap targets ≥ 40px, tables scroll inside `.scroll-x`.
|
||||||
|
|
||||||
|
## 5. Verify
|
||||||
|
|
||||||
|
Run the seeded dev server (shared brief), screenshot `/`, `/issues`,
|
||||||
|
`/issues/2026-09-02`, `/issues/2026-09-02/articles/1`, `/issues/2026-09-02/world`,
|
||||||
|
`/issues/2026-09-02/behind`, `/login`, `/account` as anonymous, `reader` and
|
||||||
|
`admin`, in light and dark, desktop and mobile, and **look at them**. Fix what
|
||||||
|
looks off. Make sure `npm run css:check` passes on the committed CSS, `cargo
|
||||||
|
fmt`, clippy `-D warnings` and `cargo test` are green (minus the sandbox-only
|
||||||
|
failures), then write `handoff-step2.md` and commit once on `v2-frontend`:
|
||||||
|
`Web dashboard v2 step 2: Tailwind design system, theme toggle, reader pages`.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Step 3 — dashboard restyle on the Tailwind system
|
||||||
|
|
||||||
|
Worktree and branch: given in your launch prompt. Read `00-shared.md`
|
||||||
|
(design language, "Dashboard" and "Controls" paragraphs) and
|
||||||
|
`handoff-step2.md` (what the design system provides: tokens, `.btn`, `.badge`,
|
||||||
|
`.kv`, `.scroll-x`, `.prose-body`, table and form defaults in
|
||||||
|
`src/web/tailwind.css`). Step 2 deliberately left `src/web/templates/dashboard/*`
|
||||||
|
unstyled; you restyle every one of them with Tailwind utilities plus those
|
||||||
|
components, then rebuild `src/web/static/app.css` (`npm run css`) and commit it.
|
||||||
|
|
||||||
|
Templates: `overview`, `runs`, `run`, `articles`, `article`, `ratings`,
|
||||||
|
`profile`, `stats`, `settings`, `settings_history`, `jobs`, `job`, `users`,
|
||||||
|
`_pager`, `_sparkline`, and the shared partials `_candidate_row.html`,
|
||||||
|
`_signals_table.html`, `_pagination.html` (the reader-side `_rating_widget.html`
|
||||||
|
is styled already; the dashboard variant with the note field must look right
|
||||||
|
inside tables and on the article page).
|
||||||
|
|
||||||
|
Rules of the road:
|
||||||
|
|
||||||
|
- Keep every handler field, form `action`/`name`/`id`, `data-*` hook and the
|
||||||
|
SVG classes the sparkline/funnel code emits (`.spark`, `.s0`…`.s5`, `.line`,
|
||||||
|
`.funnel`). Read `src/web/static/app.js` before touching anything it queries.
|
||||||
|
- Old CSS class names that tests assert on (grep `tests/` and the `#[cfg(test)]`
|
||||||
|
modules under `src/web/dashboard/` for `class=` and for names like `badge`,
|
||||||
|
`num`, `muted`, `card`, `superseded`) may stay as harmless extra classes or be
|
||||||
|
updated together with the test — never leave a test asserting a class the
|
||||||
|
template no longer emits.
|
||||||
|
- Page header pattern on every page: title, one-line description, actions
|
||||||
|
(buttons/links) right-aligned, then filters, then content. Stat tiles on the
|
||||||
|
overview and stats pages. Tables per the shared brief; long text cells
|
||||||
|
(`message`, `editor_why`) wrap with `overflow-wrap:anywhere`; numeric columns
|
||||||
|
right-aligned `tabular-nums`; sticky `thead` on `bg-paper`.
|
||||||
|
- Settings: grouped cards with monospace section keys, a two-column setting row
|
||||||
|
(label + help left, control right) collapsing on phones, locked (env-overridden)
|
||||||
|
fields visibly disabled with a small lock note, the sticky save bar, danger
|
||||||
|
actions in `text-down`. Profile: editor/preview split that stacks on phones.
|
||||||
|
Jobs: cards with a status badge, last run, and the start form; the job page's
|
||||||
|
journal in a scrolling monospace block. Users: a simple table.
|
||||||
|
- Both themes, desktop and mobile (390px), every page: screenshot as `admin`
|
||||||
|
with the seeded dev server and **look** before finishing.
|
||||||
|
|
||||||
|
Finish: `npm run css` + `npm run css:check`, `cargo fmt`, clippy `-D warnings`,
|
||||||
|
`cargo test`; write `handoff-step3.md`; commit once:
|
||||||
|
`Web dashboard v2 step 3: dashboard restyle`.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Step 4 — table-of-contents sidebar for signed-in issue pages
|
||||||
|
|
||||||
|
Worktree and branch: given in your launch prompt. Read `00-shared.md`,
|
||||||
|
`handoff-step1.md` (legacy world/behind availability) and `handoff-step2.md`
|
||||||
|
(design system). Item 4 of the shared brief: on every signed-in issue page —
|
||||||
|
`/issues/{date}`, `/issues/{date}/articles/{id}`, `/issues/{date}/world`,
|
||||||
|
`/issues/{date}/behind` — a sidebar lists the issue's chapters, marks where the
|
||||||
|
reader is, and lets them jump anywhere. Public pages get nothing.
|
||||||
|
|
||||||
|
## Data (Rust, `src/web/issue.rs`)
|
||||||
|
|
||||||
|
One helper `issue_toc(&IssueView, current: TocPosition) -> Toc` used by all
|
||||||
|
four handlers, passed to their templates as `toc`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
struct Toc { date: Date, display_date: String, issue_number: i64,
|
||||||
|
items: Vec<TocItem>, position: usize, total: usize, issue_href: String }
|
||||||
|
enum TocKind { Brief, Section, Chapter, World, Behind, Colophon }
|
||||||
|
struct TocItem { kind: TocKind, label: String, href: String, number: Option<usize>,
|
||||||
|
minutes: Option<i64>, current: bool }
|
||||||
|
```
|
||||||
|
|
||||||
|
Chapters are the picks in issue order (numbered 1..n across sections, section
|
||||||
|
labels interleaved as non-links), followed by World Briefing and Behind the
|
||||||
|
paper when available (`has_world`/`has_behind` as computed after step 1) and a
|
||||||
|
Colophon anchor (`/issues/{date}#colophon`). `position`/`total` count
|
||||||
|
navigable chapters only (articles + world + behind), so the article page can say
|
||||||
|
"Chapter 4 of 11"; on the issue page position is 0 ("Front page").
|
||||||
|
|
||||||
|
## Markup and behaviour
|
||||||
|
|
||||||
|
- Desktop (`lg:` and up): a two-column grid, `aside` on the left `w-64 xl:w-72`,
|
||||||
|
`sticky top-4`, `max-h-[calc(100vh-2rem)] overflow-y-auto`, thin scrollbar.
|
||||||
|
Header: issue number + date (link to the issue), a progress line "Chapter 4
|
||||||
|
of 11" and a 2px progress bar (width = position/total). Then the list:
|
||||||
|
section labels as tracked uppercase sans, chapters as numbered links in
|
||||||
|
Newsreader `text-[0.95rem]` with the read-time in muted sans, the current one
|
||||||
|
bold with a 2px accent bar on the left and `aria-current="page"`; World
|
||||||
|
Briefing / Behind the paper / Colophon after a hairline.
|
||||||
|
- Mobile (below `lg:`): the aside becomes a sticky bar under the site header:
|
||||||
|
a hamburger `<button type="button" aria-controls="toc" aria-expanded="false"
|
||||||
|
aria-label="Contents">` with the current chapter title truncated beside it and
|
||||||
|
"4 / 11" on the right. Tapping toggles a panel (`id="toc"`, `hidden`) that
|
||||||
|
drops down under the bar with the same list; close on link tap, on Escape,
|
||||||
|
and on tap outside. Body scroll stays enabled. Implement in `app.js` (no
|
||||||
|
inline handlers), progressive: without JS the panel is simply open below the
|
||||||
|
bar. Use the same `<nav>` markup for both breakpoints (one element, styled
|
||||||
|
responsively), not two copies.
|
||||||
|
- On article pages `app.js` also updates the progress bar with scroll progress
|
||||||
|
within the chapter (position-1 + scrolled fraction over total), throttled with
|
||||||
|
`requestAnimationFrame`, and honours `prefers-reduced-motion` (no transition).
|
||||||
|
- The reading column keeps its `max-w-[68ch]`; the grid must not push it
|
||||||
|
narrower than ~60ch at `lg`. At `xl` the aside gets its full width.
|
||||||
|
- Sidebar links to the current article do nothing surprising (`href` still
|
||||||
|
set). Keyboard: Tab order is bar → panel → content; the toggle is reachable.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Router tests: the toc appears for `reader` on all four pages with the right
|
||||||
|
`aria-current` item and "Chapter N of M"; it is absent on public pages and in
|
||||||
|
the anonymous issue page; the legacy issue (no `issue_json`) lists World
|
||||||
|
Briefing and Behind the paper when step 1's fallback finds them and omits
|
||||||
|
World when there is no EPUB. Unit test for `issue_toc` numbering across
|
||||||
|
sections.
|
||||||
|
|
||||||
|
Finish: rebuild CSS (`npm run css` + `css:check`), `cargo fmt`, clippy
|
||||||
|
`-D warnings`, `cargo test`; screenshots of the four pages at desktop and
|
||||||
|
mobile in both themes (panel closed and open); `handoff-step4.md`; one commit:
|
||||||
|
`Web dashboard v2 step 4: table-of-contents sidebar`.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Web dashboard v2 — step 1 handoff
|
||||||
|
|
||||||
|
## What landed
|
||||||
|
|
||||||
|
- Public issue pages and Atom entries now show each pick's trimmed summary and
|
||||||
|
its “Why it's here” line. The public view model still has no article body,
|
||||||
|
Brief, World Briefing, or discussion/comment content, and the public tests
|
||||||
|
assert those fields do not leak.
|
||||||
|
- Legacy `issues.issue_json IS NULL` loads now recover their colophon and
|
||||||
|
Behind the paper data from `issues.report_json`, matching the report to its
|
||||||
|
run by `started_at` for near misses. If the issue report is absent, the loader
|
||||||
|
uses the latest finished run for that issue date. Missing historical values
|
||||||
|
render as `n/a` in the web colophon rather than misleading zeroes.
|
||||||
|
- Historical model names come from the resolved run config. Summary-provider
|
||||||
|
resolution follows `editorial.summary_model`; missing model metadata falls
|
||||||
|
back to the current config and is called out in the generator note.
|
||||||
|
- Legacy World Briefings are recovered from `OEBPS/world.xhtml` in the stored
|
||||||
|
standard EPUB (or its canonical publish path). The EPUB body is used after
|
||||||
|
removing its own leading heading; missing or corrupt files are debug-logged
|
||||||
|
and treated as “no World chapter,” not as request errors.
|
||||||
|
- The development seed's 2026-09-01 issue now has a real report, a real EPUB
|
||||||
|
path with fixture World Briefing content, `issue_json = NULL`, and three
|
||||||
|
`candidate_runs` near misses. The 2026-09-02 issue retains a full snapshot.
|
||||||
|
- README and the dashboard plan now document the operator's 2026-09-03 public
|
||||||
|
summary/why decision and list recovered EPUB XHTML among the trusted `|safe`
|
||||||
|
inputs. `zip` moved from dev-only to runtime dependencies.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- Focused web issue tests: **10 passed**, including report and run-only
|
||||||
|
colophon fallback, recovered World/Behind routes, missing EPUB behavior, and
|
||||||
|
public/feed privacy assertions.
|
||||||
|
- Public view-model test: **1 passed**.
|
||||||
|
- Development seed smoke test: successfully generated the database and EPUB;
|
||||||
|
SQL verification showed the legacy row has a null snapshot, present report
|
||||||
|
and EPUB path, plus **3** near-miss rows.
|
||||||
|
- `cargo fmt`: clean.
|
||||||
|
- `cargo clippy --all-targets -- -D warnings`: clean.
|
||||||
|
- Unfiltered `cargo test`: library result **427 passed, 13 failed**. Every
|
||||||
|
failure was a pre-existing loopback-listener test rejected by the sandbox.
|
||||||
|
- Filtered sandbox run: **461 passed, 0 failed, 15 filtered** across library,
|
||||||
|
binary, integration, and doc-test targets. The filters were the 13 loopback
|
||||||
|
unit tests and both `tests/m7_server.rs` TCP tests.
|
||||||
|
|
||||||
|
## Deviations and follow-up
|
||||||
|
|
||||||
|
- `00-shared.md` lists four Anthropic listener tests, one extraction listener
|
||||||
|
test, five server listener tests, and `m7_server` as sandbox-only failures.
|
||||||
|
The code also has three pre-existing OpenAI mock-server tests that bind the
|
||||||
|
same forbidden loopback listener; they failed with the identical
|
||||||
|
`PermissionDenied` error. `docs/plans/2026-08-15-implementation-notes.md`
|
||||||
|
already lists those three. They were therefore filtered for the complete
|
||||||
|
sandbox verification and should run normally in the orchestrator.
|
||||||
|
- No migrations, routes, auth behavior, or security headers changed. No
|
||||||
|
database backfill is performed.
|
||||||
|
- This sandbox mounts the worktree's shared Git metadata under
|
||||||
|
`/home/thallada/workspace/the-daily-epub/.git/worktrees/the-daily-epub-v2a`
|
||||||
|
read-only. The required `git add` failed while creating `index.lock`, so the
|
||||||
|
verified changes remain unstaged and the single requested commit still needs
|
||||||
|
to be created by the orchestrator with message `Web dashboard v2 step 1:
|
||||||
|
public summaries, legacy issue fallback`. No source or data in the forbidden
|
||||||
|
main checkout was changed.
|
||||||
|
- Beyond that commit and the orchestrator's unsandboxed full-suite run, nothing
|
||||||
|
remains for step 1. This was not a design step, so no screenshots were taken.
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# Step 2 handoff — Tailwind design system, theme toggle, reader pages
|
||||||
|
|
||||||
|
Branch `v2-frontend`, worktree `/home/thallada/workspace/the-daily-epub-v2b`, one commit.
|
||||||
|
Step 3 (dashboard restyle) and step 4 (TOC sidebar) build on what is described below.
|
||||||
|
|
||||||
|
## Landed
|
||||||
|
|
||||||
|
### Toolchain
|
||||||
|
|
||||||
|
- `package.json` (root, `private`) pins `tailwindcss` and `@tailwindcss/cli` to `4.3.3` with
|
||||||
|
scripts `css`, `css:watch` and `css:check`. `package-lock.json` is committed.
|
||||||
|
`.gitignore` already carried `node_modules/` and `dev/`.
|
||||||
|
- `src/web/tailwind.css` is the single source; `src/web/static/app.css` is the **committed,
|
||||||
|
minified build** and is still embedded with `include_str!`, so `cargo build`, `cargo test`
|
||||||
|
and the deployed binary never need Node.
|
||||||
|
- `npm run css:check` rebuilds to a temp file and `diff -u`s it against the committed
|
||||||
|
`app.css`, exiting non-zero on drift (verified in both directions).
|
||||||
|
- Workflow documented in `README.md` and `docs/runbooks/web-dashboard-rollout.md`
|
||||||
|
(`npm ci && npm run css:check` added to the rollout build step).
|
||||||
|
|
||||||
|
### Assets
|
||||||
|
|
||||||
|
- `web::static_asset` gained fixed match arms (no path joins) for `theme.js`,
|
||||||
|
`Newsreader.woff2` and `Newsreader-italic.woff2`. The asset tuple is now
|
||||||
|
`(&str, &'static [u8])` so text assets use `include_str!(...).as_bytes()` and the fonts use
|
||||||
|
`include_bytes!`; the SHA-256 ETag / 304 / long-cache path is unchanged and its test still
|
||||||
|
passes. URLs are flat: `/static/Newsreader.woff2`, not `/static/fonts/…`.
|
||||||
|
- `@font-face` for both faces (`font-weight: 200 800`, `font-stretch: normal`,
|
||||||
|
`font-display: swap`, Google's latin `unicode-range`), plus a
|
||||||
|
`<link rel="preload" as="font" type="font/woff2" crossorigin>` for the regular face.
|
||||||
|
`document.fonts.check('600 48px Newsreader')` is true on a loaded page and the server log
|
||||||
|
shows no 404s.
|
||||||
|
|
||||||
|
### Theme
|
||||||
|
|
||||||
|
- `src/web/static/theme.js` runs synchronously in `<head>` (external file, CSP-safe): reads
|
||||||
|
`localStorage.theme`, sets or removes `document.documentElement.dataset.theme` before paint.
|
||||||
|
- `app.js` drives `[data-theme-toggle]`: cycles **system → light → dark → system**, persists
|
||||||
|
(`removeItem` for system), updates the attribute, the SVG icon and the visible label, keeps
|
||||||
|
`aria-label="Theme: <state>"`, and re-renders on `matchMedia` changes while in system mode.
|
||||||
|
- Colours are semantic CSS variables on `:root`, duplicated into
|
||||||
|
`@media (prefers-color-scheme: dark) :root:not([data-theme="light"])` and
|
||||||
|
`:root[data-theme="dark"]`, exposed to Tailwind via `@theme inline`, with
|
||||||
|
`@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *))`.
|
||||||
|
`color-scheme` follows the theme.
|
||||||
|
|
||||||
|
### Reader templates
|
||||||
|
|
||||||
|
`layout.html` (ears row + centred masthead with the 1px rule above and the 3px double rule
|
||||||
|
below + primary nav with `aria-current="page"` + admin nav row + flash + `<main id="content">`
|
||||||
|
+ footer + skip link), `issue_public.html`, `issue_full.html`, `issue_list.html`,
|
||||||
|
`article.html`, `world.html`, `behind.html`, `login.html`, `account.html`, `error.html` and
|
||||||
|
`_rating_widget.html` are all restyled with utilities. Every hook listed in the shared brief
|
||||||
|
(`form.rating`, `button[data-label]`, `.active`, `aria-pressed`, `form[data-confirm]`,
|
||||||
|
`details[id]`, `table[data-filter]`, `.table-filter`, `[data-refresh]`, `.scroll-x`, ids,
|
||||||
|
names, actions) is intact.
|
||||||
|
|
||||||
|
## What step 3 can use
|
||||||
|
|
||||||
|
Tokens (`@theme inline`, so both a CSS variable and a Tailwind colour utility exist):
|
||||||
|
|
||||||
|
| token | utilities |
|
||||||
|
| --- | --- |
|
||||||
|
| `--paper`, `--paper-2` | `bg-paper`, `bg-paper-2` |
|
||||||
|
| `--ink`, `--ink-2`, `--muted` | `text-ink`, `text-ink-2`, `text-muted`, `bg-ink` |
|
||||||
|
| `--rule`, `--rule-strong` | `border-rule`, `border-rule-strong` |
|
||||||
|
| `--accent`, `--accent-hover` | `text-accent`, `border-accent`, `bg-accent`, `hover:text-accent-hover` |
|
||||||
|
| `--loved`, `--good`, `--down`, `--warn` | `text-loved`, `text-good`, `text-down`, `text-warn` |
|
||||||
|
| `--font-serif` / `--font-sans` / `--font-mono` | `font-serif`, `font-sans`, `font-mono` |
|
||||||
|
|
||||||
|
Component classes in `@layer components`: `.btn`, `.btn-primary`, `.badge` + state modifiers
|
||||||
|
(`selected loved good down excluded shortlisted admitted eligible cleared assessed triaged
|
||||||
|
reason running requested ok degraded failed dry_run`), `.kv`, `.scroll-x`, `.prose-body`,
|
||||||
|
`.rating`/`.rating-prompt`/`.rating-note`, `.flash`, `.error`, `.notice`, `.dashboard`,
|
||||||
|
`.cards`/`.card`, `.muted`/`.meta`/`.num`, `.filters`, `.table-filter`, `.pager`/`.run-nav`/
|
||||||
|
`.tabs`, `pre.block`/`pre.preview`/`pre.journal`, `.funnel`, `.spark` (+ `.s0`–`.s5`, `.line`).
|
||||||
|
`@layer base` styles `table`/`thead`/`th`/`td` (sticky head on `bg-paper`, hairline rules,
|
||||||
|
`hover:bg-paper-2`, tabular numerals) and all form controls.
|
||||||
|
|
||||||
|
Repeated inline recipes step 3 will probably want: small label
|
||||||
|
`font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted`; headline
|
||||||
|
`font-semibold leading-[1.1] tracking-[-0.01em]`; reader measure
|
||||||
|
`mx-auto max-w-[68ch] px-4 sm:px-6`; dashboard shell `.dashboard` (`max-w-7xl`, sans).
|
||||||
|
|
||||||
|
## Deviations and fixes beyond the brief
|
||||||
|
|
||||||
|
- **`.block` collided with Tailwind's `block` display utility.** The legacy component was
|
||||||
|
renamed to `pre.block`, which keeps both `<pre class="block">` dashboard usages working and
|
||||||
|
frees `class="block"` for real layout. Before this, the article prev/next cards rendered as
|
||||||
|
grey monospace boxes.
|
||||||
|
- **`icon.hidden = …` does not work on SVG elements** (`hidden` is an `HTMLElement` IDL
|
||||||
|
attribute); the theme icon never changed. `app.js` now uses
|
||||||
|
`icon.toggleAttribute("hidden", …)`. `[hidden] { display:none !important }` stays in
|
||||||
|
`@layer base` so it beats display utilities.
|
||||||
|
- `.prose-body` lists had no markers after Preflight; added `list-disc` / `list-decimal` and a
|
||||||
|
muted `::marker`.
|
||||||
|
- The flash bar was flush to the viewport edge while the header used a gutter; `.flash` lost
|
||||||
|
its own `mx-auto max-w-7xl` and the layout wraps it in `mx-auto max-w-7xl px-4 sm:px-6`.
|
||||||
|
- The article prev/next grid rendered an empty first cell when there is no previous article;
|
||||||
|
the placeholder `<span>` is gone and the next card takes `sm:col-start-2`.
|
||||||
|
- `examples/seed_dev_db.rs` now writes `profile_path` into the throwaway `./dev` directory and
|
||||||
|
seeds it from `data/profile.md`. Without this, saving on `/dashboard/profile` while running
|
||||||
|
the dev server **rewrites the repository's `data/profile.md`** and breaks
|
||||||
|
`curate::profile::tests::shipped_profile_and_opml_parse`. It also fixes a pre-existing
|
||||||
|
`clippy::manual_is_multiple_of` error in that example (`cargo clippy --all-targets
|
||||||
|
-D warnings` did not pass on this branch before).
|
||||||
|
- `Page::new` for the Jobs and Users dashboard pages now passes `"jobs"` / `"users"` instead of
|
||||||
|
`"dashboard"`, so the admin nav highlights the right entry.
|
||||||
|
- `FullEntry` gained `is_lead` so the full issue's lead story is set larger (`text-3xl` vs
|
||||||
|
`text-2xl`), matching what `issue_public.html` already did.
|
||||||
|
- Fonts are served flat from `/static/<name>.woff2` (the brief allowed either shape).
|
||||||
|
- `issue_public.html` carries `{# step 1 adds summary/why here #}` at the point where the
|
||||||
|
backend step's summary/`why` markup should go; use the same treatment as `issue_full.html`
|
||||||
|
(summary as a body-size serif paragraph, then `border-l-2 border-accent pl-3 italic`).
|
||||||
|
- The dashboard templates are deliberately unstyled beyond what the base/component layers give
|
||||||
|
them. They render without errors (all nine routes smoke-tested) but look plain — step 3.
|
||||||
|
- The archive month label is still the raw `YYYY-MM` produced by `web::public`; changing it is
|
||||||
|
a backend concern and was left alone.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `npm run css` / `npm run css:check` — clean; drift is detected (exit 1) when `app.css` is
|
||||||
|
edited by hand.
|
||||||
|
- `cargo fmt`, `cargo clippy --all-targets -- -D warnings` — clean.
|
||||||
|
- `cargo test` — **475 passed, 0 failed** (439 lib + 9 + 2 + 3 + 4 + 7 + 9 + 2 integration,
|
||||||
|
one binary with 0 tests). Run outside the Codex sandbox, so the socket-binding tests ran too.
|
||||||
|
No test needed a cosmetic-class update.
|
||||||
|
- Seeded dev server on `127.0.0.1:3599`. All of `/`, `/issues`, `/issues/2026-09-01` (legacy),
|
||||||
|
`/issues/2026-09-02`, `/issues/2026-09-02/articles/1`, `/…/world`, `/…/behind`, `/account`,
|
||||||
|
`/feed.xml` and the nine `/dashboard/*` routes return 200 with **no console errors and no
|
||||||
|
asset 404s**; `/issues/1999-01-01` returns the styled 404 page.
|
||||||
|
- Theme toggle asserted with Playwright: system → light → dark → system, `localStorage.theme`
|
||||||
|
written/removed, `data-theme` set/removed, icon and label follow, state survives a reload.
|
||||||
|
- Focus order checked: the skip link is the first tab stop and becomes visible; the toggle
|
||||||
|
shows the accent focus ring.
|
||||||
|
|
||||||
|
### Screenshots
|
||||||
|
|
||||||
|
`/tmp/claude-1000/-home-thallada-workspace-the-daily-epub/e963db53-510f-4312-ac87-460291a92781/scratchpad/step2-shots/`
|
||||||
|
— 83 PNGs. `/`, `/issues`, `/issues/2026-09-02`, `/issues/2026-09-02/articles/1`, `/…/world`,
|
||||||
|
`/…/behind`, `/account` and `/login` × {anonymous, reader, admin} × {light, dark} ×
|
||||||
|
{1280px desktop, 390px mobile}, full page. Named
|
||||||
|
`<path>-<user>-<theme>[-mobile].png`. Plus `toggle-1..4.png` (the toggle cycle),
|
||||||
|
`flash-light/dark.png`, `rating-active-light.png`, `focus-skiplink.png`, `focus-toggle.png`
|
||||||
|
and `issues_1999-01-01-none-{light,dark}.png` (404 page).
|
||||||
|
|
||||||
|
## Left for later
|
||||||
|
|
||||||
|
- Step 1 adds `summary`/`why` to the public entries (marker comment in place).
|
||||||
|
- Step 3 restyles `src/web/templates/dashboard/*` on top of this system.
|
||||||
|
- Step 4 adds the collapsing table-of-contents sidebar to the signed-in issue pages.
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# Step 3 handoff — dashboard restyle
|
||||||
|
|
||||||
|
Branch `v2-dashboard`, worktree `/home/thallada/workspace/the-daily-epub-v2c`, one commit
|
||||||
|
on top of the merged `v2-frontend` work (step 1 + step 2).
|
||||||
|
|
||||||
|
## Landed
|
||||||
|
|
||||||
|
Every template under `src/web/templates/dashboard/` plus the shared partials
|
||||||
|
`_candidate_row.html`, `_signals_table.html` and `_pagination.html` is restyled with
|
||||||
|
Tailwind utilities and the component layer step 2 built. `src/web/static/app.css` is
|
||||||
|
rebuilt and committed; `npm run css:check` is clean.
|
||||||
|
|
||||||
|
### New/changed pieces in `src/web/tailwind.css`
|
||||||
|
|
||||||
|
`@layer base` gained: a disabled state for every control, `summary { cursor:pointer }`,
|
||||||
|
and a styled `<meter>` (webkit + moz pseudo-elements, `--rule` track, `--good` value,
|
||||||
|
`--down` when `.over`) for the overview budget bars.
|
||||||
|
|
||||||
|
`@layer components` gained the dashboard vocabulary the templates now use:
|
||||||
|
|
||||||
|
| class | what it is |
|
||||||
|
| --- | --- |
|
||||||
|
| `.page-head`, `.page-desc`, `.page-actions`, `.page-eyebrow` | the page header on all 13 pages: eyebrow/breadcrumb, `h1`, one-line description, right-aligned actions, hairline under it |
|
||||||
|
| `.tiles`, `.tile`, `.tile-label`, `.tile-num`, `.tile-delta` | stat tiles (overview, stats, run detail) |
|
||||||
|
| `.cell-wrap`, `.cell-tight` | long text cells (`message`, `editor_why`, notes, reasons) wrap with `overflow-wrap:anywhere` and a max width; dates/badges stay on one line |
|
||||||
|
| `.scroll-x.tall` | opt-in `max-h-[70vh] overflow-y-auto`, which is what actually makes the base layer's `thead { sticky top-0 bg-paper }` do something. Applied to the long tables (runs, candidates, articles, ratings, jobs history, users, settings history, stats) and deliberately not to the small tables inside cards |
|
||||||
|
| `.filters` / `.filter-actions` / `.form-inline` | one inline filter form per page: rule-bounded row, tiny uppercase labels above each control, buttons grouped at the end |
|
||||||
|
| `.pager`, `.tabs`, `.pagination` | pagination row and the ratings tab strip (accent underline on `.active`) |
|
||||||
|
| `.setting`, `.setting-label`, `.setting-input`, `.help`, `.default`, `.secret`, `button.reset`, `.save-bar` | settings: two-column rows collapsing to one on phones, monospace keys, disabled env-locked fields with a `locked` badge and a lock note, sticky save bar with a soft top shadow |
|
||||||
|
| `.disclosure` (`details.disclosure` + `.disclosure-body`) | the framed `<details>` blocks (ratings "How ratings enter the algorithm", stats "As text", article "Explain") |
|
||||||
|
| `.sparklines`, `.spark-figure`, `.spark-axis`, `.spark-legend` | sparkline cards; the SVG classes (`.spark`, `.s0`…`.s5`, `.line`) are untouched |
|
||||||
|
| `.funnel`, `.funnel-cell` | the funnel bar gets a `bg-paper-2` track; `class="funnel"` is unchanged because a test asserts it verbatim |
|
||||||
|
| `td .rating` / `.picks .rating` overrides | the rating widget compacted for table cells and the overview picks list: prompt hidden, `min-h-8` buttons, small note field, capped width |
|
||||||
|
| `tr.superseded` | muted row (the class stays exactly `superseded`; a test counts it) |
|
||||||
|
| `.badge` default | bare `<span class="badge">` now has a neutral tint, plus `.badge.restart` / `.badge.warn` |
|
||||||
|
|
||||||
|
`.dashboard` gained a vertical rhythm (`> * + * { mt-4 }`), section headings as tracked
|
||||||
|
uppercase labels on a rule (`.dashboard > h2`), muted underlined table links, and
|
||||||
|
`code` chips on `bg-paper-2`.
|
||||||
|
|
||||||
|
### Per page
|
||||||
|
|
||||||
|
- **overview** — five stat tiles (last run, warnings, verdicts this week, unrated picks,
|
||||||
|
active jobs) above the existing cards; budget rows are now provider + spend + a meter bar;
|
||||||
|
jobs/picks lists are hairline-separated with ink links.
|
||||||
|
- **runs** — page header with Stats/Jobs actions, inline status filter with a Reset that
|
||||||
|
only appears when a filter is set, numeric columns right-aligned, empty-state row.
|
||||||
|
- **run** — breadcrumb + status badge header, prev/issue/next as buttons in the header,
|
||||||
|
four tiles (date, duration, cost, warnings), funnel with a track, cards for the report,
|
||||||
|
empty-state row for candidates.
|
||||||
|
- **articles / article** — filter form, wrapped title cells; the article page splits its
|
||||||
|
facts into "Article" and "Provenance" cards, gives the rating widget its own
|
||||||
|
"Your verdict" section and keeps everything else as tables.
|
||||||
|
- **ratings** — description as the page header, tab strip, per-tab filter form, the
|
||||||
|
in-cell rating widget, muted superseded rows.
|
||||||
|
- **profile** — editor/preview split (`lg:grid-cols-2`, stacked on phones), monospace
|
||||||
|
textarea, previews as capped `pre.preview` blocks, history table, `.kv` theme list.
|
||||||
|
- **stats** — window buttons in the header, `summary` rendered as six stat tiles,
|
||||||
|
sparkline cards, cards, two filterable tables, text output in a disclosure.
|
||||||
|
- **jobs / job** — job cards now carry a status badge and a "last started" line linking
|
||||||
|
to that job (see the one backend change below); the job page is breadcrumb + two cards
|
||||||
|
+ a scrolling monospace journal.
|
||||||
|
- **settings / settings history / users** — as described in the table above.
|
||||||
|
|
||||||
|
### Backend changes (three, all small)
|
||||||
|
|
||||||
|
1. `JobCard` gained `last_status` / `last_requested` / `last_id`, filled by a new
|
||||||
|
`attach_last_runs` from the job rows the page already loads (matching `name` or
|
||||||
|
`name-<date>` for the dated `generate` card). No extra query. Unit-tested.
|
||||||
|
2. `stats::stats` passed `"dashboard"` as its nav key, so the admin nav highlighted
|
||||||
|
*Overview* on the Stats page; it now passes `"stats"`.
|
||||||
|
3. `settings.html` dropped the `! ` prefix in front of the "config.toml on disk does not
|
||||||
|
load" banner (the `.error` bar already says it); the assertion in
|
||||||
|
`settings_pages_render_save_and_show_hand_edits` was updated with it. That is the only
|
||||||
|
test touched.
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
|
||||||
|
- **`class="inline"` was a latent bug**, the same shape as step 2's `.block` collision:
|
||||||
|
Tailwind emits an `.inline { display:inline }` utility, which beats `form.inline`'s
|
||||||
|
`@apply flex` in the components layer, so those forms were laid out inline. The class
|
||||||
|
is renamed to `form-inline` in `jobs.html`, `profile.html` and `settings.html` and in
|
||||||
|
`tailwind.css`. Nothing asserted on it.
|
||||||
|
- **Sticky table heads need a scroll box.** `thead { sticky top-0 }` from step 2 is inert
|
||||||
|
inside a plain `overflow-x:auto` container, so the long tables opt into
|
||||||
|
`.scroll-x.tall` (`max-h-[70vh]`). Small tables inside cards keep the plain `.scroll-x`.
|
||||||
|
- The brief asks for "last run" on the job cards; the data was not in `JobCard`, so it was
|
||||||
|
added (item 1 above) rather than skipped.
|
||||||
|
- `_rating_widget.html` itself is untouched — two tests assert its exact attribute order
|
||||||
|
(`value="loved" data-label="loved" class="active"`), so the table-cell treatment is done
|
||||||
|
purely with `td .rating …` rules.
|
||||||
|
- `.dashboard > h2` renders section headings as small tracked uppercase labels on a rule
|
||||||
|
rather than as large sans headings; `.card h2`/`h3` stay normal-weight `text-base`.
|
||||||
|
|
||||||
|
## Hooks and asserted classes (all preserved)
|
||||||
|
|
||||||
|
`form.rating`, `button[data-label]`, `.active` + `aria-pressed`, `form[data-confirm]`,
|
||||||
|
`details[id]` (`ratings-how`, `profile-prompt`, plus new `stats-text` and `article-explain`),
|
||||||
|
`table[data-filter]`, `.table-filter`, `button[data-reset]`/`data-default`, `[data-refresh]`,
|
||||||
|
`.scroll-x`, `.spark`/`.s0`–`.s5`/`.line`, `class="funnel"`, `class="superseded"`,
|
||||||
|
`badge <state>`, `<h1>Users</h1>`, every form `action`/`name`/`id` and every `aria-*`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `npm run css` + `npm run css:check` — clean.
|
||||||
|
- `cargo fmt`, `cargo clippy --all-targets -- -D warnings` — clean.
|
||||||
|
- `cargo test` — **477 passed, 0 failed** (441 lib + 9 + 2 + 3 + 4 + 7 + 9 + 2 integration,
|
||||||
|
one binary with 0 tests). Run outside the Codex sandbox, so the socket-binding tests ran.
|
||||||
|
- Dev server on `127.0.0.1:3601` (port 3599/3602 belong to other agents, 3499 to
|
||||||
|
production). Every dashboard route returned 200, with **no console errors and no asset
|
||||||
|
404s** in the server log.
|
||||||
|
|
||||||
|
### Screenshots
|
||||||
|
|
||||||
|
`/tmp/claude-1000/-home-thallada-workspace-the-daily-epub/e963db53-510f-4312-ac87-460291a92781/scratchpad/step3-shots/`
|
||||||
|
— 58 PNGs, all signed in as `admin`: `/dashboard`, `/dashboard/runs`, `/dashboard/runs/2`,
|
||||||
|
`/dashboard/articles`, `/dashboard/articles/1`, `/dashboard/ratings`,
|
||||||
|
`/dashboard/ratings?tab=events`, `/dashboard/profile`, `/dashboard/stats`,
|
||||||
|
`/dashboard/jobs`, `/dashboard/jobs/1`, `/dashboard/settings`,
|
||||||
|
`/dashboard/settings/history`, `/dashboard/users` × {light, dark} × {1280px desktop,
|
||||||
|
390px mobile}, full page, plus viewport shots of the settings save bar
|
||||||
|
(`*-top.png`, desktop light/dark and mobile).
|
||||||
|
|
||||||
|
The throwaway `./dev` database was extended by hand for the shoot (four `jobs` rows, eight
|
||||||
|
`rating_events`, four `config_changes`) so the jobs, ratings and settings-history pages had
|
||||||
|
content; `dev/` is git-ignored and `examples/seed_dev_db.rs` was **not** changed. A future
|
||||||
|
design pass may want those rows in the seeder.
|
||||||
|
|
||||||
|
## Left for later
|
||||||
|
|
||||||
|
- Step 4 adds the collapsing table-of-contents sidebar to the signed-in issue pages.
|
||||||
|
- `examples/seed_dev_db.rs` still seeds no jobs, ratings or config changes.
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# Step 4 handoff — table-of-contents sidebar
|
||||||
|
|
||||||
|
Branch `v2-toc`, worktree `/home/thallada/workspace/the-daily-epub-v2d`, one commit.
|
||||||
|
Builds on step 2's design system (`handoff-step2.md`) and step 1's legacy fallback
|
||||||
|
(`handoff-step1.md`). Also folds in one reader-side fix from the step-2 review: the
|
||||||
|
phone "ears" row.
|
||||||
|
|
||||||
|
## Landed
|
||||||
|
|
||||||
|
### Data (`src/web/issue.rs`)
|
||||||
|
|
||||||
|
- `issue_toc(&IssueView, TocPosition) -> Toc`, one helper feeding all four signed-in
|
||||||
|
handlers (`render_full`, `article`, `world`, `behind`) as the template field `toc`.
|
||||||
|
- `TocPosition` (`FrontPage`, `Article(ArticleId)`, `World`, `Behind`) and `TocKind`
|
||||||
|
(`Brief`, `Section`, `Chapter`, `World`, `Behind`, `Colophon`) as the brief specifies.
|
||||||
|
- Chapters are the picks in `chapters::section_names` × `Lineup::section_picks` order —
|
||||||
|
the same order the issue index and the EPUB use — numbered `1..n` across sections with
|
||||||
|
the section names interleaved as non-links. World Briefing and Behind the paper follow
|
||||||
|
when available (`world_briefing.is_some() || world_html.is_some()`, and `has_behind`,
|
||||||
|
both as computed after step 1), then a `#colophon` anchor on the issue page.
|
||||||
|
- `position` / `total` count navigable chapters only (articles + world + behind), so an
|
||||||
|
article page says "Chapter 4 of 11"; the issue page has `position == 0` and reads
|
||||||
|
"Front page".
|
||||||
|
- New small helpers: `issue_href(date)` (replaces four inline `format!`s) and
|
||||||
|
`crate::pipeline::short_display_date(date)` → `"Wed, Sep 2"`.
|
||||||
|
|
||||||
|
### Markup (`src/web/templates/_toc.html`, included by the four reader templates)
|
||||||
|
|
||||||
|
- One `<nav id="toc" data-toc-panel>` for both breakpoints, wrapped with the article in a
|
||||||
|
`lg:grid lg:grid-cols-[16rem_minmax(0,1fr)] xl:grid-cols-[18rem_minmax(0,1fr)]` shell
|
||||||
|
inside `max-w-7xl`. The reading column keeps `max-w-[68ch]`; at `lg` (1024px viewport)
|
||||||
|
it still gets ~680px, comfortably above the ~60ch floor the brief asks for.
|
||||||
|
- Desktop: `lg:sticky lg:top-6 lg:max-h-[calc(100vh-3rem)] lg:overflow-y-auto`, thin
|
||||||
|
scrollbar. Header (issue number + short date linking to the issue, "Chapter N of M",
|
||||||
|
2px progress bar) is `lg:sticky lg:top-0` **inside** the scrollable nav, so it stays put
|
||||||
|
while the chapter list scrolls under it. Section labels are tracked uppercase sans;
|
||||||
|
chapters are numbered Newsreader `text-[0.95rem]` links with the read time in muted
|
||||||
|
sans; the current one is bold with a 2px accent bar and `aria-current="page"`. World
|
||||||
|
Briefing / Behind the paper / Colophon follow a hairline.
|
||||||
|
- Below `lg`: a sticky bar (`sticky top-0 z-30` on the wrapper, `lg:static`) with the
|
||||||
|
hamburger `<button data-toc-toggle aria-controls="toc" aria-expanded>`, the current
|
||||||
|
chapter title truncated, "4 / 11", and the progress bar as its bottom edge. The same
|
||||||
|
`<nav>` drops under it as an absolutely positioned panel.
|
||||||
|
- No inline `<style>`/`<script>` and no `style=""`; every hook step 2 listed is intact.
|
||||||
|
|
||||||
|
### Behaviour (`src/web/static/app.js`, `theme.js`)
|
||||||
|
|
||||||
|
- The panel toggles by `data-open` on the nav; `@media (width < 64rem) .has-js
|
||||||
|
[data-toc-panel]:not([data-open="true"]) { display:none }` in `tailwind.css`. `theme.js`
|
||||||
|
(synchronous, in `<head>`) adds `has-js` to `<html>`, so **without JS the panel is simply
|
||||||
|
visible** under the bar and **with JS there is no open-then-collapse flash**.
|
||||||
|
- Closes on link tap, on Escape (focus returns to the toggle), on a tap outside, and when
|
||||||
|
crossing to `lg`. Body scroll is never locked.
|
||||||
|
- `revealCurrent()` scrolls the panel — never the page — the minimum needed to bring the
|
||||||
|
current chapter into view, on load and on open. Long issues no longer hide the reader's
|
||||||
|
position behind the fold.
|
||||||
|
- Article pages (`<article data-toc-scroll>`) update every `[data-toc-progress]` with
|
||||||
|
`position - 1 + scrolled fraction`, throttled through `requestAnimationFrame` on a
|
||||||
|
passive scroll listener.
|
||||||
|
|
||||||
|
### Progress bar and CSP
|
||||||
|
|
||||||
|
The bar is a real `<progress class="toc-progress">`: a percentage width would need a
|
||||||
|
`style=""` attribute, which the CSP forbids, and Tailwind cannot generate a class for a
|
||||||
|
runtime number. It is styled in `@layer components` (2px, `--rule` track, `--accent`
|
||||||
|
value) and marked `aria-hidden="true"` because the adjacent text already says
|
||||||
|
"Chapter N of M". Reduced motion: the 150ms transition on the value is dropped both under
|
||||||
|
`prefers-reduced-motion: reduce` and while JS drives the bar (`[data-live]`), so scrolling
|
||||||
|
never animates.
|
||||||
|
|
||||||
|
### Ears row (step-2 review follow-up)
|
||||||
|
|
||||||
|
`layout.html`: the row is `flex-nowrap` with `gap-2 sm:gap-4`; the left cell is
|
||||||
|
`min-w-0 truncate whitespace-nowrap`; the right group and the theme toggle are
|
||||||
|
`shrink-0 whitespace-nowrap`; "Sign in" is `whitespace-nowrap` and a long username is
|
||||||
|
`max-w-32 truncate`. The four issue pages and `issue_public.html` now show
|
||||||
|
`short_display_date` below `sm:` and the full dateline + issue number above it, so at
|
||||||
|
390px the whole row is one line for anonymous, reader and admin.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- `cargo test` — **478 passed, 0 failed** (442 lib + 9 bin + 2 + 3 + 4 + 7 + 9 + 2
|
||||||
|
integration, 0 doc-tests). Run outside the Codex sandbox, so the socket-binding tests
|
||||||
|
ran too.
|
||||||
|
- New: `web::issue::tests::toc_numbers_chapters_across_sections_and_tracks_the_reader`
|
||||||
|
(unit — full item shape across two sections, `total == 4`, `position` for front page /
|
||||||
|
article / world / behind, the single back-matter divider, `current_label`, short date)
|
||||||
|
and `web::issue::tests::the_sidebar_marks_the_current_chapter_on_every_signed_in_issue_page`
|
||||||
|
(router — all four pages carry the bar and panel, the right "Chapter N of M", the right
|
||||||
|
`aria-current="page"` href and the colophon anchor; the anonymous issue page has none).
|
||||||
|
- Extended: the public-page test now asserts no `data-toc-toggle` / `id="toc"` leaks; the
|
||||||
|
legacy tests assert the sidebar offers World Briefing and Behind the paper, and that it
|
||||||
|
omits World when there is no EPUB to recover it from.
|
||||||
|
- Browser checks (Playwright, `shoot/toc-behaviour.mjs`): 14/14 — collapsed on load,
|
||||||
|
`aria-expanded`/`aria-controls`, open on tap, close on Escape with focus return, close on
|
||||||
|
outside tap, close + navigate on link tap, `aria-current` after navigation, desktop panel
|
||||||
|
always visible with the bar hidden, progress 4 → 5 on scrolling a chapter, no toc on the
|
||||||
|
public page, no console errors.
|
||||||
|
- `cargo fmt`, `cargo clippy --all-targets -- -D warnings`, `npm run css`,
|
||||||
|
`npm run css:check` — all clean.
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
|
||||||
|
- **`TocItem` has a seventh field, `divider: bool`.** The brief's hairline "after the
|
||||||
|
chapters" cannot be expressed in askama without knowing which entry starts the
|
||||||
|
back-matter group; the helper marks it instead. Everything else matches the brief's
|
||||||
|
shapes. `Toc` also carries `short_date` (for the header and the ears row) and gains one
|
||||||
|
method, `current_label()`, for the mobile bar.
|
||||||
|
- **The toggled element is the `<nav id="toc">` itself**, and the issue header lives inside
|
||||||
|
it (hidden below `lg`, where the sticky bar already shows the same information). There is
|
||||||
|
still exactly one copy of the list, as the brief requires.
|
||||||
|
- **The progress bar is a `<progress>` element**, not a div with a percentage width — see
|
||||||
|
above; a width would need an inline style.
|
||||||
|
- **Visibility is a CSS rule keyed on `.has-js` + `data-open`**, not the `hidden`
|
||||||
|
attribute: `[hidden]` would also hide the desktop sidebar, and a server-rendered `hidden`
|
||||||
|
would break the no-JS case.
|
||||||
|
- **`theme.js` gained one line** (`classList.add("has-js")`). It is the only script that
|
||||||
|
runs before paint, and without it the mobile panel flashes open on every load.
|
||||||
|
- Article top margins were normalised to `mt-10 lg:mt-12` across the four pages so the
|
||||||
|
sidebar and the reading column start on the same line.
|
||||||
|
- `web::public::PublicIssue` gained `short_date` (no private data; the public template
|
||||||
|
needed the phone dateline).
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
`/tmp/claude-1000/-home-thallada-workspace-the-daily-epub/e963db53-510f-4312-ac87-460291a92781/scratchpad/step4-shots/`
|
||||||
|
— 137 PNGs. Eight paths (`/issues/2026-09-02`, `…/articles/1`, `…/articles/5` — mid-issue,
|
||||||
|
`…/world`, `…/behind`, and the legacy `/issues/2026-09-01` with its `/world` and `/behind`)
|
||||||
|
× {reader, admin} × {light, dark} × {1280, 1024, 390 mobile closed, 390 mobile open}, named
|
||||||
|
`<path>-<user>-<theme>-<width|mobile>[-open].png`. Plus `-390-ears` crops of the phone ears
|
||||||
|
row for anonymous, reader and admin, `-1280-mid` (scrolled, progress bar advanced) and
|
||||||
|
`-1280-tall` (1500px, the whole sidebar including the back-matter group).
|
||||||
|
|
||||||
|
The Playwright helper is `shoot/shoot4.mjs` (adds `--width/--height`, `--click`, `--scroll`
|
||||||
|
and `--tag` to step 2's `shoot.mjs`, and aborts loudly if the sign-in is throttled).
|
||||||
|
|
||||||
|
## Notes for whoever runs the dev server next
|
||||||
|
|
||||||
|
- `src/web/static/app.css`, `app.js`, `theme.js` and the templates are all compiled into
|
||||||
|
the binary (`include_str!` / askama), so **the server must be rebuilt and restarted after
|
||||||
|
every CSS or template edit** — a browser reload alone shows the old assets.
|
||||||
|
- `server.login_attempts` defaults to 10 per 15 minutes per IP. A screenshot sweep signs in
|
||||||
|
once per invocation and trips the throttle after ten runs, which silently yields
|
||||||
|
screenshots of the sign-in page. The throwaway `./dev/config.toml` was given
|
||||||
|
`login_attempts = 2000`; `dev/` is git-ignored and `seed_dev_db` rewrites it.
|
||||||
|
|
||||||
|
## Left for later
|
||||||
|
|
||||||
|
- Nothing from step 4. On a very long issue the sidebar is taller than the viewport at
|
||||||
|
1280×900, so the back-matter group needs a scroll of the sidebar (by design — the brief's
|
||||||
|
`max-h`); `revealCurrent` handles the reader's own position.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Shared brief — web dashboard implementation agents
|
||||||
|
|
||||||
|
You are implementing one step of `docs/plans/2026-09-03-web-dashboard.md` in the
|
||||||
|
`daily-epub` Rust crate. Read that plan **in full** before writing code — every
|
||||||
|
decision in it is settled (§0), and §2 records verified facts about the crates and
|
||||||
|
the host. Also read `docs/plans/2026-08-15-implementation-notes.md` (conventions:
|
||||||
|
runtime sqlx queries with manual row mapping, `jiff` for time with RFC3339 UTC
|
||||||
|
strings in SQLite, `thiserror`/`anyhow` errors, askama templates, no network in
|
||||||
|
tests, rustfmt defaults, no `unwrap()` outside tests, tracing spans).
|
||||||
|
|
||||||
|
## Ground rules
|
||||||
|
|
||||||
|
- Work on the current branch (`web-dashboard`) in place. Do **not** commit; the
|
||||||
|
orchestrator reviews and commits. Do not create branches or stash.
|
||||||
|
- Do not edit earlier migrations (`0001`–`0003`). Step 1 creates
|
||||||
|
`migrations/0004_web.sql`; later steps may append a new migration file only if
|
||||||
|
the plan says so.
|
||||||
|
- Templates for the web live in `src/web/templates/` (`.html`, HTML-escaped by
|
||||||
|
default); `askama.toml` lists both template dirs. EPUB templates in
|
||||||
|
`src/epub/templates/` are untouched.
|
||||||
|
- Every `|safe` in a template must be one of the sanitized inputs listed in plan
|
||||||
|
§16; user text is never `|safe`.
|
||||||
|
- The existing routes (`/r/…`, `/opds…`, `/files/…`, `/healthz`, `/issues.json`)
|
||||||
|
and their tests keep working unchanged.
|
||||||
|
- Tests: unit tests inline per module; router tests with
|
||||||
|
`tower::ServiceExt::oneshot` against `server::router(...)` over a temp DB
|
||||||
|
(`tempfile`), never the network, never systemd. Add the tests the plan's §17
|
||||||
|
lists for your step.
|
||||||
|
- Keep `cargo fmt`, `cargo clippy --all-targets -- -D warnings` and `cargo test`
|
||||||
|
green. Finish with those three commands and report their output.
|
||||||
|
- **Sandbox note:** inside your sandbox `bind()` on 127.0.0.1 is forbidden, so
|
||||||
|
exactly these pre-existing tests fail there and must be ignored:
|
||||||
|
`curate::llm::tests::anthropic_*` (4), `extract::tests::relative_urls_resolve_against_the_url_we_landed_on`,
|
||||||
|
`server::tests::*` (5 that bind a listener), and any test in `tests/m7_server.rs`.
|
||||||
|
Do not "fix" them. Any **new** router test must use `oneshot` and not bind.
|
||||||
|
The orchestrator runs the full suite outside the sandbox.
|
||||||
|
- Don't gold-plate: implement what the plan says for your step and nothing from
|
||||||
|
later steps beyond stubs the plan explicitly asks for. Where the plan's
|
||||||
|
sketch and the real crate API disagree, follow the real API (read the crate
|
||||||
|
source in `~/.cargo/registry` or `~/.cargo/git`) and note the deviation.
|
||||||
|
- When you finish, write a short handoff at
|
||||||
|
`docs/plans/briefs/web-dashboard/handoff-step<N>.md`: what landed, deviations
|
||||||
|
from the plan and why, anything left for the next step, test counts.
|
||||||
|
|
||||||
|
## Parallel steps (added after step 2)
|
||||||
|
|
||||||
|
From step 3 on, steps may run **in parallel in separate git worktrees**, so
|
||||||
|
file ownership matters:
|
||||||
|
|
||||||
|
- `src/web/dashboard/mod.rs` already declares one submodule per page group
|
||||||
|
(`runs`, `articles`, `ratings`, `profile`, `settings`, `jobs`, `stats`,
|
||||||
|
`users`), each exposing `routes() -> Router<AppState>` that the dashboard
|
||||||
|
router merges under the admin layer. Put your routes in **your** submodule's
|
||||||
|
`routes()`; do not touch `web::router` in `src/web/mod.rs` for dashboard
|
||||||
|
routes. Only step 3 edits the `overview` handler in `dashboard/mod.rs`.
|
||||||
|
- You own: your `src/web/dashboard/<group>.rs` files, your templates under
|
||||||
|
`src/web/templates/dashboard/`, partials you introduce, your tests.
|
||||||
|
- Shared files you may touch **additively only** (no reformatting, reordering
|
||||||
|
or renaming of existing code): `src/web/mod.rs` (helpers), `src/db.rs` (new
|
||||||
|
query helpers appended inside `impl Db`), `src/main.rs`, `src/lib.rs`,
|
||||||
|
`Cargo.toml`, `src/web/static/app.css` and `app.js` (append a block at the
|
||||||
|
end under a `/* step N: … */` comment), `src/web/templates/layout.html`.
|
||||||
|
Keep such edits small so merges stay trivial.
|
||||||
|
- If your worktree is on its own branch, finish by committing **one** commit
|
||||||
|
on that branch (message `Web dashboard step N: …`); the orchestrator merges.
|
||||||
|
If you are on `web-dashboard` itself, do not commit.
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# Step 1 — Foundation
|
||||||
|
|
||||||
|
Read `docs/plans/briefs/web-dashboard/00-shared.md` first, then the plan
|
||||||
|
`docs/plans/2026-09-03-web-dashboard.md`. This step is plan §19 item 1. Sections
|
||||||
|
that govern it: §2 (verified crate facts — read carefully, especially the
|
||||||
|
axum-login git rev API), §3 (routes and access levels), §4 (architecture, state,
|
||||||
|
templates, design system), §5 (migration and data model), §6 (auth, sessions,
|
||||||
|
CSRF, throttle), §7 (public site), §15 (config keys and dependencies), §16
|
||||||
|
(security checklist), §17 (tests for the parts you build).
|
||||||
|
|
||||||
|
## Deliverables
|
||||||
|
|
||||||
|
1. **Dependencies** (§15): `axum-login` from git at rev
|
||||||
|
`151c72d7a1b4646830f86b4332e6bd6e34d719a7`, `password-auth`, `tower_governor`
|
||||||
|
(feature `axum`), `time`, `async-trait`, `rpassword`, `toml_edit`, `toml`
|
||||||
|
(promote to direct), dev `tower` with `util`. Use `cargo add`; do not hand-pin
|
||||||
|
old versions; do not add `tower-sessions` or `argon2` separately. Confirm
|
||||||
|
`cargo tree -d` shows a single sqlx and a single `libsqlite3-sys`.
|
||||||
|
2. **Migration `migrations/0004_web.sql`** exactly as §5.1 (users, sessions +
|
||||||
|
indexes, `rating_events.user_id`, `config_changes`, `profile_versions`,
|
||||||
|
`jobs`, `runs.report_json`, `issues.issue_json`), plus the index
|
||||||
|
`idx_candidate_runs_article_run ON candidate_runs(article_id, run_id DESC)`
|
||||||
|
from §9.3 (put it in this migration so step 3 needs no new file).
|
||||||
|
3. **Data model changes** (§5.2–§5.4): `RatingEvent.user_id: Option<i64>`
|
||||||
|
(bound by `append_rating_event`, read back by `current_ratings*` /
|
||||||
|
`rated_article_from_row`); `db::finish_run` writes `runs.report_json`;
|
||||||
|
`pipeline::record_issue` writes `issues.report_json` (fixing the never-written
|
||||||
|
column) and `issues.issue_json` (the `Issue` with every
|
||||||
|
`pick.article.content_html` emptied); `GenerateOutcome.run_id: i64`. Add the
|
||||||
|
`db` accessors the loader needs (`issue_by_date`, `issue_dates`/listing for
|
||||||
|
the archive, `latest_issue_date`).
|
||||||
|
4. **`src/web/` skeleton** (§4): `mod.rs` (`WebState`, `Html<T>`, `WebError`,
|
||||||
|
`Page` layout context, pagination + time helpers), `session.rs`
|
||||||
|
(`SqliteSessionStore`, `Backend`, `Credentials`, `AuthSession` alias, `Viewer`,
|
||||||
|
`require_same_origin` middleware), `users.rs` (Role, User with redacting
|
||||||
|
Debug, hash/verify wrappers, username/password rules, the CLI operations),
|
||||||
|
`public.rs` (`PublicIssue` + `/`, `/issues`, `/issues/{date}` public branch,
|
||||||
|
`/feed.xml`, `/robots.txt`), `issue.rs` (the `IssueView` loader with
|
||||||
|
`issue_json` → row fallback per §5.2; the *full* page template is step 2 —
|
||||||
|
for now a signed-in viewer on `/` and `/issues/{date}` may see the public
|
||||||
|
rendering plus the downloads list, and the loader must be complete and
|
||||||
|
tested), `static/{app.css,app.js,favicon.svg}` served from `/static/{file}`
|
||||||
|
with sha256 ETag / 304 and `Cache-Control: public, max-age=86400`, and the
|
||||||
|
templates `layout.html`, `error.html`, `login.html`, `account.html`,
|
||||||
|
`issue_public.html`, `issue_list.html`, `feed_entry.html`, `_pagination.html`.
|
||||||
|
`app.css` implements the §4.4 design tokens, masthead, reading column, the
|
||||||
|
dashboard table/badge/kv/funnel/spark/rating classes (so later steps only add
|
||||||
|
to it), light and dark. `app.js` can be minimal now (the `data-confirm` and
|
||||||
|
`<details>` persistence bits); the rating fetch enhancement is step 2.
|
||||||
|
5. **`AppState` refactor** (§4.2): `config: Arc<RwLock<Arc<Config>>>`,
|
||||||
|
`config_path: Option<PathBuf>`, `web: Arc<WebState>`, `AppState::config()`;
|
||||||
|
`server::serve(config, config_path, db)`; `main::serve` passes
|
||||||
|
`Config::resolve_path(cli.config)`. Existing handlers call `state.config()`.
|
||||||
|
`WebState { jobs: Arc<dyn JobRunner>, started_at, config_mtime }` — define a
|
||||||
|
minimal `JobRunner` trait + `DisabledRunner`/`MockRunner` in `src/web/mod.rs`
|
||||||
|
or a small `src/web/dashboard/jobs.rs` stub now so the state shape is final
|
||||||
|
(step 6 fills in `SystemdRunner` and the pages).
|
||||||
|
6. **Auth** (§6): session layer, auth layer, governor on `POST /login`
|
||||||
|
(`into_make_service_with_connect_info::<SocketAddr>` in `serve`; a periodic
|
||||||
|
`retain_recent` task; a periodic `delete_expired` task), `/login` GET/POST,
|
||||||
|
`/logout`, `/account` (change password, sign out everywhere), the origin-check
|
||||||
|
middleware on every POST except `/r/*`, `login_required!` /
|
||||||
|
`permission_required!` route layers on the sub-routers (a `/dashboard` stub
|
||||||
|
router with an admin-only placeholder overview page is fine so the guard
|
||||||
|
tests in §17 can run now), and the 403 → site error page `map_response`.
|
||||||
|
`/files/*` accepts a session or Basic auth per §8's last paragraph.
|
||||||
|
7. **Security headers** (§16) on every app response: CSP, nosniff, referrer
|
||||||
|
policy; `Cache-Control: no-store` on `/dashboard/*`; `Vary: Cookie` on HTML;
|
||||||
|
public pages `public, max-age=300` only without a session cookie.
|
||||||
|
8. **CLI** `daily-epub users add|passwd|role|disable|enable|list|logout` (§6.1),
|
||||||
|
none of which take the run lock; `users add --admin` bootstraps.
|
||||||
|
9. **Config** (§15): the new `[server]` keys with validation, `Config::default()`,
|
||||||
|
`config.example.toml`, README table (the key-for-key test must pass).
|
||||||
|
10. **README**: the route table gains the new public routes and the
|
||||||
|
session-or-Basic note on `/files/*`; a sentence that `SmartIpKeyExtractor`
|
||||||
|
trusts `X-Forwarded-For` because only nginx reaches the bind address.
|
||||||
|
11. **Tests** from §17: migration, users/passwords, session store, login flow
|
||||||
|
(cookie flags, disabled user, password change logs out other sessions,
|
||||||
|
logout, anonymous `/` sets no cookie, `next` validation), guards, origin
|
||||||
|
check, throttle (burst 3 test config), public rendering
|
||||||
|
(`public_issue_carries_no_generated_text`, comment links, no World Briefing,
|
||||||
|
feed is valid XML with one entry per issue, robots.txt, cache headers),
|
||||||
|
issue_json round trip + fallback loader + `runs.report_json` /
|
||||||
|
`issues.report_json` written and `/issues.json` returning real reports, and
|
||||||
|
the `/files/epub` session-or-Basic behaviour. The binary-level test in
|
||||||
|
`tests/m7_server.rs` (serves `/`, `/issues`, `/feed.xml`, `/login`;
|
||||||
|
`/dashboard` redirects; `users add` then a real login over TCP) — add it, but
|
||||||
|
remember it cannot run inside your sandbox.
|
||||||
|
|
||||||
|
## Notes and traps
|
||||||
|
|
||||||
|
- Read the axum-login source at the pinned rev under `~/.cargo/git/checkouts`
|
||||||
|
after `cargo add` to confirm the `Require` builder, `AuthSession::user().await`,
|
||||||
|
`login(&self, &user)`, the macros' signatures, and the session data key
|
||||||
|
`"axum-login.data"`. §2 describes what was verified; trust the source over the
|
||||||
|
plan's sketch if they differ, and record the difference in your handoff.
|
||||||
|
- `tower-sessions` 0.15 `SessionStore` still uses `async_trait`; axum-login's
|
||||||
|
own traits do not.
|
||||||
|
- `password_auth::verify_password` blocks — run it in `spawn_blocking`, and use
|
||||||
|
a fixed dummy hash for unknown usernames so timing does not reveal existence.
|
||||||
|
- The governor's `per_second` takes seconds per token: `login_window_minutes *
|
||||||
|
60 / login_attempts`.
|
||||||
|
- The `sessions.user_id` column is denormalized best-effort from
|
||||||
|
`record.data["axum-login.data"]["user_id"]` — verify the actual shape axum-login
|
||||||
|
stores (it may be a struct with `user_id` and `auth_hash`).
|
||||||
|
- `PublicIssue::from(&Issue)` is the only constructor and carries no generated
|
||||||
|
text; the no-leak test renders `fixtures::issue()` publicly and asserts the
|
||||||
|
Brief, every summary, every `why`, article body sentences and comment strings
|
||||||
|
are absent.
|
||||||
|
- The `serve_file` Basic-auth fallback must keep the existing OPDS client
|
||||||
|
behaviour byte for byte when no session cookie is present.
|
||||||
|
- Keep `server.rs` as the router root: `router()` merges `web::router()`
|
||||||
|
sub-routers; do not move the existing handlers.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Step 2 — Full issue views, rating widget, POST /rate
|
||||||
|
|
||||||
|
Read `00-shared.md`, then the plan `docs/plans/2026-09-03-web-dashboard.md`
|
||||||
|
(§3, §4.3–§4.4, §5.4, §6.3–§6.4, §8 including §8.1, §16, §17), then
|
||||||
|
`handoff-step1.md` for what the foundation actually shipped. This is plan §19
|
||||||
|
item 2. Build on the step 1 code as it exists; do not restructure it.
|
||||||
|
|
||||||
|
## Deliverables
|
||||||
|
|
||||||
|
1. `issue_full.html` (§8): masthead + dateline, The Brief (`front_page_html`
|
||||||
|
`|safe`), download buttons (from `IssueView.downloads`), the per-section
|
||||||
|
index like the EPUB's In-this-issue page (title → article page,
|
||||||
|
`source · N min read`, summary, Why it's here), the rating widget for admins,
|
||||||
|
links to World Briefing / Behind the paper (only when present), colophon
|
||||||
|
facts footer. `/` and `/issues/{date}` render this for any signed-in viewer.
|
||||||
|
2. `article.html` at `/issues/{date}/articles/{article_id}` (§8): header
|
||||||
|
(title → source, byline, meta line, Why, social line via
|
||||||
|
`chapters::social_line`, summary), body from `ammonia::clean` with the same
|
||||||
|
configuration the EPUB uses (find it in `src/html.rs`/`src/epub`; do not run
|
||||||
|
`to_xhtml`), `<img>` keep remote `src` and get `loading="lazy"
|
||||||
|
referrerpolicy="no-referrer"`, the discussion via `comments::render_xhtml`
|
||||||
|
when present, prev/next in issue order, "Read online ↗", rating widget for
|
||||||
|
admins. 404 (site error page) when the article is not in that issue.
|
||||||
|
3. `world.html` (`/issues/{date}/world`, `world::render_xhtml`, 404 when
|
||||||
|
absent) and `behind.html` (`/issues/{date}/behind`, the same lines as the
|
||||||
|
EPUB chapter via `chapters::behind_*_line`; near misses link to
|
||||||
|
`/dashboard/articles/{id}` for admins).
|
||||||
|
4. These three routes sit under the `login_required!` layer (§3).
|
||||||
|
5. The HMAC confirmation page (`server::confirmation_page`) gains the
|
||||||
|
"Open this issue on the site" link (§8).
|
||||||
|
6. Rating widget partial `_rating_widget.html` + `src/web/rate.rs` (§8.1):
|
||||||
|
`POST /rate` under the admin layer and the origin check; form and JSON
|
||||||
|
variants; `RatingEvent { source: "dashboard", user_id: Some(viewer.id) }`;
|
||||||
|
`cleared` writes value 0.0 with label `cleared`; `issue_date` optional with
|
||||||
|
the dashboard fallback (`db::latest_issue_date_for_article`); JSON →
|
||||||
|
`200 {"article_id","label","event_id"}`, form → flash + 303 to a validated
|
||||||
|
same-site `next`. Check how the CLI (`main.rs` `cmd_ratings`) writes the
|
||||||
|
`cleared` event and match it exactly.
|
||||||
|
7. `app.js`: intercept `.rating` forms, `fetch` with
|
||||||
|
`credentials: "same-origin"` and `Accept: application/json`, update the
|
||||||
|
active button; keep the no-JS form path working.
|
||||||
|
8. Tests (§17 "Full rendering", "Rating"): signed-in `/issues/{date}` contains
|
||||||
|
Brief, summaries and why; article page contains body and discussion; the
|
||||||
|
fallback loader (no `issue_json`) renders without world/discussion links;
|
||||||
|
downloads listed only for files that exist; `POST /rate` as admin appends a
|
||||||
|
`dashboard` event with `user_id`, as user → 403, anonymous → redirect/401,
|
||||||
|
JSON and form variants, `next` validated, `cleared` value 0.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Step 3 — Dashboard reads: overview, runs, articles
|
||||||
|
|
||||||
|
Read `00-shared.md`, the plan (§3, §4, §9 in full, §16, §17 "Dashboard
|
||||||
|
queries"), the curation v2 plan sections it cites
|
||||||
|
(`docs/plans/2026-09-02-personalized-curation-v2.md` §7 tables, §9–§13
|
||||||
|
signals/admission/utility/editor, §15 explain/stats) and the handoffs for
|
||||||
|
steps 1–2. This is plan §19 item 3. Study `src/curate/telemetry.rs`
|
||||||
|
(`ExplainRow`, `resolve_run`, `explain_row`, `near_misses`, `render_explain`,
|
||||||
|
`SignalsJson`) — the dashboard renders the same data as HTML and must reuse
|
||||||
|
those loaders/types where they fit rather than re-deriving them.
|
||||||
|
|
||||||
|
## Deliverables
|
||||||
|
|
||||||
|
1. `src/web/dashboard/mod.rs` — the overview (§9.1): last-run card from
|
||||||
|
`runs.report_json` (counters fallback), budget-today card per provider
|
||||||
|
(`config.referenced_providers()` + voyage, `db::provider_spend_for_utc_day`),
|
||||||
|
ratings this week by label, "Unrated picks" (last three issues' picks with
|
||||||
|
no explicit event, with the inline rating widget from step 2), jobs summary
|
||||||
|
(the `jobs` table; the pages come in step 6), config-on-disk `! ` lines from
|
||||||
|
`Config::check_report`. Sparklines are step 6 — leave a clearly marked
|
||||||
|
placeholder section or omit.
|
||||||
|
2. `src/web/dashboard/runs.rs` (§9.2): the list with `?status=` filter and
|
||||||
|
pagination; the detail with header + prev/next run, the funnel (from
|
||||||
|
`candidate_runs` grouped by stage/reason, in pipeline order, bars via the
|
||||||
|
`.funnel` CSS), admission mix, preference state, timings, provider usage,
|
||||||
|
warnings, per-feed top 20, the **config diff** against the previous non-dry
|
||||||
|
run (flatten both `config_json` documents to dotted keys), near misses via
|
||||||
|
`telemetry::near_misses`, and the candidates table with the allow-listed
|
||||||
|
filters (`stage`, `reason`, `admitted_by` via
|
||||||
|
`json_extract(admitted_by,'$[0]')`, `q`, `flag`) and sorts (utility default,
|
||||||
|
rank, triage, quality, fit, title), 100 per page, a `<details>` per row with
|
||||||
|
`_signals_table.html` built from `signals_json`.
|
||||||
|
3. `src/web/dashboard/articles.rs` (§9.3): the list with the latest
|
||||||
|
`candidate_runs` row join (the index `idx_candidate_runs_article_run` exists
|
||||||
|
from migration 0004 — confirm), all listed filters and sorts allow-listed,
|
||||||
|
50 per page; the detail with the six blocks in the plan's order (article
|
||||||
|
facts, assessments incl. `provider_rejected`, run history with expandable
|
||||||
|
signals, neighbours/interests, embedding metadata never the vector, rating
|
||||||
|
events). The "Explain (text)" `<details>` wraps `telemetry::render_explain`
|
||||||
|
output in `<pre>`. The rating widget here carries the note field.
|
||||||
|
4. Templates: `dashboard/overview.html`, `dashboard/runs.html`,
|
||||||
|
`dashboard/run.html`, `dashboard/articles.html`, `dashboard/article.html`,
|
||||||
|
partials `_signals_table.html`, `_candidate_row.html`. Every table inside
|
||||||
|
`.scroll-x`; badges per stage/reason/label; sticky thead; the `data-filter`
|
||||||
|
client-side filter from §4.4 in `app.js`.
|
||||||
|
5. Sort/filter parameters are validated against allow-lists and never
|
||||||
|
interpolated into SQL; an unknown sort falls back to the default, never
|
||||||
|
errors.
|
||||||
|
6. Tests (§17 "Dashboard queries"): funnel counts against a seeded
|
||||||
|
`candidate_runs` set; candidate filters/sorts allow-listed; config diff finds
|
||||||
|
changed dotted keys and ignores unchanged; articles list filters; article
|
||||||
|
detail shows assessments, run history and rating events. Router-level tests
|
||||||
|
for the admin guard on each new route.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Step 4 — Ratings page and profile page
|
||||||
|
|
||||||
|
Read `00-shared.md`, the plan (§3, §10, §11, §14.1 for the job names the
|
||||||
|
profile page references, §16, §17 "Ratings contributions" and "Profile"), the
|
||||||
|
curation v2 plan §6, §9.2–§9.3, §11 (profile/learned adjustments), and the
|
||||||
|
handoffs for steps 1–3. This is plan §19 item 4. Reuse `signals::decay`,
|
||||||
|
`signals::gate`, `signals::direct_feeds`, `PreferenceState::load/summary`,
|
||||||
|
`profile::parse_profile_str`, `profile::parse_interests`,
|
||||||
|
`themes::group_into_themes`, `profile::is_stale`; make
|
||||||
|
`profile::MAX_RATINGS_IN_REBUILD` `pub`.
|
||||||
|
|
||||||
|
## Deliverables
|
||||||
|
|
||||||
|
1. `src/web/dashboard/ratings.rs` + `dashboard/ratings.html` (§10): header from
|
||||||
|
`PreferenceState::summary()` with the "no embedding" notice; the "How
|
||||||
|
ratings enter the algorithm" `<details>` with current config values inlined
|
||||||
|
and links to the settings groups (the settings page is step 5; link to
|
||||||
|
`/dashboard/settings#curation.feedback` etc. — anchors the settings page will
|
||||||
|
provide); the **Current** tab with every column in the plan's table (verdict
|
||||||
|
+ inline widget with note, article, when·by with username, note, age→decay,
|
||||||
|
neighbour weight or "no embedding", feed credit, in prompt, in rebuild, used
|
||||||
|
last run from `signals_json.neighbours` of the latest non-dry run), filters
|
||||||
|
by label/source/feed/q; the **Events** tab (100 per page, `superseded`
|
||||||
|
marking, filters by label/source/user/date range).
|
||||||
|
2. `src/web/dashboard/profile.rs` + `dashboard/profile.html` (§11): the
|
||||||
|
`profile.md` editor (textarea, Save = origin-checked POST; reject empty or
|
||||||
|
> 64 KB; insert the previous content into `profile_versions`; write
|
||||||
|
atomically via `<path>.tmp` + rename preserving mode; flash), the live parsed
|
||||||
|
preview (server-rendered on GET is fine: the passthrough sections and the
|
||||||
|
extracted `## Interests` lines), history with 200-char previews and Restore
|
||||||
|
(`POST /dashboard/profile/restore`), standing OPML interests grouped by
|
||||||
|
theme (read-only, with path and count), the system prompt
|
||||||
|
(`kv.taste_profile` in a collapsed `<pre>`), the learned adjustments block
|
||||||
|
with its age and whether a rebuild is due, and a "Rebuild profile now"
|
||||||
|
button that POSTs to `/dashboard/jobs/profile-rebuild` (step 6 implements
|
||||||
|
the route; render the form now, disabled with a note if `jobs_enabled` is
|
||||||
|
false).
|
||||||
|
3. Tests (§17): decay/weight/feed-credit/in-prompt/in-rebuild against
|
||||||
|
hand-checked values; "used last run" counts neighbours in `signals_json`;
|
||||||
|
profile save writes the file and a `profile_versions` row; restore swaps;
|
||||||
|
oversized rejected; the parsed preview matches `parse_profile_str`; admin
|
||||||
|
guard on every new route.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Step 5 — Settings
|
||||||
|
|
||||||
|
Read `00-shared.md`, the plan (§3, §4.2 "Config reload", §13 in full, §15,
|
||||||
|
§16, §17 "Settings schema" and "Settings writer"), `src/config.rs` in full
|
||||||
|
(every struct, `validate`, `load`, `resolve_path`, `check_report`, `ENV_PREFIX`,
|
||||||
|
`ENV_SPLIT`, the `DAILY_EPUB_SECRET` alias, the stale-key checks), the README
|
||||||
|
configuration table and `config.example.toml`, and the handoffs for steps 1–4.
|
||||||
|
This is plan §19 item 5.
|
||||||
|
|
||||||
|
## Deliverables
|
||||||
|
|
||||||
|
1. `src/web/dashboard/settings.rs`: `schema(config, file) -> Vec<SettingGroup>`
|
||||||
|
derived by walking `toml::Value::try_from(Config::default())` and the
|
||||||
|
current config in parallel (§13.1) — one `SettingField` per leaf with
|
||||||
|
`path`, `group`, `kind`, `current`, `default`, `source`
|
||||||
|
(Default/File/Env(name)), `help`, `restart_required`. Kinds and the static
|
||||||
|
enum table exactly as §13.1 item 3; `SETTINGS_HELP` seeded from the README
|
||||||
|
table and `config.example.toml` comments, one entry per shipped key (test).
|
||||||
|
Groups render in the §13.1 item 5 order; each group gets an `id` anchor equal
|
||||||
|
to its dotted path (step 4 links to these).
|
||||||
|
2. `GET /dashboard/settings` (§4.2 reload-on-mtime first; report a file that
|
||||||
|
fails to load with the `!` banner and keep the previous config live),
|
||||||
|
`dashboard/settings.html`: `Secret` fields as set/not set, `Env` fields
|
||||||
|
disabled with the lock note, everything else as an input with the default
|
||||||
|
beside it and a reset affordance; weights note. `POST /dashboard/settings`
|
||||||
|
implements §13.2 exactly: `toml_edit::DocumentMut`, only changed fields
|
||||||
|
written ("explicit beats implicit"), typed by kind, `TextList` as a
|
||||||
|
multi-line array with trailing comma, field errors collected before any
|
||||||
|
write, render → `<path>.tmp.<pid>` → validate with `Config::load(Some(tmp))`
|
||||||
|
→ copy permissions → rename → swap `state.config`; one `config_changes` row
|
||||||
|
per changed key; flash with the restart note for `restart_required` keys;
|
||||||
|
`config_path == None` → 400.
|
||||||
|
3. Providers (§13.3): remove (refused when an `[llm]` role names it) and add
|
||||||
|
(`[a-z0-9_]+`, must not exist, `ProviderConfig::default()` with the chosen
|
||||||
|
kind and placeholders) through the same validate-and-rename path, with
|
||||||
|
`config_changes` rows.
|
||||||
|
4. `GET /dashboard/settings/history` (§13.4), `dashboard/settings_history.html`.
|
||||||
|
5. `systemd/daily-epub.service`: add `/etc/daily-epub` to `ReadWritePaths`
|
||||||
|
(the `SupplementaryGroups` line is step 6).
|
||||||
|
6. Tests (§17): every leaf of `Config::default()` appears exactly once; every
|
||||||
|
shipped key has help; secrets are `Secret` with no value; env detection uses
|
||||||
|
the derived name (set the env var inside the test with a unique key and
|
||||||
|
restore it); enum options round-trip through `validate()`; starting from
|
||||||
|
`config.example.toml` changing three keys preserves every comment and the
|
||||||
|
order of untouched lines; a new key in an absent table creates the table;
|
||||||
|
`TextList` multi-line; an invalid value (e.g. `deep_keep < shortlist_keep`)
|
||||||
|
is rejected and the file untouched; permissions preserved; `config_changes`
|
||||||
|
rows; provider add/remove; removing a referenced provider refused; reload
|
||||||
|
on mtime swaps the config.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Step 6 — Jobs and stats
|
||||||
|
|
||||||
|
Read `00-shared.md`, the plan (§2 host facts, §3, §4.2, §9.1 sparklines, §12,
|
||||||
|
§14 in full, §15 `jobs_enabled`/`journal_lines`, §16, §17 "Stats refactor" and
|
||||||
|
"Jobs"), `src/main.rs` (`lock_holder`, `cmd_*` helpers, `GenerateOptions`),
|
||||||
|
`src/curate/telemetry.rs` (`stats`), `systemd/`, and the handoffs for steps
|
||||||
|
1–5. This is plan §19 item 6.
|
||||||
|
|
||||||
|
## Deliverables
|
||||||
|
|
||||||
|
1. `src/jobs.rs` (§14.1): the `Job` enum, `parse` (regex `^[a-z0-9-]+$` and
|
||||||
|
the listed forms only), `name`, `unit`, `description`, `takes_lock`,
|
||||||
|
`dangerous`. Register in `lib.rs`.
|
||||||
|
2. `daily-epub job run <name>` (§14.2) in `main.rs`: parse (unknown → exit 2
|
||||||
|
with the catalogue), take the lock via `lock_holder` when `takes_lock()`,
|
||||||
|
find-or-insert the `requested` jobs row → `running`, run the mapped command
|
||||||
|
in-process with the same functions `main` uses, set `ok`/`failed`,
|
||||||
|
`finished_at`, `run_id` (generate, from `GenerateOutcome.run_id`), a one-line
|
||||||
|
`message`; non-zero exit on failure. The job lifecycle helpers live in
|
||||||
|
`src/jobs.rs` as db functions or on `Db`.
|
||||||
|
3. `systemd/daily-epub-job@.service` (copy of the generate unit with the `%i`
|
||||||
|
description and `job run %i`), `systemd/50-daily-epub.rules` exactly as
|
||||||
|
§14.3, `SupplementaryGroups=systemd-journal` on `daily-epub.service`.
|
||||||
|
4. `JobRunner` (§14.4): finalize the trait from step 1's stub; `SystemdRunner`
|
||||||
|
with `tokio::process::Command`, 10 s timeout, stderr in the error;
|
||||||
|
`MockRunner` with recorded calls and scripted statuses; `DisabledRunner`
|
||||||
|
when `jobs_enabled = false`. `serve` picks the runner from config.
|
||||||
|
5. `src/web/dashboard/jobs.rs` + `dashboard/jobs.html` + `dashboard/job.html`:
|
||||||
|
the catalogue cards (Start with `data-confirm` for `dangerous()`, a date
|
||||||
|
input on the `generate-<date>` card), the jobs table, `POST
|
||||||
|
/dashboard/jobs/{name}` (409 + flash for a duplicate `requested`/`running`
|
||||||
|
unit; insert; `runner.start`; failed start marks the row), `GET
|
||||||
|
/dashboard/jobs/{id}` (row, live `UnitStatus`, journal tail of
|
||||||
|
`server.journal_lines` in `<pre>`, the 30-second "unit exited before the job
|
||||||
|
started" rule). `reload_if_changed` on every job start (§4.2). `app.js`
|
||||||
|
refreshes the job page every 5 s while `requested`/`running`.
|
||||||
|
6. Stats (§12): refactor `telemetry::stats` into `stats_data -> StatsData` +
|
||||||
|
`render_stats_text(&StatsData) -> String` with byte-identical CLI output (a
|
||||||
|
test pins it against a seeded DB before and after); `GET /dashboard/stats`
|
||||||
|
with `?days=14|30|90`, the tables, three server-rendered SVG sparklines via
|
||||||
|
`_sparkline.html`, the retriever yield table.
|
||||||
|
7. Overview sparklines (§9.1): cost per run, selected per run, generation
|
||||||
|
seconds over the last 30 non-dry runs, using the same partial.
|
||||||
|
8. Tests (§17 "Stats refactor", "Jobs"): `Job::parse` accepts the catalogue and
|
||||||
|
the dated form, rejects `../x`, uppercase and unknown names; `POST
|
||||||
|
/dashboard/jobs/{name}` inserts a row and calls `MockRunner::start` with the
|
||||||
|
right unit; a running duplicate is refused; a failed start marks the row;
|
||||||
|
`job run` in-process (a `--skip-llm`-style config with mocked providers, e.g.
|
||||||
|
`features-prune` or `profile-rebuild` against a temp DB) flips
|
||||||
|
`requested → running → ok`; the polkit rule file is present and its regex
|
||||||
|
matches the unit name format (string test); stats text unchanged.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Step 7 — Docs, users page, polish
|
||||||
|
|
||||||
|
Read `00-shared.md`, the plan (§3, §6.1, §7.2, §18, §19 item 7, §20
|
||||||
|
acceptance criteria), the README, `config.example.toml`,
|
||||||
|
`docs/plans/2026-08-15-implementation-notes.md`, `docs/runbooks/`, and every
|
||||||
|
`handoff-step*.md`. This is plan §19 item 7.
|
||||||
|
|
||||||
|
## Deliverables
|
||||||
|
|
||||||
|
1. `src/web/dashboard/users.rs` + `dashboard/users.html`: read-only list of
|
||||||
|
users (username, role, disabled, created, last login, open session count)
|
||||||
|
and a note that edits happen through the `users` CLI.
|
||||||
|
2. README: the site (public vs signed-in vs admin), roles, the `users` CLI,
|
||||||
|
jobs (unit + polkit), settings (in-place writes, env locks), the updated
|
||||||
|
route table, the reverse-proxy note about `X-Forwarded-For`.
|
||||||
|
3. `config.example.toml`: confirm the new `[server]` keys are documented with
|
||||||
|
comments matching the README table.
|
||||||
|
4. `docs/plans/2026-08-15-implementation-notes.md`: a "Web dashboard
|
||||||
|
(2026-09-03)" section recording the verified facts with dates (polkit 124
|
||||||
|
and the rule, unit changes, toml_edit in-place writes, axum-login git rev +
|
||||||
|
tower-sessions 0.15 + our store, password-auth, tower_governor, the
|
||||||
|
sandbox/test caveats) and the implementation-time decisions the handoffs
|
||||||
|
recorded.
|
||||||
|
5. `docs/runbooks/web-dashboard-rollout.md` with the nine steps of plan §18,
|
||||||
|
written for the production host.
|
||||||
|
6. Layout polish: Atom `<link rel="alternate">` and favicon in `<head>`, the
|
||||||
|
Users link in the admin nav, a pass over dark mode and narrow screens in
|
||||||
|
`app.css` (every wide table scrolls in `.scroll-x`; the masthead wraps; nav
|
||||||
|
collapses to wrapping links), and the 404/500 pages in the site layout.
|
||||||
|
7. Walk plan §20's acceptance criteria one by one and fix any gap you can
|
||||||
|
close inside this step; list the rest in your handoff.
|
||||||
|
8. Tests: the users page under the admin guard; a smoke test that every
|
||||||
|
template referenced by a route renders with the fixture data (extend the
|
||||||
|
existing router tests rather than duplicating setup).
|
||||||
|
|
||||||
|
## Follow-ups collected during the orchestrator's reviews of steps 1–6
|
||||||
|
|
||||||
|
- Download buttons on the full issue page show raw byte counts; render them
|
||||||
|
human-readable (KB/MB).
|
||||||
|
- The `/files/*` deviation: with no Basic auth configured the files stay
|
||||||
|
public (see handoff-step1 review notes); document that in the README's route
|
||||||
|
table and the rollout runbook.
|
||||||
|
- The settings page cannot remove a shipped provider (handoff-step5); mention
|
||||||
|
it in the README settings section.
|
||||||
|
- Every step left a handoff in this directory; fold their "left for later"
|
||||||
|
items into the acceptance walk-through and list anything still open.
|
||||||
|
- Two Codex review documents exist under `docs/reviews/` for this plan; leave
|
||||||
|
them as they are.
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Step 1 handoff — foundation
|
||||||
|
|
||||||
|
## Landed
|
||||||
|
|
||||||
|
- Added migration `0004_web.sql`: users, SQLite-backed sessions and indexes,
|
||||||
|
rating attribution, config/profile/job history, report/issue snapshots, and
|
||||||
|
the candidate article/run index. `RatingEvent`/`RatedArticle` now carry a
|
||||||
|
nullable user id.
|
||||||
|
- Persisted full `runs.report_json`, final `issues.report_json`, and a compact
|
||||||
|
`issues.issue_json` with article bodies removed. The issue loader rehydrates
|
||||||
|
bodies from `articles` and has a reduced-row fallback for pre-migration
|
||||||
|
issues. `GenerateOutcome` exposes `run_id`.
|
||||||
|
- Added the `web` skeleton, final shared-state shape, Askama layout/error/public
|
||||||
|
templates, embedded CSS/JS/favicon with SHA-256 ETags, security/cache/request
|
||||||
|
headers, and `DisabledRunner`/`MockRunner` stubs.
|
||||||
|
- Added `axum-login` at the required git revision, the local sqlx 0.9 session
|
||||||
|
store, password-auth users/backend, session/auth route layers, role guards,
|
||||||
|
same-origin POST middleware, login governor and cleanup tasks, login/logout,
|
||||||
|
account/password/session revocation, and session-or-Basic file downloads.
|
||||||
|
- Added public latest/archive/issue pages, the stripped `PublicIssue` boundary,
|
||||||
|
Atom feed, and robots policy. Anonymous page loads remain session-cookie-free.
|
||||||
|
- Added all `daily-epub users` commands without the pipeline lock, new server
|
||||||
|
configuration/defaults/validation, example config, and README command/route/
|
||||||
|
reverse-proxy documentation.
|
||||||
|
- Expanded `tests/m7_server.rs` to cover env-only public routes, dashboard
|
||||||
|
redirect, CLI admin bootstrap, and a real TCP login/account request.
|
||||||
|
|
||||||
|
## Deviations and follow-up
|
||||||
|
|
||||||
|
- `rpassword` could not be added: it is absent from the local Cargo cache and
|
||||||
|
this sandbox cannot resolve `index.crates.io` (three retries failed). The CLI
|
||||||
|
currently uses an equivalent Unix `/dev/tty` no-echo double prompt and keeps
|
||||||
|
`--password-stdin`. Replace that helper with `rpassword::prompt_password`
|
||||||
|
after adding the dependency in a network-enabled environment.
|
||||||
|
- Cargo resolved direct `toml_edit` to 0.22.27 rather than the plan's observed
|
||||||
|
0.25.x release; it was selected by `cargo add` for the available toolchain and
|
||||||
|
lock graph, not hand-pinned.
|
||||||
|
- The finished issue report is attached immediately after `finish_run`, rather
|
||||||
|
than during the earlier issue snapshot write, because publish timing and the
|
||||||
|
final run status are not complete at snapshot time. The stored observable
|
||||||
|
value is the same final serialized report.
|
||||||
|
- The pinned axum-login source confirms `AuthSession::user().await`, immutable
|
||||||
|
`login`/`logout`, the macro route layers, and session key
|
||||||
|
`"axum-login.data"`; the implementation follows those real APIs.
|
||||||
|
- Full signed-in issue/article rendering and the real `POST /rate` handler are
|
||||||
|
step 2. Step 1 supplies the protected dashboard overview and admin-only 501
|
||||||
|
rate stub so route-guard tests exercise the final boundary.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `cargo fmt --check`: pass.
|
||||||
|
- `cargo clippy --all-targets -- -D warnings`: pass.
|
||||||
|
- `cargo test` with sandbox-bound tests skipped: **389 passed, 0 failed**.
|
||||||
|
Excluded were the four named Anthropic listener tests, the relative-URL
|
||||||
|
listener test, five `server::tests` listener tests, both `m7_server` TCP
|
||||||
|
tests, and three existing OpenAI fake-server tests that also bind loopback.
|
||||||
|
- Focused `cargo test web:: -- --nocapture`: **16 passed, 0 failed**.
|
||||||
|
- `cargo tree -i` shows one `sqlx` version (0.9.0) and one
|
||||||
|
`libsqlite3-sys` version (0.37.0).
|
||||||
|
|
||||||
|
## Orchestrator review (2026-09-03)
|
||||||
|
|
||||||
|
- `rpassword` added (the sandbox had no network); `read_password_hidden` is
|
||||||
|
now `rpassword::prompt_password`. `toml_edit` moved to 0.25 as the plan says.
|
||||||
|
- **Deviation from plan §8 kept on purpose:** `/files/*` with *no* Basic auth
|
||||||
|
configured stays public, exactly as before. The plan wanted a redirect to
|
||||||
|
`/login` there, but the public OPDS feed's acquisition links point at those
|
||||||
|
files and an e-reader cannot log in; acceptance criterion 11 ("`/files`
|
||||||
|
behave as before") and "OPDS clients are unaffected" win. With Basic auth
|
||||||
|
configured, a session cookie of any role bypasses it.
|
||||||
|
- A short new password on `/account/password` is a 400 form error, not a 500.
|
||||||
|
- Full suite outside the sandbox: 370 lib + all integration tests green.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Step 2 handoff — full issues and ratings
|
||||||
|
|
||||||
|
## Landed
|
||||||
|
|
||||||
|
- Signed-in `/` and `/issues/{date}` now render the full issue: Brief, download
|
||||||
|
links for artifacts that still exist, the section/article index with summaries
|
||||||
|
and `why`, admin rating widgets, World/Behind links when their snapshot data is
|
||||||
|
present, and colophon facts.
|
||||||
|
- Added login-protected article, World Briefing, and Behind the paper pages.
|
||||||
|
Article pages use the EPUB's ammonia cleaning path without XHTML conversion,
|
||||||
|
retain remote images with lazy/no-referrer attributes, include rendered
|
||||||
|
discussions, previous/next navigation, source links, and admin ratings.
|
||||||
|
- Replaced the Step 1 `/rate` stub with the admin-only form/JSON handler. Events
|
||||||
|
are append-only `dashboard` events attributed to the viewer; missing issue
|
||||||
|
dates use `latest_issue_date_for_article`; clear events use the CLI's exact
|
||||||
|
`cleared`/`0.0` representation; form redirects validate `next` and carry a
|
||||||
|
flash, while JSON returns the event id.
|
||||||
|
- Added the no-JS rating partial and JavaScript enhancement, active-state
|
||||||
|
updates, issue/article styling, and the signed-in private cache policy.
|
||||||
|
- The e-ink HMAC confirmation page now links to the corresponding site issue.
|
||||||
|
- Added router tests for full/fallback rendering, article discussions and image
|
||||||
|
handling, World/Behind pages, 404s and login protection, artifact gating,
|
||||||
|
both rating representations, role guards, attribution, fallback dates,
|
||||||
|
validated redirects, clear values, admin widget state, and near-miss links.
|
||||||
|
|
||||||
|
## Deviations and notes
|
||||||
|
|
||||||
|
- The pinned `axum-login` dependency disables tower-sessions' `axum-core`
|
||||||
|
feature, so its re-exported `Session` does not implement an Axum extractor in
|
||||||
|
this dependency graph. Handlers use `Extension<Session>` to read the exact
|
||||||
|
session already installed by the auth layer; no second tower-sessions
|
||||||
|
dependency was added.
|
||||||
|
- The web form and JSON response use `down`, as specified for the widget, while
|
||||||
|
the persisted event label is `not_for_me`, matching `cmd_ratings` and the
|
||||||
|
existing learned-rating queries exactly.
|
||||||
|
- No migration was needed. Step 3 can construct `RatingWidget` with
|
||||||
|
`show_note = true` for dashboard article variants.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `cargo fmt`: pass.
|
||||||
|
- `cargo clippy --all-targets -- -D warnings`: pass.
|
||||||
|
- Focused `cargo test web:: -- --nocapture`: **22 passed, 0 failed**.
|
||||||
|
- `cargo test` with the documented sandbox listener tests skipped:
|
||||||
|
**395 passed, 0 failed, 15 filtered out**. The filtered tests were the four
|
||||||
|
Anthropic listener tests, three OpenAI fake-server listener tests noted in the
|
||||||
|
Step 1 handoff review, the relative-URL listener test, five `server::tests`
|
||||||
|
listener tests, and both `tests/m7_server.rs` tests.
|
||||||
|
|
||||||
|
## Orchestrator review (2026-09-03)
|
||||||
|
|
||||||
|
- Accepted as is. `Extension<Session>` for flashes is fine (the auth layer's
|
||||||
|
session manager inserts it); no second tower-sessions dependency.
|
||||||
|
- Cosmetic follow-up for step 7: download buttons show raw byte counts;
|
||||||
|
render them human-readable (KB/MB).
|
||||||
|
- Full suite outside the sandbox: 376 lib + all integration tests green.
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Step 3 handoff — dashboard reads: overview, runs, articles
|
||||||
|
|
||||||
|
## Landed
|
||||||
|
|
||||||
|
- `src/web/dashboard/mod.rs`: the overview (§9.1) — last-run card built from
|
||||||
|
`runs.report_json` via `RunReport::info_block()` (legacy-counter fallback
|
||||||
|
when the report is missing or unparseable), budget-today `<meter>` per
|
||||||
|
referenced provider plus voyage from `db::provider_spend_for_utc_day(now)`,
|
||||||
|
ratings this week by label, "Unrated picks" (last three issues' picks with
|
||||||
|
no explicit event, each with the step 2 rating widget posting back to
|
||||||
|
`/dashboard`), a jobs summary read straight from the `jobs` table (active
|
||||||
|
rows plus the last five finished; the pages themselves are step 6), and the
|
||||||
|
`! ` lines of `Config::check_report`. Sparklines are a marked HTML comment
|
||||||
|
for step 6. The module also carries the helpers the read pages share:
|
||||||
|
`Pager`, `SignalsView` (the parsed `signals_json` behind
|
||||||
|
`_signals_table.html`), `allow_listed`, `like_pattern`, `Bind`/`bind_all`/
|
||||||
|
`dynamic_query`, formatting helpers, and the `STAGES`/`REASONS`/
|
||||||
|
`RETRIEVERS` allow-lists.
|
||||||
|
- `src/web/dashboard/runs.rs` (§9.2): `/dashboard/runs` with `?status=` and
|
||||||
|
pagination (50), and `/dashboard/runs/{id}` with header (issue link when the
|
||||||
|
issue exists, previous/next run by id), the funnel from `candidate_runs`
|
||||||
|
grouped by stage and reason, admission mix, preference state, timings,
|
||||||
|
provider usage, warnings (`#warnings` anchor used by the overview and the
|
||||||
|
list), top-20 feeds, the config diff (`flatten_json` + `config_diff`)
|
||||||
|
against the previous non-dry run that recorded a config, ten near misses via
|
||||||
|
`telemetry::near_misses`, and the candidates table (100 per page) with the
|
||||||
|
`stage`/`reason`/`admitted_by`/`q`/`flag` filters and `utility` (default)/
|
||||||
|
`rank`/`triage`/`quality`/`fit`/`title` sorts. Each row's title is a
|
||||||
|
`<details>` that expands `_signals_table.html`.
|
||||||
|
- `src/web/dashboard/articles.rs` (§9.3): `/dashboard/articles` (50 per page)
|
||||||
|
joining the best entry, the latest `candidate_runs` row through the
|
||||||
|
`MAX(run_id)` subquery (the index `idx_candidate_runs_article_run` is in
|
||||||
|
migration 0004 — confirmed), both assessments, the current explicit rating
|
||||||
|
and the latest publication; every listed filter and sort;
|
||||||
|
`/dashboard/articles/{id}` with the six blocks in the plan's order,
|
||||||
|
`provider_rejected` rows called out as such, run history rows expanding
|
||||||
|
their signals, neighbours/interests from the latest row, embedding metadata
|
||||||
|
only (never the vector), all rating events with source/user/note/value, the
|
||||||
|
rating widget with `show_note = true`, and "Explain (text)" wrapping
|
||||||
|
`telemetry::render_explain` verbatim in `<pre>`.
|
||||||
|
- Templates: `dashboard/overview.html`, `runs.html`, `run.html`,
|
||||||
|
`articles.html`, `article.html`, partials `_signals_table.html`,
|
||||||
|
`_candidate_row.html` and `dashboard/_pager.html`. Every table sits in
|
||||||
|
`.scroll-x`; badges per stage/reason/label/status; tables carry
|
||||||
|
`data-filter`.
|
||||||
|
- `app.css` / `app.js`: one appended block each (`/* step 3 … */`): cards,
|
||||||
|
filters, funnel bar, pager, signals details, diff colours, and the
|
||||||
|
filter-as-you-type behaviour for `table[data-filter]` (inserts a search
|
||||||
|
input before the table's `.scroll-x` wrapper, hides non-matching rows on
|
||||||
|
the current page only).
|
||||||
|
|
||||||
|
## Deviations and notes
|
||||||
|
|
||||||
|
- **sqlx 0.9 dynamic SQL.** `sqlx::query` only accepts `&'static str` or
|
||||||
|
`AssertSqlSafe`, so the list queries go through `dashboard::dynamic_query`,
|
||||||
|
which wraps the assembled string in `AssertSqlSafe`. The audit holds
|
||||||
|
because the string is built only from constants and allow-listed fragments
|
||||||
|
(`CANDIDATE_SORTS`/`ARTICLE_SORTS` map names to fixed `ORDER BY` text);
|
||||||
|
every user value is bound through `bind_all`. An unknown sort or filter
|
||||||
|
value falls back to the default / is dropped, never an error.
|
||||||
|
- **Funnel semantics.** `stage` records where a row stopped, so the bars
|
||||||
|
show the cumulative "reached" count (rows at this stage or a later one, so
|
||||||
|
the first bar is everything considered) with "stopped here" and the reason
|
||||||
|
breakdown beside it. Widths are `<svg width="N%">` children of `.funnel`
|
||||||
|
because the CSP (`style-src 'self'`) forbids inline `style` attributes;
|
||||||
|
the budget card uses `<meter>` for the same reason.
|
||||||
|
- **Prev/next run** links go to the neighbouring run ids overall (the
|
||||||
|
header already links the date's issue); a rerun of the same date is
|
||||||
|
therefore the immediate neighbour.
|
||||||
|
- **Extract method** is not shown on the article page: `db::article_from_row`
|
||||||
|
hard-codes `ExtractMethod::Miniflux` for every loaded article, so the value
|
||||||
|
would be meaningless. `excerpt_only` is shown instead. Source `kind`s render
|
||||||
|
via their `Debug` names (`Feed`, `Scour`, …).
|
||||||
|
- **`admitted_by` filter** is allow-listed against the six retriever names
|
||||||
|
and still applied as the plan's prefix `LIKE` on
|
||||||
|
`json_extract(admitted_by, '$[0]')`.
|
||||||
|
- The `_pagination.html` partial from step 1 (text only) was left alone; the
|
||||||
|
dashboard uses its own `dashboard/_pager.html` with prev/next links that
|
||||||
|
preserve the other query parameters.
|
||||||
|
- Only additive edits outside my files: two appended blocks in `app.css` and
|
||||||
|
`app.js`. No changes to `web/mod.rs`, `db.rs`, `rate.rs` or `telemetry.rs`
|
||||||
|
(a private `first_retriever` in telemetry was reimplemented as
|
||||||
|
`admitted_by_parts` rather than made `pub`).
|
||||||
|
|
||||||
|
## Left for later steps
|
||||||
|
|
||||||
|
- Step 6: overview sparklines (placeholder comment in `overview.html`), and
|
||||||
|
the `/dashboard/jobs` pages the overview's job links point at.
|
||||||
|
- Step 4: `/dashboard/ratings`, linked from the overview's ratings card.
|
||||||
|
- The overview's ratings-this-week card counts all explicit events in the
|
||||||
|
last seven days by label (a `cleared` is listed but not counted in the
|
||||||
|
total).
|
||||||
|
|
||||||
|
## Verification (outside the sandbox)
|
||||||
|
|
||||||
|
- `cargo fmt`: pass.
|
||||||
|
- `cargo clippy --all-targets -- -D warnings`: pass.
|
||||||
|
- `cargo test web::dashboard`: **11 passed, 0 failed**.
|
||||||
|
- `cargo test` (full suite): **387 lib tests passed, 0 failed**, plus every
|
||||||
|
integration test binary green (7, 2, 3, 4, 7, 9, 2), nothing skipped.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Step 4 handoff — ratings page and profile page
|
||||||
|
|
||||||
|
## Landed
|
||||||
|
|
||||||
|
- `src/web/dashboard/ratings.rs` + `dashboard/ratings.html` (`GET /dashboard/ratings`,
|
||||||
|
plan §10). Header from `PreferenceState::load(..).summary()` (the same loader
|
||||||
|
the pipeline uses) plus the "N rated articles have no embedding" notice; the
|
||||||
|
"How ratings enter the algorithm" `<details>` with the live config values
|
||||||
|
inlined and links to `/dashboard/settings#curation.feedback`,
|
||||||
|
`#curation.ranking` and `#curation.ranking.weights.preliminary`.
|
||||||
|
**Current** tab: one row per article from
|
||||||
|
`current_ratings_including_cleared(36_500)` with the verdict badge and the
|
||||||
|
inline rating widget (`show_note = true`), article → `/dashboard/articles/{id}`
|
||||||
|
with feed and issue date, when · source · username, note, age → decay,
|
||||||
|
neighbour weight (`value × decay`, or "no embedding"; a "(beyond lookback)"
|
||||||
|
marker when older than `rating_lookback_days`), feed credit per direct feed,
|
||||||
|
in-prompt / in-rebuild ticks from the rank among non-cleared verdicts, and
|
||||||
|
"used last run" from `signals_json.neighbours` of the latest non-dry run
|
||||||
|
(count of candidates and how many were selected). Filters: label, source,
|
||||||
|
feed, q. **Events** tab: every `rating_events` row, 100 per page,
|
||||||
|
`superseded` marked via an `EXISTS` on a later explicit event; filters label,
|
||||||
|
source, user, from/to date. Filter values are validated (allow-listed labels,
|
||||||
|
`[a-z0-9_]` sources, parsed dates/ids) and only ever bound; the dynamic
|
||||||
|
`WHERE` is assembled from fixed clause strings under `sqlx::AssertSqlSafe`.
|
||||||
|
- `src/web/dashboard/profile.rs` + `dashboard/profile.html` (plan §11).
|
||||||
|
`GET /dashboard/profile`: the `profile.md` textarea, the server-rendered
|
||||||
|
parsed preview (passthrough body in a `<pre>`, extracted `## Interests`
|
||||||
|
lines), history (50 newest `profile_versions` with 200-char previews and a
|
||||||
|
Restore form), OPML standing interests grouped by `group_into_themes` with
|
||||||
|
path and count, the learned adjustments with the prompt version, build time,
|
||||||
|
age and whether `profile::is_stale` says a rebuild is due, the collapsed
|
||||||
|
system prompt (`kv.taste_profile`), and the "Rebuild profile now" form
|
||||||
|
posting to `/dashboard/jobs/profile-rebuild` (disabled with a note when
|
||||||
|
`server.jobs_enabled` is false). `POST /dashboard/profile`: CRLF→LF
|
||||||
|
normalization, reject empty or > 64 KB (400), record the previous file in
|
||||||
|
`profile_versions` with `saved_by`, write `<path>.tmp` + rename preserving
|
||||||
|
the existing mode, flash "Saved; the next run rebuilds the system prompt."
|
||||||
|
Identical content is a no-op flash with no version row.
|
||||||
|
`POST /dashboard/profile/restore` (`version_id`): records the current file
|
||||||
|
as a new version and writes the chosen one back; unknown id → 404.
|
||||||
|
- `src/curate/profile/mod.rs`: `MAX_RATINGS_IN_REBUILD` and `stored_version`
|
||||||
|
are now `pub` (the profile page shows the prompt version and build time).
|
||||||
|
- `src/web/static/app.css`: a `/* step 4 */` block (tabs, filters, contribution
|
||||||
|
table, profile grid, previews).
|
||||||
|
|
||||||
|
## Deviations and notes
|
||||||
|
|
||||||
|
- Feed credit is shown as `value × decay / n`, not the plan §10 table's
|
||||||
|
`value / n`: `signals::feed_rates` credits the **decayed** weight ("decayed as
|
||||||
|
in §9.2", curation plan §9.3), and the page shows what the ranker computes.
|
||||||
|
- "Used last run" scans the run's `candidate_runs` rows once
|
||||||
|
(`signals_json LIKE '%"neighbours":[{%'`) and parses each `SignalsJson`,
|
||||||
|
instead of one `LIKE '%"article_id":<id>,%'` query per rated article; same
|
||||||
|
result, one query.
|
||||||
|
- The verdict block and rebuild set are count-bounded, so the neighbour weight
|
||||||
|
is still shown for ratings older than `rating_lookback_days`, with a
|
||||||
|
"(beyond lookback)" marker, since `PreferenceState::load` would not load them.
|
||||||
|
- `RatedArticle` does not carry the event `source`; the Current tab reads it
|
||||||
|
with one small query per row (ratings are sparse — hundreds at most).
|
||||||
|
- `TasteProfile.verdicts` is not persisted, so the prompt's verdict count is
|
||||||
|
derived by counting lines after `## Recent verdicts` in the stored text.
|
||||||
|
- No new migration, no changes to `db.rs`, `web/mod.rs`, `layout.html` or
|
||||||
|
`app.js`.
|
||||||
|
|
||||||
|
## Left for later steps
|
||||||
|
|
||||||
|
- Step 5 must provide the settings anchors linked from the ratings page:
|
||||||
|
`curation.feedback`, `curation.ranking`, `curation.ranking.weights.preliminary`.
|
||||||
|
- Step 6 implements `POST /dashboard/jobs/profile-rebuild`; the profile page
|
||||||
|
already renders the form (with `data-confirm`).
|
||||||
|
- Step 3's article detail pages are linked as `/dashboard/articles/{id}`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `cargo fmt --check`: pass.
|
||||||
|
- `cargo clippy --all-targets -- -D warnings`: pass.
|
||||||
|
- `cargo test` (outside the sandbox, nothing skipped): **390 lib tests passed,
|
||||||
|
0 failed** (14 new in `web::dashboard::{ratings,profile}`), plus every
|
||||||
|
integration suite green (7, 2, 3, 4, 7, 9, 2 tests).
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Step 5 handoff — settings
|
||||||
|
|
||||||
|
## Landed
|
||||||
|
|
||||||
|
- `src/web/dashboard/settings.rs`: a schema derived by walking serialized
|
||||||
|
default and live `Config` values, including optional/env-only fields, exact
|
||||||
|
field kinds and enum choices, derived environment names, source tracking,
|
||||||
|
README/example-backed help text, stable group order, and dotted-path anchors.
|
||||||
|
- `/dashboard/settings`: reload-on-mtime with a visible load-error banner and
|
||||||
|
last-good config retention; secret presence-only rendering, environment
|
||||||
|
locks, typed inputs, defaults/reset controls, and weight normalization notes.
|
||||||
|
- Typed `toml_edit` saves that preserve comments/order, collect field errors,
|
||||||
|
write multiline string arrays, create missing tables, validate through
|
||||||
|
`Config::load` in `<path>.tmp.<pid>`, preserve permissions, rename atomically,
|
||||||
|
swap the live config, and record one attributed `config_changes` row per key.
|
||||||
|
- Provider add/remove flows through the same validated writer, including name
|
||||||
|
and kind validation, role-reference refusal, placeholder defaults, audit
|
||||||
|
history, and redaction of a hand-written provider `api_key` from history.
|
||||||
|
- `/dashboard/settings/history`, newest first at 100 rows per page.
|
||||||
|
- A step 5 CSS block and reset helper in `app.js`; `/etc/daily-epub` added to
|
||||||
|
the server unit's `ReadWritePaths`.
|
||||||
|
- Sixteen settings tests covering every schema and writer case listed in §17,
|
||||||
|
including router-level rendering and POST behavior through `oneshot`.
|
||||||
|
|
||||||
|
## Deviations and notes
|
||||||
|
|
||||||
|
- Shipped providers cannot be removed, even when unreferenced. They are members
|
||||||
|
of `Config::default()`, so deleting their TOML table would immediately restore
|
||||||
|
the built-in entry and falsely report success. They can be edited or left
|
||||||
|
unreferenced; custom providers can be removed normally.
|
||||||
|
- `FieldKind::Enum` owns `Vec<String>` rather than the plan sketch's static
|
||||||
|
slice because `llm.bulk` and `llm.editor` must include provider names derived
|
||||||
|
at runtime. The rendered behavior and validation table are unchanged.
|
||||||
|
- Restart notices also cover `server.public_url`, `server.session_days`,
|
||||||
|
`server.login_attempts`, and `server.login_window_minutes`, in addition to
|
||||||
|
the plan sketch's `database_path` and `server.bind`, because those values are
|
||||||
|
captured when the server/session/throttle layers are built.
|
||||||
|
- An absent key whose effective value is already the submitted default remains
|
||||||
|
absent. This reconciles “only changed fields” with the no-JavaScript form
|
||||||
|
posting every editable field; resetting an explicit non-default file value
|
||||||
|
still writes the default explicitly.
|
||||||
|
- Step 6 should call `WebState::reload_if_changed` before starting each job, as
|
||||||
|
§4.2 requires. Step 5 supplies the shared helper; this branch's jobs module is
|
||||||
|
intentionally still the parallel-step stub.
|
||||||
|
- The shared brief's sandbox list omits three pre-existing OpenAI fake-server
|
||||||
|
tests that bind through the same `curate::llm::tests::serve` helper as its four
|
||||||
|
named Anthropic tests. The step 1 handoff records those three additional
|
||||||
|
sandbox failures; they are not settings regressions.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `cargo fmt`: pass.
|
||||||
|
- `cargo clippy --all-targets -- -D warnings`: pass.
|
||||||
|
- Focused `cargo test web::dashboard::settings -- --nocapture`: **16 passed,
|
||||||
|
0 failed**.
|
||||||
|
- Unfiltered `cargo test`: **379 library tests passed** before the harness
|
||||||
|
reported the expected loopback-bind failures (the 10 named library tests plus
|
||||||
|
the three OpenAI tests noted above); no settings test failed.
|
||||||
|
- `cargo test` with all pre-existing loopback listener tests excluded:
|
||||||
|
**411 passed, 0 failed, 15 filtered out** (379 library, 7 binary-unit, and 25
|
||||||
|
integration tests passed; 13 library listener tests and both `m7_server`
|
||||||
|
tests were filtered).
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Step 6 handoff — jobs and stats
|
||||||
|
|
||||||
|
## Landed
|
||||||
|
|
||||||
|
- Added `src/jobs.rs`: the fixed, regex-constrained job catalogue; jobs-table
|
||||||
|
query/lifecycle helpers; single-line terminal messages; `SystemdRunner` with
|
||||||
|
ten-second command timeouts, captured stderr and systemd status parsing.
|
||||||
|
- Added `daily-epub job run <name>`. It uses the existing command lock policy,
|
||||||
|
claims or creates the requested row, calls the same in-process helpers as the
|
||||||
|
ordinary CLI commands, records `ok`/`failed`, stores the generated run id,
|
||||||
|
and propagates failures to a non-zero process exit. Existing profile,
|
||||||
|
features and social CLI output retains its prior line content.
|
||||||
|
- Finalized `JobRunner`: production `serve` installs `SystemdRunner` when
|
||||||
|
`server.jobs_enabled` is true; disabled servers use `DisabledRunner`;
|
||||||
|
`AppState::new` keeps its existing test behavior; `MockRunner` records calls
|
||||||
|
and scripts starts, statuses and logs.
|
||||||
|
- Added the admin Jobs list/start/detail routes and templates: catalogue cards,
|
||||||
|
dated generation, job history, duplicate conflict handling, start-failure
|
||||||
|
persistence, live unit fields, configured journal tail, 30-second
|
||||||
|
pre-claim failure detection, disabled-state messaging and five-second active
|
||||||
|
refresh.
|
||||||
|
- Added the systemd job template, the §14.3 polkit rule verbatim, and
|
||||||
|
`SupplementaryGroups=systemd-journal` on the server unit.
|
||||||
|
- Split stats into `stats_data` plus `render_stats_text` while pinning the
|
||||||
|
complete previous CLI output. Added the 14/30/90-day stats page, aggregate
|
||||||
|
tables, retriever yield, per-run table, daily provider costs and the three
|
||||||
|
requested SVG charts.
|
||||||
|
- Replaced the overview placeholder with cost-per-run, selected-per-run and
|
||||||
|
generation-time sparklines over the last 30 finished non-dry runs. SVGs use
|
||||||
|
presentation attributes and CSS classes only; no inline styles or template
|
||||||
|
`safe` filters were added.
|
||||||
|
- Added/expanded tests for the catalogue, job lifecycle and run link, CLI lock
|
||||||
|
mapping, in-process job success/failure, polkit/unit files, runner status
|
||||||
|
parsing, every jobs route behavior, exact stats text/data, stats routes and
|
||||||
|
both sparkline forms.
|
||||||
|
|
||||||
|
## Deviations and integration notes
|
||||||
|
|
||||||
|
- Per the parallel-step constraint, job start contains exactly the requested
|
||||||
|
`// TODO(step 5 merge): reload_if_changed` marker and does not duplicate step
|
||||||
|
5's helper. The step-5 merge must replace that marker with its helper call.
|
||||||
|
- The offline in-process job test uses `features-prune`; generate's `run_id`
|
||||||
|
persistence is tested separately through the same lifecycle helper because a
|
||||||
|
full generate would require Miniflux/network fixtures. Production generate
|
||||||
|
and dry-run mappings both store `GenerateOutcome.run_id`.
|
||||||
|
- The server unit intentionally does not add `/etc/daily-epub` to
|
||||||
|
`ReadWritePaths` here; step 5 owns that additive unit change.
|
||||||
|
- `StatsData.durations` retains finished dry runs because that is what the old
|
||||||
|
CLI calculation included and the text must remain byte-identical. Dashboard
|
||||||
|
per-run series and overview sparklines exclude dry runs as specified.
|
||||||
|
|
||||||
|
## Left for later
|
||||||
|
|
||||||
|
- Step-5 merge: call its `reload_if_changed` implementation at the marked job
|
||||||
|
start site and combine its `/etc/daily-epub` unit path change.
|
||||||
|
- Step 7 owns user-facing documentation and deployment/runbook polish.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `cargo fmt`: pass.
|
||||||
|
- `cargo clippy --all-targets -- -D warnings`: pass.
|
||||||
|
- Raw `cargo test`: **406 passed, 13 failed** in the library before Cargo
|
||||||
|
stopped; every failure was a sandbox-denied loopback listener (four named
|
||||||
|
Anthropic tests, three pre-existing OpenAI fake-server tests documented in
|
||||||
|
the step-1 handoff, the named extractor test and five named server tests).
|
||||||
|
- `cargo test` with those listener tests and both `tests/m7_server.rs` listener
|
||||||
|
tests skipped: **440 passed, 0 failed, 15 filtered out** across all targets.
|
||||||
|
- Focused `cargo test jobs::`, `cargo test job_run`, and `cargo test stats`:
|
||||||
|
pass.
|
||||||
|
- `node --check` on the polkit JavaScript (via stdin): pass.
|
||||||
|
- `systemd-analyze verify systemd/daily-epub-job@.service`: no diagnostics for
|
||||||
|
the job unit.
|
||||||
|
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Step 7 handoff — docs, users page, and polish
|
||||||
|
|
||||||
|
## Landed
|
||||||
|
|
||||||
|
- Added the admin-only, read-only `/dashboard/users` page with username, role, enabled/disabled
|
||||||
|
status, created time, last login, and unexpired session count. The page directs edits to the
|
||||||
|
existing `daily-epub users` CLI.
|
||||||
|
- Expanded the README with the public/user/admin boundary, user lifecycle commands, complete
|
||||||
|
route table, systemd+polkit Jobs setup, comment-preserving Settings behavior, environment
|
||||||
|
locks, shipped-provider removal limitation, public `/files/*` behavior without Basic auth,
|
||||||
|
and the `X-Forwarded-For`/loopback trust boundary. Updated `[server]` comments in
|
||||||
|
`config.example.toml` to match.
|
||||||
|
- Added the dated Web dashboard section to the implementation notes: polkit 124 and its narrow
|
||||||
|
rule, service-unit changes, the `toml_edit` write path, the pinned auth/session/password/
|
||||||
|
throttle stack, sandbox test caveats, and the implementation-time decisions from steps 1–6.
|
||||||
|
- Added `docs/runbooks/web-dashboard-rollout.md`, with the plan's nine production rollout steps,
|
||||||
|
commands, permissions, public-files compatibility note, and smoke checks.
|
||||||
|
- Completed layout polish: Atom discovery and favicon remain in `<head>`, Users remains in the
|
||||||
|
admin nav, both navs now wrap cleanly, narrow cards/forms/rating controls collapse, dark-mode
|
||||||
|
form controls use the site colors, focus states are visible, and all wide tables (including
|
||||||
|
the nested signals table and new users table) sit in `.scroll-x`.
|
||||||
|
- Unknown routes and template-render failures now use the site-layout 404/500 pages. Issue
|
||||||
|
download buttons show human-readable B/KB/MB/GB sizes.
|
||||||
|
- Removed stale diff3 ancestor markers from the merged CSS/JavaScript and restored the missing
|
||||||
|
table-filter closure; `node --check src/web/static/app.js` passes.
|
||||||
|
- Added the users guard/content test, file-size tests, site-layout error test, and one
|
||||||
|
fixture-backed router smoke test that renders every dashboard route template.
|
||||||
|
|
||||||
|
## Acceptance criteria walk-through
|
||||||
|
|
||||||
|
1. **Public issue and Atom boundary — met.** `PublicIssue` cannot carry generated/body fields;
|
||||||
|
existing fixture tests assert titles/authors/sources/comment links/metadata are present and
|
||||||
|
private Brief/summary/why/body/comment/World text is absent. The feed parses as XML and uses
|
||||||
|
the same public view.
|
||||||
|
2. **Complete signed-in issue — met.** Existing router tests cover Brief, summaries, why lines,
|
||||||
|
article bodies/discussion, World and Behind pages, reduced pre-snapshot fallback, and
|
||||||
|
existence-gated downloads. All three artifact slots share the same loader; sizes are now
|
||||||
|
human-readable.
|
||||||
|
3. **Admin-only ratings — met.** Whole-router role guards and rating tests cover anonymous,
|
||||||
|
user, and admin outcomes, attributed `source = 'dashboard'` events, clear events, and the
|
||||||
|
unchanged HMAC path.
|
||||||
|
4. **Article history/explainability — met.** The articles list/detail routes expose the latest
|
||||||
|
stage plus full run history, reasons, signals, assessments/facets, utility/cluster/admission,
|
||||||
|
editor why, issue appearances, neighbours, embedding metadata, ratings, and text explain;
|
||||||
|
allow-listed query tests and fixture rendering pass.
|
||||||
|
5. **Run history — met.** Run list/detail renders the cumulative funnel and reasons, admission,
|
||||||
|
preference state, timings, provider costs, warnings, feeds, near misses, config diff, and
|
||||||
|
candidate table; seeded counts, sorts, filters, links, and sparklines are tested.
|
||||||
|
6. **Ratings contributions/history — met.** Current and events tabs cover decay, decayed feed
|
||||||
|
credit, neighbour weight, prompt/rebuild membership, last-run use, append-only edits/clear,
|
||||||
|
annotations, filters, and superseded history with hand-checked tests.
|
||||||
|
7. **Settings — met.** The schema/default/help/env/secret tests cover every config leaf. Writer
|
||||||
|
tests cover typed changes, comments/order/mode preservation, validation before rename,
|
||||||
|
history, providers, and reload-on-mtime. The README records why shipped providers cannot be
|
||||||
|
removed.
|
||||||
|
8. **Profile — met.** Save/restore/version tests cover the atomic editor and parsed preview;
|
||||||
|
the page shows OPML interests, prompt, learned adjustments/staleness, and the rebuild job.
|
||||||
|
9. **Jobs — met.** Fixed-name parsing, polkit/unit files, MockRunner starts/status/logs, duplicate
|
||||||
|
refusal, failure persistence, lifecycle/run linkage, journal tail, reload-before-start, and
|
||||||
|
overview links are present and tested. Pipeline work remains in the separate job unit.
|
||||||
|
10. **Session/security stack — met.** The pinned axum-login/tower-sessions store,
|
||||||
|
password-auth hashes, cookie properties, session invalidation, origin checks, login throttle,
|
||||||
|
CSP/security headers, safe redirect checks, and secret redaction have focused tests.
|
||||||
|
11. **Existing routes — met.** HMAC, OPDS, files, health, and reports remain wired; issue reports
|
||||||
|
are populated. The reviewed compatibility decision keeps files public only when Basic auth
|
||||||
|
is absent, while configured Basic auth or a valid session still gates them.
|
||||||
|
12. **Test coverage — met, with sandbox execution caveat.** All new router tests use `oneshot`;
|
||||||
|
no new test binds or invokes systemd. The filtered full run passed every runnable target.
|
||||||
|
The orchestrator should run the complete suite outside this listener-restricted sandbox.
|
||||||
|
|
||||||
|
## Prior handoff follow-ups
|
||||||
|
|
||||||
|
- Step 1's `rpassword` and direct `toml_edit 0.25` follow-ups were already resolved by the
|
||||||
|
orchestrator; its public-files deviation is now documented.
|
||||||
|
- Step 2's raw download sizes are resolved here.
|
||||||
|
- Step 3's Jobs/ratings targets and overview sparklines, Step 4's settings anchors and profile
|
||||||
|
rebuild action, and Steps 5–6's config reload plus combined unit changes are all present after
|
||||||
|
the merged steps and were included in the smoke/audit pass.
|
||||||
|
- Nothing remains open against §20. Production deployment itself is intentionally left to the
|
||||||
|
operator following the new runbook. Plan §21's product deferrals remain intentional: per-user
|
||||||
|
personalization, OPML editing, body FTS, SSE logs, passkeys, public why lines, old snapshot
|
||||||
|
backfill, and charts beyond the current sparklines.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `cargo fmt`: pass.
|
||||||
|
- `cargo clippy --all-targets -- -D warnings`: pass.
|
||||||
|
- `node --check src/web/static/app.js`: pass.
|
||||||
|
- Focused new router/size/error tests: pass.
|
||||||
|
- Raw `cargo test`: **426 passed, 13 failed** before Cargo stopped at the library target; every
|
||||||
|
failure was `PermissionDenied` while binding a loopback listener (the four documented
|
||||||
|
Anthropic tests, three OpenAI tests using the same helper, the extractor test, and five server
|
||||||
|
tests).
|
||||||
|
- `cargo test` with those 13 listener tests plus the two `tests/m7_server.rs` TCP tests filtered:
|
||||||
|
**460 passed, 0 failed, 15 filtered out** across library, binary, integration, and doc targets.
|
||||||
|
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Web dashboard step 6 implementation review
|
||||||
|
|
||||||
|
The finished step-6 implementation matches the jobs and stats brief. The fixed
|
||||||
|
job catalogue, CLI lifecycle, systemd runner and files, dashboard routes,
|
||||||
|
stats-data refactor, SVG sparklines, production runner wiring, and required
|
||||||
|
tests are all present. The review found and corrected two faulty test
|
||||||
|
assertions, restored the polkit file to the brief's verbatim rule, made job
|
||||||
|
messages reliably one-line, and strengthened the duplicate-start test to use a
|
||||||
|
genuinely running row. No outstanding correctness finding remains.
|
||||||
|
|
||||||
|
## Critical
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## High
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Medium
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Low
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Nits
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Plan Coverage
|
||||||
|
|
||||||
|
| Requirement | Status | Evidence |
|
||||||
|
|---|---|---|
|
||||||
|
| Fixed job catalogue and validated names | Implemented as planned | `src/jobs.rs`: `Job`, `parse`, `name`, `unit`, `description`, `takes_lock`, `dangerous` and traversal/uppercase/unknown-name tests. |
|
||||||
|
| `daily-epub job run <name>` lifecycle | Implemented as planned | `src/main.rs`: CLI dispatch, existing lock path, in-process command mapping, requested-row claim, terminal update, failure propagation and tests. `src/jobs.rs` owns row lifecycle helpers. |
|
||||||
|
| Unit template, polkit rule and journal group | Implemented as planned | `systemd/daily-epub-job@.service`, verbatim `systemd/50-daily-epub.rules`, and `SupplementaryGroups=systemd-journal` in `systemd/daily-epub.service`; repository-content tests cover their security-sensitive strings. |
|
||||||
|
| Production/test/disabled runners | Implemented as planned | `src/jobs.rs::SystemdRunner` uses bounded `tokio::process::Command`; `src/web/mod.rs` contains scripted `MockRunner` and `DisabledRunner`; `src/server.rs::serve` selects the production runner only when jobs are enabled while `AppState::new` remains test-compatible. |
|
||||||
|
| Jobs list/start/detail pages | Implemented as planned | `src/web/dashboard/jobs.rs` and the two jobs templates implement catalogue cards, date selection, history, duplicate refusal, failed-start persistence, live status, journal tail, the 30-second failure rule, authorization tests and five-second refresh. |
|
||||||
|
| Config reload integration boundary | Partially implemented by design | The required `// TODO(step 5 merge): reload_if_changed` is at the start site. Step 5 owns the helper and merge-time call per the brief's parallel-work constraint. |
|
||||||
|
| Stats data/text split | Implemented as planned | `src/curate/telemetry.rs` exposes `StatsData`, `stats_data`, and `render_stats_text`; seeded fixtures pin the complete legacy output byte-for-byte. |
|
||||||
|
| Stats page | Implemented as planned | `src/web/dashboard/stats.rs` and `dashboard/stats.html` provide the three allowed windows, tables, retriever yield, per-run data and the three requested charts. |
|
||||||
|
| Overview sparklines | Implemented as planned | `src/web/dashboard/mod.rs` loads the last 30 finished non-dry runs and renders cost, selected count and duration through `_sparkline.html`. |
|
||||||
|
| CSP-safe rendering | Implemented as planned | SVG geometry uses presentation attributes and stylesheet classes; changed templates contain no inline `style` attributes and no `safe` filter. |
|
||||||
|
|
||||||
|
## Testing Assessment
|
||||||
|
|
||||||
|
Meaningful tests cover every catalogue form and rejection case, job-row claim
|
||||||
|
and completion (including run-id persistence), real router authorization and
|
||||||
|
POST behavior with `MockRunner`, requested/running duplicate refusal, failed
|
||||||
|
starts, the unit-exited grace rule, disabled jobs, systemd output parsing,
|
||||||
|
polkit/unit contents, in-process job success and failure, exact
|
||||||
|
legacy stats text, stats aggregation, SVG geometry, page output, and overview
|
||||||
|
series.
|
||||||
|
|
||||||
|
The tests deliberately do not execute systemd or the network. The mapped
|
||||||
|
generate branch is therefore covered by its option construction and the
|
||||||
|
separate job run-id lifecycle test rather than a live pipeline invocation;
|
||||||
|
this follows the brief's offline-test rule. No additional step-6 test is needed.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- During the step-5 merge, replace the required job-start TODO with the single
|
||||||
|
`reload_if_changed` call supplied by step 5; do not duplicate that helper.
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Web dashboard step 5 implementation review
|
||||||
|
|
||||||
|
The final step 5 working tree implements the settings schema, editor, provider
|
||||||
|
operations, history page, reload-on-mtime behavior, and systemd write access
|
||||||
|
described by the plan. The implementation is well covered by behavior-focused
|
||||||
|
unit and router tests. The review found one security issue in the inherited
|
||||||
|
work—removing a provider could have copied a hand-written `api_key` into
|
||||||
|
`config_changes`—and fixed it with redaction plus a regression assertion.
|
||||||
|
|
||||||
|
## Critical
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## High
|
||||||
|
|
||||||
|
None. The provider-history secret exposure found during review is fixed in
|
||||||
|
`provider_literal`: `api_key` is removed before a whole-provider literal is
|
||||||
|
stored or rendered by the history page.
|
||||||
|
|
||||||
|
## Medium
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Low
|
||||||
|
|
||||||
|
### Shipped provider removal differs from the literal plan wording
|
||||||
|
|
||||||
|
- Evidence: `ProviderCard::removable` and `remove_provider` refuse entries in
|
||||||
|
`default_providers()`, even when no `[llm]` role references them.
|
||||||
|
- Plan difference: §13.3 only explicitly requires refusal when an `[llm]` role
|
||||||
|
names the provider.
|
||||||
|
- Reason and recommendation: deleting a shipped provider's file table cannot
|
||||||
|
remove it from the effective config because `Config::default()` supplies it
|
||||||
|
again; pretending otherwise would reset it while reporting removal. Keep the
|
||||||
|
explicit refusal unless config loading later gains a provider tombstone or
|
||||||
|
replacement-map semantic.
|
||||||
|
|
||||||
|
### Absent defaults are not materialized by an otherwise unchanged form
|
||||||
|
|
||||||
|
- Evidence: `plan_changes` compares an absent file value through its effective
|
||||||
|
default before deciding whether to write.
|
||||||
|
- Plan difference: §13.2's “explicit beats implicit” sentence can be read as
|
||||||
|
requiring an absent default-valued key to be written whenever posted.
|
||||||
|
- Reason and recommendation: the same section says the form carries every
|
||||||
|
editable field and only changed fields are written. Materializing every
|
||||||
|
absent default on any no-JavaScript save would violate that behavior. Keep
|
||||||
|
the effective-value comparison; a reset from an explicit non-default value
|
||||||
|
still writes the default explicitly. Clarify this sentence in a future plan
|
||||||
|
revision if touched-vs-untouched browser state becomes a requirement.
|
||||||
|
|
||||||
|
## Nits
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Plan Coverage
|
||||||
|
|
||||||
|
| Requirement | Status | Evidence |
|
||||||
|
|---|---|---|
|
||||||
|
| Derived schema, kinds, sources, help, group order and anchors | Implemented as planned | `schema`, `schema_with_env`, `walk`, `kind_for`, `SETTINGS_HELP` |
|
||||||
|
| Secret redaction and environment locks | Implemented as planned | `source_for`, secret field rendering, provider-literal redaction |
|
||||||
|
| Typed `toml_edit` save with collected errors | Implemented as planned | `plan_changes`, `apply_change` |
|
||||||
|
| Temp-file validation, permission preservation, rename and live swap | Implemented as planned | `write_validated`, `install` |
|
||||||
|
| One attributed history row per changed key | Implemented as planned | `record_changes`, `config_changes` |
|
||||||
|
| Provider add/remove and referenced-provider refusal | Implemented with the shipped-provider qualification above | `add_provider`, `remove_provider` |
|
||||||
|
| Reload on mtime with previous config retained after an error | Implemented as planned | `WebState::reload_if_changed`, settings GET banner |
|
||||||
|
| Settings history page, newest first, 100 per page | Implemented as planned | `history`, `config_changes`, `settings_history.html` |
|
||||||
|
| `/etc/daily-epub` writable in the server unit | Implemented as planned | `systemd/daily-epub.service` |
|
||||||
|
|
||||||
|
## Testing Assessment
|
||||||
|
|
||||||
|
Existing tests are meaningful: they compare the schema with serialized
|
||||||
|
`Config::default()`, compare shipped TOML leaves with the help table, exercise
|
||||||
|
all enum options through deserialization and `validate()`, prove secrets carry
|
||||||
|
no value, mutate and restore a real environment variable, compare untouched
|
||||||
|
configuration lines byte-for-byte, verify multiline arrays and new tables,
|
||||||
|
exercise validation failure and permission preservation, inspect persisted
|
||||||
|
history rows, add/remove/refuse providers, and drive the settings routes through
|
||||||
|
`Router::oneshot`. The provider test also proves a file-sourced API key does not
|
||||||
|
reach the audit table.
|
||||||
|
|
||||||
|
No weak or missing test from the step 5 list remains. The only environmental
|
||||||
|
suite limitation is the repository's pre-existing listener tests, which cannot
|
||||||
|
bind loopback in the sandbox.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Should the plan eventually define a tombstone for removing shipped providers,
|
||||||
|
or should the current “leave built-ins unreferenced” behavior become the
|
||||||
|
documented contract?
|
||||||
|
- The shared brief lists four Anthropic listener tests as sandbox-bound, while
|
||||||
|
the step 1 handoff also identifies three OpenAI tests using the same loopback
|
||||||
|
fake server. The latter fail with the identical sandbox permission error.
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# Runbook — web dashboard rollout
|
||||||
|
|
||||||
|
**Written:** 2026-09-03 for the production host. Run these commands from the checked-out
|
||||||
|
`web-dashboard` tree as an operator with `sudo`. The existing curation-v2 migration should
|
||||||
|
already be complete.
|
||||||
|
|
||||||
|
## 1. Build and install
|
||||||
|
|
||||||
|
The deployed binary does not require Node. For UI development, edit
|
||||||
|
`src/web/tailwind.css`, the templates, or `src/web/static/app.js`, then run
|
||||||
|
`npm run css` and commit the regenerated `src/web/static/app.css` alongside the
|
||||||
|
source. The `/static/*.css|js` URLs carry a hash of the embedded assets
|
||||||
|
(`web::ASSET_VERSION`), so a new build busts browser caches on its own — no
|
||||||
|
hard refresh and no version bump needed. CI and release preparation should run `npm run css:check` to detect
|
||||||
|
stylesheet drift.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm ci
|
||||||
|
npm run css:check
|
||||||
|
cargo test
|
||||||
|
cargo build --release
|
||||||
|
sudo install -m0755 target/release/daily-epub /usr/local/bin/daily-epub
|
||||||
|
daily-epub --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm the help includes both `users` and `job`. Keep the previous binary available until the
|
||||||
|
smoke tests below pass.
|
||||||
|
|
||||||
|
## 2. Install the job unit and polkit rule
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo install -m0644 systemd/daily-epub-job@.service /etc/systemd/system/
|
||||||
|
sudo install -m0644 systemd/50-daily-epub.rules /etc/polkit-1/rules.d/
|
||||||
|
sudo install -m0644 systemd/daily-epub.service /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
```
|
||||||
|
|
||||||
|
The server unit must contain both:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
SupplementaryGroups=systemd-journal
|
||||||
|
ReadWritePaths=/home/thallada/bookorbit/books/daily-epub /var/lib/daily-epub/xtc /etc/daily-epub
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep the two publish paths aligned with `[publish]`. The polkit rule permits user `daily-epub`
|
||||||
|
to start only `daily-epub-job@[a-z0-9-]+.service`; it does not grant stop, restart, or arbitrary
|
||||||
|
unit management.
|
||||||
|
|
||||||
|
## 3. Verify writable paths and ownership
|
||||||
|
|
||||||
|
Settings saves create a temporary file and rename it into `/etc/daily-epub`, so the directory
|
||||||
|
and config file must both belong to the service account:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo chown daily-epub:daily-epub /etc/daily-epub /etc/daily-epub/config.toml
|
||||||
|
sudo chmod 0750 /etc/daily-epub
|
||||||
|
sudo chmod 0640 /etc/daily-epub/config.toml
|
||||||
|
sudo install -d -m0750 -o daily-epub -g daily-epub /var/lib/daily-epub/data
|
||||||
|
sudo test -f /var/lib/daily-epub/data/profile.md
|
||||||
|
sudo -u daily-epub test -w /var/lib/daily-epub/data/profile.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `profile_path = "/var/lib/daily-epub/data/profile.md"`; `StateDirectory=daily-epub` makes
|
||||||
|
that location writable. Also re-check the configured publish directories as `daily-epub`.
|
||||||
|
|
||||||
|
## 4. Review the new server settings
|
||||||
|
|
||||||
|
All keys have defaults, so additions are optional unless production needs overrides:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[server]
|
||||||
|
bind = "127.0.0.1:3499"
|
||||||
|
public_url = "https://daily.hallada.net"
|
||||||
|
session_days = 30
|
||||||
|
login_attempts = 10
|
||||||
|
login_window_minutes = 15
|
||||||
|
jobs_enabled = true
|
||||||
|
journal_lines = 300
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep the bind address on loopback: the login throttle trusts `X-Forwarded-For`, so only the
|
||||||
|
reverse proxy should be able to reach the listener. Keep secrets in `/etc/daily-epub/env`, not
|
||||||
|
TOML. If `server.basic_auth_user` and `server.basic_auth_pass` are omitted, the existing
|
||||||
|
`/opds/*` and `/files/*` routes remain public; this is intentional so OPDS acquisition links
|
||||||
|
continue to work. When Basic auth is configured, signed-in users can still download files with
|
||||||
|
their session.
|
||||||
|
|
||||||
|
## 5. Restart, migrate, and verify public pages
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo systemctl restart daily-epub.service
|
||||||
|
sudo systemctl --no-pager --full status daily-epub.service
|
||||||
|
curl -fsS https://daily.hallada.net/ | grep -F 'The Daily EPUB'
|
||||||
|
curl -fsS https://daily.hallada.net/feed.xml >/tmp/daily-epub-feed.xml
|
||||||
|
python3 -c 'import xml.etree.ElementTree as E; E.parse("/tmp/daily-epub-feed.xml")'
|
||||||
|
```
|
||||||
|
|
||||||
|
Migration `0004_web.sql` applies automatically at start. Anonymous issue pages and the Atom
|
||||||
|
feed must show the source-link index without summaries, article bodies, comments, the Brief, or
|
||||||
|
World Briefing text.
|
||||||
|
|
||||||
|
## 6. Bootstrap the administrator
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo -u daily-epub /usr/local/bin/daily-epub \
|
||||||
|
--config /etc/daily-epub/config.toml users add tyler --admin
|
||||||
|
sudo -u daily-epub /usr/local/bin/daily-epub \
|
||||||
|
--config /etc/daily-epub/config.toml users list
|
||||||
|
```
|
||||||
|
|
||||||
|
The first command prompts twice without echo. Log in at `https://daily.hallada.net/login`, open
|
||||||
|
`/dashboard`, and confirm `/dashboard/users` shows the admin and an open session. A non-admin
|
||||||
|
account should receive the site's 403 page for every dashboard route.
|
||||||
|
|
||||||
|
## 7. Smoke test a job
|
||||||
|
|
||||||
|
Start `features-prune` from `/dashboard/jobs`, open its job detail, and watch the status and log
|
||||||
|
tail. On the host, confirm the same unit:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
systemctl status daily-epub-job@features-prune.service
|
||||||
|
journalctl -u daily-epub-job@features-prune.service -n 100 --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
If the page reports a permission failure, inspect `journalctl -u polkit` and verify the installed
|
||||||
|
rule and exact unit name. Do not broaden the rule to arbitrary units.
|
||||||
|
|
||||||
|
## 8. Smoke test an in-place settings write
|
||||||
|
|
||||||
|
In `/dashboard/settings`, change `curation.ranking.utility_protected` by one and save. Confirm
|
||||||
|
the next view shows the new value, comments and ordering remain in the file, and the attributed
|
||||||
|
change appears at `/dashboard/settings/history`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo -u daily-epub grep -n 'utility_protected' /etc/daily-epub/config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Change the value back through the page and confirm the second history row. Environment-overridden
|
||||||
|
fields should be locked, and no API key, HMAC secret, or Basic-auth password should be displayed.
|
||||||
|
|
||||||
|
## 9. Choose the history window
|
||||||
|
|
||||||
|
Candidate and assessment history is pruned after
|
||||||
|
`curation.ranking.telemetry_retention_days` (180 by default). Raise it now if the dashboard
|
||||||
|
should retain a longer article/run history, then reload `/dashboard/settings` and verify the
|
||||||
|
effective value. This changes future pruning only; it cannot restore rows already deleted.
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
//! Seed a throwaway development database so the web site can be browsed
|
||||||
|
//! locally with realistic data — two issues (one with the full `issue_json`
|
||||||
|
//! snapshot, one "legacy" issue without it), their runs, an admin and a
|
||||||
|
//! reader account. For design work and manual smoke tests only; never run it
|
||||||
|
//! against the production database.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! cargo run --example seed_dev_db -- ./dev
|
||||||
|
//! DAILY_EPUB_SERVER__HMAC_SECRET=$(head -c 48 /dev/urandom | base64) \
|
||||||
|
//! cargo run -- --config ./dev/config.toml serve
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Then open http://127.0.0.1:3599 and sign in as `admin` / `adminpassword123`
|
||||||
|
//! or `reader` / `readerpassword123`.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use daily_epub::config::Config;
|
||||||
|
use daily_epub::curate::telemetry::{self, CandidateRun};
|
||||||
|
use daily_epub::db::Db;
|
||||||
|
use daily_epub::epub::fixtures;
|
||||||
|
use daily_epub::report::{ProviderUsage, RunReport};
|
||||||
|
use daily_epub::types::{Article, Edition, Entry, Issue, Pick, TokenUsage};
|
||||||
|
use daily_epub::web::users;
|
||||||
|
use jiff::Timestamp;
|
||||||
|
use jiff::civil::Date;
|
||||||
|
|
||||||
|
const ADMIN_PASSWORD: &str = "adminpassword123";
|
||||||
|
const READER_PASSWORD: &str = "readerpassword123";
|
||||||
|
|
||||||
|
/// (section, title, feed, summary, why, words)
|
||||||
|
const STORIES: &[(&str, &str, &str, &str, &str, i64)] = &[
|
||||||
|
(
|
||||||
|
"Top Stories",
|
||||||
|
"How a Forty-Year-Old Filesystem Quietly Rewrote Its Write Path",
|
||||||
|
"Systems Weekly",
|
||||||
|
"A long, careful account of the redesign, with the benchmarks that justified it and the two regressions that nearly sank it.",
|
||||||
|
"The systems story with enough operational detail to matter to you",
|
||||||
|
3100,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Top Stories",
|
||||||
|
"The Case Against Feature Flags",
|
||||||
|
"Alice on Software",
|
||||||
|
"Argues that flags outlive their purpose and proposes a retirement discipline, with examples from three codebases.",
|
||||||
|
"A contrarian take on tooling you use every day",
|
||||||
|
1850,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Top Stories",
|
||||||
|
"What the Latest Battery Chemistry Actually Changes",
|
||||||
|
"Ars Technica",
|
||||||
|
"Separates the press-release claims from the measurable improvements in energy density and cycle life.",
|
||||||
|
"You keep an eye on energy storage; this one is unusually sober",
|
||||||
|
2200,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Deep Reads",
|
||||||
|
"A Field Guide to Distributed Consensus, Told Through One Outage",
|
||||||
|
"The Morning Paper",
|
||||||
|
"Walks through a real incident to explain leader election, log replication and why the fix was a config change.",
|
||||||
|
"Distributed systems explained with an actual outage rather than diagrams",
|
||||||
|
4200,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Deep Reads",
|
||||||
|
"Why Every Map Is a Lie & How Cartographers Choose Which One to Tell",
|
||||||
|
"Longreads",
|
||||||
|
"A history of projections and the politics behind them, from Mercator to the maps in your phone.",
|
||||||
|
"Long-form nonfiction outside the technical orbit you asked for",
|
||||||
|
5100,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Tools & Craft",
|
||||||
|
"Notes on Writing a Rust Linter That People Actually Enable",
|
||||||
|
"Rust Blog",
|
||||||
|
"Design notes on false-positive budgets, fix suggestions and the social side of shipping a lint.",
|
||||||
|
"Rust tooling with a practical bent",
|
||||||
|
1600,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Tools & Craft",
|
||||||
|
"The Terminal Is the Best UI We Have and It Is Getting Better",
|
||||||
|
"Julia's Notebook",
|
||||||
|
"A tour of modern terminal features (hyperlinks, images, synchronized output) and which tools use them.",
|
||||||
|
"Terminal ergonomics, one of your recurring interests",
|
||||||
|
1300,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Niche Corner",
|
||||||
|
"A Small-Town Bakery's Sourdough Starter Turns One Hundred",
|
||||||
|
"Saveur",
|
||||||
|
"A charming profile of a starter kept alive across four generations and the bread it still makes.",
|
||||||
|
"A small-scene delight outside the usual technical orbit",
|
||||||
|
900,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Niche Corner",
|
||||||
|
"Inside the Community Keeping 1990s Synthesizers Alive",
|
||||||
|
"Sound on Sound",
|
||||||
|
"Repair collectives, replacement parts and the odd economics of vintage gear.",
|
||||||
|
"Music hardware, for the weekend",
|
||||||
|
1700,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
fn story_article(index: usize, story: &(&str, &str, &str, &str, &str, i64)) -> Article {
|
||||||
|
let id = index as i64 + 1;
|
||||||
|
let entry_id = 1000 + id;
|
||||||
|
let (_, title, feed, _, _, words) = *story;
|
||||||
|
let mut article = fixtures::article(id, entry_id, title);
|
||||||
|
article.feed_title = feed.to_string();
|
||||||
|
article.feed_id = 7 + index as i64;
|
||||||
|
article.word_count = words;
|
||||||
|
for source in &mut article.sources {
|
||||||
|
source.feed_title = feed.to_string();
|
||||||
|
source.feed_id = article.feed_id;
|
||||||
|
}
|
||||||
|
article.content_html = body_html(title, words);
|
||||||
|
if !index.is_multiple_of(3) {
|
||||||
|
article.social.clear();
|
||||||
|
}
|
||||||
|
if index == 4 {
|
||||||
|
article.author = None;
|
||||||
|
}
|
||||||
|
article
|
||||||
|
}
|
||||||
|
|
||||||
|
fn body_html(title: &str, words: i64) -> String {
|
||||||
|
let paragraphs = (words / 120).clamp(4, 40);
|
||||||
|
let mut html = format!("<p>Body of <em>{title}</em>. ");
|
||||||
|
html.push_str("This paragraph exists so the article page has something to lay out: a first sentence with a claim, a second with the evidence, and a third that hedges just enough to be honest.</p>");
|
||||||
|
html.push_str("<h2>Where it gets interesting</h2>");
|
||||||
|
for index in 0..paragraphs {
|
||||||
|
if index == 2 {
|
||||||
|
html.push_str("<img src=\"https://picsum.photos/seed/daily-epub/900/500\" alt=\"A chart of the daily figures\">");
|
||||||
|
}
|
||||||
|
if index == 5 {
|
||||||
|
html.push_str("<blockquote><p>A pull quote that the author would rather you remembered than the rest.</p></blockquote>");
|
||||||
|
}
|
||||||
|
if index == 7 {
|
||||||
|
html.push_str(
|
||||||
|
"<pre><code>fn main() {\n println!(\"hello, reader\");\n}</code></pre>",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
html.push_str("<p>Paragraph ");
|
||||||
|
html.push_str(&(index + 1).to_string());
|
||||||
|
html.push_str(" keeps the argument moving. Long sentences alternate with short ones. Then a list of considerations that the author says nobody weighs properly, followed by the observation that everyone weighs them and simply disagrees about the weights.</p>");
|
||||||
|
}
|
||||||
|
html.push_str("<ul><li>First consideration</li><li>Second consideration</li><li>A third, longer consideration that wraps onto another line on narrow screens</li></ul>");
|
||||||
|
html
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dev_issue(date: Date, issue_number: i64, generated_at: Timestamp) -> Issue {
|
||||||
|
let mut issue = fixtures::issue();
|
||||||
|
issue.meta.date = date;
|
||||||
|
issue.meta.issue_number = issue_number;
|
||||||
|
issue.meta.generated_at = generated_at;
|
||||||
|
issue.meta.display_date = date.strftime("%A, %B %-d, %Y").to_string();
|
||||||
|
issue.lineup.date = date;
|
||||||
|
let mut section_order = Vec::new();
|
||||||
|
let mut picks = Vec::new();
|
||||||
|
let mut summaries = BTreeMap::new();
|
||||||
|
for (index, story) in STORIES.iter().enumerate() {
|
||||||
|
let (section, _, _, summary, why, _) = *story;
|
||||||
|
if !section_order.iter().any(|s| s == section) {
|
||||||
|
section_order.push(section.to_string());
|
||||||
|
}
|
||||||
|
let position = picks
|
||||||
|
.iter()
|
||||||
|
.filter(|pick: &&Pick| pick.section == section)
|
||||||
|
.count() as i64;
|
||||||
|
let article = story_article(index, story);
|
||||||
|
summaries.insert(article.id, summary.to_string());
|
||||||
|
picks.push(Pick {
|
||||||
|
discussion: (index == 0)
|
||||||
|
.then(|| fixtures::discussion(article.id, article.best_entry_id)),
|
||||||
|
article,
|
||||||
|
section: section.to_string(),
|
||||||
|
position,
|
||||||
|
is_lead: index == 0,
|
||||||
|
why: Some(why.to_string()),
|
||||||
|
summary: Some(summary.to_string()),
|
||||||
|
llm: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
issue.meta.article_count = picks.len() as i64;
|
||||||
|
issue.meta.section_count = section_order.len() as i64;
|
||||||
|
issue.meta.total_words = picks.iter().map(|pick| pick.article.word_count).sum();
|
||||||
|
issue.meta.reading_minutes = daily_epub::types::reading_minutes(issue.meta.total_words);
|
||||||
|
issue.lineup.picks = picks;
|
||||||
|
issue.lineup.section_order = section_order;
|
||||||
|
issue.editorial.summaries = summaries;
|
||||||
|
issue.editorial.front_page_html = "<p>Nine stories today. The filesystem rewrite leads because it is the rare systems piece that shows its benchmarks and its mistakes; the consensus outage walkthrough is the long read to save for the train.</p><p>Two contrarian pieces on tooling sit in the middle, and the back pages are for the weekend: a hundred-year-old sourdough starter and the people who keep old synthesizers humming.</p>".into();
|
||||||
|
if let Some(world) = issue.world_briefing.as_mut() {
|
||||||
|
world.date = date;
|
||||||
|
}
|
||||||
|
issue.behind.selected = issue.meta.article_count;
|
||||||
|
issue
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seed_issue(
|
||||||
|
db: &Db,
|
||||||
|
issue: &mut Issue,
|
||||||
|
with_snapshot: bool,
|
||||||
|
epub_path: Option<&Path>,
|
||||||
|
) -> anyhow::Result<i64> {
|
||||||
|
for pick in &mut issue.lineup.picks {
|
||||||
|
let article = &pick.article;
|
||||||
|
db.upsert_entry(&Entry {
|
||||||
|
id: article.best_entry_id,
|
||||||
|
feed_id: article.feed_id,
|
||||||
|
feed_title: Some(article.feed_title.clone()),
|
||||||
|
category: article.category.clone(),
|
||||||
|
title: article.title.clone(),
|
||||||
|
url: article.url.clone(),
|
||||||
|
canonical_url: Some(article.canonical_url.clone()),
|
||||||
|
author: article.author.clone(),
|
||||||
|
published_at: article.published_at,
|
||||||
|
comments_url: article.comments_url.clone(),
|
||||||
|
raw_content: article.content_html.clone(),
|
||||||
|
fetched_at: article.first_seen,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let id = db.upsert_article(article).await?;
|
||||||
|
pick.article.id = id;
|
||||||
|
for social in &mut pick.article.social {
|
||||||
|
social.article_id = id;
|
||||||
|
db.upsert_social(social).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let snapshot = with_snapshot
|
||||||
|
.then(|| {
|
||||||
|
let mut snapshot = issue.clone();
|
||||||
|
for pick in &mut snapshot.lineup.picks {
|
||||||
|
pick.article.content_html.clear();
|
||||||
|
}
|
||||||
|
serde_json::to_string(&snapshot)
|
||||||
|
})
|
||||||
|
.transpose()?;
|
||||||
|
let started_at = issue
|
||||||
|
.meta
|
||||||
|
.generated_at
|
||||||
|
.checked_sub(jiff::Span::new().minutes(22))
|
||||||
|
.unwrap_or(issue.meta.generated_at);
|
||||||
|
let run_id = db.start_run(issue.meta.date, started_at).await?;
|
||||||
|
let mut report = RunReport::new(issue.meta.date, started_at);
|
||||||
|
report.counts.entries_fetched = issue.colophon.entries_fetched;
|
||||||
|
report.counts.feeds_seen = issue.colophon.feeds_seen;
|
||||||
|
report.counts.articles = issue.behind.considered;
|
||||||
|
report.counts.eligible = issue.behind.eligible;
|
||||||
|
report.counts.triaged = issue.behind.triaged;
|
||||||
|
report.counts.assessed = issue.behind.read_closely;
|
||||||
|
report.counts.shortlisted = issue.behind.shortlisted;
|
||||||
|
report.counts.candidates = issue.colophon.candidates;
|
||||||
|
report.counts.selected = issue.meta.article_count;
|
||||||
|
report.counts.admitted_by = issue.behind.admitted_by.clone();
|
||||||
|
report.counts.rated_with_embeddings = issue.behind.rated_with_embeddings;
|
||||||
|
report.counts.knn_gate = issue.behind.knn_gate;
|
||||||
|
report.counts.feed_gate = issue.behind.feed_gate;
|
||||||
|
report.counts.embedded = 300;
|
||||||
|
report.provider_costs = issue
|
||||||
|
.colophon
|
||||||
|
.provider_costs
|
||||||
|
.iter()
|
||||||
|
.map(|(provider, cost_usd)| {
|
||||||
|
(
|
||||||
|
provider.clone(),
|
||||||
|
ProviderUsage {
|
||||||
|
usage: TokenUsage::default(),
|
||||||
|
cost_usd: *cost_usd,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
report.config_json = serde_json::json!({
|
||||||
|
"models": {
|
||||||
|
"bulk": issue.colophon.models.bulk,
|
||||||
|
"editor": issue.colophon.models.editor,
|
||||||
|
"embedding": issue.behind.embedding_model,
|
||||||
|
},
|
||||||
|
"editorial": {"summary_model": "editor"},
|
||||||
|
"voyage": {"model": issue.behind.embedding_model},
|
||||||
|
});
|
||||||
|
report.finish(issue.meta.generated_at);
|
||||||
|
db.finish_run(run_id, &report).await?;
|
||||||
|
let epub_path = epub_path.map(|path| path.to_string_lossy().into_owned());
|
||||||
|
db.upsert_issue(
|
||||||
|
issue.meta.date,
|
||||||
|
issue.meta.issue_number,
|
||||||
|
issue.meta.generated_at,
|
||||||
|
epub_path.as_deref(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(&issue.editorial.front_page_html),
|
||||||
|
Some(&serde_json::to_string(&report)?),
|
||||||
|
snapshot.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
db.replace_issue_articles(issue.meta.date, &issue.lineup.picks)
|
||||||
|
.await?;
|
||||||
|
Ok(run_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seed_near_misses(db: &Db, run_id: i64) -> anyhow::Result<()> {
|
||||||
|
for (index, title, quality, fit, utility) in [
|
||||||
|
(
|
||||||
|
0,
|
||||||
|
"The Database Migration That Almost Made the Cut",
|
||||||
|
8.4,
|
||||||
|
7.2,
|
||||||
|
81.5,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
1,
|
||||||
|
"An Oral History of the First E-Ink Hackers",
|
||||||
|
7.7,
|
||||||
|
8.1,
|
||||||
|
79.8,
|
||||||
|
),
|
||||||
|
(2, "A Very Good Essay About Municipal Trees", 8.0, 6.8, 76.2),
|
||||||
|
] {
|
||||||
|
let mut article = fixtures::article(0, 9001 + index, title);
|
||||||
|
article.feed_title = "The Near-Miss Review".into();
|
||||||
|
let entry = Entry {
|
||||||
|
id: article.best_entry_id,
|
||||||
|
feed_id: article.feed_id,
|
||||||
|
feed_title: Some(article.feed_title.clone()),
|
||||||
|
category: article.category.clone(),
|
||||||
|
title: article.title.clone(),
|
||||||
|
url: article.url.clone(),
|
||||||
|
canonical_url: Some(article.canonical_url.clone()),
|
||||||
|
author: article.author.clone(),
|
||||||
|
published_at: article.published_at,
|
||||||
|
comments_url: article.comments_url.clone(),
|
||||||
|
raw_content: article.content_html.clone(),
|
||||||
|
fetched_at: article.first_seen,
|
||||||
|
};
|
||||||
|
db.upsert_entry(&entry).await?;
|
||||||
|
let article_id = db.upsert_article(&article).await?;
|
||||||
|
let signals = serde_json::json!({
|
||||||
|
"v": 1,
|
||||||
|
"raw": {"quality": quality, "fit": fit},
|
||||||
|
"norm": {"quality": quality / 10.0, "fit": fit / 10.0},
|
||||||
|
"present": {"quality": true, "fit": true},
|
||||||
|
"weights": {"quality": 0.6, "fit": 0.4},
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
telemetry::write(
|
||||||
|
db,
|
||||||
|
&CandidateRun {
|
||||||
|
run_id,
|
||||||
|
article_id,
|
||||||
|
stage: "shortlisted",
|
||||||
|
excluded_reason: Some("not_selected"),
|
||||||
|
admitted_by: Some("[\"blend\"]"),
|
||||||
|
signals_json: &signals,
|
||||||
|
utility: Some(utility),
|
||||||
|
rank_utility: Some(index + 10),
|
||||||
|
cluster_id: Some(index),
|
||||||
|
cluster_rank: Some(1),
|
||||||
|
editor_why: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_config(dir: &Path) -> anyhow::Result<PathBuf> {
|
||||||
|
let path = dir.join("config.toml");
|
||||||
|
let dir = dir.canonicalize()?;
|
||||||
|
// The profile lives inside the throwaway directory: saving it from
|
||||||
|
// /dashboard/profile must never rewrite the repository's data/profile.md.
|
||||||
|
let profile = dir.join("profile.md");
|
||||||
|
let toml = format!(
|
||||||
|
"database_path = \"{db}\"\nprofile_path = \"{profile}\"\n\n[publish]\nepub_dir = \"{epubs}\"\nxtc_dir = \"{xtc}\"\n\n[server]\nbind = \"127.0.0.1:3599\"\npublic_url = \"http://127.0.0.1:3599\"\njobs_enabled = false\n",
|
||||||
|
db = dir.join("daily-epub.db").display(),
|
||||||
|
profile = profile.display(),
|
||||||
|
epubs = dir.join("epubs").display(),
|
||||||
|
xtc = dir.join("xtc").display(),
|
||||||
|
);
|
||||||
|
std::fs::write(&path, toml)?;
|
||||||
|
if !profile.exists() {
|
||||||
|
let shipped = Path::new("data/profile.md");
|
||||||
|
let body = std::fs::read_to_string(shipped)
|
||||||
|
.unwrap_or_else(|_| "# Reader profile\n\n## Who he is\n\nA developer.\n".to_string());
|
||||||
|
std::fs::write(&profile, body)?;
|
||||||
|
}
|
||||||
|
std::fs::create_dir_all(dir.join("epubs"))?;
|
||||||
|
std::fs::create_dir_all(dir.join("xtc"))?;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
let dir = std::env::args()
|
||||||
|
.nth(1)
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| PathBuf::from("dev"));
|
||||||
|
std::fs::create_dir_all(&dir)?;
|
||||||
|
let db_path = dir.join("daily-epub.db");
|
||||||
|
if db_path.exists() {
|
||||||
|
anyhow::bail!("{} already exists; delete it first", db_path.display());
|
||||||
|
}
|
||||||
|
let config_path = write_config(&dir)?;
|
||||||
|
let db = Db::open_and_migrate(&db_path).await?;
|
||||||
|
|
||||||
|
users::add(&db, "admin", ADMIN_PASSWORD, true).await?;
|
||||||
|
users::add(&db, "reader", READER_PASSWORD, false).await?;
|
||||||
|
|
||||||
|
// Legacy issue: published before `issue_json` existed, so the site must
|
||||||
|
// rebuild the colophon and back matter from the run instead.
|
||||||
|
let mut legacy = dev_issue("2026-09-01".parse()?, 18, "2026-09-01T05:31:00Z".parse()?);
|
||||||
|
let mut epub_config = Config::default();
|
||||||
|
epub_config.publish.epub_dir = dir.join("epubs");
|
||||||
|
let legacy_epub = daily_epub::epub::build_edition_with_images(
|
||||||
|
&legacy,
|
||||||
|
Edition::Standard,
|
||||||
|
&epub_config,
|
||||||
|
&epub_config.publish.epub_dir,
|
||||||
|
&[],
|
||||||
|
)?;
|
||||||
|
let legacy_run = seed_issue(&db, &mut legacy, false, Some(&legacy_epub.path)).await?;
|
||||||
|
seed_near_misses(&db, legacy_run).await?;
|
||||||
|
|
||||||
|
let mut latest = dev_issue("2026-09-02".parse()?, 19, "2026-09-02T05:29:00Z".parse()?);
|
||||||
|
seed_issue(&db, &mut latest, true, None).await?;
|
||||||
|
|
||||||
|
println!("seeded {}", db_path.display());
|
||||||
|
println!(
|
||||||
|
"run: DAILY_EPUB_SERVER__HMAC_SECRET=$(head -c 48 /dev/urandom | base64) cargo run -- --config {} serve",
|
||||||
|
config_path.display()
|
||||||
|
);
|
||||||
|
println!("open: http://127.0.0.1:3599 (admin / {ADMIN_PASSWORD}, reader / {READER_PASSWORD})");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
CREATE TABLE users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('user', 'admin')),
|
||||||
|
disabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
last_login_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
expiry INTEGER NOT NULL,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_sessions_user ON sessions(user_id);
|
||||||
|
CREATE INDEX idx_sessions_expiry ON sessions(expiry);
|
||||||
|
|
||||||
|
ALTER TABLE rating_events ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE TABLE config_changes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
old_value TEXT,
|
||||||
|
new_value TEXT,
|
||||||
|
changed_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_config_changes_at ON config_changes(changed_at);
|
||||||
|
|
||||||
|
CREATE TABLE profile_versions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
saved_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
saved_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE jobs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
unit TEXT NOT NULL,
|
||||||
|
requested_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
requested_at TEXT NOT NULL,
|
||||||
|
started_at TEXT,
|
||||||
|
finished_at TEXT,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('requested', 'running', 'ok', 'failed')),
|
||||||
|
message TEXT,
|
||||||
|
run_id INTEGER REFERENCES runs(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_jobs_requested_at ON jobs(requested_at);
|
||||||
|
|
||||||
|
ALTER TABLE runs ADD COLUMN report_json TEXT;
|
||||||
|
ALTER TABLE issues ADD COLUMN issue_json TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX idx_candidate_runs_article_run ON candidate_runs(article_id, run_id DESC);
|
||||||
Generated
+313
@@ -0,0 +1,313 @@
|
|||||||
|
{
|
||||||
|
"name": "the-daily-epub-v2b",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/cli": "4.3.3",
|
||||||
|
"tailwindcss": "4.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
|
"version": "0.3.13",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.24"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/remapping": {
|
||||||
|
"version": "2.3.5",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/gen-mapping": "^0.3.5",
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.24"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/resolve-uri": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
|
"version": "1.5.5",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/trace-mapping": {
|
||||||
|
"version": "0.3.31",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/resolve-uri": "^3.1.0",
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^1.0.3",
|
||||||
|
"is-glob": "^4.0.3",
|
||||||
|
"micromatch": "^4.0.5",
|
||||||
|
"node-addon-api": "^7.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@parcel/watcher-linux-x64-glibc": "2.5.1",
|
||||||
|
"@parcel/watcher-linux-x64-musl": "2.5.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-linux-x64-glibc": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-linux-x64-musl": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher/node_modules/detect-libc": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"dev": true,
|
||||||
|
"bin": {
|
||||||
|
"detect-libc": "bin/detect-libc.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/cli": {
|
||||||
|
"version": "4.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.3.3.tgz",
|
||||||
|
"integrity": "sha512-ZvS/n1ZHOBKcVlhkt8l5NNr1EDXk1NboYO5CYDOs6NUmvT9z6bzkwsosaJftY57T/3gWNzWMJzIXLodZC8ssdw==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@parcel/watcher": "2.5.1",
|
||||||
|
"@tailwindcss/node": "4.3.3",
|
||||||
|
"@tailwindcss/oxide": "4.3.3",
|
||||||
|
"enhanced-resolve": "^5.24.1",
|
||||||
|
"mri": "^1.2.0",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
|
"tailwindcss": "4.3.3"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tailwindcss": "dist/index.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/node": {
|
||||||
|
"version": "4.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
|
||||||
|
"integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/remapping": "^2.3.5",
|
||||||
|
"enhanced-resolve": "^5.24.1",
|
||||||
|
"jiti": "^2.7.0",
|
||||||
|
"lightningcss": "1.32.0",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"source-map-js": "^1.2.1",
|
||||||
|
"tailwindcss": "4.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide": {
|
||||||
|
"version": "4.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
|
||||||
|
"integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
|
||||||
|
"dev": true,
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
|
||||||
|
"@tailwindcss/oxide-linux-x64-musl": "4.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||||
|
"version": "4.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
|
||||||
|
"integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||||
|
"version": "4.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
|
||||||
|
"integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/braces": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"fill-range": "^7.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/detect-libc": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/enhanced-resolve": {
|
||||||
|
"version": "5.24.2",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"graceful-fs": "^4.2.4",
|
||||||
|
"tapable": "^2.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fill-range": {
|
||||||
|
"version": "7.1.1",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"to-regex-range": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/graceful-fs": {
|
||||||
|
"version": "4.2.11",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/is-extglob": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/is-glob": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"is-extglob": "^2.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-number": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/jiti": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"dev": true,
|
||||||
|
"bin": {
|
||||||
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss": {
|
||||||
|
"version": "1.32.0",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^2.0.3"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"lightningcss-linux-x64-gnu": "1.32.0",
|
||||||
|
"lightningcss-linux-x64-musl": "1.32.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-x64-gnu": {
|
||||||
|
"version": "1.32.0",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-x64-musl": {
|
||||||
|
"version": "1.32.0",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/magic-string": {
|
||||||
|
"version": "0.30.21",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/micromatch": {
|
||||||
|
"version": "4.0.8",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"braces": "^3.0.3",
|
||||||
|
"picomatch": "^2.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mri": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/node-addon-api": {
|
||||||
|
"version": "7.1.1",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/picocolors": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/picomatch": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/source-map-js": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/tailwindcss": {
|
||||||
|
"version": "4.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
|
||||||
|
"integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/tapable": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/to-regex-range": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"is-number": "^7.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"css": "tailwindcss -i src/web/tailwind.css -o src/web/static/app.css --minify",
|
||||||
|
"css:watch": "tailwindcss -i src/web/tailwind.css -o src/web/static/app.css --watch",
|
||||||
|
"css:check": "tmp=$(mktemp) && trap 'rm -f \"$tmp\"' EXIT && tailwindcss -i src/web/tailwind.css -o \"$tmp\" --minify && diff -u src/web/static/app.css \"$tmp\""
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/cli": "4.3.3",
|
||||||
|
"tailwindcss": "4.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -700,6 +700,15 @@ pub struct ServerConfig {
|
|||||||
/// Optional Basic auth for `/opds/*` and `/files/*`.
|
/// Optional Basic auth for `/opds/*` and `/files/*`.
|
||||||
pub basic_auth_user: Option<String>,
|
pub basic_auth_user: Option<String>,
|
||||||
pub basic_auth_pass: Option<String>,
|
pub basic_auth_pass: Option<String>,
|
||||||
|
/// Sliding web-session lifetime in days.
|
||||||
|
pub session_days: u32,
|
||||||
|
/// Login attempts allowed per IP during the configured window.
|
||||||
|
pub login_attempts: u32,
|
||||||
|
pub login_window_minutes: u32,
|
||||||
|
/// Whether the operator dashboard may start systemd jobs.
|
||||||
|
pub jobs_enabled: bool,
|
||||||
|
/// Number of journal lines displayed for a job.
|
||||||
|
pub journal_lines: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ServerConfig {
|
impl Default for ServerConfig {
|
||||||
@@ -710,6 +719,11 @@ impl Default for ServerConfig {
|
|||||||
hmac_secret: None,
|
hmac_secret: None,
|
||||||
basic_auth_user: None,
|
basic_auth_user: None,
|
||||||
basic_auth_pass: None,
|
basic_auth_pass: None,
|
||||||
|
session_days: 30,
|
||||||
|
login_attempts: 10,
|
||||||
|
login_window_minutes: 15,
|
||||||
|
jobs_enabled: true,
|
||||||
|
journal_lines: 300,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -931,6 +945,26 @@ impl Config {
|
|||||||
|
|
||||||
/// Cheap sanity checks so misconfiguration fails at startup, not mid-run.
|
/// Cheap sanity checks so misconfiguration fails at startup, not mid-run.
|
||||||
pub fn validate(&self) -> Result<(), ConfigError> {
|
pub fn validate(&self) -> Result<(), ConfigError> {
|
||||||
|
if self.server.session_days == 0 {
|
||||||
|
return Err(ConfigError::Invalid(
|
||||||
|
"server.session_days must be >= 1".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.server.login_attempts == 0 {
|
||||||
|
return Err(ConfigError::Invalid(
|
||||||
|
"server.login_attempts must be >= 1".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.server.login_window_minutes == 0 {
|
||||||
|
return Err(ConfigError::Invalid(
|
||||||
|
"server.login_window_minutes must be >= 1".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !(10..=5000).contains(&self.server.journal_lines) {
|
||||||
|
return Err(ConfigError::Invalid(
|
||||||
|
"server.journal_lines must be between 10 and 5000".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
if self.lookback_hours == 0 {
|
if self.lookback_hours == 0 {
|
||||||
return Err(ConfigError::Invalid("lookback_hours must be > 0".into()));
|
return Err(ConfigError::Invalid("lookback_hours must be > 0".into()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ pub const REBUILD_INTERVAL_DAYS: i64 = 7;
|
|||||||
/// so their `current_ratings` lookback is effectively unbounded.
|
/// so their `current_ratings` lookback is effectively unbounded.
|
||||||
const RATINGS_LOOKBACK_DAYS: i64 = 36_500;
|
const RATINGS_LOOKBACK_DAYS: i64 = 36_500;
|
||||||
pub const KV_LEARNED_ADJUSTMENTS: &str = "taste_profile_learned";
|
pub const KV_LEARNED_ADJUSTMENTS: &str = "taste_profile_learned";
|
||||||
const MAX_RATINGS_IN_REBUILD: usize = 200;
|
pub const MAX_RATINGS_IN_REBUILD: usize = 200;
|
||||||
|
|
||||||
pub const NO_LEARNED_ADJUSTMENTS: &str = "No reader ratings have been collected yet. Judge purely on the stated preferences and interests above.";
|
pub const NO_LEARNED_ADJUSTMENTS: &str = "No reader ratings have been collected yet. Judge purely on the stated preferences and interests above.";
|
||||||
|
|
||||||
@@ -239,7 +239,9 @@ struct ProfileVersion {
|
|||||||
built_at: String,
|
built_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn stored_version(db: &Db) -> anyhow::Result<Option<(i64, Timestamp)>> {
|
/// The stored `(version, built_at)` of the taste profile, `None` before the
|
||||||
|
/// first build. The dashboard's profile page reads it (web plan §11).
|
||||||
|
pub async fn stored_version(db: &Db) -> anyhow::Result<Option<(i64, Timestamp)>> {
|
||||||
let Some(raw) = db.kv_get(KV_PROFILE_VERSION).await? else {
|
let Some(raw) = db.kv_get(KV_PROFILE_VERSION).await? else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
@@ -509,6 +511,7 @@ mod tests {
|
|||||||
fn prompt_sections_are_ordered_and_verdicts_have_required_labels() {
|
fn prompt_sections_are_ordered_and_verdicts_have_required_labels() {
|
||||||
let rating = RatedArticle {
|
let rating = RatedArticle {
|
||||||
article_id: 1,
|
article_id: 1,
|
||||||
|
user_id: None,
|
||||||
issue_date: None,
|
issue_date: None,
|
||||||
title: "A title".into(),
|
title: "A title".into(),
|
||||||
feed_title: "A feed".into(),
|
feed_title: "A feed".into(),
|
||||||
@@ -551,6 +554,7 @@ mod tests {
|
|||||||
fn rebuild_prompt_carries_summary_facets_note_and_diversity_instruction() {
|
fn rebuild_prompt_carries_summary_facets_note_and_diversity_instruction() {
|
||||||
let rating = RatedArticle {
|
let rating = RatedArticle {
|
||||||
article_id: 1,
|
article_id: 1,
|
||||||
|
user_id: None,
|
||||||
issue_date: Some("2026-08-15".parse().unwrap()),
|
issue_date: Some("2026-08-15".parse().unwrap()),
|
||||||
title: "Postgres failover".into(),
|
title: "Postgres failover".into(),
|
||||||
feed_title: "Engineering Notes".into(),
|
feed_title: "Engineering Notes".into(),
|
||||||
@@ -647,6 +651,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
db.append_rating_event(&RatingEvent {
|
db.append_rating_event(&RatingEvent {
|
||||||
id: 0,
|
id: 0,
|
||||||
|
user_id: None,
|
||||||
article_id: 1,
|
article_id: 1,
|
||||||
issue_date: None,
|
issue_date: None,
|
||||||
kind: "explicit".into(),
|
kind: "explicit".into(),
|
||||||
|
|||||||
+385
-75
@@ -624,16 +624,179 @@ fn first_retriever(admitted_by: Option<&str>) -> String {
|
|||||||
.unwrap_or_else(|| "unknown".to_string())
|
.unwrap_or_else(|| "unknown".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone, Copy)]
|
/// Rated picks by admitting retriever: how many were rated, up, down.
|
||||||
struct UpDown {
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||||
rated: i64,
|
pub struct UpDown {
|
||||||
up: i64,
|
pub rated: i64,
|
||||||
down: i64,
|
pub up: i64,
|
||||||
|
pub down: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpDown {
|
||||||
|
/// `"NN% up"`, or `n/a` with nothing rated.
|
||||||
|
pub fn ratio(&self) -> String {
|
||||||
|
if self.rated > 0 {
|
||||||
|
format!("{:.0}% up", 100.0 * self.up as f64 / self.rated as f64)
|
||||||
|
} else {
|
||||||
|
"n/a".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One finished, non-dry run as a point of the per-run series (dashboard
|
||||||
|
/// plan §9.1, §12).
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct RunPoint {
|
||||||
|
pub run_id: i64,
|
||||||
|
pub date: String,
|
||||||
|
pub started_at: String,
|
||||||
|
pub status: String,
|
||||||
|
pub cost_usd: f64,
|
||||||
|
pub selected: i64,
|
||||||
|
pub duration_secs: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything `daily-epub stats` prints, as data (dashboard plan §12): the
|
||||||
|
/// CLI renders it with [`render_stats_text`], the stats page as tables and
|
||||||
|
/// sparklines.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq)]
|
||||||
|
pub struct StatsData {
|
||||||
|
pub days: i64,
|
||||||
|
/// First UTC date of the window.
|
||||||
|
pub since_date: String,
|
||||||
|
/// UTC date of `now`.
|
||||||
|
pub today: String,
|
||||||
|
pub issues: i64,
|
||||||
|
pub published: i64,
|
||||||
|
/// Explicit ratings per label, label order; `cleared` included.
|
||||||
|
pub ratings_by_label: Vec<(String, i64)>,
|
||||||
|
/// Explicit ratings excluding `cleared`.
|
||||||
|
pub total_ratings: i64,
|
||||||
|
pub per_retriever: BTreeMap<String, UpDown>,
|
||||||
|
pub exploration_admitted: i64,
|
||||||
|
pub exploration_selected: i64,
|
||||||
|
pub exploration_positive: i64,
|
||||||
|
/// Provider → spend over the whole window.
|
||||||
|
pub provider_totals: BTreeMap<String, f64>,
|
||||||
|
/// UTC date → provider → spend.
|
||||||
|
pub cost_by_day: BTreeMap<String, BTreeMap<String, f64>>,
|
||||||
|
/// Seconds of every finished run in the window (dry runs included), for
|
||||||
|
/// the mean generation time.
|
||||||
|
pub durations: Vec<i64>,
|
||||||
|
/// Finished non-dry runs in the window, oldest first.
|
||||||
|
pub runs: Vec<RunPoint>,
|
||||||
|
/// Issue date → picks, oldest first.
|
||||||
|
pub selected_per_issue: Vec<(String, i64)>,
|
||||||
|
/// Week (Monday, UTC) → label → explicit ratings.
|
||||||
|
pub ratings_per_week: BTreeMap<String, BTreeMap<String, i64>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StatsData {
|
||||||
|
/// `n` per issue with one decimal, or `n/a` without issues.
|
||||||
|
pub fn per_issue(&self, n: i64) -> String {
|
||||||
|
if self.issues > 0 {
|
||||||
|
format!("{:.1}", n as f64 / self.issues as f64)
|
||||||
|
} else {
|
||||||
|
"n/a".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mean_issue_size(&self) -> String {
|
||||||
|
self.per_issue(self.published)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ratings_per_issue(&self) -> String {
|
||||||
|
self.per_issue(self.total_ratings)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spend per day over the window for one provider total.
|
||||||
|
pub fn per_day(&self, total: f64) -> f64 {
|
||||||
|
total / self.days as f64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every provider's window spend added up, in provider order.
|
||||||
|
pub fn grand_total(&self) -> f64 {
|
||||||
|
let mut grand = 0.0;
|
||||||
|
for total in self.provider_totals.values() {
|
||||||
|
grand += total;
|
||||||
|
}
|
||||||
|
grand
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Integer mean of the finished runs' seconds.
|
||||||
|
pub fn mean_generation_secs(&self) -> Option<i64> {
|
||||||
|
if self.durations.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(self.durations.iter().sum::<i64>() / self.durations.len() as i64)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `daily-epub stats [--days N]` as text: the whole evaluation framework
|
/// `daily-epub stats [--days N]` as text: the whole evaluation framework
|
||||||
/// (§15.3). One fact per line, nothing wider than 80 columns.
|
/// (§15.3). One fact per line, nothing wider than 80 columns.
|
||||||
pub async fn stats(db: &Db, days: i64, now: Timestamp) -> anyhow::Result<String> {
|
pub async fn stats(db: &Db, days: i64, now: Timestamp) -> anyhow::Result<String> {
|
||||||
|
Ok(render_stats_text(&stats_data(db, days, now).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finished non-dry runs as sparkline points, oldest first: those started
|
||||||
|
/// at or after `since` (RFC3339) and/or the newest `limit`.
|
||||||
|
pub async fn run_series(
|
||||||
|
db: &Db,
|
||||||
|
since: Option<&str>,
|
||||||
|
limit: Option<i64>,
|
||||||
|
) -> Result<Vec<RunPoint>, sqlx::Error> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT id, date, started_at, finished_at, status, cost_usd, selected FROM runs
|
||||||
|
WHERE status != 'dry_run' AND finished_at IS NOT NULL
|
||||||
|
AND (? IS NULL OR started_at >= ?)
|
||||||
|
ORDER BY id DESC LIMIT ?",
|
||||||
|
)
|
||||||
|
.bind(since)
|
||||||
|
.bind(since)
|
||||||
|
.bind(limit.unwrap_or(i64::MAX))
|
||||||
|
.fetch_all(db.pool())
|
||||||
|
.await?;
|
||||||
|
let mut points: Vec<RunPoint> = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| {
|
||||||
|
let started_at: String = row.get("started_at");
|
||||||
|
let finished_at: Option<String> = row.get("finished_at");
|
||||||
|
let duration_secs = match (
|
||||||
|
started_at.parse::<Timestamp>(),
|
||||||
|
finished_at.as_deref().map(str::parse::<Timestamp>),
|
||||||
|
) {
|
||||||
|
(Ok(started), Some(Ok(finished))) => {
|
||||||
|
Some((finished.as_second() - started.as_second()).max(0))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
RunPoint {
|
||||||
|
run_id: row.get("id"),
|
||||||
|
date: row.get("date"),
|
||||||
|
started_at,
|
||||||
|
status: row.get("status"),
|
||||||
|
cost_usd: row.get("cost_usd"),
|
||||||
|
selected: row.get("selected"),
|
||||||
|
duration_secs,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
points.reverse();
|
||||||
|
Ok(points)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Monday (UTC) of the week containing `ts`, as `YYYY-MM-DD`.
|
||||||
|
pub fn week_start(ts: Timestamp) -> String {
|
||||||
|
let date = ts.to_zoned(jiff::tz::TimeZone::UTC).date();
|
||||||
|
let offset = i64::from(date.weekday().to_monday_zero_offset());
|
||||||
|
date.checked_sub(jiff::Span::new().days(offset))
|
||||||
|
.unwrap_or(date)
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gather every figure of [`StatsData`] for the last `days` days.
|
||||||
|
pub async fn stats_data(db: &Db, days: i64, now: Timestamp) -> anyhow::Result<StatsData> {
|
||||||
let days = days.max(1);
|
let days = days.max(1);
|
||||||
let since_ts = now
|
let since_ts = now
|
||||||
.checked_sub(jiff::Span::new().hours(days.saturating_mul(24)))
|
.checked_sub(jiff::Span::new().hours(days.saturating_mul(24)))
|
||||||
@@ -641,30 +804,35 @@ pub async fn stats(db: &Db, days: i64, now: Timestamp) -> anyhow::Result<String>
|
|||||||
let since = fmt_ts(since_ts);
|
let since = fmt_ts(since_ts);
|
||||||
let utc = jiff::tz::TimeZone::UTC;
|
let utc = jiff::tz::TimeZone::UTC;
|
||||||
let since_date = since_ts.to_zoned(utc.clone()).date().to_string();
|
let since_date = since_ts.to_zoned(utc.clone()).date().to_string();
|
||||||
let today = now.to_zoned(utc).date().to_string();
|
let today = now.to_zoned(utc.clone()).date().to_string();
|
||||||
let mut out = String::new();
|
let mut data = StatsData {
|
||||||
let _ = writeln!(out, "stats: last {days} days ({since_date} → {today})");
|
days,
|
||||||
|
since_date: since_date.clone(),
|
||||||
|
today,
|
||||||
|
..StatsData::default()
|
||||||
|
};
|
||||||
|
|
||||||
// --- issues and articles ---
|
// --- issues and articles ---
|
||||||
let issues: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issues WHERE date >= ?")
|
data.issues = sqlx::query_scalar("SELECT COUNT(*) FROM issues WHERE date >= ?")
|
||||||
.bind(&since_date)
|
.bind(&since_date)
|
||||||
.fetch_one(db.pool())
|
.fetch_one(db.pool())
|
||||||
.await?;
|
.await?;
|
||||||
let published: i64 =
|
data.published =
|
||||||
sqlx::query_scalar("SELECT COUNT(*) FROM issue_articles WHERE issue_date >= ?")
|
sqlx::query_scalar("SELECT COUNT(*) FROM issue_articles WHERE issue_date >= ?")
|
||||||
.bind(&since_date)
|
.bind(&since_date)
|
||||||
.fetch_one(db.pool())
|
.fetch_one(db.pool())
|
||||||
.await?;
|
.await?;
|
||||||
let _ = writeln!(out, "issues: {issues}");
|
let per_issue_rows = sqlx::query(
|
||||||
let _ = writeln!(out, "articles published: {published}");
|
"SELECT issue_date, COUNT(*) AS n FROM issue_articles
|
||||||
let per_issue = |n: i64| {
|
WHERE issue_date >= ? GROUP BY issue_date ORDER BY issue_date",
|
||||||
if issues > 0 {
|
)
|
||||||
format!("{:.1}", n as f64 / issues as f64)
|
.bind(&since_date)
|
||||||
} else {
|
.fetch_all(db.pool())
|
||||||
"n/a".to_string()
|
.await?;
|
||||||
}
|
data.selected_per_issue = per_issue_rows
|
||||||
};
|
.iter()
|
||||||
let _ = writeln!(out, "mean issue size: {} articles", per_issue(published));
|
.map(|row| (row.get::<String, _>("issue_date"), row.get::<i64, _>("n")))
|
||||||
|
.collect();
|
||||||
|
|
||||||
// --- explicit ratings by label ---
|
// --- explicit ratings by label ---
|
||||||
let labels = sqlx::query(
|
let labels = sqlx::query(
|
||||||
@@ -674,21 +842,32 @@ pub async fn stats(db: &Db, days: i64, now: Timestamp) -> anyhow::Result<String>
|
|||||||
.bind(&since)
|
.bind(&since)
|
||||||
.fetch_all(db.pool())
|
.fetch_all(db.pool())
|
||||||
.await?;
|
.await?;
|
||||||
let mut total_ratings = 0i64;
|
|
||||||
let mut by_label = Vec::new();
|
|
||||||
for row in &labels {
|
for row in &labels {
|
||||||
let label = row.get::<String, _>("label");
|
let label = row.get::<String, _>("label");
|
||||||
let n = row.get::<i64, _>("n");
|
let n = row.get::<i64, _>("n");
|
||||||
if label != "cleared" {
|
if label != "cleared" {
|
||||||
total_ratings += n;
|
data.total_ratings += n;
|
||||||
}
|
}
|
||||||
by_label.push((label, n));
|
data.ratings_by_label.push((label, n));
|
||||||
}
|
}
|
||||||
let _ = writeln!(out, "explicit ratings: {total_ratings}");
|
let events = sqlx::query(
|
||||||
for (label, n) in &by_label {
|
"SELECT label, event_at FROM rating_events
|
||||||
let _ = writeln!(out, "explicit ratings ({label}): {n}");
|
WHERE kind = 'explicit' AND event_at >= ? ORDER BY event_at",
|
||||||
|
)
|
||||||
|
.bind(&since)
|
||||||
|
.fetch_all(db.pool())
|
||||||
|
.await?;
|
||||||
|
for row in &events {
|
||||||
|
let Ok(event_at) = row.get::<String, _>("event_at").parse::<Timestamp>() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
*data
|
||||||
|
.ratings_per_week
|
||||||
|
.entry(week_start(event_at))
|
||||||
|
.or_default()
|
||||||
|
.entry(row.get::<String, _>("label"))
|
||||||
|
.or_insert(0) += 1;
|
||||||
}
|
}
|
||||||
let _ = writeln!(out, "ratings per issue: {}", per_issue(total_ratings));
|
|
||||||
|
|
||||||
// --- up/down per admitting retriever, from rated picks ---
|
// --- up/down per admitting retriever, from rated picks ---
|
||||||
let rated_picks = sqlx::query(
|
let rated_picks = sqlx::query(
|
||||||
@@ -712,8 +891,6 @@ pub async fn stats(db: &Db, days: i64, now: Timestamp) -> anyhow::Result<String>
|
|||||||
.bind(&since)
|
.bind(&since)
|
||||||
.fetch_all(db.pool())
|
.fetch_all(db.pool())
|
||||||
.await?;
|
.await?;
|
||||||
let mut per_retriever: BTreeMap<String, UpDown> = BTreeMap::new();
|
|
||||||
let mut exploration_positive = 0i64;
|
|
||||||
let mut seen: Option<ArticleId> = None;
|
let mut seen: Option<ArticleId> = None;
|
||||||
for row in &rated_picks {
|
for row in &rated_picks {
|
||||||
let article_id = row.get::<ArticleId, _>("article_id");
|
let article_id = row.get::<ArticleId, _>("article_id");
|
||||||
@@ -723,7 +900,7 @@ pub async fn stats(db: &Db, days: i64, now: Timestamp) -> anyhow::Result<String>
|
|||||||
seen = Some(article_id);
|
seen = Some(article_id);
|
||||||
let value = row.get::<f64, _>("value");
|
let value = row.get::<f64, _>("value");
|
||||||
let retriever = first_retriever(row.get::<Option<String>, _>("admitted_by").as_deref());
|
let retriever = first_retriever(row.get::<Option<String>, _>("admitted_by").as_deref());
|
||||||
let entry = per_retriever.entry(retriever).or_default();
|
let entry = data.per_retriever.entry(retriever).or_default();
|
||||||
entry.rated += 1;
|
entry.rated += 1;
|
||||||
if value > 0.0 {
|
if value > 0.0 {
|
||||||
entry.up += 1;
|
entry.up += 1;
|
||||||
@@ -735,24 +912,9 @@ pub async fn stats(db: &Db, days: i64, now: Timestamp) -> anyhow::Result<String>
|
|||||||
.map(|signals| signals.exploration)
|
.map(|signals| signals.exploration)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if exploration && value > 0.0 {
|
if exploration && value > 0.0 {
|
||||||
exploration_positive += 1;
|
data.exploration_positive += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if per_retriever.is_empty() {
|
|
||||||
let _ = writeln!(out, "rated picks by admitting retriever: none");
|
|
||||||
}
|
|
||||||
for (retriever, counts) in &per_retriever {
|
|
||||||
let ratio = if counts.rated > 0 {
|
|
||||||
format!("{:.0}% up", 100.0 * counts.up as f64 / counts.rated as f64)
|
|
||||||
} else {
|
|
||||||
"n/a".to_string()
|
|
||||||
};
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"admitted by {retriever}: {} rated · {} up · {} down · {ratio}",
|
|
||||||
counts.rated, counts.up, counts.down
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- exploration yield ---
|
// --- exploration yield ---
|
||||||
let exploration_rows = sqlx::query(
|
let exploration_rows = sqlx::query(
|
||||||
@@ -764,31 +926,25 @@ pub async fn stats(db: &Db, days: i64, now: Timestamp) -> anyhow::Result<String>
|
|||||||
.bind(&since)
|
.bind(&since)
|
||||||
.fetch_all(db.pool())
|
.fetch_all(db.pool())
|
||||||
.await?;
|
.await?;
|
||||||
let mut exploration_admitted = 0i64;
|
|
||||||
let mut exploration_selected = 0i64;
|
|
||||||
for row in &exploration_rows {
|
for row in &exploration_rows {
|
||||||
match row.get::<String, _>("stage").as_str() {
|
match row.get::<String, _>("stage").as_str() {
|
||||||
"selected" => {
|
"selected" => {
|
||||||
exploration_admitted += 1;
|
data.exploration_admitted += 1;
|
||||||
exploration_selected += 1;
|
data.exploration_selected += 1;
|
||||||
}
|
}
|
||||||
"admitted" | "assessed" | "shortlisted" => exploration_admitted += 1,
|
"admitted" | "assessed" | "shortlisted" => data.exploration_admitted += 1,
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let _ = writeln!(out, "exploration admitted: {exploration_admitted}");
|
|
||||||
let _ = writeln!(out, "exploration selected: {exploration_selected}");
|
|
||||||
let _ = writeln!(out, "exploration rated positively: {exploration_positive}");
|
|
||||||
|
|
||||||
// --- cost per day per provider (§7.6) ---
|
// --- cost per day per provider (§7.6) ---
|
||||||
let cost_rows = sqlx::query(
|
let cost_rows = sqlx::query(
|
||||||
"SELECT provider_costs_json FROM runs
|
"SELECT started_at, provider_costs_json FROM runs
|
||||||
WHERE started_at >= ? AND provider_costs_json IS NOT NULL",
|
WHERE started_at >= ? AND provider_costs_json IS NOT NULL",
|
||||||
)
|
)
|
||||||
.bind(&since)
|
.bind(&since)
|
||||||
.fetch_all(db.pool())
|
.fetch_all(db.pool())
|
||||||
.await?;
|
.await?;
|
||||||
let mut totals: BTreeMap<String, f64> = BTreeMap::new();
|
|
||||||
for row in &cost_rows {
|
for row in &cost_rows {
|
||||||
let raw = row.get::<String, _>("provider_costs_json");
|
let raw = row.get::<String, _>("provider_costs_json");
|
||||||
let Ok(providers) =
|
let Ok(providers) =
|
||||||
@@ -796,20 +952,21 @@ pub async fn stats(db: &Db, days: i64, now: Timestamp) -> anyhow::Result<String>
|
|||||||
else {
|
else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
let day = row
|
||||||
|
.get::<String, _>("started_at")
|
||||||
|
.parse::<Timestamp>()
|
||||||
|
.map(|ts| ts.to_zoned(utc.clone()).date().to_string())
|
||||||
|
.unwrap_or_else(|_| since_date.clone());
|
||||||
for (provider, usage) in providers {
|
for (provider, usage) in providers {
|
||||||
*totals.entry(provider).or_insert(0.0) += usage.cost_usd;
|
*data.provider_totals.entry(provider.clone()).or_insert(0.0) += usage.cost_usd;
|
||||||
|
*data
|
||||||
|
.cost_by_day
|
||||||
|
.entry(day.clone())
|
||||||
|
.or_default()
|
||||||
|
.entry(provider)
|
||||||
|
.or_insert(0.0) += usage.cost_usd;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut grand = 0.0;
|
|
||||||
for (provider, total) in &totals {
|
|
||||||
grand += total;
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"cost per day ({provider}): ${:.3}",
|
|
||||||
total / days as f64
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let _ = writeln!(out, "cost per day (total): ${:.3}", grand / days as f64);
|
|
||||||
|
|
||||||
// --- mean generation time ---
|
// --- mean generation time ---
|
||||||
let run_rows = sqlx::query(
|
let run_rows = sqlx::query(
|
||||||
@@ -819,26 +976,81 @@ pub async fn stats(db: &Db, days: i64, now: Timestamp) -> anyhow::Result<String>
|
|||||||
.bind(&since)
|
.bind(&since)
|
||||||
.fetch_all(db.pool())
|
.fetch_all(db.pool())
|
||||||
.await?;
|
.await?;
|
||||||
let mut durations = Vec::new();
|
|
||||||
for row in &run_rows {
|
for row in &run_rows {
|
||||||
let started = row.get::<String, _>("started_at").parse::<Timestamp>();
|
let started = row.get::<String, _>("started_at").parse::<Timestamp>();
|
||||||
let finished = row.get::<String, _>("finished_at").parse::<Timestamp>();
|
let finished = row.get::<String, _>("finished_at").parse::<Timestamp>();
|
||||||
if let (Ok(started), Ok(finished)) = (started, finished) {
|
if let (Ok(started), Ok(finished)) = (started, finished) {
|
||||||
durations.push((finished.as_second() - started.as_second()).max(0));
|
data.durations
|
||||||
|
.push((finished.as_second() - started.as_second()).max(0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if durations.is_empty() {
|
|
||||||
|
data.runs = run_series(db, Some(&since), None).await?;
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The CLI text of [`StatsData`], one fact per line.
|
||||||
|
pub fn render_stats_text(data: &StatsData) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"stats: last {} days ({} → {})",
|
||||||
|
data.days, data.since_date, data.today
|
||||||
|
);
|
||||||
|
let _ = writeln!(out, "issues: {}", data.issues);
|
||||||
|
let _ = writeln!(out, "articles published: {}", data.published);
|
||||||
|
let _ = writeln!(out, "mean issue size: {} articles", data.mean_issue_size());
|
||||||
|
let _ = writeln!(out, "explicit ratings: {}", data.total_ratings);
|
||||||
|
for (label, n) in &data.ratings_by_label {
|
||||||
|
let _ = writeln!(out, "explicit ratings ({label}): {n}");
|
||||||
|
}
|
||||||
|
let _ = writeln!(out, "ratings per issue: {}", data.ratings_per_issue());
|
||||||
|
if data.per_retriever.is_empty() {
|
||||||
|
let _ = writeln!(out, "rated picks by admitting retriever: none");
|
||||||
|
}
|
||||||
|
for (retriever, counts) in &data.per_retriever {
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"admitted by {retriever}: {} rated · {} up · {} down · {}",
|
||||||
|
counts.rated,
|
||||||
|
counts.up,
|
||||||
|
counts.down,
|
||||||
|
counts.ratio()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let _ = writeln!(out, "exploration admitted: {}", data.exploration_admitted);
|
||||||
|
let _ = writeln!(out, "exploration selected: {}", data.exploration_selected);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"exploration rated positively: {}",
|
||||||
|
data.exploration_positive
|
||||||
|
);
|
||||||
|
for (provider, total) in &data.provider_totals {
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"cost per day ({provider}): ${:.3}",
|
||||||
|
data.per_day(*total)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"cost per day (total): ${:.3}",
|
||||||
|
data.per_day(data.grand_total())
|
||||||
|
);
|
||||||
|
match data.mean_generation_secs() {
|
||||||
|
None => {
|
||||||
let _ = writeln!(out, "mean generation time: n/a (0 runs)");
|
let _ = writeln!(out, "mean generation time: n/a (0 runs)");
|
||||||
} else {
|
}
|
||||||
let mean = durations.iter().sum::<i64>() / durations.len() as i64;
|
Some(mean) => {
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
out,
|
out,
|
||||||
"mean generation time: {} ({} runs)",
|
"mean generation time: {} ({} runs)",
|
||||||
RunReport::format_duration(mean),
|
RunReport::format_duration(mean),
|
||||||
durations.len()
|
data.durations.len()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Ok(out)
|
}
|
||||||
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1523,6 +1735,41 @@ mod tests {
|
|||||||
text.lines().all(|line| line.chars().count() <= 80),
|
text.lines().all(|line| line.chars().count() <= 80),
|
||||||
"no line wider than 80 columns"
|
"no line wider than 80 columns"
|
||||||
);
|
);
|
||||||
|
// Dashboard plan §12: the CLI output is byte-identical before and
|
||||||
|
// after the `stats_data` / `render_stats_text` split.
|
||||||
|
assert_eq!(text, STATS_TEXT_BEFORE_REFACTOR);
|
||||||
|
let data = stats_data(&db, 14, now).await.unwrap();
|
||||||
|
assert_eq!(render_stats_text(&data), text);
|
||||||
|
|
||||||
|
// The figures the stats page adds on top of the text.
|
||||||
|
assert_eq!(data.issues, 2);
|
||||||
|
assert_eq!(data.total_ratings, 4);
|
||||||
|
assert_eq!(
|
||||||
|
data.selected_per_issue,
|
||||||
|
vec![("2026-08-30".to_string(), 2), ("2026-09-01".to_string(), 1)]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
data.cost_by_day["2026-08-30"]["deepseek"],
|
||||||
|
0.10 + 0.04,
|
||||||
|
"both 08-30 runs land on the same day"
|
||||||
|
);
|
||||||
|
assert_eq!(data.cost_by_day["2026-09-01"]["gemini"], 0.07);
|
||||||
|
assert_eq!(data.ratings_per_week["2026-08-31"]["loved"], 2);
|
||||||
|
assert_eq!(data.ratings_per_week["2026-08-31"]["not_for_me"], 1);
|
||||||
|
assert_eq!(data.ratings_per_week["2026-08-17"]["cleared"], 1);
|
||||||
|
assert_eq!(data.runs.len(), 3, "three finished non-dry runs");
|
||||||
|
assert_eq!(data.runs[0].run_id, run_ids[0], "oldest first");
|
||||||
|
assert_eq!(data.runs[0].duration_secs, Some(1200));
|
||||||
|
assert_eq!(data.runs[2].date, "2026-09-01");
|
||||||
|
assert_eq!(data.per_retriever["knn"].ratio(), "100% up");
|
||||||
|
assert_eq!(data.mean_generation_secs(), Some(900));
|
||||||
|
|
||||||
|
// The newest-N form the overview uses.
|
||||||
|
let last_two = run_series(&db, None, Some(2)).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
last_two.iter().map(|p| p.run_id).collect::<Vec<_>>(),
|
||||||
|
vec![run_ids[1], run_ids[2]]
|
||||||
|
);
|
||||||
|
|
||||||
// An empty database still prints every heading.
|
// An empty database still prints every heading.
|
||||||
let (_dir, empty) = db_with_articles(&[]).await;
|
let (_dir, empty) = db_with_articles(&[]).await;
|
||||||
@@ -1537,6 +1784,69 @@ mod tests {
|
|||||||
] {
|
] {
|
||||||
assert!(text.contains(line), "missing {line:?} in:\n{text}");
|
assert!(text.contains(line), "missing {line:?} in:\n{text}");
|
||||||
}
|
}
|
||||||
|
assert_eq!(text, STATS_TEXT_EMPTY_BEFORE_REFACTOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `stats(db, 14, 2026-09-02T12:00:00Z)` over the seed above, captured
|
||||||
|
/// from the pre-refactor implementation.
|
||||||
|
const STATS_TEXT_BEFORE_REFACTOR: &str = "\
|
||||||
|
stats: last 14 days (2026-08-19 → 2026-09-02)
|
||||||
|
issues: 2
|
||||||
|
articles published: 3
|
||||||
|
mean issue size: 1.5 articles
|
||||||
|
explicit ratings: 4
|
||||||
|
explicit ratings (cleared): 1
|
||||||
|
explicit ratings (good): 1
|
||||||
|
explicit ratings (loved): 2
|
||||||
|
explicit ratings (not_for_me): 1
|
||||||
|
ratings per issue: 2.0
|
||||||
|
admitted by blend: 1 rated · 0 up · 1 down · 0% up
|
||||||
|
admitted by exploration: 1 rated · 1 up · 0 down · 100% up
|
||||||
|
admitted by knn: 1 rated · 1 up · 0 down · 100% up
|
||||||
|
exploration admitted: 2
|
||||||
|
exploration selected: 1
|
||||||
|
exploration rated positively: 1
|
||||||
|
cost per day (anthropic): $0.043
|
||||||
|
cost per day (deepseek): $0.020
|
||||||
|
cost per day (gemini): $0.005
|
||||||
|
cost per day (voyage): $0.001
|
||||||
|
cost per day (total): $0.069
|
||||||
|
mean generation time: 15m00s (3 runs)
|
||||||
|
";
|
||||||
|
|
||||||
|
const STATS_TEXT_EMPTY_BEFORE_REFACTOR: &str = "\
|
||||||
|
stats: last 7 days (2026-08-26 → 2026-09-02)
|
||||||
|
issues: 0
|
||||||
|
articles published: 0
|
||||||
|
mean issue size: n/a articles
|
||||||
|
explicit ratings: 0
|
||||||
|
ratings per issue: n/a
|
||||||
|
rated picks by admitting retriever: none
|
||||||
|
exploration admitted: 0
|
||||||
|
exploration selected: 0
|
||||||
|
exploration rated positively: 0
|
||||||
|
cost per day (total): $0.000
|
||||||
|
mean generation time: n/a (0 runs)
|
||||||
|
";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn week_start_is_the_utc_monday() {
|
||||||
|
assert_eq!(
|
||||||
|
week_start("2026-09-02T12:00:00Z".parse().unwrap()),
|
||||||
|
"2026-08-31"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
week_start("2026-08-31T00:00:00Z".parse().unwrap()),
|
||||||
|
"2026-08-31"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
week_start("2026-09-06T23:59:59Z".parse().unwrap()),
|
||||||
|
"2026-08-31"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
week_start("2026-09-07T00:00:00Z".parse().unwrap()),
|
||||||
|
"2026-09-07"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -60,6 +60,27 @@ pub struct Db {
|
|||||||
pool: SqlitePool,
|
pool: SqlitePool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct IssueRow {
|
||||||
|
pub date: Date,
|
||||||
|
pub issue_number: i64,
|
||||||
|
pub generated_at: Timestamp,
|
||||||
|
pub epub_path: Option<String>,
|
||||||
|
pub x4_path: Option<String>,
|
||||||
|
pub xtc_path: Option<String>,
|
||||||
|
pub front_page_html: Option<String>,
|
||||||
|
pub report_json: Option<String>,
|
||||||
|
pub issue_json: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct IssueListRow {
|
||||||
|
pub date: Date,
|
||||||
|
pub issue_number: i64,
|
||||||
|
pub generated_at: Timestamp,
|
||||||
|
pub article_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
/// Open (creating if needed) the database at `path` with WAL + foreign keys on,
|
/// Open (creating if needed) the database at `path` with WAL + foreign keys on,
|
||||||
/// creating parent directories first. Does **not** run migrations.
|
/// creating parent directories first. Does **not** run migrations.
|
||||||
@@ -442,11 +463,12 @@ impl Db {
|
|||||||
xtc_path: Option<&str>,
|
xtc_path: Option<&str>,
|
||||||
front_page_html: Option<&str>,
|
front_page_html: Option<&str>,
|
||||||
report_json: Option<&str>,
|
report_json: Option<&str>,
|
||||||
|
issue_json: Option<&str>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO issues (date, issue_number, generated_at, epub_path, x4_path, xtc_path,
|
"INSERT INTO issues (date, issue_number, generated_at, epub_path, x4_path, xtc_path,
|
||||||
front_page_html, report_json)
|
front_page_html, report_json, issue_json)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(date) DO UPDATE SET
|
ON CONFLICT(date) DO UPDATE SET
|
||||||
issue_number = excluded.issue_number,
|
issue_number = excluded.issue_number,
|
||||||
generated_at = excluded.generated_at,
|
generated_at = excluded.generated_at,
|
||||||
@@ -454,7 +476,8 @@ impl Db {
|
|||||||
x4_path = COALESCE(excluded.x4_path, issues.x4_path),
|
x4_path = COALESCE(excluded.x4_path, issues.x4_path),
|
||||||
xtc_path = COALESCE(excluded.xtc_path, issues.xtc_path),
|
xtc_path = COALESCE(excluded.xtc_path, issues.xtc_path),
|
||||||
front_page_html = COALESCE(excluded.front_page_html, issues.front_page_html),
|
front_page_html = COALESCE(excluded.front_page_html, issues.front_page_html),
|
||||||
report_json = COALESCE(excluded.report_json, issues.report_json)",
|
report_json = COALESCE(excluded.report_json, issues.report_json),
|
||||||
|
issue_json = COALESCE(excluded.issue_json, issues.issue_json)",
|
||||||
)
|
)
|
||||||
.bind(date.to_string())
|
.bind(date.to_string())
|
||||||
.bind(issue_number)
|
.bind(issue_number)
|
||||||
@@ -464,11 +487,49 @@ impl Db {
|
|||||||
.bind(xtc_path)
|
.bind(xtc_path)
|
||||||
.bind(front_page_html)
|
.bind(front_page_html)
|
||||||
.bind(report_json)
|
.bind(report_json)
|
||||||
|
.bind(issue_json)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One stored issue, including its serialized full-issue snapshot.
|
||||||
|
pub async fn issue_by_date(&self, date: Date) -> Result<Option<IssueRow>> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT date, issue_number, generated_at, epub_path, x4_path, xtc_path,
|
||||||
|
front_page_html, report_json, issue_json
|
||||||
|
FROM issues WHERE date = ?",
|
||||||
|
)
|
||||||
|
.bind(date.to_string())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.as_ref().map(issue_from_row).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Issue archive rows, newest first. A non-positive limit means all rows.
|
||||||
|
pub async fn issue_dates(&self, limit: Option<i64>) -> Result<Vec<IssueListRow>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT i.date, i.issue_number, i.generated_at, COUNT(ia.article_id) AS article_count
|
||||||
|
FROM issues i LEFT JOIN issue_articles ia ON ia.issue_date = i.date
|
||||||
|
GROUP BY i.date, i.issue_number, i.generated_at
|
||||||
|
ORDER BY i.date DESC
|
||||||
|
LIMIT CASE WHEN ? > 0 THEN ? ELSE -1 END",
|
||||||
|
)
|
||||||
|
.bind(limit.unwrap_or(-1))
|
||||||
|
.bind(limit.unwrap_or(-1))
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
rows.iter().map(issue_list_from_row).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn latest_issue_date(&self) -> Result<Option<Date>> {
|
||||||
|
let raw: Option<String> = sqlx::query_scalar("SELECT MAX(date) FROM issues")
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
raw.map(|value| parse_date("issues.date", &value))
|
||||||
|
.transpose()
|
||||||
|
}
|
||||||
|
|
||||||
/// Replace the lineup for a date (regeneration is idempotent, notes §12).
|
/// Replace the lineup for a date (regeneration is idempotent, notes §12).
|
||||||
pub async fn replace_issue_articles(&self, date: Date, picks: &[Pick]) -> Result<()> {
|
pub async fn replace_issue_articles(&self, date: Date, picks: &[Pick]) -> Result<()> {
|
||||||
let mut tx = self.pool.begin().await?;
|
let mut tx = self.pool.begin().await?;
|
||||||
@@ -541,8 +602,8 @@ impl Db {
|
|||||||
pub async fn append_rating_event(&self, event: &RatingEvent) -> Result<i64> {
|
pub async fn append_rating_event(&self, event: &RatingEvent) -> Result<i64> {
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
"INSERT INTO rating_events
|
"INSERT INTO rating_events
|
||||||
(article_id, issue_date, kind, source, label, value, note, event_at)
|
(article_id, issue_date, kind, source, label, value, note, event_at, user_id)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
RETURNING id",
|
RETURNING id",
|
||||||
)
|
)
|
||||||
.bind(event.article_id)
|
.bind(event.article_id)
|
||||||
@@ -553,6 +614,7 @@ impl Db {
|
|||||||
.bind(event.value)
|
.bind(event.value)
|
||||||
.bind(event.note.as_deref())
|
.bind(event.note.as_deref())
|
||||||
.bind(fmt_ts(event.event_at))
|
.bind(fmt_ts(event.event_at))
|
||||||
|
.bind(event.user_id)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(row.get("id"))
|
Ok(row.get("id"))
|
||||||
@@ -611,7 +673,7 @@ impl Db {
|
|||||||
FROM rating_events re
|
FROM rating_events re
|
||||||
WHERE re.kind = 'explicit' AND re.event_at >= ?
|
WHERE re.kind = 'explicit' AND re.event_at >= ?
|
||||||
)
|
)
|
||||||
SELECT r.article_id, r.issue_date, r.label, r.value, r.note, r.event_at,
|
SELECT r.article_id, r.user_id, r.issue_date, r.label, r.value, r.note, r.event_at,
|
||||||
COALESCE(a.title, '') AS title,
|
COALESCE(a.title, '') AS title,
|
||||||
COALESCE(e.feed_title, '') AS feed_title,
|
COALESCE(e.feed_title, '') AS feed_title,
|
||||||
(SELECT ia.summary FROM issue_articles ia
|
(SELECT ia.summary FROM issue_articles ia
|
||||||
@@ -655,7 +717,8 @@ impl Db {
|
|||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE runs SET finished_at = ?, entries_fetched = ?, candidates = ?, selected = ?,
|
"UPDATE runs SET finished_at = ?, entries_fetched = ?, candidates = ?, selected = ?,
|
||||||
input_tokens = ?, cached_tokens = ?, output_tokens = ?, cost_usd = ?,
|
input_tokens = ?, cached_tokens = ?, output_tokens = ?, cost_usd = ?,
|
||||||
status = ?, error = ?, provider_costs_json = ?, config_json = ?
|
status = ?, error = ?, provider_costs_json = ?, config_json = ?,
|
||||||
|
report_json = ?
|
||||||
WHERE id = ?",
|
WHERE id = ?",
|
||||||
)
|
)
|
||||||
.bind(report.finished_at.map(fmt_ts))
|
.bind(report.finished_at.map(fmt_ts))
|
||||||
@@ -680,6 +743,7 @@ impl Db {
|
|||||||
source,
|
source,
|
||||||
})?,
|
})?,
|
||||||
)
|
)
|
||||||
|
.bind(report.to_json())
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -828,6 +892,7 @@ fn rated_article_from_row(row: &sqlx::sqlite::SqliteRow) -> Result<RatedArticle>
|
|||||||
});
|
});
|
||||||
Ok(RatedArticle {
|
Ok(RatedArticle {
|
||||||
article_id: row.get("article_id"),
|
article_id: row.get("article_id"),
|
||||||
|
user_id: row.get("user_id"),
|
||||||
issue_date,
|
issue_date,
|
||||||
title: row.get("title"),
|
title: row.get("title"),
|
||||||
feed_title: row.get("feed_title"),
|
feed_title: row.get("feed_title"),
|
||||||
@@ -840,6 +905,29 @@ fn rated_article_from_row(row: &sqlx::sqlite::SqliteRow) -> Result<RatedArticle>
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn issue_from_row(row: &sqlx::sqlite::SqliteRow) -> Result<IssueRow> {
|
||||||
|
Ok(IssueRow {
|
||||||
|
date: parse_date("issues.date", &row.get::<String, _>("date"))?,
|
||||||
|
issue_number: row.get("issue_number"),
|
||||||
|
generated_at: parse_ts("issues.generated_at", &row.get::<String, _>("generated_at"))?,
|
||||||
|
epub_path: row.get("epub_path"),
|
||||||
|
x4_path: row.get("x4_path"),
|
||||||
|
xtc_path: row.get("xtc_path"),
|
||||||
|
front_page_html: row.get("front_page_html"),
|
||||||
|
report_json: row.get("report_json"),
|
||||||
|
issue_json: row.get("issue_json"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn issue_list_from_row(row: &sqlx::sqlite::SqliteRow) -> Result<IssueListRow> {
|
||||||
|
Ok(IssueListRow {
|
||||||
|
date: parse_date("issues.date", &row.get::<String, _>("date"))?,
|
||||||
|
issue_number: row.get("issue_number"),
|
||||||
|
generated_at: parse_ts("issues.generated_at", &row.get::<String, _>("generated_at"))?,
|
||||||
|
article_count: row.get("article_count"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn social_from_row(row: &sqlx::sqlite::SqliteRow) -> Result<SocialRef> {
|
fn social_from_row(row: &sqlx::sqlite::SqliteRow) -> Result<SocialRef> {
|
||||||
let raw: String = row.get("source");
|
let raw: String = row.get("source");
|
||||||
let source = SocialSource::parse(&raw).ok_or(DbError::Decode {
|
let source = SocialSource::parse(&raw).ok_or(DbError::Decode {
|
||||||
@@ -908,6 +996,30 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(row.get::<i64, _>(0), 1);
|
assert_eq!(row.get::<i64, _>(0), 1);
|
||||||
|
for table in [
|
||||||
|
"users",
|
||||||
|
"sessions",
|
||||||
|
"config_changes",
|
||||||
|
"profile_versions",
|
||||||
|
"jobs",
|
||||||
|
] {
|
||||||
|
let exists: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?",
|
||||||
|
)
|
||||||
|
.bind(table)
|
||||||
|
.fetch_one(db.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(exists, 1, "missing {table}");
|
||||||
|
}
|
||||||
|
let columns: Vec<String> = sqlx::query("PRAGMA table_info(rating_events)")
|
||||||
|
.fetch_all(db.pool())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|row| row.get("name"))
|
||||||
|
.collect();
|
||||||
|
assert!(columns.contains(&"user_id".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1024,6 +1136,13 @@ mod tests {
|
|||||||
report.counts.selected = 20;
|
report.counts.selected = 20;
|
||||||
report.finish(ts("2026-08-15T05:36:00Z"));
|
report.finish(ts("2026-08-15T05:36:00Z"));
|
||||||
db.finish_run(run_id, &report).await.unwrap();
|
db.finish_run(run_id, &report).await.unwrap();
|
||||||
|
let stored_report: Option<String> =
|
||||||
|
sqlx::query_scalar("SELECT report_json FROM runs WHERE id = ?")
|
||||||
|
.bind(run_id)
|
||||||
|
.fetch_one(db.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(stored_report.as_deref(), Some(report.to_json().as_str()));
|
||||||
|
|
||||||
db.upsert_issue(
|
db.upsert_issue(
|
||||||
date,
|
date,
|
||||||
@@ -1034,6 +1153,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
Some("{}"),
|
Some("{}"),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1158,6 +1278,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1185,6 +1306,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let event = |label: &str, value: f64, at: &str| RatingEvent {
|
let event = |label: &str, value: f64, at: &str| RatingEvent {
|
||||||
id: 0,
|
id: 0,
|
||||||
|
user_id: None,
|
||||||
article_id,
|
article_id,
|
||||||
issue_date: Some("2026-08-15".parse().unwrap()),
|
issue_date: Some("2026-08-15".parse().unwrap()),
|
||||||
kind: "explicit".into(),
|
kind: "explicit".into(),
|
||||||
@@ -1268,9 +1390,13 @@ mod tests {
|
|||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
sqlx::raw_sql(include_str!("../migrations/0004_web.sql"))
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT article_id, issue_date, kind, source, label, value, event_at
|
"SELECT article_id, issue_date, kind, source, label, value, event_at, user_id
|
||||||
FROM rating_events ORDER BY article_id",
|
FROM rating_events ORDER BY article_id",
|
||||||
)
|
)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
@@ -1285,6 +1411,7 @@ mod tests {
|
|||||||
assert_eq!(row.get::<String, _>("kind"), "explicit");
|
assert_eq!(row.get::<String, _>("kind"), "explicit");
|
||||||
assert_eq!(row.get::<String, _>("source"), "migration");
|
assert_eq!(row.get::<String, _>("source"), "migration");
|
||||||
assert_eq!(row.get::<String, _>("issue_date"), "2026-08-15");
|
assert_eq!(row.get::<String, _>("issue_date"), "2026-08-15");
|
||||||
|
assert_eq!(row.get::<Option<i64>, _>("user_id"), None);
|
||||||
}
|
}
|
||||||
assert_eq!(rows[0].get::<String, _>("event_at"), "2026-08-15T12:00:00Z");
|
assert_eq!(rows[0].get::<String, _>("event_at"), "2026-08-15T12:00:00Z");
|
||||||
|
|
||||||
@@ -1302,6 +1429,11 @@ mod tests {
|
|||||||
"interest_embeddings",
|
"interest_embeddings",
|
||||||
"article_assessments",
|
"article_assessments",
|
||||||
"candidate_runs",
|
"candidate_runs",
|
||||||
|
"users",
|
||||||
|
"sessions",
|
||||||
|
"config_changes",
|
||||||
|
"profile_versions",
|
||||||
|
"jobs",
|
||||||
] {
|
] {
|
||||||
assert!(tables.iter().any(|table| table == expected), "{expected}");
|
assert!(tables.iter().any(|table| table == expected), "{expected}");
|
||||||
}
|
}
|
||||||
|
|||||||
+606
@@ -0,0 +1,606 @@
|
|||||||
|
//! Operator jobs (dashboard plan §14): the fixed catalogue the Jobs page can
|
||||||
|
//! start, the `jobs` table lifecycle shared by the page and `daily-epub job
|
||||||
|
//! run`, and the systemd-backed [`JobRunner`].
|
||||||
|
//!
|
||||||
|
//! A job is a `daily-epub-job@<name>.service` instance. The web server never
|
||||||
|
//! runs the pipeline in-process: it inserts a `requested` row, asks systemd to
|
||||||
|
//! start the unit (polkit allows `start` on exactly that unit pattern), and the
|
||||||
|
//! unit's `job run <name>` claims the row, does the work and finishes it.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use jiff::Timestamp;
|
||||||
|
use jiff::civil::Date;
|
||||||
|
use sqlx::Row as _;
|
||||||
|
|
||||||
|
use crate::db::{Db, fmt_ts};
|
||||||
|
use crate::web::{JobRunner, UnitStatus};
|
||||||
|
|
||||||
|
/// Prefix of every job unit; the polkit rule matches the same pattern.
|
||||||
|
pub const UNIT_PREFIX: &str = "daily-epub-job@";
|
||||||
|
|
||||||
|
/// Marker written by the page's 30-second rule (§14.4).
|
||||||
|
pub const EXITED_BEFORE_START: &str = "unit exited before the job started; see the log";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Catalogue (§14.1)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The jobs an admin can start from the dashboard (§14.1).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Job {
|
||||||
|
/// `generate` (today) or `generate-YYYY-MM-DD`.
|
||||||
|
Generate { date: Option<Date> },
|
||||||
|
/// `dry-run` → `generate --dry-run`.
|
||||||
|
DryRun,
|
||||||
|
/// `profile-rebuild` → `profile rebuild`.
|
||||||
|
ProfileRebuild,
|
||||||
|
/// `features-backfill` → `features backfill --days 30 --yes`.
|
||||||
|
FeaturesBackfill,
|
||||||
|
/// `backfill-social` → `backfill-social --days 7`.
|
||||||
|
BackfillSocial,
|
||||||
|
/// `features-prune` → `features prune`.
|
||||||
|
FeaturesPrune,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Job {
|
||||||
|
/// The catalogue in the order the Jobs page lists it.
|
||||||
|
pub const CATALOGUE: [Job; 6] = [
|
||||||
|
Job::Generate { date: None },
|
||||||
|
Job::DryRun,
|
||||||
|
Job::ProfileRebuild,
|
||||||
|
Job::FeaturesBackfill,
|
||||||
|
Job::BackfillSocial,
|
||||||
|
Job::FeaturesPrune,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// `^[a-z0-9-]+$`: the only characters a job (and so a unit instance) name
|
||||||
|
/// may contain.
|
||||||
|
pub fn valid_name(name: &str) -> bool {
|
||||||
|
!name.is_empty()
|
||||||
|
&& name
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a catalogue name or the dated `generate-YYYY-MM-DD` form; anything
|
||||||
|
/// else (including `../x`, uppercase, unknown names) is `None`.
|
||||||
|
pub fn parse(name: &str) -> Option<Job> {
|
||||||
|
if !Self::valid_name(name) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match name {
|
||||||
|
"generate" => Some(Job::Generate { date: None }),
|
||||||
|
"dry-run" => Some(Job::DryRun),
|
||||||
|
"profile-rebuild" => Some(Job::ProfileRebuild),
|
||||||
|
"features-backfill" => Some(Job::FeaturesBackfill),
|
||||||
|
"backfill-social" => Some(Job::BackfillSocial),
|
||||||
|
"features-prune" => Some(Job::FeaturesPrune),
|
||||||
|
_ => {
|
||||||
|
let date = name.strip_prefix("generate-")?;
|
||||||
|
// Exactly `YYYY-MM-DD`; the round trip rejects `2026-9-3`.
|
||||||
|
let parsed: Date = date.parse().ok()?;
|
||||||
|
(parsed.to_string() == date).then_some(Job::Generate { date: Some(parsed) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn name(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Job::Generate { date: None } => "generate".into(),
|
||||||
|
Job::Generate { date: Some(date) } => format!("generate-{date}"),
|
||||||
|
Job::DryRun => "dry-run".into(),
|
||||||
|
Job::ProfileRebuild => "profile-rebuild".into(),
|
||||||
|
Job::FeaturesBackfill => "features-backfill".into(),
|
||||||
|
Job::BackfillSocial => "backfill-social".into(),
|
||||||
|
Job::FeaturesPrune => "features-prune".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `daily-epub-job@<name>.service`.
|
||||||
|
pub fn unit(&self) -> String {
|
||||||
|
unit_for(&self.name())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn description(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Job::Generate { date: None } => {
|
||||||
|
"Build and publish today's issue, exactly as the morning timer does."
|
||||||
|
}
|
||||||
|
Job::Generate { date: Some(_) } => {
|
||||||
|
"Build and publish the issue of a given date, republishing it if it exists."
|
||||||
|
}
|
||||||
|
Job::DryRun => "Run the whole pipeline but publish nothing and record no issue.",
|
||||||
|
Job::ProfileRebuild => {
|
||||||
|
"Regenerate the learned taste adjustments from ratings with the editor model."
|
||||||
|
}
|
||||||
|
Job::FeaturesBackfill => {
|
||||||
|
"Embed rated and recently published articles (30 days) and interests into the cache."
|
||||||
|
}
|
||||||
|
Job::BackfillSocial => "Re-poll social scores for the last 7 days of entries.",
|
||||||
|
Job::FeaturesPrune => {
|
||||||
|
"Drop stale embeddings, old candidate telemetry and old assessments per the retention config."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The run-lock name `main::lock_holder` uses for the same command, when
|
||||||
|
/// the job takes the lock at all.
|
||||||
|
pub fn takes_lock(&self) -> Option<&'static str> {
|
||||||
|
match self {
|
||||||
|
Job::Generate { .. } | Job::DryRun => Some("generate"),
|
||||||
|
Job::ProfileRebuild => Some("profile rebuild"),
|
||||||
|
Job::FeaturesBackfill => Some("features backfill"),
|
||||||
|
Job::BackfillSocial => Some("backfill-social"),
|
||||||
|
Job::FeaturesPrune => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Needs a confirmation dialog: `generate` republishes an issue.
|
||||||
|
pub fn dangerous(&self) -> bool {
|
||||||
|
matches!(self, Job::Generate { .. })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `daily-epub-job@<name>.service` for a validated job name.
|
||||||
|
pub fn unit_for(name: &str) -> String {
|
||||||
|
format!("{UNIT_PREFIX}{name}.service")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// `jobs` table lifecycle (§14.2, §14.4)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// One `jobs` row.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct JobRow {
|
||||||
|
pub id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub unit: String,
|
||||||
|
pub requested_by: Option<i64>,
|
||||||
|
/// The requester's username, when the user still exists.
|
||||||
|
pub requested_by_name: Option<String>,
|
||||||
|
pub requested_at: String,
|
||||||
|
pub started_at: Option<String>,
|
||||||
|
pub finished_at: Option<String>,
|
||||||
|
pub status: String,
|
||||||
|
pub message: Option<String>,
|
||||||
|
pub run_id: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JobRow {
|
||||||
|
pub fn is_active(&self) -> bool {
|
||||||
|
matches!(self.status.as_str(), "requested" | "running")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROW_SELECT: &str =
|
||||||
|
"SELECT j.id, j.name, j.unit, j.requested_by, u.username AS requested_by_name,
|
||||||
|
j.requested_at, j.started_at, j.finished_at, j.status, j.message, j.run_id
|
||||||
|
FROM jobs j LEFT JOIN users u ON u.id = j.requested_by";
|
||||||
|
|
||||||
|
fn row_from(row: &sqlx::sqlite::SqliteRow) -> JobRow {
|
||||||
|
JobRow {
|
||||||
|
id: row.get("id"),
|
||||||
|
name: row.get("name"),
|
||||||
|
unit: row.get("unit"),
|
||||||
|
requested_by: row.get("requested_by"),
|
||||||
|
requested_by_name: row.get("requested_by_name"),
|
||||||
|
requested_at: row.get("requested_at"),
|
||||||
|
started_at: row.get("started_at"),
|
||||||
|
finished_at: row.get("finished_at"),
|
||||||
|
status: row.get("status"),
|
||||||
|
message: row.get("message"),
|
||||||
|
run_id: row.get("run_id"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One job by id.
|
||||||
|
pub async fn get(db: &Db, id: i64) -> Result<Option<JobRow>, sqlx::Error> {
|
||||||
|
let row = sqlx::query(sqlx::AssertSqlSafe(format!("{ROW_SELECT} WHERE j.id = ?")))
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(db.pool())
|
||||||
|
.await?;
|
||||||
|
Ok(row.as_ref().map(row_from))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The newest `limit` jobs.
|
||||||
|
pub async fn list(db: &Db, limit: i64) -> Result<Vec<JobRow>, sqlx::Error> {
|
||||||
|
let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
|
||||||
|
"{ROW_SELECT} ORDER BY j.id DESC LIMIT ?"
|
||||||
|
)))
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(db.pool())
|
||||||
|
.await?;
|
||||||
|
Ok(rows.iter().map(row_from).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The id of a `requested`/`running` job for this unit, if any (the page
|
||||||
|
/// refuses a duplicate start, §14.4).
|
||||||
|
pub async fn active_for_unit(db: &Db, unit: &str) -> Result<Option<i64>, sqlx::Error> {
|
||||||
|
sqlx::query_scalar(
|
||||||
|
"SELECT id FROM jobs WHERE unit = ? AND status IN ('requested', 'running')
|
||||||
|
ORDER BY id DESC LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(unit)
|
||||||
|
.fetch_optional(db.pool())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a `requested` row (the dashboard, or `job run` when nobody asked).
|
||||||
|
pub async fn insert_requested(
|
||||||
|
db: &Db,
|
||||||
|
job: &Job,
|
||||||
|
requested_by: Option<i64>,
|
||||||
|
now: Timestamp,
|
||||||
|
) -> Result<i64, sqlx::Error> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"INSERT INTO jobs (name, unit, requested_by, requested_at, status)
|
||||||
|
VALUES (?, ?, ?, ?, 'requested') RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(job.name())
|
||||||
|
.bind(job.unit())
|
||||||
|
.bind(requested_by)
|
||||||
|
.bind(fmt_ts(now))
|
||||||
|
.fetch_one(db.pool())
|
||||||
|
.await?;
|
||||||
|
Ok(row.get::<i64, _>("id"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `job run`'s first step (§14.2): the newest `requested` row with this name,
|
||||||
|
/// or a fresh one with `requested_by NULL`, flipped to `running`.
|
||||||
|
pub async fn claim(db: &Db, job: &Job, now: Timestamp) -> Result<i64, sqlx::Error> {
|
||||||
|
let existing: Option<i64> = sqlx::query_scalar(
|
||||||
|
"SELECT id FROM jobs WHERE name = ? AND status = 'requested' ORDER BY id DESC LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(job.name())
|
||||||
|
.fetch_optional(db.pool())
|
||||||
|
.await?;
|
||||||
|
let id = match existing {
|
||||||
|
Some(id) => id,
|
||||||
|
None => insert_requested(db, job, None, now).await?,
|
||||||
|
};
|
||||||
|
sqlx::query("UPDATE jobs SET status = 'running', started_at = ? WHERE id = ?")
|
||||||
|
.bind(fmt_ts(now))
|
||||||
|
.bind(id)
|
||||||
|
.execute(db.pool())
|
||||||
|
.await?;
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Terminal state of a job.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Outcome {
|
||||||
|
Ok,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Outcome {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Outcome::Ok => "ok",
|
||||||
|
Outcome::Failed => "failed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finish a job: status, `finished_at`, the one-line message and, for a
|
||||||
|
/// generate job, the run it produced.
|
||||||
|
pub async fn finish(
|
||||||
|
db: &Db,
|
||||||
|
id: i64,
|
||||||
|
outcome: Outcome,
|
||||||
|
message: &str,
|
||||||
|
run_id: Option<i64>,
|
||||||
|
now: Timestamp,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
let message = message.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE jobs SET status = ?, finished_at = ?, message = ?, run_id = ? WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(outcome.as_str())
|
||||||
|
.bind(fmt_ts(now))
|
||||||
|
.bind(message)
|
||||||
|
.bind(run_id)
|
||||||
|
.bind(id)
|
||||||
|
.execute(db.pool())
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SystemdRunner (§14.4)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Talks to systemd with `systemctl`/`journalctl` (10 s timeout each, stderr
|
||||||
|
/// in the error). Production runner when `server.jobs_enabled` is true.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SystemdRunner {
|
||||||
|
timeout: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SystemdRunner {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
timeout: Duration::from_secs(10),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHOW_PROPERTIES: &str =
|
||||||
|
"ActiveState,SubState,Result,ExecMainStatus,ExecMainStartTimestamp,ExecMainExitTimestamp";
|
||||||
|
|
||||||
|
impl SystemdRunner {
|
||||||
|
async fn run(&self, program: &str, args: &[&str]) -> Result<String, String> {
|
||||||
|
let command = format!("{program} {}", args.join(" "));
|
||||||
|
let output = tokio::time::timeout(
|
||||||
|
self.timeout,
|
||||||
|
tokio::process::Command::new(program)
|
||||||
|
.args(args)
|
||||||
|
.kill_on_drop(true)
|
||||||
|
.output(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| format!("{command}: timed out after {:?}", self.timeout))?
|
||||||
|
.map_err(|error| format!("{command}: {error}"))?;
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
|
||||||
|
if output.status.success() {
|
||||||
|
Ok(stdout)
|
||||||
|
} else {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
Err(format!(
|
||||||
|
"{command}: exit {}: {}",
|
||||||
|
output
|
||||||
|
.status
|
||||||
|
.code()
|
||||||
|
.map(|code| code.to_string())
|
||||||
|
.unwrap_or_else(|| "signal".into()),
|
||||||
|
stderr.trim()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `systemctl show -p …` output (`Key=Value` lines).
|
||||||
|
pub fn parse_unit_status(text: &str) -> UnitStatus {
|
||||||
|
let mut status = UnitStatus::default();
|
||||||
|
for line in text.lines() {
|
||||||
|
let Some((key, value)) = line.split_once('=') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let value = value.trim();
|
||||||
|
match key.trim() {
|
||||||
|
"ActiveState" => status.active_state = value.to_string(),
|
||||||
|
"SubState" => status.sub_state = value.to_string(),
|
||||||
|
"Result" => status.result = value.to_string(),
|
||||||
|
"ExecMainStatus" => status.exit_status = value.parse().ok(),
|
||||||
|
"ExecMainStartTimestamp" => {
|
||||||
|
status.started = (!value.is_empty()).then(|| value.to_string())
|
||||||
|
}
|
||||||
|
"ExecMainExitTimestamp" => {
|
||||||
|
status.exited = (!value.is_empty()).then(|| value.to_string())
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
status
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl JobRunner for SystemdRunner {
|
||||||
|
async fn start(&self, unit: &str) -> Result<(), String> {
|
||||||
|
self.run("systemctl", &["start", "--no-block", unit])
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn status(&self, unit: &str) -> Result<UnitStatus, String> {
|
||||||
|
self.run("systemctl", &["show", "-p", SHOW_PROPERTIES, unit])
|
||||||
|
.await
|
||||||
|
.map(|text| parse_unit_status(&text))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn log(&self, unit: &str, lines: usize) -> Result<String, String> {
|
||||||
|
let lines = lines.to_string();
|
||||||
|
self.run(
|
||||||
|
"journalctl",
|
||||||
|
&["-u", unit, "-n", &lines, "--no-pager", "-o", "short-iso"],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const POLKIT_RULES: &str = include_str!("../systemd/50-daily-epub.rules");
|
||||||
|
const JOB_UNIT: &str = include_str!("../systemd/daily-epub-job@.service");
|
||||||
|
const SERVER_UNIT: &str = include_str!("../systemd/daily-epub.service");
|
||||||
|
const POLKIT_UNIT_REGEX: &str = r"/^daily-epub-job@[a-z0-9-]+\.service$/";
|
||||||
|
|
||||||
|
/// The polkit rule's regex, by hand: `^daily-epub-job@[a-z0-9-]+\.service$`.
|
||||||
|
fn polkit_regex_matches(unit: &str) -> bool {
|
||||||
|
unit.strip_prefix(UNIT_PREFIX)
|
||||||
|
.and_then(|rest| rest.strip_suffix(".service"))
|
||||||
|
.is_some_and(Job::valid_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_accepts_the_catalogue_and_the_dated_form() {
|
||||||
|
for job in Job::CATALOGUE {
|
||||||
|
assert_eq!(Job::parse(&job.name()), Some(job), "{}", job.name());
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
Job::parse("generate-2026-09-03"),
|
||||||
|
Some(Job::Generate {
|
||||||
|
date: Some("2026-09-03".parse().unwrap())
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Job::parse("generate-2026-09-03").unwrap().unit(),
|
||||||
|
"daily-epub-job@generate-2026-09-03.service"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Job::parse("generate").unwrap().takes_lock(),
|
||||||
|
Some("generate")
|
||||||
|
);
|
||||||
|
assert_eq!(Job::parse("features-prune").unwrap().takes_lock(), None);
|
||||||
|
assert!(Job::parse("generate-2026-09-03").unwrap().dangerous());
|
||||||
|
assert!(!Job::parse("dry-run").unwrap().dangerous());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_rejects_traversal_uppercase_and_unknown_names() {
|
||||||
|
for name in [
|
||||||
|
"../x",
|
||||||
|
"Generate",
|
||||||
|
"GENERATE",
|
||||||
|
"generate ",
|
||||||
|
"",
|
||||||
|
"backup",
|
||||||
|
"generate-2026-9-3",
|
||||||
|
"generate-2026-13-01",
|
||||||
|
"generate-",
|
||||||
|
"generate-2026-09-03.service",
|
||||||
|
"dry_run",
|
||||||
|
"features-prune;rm",
|
||||||
|
] {
|
||||||
|
assert_eq!(Job::parse(name), None, "{name:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn polkit_rule_is_present_and_matches_the_unit_names() {
|
||||||
|
assert!(
|
||||||
|
POLKIT_RULES.contains(POLKIT_UNIT_REGEX),
|
||||||
|
"the rule must carry the unit regex verbatim"
|
||||||
|
);
|
||||||
|
assert!(POLKIT_RULES.contains(r#"action.lookup("verb") == "start""#));
|
||||||
|
assert!(POLKIT_RULES.contains(r#"subject.user == "daily-epub""#));
|
||||||
|
assert!(POLKIT_RULES.contains("org.freedesktop.systemd1.manage-units"));
|
||||||
|
for job in Job::CATALOGUE {
|
||||||
|
assert!(polkit_regex_matches(&job.unit()), "{}", job.unit());
|
||||||
|
}
|
||||||
|
assert!(polkit_regex_matches(
|
||||||
|
&Job::parse("generate-2026-09-03").unwrap().unit()
|
||||||
|
));
|
||||||
|
for unit in [
|
||||||
|
"daily-epub-job@../x.service",
|
||||||
|
"daily-epub-job@.service",
|
||||||
|
"daily-epub-job@Generate.service",
|
||||||
|
"daily-epub.service",
|
||||||
|
"daily-epub-generate.service",
|
||||||
|
"daily-epub-job@generate.service.d",
|
||||||
|
] {
|
||||||
|
assert!(!polkit_regex_matches(unit), "{unit}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unit_files_carry_the_job_template_and_journal_group() {
|
||||||
|
assert!(JOB_UNIT.contains("Description=The Daily EPUB job %i"));
|
||||||
|
assert!(JOB_UNIT.contains(
|
||||||
|
"ExecStart=/usr/local/bin/daily-epub --config /etc/daily-epub/config.toml job run %i"
|
||||||
|
));
|
||||||
|
assert!(JOB_UNIT.contains("TimeoutStartSec=45min"));
|
||||||
|
assert!(JOB_UNIT.contains("ProtectSystem=strict"));
|
||||||
|
assert!(SERVER_UNIT.contains("SupplementaryGroups=systemd-journal"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unit_status_parses_systemctl_show_output() {
|
||||||
|
let status = parse_unit_status(
|
||||||
|
"ActiveState=inactive\nSubState=dead\nResult=exit-code\nExecMainStatus=1\n\
|
||||||
|
ExecMainStartTimestamp=Thu 2026-09-03 05:30:01 EDT\nExecMainExitTimestamp=\n",
|
||||||
|
);
|
||||||
|
assert_eq!(status.active_state, "inactive");
|
||||||
|
assert_eq!(status.sub_state, "dead");
|
||||||
|
assert_eq!(status.result, "exit-code");
|
||||||
|
assert_eq!(status.exit_status, Some(1));
|
||||||
|
assert_eq!(
|
||||||
|
status.started.as_deref(),
|
||||||
|
Some("Thu 2026-09-03 05:30:01 EDT")
|
||||||
|
);
|
||||||
|
assert_eq!(status.exited, None);
|
||||||
|
assert_eq!(parse_unit_status("garbage").active_state, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn claim_reuses_the_requested_row_and_finish_records_the_run() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let db = Db::open_and_migrate(&dir.path().join("jobs.db"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let now: Timestamp = "2026-09-03T10:00:00Z".parse().unwrap();
|
||||||
|
let job = Job::Generate {
|
||||||
|
date: Some("2026-09-03".parse().unwrap()),
|
||||||
|
};
|
||||||
|
let requested = insert_requested(&db, &job, None, now).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
active_for_unit(&db, &job.unit()).await.unwrap(),
|
||||||
|
Some(requested)
|
||||||
|
);
|
||||||
|
|
||||||
|
let claimed = claim(&db, &job, now).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
claimed, requested,
|
||||||
|
"the requested row is claimed, not duplicated"
|
||||||
|
);
|
||||||
|
let row = get(&db, claimed).await.unwrap().unwrap();
|
||||||
|
assert_eq!(row.status, "running");
|
||||||
|
assert_eq!(row.started_at.as_deref(), Some("2026-09-03T10:00:00Z"));
|
||||||
|
assert!(row.is_active());
|
||||||
|
|
||||||
|
let run_id = db.start_run(job_date(&job), now).await.unwrap();
|
||||||
|
finish(
|
||||||
|
&db,
|
||||||
|
claimed,
|
||||||
|
Outcome::Ok,
|
||||||
|
"curation: done",
|
||||||
|
Some(run_id),
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let row = get(&db, claimed).await.unwrap().unwrap();
|
||||||
|
assert_eq!(row.status, "ok");
|
||||||
|
assert_eq!(row.run_id, Some(run_id));
|
||||||
|
assert_eq!(row.message.as_deref(), Some("curation: done"));
|
||||||
|
assert_eq!(row.finished_at.as_deref(), Some("2026-09-03T10:00:00Z"));
|
||||||
|
assert_eq!(active_for_unit(&db, &job.unit()).await.unwrap(), None);
|
||||||
|
|
||||||
|
// Nothing requested: `claim` inserts a row with no requester.
|
||||||
|
let fresh = claim(&db, &Job::FeaturesPrune, now).await.unwrap();
|
||||||
|
let row = get(&db, fresh).await.unwrap().unwrap();
|
||||||
|
assert_eq!(row.status, "running");
|
||||||
|
assert_eq!(row.requested_by, None);
|
||||||
|
assert_eq!(row.name, "features-prune");
|
||||||
|
assert_eq!(list(&db, 10).await.unwrap().len(), 2);
|
||||||
|
assert_eq!(list(&db, 10).await.unwrap()[0].id, fresh);
|
||||||
|
|
||||||
|
finish(
|
||||||
|
&db,
|
||||||
|
fresh,
|
||||||
|
Outcome::Failed,
|
||||||
|
"provider failed\n after retry",
|
||||||
|
None,
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
get(&db, fresh).await.unwrap().unwrap().message.as_deref(),
|
||||||
|
Some("provider failed after retry"),
|
||||||
|
"job messages are always one line"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn job_date(job: &Job) -> Date {
|
||||||
|
match job {
|
||||||
|
Job::Generate { date: Some(date) } => *date,
|
||||||
|
_ => panic!("expected a dated generate"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ pub mod extract;
|
|||||||
pub mod html;
|
pub mod html;
|
||||||
pub mod http;
|
pub mod http;
|
||||||
pub mod images;
|
pub mod images;
|
||||||
|
pub mod jobs;
|
||||||
pub mod lock;
|
pub mod lock;
|
||||||
pub mod miniflux;
|
pub mod miniflux;
|
||||||
pub mod pipeline;
|
pub mod pipeline;
|
||||||
@@ -31,6 +32,7 @@ pub mod report;
|
|||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod social;
|
pub mod social;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
pub mod web;
|
||||||
pub mod world;
|
pub mod world;
|
||||||
|
|
||||||
/// `CARGO_PKG_VERSION`, printed in the colophon and the OPDS generator tag.
|
/// `CARGO_PKG_VERSION`, printed in the colophon and the OPDS generator tag.
|
||||||
|
|||||||
+367
-23
@@ -17,7 +17,7 @@ use daily_epub::db::Db;
|
|||||||
use daily_epub::pipeline::{self, GenerateOptions, GenerateOutcome};
|
use daily_epub::pipeline::{self, GenerateOptions, GenerateOutcome};
|
||||||
use daily_epub::report::{RunReport, VOYAGE_PROVIDER};
|
use daily_epub::report::{RunReport, VOYAGE_PROVIDER};
|
||||||
use daily_epub::types::{ArticleId, RatingEvent, Vote};
|
use daily_epub::types::{ArticleId, RatingEvent, Vote};
|
||||||
use daily_epub::{curate, http, lock, server, social};
|
use daily_epub::{curate, http, jobs, lock, server, social};
|
||||||
|
|
||||||
/// A personalized daily newspaper, delivered as an EPUB.
|
/// A personalized daily newspaper, delivered as an EPUB.
|
||||||
#[derive(Debug, Parser)]
|
#[derive(Debug, Parser)]
|
||||||
@@ -58,6 +58,56 @@ enum Command {
|
|||||||
/// Inspect the resolved configuration.
|
/// Inspect the resolved configuration.
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
Config(ConfigCommand),
|
Config(ConfigCommand),
|
||||||
|
/// Manage dashboard users without taking the pipeline run lock.
|
||||||
|
#[command(subcommand)]
|
||||||
|
Users(UsersCommand),
|
||||||
|
/// Operator jobs (what `daily-epub-job@<name>.service` runs).
|
||||||
|
#[command(subcommand)]
|
||||||
|
Job(JobCommand),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
enum JobCommand {
|
||||||
|
/// Run one catalogue job in-process and record it in the `jobs` table.
|
||||||
|
Run {
|
||||||
|
/// `generate`, `generate-YYYY-MM-DD`, `dry-run`, `profile-rebuild`,
|
||||||
|
/// `features-backfill`, `backfill-social` or `features-prune`.
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
enum UsersCommand {
|
||||||
|
/// Add a user.
|
||||||
|
Add(UserAddArgs),
|
||||||
|
/// Change a user's password and revoke existing sessions.
|
||||||
|
Passwd(UserPasswordArgs),
|
||||||
|
/// Change a user's role.
|
||||||
|
Role { username: String, role: String },
|
||||||
|
/// Disable a user and revoke existing sessions.
|
||||||
|
Disable { username: String },
|
||||||
|
/// Enable a user.
|
||||||
|
Enable { username: String },
|
||||||
|
/// List users and open-session counts.
|
||||||
|
List,
|
||||||
|
/// Revoke all sessions for a user.
|
||||||
|
Logout { username: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, clap::Args)]
|
||||||
|
struct UserAddArgs {
|
||||||
|
username: String,
|
||||||
|
#[arg(long)]
|
||||||
|
admin: bool,
|
||||||
|
#[arg(long)]
|
||||||
|
password_stdin: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, clap::Args)]
|
||||||
|
struct UserPasswordArgs {
|
||||||
|
username: String,
|
||||||
|
#[arg(long)]
|
||||||
|
password_stdin: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Subcommand)]
|
#[derive(Debug, Subcommand)]
|
||||||
@@ -286,11 +336,12 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
Command::Serve => {
|
Command::Serve => {
|
||||||
let db = Db::open_and_migrate(&config.database_path).await?;
|
let db = Db::open_and_migrate(&config.database_path).await?;
|
||||||
server::serve(config, db).await?;
|
let config_path = Config::resolve_path(cli.config.as_deref());
|
||||||
|
server::serve(config, config_path, db).await?;
|
||||||
}
|
}
|
||||||
Command::Profile(ProfileCommand::Rebuild) => {
|
Command::Profile(ProfileCommand::Rebuild) => {
|
||||||
let db = Db::open_and_migrate(&config.database_path).await?;
|
let db = Db::open_and_migrate(&config.database_path).await?;
|
||||||
cmd_profile_rebuild(&config, &db).await?;
|
println!("{}", cmd_profile_rebuild(&config, &db).await?);
|
||||||
}
|
}
|
||||||
Command::Ratings(command) => {
|
Command::Ratings(command) => {
|
||||||
let db = Db::open_and_migrate(&config.database_path).await?;
|
let db = Db::open_and_migrate(&config.database_path).await?;
|
||||||
@@ -307,11 +358,11 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
Command::Features(command) => {
|
Command::Features(command) => {
|
||||||
let db = Db::open_and_migrate(&config.database_path).await?;
|
let db = Db::open_and_migrate(&config.database_path).await?;
|
||||||
cmd_features(&config, &db, command).await?;
|
println!("{}", cmd_features(&config, &db, command).await?);
|
||||||
}
|
}
|
||||||
Command::BackfillSocial(args) => {
|
Command::BackfillSocial(args) => {
|
||||||
let db = Db::open_and_migrate(&config.database_path).await?;
|
let db = Db::open_and_migrate(&config.database_path).await?;
|
||||||
cmd_backfill_social(&db, args.days).await?;
|
println!("{}", cmd_backfill_social(&db, args.days).await?);
|
||||||
}
|
}
|
||||||
Command::Db(DbCommand::Migrate) => {
|
Command::Db(DbCommand::Migrate) => {
|
||||||
let db = Db::open(&config.database_path).await?;
|
let db = Db::open(&config.database_path).await?;
|
||||||
@@ -326,29 +377,139 @@ async fn main() -> Result<()> {
|
|||||||
println!("{line}");
|
println!("{line}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Command::Users(command) => {
|
||||||
|
let db = Db::open_and_migrate(&config.database_path).await?;
|
||||||
|
cmd_users(&db, command).await?;
|
||||||
|
}
|
||||||
|
Command::Job(JobCommand::Run { name }) => {
|
||||||
|
let Some(job) = jobs::Job::parse(&name) else {
|
||||||
|
eprintln!("unknown job {name:?}; the catalogue is:");
|
||||||
|
for job in jobs::Job::CATALOGUE {
|
||||||
|
eprintln!(" {:<20} {}", job.name(), job.description());
|
||||||
|
}
|
||||||
|
eprintln!(
|
||||||
|
" {:<20} {}",
|
||||||
|
"generate-YYYY-MM-DD",
|
||||||
|
jobs::Job::Generate {
|
||||||
|
date: Some(jiff::civil::Date::default())
|
||||||
|
}
|
||||||
|
.description()
|
||||||
|
);
|
||||||
|
std::process::exit(2);
|
||||||
|
};
|
||||||
|
let db = Db::open_and_migrate(&config.database_path).await?;
|
||||||
|
cmd_job_run(&config, &db, &job).await?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The commands that write the database and provider budgets and so hold the
|
/// The commands that write the database and provider budgets and so hold the
|
||||||
/// run lock (§5): `generate`, `profile rebuild`, `features backfill`,
|
/// run lock (§5): `generate`, `profile rebuild`, `features backfill`,
|
||||||
/// `backfill-social`. Everything else is read-only or its own writer.
|
/// `backfill-social`, and a `job run` of any of them. Everything else is
|
||||||
|
/// read-only or its own writer.
|
||||||
fn lock_holder(command: &Command) -> Option<&'static str> {
|
fn lock_holder(command: &Command) -> Option<&'static str> {
|
||||||
match command {
|
match command {
|
||||||
Command::Generate(_) => Some("generate"),
|
Command::Generate(_) => Some("generate"),
|
||||||
Command::Profile(ProfileCommand::Rebuild) => Some("profile rebuild"),
|
Command::Profile(ProfileCommand::Rebuild) => Some("profile rebuild"),
|
||||||
Command::Features(FeaturesCommand::Backfill(_)) => Some("features backfill"),
|
Command::Features(FeaturesCommand::Backfill(_)) => Some("features backfill"),
|
||||||
Command::BackfillSocial(_) => Some("backfill-social"),
|
Command::BackfillSocial(_) => Some("backfill-social"),
|
||||||
|
// An unknown name takes no lock; the dispatch exits 2 before opening
|
||||||
|
// the database.
|
||||||
|
Command::Job(JobCommand::Run { name }) => {
|
||||||
|
jobs::Job::parse(name).and_then(|job| job.takes_lock())
|
||||||
|
}
|
||||||
Command::Serve
|
Command::Serve
|
||||||
| Command::Ratings(_)
|
| Command::Ratings(_)
|
||||||
| Command::Explain(_)
|
| Command::Explain(_)
|
||||||
| Command::Stats(_)
|
| Command::Stats(_)
|
||||||
| Command::Features(FeaturesCommand::Prune)
|
| Command::Features(FeaturesCommand::Prune)
|
||||||
| Command::Db(_)
|
| Command::Db(_)
|
||||||
| Command::Config(_) => None,
|
| Command::Config(_)
|
||||||
|
| Command::Users(_) => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn cmd_users(db: &Db, command: UsersCommand) -> Result<()> {
|
||||||
|
use daily_epub::web::users;
|
||||||
|
match command {
|
||||||
|
UsersCommand::Add(args) => {
|
||||||
|
let password = read_new_password(args.password_stdin)?;
|
||||||
|
let user = users::add(db, &args.username, &password, args.admin).await?;
|
||||||
|
println!("added {} ({})", user.username, user.role);
|
||||||
|
}
|
||||||
|
UsersCommand::Passwd(args) => {
|
||||||
|
let password = read_new_password(args.password_stdin)?;
|
||||||
|
let sessions = users::passwd(db, &args.username, &password).await?;
|
||||||
|
println!(
|
||||||
|
"changed password for {} and revoked {sessions} session(s)",
|
||||||
|
args.username
|
||||||
|
);
|
||||||
|
}
|
||||||
|
UsersCommand::Role { username, role } => {
|
||||||
|
let role = role.parse().map_err(anyhow::Error::msg)?;
|
||||||
|
users::set_role(db, &username, role).await?;
|
||||||
|
println!("set {username} role to {role}");
|
||||||
|
}
|
||||||
|
UsersCommand::Disable { username } => {
|
||||||
|
let sessions = users::set_disabled(db, &username, true).await?;
|
||||||
|
println!("disabled {username} and revoked {sessions} session(s)");
|
||||||
|
}
|
||||||
|
UsersCommand::Enable { username } => {
|
||||||
|
users::set_disabled(db, &username, false).await?;
|
||||||
|
println!("enabled {username}");
|
||||||
|
}
|
||||||
|
UsersCommand::List => {
|
||||||
|
for row in users::list(db).await? {
|
||||||
|
let last_login = row
|
||||||
|
.user
|
||||||
|
.last_login_at
|
||||||
|
.map(|timestamp| timestamp.to_string())
|
||||||
|
.unwrap_or_else(|| "never".into());
|
||||||
|
println!(
|
||||||
|
"{} · {}{} · created {} · last login {} · {} open session(s)",
|
||||||
|
row.user.username,
|
||||||
|
row.user.role,
|
||||||
|
if row.user.disabled {
|
||||||
|
" · disabled"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
},
|
||||||
|
row.user.created_at,
|
||||||
|
last_login,
|
||||||
|
row.open_sessions
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UsersCommand::Logout { username } => {
|
||||||
|
let sessions = users::logout(db, &username).await?;
|
||||||
|
println!("revoked {sessions} session(s) for {username}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_new_password(from_stdin: bool) -> Result<String> {
|
||||||
|
if from_stdin {
|
||||||
|
let mut password = String::new();
|
||||||
|
std::io::stdin().read_line(&mut password)?;
|
||||||
|
while password.ends_with(['\n', '\r']) {
|
||||||
|
password.pop();
|
||||||
|
}
|
||||||
|
return Ok(password);
|
||||||
|
}
|
||||||
|
let first = read_password_hidden("Password: ")?;
|
||||||
|
let second = read_password_hidden("Confirm password: ")?;
|
||||||
|
if first != second {
|
||||||
|
anyhow::bail!("passwords do not match");
|
||||||
|
}
|
||||||
|
Ok(first)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_password_hidden(prompt: &str) -> Result<String> {
|
||||||
|
rpassword::prompt_password(prompt).context("reading the password from the terminal")
|
||||||
|
}
|
||||||
|
|
||||||
/// `RUST_LOG`-driven tracing, defaulting to `info` (crate table "logging").
|
/// `RUST_LOG`-driven tracing, defaulting to `info` (crate table "logging").
|
||||||
fn init_tracing() {
|
fn init_tracing() {
|
||||||
let filter = EnvFilter::try_from_default_env()
|
let filter = EnvFilter::try_from_default_env()
|
||||||
@@ -480,7 +641,8 @@ fn print_lineup(issue: &daily_epub::types::Issue) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// `profile rebuild` runs on the editor when configured, else bulk (§14.3).
|
/// `profile rebuild` runs on the editor when configured, else bulk (§14.3).
|
||||||
async fn cmd_profile_rebuild(config: &Config, db: &Db) -> Result<()> {
|
/// Returns the one-line summary the CLI prints and `job run` records.
|
||||||
|
async fn cmd_profile_rebuild(config: &Config, db: &Db) -> Result<String> {
|
||||||
use curate::llm::{Llms, provider_meters};
|
use curate::llm::{Llms, provider_meters};
|
||||||
let profile = curate::profile::load_or_build(
|
let profile = curate::profile::load_or_build(
|
||||||
db,
|
db,
|
||||||
@@ -514,12 +676,11 @@ async fn cmd_profile_rebuild(config: &Config, db: &Db) -> Result<()> {
|
|||||||
config.curation.feedback.verdicts_in_prompt,
|
config.curation.feedback.verdicts_in_prompt,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
println!(
|
Ok(format!(
|
||||||
"taste profile rebuilt (version {}, {} chars)",
|
"taste profile rebuilt (version {}, {} chars)",
|
||||||
rebuilt.version,
|
rebuilt.version,
|
||||||
rebuilt.text.len()
|
rebuilt.text.len()
|
||||||
);
|
))
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn resolve_rating_article(
|
async fn resolve_rating_article(
|
||||||
@@ -562,6 +723,7 @@ async fn append_cli_event(
|
|||||||
};
|
};
|
||||||
let event = RatingEvent {
|
let event = RatingEvent {
|
||||||
id: 0,
|
id: 0,
|
||||||
|
user_id: None,
|
||||||
article_id,
|
article_id,
|
||||||
issue_date: db.latest_issue_date_for_article(article_id).await?,
|
issue_date: db.latest_issue_date_for_article(article_id).await?,
|
||||||
kind: "explicit".into(),
|
kind: "explicit".into(),
|
||||||
@@ -641,7 +803,9 @@ async fn cmd_explain(db: &Db, args: ExplainArgs) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cmd_features(config: &Config, db: &Db, command: FeaturesCommand) -> Result<()> {
|
/// `features backfill` / `features prune`; returns the final summary line
|
||||||
|
/// (progress lines are printed as they happen).
|
||||||
|
async fn cmd_features(config: &Config, db: &Db, command: FeaturesCommand) -> Result<String> {
|
||||||
match command {
|
match command {
|
||||||
FeaturesCommand::Backfill(args) => {
|
FeaturesCommand::Backfill(args) => {
|
||||||
if !config.voyage.enabled {
|
if !config.voyage.enabled {
|
||||||
@@ -664,8 +828,7 @@ async fn cmd_features(config: &Config, db: &Db, command: FeaturesCommand) -> Res
|
|||||||
plan.cached
|
plan.cached
|
||||||
);
|
);
|
||||||
if plan.is_empty() {
|
if plan.is_empty() {
|
||||||
println!("cache is warm; nothing to do");
|
return Ok("cache is warm; nothing to do".into());
|
||||||
return Ok(());
|
|
||||||
}
|
}
|
||||||
println!(
|
println!(
|
||||||
"estimate: ~{} tokens ≈ ${:.4} with {} at ${:.2}/M",
|
"estimate: ~{} tokens ≈ ${:.4} with {} at ${:.2}/M",
|
||||||
@@ -678,17 +841,16 @@ async fn cmd_features(config: &Config, db: &Db, command: FeaturesCommand) -> Res
|
|||||||
&& !args.yes
|
&& !args.yes
|
||||||
&& !confirm("continue?")?
|
&& !confirm("continue?")?
|
||||||
{
|
{
|
||||||
println!("aborted");
|
return Ok("aborted".into());
|
||||||
return Ok(());
|
|
||||||
}
|
}
|
||||||
let outcome = embedding::run_backfill(&service, &plan).await?;
|
let outcome = embedding::run_backfill(&service, &plan).await?;
|
||||||
println!(
|
Ok(format!(
|
||||||
"embedded {} articles and {} interests · {} tokens · ${:.4}",
|
"embedded {} articles and {} interests · {} tokens · ${:.4}",
|
||||||
outcome.articles_embedded,
|
outcome.articles_embedded,
|
||||||
outcome.interests_embedded,
|
outcome.interests_embedded,
|
||||||
outcome.tokens,
|
outcome.tokens,
|
||||||
outcome.cost_usd
|
outcome.cost_usd
|
||||||
);
|
))
|
||||||
}
|
}
|
||||||
FeaturesCommand::Prune => {
|
FeaturesCommand::Prune => {
|
||||||
let ranking = &config.curation.ranking;
|
let ranking = &config.curation.ranking;
|
||||||
@@ -699,17 +861,16 @@ async fn cmd_features(config: &Config, db: &Db, command: FeaturesCommand) -> Res
|
|||||||
jiff::Timestamp::now(),
|
jiff::Timestamp::now(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
println!(
|
Ok(format!(
|
||||||
"pruned {} embeddings older than {} days, {} candidate rows and {} assessments older than {} days",
|
"pruned {} embeddings older than {} days, {} candidate rows and {} assessments older than {} days",
|
||||||
pruned.embeddings,
|
pruned.embeddings,
|
||||||
ranking.embedding_retention_days,
|
ranking.embedding_retention_days,
|
||||||
pruned.telemetry,
|
pruned.telemetry,
|
||||||
pruned.assessments,
|
pruned.assessments,
|
||||||
ranking.telemetry_retention_days
|
ranking.telemetry_retention_days
|
||||||
);
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A y/N question on stdin; anything but a leading `y` is a no.
|
/// A y/N question on stdin; anything but a leading `y` is a no.
|
||||||
@@ -721,12 +882,94 @@ fn confirm(question: &str) -> Result<bool> {
|
|||||||
Ok(answer.trim().to_lowercase().starts_with('y'))
|
Ok(answer.trim().to_lowercase().starts_with('y'))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cmd_backfill_social(db: &Db, days: u32) -> Result<()> {
|
async fn cmd_backfill_social(db: &Db, days: u32) -> Result<String> {
|
||||||
let http = http::build_client(http::DEFAULT_TIMEOUT)?;
|
let http = http::build_client(http::DEFAULT_TIMEOUT)?;
|
||||||
let enricher = social::SocialEnricher::new(http, db.clone());
|
let enricher = social::SocialEnricher::new(http, db.clone());
|
||||||
let updated = enricher.backfill(days).await?;
|
let updated = enricher.backfill(days).await?;
|
||||||
println!("refreshed social scores for {updated} articles");
|
Ok(format!("refreshed social scores for {updated} articles"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// `job run` (dashboard plan §14.2)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// `job run <name>`: claim the newest `requested` row of this job (or insert
|
||||||
|
/// one when the unit was started by hand), run the mapped command in-process
|
||||||
|
/// with the same functions the plain subcommands use, and record `ok` /
|
||||||
|
/// `failed` with a one-line message and, for generate, the run id. A failure
|
||||||
|
/// propagates so the unit exits non-zero (`Result=exit-code`).
|
||||||
|
async fn cmd_job_run(config: &Config, db: &Db, job: &jobs::Job) -> Result<()> {
|
||||||
|
let id = jobs::claim(db, job, jiff::Timestamp::now())
|
||||||
|
.await
|
||||||
|
.context("claiming the jobs row")?;
|
||||||
|
tracing::info!(job = %job.name(), job_id = id, "job started");
|
||||||
|
let result = run_job(config, db, job).await;
|
||||||
|
let now = jiff::Timestamp::now();
|
||||||
|
match result {
|
||||||
|
Ok((message, run_id)) => {
|
||||||
|
jobs::finish(db, id, jobs::Outcome::Ok, &message, run_id, now)
|
||||||
|
.await
|
||||||
|
.context("recording the job outcome")?;
|
||||||
|
tracing::info!(job = %job.name(), job_id = id, %message, "job finished");
|
||||||
Ok(())
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
let message = format!("{error:#}");
|
||||||
|
jobs::finish(db, id, jobs::Outcome::Failed, &message, None, now)
|
||||||
|
.await
|
||||||
|
.context("recording the job failure")?;
|
||||||
|
tracing::error!(job = %job.name(), job_id = id, %message, "job failed");
|
||||||
|
Err(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The command each catalogue job maps to (§14.1), returning its one-line
|
||||||
|
/// message and the run id it produced.
|
||||||
|
async fn run_job(config: &Config, db: &Db, job: &jobs::Job) -> Result<(String, Option<i64>)> {
|
||||||
|
match job {
|
||||||
|
jobs::Job::Generate { date } => {
|
||||||
|
generate_job(config, db, date.map(|date| date.to_string()), false).await
|
||||||
|
}
|
||||||
|
jobs::Job::DryRun => generate_job(config, db, None, true).await,
|
||||||
|
jobs::Job::ProfileRebuild => Ok((cmd_profile_rebuild(config, db).await?, None)),
|
||||||
|
jobs::Job::FeaturesBackfill => {
|
||||||
|
let args = BackfillArgs {
|
||||||
|
days: 30,
|
||||||
|
rated_only: false,
|
||||||
|
all: false,
|
||||||
|
yes: true,
|
||||||
|
};
|
||||||
|
Ok((
|
||||||
|
cmd_features(config, db, FeaturesCommand::Backfill(args)).await?,
|
||||||
|
None,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
jobs::Job::BackfillSocial => Ok((cmd_backfill_social(db, 7).await?, None)),
|
||||||
|
jobs::Job::FeaturesPrune => Ok((
|
||||||
|
cmd_features(config, db, FeaturesCommand::Prune).await?,
|
||||||
|
None,
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `generate [--date D] [--dry-run]` as a job: the `curation:` line is the
|
||||||
|
/// message, the run id links the job to its run.
|
||||||
|
async fn generate_job(
|
||||||
|
config: &Config,
|
||||||
|
db: &Db,
|
||||||
|
date: Option<String>,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> Result<(String, Option<i64>)> {
|
||||||
|
let opts = GenerateOptions {
|
||||||
|
date,
|
||||||
|
dry_run,
|
||||||
|
..GenerateOptions::default()
|
||||||
|
};
|
||||||
|
let outcome = pipeline::generate(config, db, &opts).await?;
|
||||||
|
print_outcome(&outcome);
|
||||||
|
let [curation, ..] = outcome.report.info_block();
|
||||||
|
Ok((curation, Some(outcome.run_id)))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -858,9 +1101,110 @@ mod tests {
|
|||||||
vec!["db", "migrate"],
|
vec!["db", "migrate"],
|
||||||
vec!["features", "prune"],
|
vec!["features", "prune"],
|
||||||
vec!["config", "check"],
|
vec!["config", "check"],
|
||||||
|
vec!["job", "run", "features-prune"],
|
||||||
|
vec!["job", "run", "not-a-job"],
|
||||||
] {
|
] {
|
||||||
assert_eq!(lock_holder(&parse(&args)), None, "{args:?}");
|
assert_eq!(lock_holder(&parse(&args)), None, "{args:?}");
|
||||||
}
|
}
|
||||||
|
// `job run` takes the same lock as the command it maps to.
|
||||||
|
assert_eq!(
|
||||||
|
lock_holder(&parse(&["job", "run", "generate"])),
|
||||||
|
Some("generate")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lock_holder(&parse(&["job", "run", "generate-2026-09-03"])),
|
||||||
|
Some("generate")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lock_holder(&parse(&["job", "run", "dry-run"])),
|
||||||
|
Some("generate")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lock_holder(&parse(&["job", "run", "profile-rebuild"])),
|
||||||
|
Some("profile rebuild")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lock_holder(&parse(&["job", "run", "features-backfill"])),
|
||||||
|
Some("features backfill")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lock_holder(&parse(&["job", "run", "backfill-social"])),
|
||||||
|
Some("backfill-social")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_job_run() {
|
||||||
|
match Cli::try_parse_from(["daily-epub", "job", "run", "features-prune"])
|
||||||
|
.unwrap()
|
||||||
|
.command
|
||||||
|
{
|
||||||
|
Command::Job(JobCommand::Run { name }) => assert_eq!(name, "features-prune"),
|
||||||
|
other => panic!("expected job run, got {other:?}"),
|
||||||
|
}
|
||||||
|
assert!(Cli::try_parse_from(["daily-epub", "job", "run"]).is_err());
|
||||||
|
assert!(Cli::try_parse_from(["daily-epub", "job"]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dashboard plan §17 "Jobs": `job run` flips the dashboard's `requested`
|
||||||
|
/// row to `running` and then `ok` with the command's message, in-process
|
||||||
|
/// and without systemd.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn job_run_flips_requested_to_running_to_ok() {
|
||||||
|
use sqlx::Row as _;
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut config = Config {
|
||||||
|
database_path: dir.path().join("jobs.db"),
|
||||||
|
..Config::default()
|
||||||
|
};
|
||||||
|
let db = Db::open_and_migrate(&config.database_path).await.unwrap();
|
||||||
|
let now = jiff::Timestamp::now();
|
||||||
|
let job = jobs::Job::FeaturesPrune;
|
||||||
|
let requested = jobs::insert_requested(&db, &job, None, now).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
jobs::get(&db, requested).await.unwrap().unwrap().status,
|
||||||
|
"requested"
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd_job_run(&config, &db, &job).await.unwrap();
|
||||||
|
|
||||||
|
let row = jobs::get(&db, requested).await.unwrap().unwrap();
|
||||||
|
assert_eq!(row.status, "ok", "{row:?}");
|
||||||
|
assert!(row.started_at.is_some());
|
||||||
|
assert!(row.finished_at.is_some());
|
||||||
|
assert!(
|
||||||
|
row.message
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.starts_with("pruned 0 embeddings"),
|
||||||
|
"{row:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(row.run_id, None);
|
||||||
|
let rows: i64 = sqlx::query("SELECT COUNT(*) AS n FROM jobs")
|
||||||
|
.fetch_one(db.pool())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.get("n");
|
||||||
|
assert_eq!(rows, 1, "the requested row was claimed, not duplicated");
|
||||||
|
|
||||||
|
// A failing command records `failed` with the error and propagates it.
|
||||||
|
config.voyage.enabled = false;
|
||||||
|
let backfill = jobs::Job::FeaturesBackfill;
|
||||||
|
let error = cmd_job_run(&config, &db, &backfill).await.unwrap_err();
|
||||||
|
assert!(error.to_string().contains("voyage.enabled is false"));
|
||||||
|
let failed = jobs::list(&db, 1).await.unwrap().remove(0);
|
||||||
|
assert_eq!(failed.name, "features-backfill");
|
||||||
|
assert_eq!(failed.status, "failed");
|
||||||
|
assert_eq!(failed.requested_by, None, "started by hand: no requester");
|
||||||
|
assert!(
|
||||||
|
failed
|
||||||
|
.message
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.contains("voyage.enabled is false"),
|
||||||
|
"{failed:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ pub struct GenerateOptions {
|
|||||||
/// What one run produced, for the caller to print (§3.13).
|
/// What one run produced, for the caller to print (§3.13).
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct GenerateOutcome {
|
pub struct GenerateOutcome {
|
||||||
|
pub run_id: i64,
|
||||||
pub report: RunReport,
|
pub report: RunReport,
|
||||||
/// `None` only when the run failed before assembly.
|
/// `None` only when the run failed before assembly.
|
||||||
pub issue: Option<Issue>,
|
pub issue: Option<Issue>,
|
||||||
@@ -138,6 +139,17 @@ pub fn display_date(date: Date) -> String {
|
|||||||
format!("{weekday}, {month} {}, {}", date.day(), date.year())
|
format!("{weekday}, {month} {}, {}", date.day(), date.year())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "Fri, Aug 15" — the same dateline abbreviated for narrow screens (web only).
|
||||||
|
pub fn short_display_date(date: Date) -> String {
|
||||||
|
const WEEKDAYS: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||||
|
const MONTHS: [&str; 12] = [
|
||||||
|
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||||
|
];
|
||||||
|
let weekday = WEEKDAYS[(date.weekday().to_monday_zero_offset() as usize).min(6)];
|
||||||
|
let month = MONTHS[(date.month() as usize).clamp(1, 12) - 1];
|
||||||
|
format!("{weekday}, {month} {}", date.day())
|
||||||
|
}
|
||||||
|
|
||||||
/// Materialize the [`Issue`] the EPUB builder consumes (§3.10).
|
/// Materialize the [`Issue`] the EPUB builder consumes (§3.10).
|
||||||
///
|
///
|
||||||
/// Pure: every count is derived from the lineup, so the same inputs always give
|
/// Pure: every count is derived from the lineup, so the same inputs always give
|
||||||
@@ -271,12 +283,14 @@ pub async fn generate(config: &Config, db: &Db, opts: &GenerateOptions) -> Resul
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
Some(&report.to_json()),
|
Some(&report.to_json()),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::warn!(error = %e, "could not attach the run report to the issue");
|
tracing::warn!(error = %e, "could not attach the run report to the issue");
|
||||||
}
|
}
|
||||||
Ok(GenerateOutcome {
|
Ok(GenerateOutcome {
|
||||||
|
run_id,
|
||||||
report,
|
report,
|
||||||
issue: stages.issue,
|
issue: stages.issue,
|
||||||
artifacts: stages.artifacts,
|
artifacts: stages.artifacts,
|
||||||
@@ -1045,6 +1059,12 @@ async fn record_issue(db: &Db, issue: &Issue, published: &Published) -> Result<(
|
|||||||
let x4_path = path_for(Edition::X4);
|
let x4_path = path_for(Edition::X4);
|
||||||
let xtc_path = published.xtc.as_ref().map(|p| p.display().to_string());
|
let xtc_path = published.xtc.as_ref().map(|p| p.display().to_string());
|
||||||
|
|
||||||
|
let mut stored_issue = issue.clone();
|
||||||
|
for pick in &mut stored_issue.lineup.picks {
|
||||||
|
pick.article.content_html.clear();
|
||||||
|
}
|
||||||
|
let issue_json = serde_json::to_string(&stored_issue).context("serializing issue snapshot")?;
|
||||||
|
|
||||||
db.upsert_issue(
|
db.upsert_issue(
|
||||||
issue.meta.date,
|
issue.meta.date,
|
||||||
issue.meta.issue_number,
|
issue.meta.issue_number,
|
||||||
@@ -1054,6 +1074,7 @@ async fn record_issue(db: &Db, issue: &Issue, published: &Published) -> Result<(
|
|||||||
xtc_path.as_deref(),
|
xtc_path.as_deref(),
|
||||||
Some(&issue.editorial.front_page_html),
|
Some(&issue.editorial.front_page_html),
|
||||||
None,
|
None,
|
||||||
|
Some(&issue_json),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
db.replace_issue_articles(issue.meta.date, &issue.lineup.picks)
|
db.replace_issue_articles(issue.meta.date, &issue.lineup.picks)
|
||||||
|
|||||||
@@ -874,6 +874,7 @@ type=\"application/epub+zip\" length=\"1700000\"/>"
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
+174
-31
@@ -22,18 +22,23 @@
|
|||||||
//! `server.hmac_secret` is unset carries links this server rejects with 403.
|
//! `server.hmac_secret` is unset carries links this server rejects with 403.
|
||||||
|
|
||||||
use std::path::{Path as FsPath, PathBuf};
|
use std::path::{Path as FsPath, PathBuf};
|
||||||
|
use std::sync::{Arc, RwLock};
|
||||||
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::extract::{Path, Query, State};
|
use axum::extract::{Path, Query, State};
|
||||||
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
|
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
|
||||||
|
use axum::middleware::{from_fn, from_fn_with_state};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::routing::get;
|
use axum::routing::get;
|
||||||
|
use axum_login::AuthManagerLayerBuilder;
|
||||||
|
use axum_login::tower_sessions::{Expiry, SessionManagerLayer};
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
use jiff::Timestamp;
|
use jiff::Timestamp;
|
||||||
use jiff::civil::Date;
|
use jiff::civil::Date;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use tower_http::request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer};
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
@@ -64,10 +69,61 @@ pub enum ServerError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Shared axum state.
|
/// Shared axum state.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub db: Db,
|
pub db: Db,
|
||||||
pub config: Config,
|
pub config: Arc<RwLock<Arc<Config>>>,
|
||||||
|
pub config_path: Option<PathBuf>,
|
||||||
|
pub web: Arc<crate::web::WebState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for AppState {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("AppState")
|
||||||
|
.field("db", &self.db)
|
||||||
|
.field("config_path", &self.config_path)
|
||||||
|
.field("web", &self.web)
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn new(db: Db, config: Config, config_path: Option<PathBuf>) -> Self {
|
||||||
|
Self {
|
||||||
|
db,
|
||||||
|
config: Arc::new(RwLock::new(Arc::new(config))),
|
||||||
|
config_path,
|
||||||
|
web: Arc::new(crate::web::WebState {
|
||||||
|
jobs: Arc::new(crate::web::DisabledRunner),
|
||||||
|
started_at: Timestamp::now(),
|
||||||
|
config_mtime: std::sync::Mutex::new(None),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `new`, with a specific [`crate::web::JobRunner`]: `SystemdRunner` in
|
||||||
|
/// `serve`, a `MockRunner` in router tests (dashboard plan §14.4).
|
||||||
|
pub fn with_jobs(
|
||||||
|
db: Db,
|
||||||
|
config: Config,
|
||||||
|
config_path: Option<PathBuf>,
|
||||||
|
jobs: Arc<dyn crate::web::JobRunner>,
|
||||||
|
) -> Self {
|
||||||
|
let mut state = Self::new(db, config, config_path);
|
||||||
|
state.web = Arc::new(crate::web::WebState {
|
||||||
|
jobs,
|
||||||
|
started_at: Timestamp::now(),
|
||||||
|
config_mtime: std::sync::Mutex::new(None),
|
||||||
|
});
|
||||||
|
state
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn config(&self) -> Arc<Config> {
|
||||||
|
match self.config.read() {
|
||||||
|
Ok(config) => Arc::clone(&config),
|
||||||
|
Err(poisoned) => Arc::clone(&poisoned.into_inner()),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -86,6 +142,22 @@ pub use crate::auth::{constant_time_eq, rating_token, rating_url, verify_token};
|
|||||||
/// `/files/epub/{name}`, `/files/xtc/{name}`, `/healthz`, `/issues.json`, with
|
/// `/files/epub/{name}`, `/files/xtc/{name}`, `/healthz`, `/issues.json`, with
|
||||||
/// `tower-http` tracing (§3.12).
|
/// `tower-http` tracing (§3.12).
|
||||||
pub fn router(state: AppState) -> Router {
|
pub fn router(state: AppState) -> Router {
|
||||||
|
let config = state.config();
|
||||||
|
let store = crate::web::session::SqliteSessionStore::new(state.db.pool().clone());
|
||||||
|
let session_layer = SessionManagerLayer::new(store)
|
||||||
|
.with_name("daily_session")
|
||||||
|
.with_http_only(true)
|
||||||
|
.with_same_site(axum_login::tower_sessions::cookie::SameSite::Lax)
|
||||||
|
.with_secure(config.server.public_url.starts_with("https://"))
|
||||||
|
.with_expiry(Expiry::OnInactivity(time::Duration::days(i64::from(
|
||||||
|
config.server.session_days,
|
||||||
|
))));
|
||||||
|
let auth_layer = AuthManagerLayerBuilder::new(
|
||||||
|
crate::web::session::Backend::new(state.db.clone()),
|
||||||
|
session_layer,
|
||||||
|
)
|
||||||
|
.build();
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/r/{date}/{article_id}/{vote}", get(handle_rating))
|
.route("/r/{date}/{article_id}/{vote}", get(handle_rating))
|
||||||
.route(crate::publish::OPDS_PATH, get(handle_opds))
|
.route(crate::publish::OPDS_PATH, get(handle_opds))
|
||||||
@@ -97,12 +169,25 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/files/xtc/{name}", get(handle_xtc_file))
|
.route("/files/xtc/{name}", get(handle_xtc_file))
|
||||||
.route("/healthz", get(handle_healthz))
|
.route("/healthz", get(handle_healthz))
|
||||||
.route("/issues.json", get(handle_issues_json))
|
.route("/issues.json", get(handle_issues_json))
|
||||||
|
.merge(crate::web::router(&config))
|
||||||
|
.layer(PropagateRequestIdLayer::x_request_id())
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
|
.layer(SetRequestIdLayer::x_request_id(MakeRequestUuid))
|
||||||
|
.layer(auth_layer)
|
||||||
|
.layer(from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
crate::web::session::require_same_origin,
|
||||||
|
))
|
||||||
|
.layer(from_fn(crate::web::security_headers))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `daily-epub serve` — bind, serve, graceful shutdown on SIGTERM (§3.12).
|
/// `daily-epub serve` — bind, serve, graceful shutdown on SIGTERM (§3.12).
|
||||||
pub async fn serve(config: Config, db: Db) -> Result<(), ServerError> {
|
pub async fn serve(
|
||||||
|
config: Config,
|
||||||
|
config_path: Option<PathBuf>,
|
||||||
|
db: Db,
|
||||||
|
) -> Result<(), ServerError> {
|
||||||
if config.server.hmac_secret.is_none() {
|
if config.server.hmac_secret.is_none() {
|
||||||
// Not fatal for the OPDS routes, but every rating link would 500.
|
// Not fatal for the OPDS routes, but every rating link would 500.
|
||||||
tracing::warn!("server.hmac_secret is unset — rating links will be rejected");
|
tracing::warn!("server.hmac_secret is unset — rating links will be rejected");
|
||||||
@@ -117,8 +202,34 @@ pub async fn serve(config: Config, db: Db) -> Result<(), ServerError> {
|
|||||||
let local = listener.local_addr().map(|a| a.to_string()).unwrap_or(addr);
|
let local = listener.local_addr().map(|a| a.to_string()).unwrap_or(addr);
|
||||||
tracing::info!(bind = %local, public_url = %config.server.public_url, "serving");
|
tracing::info!(bind = %local, public_url = %config.server.public_url, "serving");
|
||||||
|
|
||||||
let app = router(AppState { db, config });
|
let store = crate::web::session::SqliteSessionStore::new(db.pool().clone());
|
||||||
axum::serve(listener, app)
|
if let Err(error) = store.delete_expired().await {
|
||||||
|
tracing::warn!(%error, "could not delete expired sessions at startup");
|
||||||
|
}
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut interval = tokio::time::interval(std::time::Duration::from_secs(3600));
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
if let Err(error) = store.delete_expired().await {
|
||||||
|
tracing::warn!(%error, "could not delete expired sessions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// The Jobs page starts `daily-epub-job@<name>.service` through systemd +
|
||||||
|
// polkit (§14); `server.jobs_enabled = false` swaps in the runner whose
|
||||||
|
// page says so.
|
||||||
|
let jobs: Arc<dyn crate::web::JobRunner> = if config.server.jobs_enabled {
|
||||||
|
Arc::new(crate::jobs::SystemdRunner::default())
|
||||||
|
} else {
|
||||||
|
tracing::info!("server.jobs_enabled is false; the Jobs page cannot start units");
|
||||||
|
Arc::new(crate::web::DisabledRunner)
|
||||||
|
};
|
||||||
|
let app = router(AppState::with_jobs(db, config, config_path, jobs));
|
||||||
|
axum::serve(
|
||||||
|
listener,
|
||||||
|
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
||||||
|
)
|
||||||
.with_graceful_shutdown(shutdown_signal())
|
.with_graceful_shutdown(shutdown_signal())
|
||||||
.await?;
|
.await?;
|
||||||
tracing::info!("server stopped");
|
tracing::info!("server stopped");
|
||||||
@@ -219,7 +330,8 @@ async fn handle_rating(
|
|||||||
return page(StatusCode::BAD_REQUEST, "Bad link — invalid vote.", None);
|
return page(StatusCode::BAD_REQUEST, "Bad link — invalid vote.", None);
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(secret) = state.config.server.hmac_secret.as_deref() else {
|
let config = state.config();
|
||||||
|
let Some(secret) = config.server.hmac_secret.as_deref() else {
|
||||||
tracing::error!("rating request but server.hmac_secret is unset");
|
tracing::error!("rating request but server.hmac_secret is unset");
|
||||||
return page(
|
return page(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
@@ -251,12 +363,13 @@ async fn handle_rating(
|
|||||||
};
|
};
|
||||||
let event = RatingEvent {
|
let event = RatingEvent {
|
||||||
id: 0,
|
id: 0,
|
||||||
|
user_id: None,
|
||||||
issue_date: Some(date),
|
issue_date: Some(date),
|
||||||
article_id,
|
article_id,
|
||||||
kind: "explicit".into(),
|
kind: "explicit".into(),
|
||||||
source: "epub".into(),
|
source: "epub".into(),
|
||||||
label: label.into(),
|
label: label.into(),
|
||||||
value: vote.value(&state.config.curation.feedback),
|
value: vote.value(&config.curation.feedback),
|
||||||
note: None,
|
note: None,
|
||||||
event_at: Timestamp::now(),
|
event_at: Timestamp::now(),
|
||||||
};
|
};
|
||||||
@@ -278,23 +391,17 @@ async fn handle_rating(
|
|||||||
Vote::Good => "Recorded: Good — thanks.",
|
Vote::Good => "Recorded: Good — thanks.",
|
||||||
Vote::NotForMe => "Recorded: Not for me — thanks.",
|
Vote::NotForMe => "Recorded: Not for me — thanks.",
|
||||||
};
|
};
|
||||||
confirmation_page(
|
confirmation_page(StatusCode::OK, message, &config, date, article_id, vote)
|
||||||
StatusCode::OK,
|
|
||||||
message,
|
|
||||||
&state.config,
|
|
||||||
date,
|
|
||||||
article_id,
|
|
||||||
vote,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /opds/daily.xml` — both EPUB editions of the last issues, newest first,
|
/// `GET /opds/daily.xml` — both EPUB editions of the last issues, newest first,
|
||||||
/// rendered from the publish directory on each request (§3.11).
|
/// rendered from the publish directory on each request (§3.11).
|
||||||
async fn handle_opds(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
async fn handle_opds(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
||||||
if let Some(challenge) = check_basic_auth(&state.config, &headers) {
|
let config = state.config();
|
||||||
|
if let Some(challenge) = check_basic_auth(&config, &headers) {
|
||||||
return challenge;
|
return challenge;
|
||||||
}
|
}
|
||||||
match crate::publish::build_opds(&state.db, &state.config).await {
|
match crate::publish::build_opds(&state.db, &config).await {
|
||||||
Ok(feed) => (
|
Ok(feed) => (
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
[
|
[
|
||||||
@@ -321,9 +428,10 @@ async fn handle_epub_file(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(name): Path<String>,
|
Path(name): Path<String>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
|
auth: crate::web::session::AuthSession,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let dir = state.config.publish.epub_dir.clone();
|
let dir = state.config().publish.epub_dir.clone();
|
||||||
serve_file(&state, &dir, &name, &headers).await
|
serve_file(&state, &dir, &name, &headers, auth.user().await.is_some()).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /files/xtc/{name}` — download one XTC artifact (§3.11).
|
/// `GET /files/xtc/{name}` — download one XTC artifact (§3.11).
|
||||||
@@ -334,14 +442,25 @@ async fn handle_xtc_file(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(name): Path<String>,
|
Path(name): Path<String>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
|
auth: crate::web::session::AuthSession,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let dir = state.config.publish.xtc_dir.clone();
|
let dir = state.config().publish.xtc_dir.clone();
|
||||||
serve_file(&state, &dir, &name, &headers).await
|
serve_file(&state, &dir, &name, &headers, auth.user().await.is_some()).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stream one file out of `dir`, behind the OPDS Basic auth (§3.11).
|
/// Stream one file out of `dir`. A signed-in web session (any role) bypasses
|
||||||
async fn serve_file(state: &AppState, dir: &FsPath, name: &str, headers: &HeaderMap) -> Response {
|
/// the OPDS Basic auth; without one the existing Basic auth policy applies
|
||||||
if let Some(challenge) = check_basic_auth(&state.config, headers) {
|
/// unchanged, including "no credentials configured ⇒ public", so e-readers
|
||||||
|
/// following the OPDS feed keep working (§3.11, dashboard plan §8).
|
||||||
|
async fn serve_file(
|
||||||
|
state: &AppState,
|
||||||
|
dir: &FsPath,
|
||||||
|
name: &str,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
signed_in: bool,
|
||||||
|
) -> Response {
|
||||||
|
let config = state.config();
|
||||||
|
if !signed_in && let Some(challenge) = check_basic_auth(&config, headers) {
|
||||||
return challenge;
|
return challenge;
|
||||||
}
|
}
|
||||||
let Some(path) = safe_join(dir, name) else {
|
let Some(path) = safe_join(dir, name) else {
|
||||||
@@ -498,10 +617,17 @@ fn confirmation_page(
|
|||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
let issue_url = format!(
|
||||||
|
"{}/issues/{date}",
|
||||||
|
config.server.public_url.trim_end_matches('/')
|
||||||
|
);
|
||||||
let body = page_html(
|
let body = page_html(
|
||||||
message,
|
message,
|
||||||
None,
|
None,
|
||||||
Some(&format!("<p><small>Change it: {choices}</small></p>")),
|
Some(&format!(
|
||||||
|
"<p><small>Change it: {choices}</small></p><p><small><a href=\"{}\">Open this issue on the site</a></small></p>",
|
||||||
|
escape_attr(&issue_url)
|
||||||
|
)),
|
||||||
);
|
);
|
||||||
(
|
(
|
||||||
status,
|
status,
|
||||||
@@ -681,8 +807,8 @@ mod tests {
|
|||||||
assert!(safe_join(dir, "../../etc/passwd").is_none());
|
assert!(safe_join(dir, "../../etc/passwd").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn the_confirmation_page_is_tiny_and_self_contained() {
|
async fn the_confirmation_page_is_tiny_and_self_contained() {
|
||||||
let html = page_html(
|
let html = page_html(
|
||||||
"Recorded: Loved it — thanks.",
|
"Recorded: Loved it — thanks.",
|
||||||
Some("2026-08-15 · article 42"),
|
Some("2026-08-15 · article 42"),
|
||||||
@@ -697,6 +823,25 @@ mod tests {
|
|||||||
StatusCode::FORBIDDEN
|
StatusCode::FORBIDDEN
|
||||||
);
|
);
|
||||||
assert!(page_html("<b>x</b>", None, None).contains("<b>"));
|
assert!(page_html("<b>x</b>", None, None).contains("<b>"));
|
||||||
|
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.server.hmac_secret = Some("test-secret".into());
|
||||||
|
config.server.public_url = "https://daily.example".into();
|
||||||
|
let response =
|
||||||
|
confirmation_page(StatusCode::OK, "Recorded", &config, date(), 42, Vote::Loved);
|
||||||
|
let body = String::from_utf8(
|
||||||
|
axum::body::to_bytes(response.into_body(), 4096)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.to_vec(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
body.contains(
|
||||||
|
"href=\"https://daily.example/issues/2026-08-15\">Open this issue on the site"
|
||||||
|
),
|
||||||
|
"{body}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
@@ -734,10 +879,7 @@ mod tests {
|
|||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
let addr = listener.local_addr().unwrap();
|
let addr = listener.local_addr().unwrap();
|
||||||
let app = router(AppState {
|
let app = router(AppState::new(db.clone(), config, None));
|
||||||
db: db.clone(),
|
|
||||||
config,
|
|
||||||
});
|
|
||||||
let handle = tokio::spawn(async move {
|
let handle = tokio::spawn(async move {
|
||||||
axum::serve(listener, app).await.unwrap();
|
axum::serve(listener, app).await.unwrap();
|
||||||
});
|
});
|
||||||
@@ -848,6 +990,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
Some(&format!("{{\"selected\":{n}}}")),
|
Some(&format!("{{\"selected\":{n}}}")),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -705,6 +705,7 @@ impl Vote {
|
|||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct RatingEvent {
|
pub struct RatingEvent {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
|
pub user_id: Option<i64>,
|
||||||
pub article_id: ArticleId,
|
pub article_id: ArticleId,
|
||||||
pub issue_date: Option<Date>,
|
pub issue_date: Option<Date>,
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
@@ -733,6 +734,7 @@ pub struct Facets {
|
|||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct RatedArticle {
|
pub struct RatedArticle {
|
||||||
pub article_id: ArticleId,
|
pub article_id: ArticleId,
|
||||||
|
pub user_id: Option<i64>,
|
||||||
pub issue_date: Option<Date>,
|
pub issue_date: Option<Date>,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub feed_title: String,
|
pub feed_title: String,
|
||||||
|
|||||||
@@ -0,0 +1,932 @@
|
|||||||
|
//! Dashboard: articles list and article detail (dashboard plan §9.3).
|
||||||
|
//!
|
||||||
|
//! The list joins every article to its best entry, its **latest**
|
||||||
|
//! `candidate_runs` row (via `idx_candidate_runs_article_run`), both
|
||||||
|
//! assessments, the current explicit rating and the latest publication. The
|
||||||
|
//! detail page shows everything the system knows about one article, in the
|
||||||
|
//! order of §9.3, and wraps `telemetry::render_explain` verbatim in `<pre>`.
|
||||||
|
|
||||||
|
use askama::Template;
|
||||||
|
use axum::Router;
|
||||||
|
use axum::extract::{Extension, Path, Query, State};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::routing::get;
|
||||||
|
use axum_login::tower_sessions::Session;
|
||||||
|
use jiff::civil::Date;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sqlx::Row as _;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
Bind, Pager, REASONS, STAGES, SignalsView, admitted_by_parts, allow_listed, bind_all, db_err,
|
||||||
|
dynamic_query, fmt_opt, fmt_opt_int, fmt_stored_time, like_pattern, non_empty, page_number,
|
||||||
|
widget_label,
|
||||||
|
};
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::curate::telemetry;
|
||||||
|
use crate::curate::triage::{PROVIDER_REJECTED, TRIAGE_KINDS};
|
||||||
|
use crate::db::Db;
|
||||||
|
use crate::server::AppState;
|
||||||
|
use crate::types::ArticleId;
|
||||||
|
use crate::web::rate::RatingWidget;
|
||||||
|
use crate::web::session::{AuthSession, Viewer};
|
||||||
|
use crate::web::{Html, Page, Pagination, WebError, take_flash};
|
||||||
|
|
||||||
|
const ARTICLES_PER_PAGE: u32 = 50;
|
||||||
|
|
||||||
|
/// Routes contributed by this page group (merged by `dashboard::router`).
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/dashboard/articles", get(list))
|
||||||
|
.route("/dashboard/articles/{id}", get(detail))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Articles list
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
pub struct ArticlesQuery {
|
||||||
|
pub q: Option<String>,
|
||||||
|
pub feed: Option<String>,
|
||||||
|
pub stage: Option<String>,
|
||||||
|
pub reason: Option<String>,
|
||||||
|
pub rated: Option<String>,
|
||||||
|
pub published: Option<String>,
|
||||||
|
pub from: Option<String>,
|
||||||
|
pub to: Option<String>,
|
||||||
|
pub kind: Option<String>,
|
||||||
|
pub sort: Option<String>,
|
||||||
|
pub page: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const RATED: [&str; 6] = ["any", "loved", "good", "down", "cleared", "none"];
|
||||||
|
const PUBLISHED: [&str; 2] = ["yes", "no"];
|
||||||
|
|
||||||
|
const ARTICLE_SORTS: [(&str, &str); 7] = [
|
||||||
|
("first_seen", "x.first_seen DESC, x.id DESC"),
|
||||||
|
("utility", "x.utility DESC, x.id DESC"),
|
||||||
|
("quality", "x.quality DESC, x.id DESC"),
|
||||||
|
("fit", "x.fit DESC, x.id DESC"),
|
||||||
|
("triage", "x.triage DESC, x.id DESC"),
|
||||||
|
("words", "x.word_count DESC, x.id DESC"),
|
||||||
|
("title", "x.title COLLATE NOCASE ASC, x.id ASC"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Validated article filters; see [`CandidateFilters`](super::runs::CandidateFilters).
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct ArticleFilters {
|
||||||
|
pub q: Option<String>,
|
||||||
|
pub feed: Option<i64>,
|
||||||
|
pub stage: Option<String>,
|
||||||
|
pub reason: Option<String>,
|
||||||
|
pub rated: Option<String>,
|
||||||
|
pub published: Option<String>,
|
||||||
|
pub from: Option<String>,
|
||||||
|
pub to: Option<String>,
|
||||||
|
pub kind: Option<String>,
|
||||||
|
pub sort: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ArticleFilters {
|
||||||
|
pub fn from_query(query: &ArticlesQuery) -> Self {
|
||||||
|
let owned = |value: Option<&str>| value.map(str::to_string);
|
||||||
|
let date = |value: Option<&str>| {
|
||||||
|
non_empty(value)
|
||||||
|
.and_then(|value| value.parse::<Date>().ok())
|
||||||
|
.map(|date| date.to_string())
|
||||||
|
};
|
||||||
|
let mut kinds: Vec<&str> = TRIAGE_KINDS.to_vec();
|
||||||
|
kinds.push(PROVIDER_REJECTED);
|
||||||
|
Self {
|
||||||
|
q: owned(non_empty(query.q.as_deref())),
|
||||||
|
feed: non_empty(query.feed.as_deref()).and_then(|feed| feed.parse::<i64>().ok()),
|
||||||
|
stage: owned(allow_listed(query.stage.as_deref(), &STAGES)),
|
||||||
|
reason: owned(allow_listed(query.reason.as_deref(), &REASONS)),
|
||||||
|
rated: owned(allow_listed(query.rated.as_deref(), &RATED)),
|
||||||
|
published: owned(allow_listed(query.published.as_deref(), &PUBLISHED)),
|
||||||
|
from: date(query.from.as_deref()),
|
||||||
|
to: date(query.to.as_deref()),
|
||||||
|
kind: owned(allow_listed(query.kind.as_deref(), &kinds)),
|
||||||
|
sort: ARTICLE_SORTS
|
||||||
|
.iter()
|
||||||
|
.find(|(name, _)| Some(*name) == query.sort.as_deref())
|
||||||
|
.map(|(name, _)| *name)
|
||||||
|
.unwrap_or(ARTICLE_SORTS[0].0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn order_by(&self) -> &'static str {
|
||||||
|
ARTICLE_SORTS
|
||||||
|
.iter()
|
||||||
|
.find(|(name, _)| *name == self.sort)
|
||||||
|
.map(|(_, sql)| *sql)
|
||||||
|
.unwrap_or(ARTICLE_SORTS[0].1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn where_clauses(&self) -> (String, Vec<Bind>) {
|
||||||
|
let mut sql = String::new();
|
||||||
|
let mut binds = Vec::new();
|
||||||
|
if let Some(q) = &self.q {
|
||||||
|
sql.push_str(" AND (x.title LIKE ? ESCAPE '\\' OR x.canonical_url LIKE ? ESCAPE '\\')");
|
||||||
|
binds.push(Bind::Text(like_pattern(q)));
|
||||||
|
binds.push(Bind::Text(like_pattern(q)));
|
||||||
|
}
|
||||||
|
if let Some(feed) = self.feed {
|
||||||
|
sql.push_str(" AND x.feed_id = ?");
|
||||||
|
binds.push(Bind::Int(feed));
|
||||||
|
}
|
||||||
|
if let Some(stage) = &self.stage {
|
||||||
|
sql.push_str(" AND x.stage = ?");
|
||||||
|
binds.push(Bind::Text(stage.clone()));
|
||||||
|
}
|
||||||
|
if let Some(reason) = &self.reason {
|
||||||
|
sql.push_str(" AND x.excluded_reason = ?");
|
||||||
|
binds.push(Bind::Text(reason.clone()));
|
||||||
|
}
|
||||||
|
match self.rated.as_deref() {
|
||||||
|
Some("any") => sql.push_str(" AND x.rating IS NOT NULL AND x.rating != 'cleared'"),
|
||||||
|
Some("none") => sql.push_str(" AND x.rating IS NULL"),
|
||||||
|
Some(label @ ("loved" | "good" | "cleared")) => {
|
||||||
|
sql.push_str(" AND x.rating = ?");
|
||||||
|
binds.push(Bind::Text(label.to_string()));
|
||||||
|
}
|
||||||
|
Some("down") => sql.push_str(" AND x.rating = 'not_for_me'"),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
match self.published.as_deref() {
|
||||||
|
Some("yes") => sql.push_str(" AND x.published IS NOT NULL"),
|
||||||
|
Some("no") => sql.push_str(" AND x.published IS NULL"),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if let Some(from) = &self.from {
|
||||||
|
sql.push_str(" AND substr(x.first_seen, 1, 10) >= ?");
|
||||||
|
binds.push(Bind::Text(from.clone()));
|
||||||
|
}
|
||||||
|
if let Some(to) = &self.to {
|
||||||
|
sql.push_str(" AND substr(x.first_seen, 1, 10) <= ?");
|
||||||
|
binds.push(Bind::Text(to.clone()));
|
||||||
|
}
|
||||||
|
if let Some(kind) = &self.kind {
|
||||||
|
sql.push_str(" AND x.triage_kind = ?");
|
||||||
|
binds.push(Bind::Text(kind.clone()));
|
||||||
|
}
|
||||||
|
(sql, binds)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn params(&self) -> Vec<(&'static str, Option<String>)> {
|
||||||
|
vec![
|
||||||
|
("q", self.q.clone()),
|
||||||
|
("feed", self.feed.map(|feed| feed.to_string())),
|
||||||
|
("stage", self.stage.clone()),
|
||||||
|
("reason", self.reason.clone()),
|
||||||
|
("rated", self.rated.clone()),
|
||||||
|
("published", self.published.clone()),
|
||||||
|
("from", self.from.clone()),
|
||||||
|
("to", self.to.clone()),
|
||||||
|
("kind", self.kind.clone()),
|
||||||
|
(
|
||||||
|
"sort",
|
||||||
|
(self.sort != ARTICLE_SORTS[0].0).then(|| self.sort.to_string()),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row of the articles table.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ArticleListRow {
|
||||||
|
pub id: ArticleId,
|
||||||
|
pub href: String,
|
||||||
|
pub title: String,
|
||||||
|
pub feed_id: Option<i64>,
|
||||||
|
pub feed: String,
|
||||||
|
pub first_seen: String,
|
||||||
|
pub words: i64,
|
||||||
|
pub stage: Option<String>,
|
||||||
|
pub reason: Option<String>,
|
||||||
|
pub run_id: Option<i64>,
|
||||||
|
pub run_date: Option<String>,
|
||||||
|
pub utility: String,
|
||||||
|
pub triage: String,
|
||||||
|
pub quality: String,
|
||||||
|
pub fit: String,
|
||||||
|
pub rating: Option<String>,
|
||||||
|
pub rating_class: &'static str,
|
||||||
|
pub published: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ARTICLE_INNER: &str =
|
||||||
|
"SELECT a.id, COALESCE(a.title, '') AS title, a.canonical_url, a.first_seen,
|
||||||
|
a.word_count, e.feed_id, COALESCE(e.feed_title, '') AS feed_title,
|
||||||
|
l.run_id, l.stage, l.excluded_reason, l.utility, r.date AS run_date,
|
||||||
|
t.score AS triage, t.kind AS triage_kind, d.score AS quality, d.fit AS fit,
|
||||||
|
(SELECT re.label FROM rating_events re
|
||||||
|
WHERE re.article_id = a.id AND re.kind = 'explicit'
|
||||||
|
ORDER BY re.event_at DESC, re.id DESC LIMIT 1) AS rating,
|
||||||
|
(SELECT ia.issue_date FROM issue_articles ia
|
||||||
|
WHERE ia.article_id = a.id ORDER BY ia.issue_date DESC LIMIT 1) AS published
|
||||||
|
FROM articles a
|
||||||
|
LEFT JOIN entries e ON e.id = a.best_entry_id
|
||||||
|
LEFT JOIN candidate_runs l ON l.article_id = a.id
|
||||||
|
AND l.run_id = (SELECT MAX(c2.run_id) FROM candidate_runs c2
|
||||||
|
WHERE c2.article_id = a.id)
|
||||||
|
LEFT JOIN runs r ON r.id = l.run_id
|
||||||
|
LEFT JOIN article_assessments t ON t.article_id = a.id AND t.stage = 'triage'
|
||||||
|
LEFT JOIN article_assessments d ON d.article_id = a.id AND d.stage = 'deep'";
|
||||||
|
|
||||||
|
pub async fn list_articles(
|
||||||
|
db: &Db,
|
||||||
|
config: &Config,
|
||||||
|
filters: &ArticleFilters,
|
||||||
|
page: u32,
|
||||||
|
) -> Result<(Vec<ArticleListRow>, Pagination), sqlx::Error> {
|
||||||
|
let (clauses, binds) = filters.where_clauses();
|
||||||
|
let count_sql = format!("SELECT COUNT(*) FROM ({ARTICLE_INNER}) x WHERE 1 = 1{clauses}");
|
||||||
|
let total: i64 = bind_all(dynamic_query(count_sql), &binds)
|
||||||
|
.fetch_one(db.pool())
|
||||||
|
.await?
|
||||||
|
.get(0);
|
||||||
|
let pagination = Pagination {
|
||||||
|
page,
|
||||||
|
per_page: ARTICLES_PER_PAGE,
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
let select_sql = format!(
|
||||||
|
"SELECT * FROM ({ARTICLE_INNER}) x WHERE 1 = 1{clauses} ORDER BY {} LIMIT ? OFFSET ?",
|
||||||
|
filters.order_by()
|
||||||
|
);
|
||||||
|
let rows = bind_all(dynamic_query(select_sql), &binds)
|
||||||
|
.bind(i64::from(ARTICLES_PER_PAGE))
|
||||||
|
.bind(pagination.offset())
|
||||||
|
.fetch_all(db.pool())
|
||||||
|
.await?;
|
||||||
|
let rows = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| {
|
||||||
|
let id: ArticleId = row.get("id");
|
||||||
|
let rating: Option<String> = row.get("rating");
|
||||||
|
ArticleListRow {
|
||||||
|
id,
|
||||||
|
href: format!("/dashboard/articles/{id}"),
|
||||||
|
title: row.get("title"),
|
||||||
|
feed_id: row.get("feed_id"),
|
||||||
|
feed: row.get("feed_title"),
|
||||||
|
first_seen: fmt_stored_time(Some(&row.get::<String, _>("first_seen")), config),
|
||||||
|
words: row.get("word_count"),
|
||||||
|
stage: row.get("stage"),
|
||||||
|
reason: row.get("excluded_reason"),
|
||||||
|
run_id: row.get("run_id"),
|
||||||
|
run_date: row.get("run_date"),
|
||||||
|
utility: fmt_opt(row.get("utility"), 1),
|
||||||
|
triage: fmt_opt(row.get("triage"), 1),
|
||||||
|
quality: fmt_opt(row.get("quality"), 1),
|
||||||
|
fit: fmt_opt(row.get("fit"), 1),
|
||||||
|
rating_class: widget_label(rating.as_deref()),
|
||||||
|
rating,
|
||||||
|
published: row.get("published"),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok((rows, pagination))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "dashboard/articles.html")]
|
||||||
|
struct ArticlesTemplate {
|
||||||
|
page: Page,
|
||||||
|
articles: Vec<ArticleListRow>,
|
||||||
|
filters: ArticleFilters,
|
||||||
|
feed_value: String,
|
||||||
|
stages: Vec<&'static str>,
|
||||||
|
reasons: Vec<&'static str>,
|
||||||
|
rated: Vec<&'static str>,
|
||||||
|
kinds: Vec<&'static str>,
|
||||||
|
sorts: Vec<&'static str>,
|
||||||
|
pager: Pager,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthSession,
|
||||||
|
Extension(session): Extension<Session>,
|
||||||
|
Query(query): Query<ArticlesQuery>,
|
||||||
|
) -> Result<Response, WebError> {
|
||||||
|
let viewer = auth.user().await.map(Viewer::from);
|
||||||
|
let config = state.config();
|
||||||
|
let filters = ArticleFilters::from_query(&query);
|
||||||
|
let page_no = page_number(query.page);
|
||||||
|
let (articles, pagination) = list_articles(&state.db, &config, &filters, page_no)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
let pager = Pager::new(pagination, "/dashboard/articles", &filters.params());
|
||||||
|
let mut kinds: Vec<&'static str> = TRIAGE_KINDS.to_vec();
|
||||||
|
kinds.push(PROVIDER_REJECTED);
|
||||||
|
let mut page = Page::new("Articles", viewer, "articles");
|
||||||
|
page.flash = take_flash(&session).await?;
|
||||||
|
Ok(Html(ArticlesTemplate {
|
||||||
|
page,
|
||||||
|
articles,
|
||||||
|
feed_value: filters
|
||||||
|
.feed
|
||||||
|
.map(|feed| feed.to_string())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
filters,
|
||||||
|
stages: STAGES.to_vec(),
|
||||||
|
reasons: REASONS.to_vec(),
|
||||||
|
rated: RATED.to_vec(),
|
||||||
|
kinds,
|
||||||
|
sorts: ARTICLE_SORTS.iter().map(|(name, _)| *name).collect(),
|
||||||
|
pager,
|
||||||
|
})
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Article detail
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Facet {
|
||||||
|
pub name: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One `article_assessments` row.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AssessmentView {
|
||||||
|
pub stage: String,
|
||||||
|
pub rejected: bool,
|
||||||
|
pub model: String,
|
||||||
|
pub prompt_version: i64,
|
||||||
|
pub profile_version: String,
|
||||||
|
pub score: String,
|
||||||
|
pub fit: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub rationale: String,
|
||||||
|
pub category: String,
|
||||||
|
pub paywalled: bool,
|
||||||
|
pub assessed_at: String,
|
||||||
|
pub facets: Vec<Facet>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One `candidate_runs` row of the article, newest first.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct HistoryRow {
|
||||||
|
pub run_id: i64,
|
||||||
|
pub run_href: String,
|
||||||
|
pub date: String,
|
||||||
|
pub status: String,
|
||||||
|
pub stage: String,
|
||||||
|
pub reason: Option<String>,
|
||||||
|
pub admitted_first: Option<String>,
|
||||||
|
pub admitted_rest: String,
|
||||||
|
pub utility: String,
|
||||||
|
pub rank: String,
|
||||||
|
pub cluster: String,
|
||||||
|
pub editor_why: Option<String>,
|
||||||
|
pub signals: SignalsView,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct EmbeddingView {
|
||||||
|
pub model: String,
|
||||||
|
pub dimension: i64,
|
||||||
|
pub created_at: String,
|
||||||
|
pub input_hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RatingEventView {
|
||||||
|
pub id: i64,
|
||||||
|
pub event_at: String,
|
||||||
|
pub issue_date: Option<String>,
|
||||||
|
pub kind: String,
|
||||||
|
pub source: String,
|
||||||
|
pub label: String,
|
||||||
|
pub label_class: &'static str,
|
||||||
|
pub value: String,
|
||||||
|
pub note: Option<String>,
|
||||||
|
pub user: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct InIssue {
|
||||||
|
date: String,
|
||||||
|
href: String,
|
||||||
|
section: String,
|
||||||
|
position: i64,
|
||||||
|
is_lead: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct SourceLine {
|
||||||
|
kind: String,
|
||||||
|
feed: String,
|
||||||
|
category: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct SocialLine {
|
||||||
|
source: String,
|
||||||
|
score: i64,
|
||||||
|
comments: i64,
|
||||||
|
url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "dashboard/article.html")]
|
||||||
|
struct ArticleTemplate {
|
||||||
|
page: Page,
|
||||||
|
id: ArticleId,
|
||||||
|
title: String,
|
||||||
|
url: String,
|
||||||
|
canonical_url: String,
|
||||||
|
feed: String,
|
||||||
|
feed_id: i64,
|
||||||
|
category: Option<String>,
|
||||||
|
author: Option<String>,
|
||||||
|
published_at: String,
|
||||||
|
first_seen: String,
|
||||||
|
words: i64,
|
||||||
|
excerpt_only: bool,
|
||||||
|
image_count: i64,
|
||||||
|
sources: Vec<SourceLine>,
|
||||||
|
social: Vec<SocialLine>,
|
||||||
|
in_issues: Vec<InIssue>,
|
||||||
|
rating: Option<String>,
|
||||||
|
rating_class: &'static str,
|
||||||
|
widget: RatingWidget,
|
||||||
|
explain: Option<String>,
|
||||||
|
assessments: Vec<AssessmentView>,
|
||||||
|
history: Vec<HistoryRow>,
|
||||||
|
latest_signals: Option<SignalsView>,
|
||||||
|
embedding: Option<EmbeddingView>,
|
||||||
|
events: Vec<RatingEventView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn assessments(
|
||||||
|
db: &Db,
|
||||||
|
article_id: ArticleId,
|
||||||
|
config: &Config,
|
||||||
|
) -> Result<Vec<AssessmentView>, sqlx::Error> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT stage, model, prompt_version, profile_version, score, fit, kind, facets_json,
|
||||||
|
rationale, category, paywalled_guess, assessed_at
|
||||||
|
FROM article_assessments WHERE article_id = ? ORDER BY stage DESC",
|
||||||
|
)
|
||||||
|
.bind(article_id)
|
||||||
|
.fetch_all(db.pool())
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| {
|
||||||
|
let kind: Option<String> = row.get("kind");
|
||||||
|
let facets = row
|
||||||
|
.get::<Option<String>, _>("facets_json")
|
||||||
|
.and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
|
||||||
|
.and_then(|value| value.as_object().cloned())
|
||||||
|
.map(|object| {
|
||||||
|
object
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, value)| Facet {
|
||||||
|
name,
|
||||||
|
value: match value {
|
||||||
|
serde_json::Value::String(text) => text,
|
||||||
|
serde_json::Value::Array(items) => items
|
||||||
|
.iter()
|
||||||
|
.map(|item| {
|
||||||
|
item.as_str()
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or_else(|| item.to_string())
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", "),
|
||||||
|
other => other.to_string(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
AssessmentView {
|
||||||
|
stage: row.get("stage"),
|
||||||
|
rejected: kind.as_deref() == Some(PROVIDER_REJECTED),
|
||||||
|
model: row.get("model"),
|
||||||
|
prompt_version: row.get("prompt_version"),
|
||||||
|
profile_version: fmt_opt_int(row.get("profile_version")),
|
||||||
|
score: fmt_opt(row.get("score"), 1),
|
||||||
|
fit: fmt_opt(row.get("fit"), 1),
|
||||||
|
kind: kind.unwrap_or_else(|| "—".into()),
|
||||||
|
rationale: row
|
||||||
|
.get::<Option<String>, _>("rationale")
|
||||||
|
.unwrap_or_default(),
|
||||||
|
category: row
|
||||||
|
.get::<Option<String>, _>("category")
|
||||||
|
.unwrap_or_else(|| "—".into()),
|
||||||
|
paywalled: row.get::<i64, _>("paywalled_guess") != 0,
|
||||||
|
assessed_at: fmt_stored_time(Some(&row.get::<String, _>("assessed_at")), config),
|
||||||
|
facets,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_history(db: &Db, article_id: ArticleId) -> Result<Vec<HistoryRow>, sqlx::Error> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT cr.run_id, r.date, r.status, cr.stage, cr.excluded_reason, cr.admitted_by,
|
||||||
|
cr.signals_json, cr.utility, cr.rank_utility, cr.cluster_id, cr.cluster_rank,
|
||||||
|
cr.editor_why
|
||||||
|
FROM candidate_runs cr JOIN runs r ON r.id = cr.run_id
|
||||||
|
WHERE cr.article_id = ? ORDER BY cr.run_id DESC",
|
||||||
|
)
|
||||||
|
.bind(article_id)
|
||||||
|
.fetch_all(db.pool())
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| {
|
||||||
|
let run_id: i64 = row.get("run_id");
|
||||||
|
let (admitted_first, rest) =
|
||||||
|
admitted_by_parts(row.get::<Option<String>, _>("admitted_by").as_deref());
|
||||||
|
HistoryRow {
|
||||||
|
run_id,
|
||||||
|
run_href: format!("/dashboard/runs/{run_id}"),
|
||||||
|
date: row.get("date"),
|
||||||
|
status: row.get("status"),
|
||||||
|
stage: row.get("stage"),
|
||||||
|
reason: row.get("excluded_reason"),
|
||||||
|
admitted_first,
|
||||||
|
admitted_rest: rest.join(", "),
|
||||||
|
utility: fmt_opt(row.get("utility"), 1),
|
||||||
|
rank: fmt_opt_int(row.get("rank_utility")),
|
||||||
|
cluster: match (
|
||||||
|
row.get::<Option<i64>, _>("cluster_id"),
|
||||||
|
row.get::<Option<i64>, _>("cluster_rank"),
|
||||||
|
) {
|
||||||
|
(Some(id), Some(rank)) => format!("{id} · {rank}"),
|
||||||
|
(Some(id), None) => id.to_string(),
|
||||||
|
_ => "—".into(),
|
||||||
|
},
|
||||||
|
editor_why: row.get("editor_why"),
|
||||||
|
signals: SignalsView::from_json(&row.get::<String, _>("signals_json")),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn rating_events(
|
||||||
|
db: &Db,
|
||||||
|
article_id: ArticleId,
|
||||||
|
config: &Config,
|
||||||
|
) -> Result<Vec<RatingEventView>, sqlx::Error> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT re.id, re.issue_date, re.kind, re.source, re.label, re.value, re.note,
|
||||||
|
re.event_at, u.username
|
||||||
|
FROM rating_events re LEFT JOIN users u ON u.id = re.user_id
|
||||||
|
WHERE re.article_id = ? ORDER BY re.event_at DESC, re.id DESC",
|
||||||
|
)
|
||||||
|
.bind(article_id)
|
||||||
|
.fetch_all(db.pool())
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| {
|
||||||
|
let label: String = row.get("label");
|
||||||
|
RatingEventView {
|
||||||
|
id: row.get("id"),
|
||||||
|
event_at: fmt_stored_time(Some(&row.get::<String, _>("event_at")), config),
|
||||||
|
issue_date: row.get("issue_date"),
|
||||||
|
kind: row.get("kind"),
|
||||||
|
source: row.get("source"),
|
||||||
|
label_class: widget_label(Some(&label)),
|
||||||
|
label,
|
||||||
|
value: format!("{:.2}", row.get::<f64, _>("value")),
|
||||||
|
note: row.get("note"),
|
||||||
|
user: row.get("username"),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn embedding(
|
||||||
|
db: &Db,
|
||||||
|
article_id: ArticleId,
|
||||||
|
config: &Config,
|
||||||
|
) -> Result<Option<EmbeddingView>, sqlx::Error> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT model, dimension, created_at, input_hash FROM article_embeddings
|
||||||
|
WHERE article_id = ?",
|
||||||
|
)
|
||||||
|
.bind(article_id)
|
||||||
|
.fetch_optional(db.pool())
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(|row| EmbeddingView {
|
||||||
|
model: row.get("model"),
|
||||||
|
dimension: row.get("dimension"),
|
||||||
|
created_at: fmt_stored_time(Some(&row.get::<String, _>("created_at")), config),
|
||||||
|
input_hash: row.get("input_hash"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn detail(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthSession,
|
||||||
|
Extension(session): Extension<Session>,
|
||||||
|
Path(id): Path<ArticleId>,
|
||||||
|
) -> Result<Response, WebError> {
|
||||||
|
let viewer = auth.user().await.map(Viewer::from);
|
||||||
|
let config = state.config();
|
||||||
|
let db = &state.db;
|
||||||
|
let Some(article) = db.get_article(id).await? else {
|
||||||
|
return Err(WebError::NotFound);
|
||||||
|
};
|
||||||
|
|
||||||
|
let in_issues = sqlx::query(
|
||||||
|
"SELECT issue_date, section, position, is_lead FROM issue_articles
|
||||||
|
WHERE article_id = ? ORDER BY issue_date DESC",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_all(db.pool())
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?
|
||||||
|
.iter()
|
||||||
|
.map(|row| {
|
||||||
|
let date: String = row.get("issue_date");
|
||||||
|
InIssue {
|
||||||
|
href: format!("/issues/{date}/articles/{id}"),
|
||||||
|
date,
|
||||||
|
section: row.get("section"),
|
||||||
|
position: row.get("position"),
|
||||||
|
is_lead: row.get("is_lead"),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let events = rating_events(db, id, &config).await.map_err(db_err)?;
|
||||||
|
let rating = events
|
||||||
|
.iter()
|
||||||
|
.find(|event| event.kind == "explicit")
|
||||||
|
.map(|event| event.label.clone());
|
||||||
|
let widget = RatingWidget {
|
||||||
|
article_id: id,
|
||||||
|
issue_date: in_issues
|
||||||
|
.first()
|
||||||
|
.map(|issue| issue.date.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
next: format!("/dashboard/articles/{id}"),
|
||||||
|
current: widget_label(rating.as_deref()).to_string(),
|
||||||
|
show_note: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let history = run_history(db, id).await.map_err(db_err)?;
|
||||||
|
let explain = match history.first() {
|
||||||
|
Some(latest) => match telemetry::explain_row(db, latest.run_id, id)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?
|
||||||
|
{
|
||||||
|
Some(row) => Some(telemetry::render_explain(db, &row).await.map_err(db_err)?),
|
||||||
|
None => None,
|
||||||
|
},
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let latest_signals = history
|
||||||
|
.first()
|
||||||
|
.map(|latest| latest.signals.clone())
|
||||||
|
.filter(|signals| !signals.empty);
|
||||||
|
let assessments = assessments(db, id, &config).await.map_err(db_err)?;
|
||||||
|
let embedding = embedding(db, id, &config).await.map_err(db_err)?;
|
||||||
|
|
||||||
|
let mut page = Page::new(article.title.clone(), viewer, "articles");
|
||||||
|
page.flash = take_flash(&session).await?;
|
||||||
|
Ok(Html(ArticleTemplate {
|
||||||
|
page,
|
||||||
|
id,
|
||||||
|
title: article.title.clone(),
|
||||||
|
url: article.url.clone(),
|
||||||
|
canonical_url: article.canonical_url.clone(),
|
||||||
|
feed: article.feed_title.clone(),
|
||||||
|
feed_id: article.feed_id,
|
||||||
|
category: article.category.clone(),
|
||||||
|
author: article.author.clone(),
|
||||||
|
published_at: article
|
||||||
|
.published_at
|
||||||
|
.map(|at| crate::web::format_time(at, &config))
|
||||||
|
.unwrap_or_else(|| "—".into()),
|
||||||
|
first_seen: crate::web::format_time(article.first_seen, &config),
|
||||||
|
words: article.word_count,
|
||||||
|
excerpt_only: article.excerpt_only,
|
||||||
|
image_count: article.image_count,
|
||||||
|
sources: article
|
||||||
|
.sources
|
||||||
|
.iter()
|
||||||
|
.map(|source| SourceLine {
|
||||||
|
kind: format!("{:?}", source.kind),
|
||||||
|
feed: source.feed_title.clone(),
|
||||||
|
category: source.category.clone(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
social: article
|
||||||
|
.social
|
||||||
|
.iter()
|
||||||
|
.map(|social| SocialLine {
|
||||||
|
source: format!("{:?}", social.source).to_lowercase(),
|
||||||
|
score: social.score,
|
||||||
|
comments: social.num_comments,
|
||||||
|
url: social.item_url.clone(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
in_issues,
|
||||||
|
rating_class: widget_label(rating.as_deref()),
|
||||||
|
rating,
|
||||||
|
widget,
|
||||||
|
explain,
|
||||||
|
assessments,
|
||||||
|
history,
|
||||||
|
latest_signals,
|
||||||
|
embedding,
|
||||||
|
events,
|
||||||
|
})
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::web::dashboard::tests::{
|
||||||
|
app_with_users, assert_admin_only, get, login_cookie, seed,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn query(f: impl FnOnce(&mut ArticlesQuery)) -> ArticleFilters {
|
||||||
|
let mut query = ArticlesQuery::default();
|
||||||
|
f(&mut query);
|
||||||
|
ArticleFilters::from_query(&query)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn articles_list_filters_and_sorts_are_allow_listed() {
|
||||||
|
let seed = seed().await;
|
||||||
|
let db = &seed.db;
|
||||||
|
let config = Config::default();
|
||||||
|
|
||||||
|
let all = query(|_| {});
|
||||||
|
assert_eq!(all.sort, "first_seen");
|
||||||
|
let (rows, pagination) = list_articles(db, &config, &all, 1).await.unwrap();
|
||||||
|
assert_eq!(pagination.total, 8);
|
||||||
|
assert_eq!(rows.len(), 8);
|
||||||
|
let one = rows.iter().find(|row| row.id == 1).unwrap();
|
||||||
|
assert_eq!(one.stage.as_deref(), Some("selected"), "latest run's row");
|
||||||
|
assert_eq!(one.run_id, Some(seed.run_id));
|
||||||
|
assert_eq!(one.rating.as_deref(), Some("loved"));
|
||||||
|
assert_eq!(one.published.as_deref(), Some("2026-09-02"));
|
||||||
|
assert_eq!(one.quality, "8.8");
|
||||||
|
|
||||||
|
let bogus = query(|q| {
|
||||||
|
q.sort = Some("id; DROP TABLE articles".into());
|
||||||
|
q.stage = Some("nope".into());
|
||||||
|
q.rated = Some("' OR 1=1".into());
|
||||||
|
q.from = Some("not a date".into());
|
||||||
|
q.feed = Some("abc".into());
|
||||||
|
});
|
||||||
|
assert_eq!(bogus.sort, "first_seen");
|
||||||
|
assert_eq!(bogus.stage, None);
|
||||||
|
assert_eq!(bogus.rated, None);
|
||||||
|
assert_eq!(bogus.from, None);
|
||||||
|
assert_eq!(bogus.feed, None);
|
||||||
|
let (rows, _) = list_articles(db, &config, &bogus, 1).await.unwrap();
|
||||||
|
assert_eq!(rows.len(), 8, "unknown values fall back, never error");
|
||||||
|
|
||||||
|
let by_feed = query(|q| q.feed = Some("20".into()));
|
||||||
|
let (rows, _) = list_articles(db, &config, &by_feed, 1).await.unwrap();
|
||||||
|
assert_eq!(rows.len(), 4);
|
||||||
|
assert!(rows.iter().all(|row| row.feed == "Beta Weekly"));
|
||||||
|
|
||||||
|
let selected = query(|q| q.stage = Some("selected".into()));
|
||||||
|
let (rows, _) = list_articles(db, &config, &selected, 1).await.unwrap();
|
||||||
|
assert_eq!(rows.len(), 2);
|
||||||
|
|
||||||
|
let reason = query(|q| q.reason = Some("blocked".into()));
|
||||||
|
let (rows, _) = list_articles(db, &config, &reason, 1).await.unwrap();
|
||||||
|
assert_eq!(rows.len(), 1);
|
||||||
|
assert_eq!(rows[0].id, 7);
|
||||||
|
|
||||||
|
let loved = query(|q| q.rated = Some("loved".into()));
|
||||||
|
let (rows, _) = list_articles(db, &config, &loved, 1).await.unwrap();
|
||||||
|
assert_eq!(rows.len(), 1);
|
||||||
|
assert_eq!(rows[0].id, 1);
|
||||||
|
let good = query(|q| q.rated = Some("good".into()));
|
||||||
|
let (rows, _) = list_articles(db, &config, &good, 1).await.unwrap();
|
||||||
|
assert!(rows.is_empty(), "superseded events do not count");
|
||||||
|
let unrated = query(|q| q.rated = Some("none".into()));
|
||||||
|
let (rows, _) = list_articles(db, &config, &unrated, 1).await.unwrap();
|
||||||
|
assert_eq!(rows.len(), 7);
|
||||||
|
|
||||||
|
let published = query(|q| q.published = Some("yes".into()));
|
||||||
|
let (rows, _) = list_articles(db, &config, &published, 1).await.unwrap();
|
||||||
|
assert_eq!(rows.len(), 2);
|
||||||
|
|
||||||
|
let window = query(|q| {
|
||||||
|
q.from = Some("2026-09-02".into());
|
||||||
|
q.to = Some("2026-09-02".into());
|
||||||
|
});
|
||||||
|
let (rows, _) = list_articles(db, &config, &window, 1).await.unwrap();
|
||||||
|
assert_eq!(rows.len(), 3, "ids 1, 4, 7 were first seen on the 2nd");
|
||||||
|
|
||||||
|
let kind = query(|q| q.kind = Some("provider_rejected".into()));
|
||||||
|
let (rows, _) = list_articles(db, &config, &kind, 1).await.unwrap();
|
||||||
|
assert_eq!(rows.len(), 1);
|
||||||
|
assert_eq!(rows[0].id, 3);
|
||||||
|
|
||||||
|
let search = query(|q| q.q = Some("example.com/8".into()));
|
||||||
|
let (rows, _) = list_articles(db, &config, &search, 1).await.unwrap();
|
||||||
|
assert_eq!(rows.len(), 1);
|
||||||
|
assert_eq!(rows[0].id, 8);
|
||||||
|
|
||||||
|
let by_words = query(|q| q.sort = Some("words".into()));
|
||||||
|
let (rows, _) = list_articles(db, &config, &by_words, 1).await.unwrap();
|
||||||
|
assert_eq!(rows[0].id, 8);
|
||||||
|
let by_utility = query(|q| q.sort = Some("utility".into()));
|
||||||
|
let (rows, _) = list_articles(db, &config, &by_utility, 1).await.unwrap();
|
||||||
|
assert_eq!(rows[0].id, 1);
|
||||||
|
assert_eq!(rows[7].utility, "—");
|
||||||
|
let by_title = query(|q| q.sort = Some("title".into()));
|
||||||
|
let (rows, _) = list_articles(db, &config, &by_title, 1).await.unwrap();
|
||||||
|
assert_eq!(rows[0].id, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn article_detail_shows_assessments_run_history_and_rating_events() {
|
||||||
|
let seed = seed().await;
|
||||||
|
let config = Config::default();
|
||||||
|
let views = assessments(&seed.db, 1, &config).await.unwrap();
|
||||||
|
assert_eq!(views.len(), 2);
|
||||||
|
assert_eq!(views[0].stage, "triage");
|
||||||
|
assert_eq!(views[0].score, "7.0");
|
||||||
|
assert_eq!(views[1].stage, "deep");
|
||||||
|
assert_eq!(views[1].fit, "6.0");
|
||||||
|
assert_eq!(views[1].category, "Top Stories");
|
||||||
|
assert_eq!(views[1].facets[0].name, "depth");
|
||||||
|
let rejected = assessments(&seed.db, 3, &config).await.unwrap();
|
||||||
|
assert!(rejected[0].rejected);
|
||||||
|
|
||||||
|
let history = run_history(&seed.db, 1).await.unwrap();
|
||||||
|
assert_eq!(history.len(), 2);
|
||||||
|
assert_eq!(history[0].run_id, seed.run_id);
|
||||||
|
assert_eq!(history[0].stage, "selected");
|
||||||
|
assert_eq!(history[0].admitted_first.as_deref(), Some("triage"));
|
||||||
|
assert_eq!(history[1].run_id, seed.earlier_run_id);
|
||||||
|
assert!(history[1].signals.empty);
|
||||||
|
|
||||||
|
let events = rating_events(&seed.db, 1, &config).await.unwrap();
|
||||||
|
assert_eq!(events.len(), 2);
|
||||||
|
assert_eq!(events[0].label, "loved");
|
||||||
|
assert_eq!(events[1].label, "good");
|
||||||
|
assert_eq!(events[1].note.as_deref(), Some("note good"));
|
||||||
|
|
||||||
|
let app = app_with_users(&seed.db).await;
|
||||||
|
let list = assert_admin_only(&app, "/dashboard/articles").await;
|
||||||
|
assert!(list.contains("Article 1 about prose"), "{list}");
|
||||||
|
assert!(list.contains("/dashboard/articles/1"), "{list}");
|
||||||
|
|
||||||
|
let body = assert_admin_only(&app, "/dashboard/articles/1").await;
|
||||||
|
assert!(body.contains("Careful and first-hand"), "{body}");
|
||||||
|
assert!(body.contains("A specific argument"), "{body}");
|
||||||
|
assert!(body.contains("topic_group"), "{body}");
|
||||||
|
assert!(
|
||||||
|
body.contains(&format!("/dashboard/runs/{}", seed.earlier_run_id)),
|
||||||
|
"{body}"
|
||||||
|
);
|
||||||
|
assert!(body.contains("note good"), "{body}");
|
||||||
|
assert!(body.contains("note loved"), "{body}");
|
||||||
|
assert!(body.contains("voyage-4-lite"), "{body}");
|
||||||
|
assert!(body.contains("abc123"), "{body}");
|
||||||
|
assert!(body.contains("Gaussian Splatting"), "{body}");
|
||||||
|
assert!(
|
||||||
|
body.contains("article 1: Article 1 about prose"),
|
||||||
|
"explain: {body}"
|
||||||
|
);
|
||||||
|
assert!(body.contains("name=\"note\""), "note field: {body}");
|
||||||
|
assert!(
|
||||||
|
body.contains("value=\"loved\" data-label=\"loved\" class=\"active\""),
|
||||||
|
"{body}"
|
||||||
|
);
|
||||||
|
assert!(body.contains("Top Stories"), "{body}");
|
||||||
|
assert!(body.contains("Alpha Blog"), "{body}");
|
||||||
|
|
||||||
|
let rejected = assert_admin_only(&app, "/dashboard/articles/3").await;
|
||||||
|
assert!(rejected.contains("rejected by provider"), "{rejected}");
|
||||||
|
assert!(rejected.contains("Content Exists Risk"), "{rejected}");
|
||||||
|
|
||||||
|
let admin = login_cookie(&app, "admin", "correct horse battery").await;
|
||||||
|
let missing = get(&app, "/dashboard/articles/999", Some(&admin)).await;
|
||||||
|
assert_eq!(missing.status(), axum::http::StatusCode::NOT_FOUND);
|
||||||
|
let filtered = get(
|
||||||
|
&app,
|
||||||
|
"/dashboard/articles?rated=loved&sort=nope&feed=x&page=0",
|
||||||
|
Some(&admin),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(filtered.status(), axum::http::StatusCode::OK);
|
||||||
|
let filtered = crate::web::dashboard::tests::response_text(filtered).await;
|
||||||
|
assert!(filtered.contains("Article 1 about prose"), "{filtered}");
|
||||||
|
assert!(!filtered.contains("Article 2 about graphs"), "{filtered}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,707 @@
|
|||||||
|
//! Dashboard: the Jobs pages (`/dashboard/jobs`, dashboard plan §14.4).
|
||||||
|
//!
|
||||||
|
//! The catalogue as cards, the `jobs` table, `POST /dashboard/jobs/{name}`
|
||||||
|
//! (insert `requested`, ask the runner to start the unit) and the job page
|
||||||
|
//! with the live unit status and the journal tail. The server never runs the
|
||||||
|
//! pipeline in-process: `job run` inside the unit does the work (§14.2).
|
||||||
|
|
||||||
|
use askama::Template;
|
||||||
|
use axum::Router;
|
||||||
|
use axum::body::Bytes;
|
||||||
|
use axum::extract::{Extension, Path, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Redirect, Response};
|
||||||
|
use axum::routing::get;
|
||||||
|
use axum_login::tower_sessions::Session;
|
||||||
|
use jiff::Timestamp;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::jobs::{self, EXITED_BEFORE_START, Job, JobRow};
|
||||||
|
use crate::server::AppState;
|
||||||
|
use crate::web::session::{AuthSession, Viewer};
|
||||||
|
use crate::web::{Flash, Html, Page, UnitStatus, WebError, take_flash};
|
||||||
|
|
||||||
|
use super::{db_err, duration_between, fmt_duration, fmt_stored_time};
|
||||||
|
|
||||||
|
/// Routes contributed by this page group (merged by `dashboard::router`).
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/dashboard/jobs", get(index))
|
||||||
|
.route("/dashboard/jobs/{key}", get(show).post(start))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many rows the jobs table shows.
|
||||||
|
const TABLE_ROWS: i64 = 200;
|
||||||
|
|
||||||
|
/// A `requested` row whose unit already stopped with a failure this long after
|
||||||
|
/// the request is marked failed (§14.4).
|
||||||
|
const EXIT_GRACE_SECS: i64 = 30;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct JobCard {
|
||||||
|
name: String,
|
||||||
|
description: &'static str,
|
||||||
|
dangerous: bool,
|
||||||
|
/// The `generate` card carries the date input for `generate-YYYY-MM-DD`.
|
||||||
|
dated: bool,
|
||||||
|
lock: Option<&'static str>,
|
||||||
|
/// Status of the most recent job started from this card, when there is one.
|
||||||
|
last_status: Option<String>,
|
||||||
|
/// When that job was requested, for the card's "last run" line.
|
||||||
|
last_requested: Option<String>,
|
||||||
|
/// Its id, so the card links straight to the job page.
|
||||||
|
last_id: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct JobLine {
|
||||||
|
id: i64,
|
||||||
|
name: String,
|
||||||
|
requested_by: String,
|
||||||
|
requested: String,
|
||||||
|
started: String,
|
||||||
|
finished: String,
|
||||||
|
duration: String,
|
||||||
|
status: String,
|
||||||
|
message: Option<String>,
|
||||||
|
run_id: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "dashboard/jobs.html")]
|
||||||
|
struct JobsTemplate {
|
||||||
|
page: Page,
|
||||||
|
jobs_enabled: bool,
|
||||||
|
cards: Vec<JobCard>,
|
||||||
|
jobs: Vec<JobLine>,
|
||||||
|
today: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "dashboard/job.html")]
|
||||||
|
struct JobTemplate {
|
||||||
|
page: Page,
|
||||||
|
job: JobLine,
|
||||||
|
unit: String,
|
||||||
|
description: &'static str,
|
||||||
|
refresh: bool,
|
||||||
|
status: Option<UnitStatus>,
|
||||||
|
status_error: Option<String>,
|
||||||
|
log: String,
|
||||||
|
log_error: Option<String>,
|
||||||
|
log_lines: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cards() -> Vec<JobCard> {
|
||||||
|
Job::CATALOGUE
|
||||||
|
.iter()
|
||||||
|
.map(|job| JobCard {
|
||||||
|
name: job.name(),
|
||||||
|
description: job.description(),
|
||||||
|
dangerous: job.dangerous(),
|
||||||
|
dated: matches!(job, Job::Generate { .. }),
|
||||||
|
lock: job.takes_lock(),
|
||||||
|
last_status: None,
|
||||||
|
last_requested: None,
|
||||||
|
last_id: None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `rows` is newest first, so the first row whose name is the card's name (or
|
||||||
|
/// `<name>-<date>` for the dated `generate` card) is that card's last run.
|
||||||
|
fn attach_last_runs(cards: &mut [JobCard], rows: &[JobLine]) {
|
||||||
|
for card in cards.iter_mut() {
|
||||||
|
let prefix = format!("{}-", card.name);
|
||||||
|
if let Some(row) = rows
|
||||||
|
.iter()
|
||||||
|
.find(|row| row.name == card.name || row.name.starts_with(&prefix))
|
||||||
|
{
|
||||||
|
card.last_status = Some(row.status.clone());
|
||||||
|
card.last_requested = Some(row.requested.clone());
|
||||||
|
card.last_id = Some(row.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn job_line(row: &JobRow, config: &Config) -> JobLine {
|
||||||
|
JobLine {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name.clone(),
|
||||||
|
requested_by: match (&row.requested_by_name, row.requested_by) {
|
||||||
|
(Some(name), _) => name.clone(),
|
||||||
|
(None, Some(id)) => format!("user {id}"),
|
||||||
|
(None, None) => "by hand".into(),
|
||||||
|
},
|
||||||
|
requested: fmt_stored_time(Some(&row.requested_at), config),
|
||||||
|
started: fmt_stored_time(row.started_at.as_deref(), config),
|
||||||
|
finished: fmt_stored_time(row.finished_at.as_deref(), config),
|
||||||
|
duration: fmt_duration(
|
||||||
|
row.started_at
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|started| duration_between(started, row.finished_at.as_deref())),
|
||||||
|
),
|
||||||
|
status: row.status.clone(),
|
||||||
|
message: row.message.clone(),
|
||||||
|
run_id: row.run_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn jobs_template(
|
||||||
|
state: &AppState,
|
||||||
|
viewer: Option<Viewer>,
|
||||||
|
flash: Option<Flash>,
|
||||||
|
) -> Result<JobsTemplate, WebError> {
|
||||||
|
let config = state.config();
|
||||||
|
let rows = jobs::list(&state.db, TABLE_ROWS).await.map_err(db_err)?;
|
||||||
|
let mut page = Page::new("Jobs", viewer, "jobs");
|
||||||
|
page.flash = flash;
|
||||||
|
let today = config
|
||||||
|
.tz()
|
||||||
|
.map(|tz| Timestamp::now().to_zoned(tz).date().to_string())
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
Timestamp::now()
|
||||||
|
.to_zoned(jiff::tz::TimeZone::UTC)
|
||||||
|
.date()
|
||||||
|
.to_string()
|
||||||
|
});
|
||||||
|
let jobs: Vec<JobLine> = rows.iter().map(|row| job_line(row, &config)).collect();
|
||||||
|
let mut cards = cards();
|
||||||
|
attach_last_runs(&mut cards, &jobs);
|
||||||
|
Ok(JobsTemplate {
|
||||||
|
page,
|
||||||
|
jobs_enabled: config.server.jobs_enabled,
|
||||||
|
cards,
|
||||||
|
jobs,
|
||||||
|
today,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /dashboard/jobs`: the catalogue cards and the jobs table.
|
||||||
|
async fn index(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthSession,
|
||||||
|
Extension(session): Extension<Session>,
|
||||||
|
) -> Result<Response, WebError> {
|
||||||
|
let viewer = auth.user().await.map(Viewer::from);
|
||||||
|
let flash = take_flash(&session).await?;
|
||||||
|
Ok(Html(jobs_template(&state, viewer, flash).await?).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `date` field of the start form, when present and non-empty.
|
||||||
|
fn form_date(body: &[u8]) -> Option<String> {
|
||||||
|
url::form_urlencoded::parse(body)
|
||||||
|
.find(|(key, _)| key == "date")
|
||||||
|
.map(|(_, value)| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_flash(session: &Session, kind: &str, text: String) -> Result<(), WebError> {
|
||||||
|
session
|
||||||
|
.insert(
|
||||||
|
"flash",
|
||||||
|
Flash {
|
||||||
|
kind: kind.into(),
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| WebError::Internal(error.into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /dashboard/jobs/{name}` (admin, origin-checked): parse the name,
|
||||||
|
/// refuse a duplicate `requested`/`running` unit (409), insert `requested`,
|
||||||
|
/// start the unit; a failed start marks the row `failed`.
|
||||||
|
async fn start(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthSession,
|
||||||
|
Extension(session): Extension<Session>,
|
||||||
|
Path(name): Path<String>,
|
||||||
|
body: Bytes,
|
||||||
|
) -> Result<Response, WebError> {
|
||||||
|
let viewer = auth
|
||||||
|
.user()
|
||||||
|
.await
|
||||||
|
.map(Viewer::from)
|
||||||
|
.ok_or_else(|| WebError::Unauthenticated {
|
||||||
|
next: "/dashboard/jobs".into(),
|
||||||
|
})?;
|
||||||
|
let mut job = Job::parse(&name).ok_or(WebError::NotFound)?;
|
||||||
|
if let (Job::Generate { date: None }, Some(date)) = (job, form_date(&body)) {
|
||||||
|
let parsed = date
|
||||||
|
.parse::<jiff::civil::Date>()
|
||||||
|
.map_err(|_| WebError::BadRequest(format!("invalid date {date:?}")))?;
|
||||||
|
job = Job::Generate { date: Some(parsed) };
|
||||||
|
}
|
||||||
|
let config = state.config();
|
||||||
|
if !config.server.jobs_enabled {
|
||||||
|
set_flash(
|
||||||
|
&session,
|
||||||
|
"error",
|
||||||
|
"Jobs are disabled on this server (server.jobs_enabled = false).".into(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(Redirect::to("/dashboard/jobs").into_response());
|
||||||
|
}
|
||||||
|
let unit = job.unit();
|
||||||
|
if let Some(active) = jobs::active_for_unit(&state.db, &unit)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?
|
||||||
|
{
|
||||||
|
let flash = Flash {
|
||||||
|
kind: "error".into(),
|
||||||
|
text: format!(
|
||||||
|
"{} is already requested or running (job {active}).",
|
||||||
|
job.name()
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let template = jobs_template(&state, Some(viewer), Some(flash)).await?;
|
||||||
|
return Ok((StatusCode::CONFLICT, Html(template)).into_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick up a hand-edited config.toml before the unit starts (§4.2); a file
|
||||||
|
// that no longer loads keeps the previous config live and is only logged.
|
||||||
|
if let Err(error) = crate::web::WebState::reload_if_changed(&state) {
|
||||||
|
tracing::warn!(%error, "config.toml on disk does not load; keeping the previous config");
|
||||||
|
}
|
||||||
|
let now = Timestamp::now();
|
||||||
|
let id = jobs::insert_requested(&state.db, &job, Some(viewer.id), now)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
match state.web.jobs.start(&unit).await {
|
||||||
|
Ok(()) => {
|
||||||
|
tracing::info!(user = %viewer.username, job = %job.name(), job_id = id, %unit, "job requested");
|
||||||
|
set_flash(&session, "success", format!("Started {}.", job.name())).await?;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(user = %viewer.username, job = %job.name(), job_id = id, %unit, %error, "job start failed");
|
||||||
|
jobs::finish(
|
||||||
|
&state.db,
|
||||||
|
id,
|
||||||
|
jobs::Outcome::Failed,
|
||||||
|
&format!("could not start {unit}: {error}"),
|
||||||
|
None,
|
||||||
|
Timestamp::now(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
set_flash(
|
||||||
|
&session,
|
||||||
|
"error",
|
||||||
|
format!("Could not start {}: {error}", job.name()),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Redirect::to(&format!("/dashboard/jobs/{id}")).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /dashboard/jobs/{id}`: the row, the live unit status and the journal
|
||||||
|
/// tail; applies the 30-second "unit exited before the job started" rule.
|
||||||
|
async fn show(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthSession,
|
||||||
|
Extension(session): Extension<Session>,
|
||||||
|
Path(key): Path<String>,
|
||||||
|
) -> Result<Response, WebError> {
|
||||||
|
let viewer = auth.user().await.map(Viewer::from);
|
||||||
|
let id: i64 = key.parse().map_err(|_| WebError::NotFound)?;
|
||||||
|
let config = state.config();
|
||||||
|
let db = &state.db;
|
||||||
|
let mut row = jobs::get(db, id)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?
|
||||||
|
.ok_or(WebError::NotFound)?;
|
||||||
|
let job = Job::parse(&row.name);
|
||||||
|
let (status, status_error) = match state.web.jobs.status(&row.unit).await {
|
||||||
|
Ok(status) => (Some(status), None),
|
||||||
|
Err(error) => (None, Some(error)),
|
||||||
|
};
|
||||||
|
if let Some(status) = &status
|
||||||
|
&& row.status == "requested"
|
||||||
|
&& status.exited_unsuccessfully()
|
||||||
|
&& requested_secs_ago(&row, Timestamp::now()) >= EXIT_GRACE_SECS
|
||||||
|
{
|
||||||
|
jobs::finish(
|
||||||
|
db,
|
||||||
|
id,
|
||||||
|
jobs::Outcome::Failed,
|
||||||
|
EXITED_BEFORE_START,
|
||||||
|
None,
|
||||||
|
Timestamp::now(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
row = jobs::get(db, id)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?
|
||||||
|
.ok_or(WebError::NotFound)?;
|
||||||
|
}
|
||||||
|
let lines = config.server.journal_lines;
|
||||||
|
let (log, log_error) = match state.web.jobs.log(&row.unit, lines as usize).await {
|
||||||
|
Ok(log) => (log, None),
|
||||||
|
Err(error) => (String::new(), Some(error)),
|
||||||
|
};
|
||||||
|
let mut page = Page::new(format!("Job {id} · {}", row.name), viewer, "jobs");
|
||||||
|
page.flash = take_flash(&session).await?;
|
||||||
|
Ok(Html(JobTemplate {
|
||||||
|
page,
|
||||||
|
refresh: row.is_active(),
|
||||||
|
unit: row.unit.clone(),
|
||||||
|
description: job.map(|job| job.description()).unwrap_or(""),
|
||||||
|
job: job_line(&row, &config),
|
||||||
|
status,
|
||||||
|
status_error,
|
||||||
|
log,
|
||||||
|
log_error,
|
||||||
|
log_lines: lines,
|
||||||
|
})
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn requested_secs_ago(row: &JobRow, now: Timestamp) -> i64 {
|
||||||
|
row.requested_at
|
||||||
|
.parse::<Timestamp>()
|
||||||
|
.map(|requested| now.as_second() - requested.as_second())
|
||||||
|
.unwrap_or(i64::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{Method, Request, header};
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::db::Db;
|
||||||
|
use crate::server::router;
|
||||||
|
use crate::web::MockRunner;
|
||||||
|
use crate::web::dashboard::tests::{assert_admin_only, get, login_cookie, response_text};
|
||||||
|
|
||||||
|
fn job_line_named(id: i64, name: &str, status: &str) -> JobLine {
|
||||||
|
JobLine {
|
||||||
|
id,
|
||||||
|
name: name.into(),
|
||||||
|
requested_by: "admin".into(),
|
||||||
|
requested: "2026-09-03 01:00".into(),
|
||||||
|
started: String::new(),
|
||||||
|
finished: String::new(),
|
||||||
|
duration: String::new(),
|
||||||
|
status: status.into(),
|
||||||
|
message: None,
|
||||||
|
run_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cards_take_their_last_run_from_the_newest_matching_job() {
|
||||||
|
let mut cards = cards();
|
||||||
|
// Newest first, the way `jobs::list` returns them.
|
||||||
|
let rows = vec![
|
||||||
|
job_line_named(4, "generate-2026-09-03", "running"),
|
||||||
|
job_line_named(3, "profile-rebuild", "ok"),
|
||||||
|
job_line_named(2, "generate-2026-09-02", "ok"),
|
||||||
|
];
|
||||||
|
attach_last_runs(&mut cards, &rows);
|
||||||
|
|
||||||
|
let generate = cards.iter().find(|card| card.name == "generate").unwrap();
|
||||||
|
assert_eq!(generate.last_id, Some(4), "the dated job matches its card");
|
||||||
|
assert_eq!(generate.last_status.as_deref(), Some("running"));
|
||||||
|
let rebuild = cards
|
||||||
|
.iter()
|
||||||
|
.find(|card| card.name == "profile-rebuild")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(rebuild.last_id, Some(3));
|
||||||
|
let never = cards
|
||||||
|
.iter()
|
||||||
|
.find(|card| card.last_id.is_none())
|
||||||
|
.expect("a catalogue job with no recorded run");
|
||||||
|
assert!(never.last_status.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn app_with_runner(
|
||||||
|
config: Config,
|
||||||
|
runner: Arc<dyn crate::web::JobRunner>,
|
||||||
|
) -> (tempfile::TempDir, Db, axum::Router) {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let db = Db::open_and_migrate(&dir.path().join("db.sqlite"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
crate::web::users::add(&db, "reader", "correct horse battery", false)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
crate::web::users::add(&db, "admin", "correct horse battery", true)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let app = router(AppState::with_jobs(db.clone(), config, None, runner));
|
||||||
|
(dir, db, app)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post(app: &axum::Router, uri: &str, body: &str, cookie: &str) -> Response {
|
||||||
|
app.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method(Method::POST)
|
||||||
|
.uri(uri)
|
||||||
|
.header(header::COOKIE, cookie)
|
||||||
|
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||||
|
.header("sec-fetch-site", "same-origin")
|
||||||
|
.body(Body::from(body.to_string()))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn location(response: &Response) -> String {
|
||||||
|
response
|
||||||
|
.headers()
|
||||||
|
.get(header::LOCATION)
|
||||||
|
.unwrap()
|
||||||
|
.to_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn form_date_reads_the_optional_field() {
|
||||||
|
assert_eq!(form_date(b"date=2026-09-03"), Some("2026-09-03".into()));
|
||||||
|
assert_eq!(form_date(b"date=+2026-09-03+"), Some("2026-09-03".into()));
|
||||||
|
assert_eq!(form_date(b"date="), None);
|
||||||
|
assert_eq!(form_date(b""), None);
|
||||||
|
assert_eq!(form_date(b"other=1"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn jobs_page_lists_the_catalogue_and_the_table() {
|
||||||
|
let runner = Arc::new(MockRunner::default());
|
||||||
|
let (_dir, db, app) = app_with_runner(Config::default(), runner).await;
|
||||||
|
let now: Timestamp = "2026-09-03T10:00:00Z".parse().unwrap();
|
||||||
|
let id = jobs::insert_requested(&db, &Job::FeaturesPrune, None, now)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
jobs::finish(&db, id, jobs::Outcome::Ok, "pruned 0 embeddings", None, now)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let body = assert_admin_only(&app, "/dashboard/jobs").await;
|
||||||
|
for job in Job::CATALOGUE {
|
||||||
|
assert!(
|
||||||
|
body.contains(&format!("action=\"/dashboard/jobs/{}\"", job.name())),
|
||||||
|
"{}: {body}",
|
||||||
|
job.name()
|
||||||
|
);
|
||||||
|
let escaped_description = job.description().replace('\'', "'");
|
||||||
|
assert!(body.contains(&escaped_description), "{}", job.name());
|
||||||
|
}
|
||||||
|
assert!(body.contains("data-confirm"), "generate needs confirmation");
|
||||||
|
assert!(body.contains("type=\"date\""), "the dated generate input");
|
||||||
|
assert!(body.contains("pruned 0 embeddings"), "{body}");
|
||||||
|
assert!(body.contains(&format!("/dashboard/jobs/{id}")), "{body}");
|
||||||
|
assert!(!body.contains("style=\""), "no inline styles under the CSP");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn starting_a_job_inserts_a_row_and_starts_the_unit() {
|
||||||
|
let runner = Arc::new(MockRunner::default());
|
||||||
|
let (_dir, db, app) = app_with_runner(
|
||||||
|
Config::default(),
|
||||||
|
runner.clone() as Arc<dyn crate::web::JobRunner>,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let admin = login_cookie(&app, "admin", "correct horse battery").await;
|
||||||
|
|
||||||
|
let response = post(&app, "/dashboard/jobs/features-prune", "", &admin).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||||
|
let target = location(&response);
|
||||||
|
assert!(target.starts_with("/dashboard/jobs/"), "{target}");
|
||||||
|
let row = jobs::list(&db, 10).await.unwrap().remove(0);
|
||||||
|
assert_eq!(target, format!("/dashboard/jobs/{}", row.id));
|
||||||
|
assert_eq!(row.name, "features-prune");
|
||||||
|
assert_eq!(row.unit, "daily-epub-job@features-prune.service");
|
||||||
|
assert_eq!(row.status, "requested");
|
||||||
|
assert_eq!(row.requested_by_name.as_deref(), Some("admin"));
|
||||||
|
assert_eq!(
|
||||||
|
runner.calls(),
|
||||||
|
vec!["start daily-epub-job@features-prune.service".to_string()]
|
||||||
|
);
|
||||||
|
|
||||||
|
// The job page shows the row, the unit and refreshes while active.
|
||||||
|
let page = get(&app, &target, Some(&admin)).await;
|
||||||
|
assert_eq!(page.status(), StatusCode::OK);
|
||||||
|
let body = response_text(page).await;
|
||||||
|
assert!(
|
||||||
|
body.contains("daily-epub-job@features-prune.service"),
|
||||||
|
"{body}"
|
||||||
|
);
|
||||||
|
assert!(body.contains("data-refresh=\"5\""), "{body}");
|
||||||
|
assert!(body.contains("badge requested"), "{body}");
|
||||||
|
assert!(body.contains("Started features-prune."), "the flash");
|
||||||
|
|
||||||
|
// Simulate the unit claiming the request, then prove a running
|
||||||
|
// duplicate is refused with 409 and no second row.
|
||||||
|
assert_eq!(
|
||||||
|
jobs::claim(&db, &Job::FeaturesPrune, Timestamp::now())
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
row.id
|
||||||
|
);
|
||||||
|
let duplicate = post(&app, "/dashboard/jobs/features-prune", "", &admin).await;
|
||||||
|
assert_eq!(duplicate.status(), StatusCode::CONFLICT);
|
||||||
|
let body = response_text(duplicate).await;
|
||||||
|
assert!(body.contains("already requested or running"), "{body}");
|
||||||
|
assert_eq!(jobs::list(&db, 10).await.unwrap().len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
runner
|
||||||
|
.calls()
|
||||||
|
.iter()
|
||||||
|
.filter(|call| call.starts_with("start "))
|
||||||
|
.count(),
|
||||||
|
1,
|
||||||
|
"no second start"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The dated generate form starts the dated unit.
|
||||||
|
let dated = post(&app, "/dashboard/jobs/generate", "date=2026-09-03", &admin).await;
|
||||||
|
assert_eq!(dated.status(), StatusCode::SEE_OTHER);
|
||||||
|
let row = jobs::list(&db, 10).await.unwrap().remove(0);
|
||||||
|
assert_eq!(row.name, "generate-2026-09-03");
|
||||||
|
assert_eq!(
|
||||||
|
runner.calls().last().unwrap(),
|
||||||
|
"start daily-epub-job@generate-2026-09-03.service"
|
||||||
|
);
|
||||||
|
let bad_date = post(&app, "/dashboard/jobs/generate", "date=soon", &admin).await;
|
||||||
|
assert_eq!(bad_date.status(), StatusCode::BAD_REQUEST);
|
||||||
|
|
||||||
|
// Unknown names never reach the runner.
|
||||||
|
for name in ["../x", "Generate", "backup"] {
|
||||||
|
let unknown = post(&app, &format!("/dashboard/jobs/{name}"), "", &admin).await;
|
||||||
|
assert_eq!(unknown.status(), StatusCode::NOT_FOUND, "{name}");
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
runner
|
||||||
|
.calls()
|
||||||
|
.iter()
|
||||||
|
.filter(|call| call.starts_with("start "))
|
||||||
|
.count(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
|
||||||
|
// Readers cannot start jobs.
|
||||||
|
let reader = login_cookie(&app, "reader", "correct horse battery").await;
|
||||||
|
let forbidden = post(&app, "/dashboard/jobs/features-prune", "", &reader).await;
|
||||||
|
assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_failed_start_marks_the_row_failed() {
|
||||||
|
let runner = Arc::new(MockRunner::default());
|
||||||
|
runner.fail_starts(Some("polkit: access denied"));
|
||||||
|
let (_dir, db, app) = app_with_runner(
|
||||||
|
Config::default(),
|
||||||
|
runner.clone() as Arc<dyn crate::web::JobRunner>,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let admin = login_cookie(&app, "admin", "correct horse battery").await;
|
||||||
|
let response = post(&app, "/dashboard/jobs/dry-run", "", &admin).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||||
|
let row = jobs::list(&db, 10).await.unwrap().remove(0);
|
||||||
|
assert_eq!(row.status, "failed");
|
||||||
|
assert!(row.finished_at.is_some());
|
||||||
|
assert_eq!(
|
||||||
|
row.message.as_deref(),
|
||||||
|
Some("could not start daily-epub-job@dry-run.service: polkit: access denied")
|
||||||
|
);
|
||||||
|
let body = response_text(get(&app, &location(&response), Some(&admin)).await).await;
|
||||||
|
assert!(body.contains("polkit: access denied"), "{body}");
|
||||||
|
assert!(
|
||||||
|
!body.contains("data-refresh"),
|
||||||
|
"a finished job does not refresh"
|
||||||
|
);
|
||||||
|
// The unit is free again.
|
||||||
|
runner.fail_starts(None);
|
||||||
|
let again = post(&app, "/dashboard/jobs/dry-run", "", &admin).await;
|
||||||
|
assert_eq!(again.status(), StatusCode::SEE_OTHER);
|
||||||
|
assert_eq!(jobs::list(&db, 10).await.unwrap()[0].status, "requested");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn job_page_marks_a_unit_that_exited_before_the_job_started() {
|
||||||
|
let runner = Arc::new(MockRunner::default());
|
||||||
|
let unit = Job::ProfileRebuild.unit();
|
||||||
|
runner.set_status(
|
||||||
|
&unit,
|
||||||
|
UnitStatus {
|
||||||
|
active_state: "inactive".into(),
|
||||||
|
sub_state: "dead".into(),
|
||||||
|
result: "exit-code".into(),
|
||||||
|
exit_status: Some(1),
|
||||||
|
started: Some("Thu 2026-09-03 05:30:01 EDT".into()),
|
||||||
|
exited: Some("Thu 2026-09-03 05:30:02 EDT".into()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
runner.set_log("Sep 03 05:30:01 daily-epub[1]: Error: profile rebuild is already running");
|
||||||
|
let (_dir, db, app) = app_with_runner(
|
||||||
|
Config::default(),
|
||||||
|
runner.clone() as Arc<dyn crate::web::JobRunner>,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let admin = login_cookie(&app, "admin", "correct horse battery").await;
|
||||||
|
|
||||||
|
// Requested a minute ago and never claimed.
|
||||||
|
let requested_at = Timestamp::now() - jiff::Span::new().seconds(60);
|
||||||
|
let id = jobs::insert_requested(&db, &Job::ProfileRebuild, None, requested_at)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let body =
|
||||||
|
response_text(get(&app, &format!("/dashboard/jobs/{id}"), Some(&admin)).await).await;
|
||||||
|
assert!(body.contains(EXITED_BEFORE_START), "{body}");
|
||||||
|
assert!(body.contains("badge failed"), "{body}");
|
||||||
|
assert!(body.contains("exit-code"), "the live unit status");
|
||||||
|
assert!(body.contains("is already running"), "the journal tail");
|
||||||
|
assert_eq!(jobs::get(&db, id).await.unwrap().unwrap().status, "failed");
|
||||||
|
assert!(runner.calls().contains(&format!(
|
||||||
|
"log {unit} {}",
|
||||||
|
Config::default().server.journal_lines
|
||||||
|
)));
|
||||||
|
|
||||||
|
// A fresh request whose unit has not run yet is left alone.
|
||||||
|
runner.set_status(
|
||||||
|
&Job::FeaturesPrune.unit(),
|
||||||
|
UnitStatus {
|
||||||
|
active_state: "inactive".into(),
|
||||||
|
sub_state: "dead".into(),
|
||||||
|
result: "success".into(),
|
||||||
|
..UnitStatus::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let fresh = jobs::insert_requested(&db, &Job::FeaturesPrune, None, Timestamp::now())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let body =
|
||||||
|
response_text(get(&app, &format!("/dashboard/jobs/{fresh}"), Some(&admin)).await).await;
|
||||||
|
assert!(body.contains("badge requested"), "{body}");
|
||||||
|
assert_eq!(
|
||||||
|
jobs::get(&db, fresh).await.unwrap().unwrap().status,
|
||||||
|
"requested"
|
||||||
|
);
|
||||||
|
|
||||||
|
let missing = get(&app, "/dashboard/jobs/999999", Some(&admin)).await;
|
||||||
|
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn disabled_jobs_refuse_starts_without_a_row() {
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.server.jobs_enabled = false;
|
||||||
|
let (_dir, db, app) = app_with_runner(config, Arc::new(crate::web::DisabledRunner)).await;
|
||||||
|
let admin = login_cookie(&app, "admin", "correct horse battery").await;
|
||||||
|
let body = response_text(get(&app, "/dashboard/jobs", Some(&admin)).await).await;
|
||||||
|
assert!(body.contains("Jobs are disabled"), "{body}");
|
||||||
|
let response = post(&app, "/dashboard/jobs/features-prune", "", &admin).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||||
|
assert_eq!(location(&response), "/dashboard/jobs");
|
||||||
|
assert!(jobs::list(&db, 10).await.unwrap().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,786 @@
|
|||||||
|
//! Dashboard: the profile page (`/dashboard/profile`, web plan §11).
|
||||||
|
//!
|
||||||
|
//! Edits `profile.md` with version history, shows what the loader parses out
|
||||||
|
//! of it, the standing OPML interests by theme, the stored system prompt and
|
||||||
|
//! the weekly learned adjustments, and offers the `profile-rebuild` job.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use askama::Template;
|
||||||
|
use axum::Router;
|
||||||
|
use axum::extract::{Extension, Form, State};
|
||||||
|
use axum::response::{IntoResponse, Redirect, Response};
|
||||||
|
use axum::routing::{get, post};
|
||||||
|
use axum_login::tower_sessions::Session;
|
||||||
|
use jiff::Timestamp;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sqlx::Row;
|
||||||
|
|
||||||
|
use crate::curate::profile::{self, KV_LEARNED_ADJUSTMENTS, ProfileFile, REBUILD_INTERVAL_DAYS};
|
||||||
|
use crate::db::{Db, DbError, KV_TASTE_PROFILE};
|
||||||
|
use crate::server::AppState;
|
||||||
|
use crate::web::session::{AuthSession, Viewer};
|
||||||
|
use crate::web::{Flash, Html, Page, WebError, format_time, take_flash};
|
||||||
|
|
||||||
|
/// Largest `profile.md` the editor accepts (§11).
|
||||||
|
pub const MAX_PROFILE_BYTES: usize = 64 * 1024;
|
||||||
|
const PREVIEW_CHARS: usize = 200;
|
||||||
|
const VERSIONS_SHOWN: i64 = 50;
|
||||||
|
|
||||||
|
/// Routes contributed by this page group (merged by `dashboard::router`).
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/dashboard/profile", get(show).post(save))
|
||||||
|
.route("/dashboard/profile/restore", post(restore))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// File handling
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Normalize a submitted profile: browser textareas send CRLF, the file is LF.
|
||||||
|
pub fn normalize(content: &str) -> String {
|
||||||
|
let mut text = content.replace("\r\n", "\n").replace('\r', "\n");
|
||||||
|
if !text.ends_with('\n') {
|
||||||
|
text.push('\n');
|
||||||
|
}
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reject empty or oversized profiles (§11); everything else parses.
|
||||||
|
pub fn validate(content: &str) -> Result<(), String> {
|
||||||
|
if content.trim().is_empty() {
|
||||||
|
return Err("the profile cannot be empty".into());
|
||||||
|
}
|
||||||
|
if content.len() > MAX_PROFILE_BYTES {
|
||||||
|
return Err(format!(
|
||||||
|
"the profile is {} bytes; the limit is {} bytes",
|
||||||
|
content.len(),
|
||||||
|
MAX_PROFILE_BYTES
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write `<path>.tmp` and rename it over `path`, keeping the existing file's
|
||||||
|
/// permissions (§11).
|
||||||
|
pub fn write_atomically(path: &Path, content: &str) -> anyhow::Result<()> {
|
||||||
|
let mut tmp_name = path
|
||||||
|
.file_name()
|
||||||
|
.map(|name| name.to_os_string())
|
||||||
|
.unwrap_or_else(|| "profile.md".into());
|
||||||
|
tmp_name.push(".tmp");
|
||||||
|
let tmp = path.with_file_name(tmp_name);
|
||||||
|
let permissions = std::fs::metadata(path)
|
||||||
|
.ok()
|
||||||
|
.map(|metadata| metadata.permissions());
|
||||||
|
std::fs::write(&tmp, content)
|
||||||
|
.map_err(|error| anyhow::anyhow!("writing {}: {error}", tmp.display()))?;
|
||||||
|
if let Some(permissions) = permissions
|
||||||
|
&& let Err(error) = std::fs::set_permissions(&tmp, permissions)
|
||||||
|
{
|
||||||
|
let _ = std::fs::remove_file(&tmp);
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"preserving permissions on {}: {error}",
|
||||||
|
tmp.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Err(error) = std::fs::rename(&tmp, path) {
|
||||||
|
let _ = std::fs::remove_file(&tmp);
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"renaming {} over {}: {error}",
|
||||||
|
tmp.display(),
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_profile(path: &Path) -> anyhow::Result<Option<String>> {
|
||||||
|
match std::fs::read_to_string(path) {
|
||||||
|
Ok(content) => Ok(Some(content)),
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(error) => Err(anyhow::anyhow!("reading {}: {error}", path.display())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The live preview of what the loader extracts (§11): the passthrough body
|
||||||
|
/// and the `## Interests` lines.
|
||||||
|
pub fn preview(content: &str) -> ProfileFile {
|
||||||
|
profile::parse_profile_str(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn short_preview(content: &str) -> String {
|
||||||
|
let mut out: String = content.chars().take(PREVIEW_CHARS).collect();
|
||||||
|
if content.chars().count() > PREVIEW_CHARS {
|
||||||
|
out.push('…');
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Versions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn record_version(
|
||||||
|
db: &Db,
|
||||||
|
content: &str,
|
||||||
|
saved_by: i64,
|
||||||
|
now: Timestamp,
|
||||||
|
) -> Result<i64, WebError> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"INSERT INTO profile_versions (content, saved_by, saved_at) VALUES (?, ?, ?) RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(content)
|
||||||
|
.bind(saved_by)
|
||||||
|
.bind(crate::db::fmt_ts(now))
|
||||||
|
.fetch_one(db.pool())
|
||||||
|
.await
|
||||||
|
.map_err(DbError::from)?;
|
||||||
|
Ok(row.get("id"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn version_content(db: &Db, id: i64) -> Result<Option<String>, WebError> {
|
||||||
|
let row = sqlx::query("SELECT content FROM profile_versions WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(db.pool())
|
||||||
|
.await
|
||||||
|
.map_err(DbError::from)?;
|
||||||
|
Ok(row.map(|row| row.get("content")))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct VersionView {
|
||||||
|
id: i64,
|
||||||
|
saved_at: String,
|
||||||
|
saved_by: String,
|
||||||
|
bytes: usize,
|
||||||
|
preview: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn versions(db: &Db, config: &crate::config::Config) -> Result<Vec<VersionView>, WebError> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT pv.id, pv.content, pv.saved_at, u.username
|
||||||
|
FROM profile_versions pv LEFT JOIN users u ON u.id = pv.saved_by
|
||||||
|
ORDER BY pv.id DESC LIMIT ?",
|
||||||
|
)
|
||||||
|
.bind(VERSIONS_SHOWN)
|
||||||
|
.fetch_all(db.pool())
|
||||||
|
.await
|
||||||
|
.map_err(DbError::from)?;
|
||||||
|
let mut out = Vec::with_capacity(rows.len());
|
||||||
|
for row in rows {
|
||||||
|
let content: String = row.get("content");
|
||||||
|
let saved_at = crate::db::parse_ts(
|
||||||
|
"profile_versions.saved_at",
|
||||||
|
&row.get::<String, _>("saved_at"),
|
||||||
|
)?;
|
||||||
|
out.push(VersionView {
|
||||||
|
id: row.get("id"),
|
||||||
|
saved_at: format_time(saved_at, config),
|
||||||
|
saved_by: row
|
||||||
|
.get::<Option<String>, _>("username")
|
||||||
|
.unwrap_or_else(|| "—".into()),
|
||||||
|
bytes: content.len(),
|
||||||
|
preview: short_preview(&content),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Page
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct ThemeView {
|
||||||
|
name: String,
|
||||||
|
members: String,
|
||||||
|
count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "dashboard/profile.html")]
|
||||||
|
struct ProfileTemplate {
|
||||||
|
page: Page,
|
||||||
|
path: String,
|
||||||
|
exists: bool,
|
||||||
|
content: String,
|
||||||
|
bytes: usize,
|
||||||
|
max_bytes: usize,
|
||||||
|
preview_body: String,
|
||||||
|
preview_interests: Vec<String>,
|
||||||
|
versions: Vec<VersionView>,
|
||||||
|
opml_path: String,
|
||||||
|
opml_count: usize,
|
||||||
|
opml_error: String,
|
||||||
|
themes: Vec<ThemeView>,
|
||||||
|
prompt: String,
|
||||||
|
prompt_chars: usize,
|
||||||
|
prompt_version: String,
|
||||||
|
prompt_built_at: String,
|
||||||
|
prompt_verdicts: usize,
|
||||||
|
learned: String,
|
||||||
|
learned_age: String,
|
||||||
|
rebuild_interval_days: i64,
|
||||||
|
rebuild_due: bool,
|
||||||
|
jobs_enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count_verdict_lines(prompt: &str) -> usize {
|
||||||
|
prompt
|
||||||
|
.split_once("## Recent verdicts")
|
||||||
|
.map(|(_, rest)| rest.lines().filter(|line| !line.trim().is_empty()).count())
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn show(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthSession,
|
||||||
|
Extension(session): Extension<Session>,
|
||||||
|
) -> Result<Response, WebError> {
|
||||||
|
let viewer = auth
|
||||||
|
.user()
|
||||||
|
.await
|
||||||
|
.map(Viewer::from)
|
||||||
|
.ok_or_else(|| WebError::Unauthenticated {
|
||||||
|
next: "/dashboard/profile".into(),
|
||||||
|
})?;
|
||||||
|
let config = state.config();
|
||||||
|
let db = &state.db;
|
||||||
|
let now = Timestamp::now();
|
||||||
|
|
||||||
|
let path = config.profile_path.clone();
|
||||||
|
let stored = read_profile(&path).map_err(WebError::Internal)?;
|
||||||
|
let exists = stored.is_some();
|
||||||
|
let content = stored.unwrap_or_default();
|
||||||
|
let parsed = preview(&content);
|
||||||
|
|
||||||
|
let (opml_count, opml_error, themes) = match profile::parse_interests(&config.interests_opml) {
|
||||||
|
Ok(interests) => {
|
||||||
|
let themes = profile::group_into_themes(&interests)
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, members)| ThemeView {
|
||||||
|
name,
|
||||||
|
count: members.len(),
|
||||||
|
members: members.join(", "),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
(interests.len(), String::new(), themes)
|
||||||
|
}
|
||||||
|
Err(error) => (0, format!("{error:#}"), Vec::new()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let prompt = db.kv_get(KV_TASTE_PROFILE).await?.unwrap_or_default();
|
||||||
|
let learned = db.kv_get(KV_LEARNED_ADJUSTMENTS).await?.unwrap_or_default();
|
||||||
|
let version = profile::stored_version(db)
|
||||||
|
.await
|
||||||
|
.map_err(WebError::Internal)?;
|
||||||
|
let (prompt_version, prompt_built_at, learned_age) = match version {
|
||||||
|
Some((version, built_at)) => {
|
||||||
|
let age_days = (now.as_second() - built_at.as_second()).max(0) / 86_400;
|
||||||
|
(
|
||||||
|
version.to_string(),
|
||||||
|
format_time(built_at, &config),
|
||||||
|
format!("{age_days} days old"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
None => ("—".into(), "never".into(), "never built".into()),
|
||||||
|
};
|
||||||
|
let rebuild_due = profile::is_stale(db).await.map_err(WebError::Internal)?;
|
||||||
|
|
||||||
|
let mut page = Page::new("Profile", Some(viewer), "profile");
|
||||||
|
page.flash = take_flash(&session).await?;
|
||||||
|
Ok(Html(ProfileTemplate {
|
||||||
|
page,
|
||||||
|
path: path.display().to_string(),
|
||||||
|
exists,
|
||||||
|
bytes: content.len(),
|
||||||
|
max_bytes: MAX_PROFILE_BYTES,
|
||||||
|
content,
|
||||||
|
preview_body: parsed.body,
|
||||||
|
preview_interests: parsed.interests,
|
||||||
|
versions: versions(db, &config).await?,
|
||||||
|
opml_path: config.interests_opml.display().to_string(),
|
||||||
|
opml_count,
|
||||||
|
opml_error,
|
||||||
|
themes,
|
||||||
|
prompt_chars: prompt.len(),
|
||||||
|
prompt_verdicts: count_verdict_lines(&prompt),
|
||||||
|
prompt,
|
||||||
|
prompt_version,
|
||||||
|
prompt_built_at,
|
||||||
|
learned,
|
||||||
|
learned_age,
|
||||||
|
rebuild_interval_days: REBUILD_INTERVAL_DAYS,
|
||||||
|
rebuild_due,
|
||||||
|
jobs_enabled: config.server.jobs_enabled,
|
||||||
|
})
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Save and restore
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SaveForm {
|
||||||
|
#[serde(default)]
|
||||||
|
content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct RestoreForm {
|
||||||
|
version_id: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn flash_and_redirect(
|
||||||
|
session: &Session,
|
||||||
|
kind: &str,
|
||||||
|
text: String,
|
||||||
|
) -> Result<Response, WebError> {
|
||||||
|
session
|
||||||
|
.insert(
|
||||||
|
"flash",
|
||||||
|
Flash {
|
||||||
|
kind: kind.into(),
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| WebError::Internal(error.into()))?;
|
||||||
|
Ok(Redirect::to("/dashboard/profile").into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace `profile.md` with `content`, recording the previous text as a
|
||||||
|
/// version. Returns the new version row's id when one was written.
|
||||||
|
async fn replace_profile(
|
||||||
|
state: &AppState,
|
||||||
|
viewer: &Viewer,
|
||||||
|
content: &str,
|
||||||
|
) -> Result<Option<i64>, WebError> {
|
||||||
|
let config = state.config();
|
||||||
|
let path = config.profile_path.clone();
|
||||||
|
let previous = read_profile(&path).map_err(WebError::Internal)?;
|
||||||
|
let version = match previous {
|
||||||
|
Some(previous) if previous != content => {
|
||||||
|
Some(record_version(&state.db, &previous, viewer.id, Timestamp::now()).await?)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
write_atomically(&path, content).map_err(WebError::Internal)?;
|
||||||
|
Ok(version)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthSession,
|
||||||
|
Extension(session): Extension<Session>,
|
||||||
|
Form(form): Form<SaveForm>,
|
||||||
|
) -> Result<Response, WebError> {
|
||||||
|
let viewer = auth
|
||||||
|
.user()
|
||||||
|
.await
|
||||||
|
.map(Viewer::from)
|
||||||
|
.ok_or_else(|| WebError::Unauthenticated {
|
||||||
|
next: "/dashboard/profile".into(),
|
||||||
|
})?;
|
||||||
|
let content = normalize(&form.content);
|
||||||
|
validate(&content).map_err(WebError::BadRequest)?;
|
||||||
|
let config = state.config();
|
||||||
|
let current = read_profile(&config.profile_path).map_err(WebError::Internal)?;
|
||||||
|
if current.as_deref() == Some(content.as_str()) {
|
||||||
|
return flash_and_redirect(&session, "info", "No changes to save.".into()).await;
|
||||||
|
}
|
||||||
|
replace_profile(&state, &viewer, &content).await?;
|
||||||
|
tracing::info!(
|
||||||
|
user = %viewer.username,
|
||||||
|
bytes = content.len(),
|
||||||
|
path = %config.profile_path.display(),
|
||||||
|
"profile.md saved from the dashboard"
|
||||||
|
);
|
||||||
|
flash_and_redirect(
|
||||||
|
&session,
|
||||||
|
"success",
|
||||||
|
"Saved; the next run rebuilds the system prompt.".into(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn restore(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthSession,
|
||||||
|
Extension(session): Extension<Session>,
|
||||||
|
Form(form): Form<RestoreForm>,
|
||||||
|
) -> Result<Response, WebError> {
|
||||||
|
let viewer = auth
|
||||||
|
.user()
|
||||||
|
.await
|
||||||
|
.map(Viewer::from)
|
||||||
|
.ok_or_else(|| WebError::Unauthenticated {
|
||||||
|
next: "/dashboard/profile".into(),
|
||||||
|
})?;
|
||||||
|
let content = version_content(&state.db, form.version_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(WebError::NotFound)?;
|
||||||
|
validate(&content).map_err(WebError::BadRequest)?;
|
||||||
|
replace_profile(&state, &viewer, &content).await?;
|
||||||
|
tracing::info!(
|
||||||
|
user = %viewer.username,
|
||||||
|
version = form.version_id,
|
||||||
|
"profile.md restored from the dashboard"
|
||||||
|
);
|
||||||
|
flash_and_redirect(
|
||||||
|
&session,
|
||||||
|
"success",
|
||||||
|
format!(
|
||||||
|
"Restored version #{}; the next run rebuilds the system prompt.",
|
||||||
|
form.version_id
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use axum::body::{Body, to_bytes};
|
||||||
|
use axum::http::{Method, Request, StatusCode, header};
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::web::users;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preview_matches_the_loader() {
|
||||||
|
let raw = "# Reader profile\n\n## Who\nProse.\n\n## Interests\n- Rust\nBoston\n\n## Notes\nKeep.\n";
|
||||||
|
let parsed = preview(raw);
|
||||||
|
assert_eq!(parsed, profile::parse_profile_str(raw));
|
||||||
|
assert_eq!(
|
||||||
|
parsed.body,
|
||||||
|
"# Reader profile\n\n## Who\nProse.\n\n## Notes\nKeep.\n"
|
||||||
|
);
|
||||||
|
assert_eq!(parsed.interests, ["Rust", "Boston"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_and_validate_bound_the_profile() {
|
||||||
|
assert_eq!(normalize("a\r\nb"), "a\nb\n");
|
||||||
|
assert_eq!(normalize("a\n"), "a\n");
|
||||||
|
assert!(validate(" \n").is_err());
|
||||||
|
assert!(validate("# ok\n").is_ok());
|
||||||
|
let big = "x".repeat(MAX_PROFILE_BYTES + 1);
|
||||||
|
assert!(validate(&big).is_err());
|
||||||
|
assert!(validate(&"x".repeat(MAX_PROFILE_BYTES)).is_ok());
|
||||||
|
assert_eq!(short_preview("short"), "short");
|
||||||
|
let long = "y".repeat(PREVIEW_CHARS + 5);
|
||||||
|
assert_eq!(short_preview(&long).chars().count(), PREVIEW_CHARS + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn atomic_write_preserves_mode_and_leaves_no_temp_file() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("profile.md");
|
||||||
|
std::fs::write(&path, "old\n").unwrap();
|
||||||
|
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||||
|
write_atomically(&path, "new\n").unwrap();
|
||||||
|
assert_eq!(std::fs::read_to_string(&path).unwrap(), "new\n");
|
||||||
|
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||||
|
assert_eq!(mode, 0o600);
|
||||||
|
assert!(!dir.path().join("profile.md.tmp").exists());
|
||||||
|
// A missing file is created rather than failing.
|
||||||
|
let fresh = dir.path().join("fresh.md");
|
||||||
|
write_atomically(&fresh, "created\n").unwrap();
|
||||||
|
assert_eq!(std::fs::read_to_string(&fresh).unwrap(), "created\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verdict_lines_are_counted_from_the_prompt() {
|
||||||
|
assert_eq!(count_verdict_lines("no block"), 0);
|
||||||
|
assert_eq!(
|
||||||
|
count_verdict_lines("intro\n\n## Recent verdicts\n\nLOVED | a\nGOOD | b\n"),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn setup() -> (tempfile::TempDir, AppState, axum::Router, String) {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let db = Db::open_and_migrate(&dir.path().join("db.sqlite"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
users::add(&db, "tyler", "correct horse battery", true)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
users::add(&db, "reader", "correct horse battery", false)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let config = Config {
|
||||||
|
profile_path: dir.path().join("profile.md"),
|
||||||
|
interests_opml: dir.path().join("interests.opml"),
|
||||||
|
..Config::default()
|
||||||
|
};
|
||||||
|
std::fs::write(&config.profile_path, "# Original\n\nProse.\n").unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
&config.interests_opml,
|
||||||
|
r#"<outline text="Rust"/><outline text="Boston"/>"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
db.kv_set(
|
||||||
|
KV_TASTE_PROFILE,
|
||||||
|
"system prompt text\n\n## Recent verdicts\n\nLOVED | x\n",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
db.kv_set(KV_LEARNED_ADJUSTMENTS, "- Rank depth higher.")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let state = AppState::new(db, config, None);
|
||||||
|
let app = crate::server::router(state.clone());
|
||||||
|
let cookie = login(&app, "tyler").await;
|
||||||
|
(dir, state, app, cookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn login(app: &axum::Router, username: &str) -> String {
|
||||||
|
let response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method(Method::POST)
|
||||||
|
.uri("/login")
|
||||||
|
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||||
|
.header("sec-fetch-site", "same-origin")
|
||||||
|
.header("x-forwarded-for", "192.0.2.45")
|
||||||
|
.body(Body::from(format!(
|
||||||
|
"username={username}&password=correct+horse+battery&next=%2F"
|
||||||
|
)))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||||
|
response
|
||||||
|
.headers()
|
||||||
|
.get(header::SET_COOKIE)
|
||||||
|
.unwrap()
|
||||||
|
.to_str()
|
||||||
|
.unwrap()
|
||||||
|
.split(';')
|
||||||
|
.next()
|
||||||
|
.unwrap()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(app: &axum::Router, cookie: Option<&str>) -> Response {
|
||||||
|
let mut request = Request::builder().uri("/dashboard/profile");
|
||||||
|
if let Some(cookie) = cookie {
|
||||||
|
request = request.header(header::COOKIE, cookie);
|
||||||
|
}
|
||||||
|
app.clone()
|
||||||
|
.oneshot(request.body(Body::empty()).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post(app: &axum::Router, uri: &str, body: String, cookie: Option<&str>) -> Response {
|
||||||
|
let mut request = Request::builder()
|
||||||
|
.method(Method::POST)
|
||||||
|
.uri(uri)
|
||||||
|
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||||
|
.header("sec-fetch-site", "same-origin");
|
||||||
|
if let Some(cookie) = cookie {
|
||||||
|
request = request.header(header::COOKIE, cookie);
|
||||||
|
}
|
||||||
|
app.clone()
|
||||||
|
.oneshot(request.body(Body::from(body)).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn text(response: Response) -> String {
|
||||||
|
String::from_utf8(
|
||||||
|
to_bytes(response.into_body(), 2 * 1024 * 1024)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.to_vec(),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn version_rows(db: &Db) -> Vec<(i64, String, Option<i64>)> {
|
||||||
|
sqlx::query("SELECT id, content, saved_by FROM profile_versions ORDER BY id")
|
||||||
|
.fetch_all(db.pool())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| (row.get("id"), row.get("content"), row.get("saved_by")))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn profile_page_shows_editor_preview_interests_prompt_and_rebuild_form() {
|
||||||
|
let (_dir, _state, app, cookie) = setup().await;
|
||||||
|
let response = get(&app, Some(&cookie)).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = text(response).await;
|
||||||
|
assert!(body.contains("# Original"));
|
||||||
|
assert!(body.contains("Prose."));
|
||||||
|
assert!(body.contains("Rust, Boston") || body.contains("Rust") && body.contains("Boston"));
|
||||||
|
assert!(body.contains("2 interests"));
|
||||||
|
assert!(body.contains("system prompt text"));
|
||||||
|
assert!(body.contains("Rank depth higher."));
|
||||||
|
assert!(body.contains("never built"));
|
||||||
|
assert!(body.contains("rebuild is due"));
|
||||||
|
assert!(body.contains(r#"action="/dashboard/jobs/profile-rebuild""#));
|
||||||
|
assert!(body.contains("No saved versions yet"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn save_writes_the_file_and_records_the_previous_version() {
|
||||||
|
let (_dir, state, app, cookie) = setup().await;
|
||||||
|
let admin = users::find_by_username(&state.db, "tyler")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let response = post(
|
||||||
|
&app,
|
||||||
|
"/dashboard/profile",
|
||||||
|
"content=%23+New%0D%0A%0D%0A%23%23+Interests%0D%0A-+Writerdeck%0D%0A".into(),
|
||||||
|
Some(&cookie),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||||
|
assert_eq!(
|
||||||
|
response.headers().get(header::LOCATION).unwrap(),
|
||||||
|
"/dashboard/profile"
|
||||||
|
);
|
||||||
|
let path = state.config().profile_path.clone();
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(&path).unwrap(),
|
||||||
|
"# New\n\n## Interests\n- Writerdeck\n"
|
||||||
|
);
|
||||||
|
assert!(!path.with_file_name("profile.md.tmp").exists());
|
||||||
|
let rows = version_rows(&state.db).await;
|
||||||
|
assert_eq!(rows.len(), 1);
|
||||||
|
assert_eq!(rows[0].1, "# Original\n\nProse.\n");
|
||||||
|
assert_eq!(rows[0].2, Some(admin.id));
|
||||||
|
|
||||||
|
let page = text(get(&app, Some(&cookie)).await).await;
|
||||||
|
assert!(page.contains("Saved; the next run rebuilds the system prompt."));
|
||||||
|
assert!(page.contains("Writerdeck"));
|
||||||
|
assert!(page.contains("# Original"));
|
||||||
|
assert!(page.contains(">tyler<"));
|
||||||
|
|
||||||
|
// Saving identical content records nothing.
|
||||||
|
let same = post(
|
||||||
|
&app,
|
||||||
|
"/dashboard/profile",
|
||||||
|
"content=%23+New%0A%0A%23%23+Interests%0A-+Writerdeck%0A".into(),
|
||||||
|
Some(&cookie),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(same.status(), StatusCode::SEE_OTHER);
|
||||||
|
assert_eq!(version_rows(&state.db).await.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn restore_swaps_the_file_and_records_the_current_one() {
|
||||||
|
let (_dir, state, app, cookie) = setup().await;
|
||||||
|
post(
|
||||||
|
&app,
|
||||||
|
"/dashboard/profile",
|
||||||
|
"content=%23+Second%0A".into(),
|
||||||
|
Some(&cookie),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let first = version_rows(&state.db).await[0].0;
|
||||||
|
let response = post(
|
||||||
|
&app,
|
||||||
|
"/dashboard/profile/restore",
|
||||||
|
format!("version_id={first}"),
|
||||||
|
Some(&cookie),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||||
|
let path = state.config().profile_path.clone();
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(&path).unwrap(),
|
||||||
|
"# Original\n\nProse.\n"
|
||||||
|
);
|
||||||
|
let rows = version_rows(&state.db).await;
|
||||||
|
assert_eq!(rows.len(), 2);
|
||||||
|
assert_eq!(rows[1].1, "# Second\n");
|
||||||
|
let missing = post(
|
||||||
|
&app,
|
||||||
|
"/dashboard/profile/restore",
|
||||||
|
"version_id=999".into(),
|
||||||
|
Some(&cookie),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn empty_and_oversized_profiles_are_rejected_and_the_file_is_untouched() {
|
||||||
|
let (_dir, state, app, cookie) = setup().await;
|
||||||
|
let empty = post(
|
||||||
|
&app,
|
||||||
|
"/dashboard/profile",
|
||||||
|
"content=+%0A".into(),
|
||||||
|
Some(&cookie),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(empty.status(), StatusCode::BAD_REQUEST);
|
||||||
|
let big = format!("content={}", "x".repeat(MAX_PROFILE_BYTES + 10));
|
||||||
|
let oversized = post(&app, "/dashboard/profile", big, Some(&cookie)).await;
|
||||||
|
assert_eq!(oversized.status(), StatusCode::BAD_REQUEST);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(&state.config().profile_path).unwrap(),
|
||||||
|
"# Original\n\nProse.\n"
|
||||||
|
);
|
||||||
|
assert!(version_rows(&state.db).await.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn profile_routes_are_admin_only() {
|
||||||
|
let (_dir, state, app, _admin) = setup().await;
|
||||||
|
let anonymous = get(&app, None).await;
|
||||||
|
assert_eq!(anonymous.status(), StatusCode::FOUND);
|
||||||
|
assert_eq!(
|
||||||
|
anonymous.headers().get(header::LOCATION).unwrap(),
|
||||||
|
"/login?next=%2Fdashboard%2Fprofile"
|
||||||
|
);
|
||||||
|
let reader = login(&app, "reader").await;
|
||||||
|
assert_eq!(
|
||||||
|
get(&app, Some(&reader)).await.status(),
|
||||||
|
StatusCode::FORBIDDEN
|
||||||
|
);
|
||||||
|
let save = post(
|
||||||
|
&app,
|
||||||
|
"/dashboard/profile",
|
||||||
|
"content=%23+Hacked%0A".into(),
|
||||||
|
Some(&reader),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(save.status(), StatusCode::FORBIDDEN);
|
||||||
|
let restore = post(
|
||||||
|
&app,
|
||||||
|
"/dashboard/profile/restore",
|
||||||
|
"version_id=1".into(),
|
||||||
|
Some(&reader),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(restore.status(), StatusCode::FORBIDDEN);
|
||||||
|
let anonymous_save = post(
|
||||||
|
&app,
|
||||||
|
"/dashboard/profile",
|
||||||
|
"content=%23+Hacked%0A".into(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(anonymous_save.status(), StatusCode::FOUND);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(&state.config().profile_path).unwrap(),
|
||||||
|
"# Original\n\nProse.\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,647 @@
|
|||||||
|
//! Dashboard: the stats page (`/dashboard/stats`, dashboard plan §12) and the
|
||||||
|
//! server-rendered SVG sparkline the overview shares (§9.1).
|
||||||
|
//!
|
||||||
|
//! The figures come from `telemetry::stats_data`, the same source as
|
||||||
|
//! `daily-epub stats`; the page shows them as tables plus three sparklines
|
||||||
|
//! (cost per day stacked per provider, selected per issue, ratings per week by
|
||||||
|
//! label) and the retriever yield with its up/down ratio.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
|
use askama::Template;
|
||||||
|
use axum::Router;
|
||||||
|
use axum::extract::{Extension, Query, State};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::routing::get;
|
||||||
|
use axum_login::tower_sessions::Session;
|
||||||
|
use jiff::Timestamp;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::curate::telemetry::{self, StatsData};
|
||||||
|
use crate::report::RunReport;
|
||||||
|
use crate::server::AppState;
|
||||||
|
use crate::web::session::{AuthSession, Viewer};
|
||||||
|
use crate::web::{Html, Page, WebError, take_flash};
|
||||||
|
|
||||||
|
use super::fmt_usd;
|
||||||
|
|
||||||
|
/// Routes contributed by this page group (merged by `dashboard::router`).
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
Router::new().route("/dashboard/stats", get(stats))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The windows the page offers; anything else falls back to the first.
|
||||||
|
pub const WINDOWS: [i64; 3] = [14, 30, 90];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sparklines (§9.1, §12)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const SPARK_WIDTH: f64 = 240.0;
|
||||||
|
const SPARK_HEIGHT: f64 = 48.0;
|
||||||
|
const SPARK_PAD: f64 = 2.0;
|
||||||
|
/// Fill classes cycle through this many series colours (`.spark .s0` …).
|
||||||
|
pub const SERIES_CLASSES: usize = 6;
|
||||||
|
|
||||||
|
/// One stacked-bar segment: pre-formatted SVG coordinates, a series index for
|
||||||
|
/// its fill class and a `<title>` label.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Bar {
|
||||||
|
pub x: String,
|
||||||
|
pub y: String,
|
||||||
|
pub w: String,
|
||||||
|
pub h: String,
|
||||||
|
pub series: usize,
|
||||||
|
pub label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Legend {
|
||||||
|
pub name: String,
|
||||||
|
pub series: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A small SVG chart rendered by `dashboard/_sparkline.html` from numbers
|
||||||
|
/// only — presentation attributes and classes, no inline styles (the CSP is
|
||||||
|
/// `style-src 'self'`) and no `|safe`.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Sparkline {
|
||||||
|
pub title: String,
|
||||||
|
pub caption: String,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
/// `<polyline points>` of a line chart; empty for a bar chart.
|
||||||
|
pub points: String,
|
||||||
|
pub bars: Vec<Bar>,
|
||||||
|
pub legend: Vec<Legend>,
|
||||||
|
pub first_label: String,
|
||||||
|
pub last_label: String,
|
||||||
|
pub empty: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn coord(value: f64) -> String {
|
||||||
|
let text = format!("{value:.1}");
|
||||||
|
text.strip_suffix(".0").map(str::to_string).unwrap_or(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Sparkline {
|
||||||
|
fn blank(title: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
title: title.to_string(),
|
||||||
|
caption: String::new(),
|
||||||
|
width: SPARK_WIDTH as u32,
|
||||||
|
height: SPARK_HEIGHT as u32,
|
||||||
|
points: String::new(),
|
||||||
|
bars: Vec::new(),
|
||||||
|
legend: Vec::new(),
|
||||||
|
first_label: String::new(),
|
||||||
|
last_label: String::new(),
|
||||||
|
empty: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A line over `values` (baseline 0), labelled with the first and last
|
||||||
|
/// point's label; the caption reports the count and the maximum.
|
||||||
|
pub fn line(
|
||||||
|
title: &str,
|
||||||
|
values: &[f64],
|
||||||
|
labels: (&str, &str),
|
||||||
|
unit: &str,
|
||||||
|
format_max: impl Fn(f64) -> String,
|
||||||
|
) -> Self {
|
||||||
|
let mut spark = Self::blank(title);
|
||||||
|
if values.is_empty() {
|
||||||
|
return spark;
|
||||||
|
}
|
||||||
|
let max = values.iter().copied().fold(0.0_f64, f64::max);
|
||||||
|
let inner_w = SPARK_WIDTH - 2.0 * SPARK_PAD;
|
||||||
|
let inner_h = SPARK_HEIGHT - 2.0 * SPARK_PAD;
|
||||||
|
let step = if values.len() > 1 {
|
||||||
|
inner_w / (values.len() - 1) as f64
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
let mut points = String::new();
|
||||||
|
for (index, value) in values.iter().enumerate() {
|
||||||
|
let x = if values.len() > 1 {
|
||||||
|
SPARK_PAD + step * index as f64
|
||||||
|
} else {
|
||||||
|
SPARK_WIDTH / 2.0
|
||||||
|
};
|
||||||
|
let y = if max > 0.0 {
|
||||||
|
SPARK_HEIGHT - SPARK_PAD - value.max(0.0) / max * inner_h
|
||||||
|
} else {
|
||||||
|
SPARK_HEIGHT - SPARK_PAD
|
||||||
|
};
|
||||||
|
if !points.is_empty() {
|
||||||
|
points.push(' ');
|
||||||
|
}
|
||||||
|
let _ = write!(points, "{},{}", coord(x), coord(y));
|
||||||
|
}
|
||||||
|
spark.points = points;
|
||||||
|
spark.caption = format!("{} {unit} · max {}", values.len(), format_max(max));
|
||||||
|
spark.first_label = labels.0.to_string();
|
||||||
|
spark.last_label = labels.1.to_string();
|
||||||
|
spark.empty = false;
|
||||||
|
spark
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stacked bars: one column per `(label, segments)` where each segment is
|
||||||
|
/// `(series name, value)`; series get fill classes in first-seen order.
|
||||||
|
pub fn stacked(
|
||||||
|
title: &str,
|
||||||
|
columns: &[(String, Vec<(String, f64)>)],
|
||||||
|
unit: &str,
|
||||||
|
format_max: impl Fn(f64) -> String,
|
||||||
|
) -> Self {
|
||||||
|
let mut spark = Self::blank(title);
|
||||||
|
if columns.is_empty() {
|
||||||
|
return spark;
|
||||||
|
}
|
||||||
|
let mut series: Vec<String> = Vec::new();
|
||||||
|
for (_, segments) in columns {
|
||||||
|
for (name, _) in segments {
|
||||||
|
if !series.contains(name) {
|
||||||
|
series.push(name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let max = columns
|
||||||
|
.iter()
|
||||||
|
.map(|(_, segments)| segments.iter().map(|(_, v)| v.max(0.0)).sum::<f64>())
|
||||||
|
.fold(0.0_f64, f64::max);
|
||||||
|
let inner_w = SPARK_WIDTH - 2.0 * SPARK_PAD;
|
||||||
|
let inner_h = SPARK_HEIGHT - 2.0 * SPARK_PAD;
|
||||||
|
let slot = inner_w / columns.len() as f64;
|
||||||
|
let gap = if slot > 3.0 { 1.0 } else { 0.0 };
|
||||||
|
let bar_w = (slot - gap).max(0.5);
|
||||||
|
let mut bars = Vec::new();
|
||||||
|
for (index, (label, segments)) in columns.iter().enumerate() {
|
||||||
|
let x = SPARK_PAD + slot * index as f64;
|
||||||
|
let mut top = SPARK_HEIGHT - SPARK_PAD;
|
||||||
|
for (name, value) in segments {
|
||||||
|
let value = value.max(0.0);
|
||||||
|
if value <= 0.0 || max <= 0.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let h = value / max * inner_h;
|
||||||
|
top -= h;
|
||||||
|
let series_index = series.iter().position(|s| s == name).unwrap_or(0);
|
||||||
|
bars.push(Bar {
|
||||||
|
x: coord(x),
|
||||||
|
y: coord(top),
|
||||||
|
w: coord(bar_w),
|
||||||
|
h: coord(h),
|
||||||
|
series: series_index % SERIES_CLASSES,
|
||||||
|
label: format!("{label} · {name}: {}", format_max(value)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
spark.bars = bars;
|
||||||
|
spark.legend = series
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, name)| Legend {
|
||||||
|
name: name.clone(),
|
||||||
|
series: index % SERIES_CLASSES,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
spark.caption = format!("{} {unit} · max {}", columns.len(), format_max(max));
|
||||||
|
spark.first_label = columns[0].0.clone();
|
||||||
|
spark.last_label = columns[columns.len() - 1].0.clone();
|
||||||
|
spark.empty = false;
|
||||||
|
spark
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stats page (§12)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct StatsQuery {
|
||||||
|
days: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct KeyValue {
|
||||||
|
key: String,
|
||||||
|
value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct RetrieverLine {
|
||||||
|
retriever: String,
|
||||||
|
rated: i64,
|
||||||
|
up: i64,
|
||||||
|
down: i64,
|
||||||
|
ratio: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct CostRow {
|
||||||
|
date: String,
|
||||||
|
cells: Vec<String>,
|
||||||
|
total: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct RunLine {
|
||||||
|
run_id: i64,
|
||||||
|
date: String,
|
||||||
|
status: String,
|
||||||
|
cost: String,
|
||||||
|
selected: i64,
|
||||||
|
duration: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "dashboard/stats.html")]
|
||||||
|
struct StatsTemplate {
|
||||||
|
page: Page,
|
||||||
|
days: i64,
|
||||||
|
windows: Vec<i64>,
|
||||||
|
since_date: String,
|
||||||
|
today: String,
|
||||||
|
summary: Vec<KeyValue>,
|
||||||
|
ratings: Vec<KeyValue>,
|
||||||
|
retrievers: Vec<RetrieverLine>,
|
||||||
|
exploration: Vec<KeyValue>,
|
||||||
|
providers: Vec<String>,
|
||||||
|
cost_rows: Vec<CostRow>,
|
||||||
|
cost_per_day: Vec<KeyValue>,
|
||||||
|
runs: Vec<RunLine>,
|
||||||
|
sparklines: Vec<Sparkline>,
|
||||||
|
text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `?days=` clamped to one of [`WINDOWS`].
|
||||||
|
pub fn window(days: Option<i64>) -> i64 {
|
||||||
|
days.filter(|days| WINDOWS.contains(days))
|
||||||
|
.unwrap_or(WINDOWS[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /dashboard/stats?days=14|30|90`.
|
||||||
|
async fn stats(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthSession,
|
||||||
|
Extension(session): Extension<Session>,
|
||||||
|
Query(query): Query<StatsQuery>,
|
||||||
|
) -> Result<Response, WebError> {
|
||||||
|
let viewer = auth.user().await.map(Viewer::from);
|
||||||
|
let days = window(query.days);
|
||||||
|
let now = Timestamp::now();
|
||||||
|
let data = telemetry::stats_data(&state.db, days, now)
|
||||||
|
.await
|
||||||
|
.map_err(WebError::Internal)?;
|
||||||
|
let text = telemetry::render_stats_text(&data);
|
||||||
|
|
||||||
|
let summary = vec![
|
||||||
|
KeyValue {
|
||||||
|
key: "Issues".into(),
|
||||||
|
value: data.issues.to_string(),
|
||||||
|
},
|
||||||
|
KeyValue {
|
||||||
|
key: "Articles published".into(),
|
||||||
|
value: data.published.to_string(),
|
||||||
|
},
|
||||||
|
KeyValue {
|
||||||
|
key: "Mean issue size".into(),
|
||||||
|
value: format!("{} articles", data.mean_issue_size()),
|
||||||
|
},
|
||||||
|
KeyValue {
|
||||||
|
key: "Explicit ratings".into(),
|
||||||
|
value: data.total_ratings.to_string(),
|
||||||
|
},
|
||||||
|
KeyValue {
|
||||||
|
key: "Ratings per issue".into(),
|
||||||
|
value: data.ratings_per_issue(),
|
||||||
|
},
|
||||||
|
KeyValue {
|
||||||
|
key: "Mean generation time".into(),
|
||||||
|
value: match data.mean_generation_secs() {
|
||||||
|
Some(secs) => format!(
|
||||||
|
"{} ({} runs)",
|
||||||
|
RunReport::format_duration(secs),
|
||||||
|
data.durations.len()
|
||||||
|
),
|
||||||
|
None => "n/a (0 runs)".into(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let ratings = data
|
||||||
|
.ratings_by_label
|
||||||
|
.iter()
|
||||||
|
.map(|(label, n)| KeyValue {
|
||||||
|
key: label.clone(),
|
||||||
|
value: n.to_string(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let retrievers = data
|
||||||
|
.per_retriever
|
||||||
|
.iter()
|
||||||
|
.map(|(retriever, counts)| RetrieverLine {
|
||||||
|
retriever: retriever.clone(),
|
||||||
|
rated: counts.rated,
|
||||||
|
up: counts.up,
|
||||||
|
down: counts.down,
|
||||||
|
ratio: counts.ratio(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let exploration = vec![
|
||||||
|
KeyValue {
|
||||||
|
key: "Admitted".into(),
|
||||||
|
value: data.exploration_admitted.to_string(),
|
||||||
|
},
|
||||||
|
KeyValue {
|
||||||
|
key: "Selected".into(),
|
||||||
|
value: data.exploration_selected.to_string(),
|
||||||
|
},
|
||||||
|
KeyValue {
|
||||||
|
key: "Rated positively".into(),
|
||||||
|
value: data.exploration_positive.to_string(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let providers: Vec<String> = data.provider_totals.keys().cloned().collect();
|
||||||
|
let cost_rows = data
|
||||||
|
.cost_by_day
|
||||||
|
.iter()
|
||||||
|
.map(|(date, by_provider)| CostRow {
|
||||||
|
date: date.clone(),
|
||||||
|
cells: providers
|
||||||
|
.iter()
|
||||||
|
.map(|provider| {
|
||||||
|
by_provider
|
||||||
|
.get(provider)
|
||||||
|
.map(|usd| format!("{usd:.3}"))
|
||||||
|
.unwrap_or_else(|| "—".into())
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
total: format!("{:.3}", by_provider.values().sum::<f64>()),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut cost_per_day: Vec<KeyValue> = data
|
||||||
|
.provider_totals
|
||||||
|
.iter()
|
||||||
|
.map(|(provider, total)| KeyValue {
|
||||||
|
key: provider.clone(),
|
||||||
|
value: format!("${:.3}", data.per_day(*total)),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
cost_per_day.push(KeyValue {
|
||||||
|
key: "total".into(),
|
||||||
|
value: format!("${:.3}", data.per_day(data.grand_total())),
|
||||||
|
});
|
||||||
|
let runs = data
|
||||||
|
.runs
|
||||||
|
.iter()
|
||||||
|
.map(|run| RunLine {
|
||||||
|
run_id: run.run_id,
|
||||||
|
date: run.date.clone(),
|
||||||
|
status: run.status.clone(),
|
||||||
|
cost: fmt_usd(run.cost_usd),
|
||||||
|
selected: run.selected,
|
||||||
|
duration: super::fmt_duration(run.duration_secs),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut page = Page::new("Stats", viewer, "stats");
|
||||||
|
page.flash = take_flash(&session).await?;
|
||||||
|
Ok(Html(StatsTemplate {
|
||||||
|
page,
|
||||||
|
days,
|
||||||
|
windows: WINDOWS.to_vec(),
|
||||||
|
since_date: data.since_date.clone(),
|
||||||
|
today: data.today.clone(),
|
||||||
|
summary,
|
||||||
|
ratings,
|
||||||
|
retrievers,
|
||||||
|
exploration,
|
||||||
|
providers,
|
||||||
|
cost_rows,
|
||||||
|
cost_per_day,
|
||||||
|
runs,
|
||||||
|
sparklines: stats_sparklines(&data),
|
||||||
|
text,
|
||||||
|
})
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every UTC date from `since` to `today` inclusive.
|
||||||
|
fn dates_between(since: &str, today: &str) -> Vec<String> {
|
||||||
|
let (Ok(mut date), Ok(end)) = (
|
||||||
|
since.parse::<jiff::civil::Date>(),
|
||||||
|
today.parse::<jiff::civil::Date>(),
|
||||||
|
) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let mut dates = Vec::new();
|
||||||
|
while date <= end && dates.len() < 400 {
|
||||||
|
dates.push(date.to_string());
|
||||||
|
match date.tomorrow() {
|
||||||
|
Ok(next) => date = next,
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dates
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stats page's three charts (§12).
|
||||||
|
fn stats_sparklines(data: &StatsData) -> Vec<Sparkline> {
|
||||||
|
let providers: Vec<String> = data.provider_totals.keys().cloned().collect();
|
||||||
|
let empty = BTreeMap::new();
|
||||||
|
let cost_columns: Vec<(String, Vec<(String, f64)>)> =
|
||||||
|
dates_between(&data.since_date, &data.today)
|
||||||
|
.into_iter()
|
||||||
|
.map(|date| {
|
||||||
|
let by_provider = data.cost_by_day.get(&date).unwrap_or(&empty);
|
||||||
|
let segments = providers
|
||||||
|
.iter()
|
||||||
|
.map(|provider| {
|
||||||
|
(
|
||||||
|
provider.clone(),
|
||||||
|
by_provider.get(provider).copied().unwrap_or(0.0),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
(date, segments)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let cost = if data.cost_by_day.is_empty() {
|
||||||
|
Sparkline::stacked("Cost per day", &[], "days", fmt_usd)
|
||||||
|
} else {
|
||||||
|
Sparkline::stacked("Cost per day", &cost_columns, "days", |usd| {
|
||||||
|
format!("${usd:.3}")
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let selected_values: Vec<f64> = data
|
||||||
|
.selected_per_issue
|
||||||
|
.iter()
|
||||||
|
.map(|(_, n)| *n as f64)
|
||||||
|
.collect();
|
||||||
|
let selected = Sparkline::line(
|
||||||
|
"Selected per issue",
|
||||||
|
&selected_values,
|
||||||
|
(
|
||||||
|
data.selected_per_issue
|
||||||
|
.first()
|
||||||
|
.map(|(date, _)| date.as_str())
|
||||||
|
.unwrap_or(""),
|
||||||
|
data.selected_per_issue
|
||||||
|
.last()
|
||||||
|
.map(|(date, _)| date.as_str())
|
||||||
|
.unwrap_or(""),
|
||||||
|
),
|
||||||
|
"issues",
|
||||||
|
|n| format!("{n:.0}"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let week_columns: Vec<(String, Vec<(String, f64)>)> = data
|
||||||
|
.ratings_per_week
|
||||||
|
.iter()
|
||||||
|
.map(|(week, by_label)| {
|
||||||
|
(
|
||||||
|
week.clone(),
|
||||||
|
by_label
|
||||||
|
.iter()
|
||||||
|
.map(|(label, n)| (label.clone(), *n as f64))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let weekly = Sparkline::stacked("Ratings per week", &week_columns, "weeks", |n| {
|
||||||
|
format!("{n:.0}")
|
||||||
|
});
|
||||||
|
vec![cost, selected, weekly]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::web::dashboard::tests::{
|
||||||
|
app_with_users, assert_admin_only, get, login_cookie, response_text, seed,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn window_falls_back_to_fourteen_days() {
|
||||||
|
assert_eq!(window(None), 14);
|
||||||
|
assert_eq!(window(Some(30)), 30);
|
||||||
|
assert_eq!(window(Some(90)), 90);
|
||||||
|
assert_eq!(window(Some(7)), 14);
|
||||||
|
assert_eq!(window(Some(-1)), 14);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_sparkline_scales_to_the_maximum_and_marks_empties() {
|
||||||
|
let spark = Sparkline::line(
|
||||||
|
"Cost",
|
||||||
|
&[0.0, 2.0, 1.0],
|
||||||
|
("2026-09-01", "2026-09-03"),
|
||||||
|
"runs",
|
||||||
|
fmt_usd,
|
||||||
|
);
|
||||||
|
assert!(!spark.empty);
|
||||||
|
assert_eq!(spark.points, "2,46 120,2 238,24");
|
||||||
|
assert_eq!(spark.caption, "3 runs · max $2.00");
|
||||||
|
assert_eq!(spark.first_label, "2026-09-01");
|
||||||
|
assert_eq!(spark.last_label, "2026-09-03");
|
||||||
|
assert!(spark.bars.is_empty());
|
||||||
|
|
||||||
|
let flat = Sparkline::line("Flat", &[0.0, 0.0], ("a", "b"), "runs", fmt_usd);
|
||||||
|
assert_eq!(flat.points, "2,46 238,46");
|
||||||
|
|
||||||
|
let none = Sparkline::line("None", &[], ("", ""), "runs", fmt_usd);
|
||||||
|
assert!(none.empty);
|
||||||
|
assert!(none.points.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stacked_sparkline_assigns_series_classes_in_first_seen_order() {
|
||||||
|
let columns = vec![
|
||||||
|
(
|
||||||
|
"2026-09-01".to_string(),
|
||||||
|
vec![("deepseek".to_string(), 0.1), ("voyage".to_string(), 0.1)],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"2026-09-02".to_string(),
|
||||||
|
vec![("deepseek".to_string(), 0.0), ("voyage".to_string(), 0.4)],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
let spark =
|
||||||
|
Sparkline::stacked("Cost per day", &columns, "days", |usd| format!("${usd:.3}"));
|
||||||
|
assert!(!spark.empty);
|
||||||
|
assert_eq!(spark.legend.len(), 2);
|
||||||
|
assert_eq!(spark.legend[0].name, "deepseek");
|
||||||
|
assert_eq!(spark.legend[0].series, 0);
|
||||||
|
assert_eq!(spark.legend[1].series, 1);
|
||||||
|
// Three visible segments: the zero-valued one is skipped.
|
||||||
|
assert_eq!(spark.bars.len(), 3);
|
||||||
|
assert_eq!(spark.bars[0].series, 0);
|
||||||
|
assert_eq!(spark.bars[0].h, "11");
|
||||||
|
assert_eq!(spark.bars[0].y, "35");
|
||||||
|
assert_eq!(spark.bars[1].y, "24", "stacked on top of the first");
|
||||||
|
assert_eq!(spark.bars[2].h, "44");
|
||||||
|
assert_eq!(spark.bars[2].label, "2026-09-02 · voyage: $0.400");
|
||||||
|
assert_eq!(spark.caption, "2 days · max $0.400");
|
||||||
|
|
||||||
|
let none = Sparkline::stacked("None", &[], "days", fmt_usd);
|
||||||
|
assert!(none.empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dates_between_is_inclusive() {
|
||||||
|
assert_eq!(
|
||||||
|
dates_between("2026-08-30", "2026-09-01"),
|
||||||
|
vec!["2026-08-30", "2026-08-31", "2026-09-01"]
|
||||||
|
);
|
||||||
|
assert!(dates_between("2026-09-02", "2026-09-01").is_empty());
|
||||||
|
assert!(dates_between("bad", "2026-09-01").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stats_page_shows_tables_sparklines_and_the_text() {
|
||||||
|
let seed = seed().await;
|
||||||
|
// The seed is dated 2026-09-02; the page windows on `now`, so move the
|
||||||
|
// runs and rating events into the last hour to keep the test stable.
|
||||||
|
let now = Timestamp::now();
|
||||||
|
let earlier = crate::db::fmt_ts(now - jiff::Span::new().hours(1));
|
||||||
|
sqlx::query("UPDATE runs SET started_at = ?, finished_at = ?")
|
||||||
|
.bind(&earlier)
|
||||||
|
.bind(crate::db::fmt_ts(now))
|
||||||
|
.execute(seed.db.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query("UPDATE rating_events SET event_at = ?")
|
||||||
|
.bind(&earlier)
|
||||||
|
.execute(seed.db.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let app = app_with_users(&seed.db).await;
|
||||||
|
let body = assert_admin_only(&app, "/dashboard/stats").await;
|
||||||
|
assert!(body.contains("?days=30"), "{body}");
|
||||||
|
assert!(body.contains("?days=90"), "{body}");
|
||||||
|
assert!(body.contains("Retriever yield"), "{body}");
|
||||||
|
assert!(body.contains("<rect"), "{body}");
|
||||||
|
assert!(body.contains("Ratings per week"), "{body}");
|
||||||
|
assert!(body.contains("badge loved"), "{body}");
|
||||||
|
assert!(body.contains("deepseek"), "{body}");
|
||||||
|
assert!(
|
||||||
|
body.contains("mean generation time:"),
|
||||||
|
"the CLI text is included"
|
||||||
|
);
|
||||||
|
assert!(!body.contains("style=\""), "no inline styles under the CSP");
|
||||||
|
assert!(
|
||||||
|
body.contains(&format!("/dashboard/runs/{}", seed.run_id)),
|
||||||
|
"{body}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// An out-of-catalogue window falls back to 14 days.
|
||||||
|
let admin = login_cookie(&app, "admin", "correct horse battery").await;
|
||||||
|
let fallback = get(&app, "/dashboard/stats?days=7", Some(&admin)).await;
|
||||||
|
assert_eq!(fallback.status(), axum::http::StatusCode::OK);
|
||||||
|
let body = response_text(fallback).await;
|
||||||
|
assert!(body.contains("stats: last 14 days"), "{body}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
//! Dashboard: the read-only Users page (`/dashboard/users`, plan §6.1).
|
||||||
|
|
||||||
|
use askama::Template;
|
||||||
|
use axum::Router;
|
||||||
|
use axum::extract::{Extension, State};
|
||||||
|
use axum::routing::get;
|
||||||
|
use axum_login::tower_sessions::Session;
|
||||||
|
|
||||||
|
use crate::server::AppState;
|
||||||
|
use crate::web::session::{AuthSession, Viewer};
|
||||||
|
use crate::web::{Html, Page, WebError, format_time, take_flash};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct UserLine {
|
||||||
|
username: String,
|
||||||
|
role: String,
|
||||||
|
disabled: bool,
|
||||||
|
created: String,
|
||||||
|
last_login: String,
|
||||||
|
open_sessions: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "dashboard/users.html")]
|
||||||
|
struct UsersTemplate {
|
||||||
|
page: Page,
|
||||||
|
users: Vec<UserLine>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Routes contributed by this page group (merged by `dashboard::router`).
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
Router::new().route("/dashboard/users", get(index))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn index(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthSession,
|
||||||
|
Extension(session): Extension<Session>,
|
||||||
|
) -> Result<Html<UsersTemplate>, WebError> {
|
||||||
|
let viewer = auth.user().await.map(Viewer::from);
|
||||||
|
let config = state.config();
|
||||||
|
let users = crate::web::users::list(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(WebError::Internal)?
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| UserLine {
|
||||||
|
username: row.user.username,
|
||||||
|
role: row.user.role.to_string(),
|
||||||
|
disabled: row.user.disabled,
|
||||||
|
created: format_time(row.user.created_at, &config),
|
||||||
|
last_login: row
|
||||||
|
.user
|
||||||
|
.last_login_at
|
||||||
|
.map(|at| format_time(at, &config))
|
||||||
|
.unwrap_or_else(|| "never".into()),
|
||||||
|
open_sessions: row.open_sessions,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut page = Page::new("Users", viewer, "users");
|
||||||
|
page.flash = take_flash(&session).await?;
|
||||||
|
Ok(Html(UsersTemplate { page, users }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::web::dashboard::tests::{app_with_users, assert_admin_only, seed};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn users_page_is_admin_only_and_lists_accounts_and_sessions() {
|
||||||
|
let seed = seed().await;
|
||||||
|
let app = app_with_users(&seed.db).await;
|
||||||
|
let body = assert_admin_only(&app, "/dashboard/users").await;
|
||||||
|
|
||||||
|
assert!(body.contains("<h1>Users</h1>"), "{body}");
|
||||||
|
assert!(body.contains("reader"), "{body}");
|
||||||
|
assert!(body.contains("admin"), "{body}");
|
||||||
|
assert!(body.contains("Open sessions"), "{body}");
|
||||||
|
assert!(body.contains("daily-epub users"), "{body}");
|
||||||
|
}
|
||||||
|
}
|
||||||
+2269
File diff suppressed because it is too large.
Load diff
+1093
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,399 @@
|
|||||||
|
use askama::Template;
|
||||||
|
use axum::extract::{Extension, Path, State};
|
||||||
|
use axum::http::{HeaderMap, StatusCode, header};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum_login::tower_sessions::Session;
|
||||||
|
use jiff::civil::Date;
|
||||||
|
|
||||||
|
use crate::server::AppState;
|
||||||
|
use crate::types::{Issue, SocialSource};
|
||||||
|
use crate::web::issue::{self, Download};
|
||||||
|
use crate::web::session::{AuthSession, Viewer};
|
||||||
|
use crate::web::{Html, Page, WebError};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PublicIssue {
|
||||||
|
pub date: Date,
|
||||||
|
pub issue_number: i64,
|
||||||
|
pub display_date: String,
|
||||||
|
pub short_date: String,
|
||||||
|
pub article_count: i64,
|
||||||
|
pub reading_minutes: i64,
|
||||||
|
pub stats_line: String,
|
||||||
|
pub sections: Vec<PublicSection>,
|
||||||
|
pub generated_at: jiff::Timestamp,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PublicSection {
|
||||||
|
pub name: String,
|
||||||
|
pub entries: Vec<PublicEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PublicEntry {
|
||||||
|
pub title: String,
|
||||||
|
pub url: String,
|
||||||
|
pub author: Option<String>,
|
||||||
|
pub source: String,
|
||||||
|
pub domain: String,
|
||||||
|
pub reading_minutes: i64,
|
||||||
|
pub word_count: i64,
|
||||||
|
pub summary: Option<String>,
|
||||||
|
pub why: Option<String>,
|
||||||
|
pub comment_links: Vec<CommentLink>,
|
||||||
|
pub is_lead: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CommentLink {
|
||||||
|
pub label: String,
|
||||||
|
pub url: String,
|
||||||
|
pub meta: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&Issue> for PublicIssue {
|
||||||
|
fn from(issue: &Issue) -> Self {
|
||||||
|
let sections =
|
||||||
|
issue
|
||||||
|
.lineup
|
||||||
|
.section_order
|
||||||
|
.iter()
|
||||||
|
.map(|name| PublicSection {
|
||||||
|
name: name.clone(),
|
||||||
|
entries: issue
|
||||||
|
.lineup
|
||||||
|
.section_picks(name)
|
||||||
|
.into_iter()
|
||||||
|
.map(|pick| {
|
||||||
|
let article = &pick.article;
|
||||||
|
let mut comment_links: Vec<CommentLink> = article
|
||||||
|
.social
|
||||||
|
.iter()
|
||||||
|
.filter_map(|social| {
|
||||||
|
let url = social.item_url.clone()?;
|
||||||
|
let label = match social.source {
|
||||||
|
SocialSource::Hn => "Hacker News",
|
||||||
|
SocialSource::Lobsters => "Lobsters",
|
||||||
|
SocialSource::Reddit => "Reddit",
|
||||||
|
SocialSource::X => "X",
|
||||||
|
};
|
||||||
|
Some(CommentLink {
|
||||||
|
label: label.into(),
|
||||||
|
url,
|
||||||
|
meta: format!(
|
||||||
|
"{} points · {} comments",
|
||||||
|
social.score, social.num_comments
|
||||||
|
),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if let Some(url) = article.comments_url.as_ref().filter(|url| {
|
||||||
|
*url != &article.canonical_url && *url != &article.url
|
||||||
|
}) {
|
||||||
|
comment_links.push(CommentLink {
|
||||||
|
label: "Comments".into(),
|
||||||
|
url: url.clone(),
|
||||||
|
meta: String::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
PublicEntry {
|
||||||
|
title: article.title.clone(),
|
||||||
|
url: article.canonical_url.clone(),
|
||||||
|
author: article.author.clone(),
|
||||||
|
source: article.feed_title.clone(),
|
||||||
|
domain: domain(&article.canonical_url),
|
||||||
|
reading_minutes: article.reading_minutes(),
|
||||||
|
word_count: article.word_count,
|
||||||
|
summary: pick
|
||||||
|
.summary
|
||||||
|
.as_deref()
|
||||||
|
.or_else(|| {
|
||||||
|
issue
|
||||||
|
.editorial
|
||||||
|
.summaries
|
||||||
|
.get(&article.id)
|
||||||
|
.map(String::as_str)
|
||||||
|
})
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|summary| !summary.is_empty())
|
||||||
|
.map(str::to_string),
|
||||||
|
why: pick.why.clone(),
|
||||||
|
comment_links,
|
||||||
|
is_lead: pick.is_lead,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Self {
|
||||||
|
date: issue.meta.date,
|
||||||
|
issue_number: issue.meta.issue_number,
|
||||||
|
display_date: issue.meta.display_date.clone(),
|
||||||
|
short_date: crate::pipeline::short_display_date(issue.meta.date),
|
||||||
|
article_count: issue.meta.article_count,
|
||||||
|
reading_minutes: issue.meta.reading_minutes,
|
||||||
|
stats_line: issue.meta.stats_line(),
|
||||||
|
sections,
|
||||||
|
generated_at: issue.meta.generated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn domain(raw: &str) -> String {
|
||||||
|
url::Url::parse(raw)
|
||||||
|
.ok()
|
||||||
|
.and_then(|url| url.host_str().map(str::to_string))
|
||||||
|
.map(|host| host.strip_prefix("www.").unwrap_or(&host).to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "issue_public.html")]
|
||||||
|
struct IssuePublicTemplate {
|
||||||
|
page: Page,
|
||||||
|
issue: PublicIssue,
|
||||||
|
downloads: Vec<Download>,
|
||||||
|
empty: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ArchiveMonth {
|
||||||
|
pub label: String,
|
||||||
|
pub issues: Vec<ArchiveIssue>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ArchiveIssue {
|
||||||
|
pub date: Date,
|
||||||
|
pub display_date: String,
|
||||||
|
pub issue_number: i64,
|
||||||
|
pub article_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "issue_list.html")]
|
||||||
|
struct IssueListTemplate {
|
||||||
|
page: Page,
|
||||||
|
months: Vec<ArchiveMonth>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "feed_entry.html")]
|
||||||
|
struct FeedEntryTemplate<'a> {
|
||||||
|
issue: &'a PublicIssue,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn latest(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthSession,
|
||||||
|
Extension(session): Extension<Session>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Response, WebError> {
|
||||||
|
let Some(date) = state.db.latest_issue_date().await? else {
|
||||||
|
let viewer = auth.user().await.map(Viewer::from);
|
||||||
|
let response = Html(IssuePublicTemplate {
|
||||||
|
page: Page::new("Latest issue", viewer, "latest"),
|
||||||
|
issue: empty_issue(),
|
||||||
|
downloads: Vec::new(),
|
||||||
|
empty: true,
|
||||||
|
})
|
||||||
|
.into_response();
|
||||||
|
return Ok(public_cache(response, &headers));
|
||||||
|
};
|
||||||
|
show_issue(State(state), auth, Extension(session), headers, Path(date)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn show_issue(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthSession,
|
||||||
|
Extension(session): Extension<Session>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(date): Path<Date>,
|
||||||
|
) -> Result<Response, WebError> {
|
||||||
|
let Some(view) = issue::load(&state.db, &state.config(), date).await? else {
|
||||||
|
return Err(WebError::NotFound);
|
||||||
|
};
|
||||||
|
let viewer = auth.user().await.map(Viewer::from);
|
||||||
|
if let Some(viewer) = viewer {
|
||||||
|
let response = issue::render_full(&state, view, viewer, &session).await?;
|
||||||
|
return Ok(public_cache(response, &headers));
|
||||||
|
}
|
||||||
|
let response = Html(IssuePublicTemplate {
|
||||||
|
page: Page::new(format!("Issue {date}"), None, "latest"),
|
||||||
|
issue: PublicIssue::from(&view.issue),
|
||||||
|
downloads: Vec::new(),
|
||||||
|
empty: false,
|
||||||
|
})
|
||||||
|
.into_response();
|
||||||
|
Ok(public_cache(response, &headers))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn archive(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthSession,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Response, WebError> {
|
||||||
|
let rows = state.db.issue_dates(None).await?;
|
||||||
|
let mut months: Vec<ArchiveMonth> = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
let key = format!("{:04}-{:02}", row.date.year(), row.date.month());
|
||||||
|
if months.last().map(|month| month.label.as_str()) != Some(key.as_str()) {
|
||||||
|
months.push(ArchiveMonth {
|
||||||
|
label: key,
|
||||||
|
issues: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(month) = months.last_mut() {
|
||||||
|
month.issues.push(ArchiveIssue {
|
||||||
|
date: row.date,
|
||||||
|
display_date: crate::pipeline::display_date(row.date),
|
||||||
|
issue_number: row.issue_number,
|
||||||
|
article_count: row.article_count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let response = Html(IssueListTemplate {
|
||||||
|
page: Page::new(
|
||||||
|
"Issue archive",
|
||||||
|
auth.user().await.map(Viewer::from),
|
||||||
|
"archive",
|
||||||
|
),
|
||||||
|
months,
|
||||||
|
})
|
||||||
|
.into_response();
|
||||||
|
Ok(public_cache(response, &headers))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn feed(State(state): State<AppState>) -> Result<Response, WebError> {
|
||||||
|
let config = state.config();
|
||||||
|
let rows = state.db.issue_dates(Some(30)).await?;
|
||||||
|
let mut entries = String::new();
|
||||||
|
let mut updated = jiff::Timestamp::UNIX_EPOCH;
|
||||||
|
for row in rows {
|
||||||
|
let Some(view) = issue::load(&state.db, &config, row.date).await? else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
updated = updated.max(view.issue.meta.generated_at);
|
||||||
|
let issue = PublicIssue::from(&view.issue);
|
||||||
|
let content = FeedEntryTemplate { issue: &issue }
|
||||||
|
.render()
|
||||||
|
.map_err(|error| WebError::Internal(error.into()))?;
|
||||||
|
let href = format!(
|
||||||
|
"{}/issues/{}",
|
||||||
|
config.server.public_url.trim_end_matches('/'),
|
||||||
|
issue.date
|
||||||
|
);
|
||||||
|
entries.push_str(&format!(
|
||||||
|
"<entry><id>tag:{},{}:issue/{}</id><title>The Daily EPUB — {}</title><updated>{}</updated><link rel=\"alternate\" href=\"{}\"/><content type=\"html\">{}</content></entry>",
|
||||||
|
feed_host(&config.server.public_url),
|
||||||
|
issue.date.year(),
|
||||||
|
issue.date,
|
||||||
|
issue.date,
|
||||||
|
issue.generated_at,
|
||||||
|
xml_escape(&href),
|
||||||
|
xml_escape(&content),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let home = config.server.public_url.trim_end_matches('/');
|
||||||
|
let body = format!(
|
||||||
|
"<?xml version=\"1.0\" encoding=\"utf-8\"?><feed xmlns=\"http://www.w3.org/2005/Atom\"><id>{}</id><title>The Daily EPUB</title><updated>{}</updated><link rel=\"self\" href=\"{}/feed.xml\"/>{}</feed>",
|
||||||
|
xml_escape(home),
|
||||||
|
updated,
|
||||||
|
xml_escape(home),
|
||||||
|
entries
|
||||||
|
);
|
||||||
|
Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, "application/atom+xml; charset=utf-8"),
|
||||||
|
(header::CACHE_CONTROL, "public, max-age=300"),
|
||||||
|
],
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn robots() -> Response {
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
[(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
|
||||||
|
"User-agent: *\nAllow: /\nAllow: /issues\nDisallow: /dashboard\nDisallow: /login\nDisallow: /files\nDisallow: /r\nDisallow: /opds\n",
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn public_cache(mut response: Response, request_headers: &HeaderMap) -> Response {
|
||||||
|
let value = if request_headers
|
||||||
|
.get(header::COOKIE)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.is_some_and(|cookies| cookies.contains("daily_session="))
|
||||||
|
{
|
||||||
|
"private, no-store"
|
||||||
|
} else {
|
||||||
|
"public, max-age=300"
|
||||||
|
};
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::CACHE_CONTROL,
|
||||||
|
axum::http::HeaderValue::from_static(value),
|
||||||
|
);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
fn feed_host(public_url: &str) -> String {
|
||||||
|
url::Url::parse(public_url)
|
||||||
|
.ok()
|
||||||
|
.and_then(|url| url.host_str().map(str::to_string))
|
||||||
|
.unwrap_or_else(|| "daily.hallada.net".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn xml_escape(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_issue() -> PublicIssue {
|
||||||
|
PublicIssue {
|
||||||
|
date: "1970-01-01".parse().expect("valid epoch date"),
|
||||||
|
issue_number: 0,
|
||||||
|
display_date: String::new(),
|
||||||
|
short_date: String::new(),
|
||||||
|
article_count: 0,
|
||||||
|
reading_minutes: 0,
|
||||||
|
stats_line: String::new(),
|
||||||
|
sections: Vec::new(),
|
||||||
|
generated_at: jiff::Timestamp::UNIX_EPOCH,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_issue_shows_summaries_and_why_but_no_bodies() {
|
||||||
|
let source = crate::epub::fixtures::issue();
|
||||||
|
let public = PublicIssue::from(&source);
|
||||||
|
let html = FeedEntryTemplate { issue: &public }.render().unwrap();
|
||||||
|
assert!(html.contains("The Lead Story"));
|
||||||
|
assert!(html.contains("Hacker News"));
|
||||||
|
assert!(html.contains("What it argues, and why it is worth the time."));
|
||||||
|
assert!(html.contains("A short abstract for the second piece."));
|
||||||
|
assert!(html.contains("The systems story with enough operational detail to matter"));
|
||||||
|
assert!(html.contains("A small-scene delight outside the usual technical orbit"));
|
||||||
|
for private in [
|
||||||
|
"Two stories today",
|
||||||
|
"Body of",
|
||||||
|
"write path",
|
||||||
|
"Agreed",
|
||||||
|
"Something happened",
|
||||||
|
"concise view of the day",
|
||||||
|
] {
|
||||||
|
assert!(!html.contains(private), "leaked {private:?} in {html}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+229
@@ -0,0 +1,229 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use axum::Form;
|
||||||
|
use axum::extract::{Extension, FromRequest, Json, Request, State};
|
||||||
|
use axum::http::{HeaderMap, header};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum_login::tower_sessions::Session;
|
||||||
|
use jiff::civil::Date;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::Row;
|
||||||
|
|
||||||
|
use crate::server::AppState;
|
||||||
|
use crate::types::{ArticleId, RatingEvent, Vote};
|
||||||
|
use crate::web::session::{self, AuthSession};
|
||||||
|
use crate::web::{Flash, WebError};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RatingWidget {
|
||||||
|
pub article_id: ArticleId,
|
||||||
|
pub issue_date: String,
|
||||||
|
pub next: String,
|
||||||
|
pub current: String,
|
||||||
|
pub show_note: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RatingWidget {
|
||||||
|
pub fn for_issue(
|
||||||
|
article_id: ArticleId,
|
||||||
|
issue_date: Date,
|
||||||
|
next: impl Into<String>,
|
||||||
|
current: Option<&str>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
article_id,
|
||||||
|
issue_date: issue_date.to_string(),
|
||||||
|
next: next.into(),
|
||||||
|
current: web_label(current).to_string(),
|
||||||
|
show_note: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn web_label(label: Option<&str>) -> &str {
|
||||||
|
match label {
|
||||||
|
Some("not_for_me" | "down") => "down",
|
||||||
|
Some("loved") => "loved",
|
||||||
|
Some("good") => "good",
|
||||||
|
Some("cleared") => "cleared",
|
||||||
|
_ => "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn current_for_issue(
|
||||||
|
state: &AppState,
|
||||||
|
date: Date,
|
||||||
|
) -> Result<HashMap<ArticleId, String>, WebError> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"WITH ranked AS (
|
||||||
|
SELECT re.article_id, re.label,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY re.article_id
|
||||||
|
ORDER BY re.event_at DESC, re.id DESC
|
||||||
|
) AS event_rank
|
||||||
|
FROM rating_events re
|
||||||
|
JOIN issue_articles ia ON ia.article_id = re.article_id
|
||||||
|
WHERE re.kind = 'explicit' AND ia.issue_date = ?
|
||||||
|
)
|
||||||
|
SELECT article_id, label FROM ranked WHERE event_rank = 1",
|
||||||
|
)
|
||||||
|
.bind(date.to_string())
|
||||||
|
.fetch_all(state.db.pool())
|
||||||
|
.await
|
||||||
|
.map_err(crate::db::DbError::from)?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| (row.get("article_id"), row.get("label")))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct RatingInput {
|
||||||
|
article_id: ArticleId,
|
||||||
|
#[serde(default)]
|
||||||
|
issue_date: Option<String>,
|
||||||
|
label: String,
|
||||||
|
#[serde(default)]
|
||||||
|
note: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
next: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct RatingResponse {
|
||||||
|
article_id: ArticleId,
|
||||||
|
label: String,
|
||||||
|
event_id: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthSession,
|
||||||
|
Extension(session): Extension<Session>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
request: Request,
|
||||||
|
) -> Result<Response, WebError> {
|
||||||
|
let content_type = headers
|
||||||
|
.get(header::CONTENT_TYPE)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let input = if content_type.starts_with("application/json") {
|
||||||
|
Json::<RatingInput>::from_request(request, &state)
|
||||||
|
.await
|
||||||
|
.map(|Json(input)| input)
|
||||||
|
.map_err(|_| WebError::BadRequest("invalid rating JSON".into()))?
|
||||||
|
} else {
|
||||||
|
Form::<RatingInput>::from_request(request, &state)
|
||||||
|
.await
|
||||||
|
.map(|Form(input)| input)
|
||||||
|
.map_err(|_| WebError::BadRequest("invalid rating form".into()))?
|
||||||
|
};
|
||||||
|
|
||||||
|
let viewer = auth.user().await.ok_or_else(|| WebError::Unauthenticated {
|
||||||
|
next: "/rate".into(),
|
||||||
|
})?;
|
||||||
|
if state.db.get_article(input.article_id).await?.is_none() {
|
||||||
|
return Err(WebError::BadRequest(format!(
|
||||||
|
"article {} does not exist",
|
||||||
|
input.article_id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let issue_date = match input
|
||||||
|
.issue_date
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
Some(raw) => Some(
|
||||||
|
raw.parse::<Date>()
|
||||||
|
.map_err(|_| WebError::BadRequest("invalid issue date".into()))?,
|
||||||
|
),
|
||||||
|
None => {
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.latest_issue_date_for_article(input.article_id)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let config = state.config();
|
||||||
|
let (event_label, value, response_label, flash_label) = match input.label.as_str() {
|
||||||
|
"loved" => (
|
||||||
|
"loved",
|
||||||
|
Vote::Loved.value(&config.curation.feedback),
|
||||||
|
"loved",
|
||||||
|
"Loved it",
|
||||||
|
),
|
||||||
|
"good" => (
|
||||||
|
"good",
|
||||||
|
Vote::Good.value(&config.curation.feedback),
|
||||||
|
"good",
|
||||||
|
"Good",
|
||||||
|
),
|
||||||
|
"down" => (
|
||||||
|
"not_for_me",
|
||||||
|
Vote::NotForMe.value(&config.curation.feedback),
|
||||||
|
"down",
|
||||||
|
"Not for me",
|
||||||
|
),
|
||||||
|
"cleared" => ("cleared", 0.0, "cleared", "Cleared"),
|
||||||
|
_ => return Err(WebError::BadRequest("invalid rating label".into())),
|
||||||
|
};
|
||||||
|
let note = input
|
||||||
|
.note
|
||||||
|
.map(|note| note.trim().to_string())
|
||||||
|
.filter(|note| !note.is_empty());
|
||||||
|
let event_id = state
|
||||||
|
.db
|
||||||
|
.append_rating_event(&RatingEvent {
|
||||||
|
id: 0,
|
||||||
|
user_id: Some(viewer.id),
|
||||||
|
article_id: input.article_id,
|
||||||
|
issue_date,
|
||||||
|
kind: "explicit".into(),
|
||||||
|
source: "dashboard".into(),
|
||||||
|
label: event_label.into(),
|
||||||
|
value,
|
||||||
|
note,
|
||||||
|
event_at: jiff::Timestamp::now(),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let wants_json = headers
|
||||||
|
.get(header::ACCEPT)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.is_some_and(|value| value.contains("application/json"));
|
||||||
|
if wants_json {
|
||||||
|
return Ok(Json(RatingResponse {
|
||||||
|
article_id: input.article_id,
|
||||||
|
label: response_label.into(),
|
||||||
|
event_id,
|
||||||
|
})
|
||||||
|
.into_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
session
|
||||||
|
.insert(
|
||||||
|
"flash",
|
||||||
|
Flash {
|
||||||
|
kind: "success".into(),
|
||||||
|
text: format!("Rated: {flash_label}"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| WebError::Internal(error.into()))?;
|
||||||
|
let destination = session::valid_next(input.next.as_deref()).to_string();
|
||||||
|
Ok(axum::response::Redirect::to(&destination).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn database_and_widget_labels_are_mapped_explicitly() {
|
||||||
|
assert_eq!(web_label(Some("not_for_me")), "down");
|
||||||
|
assert_eq!(web_label(Some("cleared")), "cleared");
|
||||||
|
assert_eq!(web_label(None), "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,538 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use askama::Template;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use axum::Form;
|
||||||
|
use axum::extract::{Query, Request, State};
|
||||||
|
use axum::http::{StatusCode, header};
|
||||||
|
use axum::middleware::Next;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum_login::tower_sessions::session::{Id, Record};
|
||||||
|
use axum_login::tower_sessions::{SessionStore, session_store};
|
||||||
|
use axum_login::{AuthUser, AuthnBackend, AuthzBackend};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sqlx::{Row, SqlitePool};
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
|
use crate::db::{Db, fmt_ts};
|
||||||
|
use crate::server::AppState;
|
||||||
|
use crate::web::users::{self, Role, User};
|
||||||
|
use crate::web::{Html, Page, WebError};
|
||||||
|
|
||||||
|
const DUMMY_HASH: &str = "$argon2i$v=19$m=65536,t=1,p=1$c29tZXNhbHQAAAAAAAAAAA$+r0d29hqEB0yasKr55ZgICsQGSkl0v0kgwhd+U3wyRo";
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct SqliteSessionStore {
|
||||||
|
pool: SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqliteSessionStore {
|
||||||
|
pub fn new(pool: SqlitePool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_expired(&self) -> Result<u64, sqlx::Error> {
|
||||||
|
let result = sqlx::query("DELETE FROM sessions WHERE expiry <= unixepoch()")
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(result.rows_affected())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_for_user(&self, user_id: i64) -> Result<u64, sqlx::Error> {
|
||||||
|
let result = sqlx::query("DELETE FROM sessions WHERE user_id = ?")
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(result.rows_affected())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store_error(error: impl std::fmt::Display) -> session_store::Error {
|
||||||
|
session_store::Error::Backend(error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl SessionStore for SqliteSessionStore {
|
||||||
|
/// Insert a brand-new record, drawing a fresh id on the (astronomically
|
||||||
|
/// unlikely) collision with an existing row instead of overwriting it.
|
||||||
|
async fn create(&self, record: &mut Record) -> session_store::Result<()> {
|
||||||
|
loop {
|
||||||
|
let exists: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sessions WHERE id = ?")
|
||||||
|
.bind(record.id.to_string())
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(store_error)?;
|
||||||
|
if exists == 0 {
|
||||||
|
return self.save(record).await;
|
||||||
|
}
|
||||||
|
record.id = Id::default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save(&self, record: &Record) -> session_store::Result<()> {
|
||||||
|
let data = serde_json::to_string(&record.data).map_err(store_error)?;
|
||||||
|
let user_id = record
|
||||||
|
.data
|
||||||
|
.get("axum-login.data")
|
||||||
|
.and_then(|value| value.get("user_id"))
|
||||||
|
.and_then(serde_json::Value::as_i64);
|
||||||
|
let now = fmt_ts(jiff::Timestamp::now());
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO sessions (id, data, expiry, user_id, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET data = excluded.data, expiry = excluded.expiry,
|
||||||
|
user_id = excluded.user_id, updated_at = excluded.updated_at",
|
||||||
|
)
|
||||||
|
.bind(record.id.to_string())
|
||||||
|
.bind(data)
|
||||||
|
.bind(record.expiry_date.unix_timestamp())
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(now)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(store_error)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load(&self, id: &Id) -> session_store::Result<Option<Record>> {
|
||||||
|
let row =
|
||||||
|
sqlx::query("SELECT data, expiry FROM sessions WHERE id = ? AND expiry > unixepoch()")
|
||||||
|
.bind(id.to_string())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(store_error)?;
|
||||||
|
row.map(|row| {
|
||||||
|
let data = serde_json::from_str(&row.get::<String, _>("data")).map_err(store_error)?;
|
||||||
|
let expiry_date =
|
||||||
|
OffsetDateTime::from_unix_timestamp(row.get("expiry")).map_err(store_error)?;
|
||||||
|
Ok(Record {
|
||||||
|
id: *id,
|
||||||
|
data,
|
||||||
|
expiry_date,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, id: &Id) -> session_store::Result<()> {
|
||||||
|
sqlx::query("DELETE FROM sessions WHERE id = ?")
|
||||||
|
.bind(id.to_string())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(store_error)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct Credentials {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub next: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum BackendError {
|
||||||
|
#[error(transparent)]
|
||||||
|
Db(#[from] crate::db::DbError),
|
||||||
|
#[error("password verification task failed: {0}")]
|
||||||
|
Join(#[from] tokio::task::JoinError),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Backend {
|
||||||
|
db: Db,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Backend {
|
||||||
|
pub fn new(db: Db) -> Self {
|
||||||
|
Self { db }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthUser for User {
|
||||||
|
type Id = i64;
|
||||||
|
|
||||||
|
fn id(&self) -> Self::Id {
|
||||||
|
self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session_auth_hash(&self) -> &[u8] {
|
||||||
|
self.password_hash.as_bytes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthnBackend for Backend {
|
||||||
|
type User = User;
|
||||||
|
type Credentials = Credentials;
|
||||||
|
type Error = BackendError;
|
||||||
|
|
||||||
|
async fn authenticate(&self, creds: Credentials) -> Result<Option<User>, BackendError> {
|
||||||
|
let user = users::find_by_username(&self.db, &creds.username).await?;
|
||||||
|
let hash = user
|
||||||
|
.as_ref()
|
||||||
|
.map(|user| user.password_hash.clone())
|
||||||
|
.unwrap_or_else(|| DUMMY_HASH.to_string());
|
||||||
|
let password = creds.password;
|
||||||
|
let valid =
|
||||||
|
tokio::task::spawn_blocking(move || users::verify_password(&hash, &password)).await?;
|
||||||
|
Ok(user.filter(|user| valid && !user.disabled))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_user(&self, id: &i64) -> Result<Option<User>, BackendError> {
|
||||||
|
Ok(users::find_by_id(&self.db, *id)
|
||||||
|
.await?
|
||||||
|
.filter(|user| !user.disabled))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthzBackend for Backend {
|
||||||
|
type Permission = Role;
|
||||||
|
|
||||||
|
async fn get_user_permissions(&self, user: &User) -> Result<HashSet<Role>, BackendError> {
|
||||||
|
let permissions = match user.role {
|
||||||
|
Role::Admin => [Role::User, Role::Admin].into_iter().collect(),
|
||||||
|
Role::User => [Role::User].into_iter().collect(),
|
||||||
|
};
|
||||||
|
Ok(permissions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type AuthSession = axum_login::AuthSession<Backend>;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Viewer {
|
||||||
|
pub id: i64,
|
||||||
|
pub username: String,
|
||||||
|
pub role: Role,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<User> for Viewer {
|
||||||
|
fn from(user: User) -> Self {
|
||||||
|
Self {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn valid_next(next: Option<&str>) -> &str {
|
||||||
|
next.filter(|next| next.starts_with('/') && !next.starts_with("//"))
|
||||||
|
.unwrap_or("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn require_same_origin(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
request: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
|
if request.method() != axum::http::Method::POST {
|
||||||
|
return next.run(request).await;
|
||||||
|
}
|
||||||
|
let headers = request.headers();
|
||||||
|
if let Some(site) = headers
|
||||||
|
.get("sec-fetch-site")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
{
|
||||||
|
if !matches!(site, "same-origin" | "none") {
|
||||||
|
return StatusCode::FORBIDDEN.into_response();
|
||||||
|
}
|
||||||
|
} else if let Some(origin) = request_origin(headers) {
|
||||||
|
let config = state.config();
|
||||||
|
let public_origin = url::Url::parse(&config.server.public_url)
|
||||||
|
.ok()
|
||||||
|
.map(|url| url.origin().ascii_serialization());
|
||||||
|
let host_origin = headers
|
||||||
|
.get(header::HOST)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.map(|host| format!("{}://{host}", forwarded_scheme(headers)));
|
||||||
|
if public_origin.as_deref() != Some(origin.as_str())
|
||||||
|
&& host_origin.as_deref() != Some(origin.as_str())
|
||||||
|
{
|
||||||
|
return StatusCode::FORBIDDEN.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next.run(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
pub struct LoginQuery {
|
||||||
|
#[serde(default)]
|
||||||
|
next: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "login.html")]
|
||||||
|
struct LoginTemplate {
|
||||||
|
page: Page,
|
||||||
|
next: String,
|
||||||
|
error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "account.html")]
|
||||||
|
struct AccountTemplate {
|
||||||
|
page: Page,
|
||||||
|
error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login_page(auth: AuthSession, Query(query): Query<LoginQuery>) -> Response {
|
||||||
|
let viewer = auth.user().await.map(Viewer::from);
|
||||||
|
Html(LoginTemplate {
|
||||||
|
page: Page::new("Sign in", viewer, "login"),
|
||||||
|
next: valid_next(query.next.as_deref()).to_string(),
|
||||||
|
error: String::new(),
|
||||||
|
})
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthSession,
|
||||||
|
Form(credentials): Form<Credentials>,
|
||||||
|
) -> Result<Response, WebError> {
|
||||||
|
let destination = valid_next(credentials.next.as_deref()).to_string();
|
||||||
|
match auth
|
||||||
|
.authenticate(credentials)
|
||||||
|
.await
|
||||||
|
.map_err(|error| WebError::Internal(error.into()))?
|
||||||
|
{
|
||||||
|
Some(user) => {
|
||||||
|
auth.login(&user)
|
||||||
|
.await
|
||||||
|
.map_err(|error| WebError::Internal(error.into()))?;
|
||||||
|
sqlx::query("UPDATE users SET last_login_at = ? WHERE id = ?")
|
||||||
|
.bind(fmt_ts(jiff::Timestamp::now()))
|
||||||
|
.bind(user.id)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await
|
||||||
|
.map_err(crate::db::DbError::from)?;
|
||||||
|
Ok(axum::response::Redirect::to(&destination).into_response())
|
||||||
|
}
|
||||||
|
None => Ok((
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Html(LoginTemplate {
|
||||||
|
page: Page::new("Sign in", None, "login"),
|
||||||
|
next: destination,
|
||||||
|
error: "invalid username or password".into(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn logout(auth: AuthSession) -> Result<Response, WebError> {
|
||||||
|
auth.logout()
|
||||||
|
.await
|
||||||
|
.map_err(|error| WebError::Internal(error.into()))?;
|
||||||
|
Ok(axum::response::Redirect::to("/").into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn account(auth: AuthSession) -> Result<Response, WebError> {
|
||||||
|
let user = auth.user().await.ok_or_else(|| WebError::Unauthenticated {
|
||||||
|
next: "/account".into(),
|
||||||
|
})?;
|
||||||
|
Ok(Html(AccountTemplate {
|
||||||
|
page: Page::new("Account", Some(user.into()), "account"),
|
||||||
|
error: String::new(),
|
||||||
|
})
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct PasswordForm {
|
||||||
|
current_password: String,
|
||||||
|
new_password: String,
|
||||||
|
confirm_password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn change_password(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthSession,
|
||||||
|
Form(form): Form<PasswordForm>,
|
||||||
|
) -> Result<Response, WebError> {
|
||||||
|
let user = auth.user().await.ok_or_else(|| WebError::Unauthenticated {
|
||||||
|
next: "/account".into(),
|
||||||
|
})?;
|
||||||
|
let hash = user.password_hash.clone();
|
||||||
|
let current = form.current_password;
|
||||||
|
let valid = tokio::task::spawn_blocking(move || users::verify_password(&hash, ¤t))
|
||||||
|
.await
|
||||||
|
.map_err(|error| WebError::Internal(error.into()))?;
|
||||||
|
let problem = if !valid {
|
||||||
|
Some("current password is incorrect".to_string())
|
||||||
|
} else if form.new_password != form.confirm_password {
|
||||||
|
Some("the new passwords do not match".to_string())
|
||||||
|
} else {
|
||||||
|
users::validate_password(&form.new_password)
|
||||||
|
.err()
|
||||||
|
.map(|error| error.to_string())
|
||||||
|
};
|
||||||
|
if let Some(error) = problem {
|
||||||
|
return Ok((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Html(AccountTemplate {
|
||||||
|
page: Page::new("Account", Some(user.into()), "account"),
|
||||||
|
error,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response());
|
||||||
|
}
|
||||||
|
let password = form.new_password;
|
||||||
|
let password_hash = tokio::task::spawn_blocking(move || users::hash_password(&password))
|
||||||
|
.await
|
||||||
|
.map_err(|error| WebError::Internal(error.into()))?;
|
||||||
|
sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?")
|
||||||
|
.bind(&password_hash)
|
||||||
|
.bind(user.id)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await
|
||||||
|
.map_err(crate::db::DbError::from)?;
|
||||||
|
SqliteSessionStore::new(state.db.pool().clone())
|
||||||
|
.delete_for_user(user.id)
|
||||||
|
.await
|
||||||
|
.map_err(crate::db::DbError::from)?;
|
||||||
|
let mut updated = user;
|
||||||
|
updated.password_hash = password_hash;
|
||||||
|
auth.login(&updated)
|
||||||
|
.await
|
||||||
|
.map_err(|error| WebError::Internal(error.into()))?;
|
||||||
|
Ok(axum::response::Redirect::to("/account").into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn logout_everywhere(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthSession,
|
||||||
|
) -> Result<Response, WebError> {
|
||||||
|
let user = auth.user().await.ok_or_else(|| WebError::Unauthenticated {
|
||||||
|
next: "/account".into(),
|
||||||
|
})?;
|
||||||
|
SqliteSessionStore::new(state.db.pool().clone())
|
||||||
|
.delete_for_user(user.id)
|
||||||
|
.await
|
||||||
|
.map_err(crate::db::DbError::from)?;
|
||||||
|
auth.logout()
|
||||||
|
.await
|
||||||
|
.map_err(|error| WebError::Internal(error.into()))?;
|
||||||
|
Ok(axum::response::Redirect::to("/").into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn forwarded_scheme(headers: &axum::http::HeaderMap) -> &str {
|
||||||
|
headers
|
||||||
|
.get("x-forwarded-proto")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.unwrap_or("http")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_origin(headers: &axum::http::HeaderMap) -> Option<String> {
|
||||||
|
if let Some(origin) = headers
|
||||||
|
.get(header::ORIGIN)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
{
|
||||||
|
return Some(origin.trim_end_matches('/').to_string());
|
||||||
|
}
|
||||||
|
let referer = headers
|
||||||
|
.get(header::REFERER)
|
||||||
|
.and_then(|value| value.to_str().ok())?;
|
||||||
|
let url = url::Url::from_str(referer).ok()?;
|
||||||
|
Some(url.origin().ascii_serialization())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use axum_login::tower_sessions::SessionStore;
|
||||||
|
use serde_json::json;
|
||||||
|
use time::Duration;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
async fn store() -> (tempfile::TempDir, Db, SqliteSessionStore) {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let db = Db::open_and_migrate(&dir.path().join("db.sqlite"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let store = SqliteSessionStore::new(db.pool().clone());
|
||||||
|
(dir, db, store)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record(user_id: Option<i64>, expiry: OffsetDateTime) -> Record {
|
||||||
|
let mut data = HashMap::new();
|
||||||
|
if let Some(user_id) = user_id {
|
||||||
|
data.insert(
|
||||||
|
"axum-login.data".into(),
|
||||||
|
json!({"user_id": user_id, "auth_hash": [1, 2, 3]}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Record {
|
||||||
|
id: Id::default(),
|
||||||
|
data,
|
||||||
|
expiry_date: expiry,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn session_store_round_trips_denormalizes_and_deletes() {
|
||||||
|
let (_dir, db, store) = store().await;
|
||||||
|
let user = users::add(&db, "reader", "correct horse battery", false)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let active = record(
|
||||||
|
Some(user.id),
|
||||||
|
OffsetDateTime::now_utc() + Duration::hours(1),
|
||||||
|
);
|
||||||
|
store.save(&active).await.unwrap();
|
||||||
|
let loaded = store.load(&active.id).await.unwrap().unwrap();
|
||||||
|
assert_eq!(loaded.id, active.id);
|
||||||
|
assert_eq!(loaded.data, active.data);
|
||||||
|
assert_eq!(
|
||||||
|
loaded.expiry_date.unix_timestamp(),
|
||||||
|
active.expiry_date.unix_timestamp()
|
||||||
|
);
|
||||||
|
let denormalized: Option<i64> =
|
||||||
|
sqlx::query_scalar("SELECT user_id FROM sessions WHERE id = ?")
|
||||||
|
.bind(active.id.to_string())
|
||||||
|
.fetch_one(db.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(denormalized, Some(user.id));
|
||||||
|
assert_eq!(store.delete_for_user(user.id).await.unwrap(), 1);
|
||||||
|
assert!(store.load(&active.id).await.unwrap().is_none());
|
||||||
|
|
||||||
|
let anonymous = record(None, OffsetDateTime::now_utc() + Duration::hours(1));
|
||||||
|
store.save(&anonymous).await.unwrap();
|
||||||
|
let denormalized: Option<i64> =
|
||||||
|
sqlx::query_scalar("SELECT user_id FROM sessions WHERE id = ?")
|
||||||
|
.bind(anonymous.id.to_string())
|
||||||
|
.fetch_one(db.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(denormalized, None);
|
||||||
|
store.delete(&anonymous.id).await.unwrap();
|
||||||
|
assert!(store.load(&anonymous.id).await.unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn expired_sessions_are_hidden_and_pruned_and_errors_surface() {
|
||||||
|
let (_dir, db, store) = store().await;
|
||||||
|
let expired = record(None, OffsetDateTime::now_utc() - Duration::seconds(1));
|
||||||
|
store.save(&expired).await.unwrap();
|
||||||
|
assert!(store.load(&expired.id).await.unwrap().is_none());
|
||||||
|
assert_eq!(store.delete_expired().await.unwrap(), 1);
|
||||||
|
db.close().await;
|
||||||
|
let error = store.save(&record(None, OffsetDateTime::now_utc())).await;
|
||||||
|
assert!(matches!(error, Err(session_store::Error::Backend(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_targets_must_be_same_site_paths() {
|
||||||
|
assert_eq!(valid_next(Some("/dashboard")), "/dashboard");
|
||||||
|
assert_eq!(valid_next(Some("//evil.example/")), "/");
|
||||||
|
assert_eq!(valid_next(Some("https://evil.example/")), "/");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
|
||||||
|
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--font-serif:"Newsreader", "Iowan Old Style", "Palatino Linotype", Georgia, serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, monospace;--spacing:.25rem;--container-md:28rem;--container-lg:32rem;--container-2xl:42rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--tracking-normal:0em;--leading-tight:1.25;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--radius-sm:.25rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--default-mono-font-family:ui-monospace, SFMono-Regular, Menlo, monospace;--color-paper:var(--paper);--color-paper-2:var(--paper-2);--color-ink:var(--ink)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgrouLine truncated
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
const themeToggle = document.querySelector("[data-theme-toggle]");
|
||||||
|
if (themeToggle) {
|
||||||
|
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
const readTheme = () => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem("theme");
|
||||||
|
return saved === "light" || saved === "dark" ? saved : "system";
|
||||||
|
} catch (_) {
|
||||||
|
return "system";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const renderTheme = (theme) => {
|
||||||
|
themeToggle.setAttribute("aria-label", `Theme: ${theme}`);
|
||||||
|
themeToggle.querySelector("[data-theme-label]").textContent = theme[0].toUpperCase() + theme.slice(1);
|
||||||
|
themeToggle.querySelectorAll("[data-theme-icon]").forEach((icon) => {
|
||||||
|
// SVGElement has no `hidden` IDL attribute; toggle the content attribute.
|
||||||
|
icon.toggleAttribute("hidden", icon.getAttribute("data-theme-icon") !== theme);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const setTheme = (theme) => {
|
||||||
|
if (theme === "system") document.documentElement.removeAttribute("data-theme");
|
||||||
|
else document.documentElement.dataset.theme = theme;
|
||||||
|
try {
|
||||||
|
if (theme === "system") localStorage.removeItem("theme");
|
||||||
|
else localStorage.setItem("theme", theme);
|
||||||
|
} catch (_) {}
|
||||||
|
renderTheme(theme);
|
||||||
|
};
|
||||||
|
renderTheme(readTheme());
|
||||||
|
themeToggle.addEventListener("click", () => {
|
||||||
|
const current = readTheme();
|
||||||
|
setTheme(current === "system" ? "light" : current === "light" ? "dark" : "system");
|
||||||
|
});
|
||||||
|
media.addEventListener("change", () => {
|
||||||
|
if (readTheme() === "system") {
|
||||||
|
document.documentElement.removeAttribute("data-theme");
|
||||||
|
renderTheme("system");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("submit", (event) => {
|
||||||
|
const form = event.target;
|
||||||
|
if (form.matches("form.rating") && event.submitter) {
|
||||||
|
event.preventDefault();
|
||||||
|
const submitted = event.submitter;
|
||||||
|
const body = new URLSearchParams(new FormData(form));
|
||||||
|
body.set(submitted.name, submitted.value);
|
||||||
|
fetch(form.action, {
|
||||||
|
method: "POST",
|
||||||
|
body,
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
}).then((response) => {
|
||||||
|
if (!response.ok) throw new Error("rating request failed");
|
||||||
|
return response.json();
|
||||||
|
}).then((result) => {
|
||||||
|
form.querySelectorAll("button[data-label]").forEach((button) => {
|
||||||
|
const active = button.dataset.label === result.label;
|
||||||
|
button.classList.toggle("active", active);
|
||||||
|
button.setAttribute("aria-pressed", active ? "true" : "false");
|
||||||
|
});
|
||||||
|
}).catch(() => {
|
||||||
|
const label = document.createElement("input");
|
||||||
|
label.type = "hidden";
|
||||||
|
label.name = submitted.name;
|
||||||
|
label.value = submitted.value;
|
||||||
|
form.appendChild(label);
|
||||||
|
form.submit();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = form.dataset.confirm;
|
||||||
|
if (message && !window.confirm(message)) event.preventDefault();
|
||||||
|
});
|
||||||
|
document.querySelectorAll("details[id]").forEach((details) => {
|
||||||
|
try {
|
||||||
|
const key = "details:" + details.id;
|
||||||
|
details.open = localStorage.getItem(key) === "open";
|
||||||
|
details.addEventListener("toggle", () => localStorage.setItem(key, details.open ? "open" : "closed"));
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
/* step 3: filter-as-you-type on tables with data-filter (this page's rows only) */
|
||||||
|
document.querySelectorAll("table[data-filter]").forEach((table) => {
|
||||||
|
const rows = table.querySelectorAll("tbody tr");
|
||||||
|
if (rows.length < 2) return;
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "search";
|
||||||
|
input.className = "table-filter";
|
||||||
|
input.placeholder = "Filter rows on this page";
|
||||||
|
input.setAttribute("aria-label", "Filter rows on this page");
|
||||||
|
const host = table.closest(".scroll-x") || table;
|
||||||
|
host.parentNode.insertBefore(input, host);
|
||||||
|
input.addEventListener("input", () => {
|
||||||
|
const needle = input.value.trim().toLowerCase();
|
||||||
|
rows.forEach((row) => {
|
||||||
|
row.hidden = needle !== "" && !row.textContent.toLowerCase().includes(needle);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
/* step 5: settings — "reset to default" fills the field with its default */
|
||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
const button = event.target.closest("button[data-reset]");
|
||||||
|
if (!button) return;
|
||||||
|
const input = document.getElementById(button.dataset.reset);
|
||||||
|
if (!input) return;
|
||||||
|
input.value = button.dataset.default;
|
||||||
|
});
|
||||||
|
/* step 6: reload a job page every N seconds while its job is requested/running */
|
||||||
|
document.querySelectorAll("[data-refresh]").forEach((element) => {
|
||||||
|
const seconds = Number(element.dataset.refresh);
|
||||||
|
if (seconds > 0) setTimeout(() => window.location.reload(), seconds * 1000);
|
||||||
|
});
|
||||||
|
/* step 4: table-of-contents panel (below `lg`) and reading-progress bar */
|
||||||
|
const tocPanel = document.querySelector("[data-toc-panel]");
|
||||||
|
const tocToggle = document.querySelector("[data-toc-toggle]");
|
||||||
|
if (tocPanel) {
|
||||||
|
// Long issues overflow the sidebar; scroll just enough to show where we are.
|
||||||
|
const revealCurrent = () => {
|
||||||
|
const current = tocPanel.querySelector("a[aria-current=page]");
|
||||||
|
if (!current || tocPanel.scrollHeight <= tocPanel.clientHeight) return;
|
||||||
|
const margin = 24;
|
||||||
|
const top = current.offsetTop - margin;
|
||||||
|
const bottom = current.offsetTop + current.offsetHeight + margin;
|
||||||
|
if (bottom > tocPanel.scrollTop + tocPanel.clientHeight) {
|
||||||
|
tocPanel.scrollTop = bottom - tocPanel.clientHeight;
|
||||||
|
} else if (top < tocPanel.scrollTop) {
|
||||||
|
tocPanel.scrollTop = Math.max(0, top);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const setOpen = (open) => {
|
||||||
|
tocPanel.dataset.open = open ? "true" : "false";
|
||||||
|
if (tocToggle) tocToggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||||
|
if (open) revealCurrent();
|
||||||
|
};
|
||||||
|
setOpen(false);
|
||||||
|
revealCurrent();
|
||||||
|
if (tocToggle) {
|
||||||
|
tocToggle.addEventListener("click", () => setOpen(tocPanel.dataset.open !== "true"));
|
||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
if (tocPanel.dataset.open !== "true" || tocToggle.contains(event.target)) return;
|
||||||
|
// A tap on a chapter closes the panel; so does a tap anywhere outside it.
|
||||||
|
if (!tocPanel.contains(event.target) || event.target.closest("a")) setOpen(false);
|
||||||
|
});
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key !== "Escape" || tocPanel.dataset.open !== "true") return;
|
||||||
|
setOpen(false);
|
||||||
|
tocToggle.focus();
|
||||||
|
});
|
||||||
|
window.matchMedia("(min-width: 64rem)").addEventListener("change", () => setOpen(false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const tocBars = document.querySelectorAll("[data-toc-progress]");
|
||||||
|
const tocChapter = document.querySelector("[data-toc-scroll]");
|
||||||
|
if (tocBars.length && tocChapter) {
|
||||||
|
// "Chapter N" is worth position N once finished; show N-1 plus how far down we are.
|
||||||
|
const base = Math.max(0, Number(tocBars[0].getAttribute("value")) - 1);
|
||||||
|
let queued = false;
|
||||||
|
const paint = () => {
|
||||||
|
queued = false;
|
||||||
|
const start = window.scrollY + tocChapter.getBoundingClientRect().top;
|
||||||
|
const end = start + tocChapter.offsetHeight - window.innerHeight;
|
||||||
|
const read = end > start ? (window.scrollY - start) / (end - start) : 1;
|
||||||
|
const value = base + Math.min(1, Math.max(0, read));
|
||||||
|
tocBars.forEach((bar) => {
|
||||||
|
// The CSS transition is for navigation, not for tracking a finger.
|
||||||
|
bar.dataset.live = "true";
|
||||||
|
bar.value = value;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const schedule = () => {
|
||||||
|
if (queued) return;
|
||||||
|
queued = true;
|
||||||
|
window.requestAnimationFrame(paint);
|
||||||
|
};
|
||||||
|
window.addEventListener("scroll", schedule, { passive: true });
|
||||||
|
window.addEventListener("resize", schedule);
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="6" fill="#fbfaf6"/><path d="M13 12h38v40H13z" fill="none" stroke="#171713" stroke-width="3"/><path d="M20 21h24M20 29h24M20 37h18M20 45h20" stroke="#8b1e1e" stroke-width="3"/></svg>
|
||||||
|
After Width: | Height: | Size: 275 B |
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2020 The Newsreader Project Authors (http://github.com/productiontype/Newsreader)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
(() => {
|
||||||
|
// Marks the document as scripted before first paint so progressively enhanced
|
||||||
|
// widgets (the contents panel) can start collapsed without a flash.
|
||||||
|
document.documentElement.classList.add("has-js");
|
||||||
|
try {
|
||||||
|
const theme = localStorage.getItem("theme");
|
||||||
|
if (theme === "light" || theme === "dark") document.documentElement.dataset.theme = theme;
|
||||||
|
else document.documentElement.removeAttribute("data-theme");
|
||||||
|
} catch (_) {
|
||||||
|
document.documentElement.removeAttribute("data-theme");
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@source "../web/templates";
|
||||||
|
@source "./static/app.js";
|
||||||
|
|
||||||
|
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Newsreader";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 200 800;
|
||||||
|
font-stretch: normal;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("/static/Newsreader.woff2") format("woff2");
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Newsreader";
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 200 800;
|
||||||
|
font-stretch: normal;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("/static/Newsreader-italic.woff2") format("woff2");
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--paper:#f6f3ec; --paper-2:#ece7db; --ink:#1c1b18; --ink-2:#4f4b43; --muted:#7a7568;
|
||||||
|
--rule:#d8d2c4; --rule-strong:#1c1b18; --accent:#a3231f; --accent-hover:#7c1a16;
|
||||||
|
--loved:#2f6f46; --good:#2f6a8f; --down:#9c3f36; --warn:#8a6d1f; color-scheme:light;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme="light"]) {
|
||||||
|
--paper:#151513; --paper-2:#1e1d1a; --ink:#ebe6da; --ink-2:#bfb9aa; --muted:#8f8a7d;
|
||||||
|
--rule:#3a3833; --rule-strong:#ebe6da; --accent:#ef8c82; --accent-hover:#f7aaa2;
|
||||||
|
--loved:#7fcb92; --good:#7fb8d8; --down:#ea8f84; --warn:#d9b45a; color-scheme:dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--paper:#151513; --paper-2:#1e1d1a; --ink:#ebe6da; --ink-2:#bfb9aa; --muted:#8f8a7d;
|
||||||
|
--rule:#3a3833; --rule-strong:#ebe6da; --accent:#ef8c82; --accent-hover:#f7aaa2;
|
||||||
|
--loved:#7fcb92; --good:#7fb8d8; --down:#ea8f84; --warn:#d9b45a; color-scheme:dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-paper:var(--paper); --color-paper-2:var(--paper-2); --color-ink:var(--ink);
|
||||||
|
--color-ink-2:var(--ink-2); --color-muted:var(--muted); --color-rule:var(--rule);
|
||||||
|
--color-rule-strong:var(--rule-strong); --color-accent:var(--accent);
|
||||||
|
--color-accent-hover:var(--accent-hover); --color-loved:var(--loved); --color-good:var(--good);
|
||||||
|
--color-down:var(--down); --color-warn:var(--warn);
|
||||||
|
--font-serif:"Newsreader", "Iowan Old Style", "Palatino Linotype", Georgia, serif;
|
||||||
|
--font-sans:ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
--font-mono:ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
[hidden] { display:none !important; }
|
||||||
|
html { @apply bg-paper text-ink antialiased; }
|
||||||
|
body { @apply m-0 min-h-screen bg-paper font-serif text-[1.0625rem] leading-[1.65] text-ink; font-optical-sizing:auto; }
|
||||||
|
::selection { background:color-mix(in oklab, var(--accent) 24%, transparent); }
|
||||||
|
a { @apply text-accent decoration-rule underline-offset-4 transition-colors duration-150 hover:text-accent-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-paper; }
|
||||||
|
h1, h2, h3 { @apply text-ink; font-optical-sizing:auto; }
|
||||||
|
button, input, select, textarea { font:inherit; @apply rounded-sm border border-rule bg-paper px-2.5 py-1.5 text-ink transition-colors duration-150 placeholder:text-muted focus:border-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-paper; }
|
||||||
|
button { @apply cursor-pointer; }
|
||||||
|
button:disabled, input:disabled, select:disabled, textarea:disabled { @apply cursor-not-allowed border-rule bg-paper-2 text-muted; }
|
||||||
|
textarea { @apply min-h-28; }
|
||||||
|
summary { @apply cursor-pointer; }
|
||||||
|
table { @apply w-full border-collapse font-sans text-sm leading-snug; font-variant-numeric:tabular-nums; }
|
||||||
|
thead { @apply sticky top-0 z-10 bg-paper; }
|
||||||
|
th, td { @apply border-b border-rule px-2 py-2 text-left align-top; }
|
||||||
|
th { @apply text-[0.72rem] font-semibold uppercase tracking-[0.1em] text-muted; }
|
||||||
|
tbody tr { @apply transition-colors duration-150 hover:bg-paper-2; }
|
||||||
|
code, pre { @apply font-mono; }
|
||||||
|
meter { @apply block h-2 w-full rounded-sm; -webkit-appearance:none; appearance:none; background:var(--rule); }
|
||||||
|
meter::-webkit-meter-bar { background:var(--rule); border:0; border-radius:2px; height:0.5rem; }
|
||||||
|
meter::-webkit-meter-optimum-value, meter::-webkit-meter-suboptimum-value, meter::-webkit-meter-even-less-good-value { background:var(--good); border-radius:2px; }
|
||||||
|
meter.over::-webkit-meter-optimum-value, meter.over::-webkit-meter-suboptimum-value, meter.over::-webkit-meter-even-less-good-value { background:var(--down); }
|
||||||
|
meter::-moz-meter-bar { background:var(--good); border-radius:2px; }
|
||||||
|
meter.over::-moz-meter-bar { background:var(--down); }
|
||||||
|
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior:auto !important; transition-duration:0.01ms !important; } }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
/* ---------- shared controls ---------- */
|
||||||
|
.btn, .button { @apply inline-flex min-h-10 items-center justify-center rounded-sm border border-rule-strong bg-transparent px-3 py-1.5 font-sans text-sm font-medium text-ink no-underline transition-colors duration-150 hover:bg-ink hover:text-paper focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-paper; }
|
||||||
|
.btn-primary { @apply inline-flex min-h-10 items-center justify-center rounded-sm border border-ink bg-ink px-3 py-1.5 font-sans text-sm font-medium text-paper no-underline transition-colors duration-150 hover:border-accent hover:bg-accent hover:text-paper focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-paper; }
|
||||||
|
.btn-quiet { @apply inline-flex min-h-9 items-center gap-1 rounded-sm border-0 bg-transparent px-2 py-1 font-sans text-sm text-muted underline decoration-rule underline-offset-4 hover:text-ink hover:decoration-accent; }
|
||||||
|
.btn:disabled, .btn-primary:disabled, .btn-danger:disabled { @apply cursor-not-allowed border-rule bg-paper-2 text-muted hover:bg-paper-2 hover:text-muted; }
|
||||||
|
.btn-danger, button.danger { @apply inline-flex min-h-9 items-center justify-center rounded-sm border border-down bg-transparent px-2.5 py-1 font-sans text-sm font-medium text-down transition-colors duration-150 hover:bg-down hover:text-paper; }
|
||||||
|
|
||||||
|
.badge { @apply inline-flex items-center whitespace-nowrap rounded-full px-2 py-0.5 font-sans text-xs font-medium leading-5; color:var(--ink-2); background:color-mix(in oklab, var(--ink) 8%, transparent); }
|
||||||
|
.badge.selected, .badge.loved, .badge.ok { color:var(--loved); background:color-mix(in oklab, var(--loved) 14%, transparent); }
|
||||||
|
.badge.good, .badge.assessed, .badge.triaged, .badge.degraded { color:var(--good); background:color-mix(in oklab, var(--good) 14%, transparent); }
|
||||||
|
.badge.down, .badge.excluded, .badge.failed, .badge.dry_run { color:var(--down); background:color-mix(in oklab, var(--down) 14%, transparent); }
|
||||||
|
.badge.shortlisted, .badge.admitted, .badge.eligible, .badge.cleared, .badge.reason, .badge.running, .badge.requested { color:var(--muted); background:color-mix(in oklab, var(--muted) 14%, transparent); }
|
||||||
|
.badge.restart, .badge.warn { color:var(--warn); background:color-mix(in oklab, var(--warn) 16%, transparent); }
|
||||||
|
|
||||||
|
.kv { @apply grid grid-cols-[minmax(9rem,1fr)_2fr] gap-x-5 gap-y-1.5; }
|
||||||
|
.kv dt { @apply text-sm text-muted; }
|
||||||
|
.kv dd { @apply m-0 min-w-0 text-sm; overflow-wrap:anywhere; }
|
||||||
|
.scroll-x { @apply max-w-full overflow-x-auto; overscroll-behavior-inline:contain; }
|
||||||
|
.scroll-x > table { min-width:max-content; }
|
||||||
|
.scroll-x.tall { @apply max-h-[70vh] overflow-y-auto; overscroll-behavior-block:contain; }
|
||||||
|
|
||||||
|
.prose-body { @apply text-[1.0625rem] leading-[1.72] text-ink; }
|
||||||
|
.prose-body > * + * { @apply mt-5; }
|
||||||
|
.prose-body h2 { @apply mt-10 border-t border-rule pt-4 text-2xl font-semibold leading-[1.15] tracking-[-0.01em]; }
|
||||||
|
.prose-body h3 { @apply mt-8 text-xl font-semibold leading-tight; }
|
||||||
|
.prose-body a { @apply text-ink underline decoration-rule underline-offset-4 hover:text-ink hover:decoration-accent; }
|
||||||
|
.prose-body img { @apply mx-auto my-7 block h-auto max-w-full rounded-sm text-center font-sans text-xs italic text-muted; }
|
||||||
|
.prose-body pre { @apply max-w-full overflow-x-auto rounded-sm bg-paper-2 p-4 text-sm leading-relaxed; }
|
||||||
|
.prose-body :not(pre) > code { @apply rounded-sm bg-paper-2 px-1 py-0.5 text-[0.9em]; }
|
||||||
|
.prose-body blockquote { @apply my-7 border-l-2 border-rule-strong pl-5 text-ink-2; }
|
||||||
|
.prose-body ul { @apply list-disc pl-6; }
|
||||||
|
.prose-body ol { @apply list-decimal pl-6; }
|
||||||
|
.prose-body li::marker { color:var(--muted); }
|
||||||
|
.prose-body li + li { @apply mt-2; }
|
||||||
|
|
||||||
|
.rating { @apply my-5 flex flex-wrap items-center font-sans text-sm; }
|
||||||
|
.rating-prompt { @apply mr-3 text-[0.72rem] uppercase tracking-[0.1em] text-muted; }
|
||||||
|
.rating button[data-label]:not(.clear) { @apply -ml-px min-h-10 rounded-none border-rule bg-transparent px-3 py-1.5 text-ink first:ml-0 first:rounded-l-sm last:rounded-r-sm hover:z-10 hover:border-ink hover:bg-paper-2; }
|
||||||
|
.rating button[data-label]:not(.clear).active { @apply z-10 border-ink bg-ink text-paper; }
|
||||||
|
.rating button.clear { @apply ml-2 min-h-10 border-0 bg-transparent px-2 text-muted underline decoration-rule underline-offset-4 hover:text-ink; }
|
||||||
|
.rating button.clear.active { @apply font-semibold text-ink; }
|
||||||
|
.rating-note { @apply mt-2 flex w-full max-w-md basis-full items-center gap-2 text-muted; }
|
||||||
|
.rating-note input { @apply flex-1; }
|
||||||
|
|
||||||
|
.flash { @apply my-4 border-l-4 border-accent bg-paper-2 px-4 py-3 font-sans text-sm; }
|
||||||
|
.error { @apply border-l-4 border-down bg-paper-2 px-4 py-3 font-sans text-sm text-down; }
|
||||||
|
.notice { @apply border-l-4 border-warn bg-paper-2 px-4 py-3 font-sans text-sm text-ink-2; }
|
||||||
|
ul.error, ul.notice { @apply list-disc pl-8; }
|
||||||
|
|
||||||
|
/* ---------- dashboard shell ---------- */
|
||||||
|
.dashboard { @apply mx-auto my-8 max-w-7xl px-4 pb-6 font-sans text-base leading-normal sm:px-6; }
|
||||||
|
.dashboard > * + * { @apply mt-4; }
|
||||||
|
.dashboard h1 { @apply text-2xl font-semibold leading-tight tracking-[-0.01em]; }
|
||||||
|
.dashboard h2 { @apply text-lg font-semibold; }
|
||||||
|
.dashboard h3 { @apply text-sm font-semibold; }
|
||||||
|
.dashboard > h2 { @apply mt-9 border-t border-rule pt-3 text-[0.78rem] font-semibold uppercase tracking-[0.14em] text-muted; }
|
||||||
|
.dashboard > p, .dashboard > ul, .dashboard > ol { @apply text-sm; }
|
||||||
|
.dashboard code { @apply rounded-sm bg-paper-2 px-1 py-0.5 text-[0.85em]; }
|
||||||
|
.dashboard pre code { @apply bg-transparent p-0; }
|
||||||
|
.dashboard table a { @apply text-ink underline decoration-rule underline-offset-2 hover:text-ink hover:decoration-accent; }
|
||||||
|
|
||||||
|
.page-head { @apply flex flex-wrap items-end justify-between gap-x-6 gap-y-3 border-b border-rule pb-4; }
|
||||||
|
.page-head > div:first-child { @apply min-w-0; }
|
||||||
|
.dashboard > .page-head + * { @apply mt-6; }
|
||||||
|
.page-desc { @apply mt-1.5 max-w-[74ch] text-sm text-muted; }
|
||||||
|
.page-desc a { @apply text-accent; }
|
||||||
|
.page-actions { @apply flex flex-wrap items-center gap-2 font-sans text-sm; }
|
||||||
|
.page-eyebrow { @apply text-[0.7rem] uppercase tracking-[0.14em] text-muted; }
|
||||||
|
|
||||||
|
.tiles { @apply grid gap-3; grid-template-columns:repeat(auto-fit,minmax(min(10.5rem,100%),1fr)); }
|
||||||
|
.tile { @apply min-w-0 border border-rule px-3.5 py-3; }
|
||||||
|
.tile-label { @apply block text-[0.68rem] font-medium uppercase tracking-[0.12em] text-muted; }
|
||||||
|
.tile-num { @apply mt-1 block text-[1.55rem] font-semibold leading-tight tracking-[-0.01em]; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; }
|
||||||
|
.tile-delta { @apply mt-1 block text-xs text-muted; overflow-wrap:anywhere; }
|
||||||
|
|
||||||
|
.cards { @apply grid gap-4; grid-template-columns:repeat(auto-fit,minmax(min(20rem,100%),1fr)); }
|
||||||
|
.card { @apply min-w-0 border border-rule p-4; }
|
||||||
|
.card > * + * { @apply mt-3; }
|
||||||
|
.card h2 { @apply text-base font-semibold; }
|
||||||
|
.card h3 { @apply text-base font-semibold; }
|
||||||
|
.card > h2:first-child, .card > h3:first-child { @apply mt-0; }
|
||||||
|
.card p, .card li { @apply text-sm; }
|
||||||
|
|
||||||
|
.muted, .meta { @apply text-muted; }
|
||||||
|
.jobs a, .picks > li > a:first-child { @apply text-ink no-underline hover:text-accent; }
|
||||||
|
.num { @apply whitespace-nowrap text-right tabular-nums; }
|
||||||
|
.cell-wrap { @apply min-w-[10rem] max-w-[24rem] whitespace-normal; overflow-wrap:anywhere; }
|
||||||
|
.cell-tight { @apply whitespace-nowrap; }
|
||||||
|
|
||||||
|
.filters { @apply flex flex-wrap items-end gap-x-4 gap-y-3 border-y border-rule py-3 font-sans text-sm; }
|
||||||
|
.filters label { @apply grid gap-1 text-[0.66rem] font-medium uppercase tracking-[0.1em] text-muted; }
|
||||||
|
.filters input, .filters select, .filters textarea { @apply text-sm normal-case tracking-normal text-ink; }
|
||||||
|
.filters select, .filters input[type="date"] { @apply min-w-32; }
|
||||||
|
.filters .filter-actions { @apply flex items-center gap-3; }
|
||||||
|
.form-inline { @apply flex flex-wrap items-center gap-3 font-sans text-sm; }
|
||||||
|
.form-inline label { @apply flex items-center gap-2 text-sm; }
|
||||||
|
.table-filter { @apply my-2 w-80 max-w-full font-sans text-sm; }
|
||||||
|
|
||||||
|
.pager { @apply flex flex-wrap items-center justify-between gap-x-6 gap-y-2 py-1 font-sans text-sm text-muted; }
|
||||||
|
.pager a { @apply text-ink no-underline hover:text-accent; }
|
||||||
|
.run-nav { @apply flex flex-wrap items-center justify-between gap-x-6 gap-y-2 font-sans text-sm; }
|
||||||
|
.run-nav a { @apply text-ink no-underline hover:text-accent; }
|
||||||
|
.tabs { @apply flex flex-wrap items-center gap-x-6 border-b border-rule font-sans text-[0.72rem] font-medium uppercase tracking-[0.12em]; }
|
||||||
|
.tabs a { @apply -mb-px flex min-h-10 items-center border-b-2 border-transparent text-muted no-underline hover:text-ink; }
|
||||||
|
.tabs a.active { @apply border-accent text-ink; }
|
||||||
|
.pagination { @apply font-sans text-sm text-muted; }
|
||||||
|
|
||||||
|
tr.superseded > td { @apply text-muted; }
|
||||||
|
tr.superseded a { @apply text-muted; }
|
||||||
|
|
||||||
|
/* the rating widget has to fit inside a table cell */
|
||||||
|
td .rating, .picks .rating { @apply my-1.5 max-w-[15rem] gap-y-1 text-xs; }
|
||||||
|
td .rating-prompt, .picks .rating-prompt { @apply hidden; }
|
||||||
|
td .rating button[data-label]:not(.clear), .picks .rating button[data-label]:not(.clear) { @apply min-h-8 px-2.5 py-1 text-xs; }
|
||||||
|
td .rating button.clear, .picks .rating button.clear { @apply ml-2 min-h-8 px-0 text-xs; }
|
||||||
|
td .rating-note { @apply mt-1 max-w-[15rem] gap-1.5 text-[0.7rem]; }
|
||||||
|
td .rating-note input { @apply px-1.5 py-0.5 text-xs; }
|
||||||
|
.picks .rating { @apply max-w-none; }
|
||||||
|
|
||||||
|
pre.block, pre.preview, pre.journal { @apply overflow-auto whitespace-pre-wrap rounded-sm bg-paper-2 p-3 font-mono text-[0.8125rem] leading-relaxed; overflow-wrap:anywhere; }
|
||||||
|
pre.block { @apply max-h-64; }
|
||||||
|
pre.preview { @apply max-h-56; }
|
||||||
|
pre.journal { @apply max-h-[34rem] p-4; }
|
||||||
|
pre.prompt, pre.learned { @apply max-h-[32rem]; }
|
||||||
|
td > pre.preview { @apply max-h-24 max-w-md bg-transparent p-0 text-xs text-muted; }
|
||||||
|
|
||||||
|
details.disclosure { @apply border border-rule; }
|
||||||
|
details.disclosure > summary { @apply px-4 py-3 font-sans text-sm font-medium; }
|
||||||
|
details.disclosure[open] > summary { @apply border-b border-rule; }
|
||||||
|
details.disclosure > .disclosure-body { @apply px-4 py-3 text-sm; }
|
||||||
|
details.disclosure > .disclosure-body > * + * { @apply mt-3; }
|
||||||
|
details.signals > summary { @apply min-w-0; }
|
||||||
|
.signals-body { @apply mt-2 max-w-2xl border-l-2 border-rule pl-3 text-xs; }
|
||||||
|
.signals-body > * + * { @apply mt-2; }
|
||||||
|
.signals-body ul { @apply list-disc pl-5; }
|
||||||
|
|
||||||
|
.funnel { @apply w-full min-w-[6rem] rounded-sm bg-paper-2; }
|
||||||
|
.funnel > * { @apply block min-w-px rounded-sm bg-good; }
|
||||||
|
.funnel-cell { @apply w-[14rem] min-w-[8rem] align-middle; }
|
||||||
|
|
||||||
|
.sparklines { @apply grid gap-4; grid-template-columns:repeat(auto-fit,minmax(min(18rem,100%),1fr)); }
|
||||||
|
.spark-figure { @apply m-0 min-w-0 border border-rule p-3; }
|
||||||
|
.spark-figure figcaption { @apply mb-2 text-[0.7rem] font-semibold uppercase tracking-[0.1em] text-ink; }
|
||||||
|
.spark-figure figcaption .muted { @apply font-normal normal-case tracking-normal; }
|
||||||
|
.spark { @apply h-auto w-full max-w-full; }
|
||||||
|
.spark .s0 { fill:var(--good); } .spark .s1 { fill:var(--loved); } .spark .s2 { fill:var(--down); } .spark .s3 { fill:var(--accent); } .spark .s4 { fill:var(--muted); } .spark .s5 { fill:var(--ink); } .spark .line { stroke:var(--accent); }
|
||||||
|
.spark-axis { @apply mt-1 flex justify-between text-[0.66rem] uppercase tracking-[0.08em]; }
|
||||||
|
.spark-legend { @apply mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted; }
|
||||||
|
.spark-legend li { @apply flex items-center gap-1.5; }
|
||||||
|
|
||||||
|
/* ---------- settings ---------- */
|
||||||
|
.settings-form > * + * { @apply mt-5; }
|
||||||
|
.setting { @apply grid gap-2 border-t border-rule py-3 md:grid-cols-[minmax(0,19rem)_minmax(0,1fr)] md:gap-6; }
|
||||||
|
.setting:first-of-type { @apply border-t-0 pt-1; }
|
||||||
|
.setting-label { @apply min-w-0; }
|
||||||
|
.setting-label label { @apply inline-block; }
|
||||||
|
.setting-label code { @apply bg-transparent p-0 text-[0.85rem] font-medium text-ink; overflow-wrap:anywhere; }
|
||||||
|
.setting-input { @apply min-w-0; }
|
||||||
|
.setting-input > input, .setting-input > select, .setting-input > textarea { @apply w-full max-w-lg; }
|
||||||
|
.help, .default { @apply mt-1 text-xs text-muted; overflow-wrap:anywhere; }
|
||||||
|
.setting-label .help { @apply max-w-[42ch]; }
|
||||||
|
.secret { @apply text-sm text-ink-2; }
|
||||||
|
code.lines { @apply mt-1 block whitespace-pre-wrap; }
|
||||||
|
button.reset { @apply ml-1 inline min-h-0 rounded-none border-0 bg-transparent p-0 text-xs text-muted underline decoration-rule underline-offset-2 hover:text-ink; }
|
||||||
|
.save-bar { @apply sticky bottom-0 z-20 -mx-4 mt-6 flex flex-wrap items-center justify-end gap-x-4 gap-y-2 border-t border-rule bg-paper px-4 py-3 sm:-mx-6 sm:px-6; box-shadow:0 -10px 18px -14px color-mix(in oklab, var(--ink) 55%, transparent); }
|
||||||
|
.save-bar .meta { @apply mr-auto text-xs; }
|
||||||
|
|
||||||
|
/* step 4: table-of-contents sidebar */
|
||||||
|
.toc-progress { @apply block h-0.5 w-full appearance-none border-0 p-0 align-middle; background:var(--rule); }
|
||||||
|
.toc-progress::-webkit-progress-bar { background:var(--rule); }
|
||||||
|
.toc-progress::-webkit-progress-value { background:var(--accent); transition:width 150ms linear; }
|
||||||
|
.toc-progress::-moz-progress-bar { background:var(--accent); }
|
||||||
|
/* While JS drives the bar from scroll position it must track the finger exactly. */
|
||||||
|
.toc-progress[data-live]::-webkit-progress-value { transition:none; }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .toc-progress::-webkit-progress-value { transition:none; } }
|
||||||
|
[data-toc-panel] { scrollbar-width:thin; scrollbar-color:var(--rule) transparent; }
|
||||||
|
/* Without JS the panel is simply visible under the bar; `has-js` is set in the head. */
|
||||||
|
@media (width < 64rem) {
|
||||||
|
.has-js [data-toc-panel]:not([data-open="true"]) { display:none; }
|
||||||
|
}
|
||||||
|
@media (max-width:40rem) {
|
||||||
|
.kv { @apply block; } .kv dt { @apply mt-2; }
|
||||||
|
.rating { @apply items-stretch; } .rating-prompt, .rating-note { @apply basis-full; } .rating-prompt { @apply mb-2; }
|
||||||
|
.filters label { @apply w-full; }
|
||||||
|
.filters input, .filters select { @apply w-full; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<tr>
|
||||||
|
<td class="cell-wrap"><details class="signals"><summary><a href="{{ candidate.href }}">{{ candidate.title }}</a></summary>{% let signals = candidate.signals %}{% include "_signals_table.html" %}</details></td>
|
||||||
|
<td class="cell-tight text-muted">{{ candidate.feed }}</td>
|
||||||
|
<td class="num">{{ candidate.words }}</td>
|
||||||
|
<td class="cell-tight"><span class="badge {{ candidate.stage }}">{{ candidate.stage }}</span></td>
|
||||||
|
<td class="cell-tight">{% if let Some(reason) = candidate.reason %}<span class="badge reason">{{ reason }}</span>{% endif %}</td>
|
||||||
|
<td class="cell-tight text-muted">{% if let Some(first) = candidate.admitted_first %}{{ first }}{% if !candidate.admitted_rest.is_empty() %} <span class="muted text-xs">({{ candidate.admitted_rest }})</span>{% endif %}{% endif %}</td>
|
||||||
|
<td class="num">{{ candidate.utility }}</td>
|
||||||
|
<td class="num">{{ candidate.rank }}</td>
|
||||||
|
<td class="num">{{ candidate.cluster }}</td>
|
||||||
|
<td class="num">{{ candidate.triage }}</td>
|
||||||
|
<td class="num">{{ candidate.quality }}</td>
|
||||||
|
<td class="num">{{ candidate.fit }}</td>
|
||||||
|
<td class="cell-tight">{% if candidate.exploration %}<span class="badge">exploration</span>{% endif %}{% if candidate.auto_include %}<span class="badge">auto</span>{% endif %}</td>
|
||||||
|
<td class="cell-wrap text-muted">{% if let Some(why) = candidate.editor_why %}{{ why }}{% endif %}</td>
|
||||||
|
</tr>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{% if pagination.pages() > 1 %}<nav class="pagination" aria-label="Pagination">Page <span class="text-ink">{{ pagination.page }}</span> of {{ pagination.pages() }}</nav>{% endif %}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<form class="rating" method="post" action="/rate">
|
||||||
|
<input type="hidden" name="article_id" value="{{ widget.article_id }}">
|
||||||
|
<input type="hidden" name="issue_date" value="{{ widget.issue_date }}">
|
||||||
|
<input type="hidden" name="next" value="{{ widget.next }}">
|
||||||
|
<span class="rating-prompt">Was this a good pick?</span>
|
||||||
|
<button type="submit" name="label" value="loved" data-label="loved"{% if widget.current == "loved" %} class="active" aria-pressed="true"{% else %} aria-pressed="false"{% endif %}>Loved it</button>
|
||||||
|
<button type="submit" name="label" value="good" data-label="good"{% if widget.current == "good" %} class="active" aria-pressed="true"{% else %} aria-pressed="false"{% endif %}>Good</button>
|
||||||
|
<button type="submit" name="label" value="down" data-label="down"{% if widget.current == "down" %} class="active" aria-pressed="true"{% else %} aria-pressed="false"{% endif %}>Not for me</button>
|
||||||
|
{% if widget.show_note %}<label class="rating-note">Note <input name="note"></label>{% endif %}
|
||||||
|
<button class="clear{% if widget.current == "cleared" %} active{% endif %}" type="submit" name="label" value="cleared" data-label="cleared"{% if widget.current == "cleared" %} aria-pressed="true"{% else %} aria-pressed="false"{% endif %}>clear</button>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{% if signals.empty %}<p class="muted text-xs">No signals recorded for this row (hygiene exclusion or thin telemetry).</p>{% else %}<div class="signals-body">
|
||||||
|
<div class="scroll-x"><table class="signals"><thead><tr><th>signal</th><th class="num">raw</th><th class="num">norm</th><th class="num">weight</th><th>present</th></tr></thead>
|
||||||
|
<tbody>{% for line in signals.lines %}<tr{% if !line.present %} class="muted"{% endif %}><td>{{ line.name }}</td><td class="num">{{ line.raw }}</td><td class="num">{{ line.norm }}</td><td class="num">{{ line.weight }}</td><td>{% if line.present %}yes{% else %}<span class="muted">absent</span>{% endif %}</td></tr>{% endfor %}</tbody></table></div>
|
||||||
|
<p class="muted">Preliminary blend <span class="tabular-nums text-ink">{{ signals.blend }}</span>{% if let Some(cos) = signals.top1_cos %} · interest top-1 cosine <span class="tabular-nums">{{ cos }}</span>{% endif %}{% if signals.exploration %} · <span class="badge">exploration</span>{% endif %}{% if signals.auto_include %} · <span class="badge">auto-include</span>{% endif %}</p>
|
||||||
|
{% if !signals.top_interests.is_empty() %}<p class="page-eyebrow">Top interests</p><ul>{% for interest in signals.top_interests %}<li>{{ interest.name }} <span class="muted">· z {{ interest.z }} · cos {{ interest.cos }}</span></li>{% endfor %}</ul>{% endif %}
|
||||||
|
{% if !signals.neighbours.is_empty() %}<p class="page-eyebrow">Nearest rated neighbours</p><ul>{% for neighbour in signals.neighbours %}<li><span class="badge {{ neighbour.label }}">{{ neighbour.label }}</span> <a href="/dashboard/articles/{{ neighbour.article_id }}">{{ neighbour.title }}</a> <span class="muted">· cos {{ neighbour.cos }}</span></li>{% endfor %}</ul>{% endif %}
|
||||||
|
{% if !signals.notes.is_empty() %}<ul class="muted">{% for note in signals.notes %}<li>{{ note }}</li>{% endfor %}</ul>{% endif %}
|
||||||
|
</div>{% endif %}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<div class="sticky top-0 z-30 lg:static lg:z-auto">
|
||||||
|
<div class="relative flex min-h-12 items-center gap-3 border-b border-rule bg-paper px-4 sm:px-6 lg:hidden">
|
||||||
|
<button class="-ml-2 flex h-10 w-10 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-ink hover:text-accent" type="button" data-toc-toggle aria-controls="toc" aria-expanded="false" aria-label="Contents">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" class="h-5 w-5 fill-none stroke-current" stroke-width="1.75"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
||||||
|
</button>
|
||||||
|
<span class="min-w-0 flex-1 truncate font-sans text-sm leading-tight text-ink">{{ toc.current_label() }}</span>
|
||||||
|
{% if toc.position > 0 %}<span class="shrink-0 font-sans text-xs tabular-nums text-muted">{{ toc.position }} / {{ toc.total }}</span>{% endif %}
|
||||||
|
<progress class="toc-progress absolute inset-x-0 -bottom-px" data-toc-progress value="{{ toc.position }}" max="{{ toc.total }}" aria-hidden="true"></progress>
|
||||||
|
</div>
|
||||||
|
<nav id="toc" data-toc-panel class="absolute inset-x-0 top-full z-30 max-h-[72vh] overflow-y-auto border-b border-rule bg-paper px-4 pb-7 pt-2 shadow-[0_18px_32px_-26px_rgb(0_0_0_/_0.6)] sm:px-6 lg:sticky lg:inset-x-auto lg:top-6 lg:z-auto lg:mt-12 lg:max-h-[calc(100vh-3rem)] lg:border-0 lg:px-0 lg:pb-10 lg:pt-0 lg:shadow-none" aria-label="Contents">
|
||||||
|
<div class="hidden lg:sticky lg:top-0 lg:z-10 lg:block lg:bg-paper lg:pb-3">
|
||||||
|
<a class="block font-sans text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted no-underline hover:text-ink" href="{{ toc.issue_href }}">No. {{ toc.issue_number }} <span class="text-rule" aria-hidden="true">·</span> {{ toc.short_date }}</a>
|
||||||
|
<p class="mt-2 font-sans text-[0.72rem] text-ink-2">{% if toc.position > 0 %}Chapter {{ toc.position }} of {{ toc.total }}{% else %}Front page{% endif %}</p>
|
||||||
|
<progress class="toc-progress mt-2.5" data-toc-progress value="{{ toc.position }}" max="{{ toc.total }}" aria-hidden="true"></progress>
|
||||||
|
</div>
|
||||||
|
<ul class="m-0 mt-1 list-none p-0 lg:mt-4">
|
||||||
|
{% for item in toc.items %}{% if item.is_section() %}<li class="mt-5 border-t border-rule pt-2 font-sans text-[0.66rem] font-semibold uppercase tracking-[0.12em] text-muted">{{ item.label }}</li>{% else %}<li{% if item.divider %} class="mt-4 border-t border-rule pt-3"{% endif %}><a class="flex items-baseline gap-2 border-l-2 py-2.5 pl-3 no-underline transition-colors duration-150 lg:py-1.5 {% if item.current %}border-accent font-semibold text-ink{% else %}border-transparent text-ink-2 hover:border-rule-strong hover:text-accent{% endif %}" href="{{ item.href }}"{% if item.current %} aria-current="page"{% endif %}>{% match item.number %}{% when Some with (number) %}<span class="w-4 shrink-0 font-sans text-[0.66rem] tabular-nums text-muted">{{ number }}</span>{% when None %}<span class="w-4 shrink-0" aria-hidden="true"></span>{% endmatch %}<span class="min-w-0 flex-1 font-serif text-[0.95rem] leading-snug">{{ item.label }}</span>{% match item.minutes %}{% when Some with (minutes) %}<span class="shrink-0 font-sans text-[0.66rem] tabular-nums text-muted">{{ minutes }} min</span>{% when None %}{% endmatch %}</a></li>{% endif %}{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{% extends "layout.html" %}{% block ears %}<span>Reader account</span>{% endblock %}{% block content %}<section class="mx-auto mt-12 max-w-md px-4 sm:mt-16 sm:px-6"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Reader account</p><h1 class="mt-2 text-4xl font-semibold leading-[1.1] tracking-[-0.01em]">Account</h1>{% if !error.is_empty() %}<p class="error mt-6">{{ error }}</p>{% endif %}<form class="mt-8 grid gap-5 border-b border-rule pb-9 font-sans text-sm" method="post" action="/account/password"><label class="grid gap-1.5 font-medium">Current password <input class="min-h-11 w-full" type="password" name="current_password" autocomplete="current-password" required></label><label class="grid gap-1.5 font-medium">New password <input class="min-h-11 w-full" type="password" name="new_password" autocomplete="new-password" minlength="12" required></label><label class="grid gap-1.5 font-medium">Confirm password <input class="min-h-11 w-full" type="password" name="confirm_password" autocomplete="new-password" minlength="12" required></label><button class="btn-primary mt-1 w-full">Change password</button></form><div class="mt-7 flex flex-wrap gap-3 font-sans text-sm"><form class="m-0" method="post" action="/account/logout-all" data-confirm="Sign out everywhere?"><button class="btn">Sign out everywhere</button></form><form class="m-0" method="post" action="/logout"><button class="btn">Sign out</button></form></div></section>{% endblock %}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{% extends "layout.html" %}{% block ears %}<span class="sm:hidden">{{ toc.short_date }}</span><span class="hidden sm:inline">{{ toc.display_date }} · No. {{ toc.issue_number }}</span>{% endblock %}{% block content %}<div class="mx-auto max-w-7xl lg:grid lg:grid-cols-[16rem_minmax(0,1fr)] lg:gap-x-10 lg:px-6 xl:grid-cols-[18rem_minmax(0,1fr)] xl:gap-x-14">{% include "_toc.html" %}
|
||||||
|
<article class="mx-auto mt-10 w-full max-w-[68ch] px-4 sm:px-6 lg:mt-12 lg:px-0" data-toc-scroll>
|
||||||
|
<header><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Article</p><h1 class="mt-3 text-4xl font-semibold leading-[1.1] tracking-[-0.01em] sm:text-5xl"><a class="text-ink no-underline hover:text-accent" href="{{ source_url }}">{{ title }}</a></h1>{% match byline %}{% when Some with (byline) %}<p class="mt-5 font-sans text-sm font-medium text-ink-2">{{ byline }}</p>{% when None %}{% endmatch %}<p class="mt-1 font-sans text-sm text-muted">{{ meta_line }}</p>{% match why %}{% when Some with (why) %}<p class="mt-6 border-l-2 border-accent pl-3 italic text-ink-2">Why it's here: {{ why }}</p>{% when None %}{% endmatch %}{% match social_line %}{% when Some with (social) %}<p class="mt-4 font-sans text-sm text-muted">{{ social }}</p>{% when None %}{% endmatch %}{% match summary %}{% when Some with (summary) %}<p class="mt-7 text-xl italic leading-relaxed text-ink-2">{{ summary }}</p>{% when None %}{% endmatch %}{% if excerpt_only %}<p class="notice mt-6">Excerpt only — continue reading at the original site.</p>{% endif %}</header>
|
||||||
|
<div class="prose-body mt-10 border-t border-rule pt-8">{{ body_html|safe }}</div>
|
||||||
|
{% match discussion_html %}{% when Some with (discussion) %}<section class="discussion prose-body mt-12 border-t border-rule pt-6 [&_.comment-meta]:font-sans [&_.comment-meta]:text-sm [&_.comment-meta]:text-muted [&_blockquote.reply]:ml-6"><h2 class="mb-6 border-0 pt-0 text-2xl">Discussion</h2>{{ discussion|safe }}</section>{% when None %}{% endmatch %}
|
||||||
|
<footer class="mt-12 border-t border-rule pt-6">{% match rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}<p class="mt-6"><a class="btn" href="{{ read_online_url }}">Read online ↗</a></p><nav class="mt-10 grid grid-cols-1 gap-3 font-sans sm:grid-cols-2" aria-label="Adjacent articles">{% match previous %}{% when Some with (previous) %}<a class="group min-h-24 border border-rule p-4 text-ink no-underline hover:border-accent" rel="prev" href="{{ previous.href }}"><span class="block text-[0.68rem] uppercase tracking-[0.12em] text-muted">Previous</span><span class="mt-2 block leading-snug group-hover:text-accent">← {{ previous.title }}</span></a>{% when None %}{% endmatch %}{% match next %}{% when Some with (next) %}<a class="group min-h-24 border border-rule p-4 text-right text-ink no-underline hover:border-accent sm:col-start-2" rel="next" href="{{ next.href }}"><span class="block text-[0.68rem] uppercase tracking-[0.12em] text-muted">Next</span><span class="mt-2 block leading-snug group-hover:text-accent">{{ next.title }} →</span></a>{% when None %}{% endmatch %}</nav><p class="mt-8 font-sans text-sm"><a href="{{ issue_href }}">← Back to this issue</a></p></footer>
|
||||||
|
</article>
|
||||||
|
</div>{% endblock %}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{% extends "layout.html" %}{% block ears %}<span class="sm:hidden">{{ toc.short_date }}</span><span class="hidden sm:inline">{{ toc.display_date }} · No. {{ toc.issue_number }}</span>{% endblock %}{% block content %}<div class="mx-auto max-w-7xl lg:grid lg:grid-cols-[16rem_minmax(0,1fr)] lg:gap-x-10 lg:px-6 xl:grid-cols-[18rem_minmax(0,1fr)] xl:gap-x-14">{% include "_toc.html" %}
|
||||||
|
<article class="mx-auto mt-10 w-full max-w-[68ch] px-4 sm:px-6 lg:mt-12 lg:px-0"><header class="mb-9"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Edition notes</p><h1 class="mt-2 text-4xl font-semibold leading-[1.1] tracking-[-0.01em] sm:text-5xl">Behind the paper</h1></header><section class="border-y border-rule py-6"><p>{{ summary_line }}</p><p class="mt-3">{{ admitted_line }}</p><p class="mt-3">{{ learned_line }}</p></section><section class="mt-10"><h2 class="border-t border-rule pt-2 font-sans text-[0.72rem] font-semibold uppercase tracking-[0.12em] text-muted">Near misses</h2><p class="mt-5 italic text-ink-2">Highest utility not selected.</p>{% if near_misses.is_empty() %}<p class="mt-4 text-muted">None recorded for this run.</p>{% else %}<ol class="mt-5 list-decimal space-y-3 pl-6">{% for near_miss in near_misses %}<li>{% if page.is_admin() %}<a class="text-ink decoration-rule hover:decoration-accent" href="/dashboard/articles/{{ near_miss.article_id }}">{{ near_miss.line }}</a>{% else %}{{ near_miss.line }}{% endif %}</li>{% endfor %}</ol>{% endif %}</section><p class="mt-10 border-l-2 border-rule pl-4 italic text-ink-2">{{ models_line }}</p><p class="mt-10 border-t border-rule pt-5 font-sans text-sm"><a href="{{ issue_href }}">← Back to this issue</a></p></article>
|
||||||
|
</div>{% endblock %}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{% if pager.pages > 1 %}<nav class="pager" aria-label="Pagination"><span>Page <span class="text-ink">{{ pager.page }}</span> of {{ pager.pages }} · {{ pager.total }} rows</span><span class="flex items-center gap-2">{% if let Some(href) = pager.prev_href %}<a class="btn" href="{{ href }}">← Previous</a>{% endif %}{% if let Some(href) = pager.next_href %}<a class="btn" href="{{ href }}">Next →</a>{% endif %}</span></nav>{% else %}<p class="pager">{{ pager.total }} row{% if pager.total != 1 %}s{% endif %}</p>{% endif %}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<figure class="spark-figure"><figcaption>{{ spark.title }}{% if !spark.caption.is_empty() %} <span class="muted">· {{ spark.caption }}</span>{% endif %}</figcaption>
|
||||||
|
{% if spark.empty %}<p class="muted text-sm">No data yet.</p>{% else %}<svg class="spark" viewBox="0 0 {{ spark.width }} {{ spark.height }}" width="{{ spark.width }}" height="{{ spark.height }}" role="img" aria-label="{{ spark.title }}">
|
||||||
|
{% for bar in spark.bars %}<rect class="s{{ bar.series }}" x="{{ bar.x }}" y="{{ bar.y }}" width="{{ bar.w }}" height="{{ bar.h }}"><title>{{ bar.label }}</title></rect>
|
||||||
|
{% endfor %}{% if !spark.points.is_empty() %}<polyline class="line" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" points="{{ spark.points }}"/>{% endif %}
|
||||||
|
</svg>
|
||||||
|
<div class="spark-axis muted"><span>{{ spark.first_label }}</span><span>{{ spark.last_label }}</span></div>
|
||||||
|
{% if !spark.legend.is_empty() %}<ul class="spark-legend">{% for item in spark.legend %}<li><svg class="spark h-2.5 w-2.5 shrink-0" width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><rect class="s{{ item.series }}" width="10" height="10"/></svg> {{ item.name }}</li>{% endfor %}</ul>{% endif %}
|
||||||
|
{% endif %}</figure>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
{% extends "layout.html" %}{% block content %}<section class="dashboard">
|
||||||
|
<header class="page-head"><div>
|
||||||
|
<p class="page-eyebrow"><a class="text-muted no-underline hover:text-accent" href="/dashboard/articles">Articles</a> › {{ id }}</p>
|
||||||
|
<h1 class="mt-1"><a class="text-ink no-underline hover:text-accent" href="{{ url }}">{{ title }}</a></h1>
|
||||||
|
<p class="page-desc"><a href="/dashboard/articles?feed={{ feed_id }}">{{ feed }}</a>{% if let Some(category) = category %} · {{ category }}{% endif %}{% if let Some(author) = author %} · {{ author }}{% endif %}</p>
|
||||||
|
</div><div class="page-actions">{% if let Some(rating) = rating %}<span class="badge {{ rating_class }}">{{ rating }}</span>{% else %}<span class="badge">unrated</span>{% endif %}<a class="btn" href="{{ url }}">Open source</a></div></header>
|
||||||
|
|
||||||
|
<div class="cards">
|
||||||
|
<section class="card"><h2>Article</h2>
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>Canonical URL</dt><dd><a href="{{ canonical_url }}">{{ canonical_url }}</a></dd>
|
||||||
|
<dt>Published</dt><dd>{{ published_at }}</dd>
|
||||||
|
<dt>First seen</dt><dd>{{ first_seen }}</dd>
|
||||||
|
<dt>Words</dt><dd>{{ words }}{% if excerpt_only %} <span class="badge">excerpt only</span>{% endif %}</dd>
|
||||||
|
<dt>Images</dt><dd>{{ image_count }}</dd>
|
||||||
|
<dt>Current rating</dt><dd>{% if let Some(rating) = rating %}<span class="badge {{ rating_class }}">{{ rating }}</span>{% else %}<span class="muted">unrated</span>{% endif %}</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
<section class="card"><h2>Provenance</h2>
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>Sources</dt><dd>{% if sources.is_empty() %}<span class="muted">none recorded</span>{% else %}{% for source in sources %}{{ source.kind }} · {{ source.feed }}{% if let Some(category) = source.category %} ({{ category }}){% endif %}{% if !loop.last %}<br>{% endif %}{% endfor %}{% endif %}</dd>
|
||||||
|
<dt>Social</dt><dd>{% if social.is_empty() %}<span class="muted">none</span>{% else %}{% for item in social %}{% if let Some(url) = item.url %}<a href="{{ url }}">{{ item.source }}</a>{% else %}{{ item.source }}{% endif %} · {{ item.score }} points · {{ item.comments }} comments{% if !loop.last %}<br>{% endif %}{% endfor %}{% endif %}</dd>
|
||||||
|
<dt>In issues</dt><dd>{% if in_issues.is_empty() %}<span class="muted">never published</span>{% else %}{% for issue in in_issues %}<a href="{{ issue.href }}">{{ issue.date }}</a> · {{ issue.section }} · position {{ issue.position }}{% if issue.is_lead %} · lead{% endif %}{% if !loop.last %}<br>{% endif %}{% endfor %}{% endif %}</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Your verdict</h2>
|
||||||
|
{% include "_rating_widget.html" %}
|
||||||
|
{% if let Some(text) = explain %}<details class="explain disclosure" id="article-explain"><summary>Explain (text)</summary><div class="disclosure-body"><pre class="block">{{ text }}</pre></div></details>{% endif %}
|
||||||
|
|
||||||
|
<h2>Assessments</h2>
|
||||||
|
{% if assessments.is_empty() %}<p class="muted text-sm">No LLM assessment stored.</p>{% else %}<div class="cards">{% for assessment in assessments %}<section class="card">
|
||||||
|
<h3 class="flex flex-wrap items-center gap-2">{{ assessment.stage }}{% if assessment.rejected %} <span class="badge down">rejected by provider</span>{% endif %}</h3>
|
||||||
|
{% if assessment.rejected %}<p>{{ assessment.rationale }}</p><p class="muted text-xs">{{ assessment.model }} · prompt v{{ assessment.prompt_version }} · {{ assessment.assessed_at }}</p>{% else %}<dl class="kv">
|
||||||
|
{% if assessment.stage == "deep" %}<dt>Quality</dt><dd class="tabular-nums">{{ assessment.score }}</dd><dt>Fit</dt><dd class="tabular-nums">{{ assessment.fit }}</dd><dt>Category</dt><dd>{{ assessment.category }}</dd><dt>Format</dt><dd>{{ assessment.kind }}</dd><dt>Paywalled guess</dt><dd>{% if assessment.paywalled %}yes{% else %}no{% endif %}</dd>{% else %}<dt>Interest</dt><dd class="tabular-nums">{{ assessment.score }}</dd><dt>Kind</dt><dd>{{ assessment.kind }}</dd>{% endif %}
|
||||||
|
<dt>Rationale</dt><dd>{{ assessment.rationale }}</dd>
|
||||||
|
<dt>Model</dt><dd>{{ assessment.model }} · prompt v{{ assessment.prompt_version }} · profile v{{ assessment.profile_version }}</dd>
|
||||||
|
<dt>Assessed</dt><dd>{{ assessment.assessed_at }}</dd>
|
||||||
|
</dl>
|
||||||
|
{% if !assessment.facets.is_empty() %}<div class="scroll-x"><table class="facets"><thead><tr><th>facet</th><th>value</th></tr></thead><tbody>{% for facet in assessment.facets %}<tr><td>{{ facet.name }}</td><td class="cell-wrap">{{ facet.value }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}{% endif %}
|
||||||
|
</section>{% endfor %}</div>{% endif %}
|
||||||
|
|
||||||
|
<h2>Run history</h2>
|
||||||
|
{% if history.is_empty() %}<p class="muted text-sm">Never considered by a run.</p>{% else %}<div class="scroll-x"><table class="history">
|
||||||
|
<thead><tr><th>run</th><th>date</th><th>stage</th><th>reason</th><th>admitted by</th><th class="num">utility</th><th class="num">rank</th><th class="num">cluster</th><th>editor</th></tr></thead>
|
||||||
|
<tbody>{% for row in history %}<tr>
|
||||||
|
<td class="cell-wrap"><details class="signals"><summary><a href="{{ row.run_href }}">run {{ row.run_id }}</a> <span class="badge {{ row.status }}">{{ row.status }}</span></summary>{% let signals = row.signals %}{% include "_signals_table.html" %}</details></td>
|
||||||
|
<td class="cell-tight text-muted">{{ row.date }}</td>
|
||||||
|
<td class="cell-tight"><span class="badge {{ row.stage }}">{{ row.stage }}</span></td>
|
||||||
|
<td class="cell-tight text-muted">{% if let Some(reason) = row.reason %}{{ reason }}{% endif %}</td>
|
||||||
|
<td class="cell-tight text-muted">{% if let Some(first) = row.admitted_first %}{{ first }}{% if !row.admitted_rest.is_empty() %} <span class="muted text-xs">({{ row.admitted_rest }})</span>{% endif %}{% endif %}</td>
|
||||||
|
<td class="num">{{ row.utility }}</td>
|
||||||
|
<td class="num">{{ row.rank }}</td>
|
||||||
|
<td class="num">{{ row.cluster }}</td>
|
||||||
|
<td class="cell-wrap text-muted">{% if let Some(why) = row.editor_why %}{{ why }}{% endif %}</td>
|
||||||
|
</tr>{% endfor %}</tbody></table></div>{% endif %}
|
||||||
|
|
||||||
|
<h2>Neighbours and interests</h2>
|
||||||
|
{% if let Some(signals) = latest_signals %}{% if signals.top_interests.is_empty() && signals.neighbours.is_empty() %}<p class="muted text-sm">The latest run recorded no interests or neighbours for this article.</p>{% else %}<div class="cards">
|
||||||
|
<section class="card"><h3>Top interests</h3>{% if signals.top_interests.is_empty() %}<p class="muted">None.</p>{% else %}<div class="scroll-x"><table><thead><tr><th>interest</th><th class="num">z</th><th class="num">cos</th></tr></thead><tbody>{% for interest in signals.top_interests %}<tr><td>{{ interest.name }}</td><td class="num">{{ interest.z }}</td><td class="num">{{ interest.cos }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}</section>
|
||||||
|
<section class="card"><h3>Nearest rated neighbours</h3>{% if signals.neighbours.is_empty() %}<p class="muted">None.</p>{% else %}<div class="scroll-x"><table><thead><tr><th>label</th><th class="num">cos</th><th>article</th></tr></thead><tbody>{% for neighbour in signals.neighbours %}<tr><td><span class="badge {{ neighbour.label }}">{{ neighbour.label }}</span></td><td class="num">{{ neighbour.cos }}</td><td class="cell-wrap"><a href="/dashboard/articles/{{ neighbour.article_id }}">{{ neighbour.title }}</a></td></tr>{% endfor %}</tbody></table></div>{% endif %}</section>
|
||||||
|
</div>{% endif %}{% else %}<p class="muted text-sm">No signals recorded.</p>{% endif %}
|
||||||
|
|
||||||
|
<h2>Embedding</h2>
|
||||||
|
{% if let Some(embedding) = embedding %}<dl class="kv"><dt>Model</dt><dd>{{ embedding.model }}</dd><dt>Dimension</dt><dd class="tabular-nums">{{ embedding.dimension }}</dd><dt>Created</dt><dd>{{ embedding.created_at }}</dd><dt>Input hash</dt><dd><code>{{ embedding.input_hash }}</code></dd></dl>{% else %}<p class="muted text-sm">No embedding stored.</p>{% endif %}
|
||||||
|
|
||||||
|
<h2>Rating events</h2>
|
||||||
|
{% if events.is_empty() %}<p class="muted text-sm">No rating events.</p>{% else %}<div class="scroll-x"><table class="events">
|
||||||
|
<thead><tr><th>when</th><th>label</th><th class="num">value</th><th>kind</th><th>source</th><th>user</th><th>issue</th><th>note</th></tr></thead>
|
||||||
|
<tbody>{% for event in events %}<tr>
|
||||||
|
<td class="cell-tight text-muted">{{ event.event_at }}</td>
|
||||||
|
<td class="cell-tight"><span class="badge {{ event.label_class }}">{{ event.label }}</span></td>
|
||||||
|
<td class="num">{{ event.value }}</td>
|
||||||
|
<td class="cell-tight text-muted">{{ event.kind }}</td>
|
||||||
|
<td class="cell-tight text-muted">{{ event.source }}</td>
|
||||||
|
<td class="cell-tight">{% if let Some(user) = event.user %}{{ user }}{% else %}<span class="muted">—</span>{% endif %}</td>
|
||||||
|
<td class="cell-tight">{% if let Some(date) = event.issue_date %}<a href="/issues/{{ date }}">{{ date }}</a>{% endif %}</td>
|
||||||
|
<td class="cell-wrap text-muted">{% if let Some(note) = event.note %}{{ note }}{% endif %}</td>
|
||||||
|
</tr>{% endfor %}</tbody></table></div>{% endif %}
|
||||||
|
</section>{% endblock %}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{% extends "layout.html" %}{% block content %}<section class="dashboard">
|
||||||
|
<header class="page-head"><div>
|
||||||
|
<h1>Articles</h1>
|
||||||
|
<p class="page-desc">Every article the crawler has ever seen, with the last stage it reached, its scores and its verdict.</p>
|
||||||
|
</div><div class="page-actions"><a class="btn" href="/dashboard/ratings">Ratings</a><a class="btn" href="/dashboard/runs">Runs</a></div></header>
|
||||||
|
<form class="filters" method="get" action="/dashboard/articles">
|
||||||
|
<label>Title or URL <input type="search" name="q" value="{% if let Some(q) = filters.q %}{{ q }}{% endif %}" placeholder="contains…"></label>
|
||||||
|
<label>Feed id <input type="number" name="feed" value="{{ feed_value }}" min="1"></label>
|
||||||
|
<label>Stage <select name="stage"><option value="">any</option>{% for name in stages %}<option value="{{ name }}"{% if let Some(current) = filters.stage %}{% if current.as_str() == *name %} selected{% endif %}{% endif %}>{{ name }}</option>{% endfor %}</select></label>
|
||||||
|
<label>Reason <select name="reason"><option value="">any</option>{% for name in reasons %}<option value="{{ name }}"{% if let Some(current) = filters.reason %}{% if current.as_str() == *name %} selected{% endif %}{% endif %}>{{ name }}</option>{% endfor %}</select></label>
|
||||||
|
<label>Rated <select name="rated"><option value="">all</option>{% for name in rated %}<option value="{{ name }}"{% if let Some(current) = filters.rated %}{% if current.as_str() == *name %} selected{% endif %}{% endif %}>{{ name }}</option>{% endfor %}</select></label>
|
||||||
|
<label>Published <select name="published"><option value="">all</option><option value="yes"{% if let Some(current) = filters.published %}{% if current == "yes" %} selected{% endif %}{% endif %}>yes</option><option value="no"{% if let Some(current) = filters.published %}{% if current == "no" %} selected{% endif %}{% endif %}>no</option></select></label>
|
||||||
|
<label>First seen from <input type="date" name="from" value="{% if let Some(from) = filters.from %}{{ from }}{% endif %}"></label>
|
||||||
|
<label>to <input type="date" name="to" value="{% if let Some(to) = filters.to %}{{ to }}{% endif %}"></label>
|
||||||
|
<label>Triage kind <select name="kind"><option value="">any</option>{% for name in kinds %}<option value="{{ name }}"{% if let Some(current) = filters.kind %}{% if current.as_str() == *name %} selected{% endif %}{% endif %}>{{ name }}</option>{% endfor %}</select></label>
|
||||||
|
<label>Sort <select name="sort">{% for name in sorts %}<option value="{{ name }}"{% if filters.sort == *name %} selected{% endif %}>{{ name }}</option>{% endfor %}</select></label>
|
||||||
|
<div class="filter-actions"><button class="btn" type="submit">Apply</button> <a class="btn-quiet" href="/dashboard/articles">Reset</a></div>
|
||||||
|
</form>
|
||||||
|
{% include "dashboard/_pager.html" %}
|
||||||
|
<div class="scroll-x tall"><table class="articles" data-filter>
|
||||||
|
<thead><tr><th>first seen</th><th>title</th><th>feed</th><th class="num">words</th><th>last stage</th><th>reason</th><th class="num">utility</th><th class="num">triage</th><th class="num">quality</th><th class="num">fit</th><th>rating</th><th>published</th></tr></thead>
|
||||||
|
<tbody>{% for article in articles %}<tr>
|
||||||
|
<td class="cell-tight text-muted">{{ article.first_seen }}</td>
|
||||||
|
<td class="cell-wrap"><a href="{{ article.href }}">{{ article.title }}</a></td>
|
||||||
|
<td class="cell-tight">{% if let Some(feed_id) = article.feed_id %}<a href="/dashboard/articles?feed={{ feed_id }}">{{ article.feed }}</a>{% else %}{{ article.feed }}{% endif %}</td>
|
||||||
|
<td class="num">{{ article.words }}</td>
|
||||||
|
<td class="cell-tight">{% if let Some(stage) = article.stage %}<span class="badge {{ stage }}">{{ stage }}</span>{% if let Some(run_id) = article.run_id %} <a class="muted text-xs" href="/dashboard/runs/{{ run_id }}">{% if let Some(date) = article.run_date %}{{ date }}{% else %}run {{ run_id }}{% endif %}</a>{% endif %}{% else %}<span class="muted text-xs">never considered</span>{% endif %}</td>
|
||||||
|
<td class="cell-tight text-muted">{% if let Some(reason) = article.reason %}{{ reason }}{% endif %}</td>
|
||||||
|
<td class="num">{{ article.utility }}</td>
|
||||||
|
<td class="num">{{ article.triage }}</td>
|
||||||
|
<td class="num">{{ article.quality }}</td>
|
||||||
|
<td class="num">{{ article.fit }}</td>
|
||||||
|
<td class="cell-tight">{% if let Some(rating) = article.rating %}<span class="badge {{ article.rating_class }}">{{ rating }}</span>{% endif %}</td>
|
||||||
|
<td class="cell-tight">{% if let Some(date) = article.published %}<a href="/issues/{{ date }}">{{ date }}</a>{% endif %}</td>
|
||||||
|
</tr>{% endfor %}{% if articles.is_empty() %}<tr><td colspan="12" class="text-muted">No articles match this filter.</td></tr>{% endif %}</tbody></table></div>
|
||||||
|
{% include "dashboard/_pager.html" %}
|
||||||
|
</section>{% endblock %}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{% extends "layout.html" %}{% block content %}<section class="dashboard"{% if refresh %} data-refresh="5"{% endif %}>
|
||||||
|
<header class="page-head"><div>
|
||||||
|
<p class="page-eyebrow"><a class="text-muted no-underline hover:text-accent" href="/dashboard/jobs">Jobs</a> › Job {{ job.id }}</p>
|
||||||
|
<h1 class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1"><span class="font-mono">{{ job.name }}</span> <span class="badge {{ job.status }}">{{ job.status }}</span></h1>
|
||||||
|
{% if !description.is_empty() %}<p class="page-desc">{{ description }}</p>{% endif %}
|
||||||
|
</div><div class="page-actions"><a class="btn" href="/dashboard/jobs">All jobs</a>{% if let Some(run_id) = job.run_id %}<a class="btn" href="/dashboard/runs/{{ run_id }}">Run {{ run_id }}</a>{% endif %}</div></header>
|
||||||
|
<div class="cards">
|
||||||
|
<section class="card"><h2>Job</h2>
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>Unit</dt><dd><code>{{ unit }}</code></dd>
|
||||||
|
<dt>Requested</dt><dd>{{ job.requested }} · {{ job.requested_by }}</dd>
|
||||||
|
<dt>Started</dt><dd>{{ job.started }}</dd>
|
||||||
|
<dt>Finished</dt><dd>{{ job.finished }} · {{ job.duration }}</dd>
|
||||||
|
<dt>Status</dt><dd><span class="badge {{ job.status }}">{{ job.status }}</span></dd>
|
||||||
|
{% if let Some(message) = job.message %}<dt>Message</dt><dd class="message">{{ message }}</dd>{% endif %}
|
||||||
|
{% if let Some(run_id) = job.run_id %}<dt>Run</dt><dd><a href="/dashboard/runs/{{ run_id }}">Run {{ run_id }}</a></dd>{% endif %}
|
||||||
|
</dl>
|
||||||
|
{% if refresh %}<p class="muted text-xs">This page reloads every 5 seconds while the job is requested or running.</p>{% endif %}
|
||||||
|
</section>
|
||||||
|
<section class="card"><h2>Unit</h2>
|
||||||
|
{% if let Some(status) = status %}<dl class="kv">
|
||||||
|
<dt>Active</dt><dd>{{ status.active_state }}{% if !status.sub_state.is_empty() %} ({{ status.sub_state }}){% endif %}</dd>
|
||||||
|
<dt>Result</dt><dd>{{ status.result }}{% if let Some(code) = status.exit_status %} · exit status {{ code }}{% endif %}</dd>
|
||||||
|
{% if let Some(started) = status.started %}<dt>Main started</dt><dd>{{ started }}</dd>{% endif %}
|
||||||
|
{% if let Some(exited) = status.exited %}<dt>Main exited</dt><dd>{{ exited }}</dd>{% endif %}
|
||||||
|
</dl>{% endif %}
|
||||||
|
{% if let Some(error) = status_error %}<p class="error">Unit status unavailable: {{ error }}</p>{% endif %}
|
||||||
|
<p class="muted text-xs">From <code>systemctl show {{ unit }}</code>.</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<h2>Journal</h2>
|
||||||
|
{% if let Some(error) = log_error %}<p class="error">Journal unavailable: {{ error }}</p>{% endif %}
|
||||||
|
<p class="muted text-sm">Last {{ log_lines }} lines of <code>journalctl -u {{ unit }}</code>.</p>
|
||||||
|
<pre class="preview journal">{{ log }}</pre>
|
||||||
|
</section>{% endblock %}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{% extends "layout.html" %}{% block content %}<section class="dashboard">
|
||||||
|
<header class="page-head"><div>
|
||||||
|
<h1>Jobs</h1>
|
||||||
|
<p class="page-desc">{% if jobs_enabled %}Each job starts <code>daily-epub-job@<name>.service</code> through systemd; the unit runs <code>daily-epub job run <name></code> and records itself here.{% else %}Jobs are disabled on this server; the catalogue below is read-only.{% endif %}</p>
|
||||||
|
</div><div class="page-actions"><a class="btn" href="/dashboard/runs">Runs</a><a class="btn" href="/dashboard/settings">Settings</a></div></header>
|
||||||
|
{% if jobs_enabled %}<p class="muted text-sm">Jobs that take the run lock wait for nothing: a second <code>generate</code> while one is running fails immediately.</p>{% else %}<p class="notice">Jobs are disabled on this server (<code>server.jobs_enabled = false</code>); run the commands by hand instead.</p>{% endif %}
|
||||||
|
<div class="cards job-cards">{% for card in cards %}<section class="card flex flex-col">
|
||||||
|
<div class="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
|
||||||
|
<h2 class="font-mono text-base">{{ card.name }}</h2>
|
||||||
|
{% if let Some(status) = card.last_status %}<span class="badge {{ status }}">{{ status }}</span>{% else %}<span class="muted text-xs uppercase tracking-[0.1em]">never run</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
<p class="text-muted">{{ card.description }}</p>
|
||||||
|
<p class="text-xs text-muted">{% if let Some(id) = card.last_id %}Last started <a href="/dashboard/jobs/{{ id }}">{% if let Some(when) = card.last_requested %}{{ when }}{% else %}job {{ id }}{% endif %}</a>{% else %}No run recorded yet.{% endif %}{% if let Some(lock) = card.lock %} · takes the <code class="whitespace-nowrap">{{ lock }}</code> lock{% endif %}</p>
|
||||||
|
<form method="post" action="/dashboard/jobs/{{ card.name }}" class="form-inline mt-auto pt-1"{% if card.dangerous %} data-confirm="Start {{ card.name }}? This republishes the issue of that date."{% endif %}>
|
||||||
|
{% if card.dated %}<label class="text-xs uppercase tracking-[0.1em] text-muted">Date <input type="date" name="date" value="{{ today }}" class="text-sm normal-case tracking-normal text-ink"></label>{% endif %}
|
||||||
|
<button class="btn" type="submit"{% if !jobs_enabled %} disabled{% endif %}>Start</button>
|
||||||
|
</form>
|
||||||
|
</section>{% endfor %}</div>
|
||||||
|
<h2>History</h2>
|
||||||
|
{% if jobs.is_empty() %}<p class="muted text-sm">No jobs recorded yet.</p>{% else %}<div class="scroll-x tall"><table data-filter>
|
||||||
|
<thead><tr><th class="num">job</th><th>name</th><th>requested by</th><th>requested</th><th>started</th><th>finished</th><th class="num">duration</th><th>status</th><th>message</th><th class="num">run</th></tr></thead>
|
||||||
|
<tbody>{% for job in jobs %}<tr>
|
||||||
|
<td class="num"><a href="/dashboard/jobs/{{ job.id }}">{{ job.id }}</a></td>
|
||||||
|
<td class="cell-tight font-mono text-xs">{{ job.name }}</td>
|
||||||
|
<td class="cell-tight text-muted">{{ job.requested_by }}</td>
|
||||||
|
<td class="cell-tight text-muted">{{ job.requested }}</td>
|
||||||
|
<td class="cell-tight text-muted">{{ job.started }}</td>
|
||||||
|
<td class="cell-tight text-muted">{{ job.finished }}</td>
|
||||||
|
<td class="num">{{ job.duration }}</td>
|
||||||
|
<td class="cell-tight"><span class="badge {{ job.status }}">{{ job.status }}</span></td>
|
||||||
|
<td class="message cell-wrap text-muted">{% if let Some(message) = job.message %}{{ message }}{% endif %}</td>
|
||||||
|
<td class="num">{% if let Some(run_id) = job.run_id %}<a href="/dashboard/runs/{{ run_id }}">{{ run_id }}</a>{% endif %}</td>
|
||||||
|
</tr>{% endfor %}</tbody></table></div>{% endif %}
|
||||||
|
</section>{% endblock %}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
{% extends "layout.html" %}{% block content %}<section class="dashboard">
|
||||||
|
<header class="page-head"><div>
|
||||||
|
<h1>Overview</h1>
|
||||||
|
<p class="page-desc">The state of the paper right now: the last run, today's spend, this week's verdicts and anything waiting on you.</p>
|
||||||
|
</div><div class="page-actions"><a class="btn" href="/dashboard/runs">Runs</a><a class="btn" href="/dashboard/jobs">Jobs</a><a class="btn" href="/dashboard/stats">Stats</a></div></header>
|
||||||
|
|
||||||
|
<div class="tiles">
|
||||||
|
<div class="tile"><span class="tile-label">Last run</span>{% if let Some(run) = last_run %}<span class="tile-num">{{ run.date }}</span><span class="tile-delta"><span class="badge {{ run.status }}">{{ run.status }}</span> · {{ run.duration }}</span>{% else %}<span class="tile-num text-muted">—</span><span class="tile-delta">no runs yet</span>{% endif %}</div>
|
||||||
|
<div class="tile"><span class="tile-label">Warnings</span>{% if let Some(run) = last_run %}<span class="tile-num{% if run.warnings > 0 %} text-warn{% endif %}">{{ run.warnings }}</span><span class="tile-delta">{% if run.warnings > 0 %}<a href="/dashboard/runs/{{ run.id }}#warnings">on run {{ run.id }}</a>{% else %}clean run{% endif %}</span>{% else %}<span class="tile-num text-muted">—</span><span class="tile-delta">no runs yet</span>{% endif %}</div>
|
||||||
|
<div class="tile"><span class="tile-label">Verdicts, 7 days</span><span class="tile-num">{{ ratings_total }}</span><span class="tile-delta"><a href="/dashboard/ratings">rating history</a></span></div>
|
||||||
|
<div class="tile"><span class="tile-label">Unrated picks</span><span class="tile-num">{{ unrated.len() }}</span><span class="tile-delta">from the last three issues</span></div>
|
||||||
|
<div class="tile"><span class="tile-label">Active jobs</span><span class="tile-num">{{ active_jobs.len() }}</span><span class="tile-delta"><a href="/dashboard/jobs">all jobs</a></span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cards">
|
||||||
|
<section class="card"><h2>Last run</h2>
|
||||||
|
{% if let Some(run) = last_run %}<p><a href="/dashboard/runs/{{ run.id }}">Run {{ run.id }}</a> · {{ run.date }} · <span class="badge {{ run.status }}">{{ run.status }}</span></p>
|
||||||
|
<p class="muted text-xs">Started {{ run.started }} · {{ run.duration }}</p>
|
||||||
|
<pre class="block">{% for line in run.lines %}{{ line }}
|
||||||
|
{% endfor %}</pre>
|
||||||
|
{% if run.warnings > 0 %}<p class="text-sm"><a href="/dashboard/runs/{{ run.id }}#warnings">{{ run.warnings }} warning{% if run.warnings != 1 %}s{% endif %}</a></p>{% endif %}
|
||||||
|
{% if let Some(error) = run.error %}<p class="error">{{ error }}</p>{% endif %}
|
||||||
|
{% else %}<p class="muted">No runs recorded yet.</p>{% endif %}
|
||||||
|
</section>
|
||||||
|
<section class="card"><h2>Budget today</h2>
|
||||||
|
{% if budget.is_empty() %}<p class="muted">No providers configured.</p>{% else %}<dl class="kv budget grid-cols-1 gap-y-3">
|
||||||
|
{% for line in budget %}<div><div class="flex flex-wrap items-baseline justify-between gap-2"><dt class="font-medium text-ink">{{ line.provider }}</dt><dd class="text-muted">{{ line.spent }} of {{ line.ceiling }}{% if line.over %} · <strong class="text-down">ceiling reached</strong>{% endif %}</dd></div><dd class="mt-1"><meter value="{{ line.value }}" max="{{ line.max }}"{% if line.over %} class="over"{% endif %}></meter></dd></div>
|
||||||
|
{% endfor %}</dl>{% endif %}
|
||||||
|
<p class="muted text-xs">Spend on the current UTC day across every recorded run.</p>
|
||||||
|
</section>
|
||||||
|
<section class="card"><h2>Ratings this week</h2>
|
||||||
|
{% if ratings.is_empty() %}<p class="muted">No explicit ratings in the last seven days.</p>{% else %}<p class="flex flex-wrap items-center gap-x-3 gap-y-1.5">{{ ratings_total }} verdict{% if ratings_total != 1 %}s{% endif %}{% for count in ratings %}<span class="inline-flex items-center gap-1.5"><span class="badge {{ count.label }}">{{ count.label }}</span> <span class="tabular-nums">{{ count.count }}</span></span>{% endfor %}</p>{% endif %}
|
||||||
|
<p class="text-sm"><a href="/dashboard/ratings">Rating history</a></p>
|
||||||
|
</section>
|
||||||
|
<section class="card"><h2>Jobs</h2>
|
||||||
|
{% if active_jobs.is_empty() && finished_jobs.is_empty() %}<p class="muted">No jobs recorded.</p>{% else %}
|
||||||
|
{% if !active_jobs.is_empty() %}<ul class="jobs divide-y divide-rule border-y border-rule">{% for job in active_jobs %}<li class="flex flex-wrap items-baseline gap-x-2 py-1.5"><a class="font-mono text-xs" href="/dashboard/jobs/{{ job.id }}">{{ job.name }}</a> <span class="badge {{ job.status }}">{{ job.status }}</span> <span class="muted text-xs">requested {{ job.requested }}</span></li>{% endfor %}</ul>{% endif %}
|
||||||
|
{% if !finished_jobs.is_empty() %}<p class="page-eyebrow">Recently finished</p><ul class="jobs divide-y divide-rule border-y border-rule">{% for job in finished_jobs %}<li class="flex flex-wrap items-baseline gap-x-2 py-1.5"><a class="font-mono text-xs" href="/dashboard/jobs/{{ job.id }}">{{ job.name }}</a> <span class="badge {{ job.status }}">{{ job.status }}</span> <span class="muted text-xs">{{ job.finished }}</span>{% if let Some(message) = job.message %} <span class="muted text-xs">— {{ message }}</span>{% endif %}</li>{% endfor %}</ul>{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
<p class="text-sm"><a href="/dashboard/jobs">All jobs</a></p>
|
||||||
|
</section>
|
||||||
|
{% if !config_warnings.is_empty() %}<section class="card"><h2>Config on disk</h2>
|
||||||
|
<ul class="warnings error list-disc pl-5">{% for line in config_warnings %}<li>{{ line }}</li>{% endfor %}</ul>
|
||||||
|
<p class="text-sm"><a href="/dashboard/settings">Settings</a></p>
|
||||||
|
</section>{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Last 30 runs</h2>
|
||||||
|
<div class="sparklines">{% for item in sparklines %}{% let spark = item %}{% include "dashboard/_sparkline.html" %}{% endfor %}</div>
|
||||||
|
|
||||||
|
<h2>Unrated picks</h2>
|
||||||
|
{% if unrated.is_empty() %}<p class="muted text-sm">Every pick from the last three issues has a verdict.</p>{% else %}<p class="muted text-sm">Picks from the last three issues without a verdict yet.</p>
|
||||||
|
<ul class="picks divide-y divide-rule border-y border-rule">{% for pick in unrated %}<li class="py-3"><a class="font-medium text-ink no-underline hover:text-accent" href="{{ pick.issue_href }}">{{ pick.title }}</a> <span class="muted text-xs">· {{ pick.feed }} · {{ pick.issue_date }}</span>
|
||||||
|
{% let widget = pick.widget %}{% include "_rating_widget.html" %}</li>{% endfor %}</ul>{% endif %}
|
||||||
|
</section>{% endblock %}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
{% extends "layout.html" %}{% block content %}<section class="dashboard profile">
|
||||||
|
<header class="page-head"><div>
|
||||||
|
<h1>Profile</h1>
|
||||||
|
<p class="page-desc">The standing taste file the curator reads before every run: what you like, what the OPML declares, and what the editor model has learned from your verdicts.</p>
|
||||||
|
</div><div class="page-actions"><a class="btn" href="/dashboard/ratings">Ratings</a><a class="btn" href="/dashboard/settings#curation.feedback">Feedback settings</a></div></header>
|
||||||
|
|
||||||
|
<div class="profile-grid grid gap-6 lg:grid-cols-2">
|
||||||
|
<div class="profile-editor min-w-0">
|
||||||
|
<h3 class="font-mono text-base">profile.md</h3>
|
||||||
|
<p class="meta mt-1 text-xs"><code>{{ path }}</code>{% if !exists %} — <strong class="text-down">missing</strong>; saving creates it{% else %} · {{ bytes }} bytes{% endif %} · limit {{ max_bytes }} bytes. Any <code>## Interests</code> section is parsed one interest per line; everything else goes into the system prompt verbatim.</p>
|
||||||
|
<form method="post" action="/dashboard/profile" class="profile-form mt-3">
|
||||||
|
<textarea name="content" rows="24" spellcheck="true" required class="w-full font-mono text-sm">{{ content }}</textarea>
|
||||||
|
<div class="mt-3 flex flex-wrap items-center gap-3"><button class="btn-primary" type="submit">Save</button> <span class="meta text-xs">The next run rebuilds the system prompt from the saved file.</span></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-preview min-w-0">
|
||||||
|
<h3 class="text-base">What the loader parses</h3>
|
||||||
|
<p class="meta mt-1 text-xs">A live read of the text on the left, exactly as <code>curate::profile</code> splits it.</p>
|
||||||
|
<h4 class="mt-4 page-eyebrow">Passthrough sections</h4>
|
||||||
|
{% if preview_body.trim().is_empty() %}<p class="meta mt-1 text-sm">Nothing passes through — the file is empty or only has an Interests section.</p>{% else %}<pre class="preview mt-2">{{ preview_body }}</pre>{% endif %}
|
||||||
|
<h4 class="mt-5 page-eyebrow">Extracted <code>## Interests</code> lines</h4>
|
||||||
|
{% if preview_interests.is_empty() %}<p class="meta mt-1 text-sm">None. The prompt uses the OPML interests alone.</p>{% else %}<ul class="mt-2 list-disc space-y-1 pl-5 text-sm marker:text-muted">{% for interest in preview_interests %}<li>{{ interest }}</li>{% endfor %}</ul>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>History</h2>
|
||||||
|
{% if versions.is_empty() %}<p class="meta text-sm">No saved versions yet. The first save records the current file as version 1.</p>{% else %}
|
||||||
|
<p class="meta text-sm">Each row is the text that was replaced by a save (or a restore). Restore writes it back and records what is on disk now as a new version.</p>
|
||||||
|
<div class="scroll-x tall"><table class="versions">
|
||||||
|
<thead><tr><th class="num">Version</th><th>Replaced at</th><th>By</th><th class="num">Size</th><th>Preview</th><th></th></tr></thead>
|
||||||
|
<tbody>{% for version in versions %}<tr>
|
||||||
|
<td class="num">#{{ version.id }}</td>
|
||||||
|
<td class="cell-tight text-muted">{{ version.saved_at }}</td>
|
||||||
|
<td class="cell-tight">{{ version.saved_by }}</td>
|
||||||
|
<td class="num text-muted">{{ version.bytes }} bytes</td>
|
||||||
|
<td><pre class="preview">{{ version.preview }}</pre></td>
|
||||||
|
<td class="cell-tight"><form method="post" action="/dashboard/profile/restore" data-confirm="Restore version #{{ version.id }}? The current file is kept as a new version."><input type="hidden" name="version_id" value="{{ version.id }}"><button class="btn" type="submit">Restore</button></form></td>
|
||||||
|
</tr>{% endfor %}</tbody></table></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h2>Standing interests</h2>
|
||||||
|
<p class="meta text-sm"><code>{{ opml_path }}</code> · {{ opml_count }} interests, grouped the way the system prompt lists them. The union of these and the <code>## Interests</code> lines above is what the prompt uses; edit the OPML file to change them.</p>
|
||||||
|
{% if !opml_error.is_empty() %}<p class="error">{{ opml_error }}</p>{% endif %}
|
||||||
|
{% if !themes.is_empty() %}<dl class="kv themes">{% for theme in themes %}<dt class="text-ink">{{ theme.name }} <span class="meta">({{ theme.count }})</span></dt><dd class="text-muted">{{ theme.members }}</dd>{% endfor %}</dl>{% endif %}
|
||||||
|
|
||||||
|
<h2>Learned adjustments</h2>
|
||||||
|
<p class="meta text-sm">Rebuilt weekly from ratings by the editor model (prompt version {{ prompt_version }}, built {{ prompt_built_at }}, {{ learned_age }}). {% if rebuild_due %}<strong class="text-warn">A rebuild is due</strong> — the next run performs it, or start it now.{% else %}The next scheduled rebuild is at least {{ rebuild_interval_days }} days after the last one; the next run performs it when due.{% endif %}</p>
|
||||||
|
{% if learned.trim().is_empty() %}<p class="meta text-sm">No learned adjustments stored yet.</p>{% else %}<pre class="preview learned">{{ learned }}</pre>{% endif %}
|
||||||
|
<form method="post" action="/dashboard/jobs/profile-rebuild" class="form-inline" data-confirm="Rebuild the learned adjustments now? This calls the editor model."><button class="btn" type="submit"{% if !jobs_enabled %} disabled{% endif %}>Rebuild profile now</button>{% if !jobs_enabled %} <span class="meta text-xs">Jobs are disabled on this server (<code>server.jobs_enabled = false</code>); run <code>daily-epub profile rebuild</code> instead.</span>{% else %} <span class="meta text-xs">Starts the <code>profile-rebuild</code> job through systemd.</span>{% endif %}</form>
|
||||||
|
|
||||||
|
<h2>System prompt</h2>
|
||||||
|
<p class="meta text-sm">Stored after the last run: version {{ prompt_version }}, {{ prompt_chars }} characters, {{ prompt_verdicts }} verdict lines. Every LLM call of a run receives exactly this text.</p>
|
||||||
|
{% if prompt.is_empty() %}<p class="meta text-sm">No prompt has been built yet.</p>{% else %}<details id="profile-prompt" class="disclosure"><summary>Show the system prompt</summary><div class="disclosure-body"><pre class="preview prompt">{{ prompt }}</pre></div></details>{% endif %}
|
||||||
|
</section>{% endblock %}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
{% extends "layout.html" %}{% block content %}<section class="dashboard ratings">
|
||||||
|
<header class="page-head"><div>
|
||||||
|
<h1>Ratings</h1>
|
||||||
|
<p class="page-desc">{{ summary_line }}</p>
|
||||||
|
</div><div class="page-actions"><a class="btn" href="/dashboard/profile">Profile</a><a class="btn" href="/dashboard/articles?rated=unrated">Unrated articles</a></div></header>
|
||||||
|
{% if no_embedding_count > 0 %}<p class="notice">{{ no_embedding_count }} rated articles have no embedding and cannot act as neighbours — run the <code>features-backfill</code> job (<code>features backfill --rated-only</code>).</p>{% endif %}
|
||||||
|
<details id="ratings-how" class="how disclosure">
|
||||||
|
<summary>How ratings enter the algorithm</summary>
|
||||||
|
<div class="disclosure-body">
|
||||||
|
<ol class="list-decimal space-y-3 pl-5 marker:text-muted">
|
||||||
|
<li><strong>Prompt verdict block.</strong> The {{ how.verdicts_in_prompt }} most recent non-cleared verdicts, newest first, are written into every LLM call's system prompt as one line each (<code>LOVED | title | feed | summary</code>). Only the rank matters here — a verdict never ages out of this block, it is pushed out by newer ones. Tune <a href="/dashboard/settings#curation.feedback"><code>curation.feedback.verdicts_in_prompt</code></a>.</li>
|
||||||
|
<li><strong>Weekly learned adjustments.</strong> Every {{ how.rebuild_interval_days }} days the editor model rewrites the profile's "Learned adjustments" bullets from the {{ how.max_ratings_in_rebuild }} most recent non-cleared verdicts, including notes and deep-assessment facets. See the <a href="/dashboard/profile">Profile</a> page.</li>
|
||||||
|
<li><strong>Rated-neighbour signal.</strong> Each verdict with an embedding is an example with weight <code>value × 0.5^(age / {{ how.half_life_days }} days)</code>, where loved = {{ how.loved }}, good = {{ how.good }}, not for me = {{ how.not_for_me }}; ratings older than {{ how.lookback_days }} days are not loaded. A candidate's signal is the weighted mean cosine to its {{ how.neighbour_k }} nearest positive examples minus {{ how.negative_coefficient }} × the same over its nearest negative ones. The signal's preliminary weight ({{ how.knn_weight }}) is scaled by a gate that opens above {{ how.knn_floor }} embedded verdicts and is fully open at {{ how.knn_full }}. Tune <a href="/dashboard/settings#curation.ranking"><code>curation.ranking.rating_half_life_days</code>, <code>knn_floor</code>, <code>knn_full</code>, <code>neighbour_k</code></a> and <a href="/dashboard/settings#curation.ranking.weights.preliminary"><code>weights.preliminary.knn</code></a>.</li>
|
||||||
|
<li><strong>Feed affinity.</strong> The same decayed weight is credited to the rated article's direct feeds, split evenly; each feed's Beta-smoothed rate <code>(up + 1) / (up + down + 2)</code> becomes a candidate's signal (the mean over its rated direct feeds). Its weight ({{ how.feed_weight }}) is gated between {{ how.feed_floor }} and {{ how.feed_full }} attributable ratings. Tune <a href="/dashboard/settings#curation.ranking"><code>curation.ranking.feed_floor</code>, <code>feed_full</code></a> and <a href="/dashboard/settings#curation.ranking.weights.preliminary"><code>weights.preliminary.feed</code></a>.</li>
|
||||||
|
</ol>
|
||||||
|
<p class="muted">Clearing a verdict removes it from all four paths without deleting history; ratings are append-only.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<nav class="tabs" aria-label="Ratings view"><a href="{{ current_href }}"{% if tab == "current" %} class="active" aria-current="page"{% endif %}>Current ({{ current_total }})</a> <a href="{{ events_href }}"{% if tab == "events" %} class="active" aria-current="page"{% endif %}>Events</a></nav>
|
||||||
|
|
||||||
|
{% if tab == "events" %}
|
||||||
|
<form class="filters" method="get" action="/dashboard/ratings">
|
||||||
|
<input type="hidden" name="tab" value="events">
|
||||||
|
<label>Label <select name="label"><option value="">any</option><option value="loved"{% if filter_label == "loved" %} selected{% endif %}>Loved it</option><option value="good"{% if filter_label == "good" %} selected{% endif %}>Good</option><option value="down"{% if filter_label == "down" %} selected{% endif %}>Not for me</option><option value="cleared"{% if filter_label == "cleared" %} selected{% endif %}>Cleared</option></select></label>
|
||||||
|
<label>Source <select name="source"><option value="">any</option>{% for source in sources %}<option value="{{ source }}"{% if filter_source == source.as_str() %} selected{% endif %}>{{ source }}</option>{% endfor %}</select></label>
|
||||||
|
<label>User <select name="user"><option value="">any</option>{% for username in usernames %}<option value="{{ username }}"{% if filter_user == username.as_str() %} selected{% endif %}>{{ username }}</option>{% endfor %}</select></label>
|
||||||
|
<label>From <input type="date" name="from" value="{{ filter_from }}"></label>
|
||||||
|
<label>To <input type="date" name="to" value="{{ filter_to }}"></label>
|
||||||
|
<div class="filter-actions"><button class="btn" type="submit">Filter</button> <a class="btn-quiet" href="{{ events_href }}">Reset</a></div>
|
||||||
|
</form>
|
||||||
|
<p class="meta text-sm">{{ pagination.total }} events, newest first. History is append-only; a later explicit event for the same article marks the earlier one <em>superseded</em>. Use the clear verdict to retract.</p>
|
||||||
|
<div class="scroll-x tall"><table>
|
||||||
|
<thead><tr><th class="num">Event</th><th>Verdict</th><th class="num">Value</th><th>Article</th><th>Issue</th><th>When</th><th>Source</th><th>By</th><th>Note</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for event in events %}<tr{% if event.superseded %} class="superseded"{% endif %}>
|
||||||
|
<td class="num">#{{ event.id }}{% if event.kind != "explicit" %} <span class="badge">{{ event.kind }}</span>{% endif %}{% if event.superseded %} <span class="badge cleared">superseded</span>{% endif %}</td>
|
||||||
|
<td class="cell-tight"><span class="badge {{ event.badge }}">{{ event.verdict }}</span></td>
|
||||||
|
<td class="num">{{ event.value }}</td>
|
||||||
|
<td class="cell-wrap"><a href="/dashboard/articles/{{ event.article_id }}">{{ event.title }}</a></td>
|
||||||
|
<td class="cell-tight">{% if !event.issue_date.is_empty() %}<a href="/issues/{{ event.issue_date }}">{{ event.issue_date }}</a>{% endif %}</td>
|
||||||
|
<td class="cell-tight text-muted">{{ event.when }}</td>
|
||||||
|
<td class="cell-tight text-muted">{{ event.source }}</td>
|
||||||
|
<td class="cell-tight text-muted">{{ event.username }}</td>
|
||||||
|
<td class="cell-wrap text-muted">{{ event.note }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if events.is_empty() %}<tr><td colspan="9" class="text-muted">No rating events match.</td></tr>{% endif %}
|
||||||
|
</tbody></table></div>
|
||||||
|
<nav class="pager" aria-label="Pagination">{% include "_pagination.html" %}<span class="flex items-center gap-2">{% if !prev_href.is_empty() %}<a class="btn" href="{{ prev_href }}">← Newer</a>{% endif %}{% if !next_href.is_empty() %}<a class="btn" href="{{ next_href }}">Older →</a>{% endif %}</span></nav>
|
||||||
|
{% else %}
|
||||||
|
<form class="filters" method="get" action="/dashboard/ratings">
|
||||||
|
<input type="hidden" name="tab" value="current">
|
||||||
|
<label>Label <select name="label"><option value="">any</option><option value="loved"{% if filter_label == "loved" %} selected{% endif %}>Loved it</option><option value="good"{% if filter_label == "good" %} selected{% endif %}>Good</option><option value="down"{% if filter_label == "down" %} selected{% endif %}>Not for me</option><option value="cleared"{% if filter_label == "cleared" %} selected{% endif %}>Cleared</option></select></label>
|
||||||
|
<label>Source <select name="source"><option value="">any</option>{% for source in sources %}<option value="{{ source }}"{% if filter_source == source.as_str() %} selected{% endif %}>{{ source }}</option>{% endfor %}</select></label>
|
||||||
|
<label>Feed <select name="feed"><option value="">any</option>{% for feed in feeds %}<option value="{{ feed.id }}"{% if filter_feed == feed.id.to_string() %} selected{% endif %}>{{ feed.title }}</option>{% endfor %}</select></label>
|
||||||
|
<label>Title <input type="search" name="q" value="{{ filter_q }}" placeholder="contains…"></label>
|
||||||
|
<div class="filter-actions"><button class="btn" type="submit">Filter</button> <a class="btn-quiet" href="{{ current_href }}">Reset</a></div>
|
||||||
|
</form>
|
||||||
|
<p class="meta text-sm">One row per rated article (its latest explicit event), newest first. {% match last_run %}{% when Some with (run) %}"Used last run" counts the candidates of run #{{ run.id }} ({{ run.date }}, {{ run.status }}) that listed the article among their nearest rated neighbours, and how many of those were selected.{% when None %}No run has happened yet, so "Used last run" is empty.{% endmatch %}</p>
|
||||||
|
<div class="scroll-x tall"><table class="contributions">
|
||||||
|
<thead><tr><th>Verdict</th><th>Article</th><th>When · by</th><th>Note</th><th>Age → decay</th><th class="num">Neighbour weight</th><th>Feed credit</th><th>In prompt</th><th>In rebuild</th><th>Used last run</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in current %}<tr>
|
||||||
|
<td class="min-w-[13rem]"><span class="badge {{ row.badge }}">{{ row.verdict }}</span>{% let widget = row.widget %}{% include "_rating_widget.html" %}</td>
|
||||||
|
<td class="cell-wrap"><a href="/dashboard/articles/{{ row.article_id }}">{{ row.title }}</a><br><span class="meta text-xs">{{ row.feed_title }}{% if !row.issue_date.is_empty() %} · <a href="/issues/{{ row.issue_date }}">{{ row.issue_date }}</a>{% endif %}</span></td>
|
||||||
|
<td class="cell-tight">{{ row.when }}<br><span class="meta text-xs">{{ row.source }} · <span class="by">{{ row.username }}</span></span></td>
|
||||||
|
<td class="cell-wrap text-muted">{{ row.note }}</td>
|
||||||
|
<td class="cell-tight tabular-nums">{{ row.age_days }} d → {{ row.decay }}</td>
|
||||||
|
<td class="num">{% if row.has_embedding && !row.neighbour_weight.is_empty() %}{{ row.neighbour_weight }}{% if row.beyond_lookback %} <span class="meta text-xs">(beyond lookback)</span>{% endif %}{% else if row.badge == "cleared" %}<span class="meta">—</span>{% else %}<span class="meta text-xs">no embedding</span>{% endif %}</td>
|
||||||
|
<td class="cell-tight text-muted">{% for credit in row.feed_credits %}{{ credit.title }} <span class="tabular-nums text-ink">{{ credit.credit }}</span>{% if !loop.last %}<br>{% endif %}{% endfor %}</td>
|
||||||
|
<td class="text-center">{% if row.in_prompt %}<span class="text-loved">✓</span>{% endif %}</td>
|
||||||
|
<td class="text-center">{% if row.in_rebuild %}<span class="text-loved">✓</span>{% endif %}</td>
|
||||||
|
<td class="cell-tight tabular-nums">{% if row.used_total > 0 %}{{ row.used_total }} ({{ row.used_selected }} selected){% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if current.is_empty() %}<tr><td colspan="10" class="text-muted">No current verdicts match.</td></tr>{% endif %}
|
||||||
|
</tbody></table></div>
|
||||||
|
{% endif %}
|
||||||
|
</section>{% endblock %}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
{% extends "layout.html" %}{% block content %}<section class="dashboard">
|
||||||
|
<header class="page-head"><div>
|
||||||
|
<p class="page-eyebrow"><a class="text-muted no-underline hover:text-accent" href="/dashboard/runs">Runs</a> › {{ run.date }}</p>
|
||||||
|
<h1 class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1">Run {{ run.id }} <span class="badge {{ run.status }}">{{ run.status }}</span>{% if run.dry_run %} <span class="badge">dry run</span>{% endif %}</h1>
|
||||||
|
<p class="page-desc">Started {{ run.started }} · finished {{ run.finished }}.</p>
|
||||||
|
</div><nav class="page-actions" aria-label="Run navigation">{% if let Some(prev) = run.prev_id %}<a class="btn" href="/dashboard/runs/{{ prev }}">← Run {{ prev }}</a>{% endif %}{% if let Some(href) = run.issue_href %}<a class="btn" href="{{ href }}">Issue {{ run.date }}</a>{% else %}<span class="muted text-sm">No issue for {{ run.date }}</span>{% endif %}{% if let Some(next) = run.next_id %}<a class="btn" href="/dashboard/runs/{{ next }}">Run {{ next }} →</a>{% endif %}</nav></header>
|
||||||
|
|
||||||
|
<div class="tiles">
|
||||||
|
<div class="tile"><span class="tile-label">Issue date</span><span class="tile-num">{{ run.date }}</span><span class="tile-delta">run {{ run.id }}</span></div>
|
||||||
|
<div class="tile"><span class="tile-label">Duration</span><span class="tile-num">{{ run.duration }}</span><span class="tile-delta">wall clock</span></div>
|
||||||
|
<div class="tile"><span class="tile-label">Token cost</span><span class="tile-num">{{ run.total_cost }}</span><span class="tile-delta">every provider</span></div>
|
||||||
|
<div class="tile"><span class="tile-label">Warnings</span><span class="tile-num{% if !warnings.is_empty() %} text-warn{% endif %}">{{ warnings.len() }}</span><span class="tile-delta">{% if warnings.is_empty() %}clean{% else %}<a href="#warnings">see below</a>{% endif %}</span></div>
|
||||||
|
</div>
|
||||||
|
{% if let Some(error) = run.error %}<p class="error">{{ error }}</p>{% endif %}
|
||||||
|
|
||||||
|
<h2>Funnel</h2>
|
||||||
|
<div class="scroll-x"><table class="funnel-table">
|
||||||
|
<thead><tr><th>stage</th><th>share</th><th class="num">reached</th><th class="num">stopped here</th><th>why</th></tr></thead>
|
||||||
|
<tbody>{% for stage in funnel %}<tr>
|
||||||
|
<td class="cell-tight align-middle"><span class="badge {{ stage.stage }}">{{ stage.stage }}</span></td>
|
||||||
|
<td class="funnel-cell"><div class="funnel"><svg width="{{ stage.percent }}%" height="12" role="img" aria-label="{{ stage.reached }} reached"></svg></div></td>
|
||||||
|
<td class="num align-middle">{{ stage.reached }}</td>
|
||||||
|
<td class="num align-middle text-muted">{{ stage.stopped }}</td>
|
||||||
|
<td class="reasons cell-wrap align-middle text-muted">{% for reason in stage.reasons %}{{ reason.reason }} <span class="tabular-nums text-ink">{{ reason.count }}</span>{% if !loop.last %} · {% endif %}{% endfor %}</td>
|
||||||
|
</tr>{% endfor %}</tbody></table></div>
|
||||||
|
|
||||||
|
{% if has_report %}<div class="cards">
|
||||||
|
<section class="card"><h2>Admission mix</h2>{% if admission.is_empty() %}<p class="muted">Nothing admitted.</p>{% else %}<dl class="kv">{% for line in admission %}<dt>{{ line.name }}</dt><dd class="tabular-nums">{{ line.count }}</dd>{% endfor %}</dl>{% endif %}</section>
|
||||||
|
<section class="card"><h2>Preference state</h2><dl class="kv">
|
||||||
|
<dt>rated with embeddings</dt><dd class="tabular-nums">{{ preference.rated_with_embeddings }}</dd>
|
||||||
|
<dt>knn gate</dt><dd class="tabular-nums">{{ preference.knn_gate }}</dd>
|
||||||
|
<dt>feed gate</dt><dd class="tabular-nums">{{ preference.feed_gate }}</dd>
|
||||||
|
<dt>verdicts in prompt</dt><dd class="tabular-nums">{{ preference.verdicts_in_prompt }}</dd>
|
||||||
|
</dl></section>
|
||||||
|
<section class="card"><h2>Timings</h2><div class="scroll-x"><table><thead><tr><th>stage</th><th class="num">seconds</th></tr></thead><tbody>{% for line in timings %}<tr><td>{{ line.stage }}</td><td class="num">{{ line.seconds }}</td></tr>{% endfor %}<tr><th>total</th><th class="num">{{ timings_total }}</th></tr></tbody></table></div></section>
|
||||||
|
<section class="card"><h2>Provider usage</h2><div class="scroll-x"><table><thead><tr><th>provider</th><th class="num">in</th><th class="num">cached</th><th class="num">cache write</th><th class="num">out</th><th class="num">cost</th></tr></thead><tbody>{% for line in providers %}<tr><td>{{ line.provider }}</td><td class="num">{{ line.input }}</td><td class="num">{{ line.cached }}</td><td class="num">{{ line.cache_write }}</td><td class="num">{{ line.output }}</td><td class="num">{{ line.cost }}</td></tr>{% endfor %}</tbody></table></div></section>
|
||||||
|
<section class="card" id="warnings"><h2>Warnings</h2>{% if warnings.is_empty() %}<p class="muted">None.</p>{% else %}<ul class="list-disc pl-5 text-warn">{% for warning in warnings %}<li>{{ warning }}</li>{% endfor %}</ul>{% endif %}</section>
|
||||||
|
<section class="card"><h2>Feeds (top {{ feeds.len() }})</h2><div class="scroll-x"><table><thead><tr><th>feed</th><th class="num">entries</th></tr></thead><tbody>{% for line in feeds %}<tr><td>{{ line.name }}</td><td class="num">{{ line.count }}</td></tr>{% endfor %}</tbody></table></div></section>
|
||||||
|
</div>{% else %}<p class="muted text-sm">This run has no stored report; only the funnel and candidates are available.</p>{% endif %}
|
||||||
|
|
||||||
|
<h2>Config diff</h2>
|
||||||
|
{% if let Some(against) = diff_against %}{% if config_diff.is_empty() %}<p class="muted text-sm">No settings changed since <a href="/dashboard/runs/{{ against }}">run {{ against }}</a>.</p>{% else %}<p class="muted text-sm">Against the previous non-dry run, <a href="/dashboard/runs/{{ against }}">run {{ against }}</a>.</p>
|
||||||
|
<div class="scroll-x"><table class="diff"><thead><tr><th>key</th><th>before</th><th>after</th></tr></thead><tbody>{% for row in config_diff %}<tr><td><code>{{ row.key }}</code></td><td class="before cell-wrap text-muted">{{ row.before }}</td><td class="after cell-wrap text-ink">{{ row.after }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}{% else %}<p class="muted text-sm">No earlier non-dry run to compare against.</p>{% endif %}
|
||||||
|
|
||||||
|
<h2>Near misses</h2>
|
||||||
|
{% if near_misses.is_empty() %}<p class="muted text-sm">None recorded.</p>{% else %}<div class="scroll-x"><table>
|
||||||
|
<thead><tr><th class="num">#</th><th class="num">score</th><th>title</th><th>feed</th><th class="num">quality</th><th class="num">fit</th><th>stage</th><th>reason</th></tr></thead>
|
||||||
|
<tbody>{% for miss in near_misses %}<tr><td class="num text-muted">{{ loop.index }}</td><td class="num">{{ miss.score }}</td><td class="cell-wrap"><a href="{{ miss.href }}">{{ miss.title }}</a></td><td class="cell-tight text-muted">{{ miss.feed }}</td><td class="num">{{ miss.quality }}</td><td class="num">{{ miss.fit }}</td><td class="cell-tight"><span class="badge {{ miss.stage }}">{{ miss.stage }}</span></td><td class="cell-wrap text-muted">{% if let Some(reason) = miss.reason %}{{ reason }}{% endif %}</td></tr>{% endfor %}</tbody></table></div>{% endif %}
|
||||||
|
|
||||||
|
<h2>Candidates</h2>
|
||||||
|
<form class="filters" method="get" action="/dashboard/runs/{{ run.id }}">
|
||||||
|
<label>Stage <select name="stage"><option value="">any</option>{% for name in stages %}<option value="{{ name }}"{% if let Some(current) = filters.stage %}{% if current.as_str() == *name %} selected{% endif %}{% endif %}>{{ name }}</option>{% endfor %}</select></label>
|
||||||
|
<label>Reason <select name="reason"><option value="">any</option>{% for name in reasons %}<option value="{{ name }}"{% if let Some(current) = filters.reason %}{% if current.as_str() == *name %} selected{% endif %}{% endif %}>{{ name }}</option>{% endfor %}</select></label>
|
||||||
|
<label>Admitted by <select name="admitted_by"><option value="">any</option>{% for name in retrievers %}<option value="{{ name }}"{% if let Some(current) = filters.admitted_by %}{% if current.as_str() == *name %} selected{% endif %}{% endif %}>{{ name }}</option>{% endfor %}</select></label>
|
||||||
|
<label>Flag <select name="flag"><option value="">any</option><option value="exploration"{% if let Some(current) = filters.flag %}{% if current == "exploration" %} selected{% endif %}{% endif %}>exploration</option><option value="auto"{% if let Some(current) = filters.flag %}{% if current == "auto" %} selected{% endif %}{% endif %}>auto-include</option></select></label>
|
||||||
|
<label>Title <input type="search" name="q" value="{% if let Some(q) = filters.q %}{{ q }}{% endif %}" placeholder="contains…"></label>
|
||||||
|
<label>Sort <select name="sort">{% for name in sorts %}<option value="{{ name }}"{% if filters.sort == *name %} selected{% endif %}>{{ name }}</option>{% endfor %}</select></label>
|
||||||
|
<div class="filter-actions"><button class="btn" type="submit">Apply</button> <a class="btn-quiet" href="/dashboard/runs/{{ run.id }}">Reset</a></div>
|
||||||
|
</form>
|
||||||
|
{% include "dashboard/_pager.html" %}
|
||||||
|
<div class="scroll-x tall"><table class="candidates" data-filter>
|
||||||
|
<thead><tr><th>title</th><th>feed</th><th class="num">words</th><th>stage</th><th>reason</th><th>admitted by</th><th class="num">utility</th><th class="num">rank</th><th class="num">cluster</th><th class="num">triage</th><th class="num">quality</th><th class="num">fit</th><th>flags</th><th>editor</th></tr></thead>
|
||||||
|
<tbody>{% for candidate in candidates %}{% include "_candidate_row.html" %}{% endfor %}{% if candidates.is_empty() %}<tr><td colspan="14" class="text-muted">No candidates match this filter.</td></tr>{% endif %}</tbody></table></div>
|
||||||
|
{% include "dashboard/_pager.html" %}
|
||||||
|
</section>{% endblock %}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{% extends "layout.html" %}{% block content %}<section class="dashboard">
|
||||||
|
<header class="page-head"><div>
|
||||||
|
<h1>Runs</h1>
|
||||||
|
<p class="page-desc">Every recorded pipeline run, newest first: how the funnel narrowed, what it cost and whether it finished clean.</p>
|
||||||
|
</div><div class="page-actions"><a class="btn" href="/dashboard/stats">Stats</a><a class="btn" href="/dashboard/jobs">Jobs</a></div></header>
|
||||||
|
<form class="filters" method="get" action="/dashboard/runs">
|
||||||
|
<label>Status <select name="status"><option value="">any</option>{% for name in statuses %}<option value="{{ name }}"{% if status == *name %} selected{% endif %}>{{ name }}</option>{% endfor %}</select></label>
|
||||||
|
<div class="filter-actions"><button class="btn" type="submit">Filter</button>{% if !status.is_empty() %}<a class="btn-quiet" href="/dashboard/runs">Reset</a>{% endif %}</div>
|
||||||
|
</form>
|
||||||
|
<div class="scroll-x tall"><table data-filter>
|
||||||
|
<thead><tr><th>run</th><th>date</th><th>status</th><th>started</th><th>duration</th><th>considered → eligible → triaged → assessed → shortlisted → selected</th><th>cost</th><th class="num">total</th><th class="num">warnings</th></tr></thead>
|
||||||
|
<tbody>{% for run in runs %}<tr>
|
||||||
|
<td class="num"><a href="/dashboard/runs/{{ run.id }}">{{ run.id }}</a></td>
|
||||||
|
<td class="cell-tight"><a href="/issues/{{ run.date }}">{{ run.date }}</a></td>
|
||||||
|
<td class="cell-tight"><span class="badge {{ run.status }}">{{ run.status }}</span>{% if run.dry_run %} <span class="muted text-xs">dry</span>{% endif %}</td>
|
||||||
|
<td class="cell-tight text-muted">{{ run.started }}</td>
|
||||||
|
<td class="num text-muted">{{ run.duration }}</td>
|
||||||
|
<td class="cell-tight tabular-nums">{{ run.funnel }}</td>
|
||||||
|
<td class="text-muted">{{ run.costs }}</td>
|
||||||
|
<td class="num">{{ run.total }}</td>
|
||||||
|
<td class="num">{% if run.warnings > 0 %}<a href="/dashboard/runs/{{ run.id }}#warnings">{{ run.warnings }}</a>{% else %}<span class="text-muted">0</span>{% endif %}</td>
|
||||||
|
</tr>{% endfor %}{% if runs.is_empty() %}<tr><td colspan="9" class="text-muted">No runs match this filter.</td></tr>{% endif %}</tbody></table></div>
|
||||||
|
{% include "dashboard/_pager.html" %}
|
||||||
|
</section>{% endblock %}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{% extends "layout.html" %}{% block content %}<section class="dashboard settings">
|
||||||
|
<header class="page-head"><div>
|
||||||
|
<h1>Settings</h1>
|
||||||
|
<p class="page-desc">{% match config_path %}{% when Some with (path) %}Editing <code>{{ path }}</code>. Saved settings apply to the next run; a hand edit on disk shows up on the next page view.{% when None %}No config file is configured; start the server with <code>--config</code> to edit settings here. The values below are the built-in defaults plus the environment.{% endmatch %}</p>
|
||||||
|
</div><div class="page-actions"><a class="btn" href="/dashboard/settings/history">History</a><a class="btn" href="#add-provider">Add provider</a></div></header>
|
||||||
|
{% match load_error %}{% when Some with (error) %}<p class="error banner">config.toml on disk does not load: {{ error }} — the previous configuration stays live.</p>{% when None %}{% endmatch %}
|
||||||
|
{% if !errors.is_empty() %}<ul class="error banner">{% for error in errors %}<li>{{ error }}</li>{% endfor %}</ul>{% endif %}
|
||||||
|
<nav class="flex flex-wrap gap-x-3 gap-y-1 border-y border-rule py-3 font-mono text-xs" aria-label="Settings groups">{% for group in groups %}<a class="text-muted no-underline hover:text-accent" href="#{{ group.id }}">{{ group.id }}</a>{% endfor %}</nav>
|
||||||
|
<form method="post" action="/dashboard/settings" class="settings-form" id="settings-form">
|
||||||
|
{% for group in groups %}<section class="card scroll-mt-4" id="{{ group.id }}">
|
||||||
|
<div>
|
||||||
|
<p class="page-eyebrow font-mono normal-case tracking-normal">{{ group.id }}</p>
|
||||||
|
<h2 class="mt-0.5">{{ group.title }}</h2>
|
||||||
|
</div>
|
||||||
|
{% match group.provider %}{% when Some with (provider) %}<p class="meta text-xs">Key: <code>{{ crate::config::ProviderConfig::api_key_env_var(provider.name) }}</code> in the env file. {% if provider.removable() %}Not named by any <code>[llm]</code> role. <button type="submit" form="remove-{{ provider.name }}" class="danger">Remove provider</button>{% else if provider.built_in %}Built in: declared by the defaults, so it can be edited and left unreferenced but not removed.{% else %}Named by <code>llm.{{ provider.roles() }}</code>; reassign the role before removing it.{% endif %}</p>{% when None %}{% endmatch %}
|
||||||
|
{% if group.is_weights() %}<p class="meta text-xs">Weights need not sum to 1; they are renormalized over the signals present for each article.</p>{% endif %}
|
||||||
|
{% for field in group.fields %}<div class="setting kind-{{ field.kind_name() }}">
|
||||||
|
<div class="setting-label"><label for="{{ field.input_id() }}"><code>{{ field.key }}</code></label>{% if field.restart_required %} <span class="badge restart">restart</span>{% endif %}{% if field.is_env() %} <span class="badge">locked</span>{% endif %}{% match field.help %}{% when Some with (help) %}<p class="help">{{ help }}</p>{% when None %}{% endmatch %}</div>
|
||||||
|
<div class="setting-input">
|
||||||
|
{% if field.is_secret() %}<p class="secret">{% if field.is_set() %}set{% if field.is_env() %} (from <code>{{ field.env_var() }}</code>){% else if field.is_file() %} (in the config file — move it to the env file){% endif %}{% else %}not set — set <code>{{ field.env_var() }}</code> in the env file{% endif %}</p>
|
||||||
|
{% else if field.is_env() %}{% if field.is_text_list() %}<textarea id="{{ field.input_id() }}" disabled>{{ field.current }}</textarea>{% else %}<input id="{{ field.input_id() }}" type="text" value="{{ field.current }}" disabled>{% endif %}<p class="help">locked by <code>{{ field.env_var() }}</code> in the env file</p>
|
||||||
|
{% else if field.is_bool() %}<select id="{{ field.input_id() }}" name="{{ field.path }}"><option value="true"{% if field.current == "true" %} selected{% endif %}>true</option><option value="false"{% if field.current == "false" %} selected{% endif %}>false</option></select>
|
||||||
|
{% else if field.is_enum() %}<select id="{{ field.input_id() }}" name="{{ field.path }}">{% for option in field.options() %}<option value="{{ option }}"{% if field.current == option.as_str() %} selected{% endif %}>{% if option.is_empty() %}(none){% else %}{{ option }}{% endif %}</option>{% endfor %}</select>
|
||||||
|
{% else if field.is_text_list() %}<textarea id="{{ field.input_id() }}" name="{{ field.path }}" rows="4" spellcheck="false">{{ field.current }}</textarea>
|
||||||
|
{% else if field.is_number() %}<input id="{{ field.input_id() }}" name="{{ field.path }}" type="number" step="{% if field.is_integer() %}1{% else %}any{% endif %}" value="{{ field.current }}">
|
||||||
|
{% else %}<input id="{{ field.input_id() }}" name="{{ field.path }}" type="text" value="{{ field.current }}" spellcheck="false">
|
||||||
|
{% endif %}
|
||||||
|
{% if !field.is_secret() && !field.is_env() %}<p class="default">default: {% if field.default.is_empty() %}<em>unset</em>{% else if field.is_text_list() %}<code class="lines">{{ field.default }}</code>{% else %}<code>{{ field.default }}</code>{% endif %}{% if field.current != field.default %} <button type="button" class="reset" data-reset="{{ field.input_id() }}" data-default="{{ field.default }}">reset to default</button>{% endif %}</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="actions save-bar">{% match config_path %}{% when Some with (path) %}<span class="meta">Writes <code>{{ path }}</code>.</span><button class="btn-primary" type="submit">Save settings</button>{% when None %}<span class="meta">No config file: settings are read-only.</span><button class="btn-primary" type="submit" disabled>Save settings</button>{% endmatch %}</div>
|
||||||
|
</form>
|
||||||
|
{% for group in groups %}{% match group.provider %}{% when Some with (provider) %}{% if provider.removable() %}<form method="post" action="/dashboard/settings/providers" id="remove-{{ provider.name }}" class="form-inline" data-confirm="Remove [providers.{{ provider.name }}] from config.toml?"><input type="hidden" name="action" value="remove"><input type="hidden" name="name" value="{{ provider.name }}"></form>{% endif %}{% when None %}{% endmatch %}{% endfor %}
|
||||||
|
<section class="card scroll-mt-4" id="add-provider">
|
||||||
|
<h2>Add provider</h2>
|
||||||
|
<form method="post" action="/dashboard/settings/providers" class="add-provider filters border-y-0 py-0">
|
||||||
|
<input type="hidden" name="action" value="add">
|
||||||
|
<label>Name <input name="name" type="text" pattern="[a-z0-9_]+" placeholder="mistral" required{% if config_path.is_none() %} disabled{% endif %}></label>
|
||||||
|
<label>Kind <select name="kind">{% for kind in provider_kinds %}<option value="{{ kind }}">{{ kind }}</option>{% endfor %}</select></label>
|
||||||
|
<div class="filter-actions"><button class="btn" type="submit"{% if config_path.is_none() %} disabled{% endif %}>Add provider</button></div>
|
||||||
|
</form>
|
||||||
|
<p class="help">Inserts <code>[providers.<name>]</code> with placeholder <code>base_url</code> and <code>model</code>; edit them above afterwards and put <code>DAILY_EPUB_PROVIDERS__<NAME>__API_KEY</code> in the env file.</p>
|
||||||
|
</section>
|
||||||
|
</section>{% endblock %}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{% extends "layout.html" %}{% block content %}<section class="dashboard">
|
||||||
|
<header class="page-head"><div>
|
||||||
|
<h1>Settings history</h1>
|
||||||
|
<p class="page-desc">Every settings change made through the dashboard, newest first. Hand edits to <code>config.toml</code> on disk are not recorded here.</p>
|
||||||
|
</div><div class="page-actions"><a class="btn" href="/dashboard/settings">Back to settings</a></div></header>
|
||||||
|
{% if changes.is_empty() %}<p class="muted text-sm">No settings have been changed through the dashboard yet.</p>{% else %}<div class="scroll-x tall"><table class="history" data-filter>
|
||||||
|
<thead><tr><th>When</th><th>Who</th><th>Key</th><th>Before</th><th>After</th></tr></thead>
|
||||||
|
<tbody>{% for change in changes %}<tr>
|
||||||
|
<td class="cell-tight text-muted">{{ change.changed_at }}</td>
|
||||||
|
<td class="cell-tight">{% match change.username %}{% when Some with (name) %}{{ name }}{% when None %}<em class="text-muted">removed user</em>{% endmatch %}</td>
|
||||||
|
<td><code>{{ change.key }}</code></td>
|
||||||
|
<td class="cell-wrap">{% match change.old_value %}{% when Some with (value) %}<pre class="preview">{{ value }}</pre>{% when None %}<em class="text-muted">absent</em>{% endmatch %}</td>
|
||||||
|
<td class="cell-wrap">{% match change.new_value %}{% when Some with (value) %}<pre class="preview">{{ value }}</pre>{% when None %}<em class="text-muted">removed</em>{% endmatch %}</td>
|
||||||
|
</tr>{% endfor %}</tbody>
|
||||||
|
</table></div>
|
||||||
|
{% if pagination.pages() > 1 %}<nav class="pager" aria-label="Pagination">{% include "_pagination.html" %}<span class="flex items-center gap-2">{% if pagination.page > 1 %}<a class="btn" href="/dashboard/settings/history?page={{ pagination.page - 1 }}">← Newer</a>{% endif %}{% if pagination.page < pagination.pages() %}<a class="btn" href="/dashboard/settings/history?page={{ pagination.page + 1 }}">Older →</a>{% endif %}</span></nav>{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</section>{% endblock %}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{% extends "layout.html" %}{% block content %}<section class="dashboard">
|
||||||
|
<header class="page-head"><div>
|
||||||
|
<h1>Stats</h1>
|
||||||
|
<p class="page-desc">The last {{ days }} days ({{ since_date }} → {{ today }}) — the same figures as <code>daily-epub stats --days {{ days }}</code>.</p>
|
||||||
|
</div><nav class="page-actions" aria-label="Window">{% for window in windows %}{% if *window == days %}<span class="btn-primary" aria-current="true">{{ window }} days</span>{% else %}<a class="btn" href="/dashboard/stats?days={{ window }}">{{ window }} days</a>{% endif %}{% endfor %}</nav></header>
|
||||||
|
|
||||||
|
<div class="tiles">{% for line in summary %}<div class="tile"><span class="tile-label">{{ line.key }}</span><span class="tile-num">{{ line.value }}</span></div>{% endfor %}</div>
|
||||||
|
|
||||||
|
<div class="sparklines">{% for item in sparklines %}{% let spark = item %}{% include "dashboard/_sparkline.html" %}{% endfor %}</div>
|
||||||
|
|
||||||
|
<div class="cards">
|
||||||
|
<section class="card"><h2>Verdicts in the window</h2>
|
||||||
|
{% if ratings.is_empty() %}<p class="muted">No explicit ratings in the window.</p>{% else %}<p class="flex flex-wrap items-center gap-x-3 gap-y-1.5">{% for line in ratings %}<span class="inline-flex items-center gap-1.5"><span class="badge {{ line.key }}">{{ line.key }}</span> <span class="tabular-nums">{{ line.value }}</span></span>{% endfor %}</p>{% endif %}
|
||||||
|
<p class="muted text-xs">Explicit verdicts only; cleared ones still count as events.</p>
|
||||||
|
</section>
|
||||||
|
<section class="card"><h2>Retriever yield</h2>
|
||||||
|
{% if retrievers.is_empty() %}<p class="muted">No rated picks by admitting retriever in the window.</p>{% else %}<div class="scroll-x"><table>
|
||||||
|
<thead><tr><th>admitted by</th><th class="num">rated</th><th class="num">up</th><th class="num">down</th><th class="num">ratio</th></tr></thead>
|
||||||
|
<tbody>{% for line in retrievers %}<tr><td>{{ line.retriever }}</td><td class="num">{{ line.rated }}</td><td class="num text-loved">{{ line.up }}</td><td class="num text-down">{{ line.down }}</td><td class="num">{{ line.ratio }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}
|
||||||
|
<h3 class="text-sm">Exploration</h3>
|
||||||
|
<dl class="kv">{% for line in exploration %}<dt>{{ line.key }}</dt><dd class="tabular-nums">{{ line.value }}</dd>{% endfor %}</dl>
|
||||||
|
</section>
|
||||||
|
<section class="card"><h2>Cost per day</h2>
|
||||||
|
<dl class="kv">{% for line in cost_per_day %}<dt>{{ line.key }}</dt><dd class="tabular-nums">{{ line.value }}</dd>{% endfor %}</dl>
|
||||||
|
<p class="muted text-xs">Window spend divided by {{ days }} days, per provider.</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Spend by day</h2>
|
||||||
|
{% if cost_rows.is_empty() %}<p class="muted text-sm">No provider costs recorded in the window.</p>{% else %}<div class="scroll-x tall"><table data-filter>
|
||||||
|
<thead><tr><th>day (UTC)</th>{% for provider in providers %}<th class="num">{{ provider }}</th>{% endfor %}<th class="num">total</th></tr></thead>
|
||||||
|
<tbody>{% for row in cost_rows %}<tr><td class="cell-tight">{{ row.date }}</td>{% for cell in row.cells %}<td class="num text-muted">{{ cell }}</td>{% endfor %}<td class="num">{{ row.total }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}
|
||||||
|
|
||||||
|
<h2>Runs in the window</h2>
|
||||||
|
{% if runs.is_empty() %}<p class="muted text-sm">No finished runs in the window.</p>{% else %}<div class="scroll-x tall"><table data-filter>
|
||||||
|
<thead><tr><th class="num">run</th><th>date</th><th>status</th><th class="num">cost</th><th class="num">selected</th><th class="num">duration</th></tr></thead>
|
||||||
|
<tbody>{% for run in runs %}<tr><td class="num"><a href="/dashboard/runs/{{ run.run_id }}">{{ run.run_id }}</a></td><td class="cell-tight"><a href="/issues/{{ run.date }}">{{ run.date }}</a></td><td class="cell-tight"><span class="badge {{ run.status }}">{{ run.status }}</span></td><td class="num">{{ run.cost }}</td><td class="num">{{ run.selected }}</td><td class="num text-muted">{{ run.duration }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}
|
||||||
|
|
||||||
|
<details class="explain disclosure" id="stats-text"><summary>As text (<code>daily-epub stats --days {{ days }}</code>)</summary><div class="disclosure-body"><pre class="preview">{{ text }}</pre></div></details>
|
||||||
|
</section>{% endblock %}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{% extends "layout.html" %}{% block content %}<section class="dashboard">
|
||||||
|
<header class="page-head"><div>
|
||||||
|
<h1>Users</h1>
|
||||||
|
<p class="page-desc">Every account on this server, with its role and open sessions.</p>
|
||||||
|
</div></header>
|
||||||
|
<p class="muted text-sm">Accounts are read-only here. Create, change, disable, enable, or sign out users with the <code>daily-epub users</code> CLI on the server.</p>
|
||||||
|
<div class="scroll-x tall"><table data-filter>
|
||||||
|
<thead><tr><th>Username</th><th>Role</th><th>Status</th><th>Created</th><th>Last login</th><th class="num">Open sessions</th></tr></thead>
|
||||||
|
<tbody>{% for user in users %}<tr>
|
||||||
|
<td class="font-medium text-ink">{{ user.username }}</td>
|
||||||
|
<td><span class="badge">{{ user.role }}</span></td>
|
||||||
|
<td>{% if user.disabled %}<span class="badge down">disabled</span>{% else %}<span class="badge loved">enabled</span>{% endif %}</td>
|
||||||
|
<td class="cell-tight text-muted">{{ user.created }}</td>
|
||||||
|
<td class="cell-tight text-muted">{{ user.last_login }}</td>
|
||||||
|
<td class="num">{{ user.open_sessions }}</td>
|
||||||
|
</tr>{% endfor %}{% if users.is_empty() %}<tr><td colspan="6" class="text-muted">No users. Bootstrap an admin with <code>daily-epub users add <username></code> <code>--admin</code>.</td></tr>{% endif %}</tbody>
|
||||||
|
</table></div>
|
||||||
|
</section>{% endblock %}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{% extends "layout.html" %}{% block ears %}<span>Something went wrong</span>{% endblock %}{% block content %}<section class="mx-auto max-w-[68ch] px-4 py-20 text-center sm:px-6"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-down">Error</p><h1 class="mt-3 text-4xl font-semibold leading-[1.1] tracking-[-0.01em] sm:text-5xl">{{ heading }}</h1><p class="mt-5 text-lg text-ink-2">{{ message }}</p><p class="mt-8 font-sans text-sm"><a href="/">Return to the latest issue</a></p></section>{% endblock %}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{% for section in issue.sections %}<h2>{{ section.name }}</h2><ul>{% for entry in section.entries %}<li><a href="{{ entry.url }}">{{ entry.title }}</a> — {{ entry.source }} ({{ entry.domain }}){% match entry.summary %}{% when Some with (summary) %}<p class="summary">{{ summary }}</p>{% when None %}{% endmatch %}{% match entry.why %}{% when Some with (why) %}<p class="why"><em>Why it's here: {{ why }}</em></p>{% when None %}{% endmatch %}{% if !entry.comment_links.is_empty() %} · {% for link in entry.comment_links %}<a href="{{ link.url }}">{{ link.label }}</a>{% endfor %}{% endif %}</li>{% endfor %}</ul>{% endfor %}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{% extends "layout.html" %}{% block ears %}<span class="sm:hidden">{{ toc.short_date }}</span><span class="hidden sm:inline">{{ toc.display_date }} · No. {{ toc.issue_number }}</span>{% endblock %}{% block content %}
|
||||||
|
<div class="mx-auto max-w-7xl lg:grid lg:grid-cols-[16rem_minmax(0,1fr)] lg:gap-x-10 lg:px-6 xl:grid-cols-[18rem_minmax(0,1fr)] xl:gap-x-14">{% include "_toc.html" %}
|
||||||
|
<article class="mx-auto mt-10 w-full max-w-[68ch] px-4 sm:px-6 lg:mt-12 lg:px-0">
|
||||||
|
<header class="mb-8 text-center"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">{{ display_date }} · No. {{ issue_number }}</p><p class="mt-2 font-sans text-sm text-muted">{{ stats_line }}</p></header>
|
||||||
|
<section aria-labelledby="brief-heading"><h1 id="brief-heading" class="border-t border-rule pt-2 font-sans text-[0.72rem] font-semibold uppercase tracking-[0.12em] text-muted">The Brief</h1><div class="editorial prose-body mt-5 [&>p:first-child]:first-letter:float-left [&>p:first-child]:first-letter:mr-2 [&>p:first-child]:first-letter:mt-1 [&>p:first-child]:first-letter:font-serif [&>p:first-child]:first-letter:text-[4.6rem] [&>p:first-child]:first-letter:font-semibold [&>p:first-child]:first-letter:leading-[0.72]">{{ front_page_html|safe }}</div></section>
|
||||||
|
{% if !downloads.is_empty() %}<div class="my-8 flex flex-wrap gap-2 border-y border-rule py-4">{% for download in downloads %}<a class="btn" href="{{ download.href }}">Download {{ download.label }} <span class="ml-1 text-muted">{{ download.size }}</span></a>{% endfor %}</div>{% endif %}
|
||||||
|
<section class="mt-12" aria-labelledby="index-heading"><h2 id="index-heading" class="text-center text-3xl font-semibold leading-[1.1] tracking-[-0.01em]">In This Issue</h2>
|
||||||
|
{% for section in sections %}<section class="mt-10"><h3 class="border-t border-rule pt-2 font-sans text-[0.72rem] font-semibold uppercase tracking-[0.12em] text-muted">{{ section.name }}</h3><ul class="m-0 list-none p-0">{% for entry in section.entries %}<li class="border-b border-rule py-6"><h4 class="font-serif font-semibold leading-[1.1] tracking-[-0.01em] {% if entry.is_lead %}text-3xl{% else %}text-2xl{% endif %}"><a class="text-ink no-underline hover:text-accent" href="{{ entry.href }}">{{ entry.title }}</a></h4><p class="mt-2 font-sans text-sm text-muted">{{ entry.source }} · {{ entry.reading_minutes }} min read</p>{% if !entry.summary.is_empty() %}<p class="mt-4">{{ entry.summary }}</p>{% endif %}{% match entry.why %}{% when Some with (why) %}<p class="mt-4 border-l-2 border-accent pl-3 italic text-ink-2">Why it's here: {{ why }}</p>{% when None %}{% endmatch %}{% match entry.rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}</li>{% endfor %}</ul></section>{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% if has_world || has_behind %}<nav class="my-12 flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-y border-rule py-4 text-center font-sans text-sm uppercase tracking-[0.08em]" aria-label="Issue chapters">{% if has_world %}<a class="text-ink no-underline hover:text-accent" href="/issues/{{ date }}/world">World Briefing</a>{% endif %}{% if has_world && has_behind %}<span class="text-muted" aria-hidden="true">·</span>{% endif %}{% if has_behind %}<a class="text-ink no-underline hover:text-accent" href="/issues/{{ date }}/behind">Behind the paper</a>{% endif %}</nav>{% endif %}
|
||||||
|
<footer id="colophon" class="mt-12 scroll-mt-6 border-t border-rule pt-2 font-sans text-sm leading-relaxed"><h2 class="text-[0.72rem] font-semibold uppercase tracking-[0.12em] text-muted">Colophon</h2><p class="my-4 text-ink-2"><em>The Daily EPUB</em> is assembled every morning from a personal feed reader.</p><dl class="kv border-y border-rule py-4 text-xs sm:text-sm"><dt>Generated</dt><dd>{{ colophon.generated_at }}</dd><dt>Bulk model</dt><dd>{{ colophon.bulk_model }}</dd><dt>Editor model</dt><dd>{{ colophon.editor_model }}</dd><dt>Summaries model</dt><dd>{{ colophon.summaries_model }}</dd><dt>Entries considered</dt><dd>{% match colophon.entries_fetched %}{% when Some with (entries) %}{{ entries }}{% match colophon.feeds_seen %}{% when Some with (feeds) %} from {{ feeds }} feeds{% when None %}{% endmatch %}{% when None %}n/a{% endmatch %}</dd><dt>Candidates scored</dt><dd>{% match colophon.candidates %}{% when Some with (candidates) %}{{ candidates }}{% when None %}n/a{% endmatch %}</dd><dt>Articles selected</dt><dd>{{ colophon.article_count }} across {{ colophon.section_count }} sections</dd><dt>Words</dt><dd>{{ colophon.total_words }} · ~{{ colophon.reading_minutes }} min read</dd>{% for cost in colophon.provider_costs %}<dt>{{ cost.provider }} cost</dt><dd>{{ cost.cost }}</dd>{% endfor %}<dt>Total token cost</dt><dd>{% match colophon.cost_usd %}{% when Some with (cost) %}{{ cost }}{% when None %}n/a{% endmatch %}</dd><dt>Generator</dt><dd>{{ colophon.generator_version }}</dd></dl></footer>
|
||||||
|
</article>
|
||||||
|
</div>{% endblock %}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{% extends "layout.html" %}{% block ears %}<span>Past editions</span>{% endblock %}{% block content %}<section class="mx-auto mt-10 max-w-[68ch] px-4 sm:mt-14 sm:px-6"><header class="mb-10"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Past editions</p><h1 class="mt-2 text-4xl font-semibold leading-[1.1] tracking-[-0.01em] sm:text-5xl">Issue archive</h1></header>{% for month in months %}<section class="mt-10"><h2 class="border-t border-rule pt-2 font-sans text-[0.72rem] font-semibold uppercase tracking-[0.12em] text-muted">{{ month.label }}</h2><ul class="m-0 list-none p-0">{% for issue in month.issues %}<li class="grid grid-cols-[1fr_auto] gap-x-4 border-b border-rule py-4"><a class="text-xl font-semibold leading-tight text-ink no-underline hover:text-accent" href="/issues/{{ issue.date }}">{{ issue.display_date }}</a><span class="font-sans text-sm tabular-nums text-muted">No. {{ issue.issue_number }}</span><span class="font-sans text-sm text-muted">{{ issue.article_count }} articles</span></li>{% endfor %}</ul></section>{% endfor %}</section>{% endblock %}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{% extends "layout.html" %}{% block ears %}{% if empty %}<span>Morning edition</span>{% else %}<span class="sm:hidden">{{ issue.short_date }}</span><span class="hidden sm:inline">{{ issue.display_date }} · No. {{ issue.issue_number }}</span>{% endif %}{% endblock %}{% block content %}
|
||||||
|
<article class="mx-auto mt-8 max-w-[68ch] px-4 sm:mt-12 sm:px-6">
|
||||||
|
{% if empty %}<div class="py-16 text-center"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Morning edition</p><h1 class="mt-3 text-4xl font-semibold leading-[1.1] tracking-[-0.01em]">No issue yet</h1><p class="mt-4 text-ink-2">The first issue has not been published.</p></div>{% else %}
|
||||||
|
<header class="mb-10 text-center"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">{{ issue.display_date }} · No. {{ issue.issue_number }}</p><p class="mt-2 font-sans text-sm text-muted">{{ issue.stats_line }}</p><p class="mx-auto mt-5 max-w-[58ch] text-lg italic leading-relaxed text-ink-2">A personal morning paper, assembled daily; the selection is the reader's, the words are the authors'.</p></header>
|
||||||
|
{% if !downloads.is_empty() %}<div class="mb-8 flex flex-wrap justify-center gap-2">{% for download in downloads %}<a class="btn" href="{{ download.href }}">{{ download.label }} <span class="ml-1 text-muted">{{ download.size }}</span></a>{% endfor %}</div>{% endif %}
|
||||||
|
{% for section in issue.sections %}<section class="mt-10"><h2 class="border-t border-rule pt-2 font-sans text-[0.72rem] font-semibold uppercase tracking-[0.12em] text-muted">{{ section.name }}</h2><div>{% for entry in section.entries %}<article class="border-b border-rule py-6{% if entry.is_lead %} pt-5{% endif %}"><h3 class="font-semibold leading-[1.1] tracking-[-0.01em] {% if entry.is_lead %}text-3xl{% else %}text-2xl{% endif %}"><a class="text-ink no-underline hover:text-accent" href="{{ entry.url }}">{{ entry.title }}</a></h3><p class="mt-2 font-sans text-sm leading-relaxed text-muted">{% match entry.author %}{% when Some with (author) %}{{ author }} · {% when None %}{% endmatch %}{{ entry.source }}{% if !entry.domain.is_empty() %} ({{ entry.domain }}){% endif %} · {{ entry.reading_minutes }} min</p>{% match entry.summary %}{% when Some with (summary) %}<p class="mt-4">{{ summary }}</p>{% when None %}{% endmatch %}{% match entry.why %}{% when Some with (why) %}<p class="mt-4 border-l-2 border-accent pl-3 italic text-ink-2">Why it's here: {{ why }}</p>{% when None %}{% endmatch %}{% if !entry.comment_links.is_empty() %}<p class="mt-3 flex flex-wrap gap-x-4 gap-y-1 font-sans text-sm text-muted">{% for link in entry.comment_links %}<a class="text-muted hover:text-accent" rel="noopener" target="_blank" href="{{ link.url }}">{{ link.label }}{% if !link.meta.is_empty() %}: {{ link.meta }}{% endif %}</a>{% endfor %}</p>{% endif %}</article>{% endfor %}</div></section>{% endfor %}
|
||||||
|
<p class="mt-10 text-center font-sans text-sm"><a href="/issues">Browse the archive →</a></p>
|
||||||
|
{% endif %}</article>{% endblock %}
|
||||||
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user