Compare commits

..

2 Commits

Author SHA1 Message Date
ddidderr 72659eab2e [deps] cargo update
Updating bitflags v2.12.1 -> v2.13.0
Updating hashlink v0.11.0 -> v0.11.1
2026-06-07 19:02:10 +02:00
ddidderr 9d14e63613 fix: harden application log viewer
Add an Application Logs window backed by a bounded persistent main log file.
The viewer loads history from lanspread.log, subscribes to live INFO/WARN/ERROR
log events, supports filtering/copy/pause controls, and keeps the menu/window
routing separate from the unpack log viewer.

The backend sink now owns serialized access to the log file. History reads and
append-time trimming use the same sink lock, so opening the logs window cannot
race with a concurrent write and rewrite away a freshly appended line. The sink
also keeps a persistent file handle instead of reopening the file for each
captured event.

Live log events carry sink-local sequence ids. The frontend uses the history
watermark plus returned history line counts to suppress live events that were
already included in the history response, while preserving buffered rows that
were trimmed out of the history file. Auto-scroll now follows the last visible
row identity, so it continues following after the in-memory cap keeps the row
count stable.

No timestamp code change was needed. On the Linux dev host, a temporary probe
showed time::OffsetDateTime::now_local() returning +02:00 while UTC was +00:00,
matching the host CEST offset.

Test Plan:
- just fmt
- just frontend-test
- just test
- just clippy
- just build
- git diff --cached --check
- temporary Linux probe of OffsetDateTime::now_local() showed local +02:00

Refs: none
2026-06-07 18:59:05 +02:00
7 changed files with 457 additions and 211 deletions
Generated
+33 -33
View File
@@ -155,9 +155,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.12.1" version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
dependencies = [ dependencies = [
"serde_core", "serde_core",
] ]
@@ -243,7 +243,7 @@ version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
dependencies = [ dependencies = [
"bitflags 2.12.1", "bitflags 2.13.0",
"cairo-sys-rs", "cairo-sys-rs",
"glib", "glib",
"libc", "libc",
@@ -432,7 +432,7 @@ version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [ dependencies = [
"bitflags 2.12.1", "bitflags 2.13.0",
"core-foundation", "core-foundation",
"core-graphics-types", "core-graphics-types",
"foreign-types", "foreign-types",
@@ -445,7 +445,7 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [ dependencies = [
"bitflags 2.12.1", "bitflags 2.13.0",
"core-foundation", "core-foundation",
"libc", "libc",
] ]
@@ -689,7 +689,7 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [ dependencies = [
"bitflags 2.12.1", "bitflags 2.13.0",
"block2", "block2",
"libc", "libc",
"objc2", "objc2",
@@ -1323,7 +1323,7 @@ version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
dependencies = [ dependencies = [
"bitflags 2.12.1", "bitflags 2.13.0",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"futures-executor", "futures-executor",
@@ -1478,9 +1478,9 @@ dependencies = [
[[package]] [[package]]
name = "hashlink" name = "hashlink"
version = "0.11.0" version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f"
dependencies = [ dependencies = [
"hashbrown 0.16.1", "hashbrown 0.16.1",
] ]
@@ -1804,7 +1804,7 @@ version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1" checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1"
dependencies = [ dependencies = [
"bitflags 2.12.1", "bitflags 2.13.0",
"inotify-sys", "inotify-sys",
"libc", "libc",
] ]
@@ -1975,7 +1975,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
dependencies = [ dependencies = [
"bitflags 2.12.1", "bitflags 2.13.0",
"serde", "serde",
"unicode-segmentation", "unicode-segmentation",
] ]
@@ -1996,7 +1996,7 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
dependencies = [ dependencies = [
"bitflags 2.12.1", "bitflags 2.13.0",
"libc", "libc",
] ]
@@ -2339,7 +2339,7 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
dependencies = [ dependencies = [
"bitflags 2.12.1", "bitflags 2.13.0",
"jni-sys 0.3.1", "jni-sys 0.3.1",
"log", "log",
"ndk-sys", "ndk-sys",
@@ -2369,7 +2369,7 @@ version = "8.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
dependencies = [ dependencies = [
"bitflags 2.12.1", "bitflags 2.13.0",
"fsevent-sys", "fsevent-sys",
"inotify", "inotify",
"kqueue", "kqueue",
@@ -2387,7 +2387,7 @@ version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
dependencies = [ dependencies = [
"bitflags 2.12.1", "bitflags 2.13.0",
] ]
[[package]] [[package]]
@@ -2481,7 +2481,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [ dependencies = [
"bitflags 2.12.1", "bitflags 2.13.0",
"block2", "block2",
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
@@ -2494,7 +2494,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
dependencies = [ dependencies = [
"bitflags 2.12.1", "bitflags 2.13.0",
"objc2", "objc2",
"objc2-foundation", "objc2-foundation",
] ]
@@ -2515,7 +2515,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [ dependencies = [
"bitflags 2.12.1", "bitflags 2.13.0",
"dispatch2", "dispatch2",
"objc2", "objc2",
] ]
@@ -2526,7 +2526,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [ dependencies = [
"bitflags 2.12.1", "bitflags 2.13.0",
"dispatch2", "dispatch2",
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
@@ -2559,7 +2559,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [ dependencies = [
"bitflags 2.12.1", "bitflags 2.13.0",
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-core-graphics", "objc2-core-graphics",
@@ -2586,7 +2586,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [ dependencies = [
"bitflags 2.12.1", "bitflags 2.13.0",
"block2", "block2",
"libc", "libc",
"objc2", "objc2",
@@ -2599,7 +2599,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [ dependencies = [
"bitflags 2.12.1", "bitflags 2.13.0",
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
] ]
@@ -2610,7 +2610,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [ dependencies = [
"bitflags 2.12.1", "bitflags 2.13.0",
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-foundation", "objc2-foundation",
@@ -2622,7 +2622,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
dependencies = [ dependencies = [
"bitflags 2.12.1", "bitflags 2.13.0",
"block2", "block2",
"objc2", "objc2",
"objc2-cloud-kit", "objc2-cloud-kit",
@@ -2653,7 +2653,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f"
dependencies = [ dependencies = [
"bitflags 2.12.1", "bitflags 2.13.0",
"block2", "block2",
"objc2", "objc2",
"objc2-app-kit", "objc2-app-kit",
@@ -2858,7 +2858,7 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [ dependencies = [
"bitflags 2.12.1", "bitflags 2.13.0",
"crc32fast", "crc32fast",
"fdeflate", "fdeflate",
"flate2", "flate2",
@@ -3067,7 +3067,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [ dependencies = [
"bitflags 2.12.1", "bitflags 2.13.0",
] ]
[[package]] [[package]]
@@ -3223,7 +3223,7 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [ dependencies = [
"bitflags 2.12.1", "bitflags 2.13.0",
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys",
@@ -3511,7 +3511,7 @@ version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
dependencies = [ dependencies = [
"bitflags 2.12.1", "bitflags 2.13.0",
"cssparser", "cssparser",
"derive_more", "derive_more",
"log", "log",
@@ -4100,7 +4100,7 @@ version = "0.35.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
dependencies = [ dependencies = [
"bitflags 2.12.1", "bitflags 2.13.0",
"block2", "block2",
"core-foundation", "core-foundation",
"core-graphics", "core-graphics",
@@ -4770,7 +4770,7 @@ version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [ dependencies = [
"bitflags 2.12.1", "bitflags 2.13.0",
"bytes", "bytes",
"futures-util", "futures-util",
"http", "http",
@@ -5201,7 +5201,7 @@ version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [ dependencies = [
"bitflags 2.12.1", "bitflags 2.13.0",
"hashbrown 0.15.5", "hashbrown 0.15.5",
"indexmap 2.14.0", "indexmap 2.14.0",
"semver", "semver",
@@ -5892,7 +5892,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bitflags 2.12.1", "bitflags 2.13.0",
"indexmap 2.14.0", "indexmap 2.14.0",
"log", "log",
"serde", "serde",
+1 -1
View File
@@ -39,12 +39,12 @@ tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-shell = "2" tauri-plugin-shell = "2"
tauri-plugin-store = "2" tauri-plugin-store = "2"
time = { version = "0.3", features = ["local-offset"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["codec", "rt"] } tokio-util = { version = "0.7", features = ["codec", "rt"] }
tracing = "0.1" tracing = "0.1"
tracing-log = "0.2" tracing-log = "0.2"
tracing-subscriber = "0.3" tracing-subscriber = "0.3"
time = { version = "0.3", features = ["local-offset"] }
uuid = { version = "1", features = ["v7"] } uuid = { version = "1", features = ["v7"] }
walkdir = "2" walkdir = "2"
windows = { windows = {
@@ -33,12 +33,12 @@ tauri = { workspace = true }
tauri-plugin-dialog = { workspace = true } tauri-plugin-dialog = { workspace = true }
tauri-plugin-shell = { workspace = true } tauri-plugin-shell = { workspace = true }
tauri-plugin-store = { workspace = true } tauri-plugin-store = { workspace = true }
time = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
tokio-util = { workspace = true } tokio-util = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
tracing-log = { workspace = true } tracing-log = { workspace = true }
tracing-subscriber = { workspace = true } tracing-subscriber = { workspace = true }
time = { workspace = true }
walkdir = { workspace = true } walkdir = { workspace = true }
[build-dependencies] [build-dependencies]
@@ -1,7 +1,7 @@
use std::{ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
fs::{self, OpenOptions}, fs::{self, OpenOptions},
io::{Read as _, Seek as _, SeekFrom, Write as _}, io::{self, Read as _, Seek as _, SeekFrom, Write as _},
net::SocketAddr, net::SocketAddr,
path::{Component, Path, PathBuf}, path::{Component, Path, PathBuf},
sync::{Arc, Mutex, OnceLock}, sync::{Arc, Mutex, OnceLock},
@@ -87,6 +87,7 @@ struct LanSpreadState {
catalog: Arc<RwLock<GameCatalog>>, catalog: Arc<RwLock<GameCatalog>>,
unpack_logs: Arc<RwLock<Vec<UnpackLogEntry>>>, unpack_logs: Arc<RwLock<Vec<UnpackLogEntry>>>,
state_dir: OnceLock<PathBuf>, state_dir: OnceLock<PathBuf>,
main_log_sink: OnceLock<MainLogSink>,
active_outbound_transfers: OutboundTransfers, active_outbound_transfers: OutboundTransfers,
outbound_transfer_emit: Arc<RwLock<OutboundTransferEmitState>>, outbound_transfer_emit: Arc<RwLock<OutboundTransferEmitState>>,
} }
@@ -144,6 +145,14 @@ struct UnpackLogEntry {
struct MainLogLinePayload { struct MainLogLinePayload {
line: String, line: String,
level: String, level: String,
sequence: Option<u64>,
}
#[derive(Clone, Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct MainLogHistoryPayload {
contents: String,
last_sequence: u64,
} }
struct SidecarUnpacker { struct SidecarUnpacker {
@@ -174,15 +183,27 @@ async fn get_unpack_logs(
} }
#[tauri::command] #[tauri::command]
async fn get_main_logs(app_handle: tauri::AppHandle) -> tauri::Result<String> { async fn get_main_logs(
app_handle: tauri::AppHandle,
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<MainLogHistoryPayload> {
if let Some(sink) = state.inner().main_log_sink.get() {
return Ok(sink.read_history()?);
}
let state_dir = app_handle.path().app_data_dir()?; let state_dir = app_handle.path().app_data_dir()?;
fs::create_dir_all(&state_dir)?; fs::create_dir_all(&state_dir)?;
let path = main_log_path(&state_dir); let path = main_log_path(&state_dir);
trim_main_log_file(&path)?;
match fs::read_to_string(&path) { match read_main_log_file_to_limit(&path, MAX_MAIN_LOG_BYTES) {
Ok(contents) => Ok(contents), Ok(contents) => Ok(MainLogHistoryPayload {
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(String::new()), contents,
last_sequence: 0,
}),
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(MainLogHistoryPayload {
contents: String::new(),
last_sequence: 0,
}),
Err(err) => Err(err.into()), Err(err) => Err(err.into()),
} }
} }
@@ -1384,33 +1405,70 @@ fn main_log_path(state_dir: &Path) -> PathBuf {
state_dir.join(MAIN_LOG_FILE_NAME) state_dir.join(MAIN_LOG_FILE_NAME)
} }
fn trim_main_log_file(path: &Path) -> std::io::Result<()> { #[cfg(test)]
trim_main_log_file_to_limit(path, MAX_MAIN_LOG_BYTES) fn trim_main_log_file_to_limit(path: &Path, max_bytes: u64) -> io::Result<()> {
} let mut file = match OpenOptions::new().read(true).write(true).open(path) {
Ok(file) => file,
fn trim_main_log_file_to_limit(path: &Path, max_bytes: u64) -> std::io::Result<()> { Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
let metadata = match fs::metadata(path) {
Ok(metadata) => metadata,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(err) => return Err(err), Err(err) => return Err(err),
}; };
trim_main_log_file_to_limit_with_file(&mut file, max_bytes)
}
fn trim_main_log_file_to_limit_with_file(file: &mut fs::File, max_bytes: u64) -> io::Result<()> {
let metadata = file.metadata()?;
if metadata.len() <= max_bytes { if metadata.len() <= max_bytes {
file.seek(SeekFrom::End(0))?;
return Ok(()); return Ok(());
} }
if max_bytes == 0 { let tail = if max_bytes == 0 {
fs::write(path, [])?; String::new()
return Ok(()); } else {
}
let mut file = fs::File::open(path)?;
file.seek(SeekFrom::Start(metadata.len() - max_bytes))?; file.seek(SeekFrom::Start(metadata.len() - max_bytes))?;
let mut bytes = Vec::with_capacity(usize::try_from(max_bytes).unwrap_or(usize::MAX)); let mut bytes = Vec::with_capacity(usize::try_from(max_bytes).unwrap_or(usize::MAX));
file.read_to_end(&mut bytes)?; file.read_to_end(&mut bytes)?;
let tail = valid_utf8_tail(bytes); valid_utf8_tail(bytes)
fs::write(path, tail.as_bytes()) };
file.set_len(0)?;
file.seek(SeekFrom::Start(0))?;
file.write_all(tail.as_bytes())?;
file.seek(SeekFrom::End(0))?;
Ok(())
}
fn read_main_log_file_to_limit(path: &Path, max_bytes: u64) -> io::Result<String> {
let mut file = fs::File::open(path)?;
read_main_log_file_to_limit_with_file(&mut file, max_bytes)
}
fn read_main_log_file_to_limit_with_file(
file: &mut fs::File,
max_bytes: u64,
) -> io::Result<String> {
let metadata = file.metadata()?;
if metadata.len() == 0 || max_bytes == 0 {
file.seek(SeekFrom::End(0))?;
return Ok(String::new());
}
let start = metadata.len().saturating_sub(max_bytes);
file.seek(SeekFrom::Start(start))?;
let capacity = usize::try_from(metadata.len() - start).unwrap_or(usize::MAX);
let mut bytes = Vec::with_capacity(capacity);
file.read_to_end(&mut bytes)?;
file.seek(SeekFrom::End(0))?;
if start == 0 {
Ok(String::from_utf8_lossy(&bytes).into_owned())
} else {
Ok(valid_utf8_tail(bytes))
}
} }
fn valid_utf8_tail(bytes: Vec<u8>) -> String { fn valid_utf8_tail(bytes: Vec<u8>) -> String {
@@ -1427,7 +1485,13 @@ fn valid_utf8_tail(bytes: Vec<u8>) -> String {
struct MainLogSink { struct MainLogSink {
app_handle: AppHandle, app_handle: AppHandle,
path: PathBuf, path: PathBuf,
file_lock: Arc<Mutex<()>>, file_state: Arc<Mutex<MainLogFileState>>,
}
#[derive(Default)]
struct MainLogFileState {
file: Option<fs::File>,
last_sequence: u64,
} }
impl MainLogSink { impl MainLogSink {
@@ -1435,54 +1499,101 @@ impl MainLogSink {
Self { Self {
app_handle, app_handle,
path, path,
file_lock: Arc::new(Mutex::new(())), file_state: Arc::new(Mutex::new(MainLogFileState::default())),
} }
} }
fn write_line(&self, line: String, level: Level) { fn write_line(&self, line: String, level: Level) {
write_main_log_stdout(&line); write_main_log_stdout(&line);
self.append_file_line(&line); let sequence = self.append_file_line(&line);
let _ = self.app_handle.emit( let _ = self.app_handle.emit(
"main-log-line", "main-log-line",
MainLogLinePayload { MainLogLinePayload {
line, line,
level: level.as_str().to_string(), level: level.as_str().to_string(),
sequence,
}, },
); );
} }
fn append_file_line(&self, line: &str) { fn read_history(&self) -> io::Result<MainLogHistoryPayload> {
let Ok(_guard) = self.file_lock.lock() else { let mut file_state = self
return; .file_state
}; .lock()
.map_err(|_| io::Error::other("main log file lock poisoned"))?;
if let Some(parent) = self.path.parent() { if file_state.file.is_none() && !self.path.exists() {
let _ = fs::create_dir_all(parent); return Ok(MainLogHistoryPayload {
contents: String::new(),
last_sequence: file_state.last_sequence,
});
} }
let Ok(mut file) = OpenOptions::new() let contents = {
.create(true) let file = self.cached_file(&mut file_state.file)?;
.append(true) trim_main_log_file_to_limit_with_file(file, MAX_MAIN_LOG_BYTES)?;
.open(&self.path) read_main_log_file_to_limit_with_file(file, MAX_MAIN_LOG_BYTES)?
else {
return;
}; };
if writeln!(file, "{line}").is_err() { Ok(MainLogHistoryPayload {
return; contents,
last_sequence: file_state.last_sequence,
})
} }
let should_trim = file fn append_file_line(&self, line: &str) -> Option<u64> {
.metadata() let Ok(mut file_state) = self.file_state.lock() else {
.is_ok_and(|metadata| { return None;
metadata.len() > MAX_MAIN_LOG_BYTES.saturating_add(MAIN_LOG_TRIM_SLACK_BYTES) };
let write_result = self.cached_file(&mut file_state.file).and_then(|file| {
file.seek(SeekFrom::End(0))
.and_then(|_| writeln!(file, "{line}"))
}); });
if should_trim { if write_result.is_err() {
let _ = trim_main_log_file(&self.path); file_state.file = None;
return None;
}
file_state.last_sequence = file_state.last_sequence.saturating_add(1);
let sequence = file_state.last_sequence;
let should_trim = file_state.file.as_ref().is_some_and(|file| {
file.metadata().is_ok_and(|metadata| {
metadata.len() > MAX_MAIN_LOG_BYTES.saturating_add(MAIN_LOG_TRIM_SLACK_BYTES)
})
});
if should_trim && let Some(file) = file_state.file.as_mut() {
let _ = trim_main_log_file_to_limit_with_file(file, MAX_MAIN_LOG_BYTES);
}
Some(sequence)
}
fn cached_file<'a>(&self, file: &'a mut Option<fs::File>) -> io::Result<&'a mut fs::File> {
if file.is_none() {
*file = Some(open_main_log_file(&self.path)?);
}
file.as_mut()
.ok_or_else(|| io::Error::other("main log file was not opened"))
} }
} }
fn open_main_log_file(path: &Path) -> io::Result<fs::File> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(path)
} }
struct MainLogLayer { struct MainLogLayer {
@@ -1517,13 +1628,8 @@ where
.unwrap_or_else(|| metadata.target().to_string()); .unwrap_or_else(|| metadata.target().to_string());
let message = visitor.into_message(); let message = visitor.into_message();
let (date, time) = current_main_log_timestamp(); let (date, time) = current_main_log_timestamp();
let line = format_main_log_line_parts( let line =
&date, format_main_log_line_parts(&date, &time, &target, metadata.level().as_str(), &message);
&time,
&target,
metadata.level().as_str(),
&message,
);
self.sink.write_line(line, *metadata.level()); self.sink.write_line(line, *metadata.level());
} }
@@ -1622,8 +1728,9 @@ fn format_main_log_line_parts(
message: &str, message: &str,
) -> String { ) -> String {
format!( format!(
"[{date}][{time}][{}][{level}] {message}", "[{date}][{time}][{}][{level}] {}",
normalize_main_log_target(target) normalize_main_log_target(target),
normalize_main_log_message(message)
) )
} }
@@ -1641,12 +1748,8 @@ fn write_main_log_stdout(line: &str) {
let _ = writeln!(stdout, "{line}"); let _ = writeln!(stdout, "{line}");
} }
fn init_main_logging( fn init_main_logging(sink: MainLogSink) -> Result<(), Box<dyn std::error::Error>> {
app_handle: AppHandle, let subscriber = tracing_subscriber::registry().with(MainLogLayer::new(sink));
path: PathBuf,
) -> Result<(), Box<dyn std::error::Error>> {
let subscriber =
tracing_subscriber::registry().with(MainLogLayer::new(MainLogSink::new(app_handle, path)));
tracing::subscriber::set_global_default(subscriber)?; tracing::subscriber::set_global_default(subscriber)?;
tracing_log::LogTracer::builder() tracing_log::LogTracer::builder()
.with_max_level(log::LevelFilter::Info) .with_max_level(log::LevelFilter::Info)
@@ -2594,8 +2697,12 @@ pub fn run() {
.setup(move |app| { .setup(move |app| {
let state_dir = app.path().app_data_dir()?; let state_dir = app.path().app_data_dir()?;
std::fs::create_dir_all(&state_dir)?; std::fs::create_dir_all(&state_dir)?;
init_main_logging(app.handle().clone(), main_log_path(&state_dir))?; let main_log_sink = MainLogSink::new(app.handle().clone(), main_log_path(&state_dir));
let state = app.state::<LanSpreadState>(); let state = app.state::<LanSpreadState>();
if state.main_log_sink.set(main_log_sink.clone()).is_err() {
log::warn!("main log sink was already initialized");
}
init_main_logging(main_log_sink)?;
let unpack_logs = load_unpack_logs(&state_dir); let unpack_logs = load_unpack_logs(&state_dir);
tauri::async_runtime::block_on(async { tauri::async_runtime::block_on(async {
*state.unpack_logs.write().await = unpack_logs; *state.unpack_logs.write().await = unpack_logs;
@@ -3,114 +3,28 @@ import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { SegmentedRadio } from './components/SegmentedRadio'; import { SegmentedRadio } from './components/SegmentedRadio';
import {
capLogRows,
consumeLoadedHistoryRow,
dedupeBufferedRows,
formatCount,
LEVEL_FILTER_MIN,
LEVEL_FILTER_OPTIONS,
LEVEL_ORDER,
lineCountsFromRows,
type LevelFilter,
type MainLogHistoryPayload,
type MainLogLinePayload,
type MainLogRow,
rowFromPayload,
rowsFromHistory,
} from './lib/mainLogs';
import './MainLogsWindow.css'; import './MainLogsWindow.css';
export const isMainLogsView = (): boolean => export const isMainLogsView = (): boolean =>
new URLSearchParams(window.location.search).get('view') === 'main-logs'; new URLSearchParams(window.location.search).get('view') === 'main-logs';
const LOG_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'] as const;
type LogLevel = typeof LOG_LEVELS[number];
type LevelFilter = 'all' | 'debug' | 'info' | 'warn' | 'error';
interface MainLogLinePayload {
line: string;
level: string;
}
interface MainLogRow {
line: string;
level: LogLevel;
}
const LEVEL_ORDER: Record<LogLevel, number> = {
TRACE: 0,
DEBUG: 1,
INFO: 2,
WARN: 3,
ERROR: 4,
};
const LEVEL_FILTER_MIN: Record<LevelFilter, number> = {
all: LEVEL_ORDER.TRACE,
debug: LEVEL_ORDER.DEBUG,
info: LEVEL_ORDER.INFO,
warn: LEVEL_ORDER.WARN,
error: LEVEL_ORDER.ERROR,
};
const LEVEL_FILTER_OPTIONS: ReadonlyArray<{ value: LevelFilter; label: string }> = [
{ value: 'all', label: 'All' },
{ value: 'debug', label: 'Debug+' },
{ value: 'info', label: 'Info+' },
{ value: 'warn', label: 'Warn+' },
{ value: 'error', label: 'Error only' },
];
const MAX_IN_MEMORY_LOG_ROWS = 12_000;
const MAX_IN_MEMORY_LOG_CHARS = 2 * 1024 * 1024;
const isLogLevel = (value: string | undefined): value is LogLevel =>
typeof value === 'string' && (LOG_LEVELS as readonly string[]).includes(value);
const normalizeLogLevel = (value: string | undefined): LogLevel => {
const upper = value?.toUpperCase();
return isLogLevel(upper) ? upper : 'INFO';
};
const parseLogLevelFromLine = (line: string): LogLevel => {
const match = line.match(/\[(TRACE|DEBUG|INFO|WARN|ERROR)\](?:\s|$)/);
return normalizeLogLevel(match?.[1]);
};
const rowFromPayload = (payload: MainLogLinePayload): MainLogRow => ({
line: payload.line,
level: normalizeLogLevel(payload.level),
});
const rowsFromHistory = (text: string): MainLogRow[] =>
text
.split(/\r?\n/)
.filter(line => line.length > 0)
.map(line => ({ line, level: parseLogLevelFromLine(line) }));
const capLogRows = (rows: MainLogRow[]): MainLogRow[] => {
let charCount = 0;
const capped: MainLogRow[] = [];
for (let index = rows.length - 1; index >= 0; index -= 1) {
const row = rows[index];
const rowChars = row.line.length + 1;
const wouldExceedRows = capped.length >= MAX_IN_MEMORY_LOG_ROWS;
const wouldExceedChars = charCount + rowChars > MAX_IN_MEMORY_LOG_CHARS;
if (wouldExceedRows || (wouldExceedChars && capped.length > 0)) {
break;
}
capped.push(row);
charCount += rowChars;
}
return capped.reverse();
};
const dedupeBufferedRows = (historyRows: MainLogRow[], bufferedRows: MainLogRow[]): MainLogRow[] => {
const lineCounts = new Map<string, number>();
historyRows.forEach(row => {
lineCounts.set(row.line, (lineCounts.get(row.line) ?? 0) + 1);
});
return bufferedRows.filter(row => {
const count = lineCounts.get(row.line) ?? 0;
if (count <= 0) return true;
lineCounts.set(row.line, count - 1);
return false;
});
};
const formatCount = (count: number, noun: string): string =>
`${count.toLocaleString()} ${noun}${count === 1 ? '' : 's'}`;
export const MainLogsWindow = () => { export const MainLogsWindow = () => {
const [logs, setLogs] = useState<MainLogRow[]>([]); const [logs, setLogs] = useState<MainLogRow[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -127,6 +41,8 @@ export const MainLogsWindow = () => {
const initialBufferRef = useRef<MainLogRow[]>([]); const initialBufferRef = useRef<MainLogRow[]>([]);
const pausedBufferRef = useRef<MainLogRow[]>([]); const pausedBufferRef = useRef<MainLogRow[]>([]);
const pausedRef = useRef(false); const pausedRef = useRef(false);
const lastHistorySequenceRef = useRef(0);
const historyLineCountsRef = useRef<Map<string, number>>(new Map());
const appendVisibleRows = useCallback((rows: MainLogRow[]) => { const appendVisibleRows = useCallback((rows: MainLogRow[]) => {
setLogs(current => capLogRows([...current, ...rows])); setLogs(current => capLogRows([...current, ...rows]));
@@ -146,6 +62,15 @@ export const MainLogsWindow = () => {
initialBufferRef.current = capLogRows([...initialBufferRef.current, row]); initialBufferRef.current = capLogRows([...initialBufferRef.current, row]);
return; return;
} }
if (
consumeLoadedHistoryRow(
historyLineCountsRef.current,
row,
lastHistorySequenceRef.current,
)
) {
return;
}
if (pausedRef.current) { if (pausedRef.current) {
bufferPausedRows([row]); bufferPausedRows([row]);
return; return;
@@ -159,12 +84,19 @@ export const MainLogsWindow = () => {
handleIncomingRow(rowFromPayload(event.payload)); handleIncomingRow(rowFromPayload(event.payload));
}); });
const history = await invoke<string>('get_main_logs'); const history = await invoke<MainLogHistoryPayload>('get_main_logs');
if (cancelled) return; if (cancelled) return;
const historyRows = rowsFromHistory(history); lastHistorySequenceRef.current = history.lastSequence;
const liveRows = dedupeBufferedRows(historyRows, initialBufferRef.current); const historyRows = rowsFromHistory(history.contents);
const historyLineCounts = lineCountsFromRows(historyRows);
const liveRows = dedupeBufferedRows(
historyLineCounts,
initialBufferRef.current,
lastHistorySequenceRef.current,
);
initialBufferRef.current = []; initialBufferRef.current = [];
historyLineCountsRef.current = historyLineCounts;
historyLoadedRef.current = true; historyLoadedRef.current = true;
if (pausedRef.current) { if (pausedRef.current) {
@@ -192,6 +124,8 @@ export const MainLogsWindow = () => {
cancelled = true; cancelled = true;
historyLoadedRef.current = false; historyLoadedRef.current = false;
initialBufferRef.current = []; initialBufferRef.current = [];
lastHistorySequenceRef.current = 0;
historyLineCountsRef.current = new Map();
unlisten?.(); unlisten?.();
}; };
}, [appendVisibleRows, bufferPausedRows]); }, [appendVisibleRows, bufferPausedRows]);
@@ -215,6 +149,8 @@ export const MainLogsWindow = () => {
}); });
}, [levelFilter, logs, regex]); }, [levelFilter, logs, regex]);
const lastVisibleRow = filteredRows.length > 0 ? filteredRows[filteredRows.length - 1] : null;
useEffect(() => { useEffect(() => {
if (!autoScroll) return; if (!autoScroll) return;
const viewport = viewportRef.current; const viewport = viewportRef.current;
@@ -223,7 +159,7 @@ export const MainLogsWindow = () => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
viewport.scrollTop = viewport.scrollHeight; viewport.scrollTop = viewport.scrollHeight;
}); });
}, [autoScroll, filteredRows.length]); }, [autoScroll, filteredRows.length, lastVisibleRow?.id]);
const flushPausedRows = useCallback(() => { const flushPausedRows = useCallback(() => {
const buffered = pausedBufferRef.current; const buffered = pausedBufferRef.current;
@@ -328,9 +264,9 @@ export const MainLogsWindow = () => {
<div className="main-log-empty"> <div className="main-log-empty">
{logs.length === 0 ? 'No application logs recorded yet.' : 'No log lines match the current filters.'} {logs.length === 0 ? 'No application logs recorded yet.' : 'No log lines match the current filters.'}
</div> </div>
) : filteredRows.map((row, index) => ( ) : filteredRows.map(row => (
<div <div
key={`${index}-${row.line}`} key={row.id}
className={`main-log-line level-${row.level.toLowerCase()}`} className={`main-log-line level-${row.level.toLowerCase()}`}
> >
{row.line} {row.line}
@@ -0,0 +1,148 @@
export const LOG_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'] as const;
export type LogLevel = typeof LOG_LEVELS[number];
export type LevelFilter = 'all' | 'debug' | 'info' | 'warn' | 'error';
export interface MainLogLinePayload {
line: string;
level: string;
sequence?: number | null;
}
export interface MainLogHistoryPayload {
contents: string;
lastSequence: number;
}
export interface MainLogRow {
id: string;
line: string;
level: LogLevel;
sequence?: number;
}
export const LEVEL_ORDER: Record<LogLevel, number> = {
TRACE: 0,
DEBUG: 1,
INFO: 2,
WARN: 3,
ERROR: 4,
};
export const LEVEL_FILTER_MIN: Record<LevelFilter, number> = {
all: LEVEL_ORDER.TRACE,
debug: LEVEL_ORDER.DEBUG,
info: LEVEL_ORDER.INFO,
warn: LEVEL_ORDER.WARN,
error: LEVEL_ORDER.ERROR,
};
export const LEVEL_FILTER_OPTIONS: ReadonlyArray<{ value: LevelFilter; label: string }> = [
{ value: 'all', label: 'All' },
{ value: 'debug', label: 'Debug+' },
{ value: 'info', label: 'Info+' },
{ value: 'warn', label: 'Warn+' },
{ value: 'error', label: 'Error only' },
];
const MAX_IN_MEMORY_LOG_ROWS = 12_000;
const MAX_IN_MEMORY_LOG_CHARS = 2 * 1024 * 1024;
let nextSyntheticLogRowId = 0;
const syntheticLogRowId = (): string => {
nextSyntheticLogRowId += 1;
return `live-synthetic-${nextSyntheticLogRowId}`;
};
const isLogLevel = (value: string | undefined): value is LogLevel =>
typeof value === 'string' && (LOG_LEVELS as readonly string[]).includes(value);
export const normalizeLogLevel = (value: string | undefined): LogLevel => {
const upper = value?.toUpperCase();
return isLogLevel(upper) ? upper : 'INFO';
};
export const parseLogLevelFromLine = (line: string): LogLevel => {
const match = line.match(/\[(TRACE|DEBUG|INFO|WARN|ERROR)\](?:\s|$)/);
return normalizeLogLevel(match?.[1]);
};
export const rowFromPayload = (payload: MainLogLinePayload): MainLogRow => {
const sequence = typeof payload.sequence === 'number' ? payload.sequence : undefined;
return {
id: sequence === undefined ? syntheticLogRowId() : `live-${sequence}`,
line: payload.line,
level: normalizeLogLevel(payload.level),
sequence,
};
};
export const rowsFromHistory = (text: string): MainLogRow[] =>
text
.split(/\r?\n/)
.filter(line => line.length > 0)
.map((line, index) => ({
id: `history-${index}`,
line,
level: parseLogLevelFromLine(line),
}));
export const capLogRows = (rows: MainLogRow[]): MainLogRow[] => {
let charCount = 0;
const capped: MainLogRow[] = [];
for (let index = rows.length - 1; index >= 0; index -= 1) {
const row = rows[index];
const rowChars = row.line.length + 1;
const wouldExceedRows = capped.length >= MAX_IN_MEMORY_LOG_ROWS;
const wouldExceedChars = charCount + rowChars > MAX_IN_MEMORY_LOG_CHARS;
if (wouldExceedRows || (wouldExceedChars && capped.length > 0)) {
break;
}
capped.push(row);
charCount += rowChars;
}
return capped.reverse();
};
export const rowWasLoadedInHistory = (row: MainLogRow, lastHistorySequence: number): boolean =>
typeof row.sequence === 'number' && row.sequence <= lastHistorySequence;
export const lineCountsFromRows = (rows: MainLogRow[]): Map<string, number> => {
const lineCounts = new Map<string, number>();
rows.forEach(row => {
lineCounts.set(row.line, (lineCounts.get(row.line) ?? 0) + 1);
});
return lineCounts;
};
export const consumeLoadedHistoryRow = (
historyLineCounts: Map<string, number>,
row: MainLogRow,
lastHistorySequence: number,
): boolean => {
if (!rowWasLoadedInHistory(row, lastHistorySequence)) return false;
const count = historyLineCounts.get(row.line) ?? 0;
if (count <= 0) return false;
if (count === 1) {
historyLineCounts.delete(row.line);
} else {
historyLineCounts.set(row.line, count - 1);
}
return true;
};
export const dedupeBufferedRows = (
historyLineCounts: Map<string, number>,
bufferedRows: MainLogRow[],
lastHistorySequence: number,
): MainLogRow[] =>
bufferedRows.filter(row => !consumeLoadedHistoryRow(historyLineCounts, row, lastHistorySequence));
export const formatCount = (count: number, noun: string): string =>
`${count.toLocaleString()} ${noun}${count === 1 ? '' : 's'}`;
@@ -0,0 +1,55 @@
import {
dedupeBufferedRows,
lineCountsFromRows,
rowFromPayload,
rowsFromHistory,
rowWasLoadedInHistory,
} from '../src/lib/mainLogs.ts';
const assertEquals = <T>(actual: T, expected: T, message: string) => {
if (actual !== expected) {
throw new Error(`${message}: expected ${expected}, got ${actual}`);
}
};
Deno.test('history rows parse levels and stable ids', () => {
const rows = rowsFromHistory('[2026-06-07][12:00:00][app][WARN] careful\nplain line\n');
assertEquals(rows.length, 2, 'history should skip trailing empty line');
assertEquals(rows[0].id, 'history-0', 'history id should include row position');
assertEquals(rows[0].level, 'WARN', 'explicit level should be parsed');
assertEquals(rows[1].level, 'INFO', 'unknown level should default to info');
});
Deno.test('buffered main log rows covered by history sequence are removed', () => {
const historyRows = rowsFromHistory('[2026-06-07][12:00:01][app][INFO] included\n');
const included = rowFromPayload({
line: '[2026-06-07][12:00:01][app][INFO] included',
level: 'INFO',
sequence: 4,
});
const fresh = rowFromPayload({
line: '[2026-06-07][12:00:02][app][INFO] fresh',
level: 'INFO',
sequence: 5,
});
const deduped = dedupeBufferedRows(lineCountsFromRows(historyRows), [included, fresh], 4);
assertEquals(rowWasLoadedInHistory(included, 4), true, 'included row should match history');
assertEquals(deduped.length, 1, 'only fresh row should remain');
assertEquals(deduped[0].line, fresh.line, 'fresh row should not be dropped');
});
Deno.test('buffered rows missing from trimmed history are retained', () => {
const retained = rowFromPayload({
line: '[2026-06-07][12:00:00][app][INFO] trimmed out',
level: 'INFO',
sequence: 2,
});
const deduped = dedupeBufferedRows(new Map(), [retained], 4);
assertEquals(deduped.length, 1, 'trimmed row should remain visible');
assertEquals(deduped[0].line, retained.line, 'trimmed row should be preserved');
});