diff --git a/Cargo.lock b/Cargo.lock index 065f9c76f..28298a22b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -51,6 +51,12 @@ dependencies = [ "cc", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -568,12 +574,27 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + [[package]] name = "cast" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.51" @@ -676,6 +697,20 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "compression-codecs" version = "0.4.35" @@ -838,6 +873,31 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -854,6 +914,40 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.113", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.113", +] + [[package]] name = "datadog-protos" version = "0.1.0" @@ -1081,6 +1175,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1320,13 +1420,24 @@ dependencies = [ "ahash 0.7.8", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -1656,6 +1767,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1700,6 +1817,28 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.113", +] + [[package]] name = "integer-encoding" version = "3.0.4" @@ -2017,6 +2156,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "lading_tui" +version = "0.1.0" +dependencies = [ + "crossterm", + "rand 0.9.2", + "ratatui", + "serde", + "serde_yaml", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -2141,6 +2291,15 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "matchers" version = "0.2.0" @@ -2235,6 +2394,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -3119,6 +3279,27 @@ dependencies = [ "rand_core 0.9.3", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools 0.13.0", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "raw-cpuid" version = "11.6.0" @@ -3751,6 +3932,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -3810,12 +4012,40 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.113", +] + [[package]] name = "subtle" version = "2.6.1" @@ -4395,6 +4625,35 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "unsafe-libyaml" version = "0.2.11" diff --git a/Cargo.toml b/Cargo.toml index dbc185a33..1dfaf7793 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "lading_fuzz", "lading_payload", "lading_throttle", + "lading_tui", ] [workspace.dependencies] diff --git a/examples/lading-custom-30-30-40.yaml b/examples/lading-custom-30-30-40.yaml new file mode 100644 index 000000000..85f4da1c3 --- /dev/null +++ b/examples/lading-custom-30-30-40.yaml @@ -0,0 +1,19 @@ +generator: +- file_gen: + logrotate_fs: + seed: [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97, 101, 103, 107, 109, 113, 127, 131] + concurrent_logs: 2 + maximum_bytes_per_log: 10MiB + total_rotations: 4 + max_depth: 0 + variant: + templated_json: + template_path: examples/templates/custom_30_30_40.yaml + maximum_prebuild_cache_size_bytes: 100MiB + maximum_block_size: 2MiB + mount_point: /tmp/logrotate + load_profile: + constant: 1MiB +blackhole: +- tcp: + binding_addr: 0.0.0.0:8080 diff --git a/examples/lading-pii-mixed.yaml b/examples/lading-pii-mixed.yaml new file mode 100644 index 000000000..c01fff72b --- /dev/null +++ b/examples/lading-pii-mixed.yaml @@ -0,0 +1,19 @@ +generator: +- file_gen: + logrotate_fs: + seed: [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 8, 9, 7, 9, 3, 2, 3, 8, 4, 6, 2, 6, 4, 3, 3, 8, 3, 2, 7, 9, 5] + concurrent_logs: 2 + maximum_bytes_per_log: 100MiB + total_rotations: 4 + max_depth: 0 + variant: + templated_json: + template_path: examples/templates/pii_mixed_logs.yaml + maximum_prebuild_cache_size_bytes: 500MiB + maximum_block_size: 2MiB + mount_point: /tmp/logrotate + load_profile: + constant: 10MiB +blackhole: +- tcp: + binding_addr: 0.0.0.0:8080 diff --git a/examples/templates/custom_30_30_40.yaml b/examples/templates/custom_30_30_40.yaml new file mode 100644 index 000000000..dac3c3014 --- /dev/null +++ b/examples/templates/custom_30_30_40.yaml @@ -0,0 +1,21 @@ +definitions: + random_ipv4: + !format + template: "{}.{}.{}.{}" + args: + - !range { min: 1, max: 255 } + - !range { min: 0, max: 255 } + - !range { min: 0, max: 255 } + - !range { min: 1, max: 254 } + +generator: + !weighted + - weight: 30 + value: !object + message: !const "error" + - weight: 30 + value: !object + message: !reference random_ipv4 + - weight: 40 + value: !object + message: !const "hello world" diff --git a/examples/templates/pii_mixed_logs.yaml b/examples/templates/pii_mixed_logs.yaml new file mode 100644 index 000000000..2aa127345 --- /dev/null +++ b/examples/templates/pii_mixed_logs.yaml @@ -0,0 +1,89 @@ +definitions: + random_ipv4: + !format + template: "{}.{}.{}.{}" + args: + - !range { min: 1, max: 255 } + - !range { min: 0, max: 255 } + - !range { min: 0, max: 255 } + - !range { min: 1, max: 254 } + + email_address: + !format + template: "user{}@{}.{}" + args: + - !range { min: 1000, max: 9999 } + - !choose ["example", "mail", "inbox", "test", "demo"] + - !choose ["com", "net", "org", "io"] + + credit_card: + !weighted + - weight: 50 + value: + !format + template: "{}-{}-{}-{}" + args: + - !range { min: 1000, max: 9999 } + - !range { min: 1000, max: 9999 } + - !range { min: 1000, max: 9999 } + - !range { min: 1000, max: 9999 } + - weight: 50 + value: + !format + template: "{}{}{}{}" + args: + - !range { min: 1000, max: 9999 } + - !range { min: 1000, max: 9999 } + - !range { min: 1000, max: 9999 } + - !range { min: 1000, max: 9999 } + + tag_value: + !choose ["auth", "api", "database", "cache", "network", + "storage", "compute", "billing", "security", "monitoring"] + +generator: + !object + timestamp: !timestamp + level: + !weighted + - weight: 10 + value: !const "ERROR" + - weight: 30 + value: !const "INFO" + - weight: 60 + value: !const "DEBUG" + # 70% plain, 15% credit card inline, 9% ip inline, 6% email inline + message: + !weighted + - weight: 70 + value: + !choose ["request processed", "task completed", "health check passed", + "connection established", "query executed", "cache hit", + "cache miss", "retry attempt", "timeout occurred", "validation failed"] + - weight: 15 + value: + !format + template: "{} card {}" + args: + - !choose ["charged to", "transaction processed with", + "payment declined for", "refund issued to"] + - !reference credit_card + - weight: 9 + value: + !format + template: "request from {} {}" + args: + - !reference random_ipv4 + - !choose ["completed", "blocked", "throttled", "accepted"] + - weight: 6 + value: + !format + template: "{} for {}" + args: + - !choose ["login attempt", "password reset", + "account verification", "session created"] + - !reference email_address + tags: + !array + length: { min: 5, max: 15 } + element: !reference tag_value \ No newline at end of file diff --git a/lading_tui/Cargo.toml b/lading_tui/Cargo.toml new file mode 100644 index 000000000..1900d5e48 --- /dev/null +++ b/lading_tui/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "lading_tui" +version = "0.1.0" +edition = "2024" +license = "MIT" +repository = "https://github.com/datadog/lading/" +keywords = ["tui", "lading", "load-testing"] +categories = ["development-tools::profiling"] +description = "A TUI for configuring and running lading load tests." +readme = "README.md" + +[[bin]] +name = "lading_tui" +path = "src/main.rs" + +[dependencies] +ratatui = "0.29" +crossterm = "0.28" +serde = { workspace = true } +serde_yaml = { workspace = true } +rand = { workspace = true, features = ["small_rng", "os_rng"] } + +# Override workspace lints — a TUI binary needs different rules than library crates. +[lints.clippy] +all = "warn" +pedantic = "warn" +# Allow things that are fine in a TUI binary +unwrap_used = "allow" +print_stdout = "allow" +print_stderr = "allow" +missing_docs_in_private_items = "allow" +module_name_repetitions = "allow" +too_many_lines = "allow" +wildcard_imports = "allow" +match_wildcard_for_single_variants = "allow" + +[lints.rust] +missing_docs = "allow" +unreachable_pub = "allow" +missing_copy_implementations = "allow" +missing_debug_implementations = "allow" diff --git a/lading_tui/src/app.rs b/lading_tui/src/app.rs new file mode 100644 index 000000000..4ba0d7fcc --- /dev/null +++ b/lading_tui/src/app.rs @@ -0,0 +1,1926 @@ +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Read, Seek, SeekFrom}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc::{self, Receiver}; +use std::time::{Duration, Instant}; + +use crossterm::event::{KeyCode, KeyModifiers}; +use rand::{RngCore, SeedableRng, rngs::SmallRng}; + +const DEFAULT_TEMPLATE: &str = "\ +generator:\n\ + !object\n\ + timestamp: !timestamp\n\ + level:\n\ + !weighted\n\ + - weight: 75\n\ + value: !const \"INFO\"\n\ + - weight: 20\n\ + value: !const \"WARN\"\n\ + - weight: 5\n\ + value: !const \"ERROR\"\n\ + message: !choose [\"request processed\", \"user login\", \"cache miss\", \"db query\"]\n\ + duration_ms: !range { min: 1, max: 5000 }\n\ +"; + +/// Generate 32 bytes of OS entropy for the config seed. +fn fresh_seed() -> [u8; 32] { + let mut buf = [0u8; 32]; + SmallRng::from_os_rng().fill_bytes(&mut buf); + buf +} + +use crate::config::{ + BlackholeEntry, BlackholeKind, ImportedFields, LoadProfileKind, build_load_profile_value, + build_variant_value, build_yaml, parse_config, +}; +use crate::variants::{ALL_VARIANTS, VariantKind}; + +// --------------------------------------------------------------------------- +// Form types (replaces old Step/FieldId/SubField wizard) +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum FormRow { + ConfigPath, + // --- component headers --- + GeneratorComponent, + BlackholeComponent, + // --- generator sub-rows (visible when generator_expanded) --- + Variant, + ConcurrentLogs, + MaxBytesPerLog, + TotalRotations, + MaxDepth, + MountPoint, + LoadProfile, + ConstantRate, // only when LoadProfileKind::Constant + LinearInitial, // only when LoadProfileKind::Linear + LinearRate, // only when LoadProfileKind::Linear + MaxPrebuildCache, + MaxBlockSize, + SplunkHecEncoding, // only when variant == SplunkHec + StaticPath, // only when variant == Static or StaticChunks + TemplatedJsonPath, // only when variant == TemplatedJson + TemplateEditAction, // "Open template editor" button row + // --- blackhole sub-rows (visible when blackhole_expanded) --- + BlackholeEntry(usize), // one row per entry + BlackholeAddEntry, // "+ Add entry" row + // --- save --- + SavePath, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum FormMode { + Navigate, + VariantSubMenu, + LoadProfileSubMenu, + Editing, +} + +#[derive(Clone, PartialEq, Eq)] +pub enum PreviewState { + Idle, + Building, + Running, + Failed(String), + Stopped, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum ImportMode { + Inactive, + EnterPath, + ConfirmUnsaved, +} + +// --------------------------------------------------------------------------- +// Per-file live state +// --------------------------------------------------------------------------- + +#[derive(Default)] +pub struct LogFileState { + pub content: String, // rolling 2000-line display buffer (live tail) + pub total_lines: usize, // actual line count (may exceed buffer) + pub total_bytes: usize, // actual byte count of the file + pub read_pos: u64, // last byte offset read (incremental reads) +} + +// --------------------------------------------------------------------------- +// App +// --------------------------------------------------------------------------- + +pub struct App { + // --- form navigation --- + pub form_row: usize, + pub generator_expanded: bool, + pub blackhole_expanded: bool, + pub variant_expanded: bool, + pub variant_sub_cursor: usize, + pub load_profile_expanded: bool, + pub load_profile_sub_cursor: usize, // 0=Constant, 1=Linear + pub form_editing: bool, + pub auto_import_done: bool, + + // --- blackhole config --- + pub blackhole_entries: Vec, + + // --- config values --- + pub seed: [u8; 32], + pub variant_cursor: usize, + pub concurrent_logs: u16, + pub max_bytes_per_log: String, + pub total_rotations: u8, + pub max_depth: u8, + pub mount_point: String, + pub load_profile_kind: LoadProfileKind, + pub constant_rate: String, + pub linear_initial: String, + pub linear_rate: String, + pub max_prebuild_cache: String, + pub max_block_size: String, + pub splunk_enc_cursor: usize, // 0 = text, 1 = json + pub static_path: String, + pub template_path: String, + + // --- save state --- + pub save_path: String, + pub saved: bool, + pub dirty: bool, + pub save_notification: Option, // set on save, cleared after ~3s + + // --- transient UI state --- + pub input: String, + pub input_cursor: usize, // byte offset of text cursor within `input` + pub error: Option, + pub tab: usize, // 0 = Build, 1 = Preview + pub quit: bool, + + // --- import overlay --- + pub import_mode: ImportMode, + pub import_input: String, + pub import_error: Option, + pub import_pending_path: String, + + // --- preview tab --- + pub preview_state: PreviewState, + pub preview_config_input: String, + pub preview_config_error: Option, + pub preview_build_child: Option, + pub preview_lading_child: Option, + pub preview_build_log: String, + pub preview_build_log_rx: Option>, + pub preview_lading_log: String, + pub preview_lading_log_rx: Option>, + pub preview_log_files: Vec, // active .log files + pub preview_file_tab: usize, + pub preview_log_states: HashMap, // per-file live state + snapshots + pub preview_rotated_files: Vec>, // [tab_idx] -> sorted rotated files + pub preview_rotated_cache: HashMap, // path -> (content, lines, bytes) + pub preview_rotated_expanded: bool, + pub preview_rotated_cursor: usize, // index within current tab's rotated list + pub preview_viewing_rotated: bool, // if true, show rotated content instead + pub preview_full_file_content: HashMap, // captured on stop: full file (content, lines, bytes) + pub preview_last_refresh: Instant, // 1s lightweight tick (incremental reads) + pub preview_last_full_refresh: Instant, // 5s full refresh (dir walk + rotated cache) + pub preview_mount_point: String, + pub preview_run_config: String, // patched copy of config with remapped mount_point + pub preview_config_yaml: String, // YAML content of the active run config + pub preview_config_panel_expanded: bool, // whether config panel is expanded in left panel + pub preview_pretty_mode: bool, // line numbers + alternating colors (Stopped only) + pub preview_content_scroll: u32, // lines scrolled up from bottom (0 = follow tail) + pub preview_max_scroll: std::cell::Cell, // set by render each frame; max useful scroll value + + // --- template editor --- + pub template_editor_open: bool, + pub template_lines: Vec, + pub template_cursor_row: usize, + pub template_cursor_col: usize, + pub template_scroll: usize, + pub template_just_saved: bool, +} + +impl App { + pub fn new(config_path: Option) -> Self { + let seed = fresh_seed(); + + let mut app = App { + form_row: 0, + generator_expanded: true, + blackhole_expanded: true, + variant_expanded: false, + variant_sub_cursor: 0, + load_profile_expanded: false, + load_profile_sub_cursor: 0, + form_editing: false, + auto_import_done: false, + blackhole_entries: vec![BlackholeEntry { + kind: BlackholeKind::Http, + addr: "127.0.0.1:9091".into(), + }], + seed, + variant_cursor: 0, + concurrent_logs: 8, + max_bytes_per_log: "100MiB".into(), + total_rotations: 4, + max_depth: 0, + mount_point: "/tmp/logrotate".into(), + load_profile_kind: LoadProfileKind::Constant, + constant_rate: "1MiB".into(), + linear_initial: "1MiB".into(), + linear_rate: "100KiB".into(), + max_prebuild_cache: "1GiB".into(), + max_block_size: "2MiB".into(), + splunk_enc_cursor: 0, + static_path: String::new(), + template_path: String::new(), + save_path: config_path.unwrap_or_else(|| "/tmp/lading_config.yaml".into()), + saved: false, + dirty: false, + save_notification: None, + input: String::new(), + input_cursor: 0, + error: None, + tab: 0, + quit: false, + import_mode: ImportMode::Inactive, + import_input: String::new(), + import_error: None, + import_pending_path: String::new(), + preview_state: PreviewState::Idle, + preview_config_input: "/tmp/lading_config.yaml".into(), + preview_config_error: None, + preview_build_child: None, + preview_lading_child: None, + preview_build_log: String::new(), + preview_build_log_rx: None, + preview_lading_log: String::new(), + preview_lading_log_rx: None, + preview_log_files: Vec::new(), + preview_file_tab: 0, + preview_log_states: HashMap::new(), + preview_rotated_files: Vec::new(), + preview_rotated_cache: HashMap::new(), + preview_rotated_expanded: false, + preview_rotated_cursor: 0, + preview_viewing_rotated: false, + preview_full_file_content: HashMap::new(), + preview_last_refresh: Instant::now(), + preview_last_full_refresh: Instant::now(), + preview_mount_point: String::new(), + preview_run_config: String::new(), + preview_config_yaml: String::new(), + preview_config_panel_expanded: false, + preview_pretty_mode: false, + preview_content_scroll: 0, + preview_max_scroll: std::cell::Cell::new(0), + template_editor_open: false, + template_lines: DEFAULT_TEMPLATE.lines().map(str::to_string).collect(), + template_cursor_row: 0, + template_cursor_col: 0, + template_scroll: 0, + template_just_saved: false, + }; + app.try_auto_import(); + app + } + + pub fn selected_variant(&self) -> VariantKind { + ALL_VARIANTS[self.variant_cursor] + } + + pub fn current_yaml(&self) -> String { + let variant = build_variant_value( + self.selected_variant(), + self.splunk_enc_cursor, + &self.static_path, + &self.template_path, + ); + let lp = build_load_profile_value( + self.load_profile_kind, + &self.constant_rate, + &self.linear_initial, + &self.linear_rate, + ); + build_yaml( + &self.seed, + self.concurrent_logs, + &self.max_bytes_per_log, + self.total_rotations, + self.max_depth, + &self.mount_point, + &self.max_prebuild_cache, + &self.max_block_size, + variant, + lp, + &self.blackhole_entries, + ) + } + + /// Returns the path of the currently selected rotated file, if any. + pub fn current_rotated_path(&self) -> Option<&PathBuf> { + self.preview_rotated_files + .get(self.preview_file_tab) + .and_then(|v| v.get(self.preview_rotated_cursor)) + } + + /// Returns the content to display: rotated file > full stopped > live rolling. + pub fn displayed_content(&self) -> &str { + if self.preview_viewing_rotated { + if let Some(path) = self.current_rotated_path() { + if let Some((content, _, _)) = self.preview_rotated_cache.get(path) { + return content; + } + } + return ""; + } + let path = self.preview_log_files.get(self.preview_file_tab); + if self.preview_state == PreviewState::Stopped { + if let Some(p) = path { + if let Some((content, _, _)) = self.preview_full_file_content.get(p) { + return content; + } + } + } + let state = path.and_then(|p| self.preview_log_states.get(p)); + state.map(|s| s.content.as_str()).unwrap_or("") + } + + /// Returns the total line count for the currently displayed content. + pub fn displayed_total_lines(&self) -> usize { + if self.preview_viewing_rotated { + if let Some(path) = self.current_rotated_path() { + if let Some((_, lines, _)) = self.preview_rotated_cache.get(path) { + return *lines; + } + } + return 0; + } + let path = self.preview_log_files.get(self.preview_file_tab); + if self.preview_state == PreviewState::Stopped { + if let Some(p) = path { + if let Some((_, lines, _)) = self.preview_full_file_content.get(p) { + return *lines; + } + } + } + path.and_then(|p| self.preview_log_states.get(p)) + .map(|s| s.total_lines) + .unwrap_or(0) + } + + /// Returns the total byte count for the currently displayed content. + pub fn displayed_total_bytes(&self) -> usize { + if self.preview_viewing_rotated { + if let Some(path) = self.current_rotated_path() { + if let Some((_, _, bytes)) = self.preview_rotated_cache.get(path) { + return *bytes; + } + } + return 0; + } + let path = self.preview_log_files.get(self.preview_file_tab); + if self.preview_state == PreviewState::Stopped { + if let Some(p) = path { + if let Some((_, _, bytes)) = self.preview_full_file_content.get(p) { + return *bytes; + } + } + } + path.and_then(|p| self.preview_log_states.get(p)) + .map(|s| s.total_bytes) + .unwrap_or(0) + } + + // ----------------------------------------------------------------------- + // Tick — called every loop iteration for background work + // ----------------------------------------------------------------------- + + pub fn tick(&mut self) { + // Clear save notification after 3 seconds + if let Some(t) = self.save_notification { + if t.elapsed().as_secs() >= 3 { + self.save_notification = None; + } + } + match &self.preview_state { + PreviewState::Building => self.tick_building(), + PreviewState::Running => self.tick_running(), + _ => {} + } + } + + fn tick_building(&mut self) { + // Drain lines from the background reader thread (non-blocking) + if let Some(rx) = &self.preview_build_log_rx { + while let Ok(line) = rx.try_recv() { + self.preview_build_log.push_str(&line); + self.preview_build_log.push('\n'); + } + } + + let exited = self + .preview_build_child + .as_mut() + .and_then(|c| c.try_wait().ok()) + .flatten(); + if let Some(status) = exited { + if status.success() { + self.preview_build_child = None; + self.start_lading(); + } else { + let log = self.preview_build_log.clone(); + let last = log + .lines() + .rev() + .take(5) + .collect::>() + .into_iter() + .rev() + .collect::>() + .join("\n"); + self.preview_state = PreviewState::Failed(format!("Build failed:\n{last}")); + self.preview_build_child = None; + } + } + } + + fn tick_running(&mut self) { + // Drain lading log lines from the background reader thread (non-blocking) + if let Some(rx) = &self.preview_lading_log_rx { + while let Ok(line) = rx.try_recv() { + self.preview_lading_log.push_str(&line); + self.preview_lading_log.push('\n'); + } + } + + let exited = self + .preview_lading_child + .as_mut() + .and_then(|c| c.try_wait().ok()) + .flatten(); + if let Some(status) = exited { + self.preview_lading_child = None; + self.preview_state = + PreviewState::Failed(format!("lading exited unexpectedly: {status}")); + return; + } + + if self.preview_last_refresh.elapsed() >= Duration::from_secs(1) { + self.refresh_log_files_lightweight(); + } + if self.preview_last_full_refresh.elapsed() >= Duration::from_secs(5) { + self.refresh_log_files_full(); + } + } + + // 1s tick: incremental reads of active files. + fn refresh_log_files_lightweight(&mut self) { + if self.preview_mount_point.is_empty() { + return; + } + let active_paths: Vec = self.preview_log_files.clone(); + for path in &active_paths { + self.read_file_incremental(path); + } + self.preview_last_refresh = Instant::now(); + } + + // 5s tick: dir walk, file grouping, rotated cache update. + fn refresh_log_files_full(&mut self) { + if self.preview_mount_point.is_empty() { + return; + } + let mount = std::path::Path::new(&self.preview_mount_point); + let mut active: Vec = Vec::new(); + let mut all_rotated: Vec = Vec::new(); + if let Ok(entries) = self.walk_dir(mount) { + for path in entries { + if path.extension().and_then(|e| e.to_str()) == Some("log") { + active.push(path); + } else if is_rotated_log(&path) { + all_rotated.push(path); + } + } + } + active.sort(); + all_rotated.sort(); + + // Group rotated files by their active-file parent (foo.log.1 → foo.log). + let mut rotated: Vec> = vec![vec![]; active.len()]; + for rpath in all_rotated { + if let Some(idx) = active.iter().position(|a| is_rotated_of(a, &rpath)) { + rotated[idx].push(rpath); + } + } + + self.preview_log_files = active; + self.preview_rotated_files = rotated; + + // clamp tab index + if !self.preview_log_files.is_empty() { + self.preview_file_tab = self.preview_file_tab.min(self.preview_log_files.len() - 1); + } else { + self.preview_file_tab = 0; + } + // clamp rotated cursor to current tab's list + let cur_rot_len = self + .preview_rotated_files + .get(self.preview_file_tab) + .map(Vec::len) + .unwrap_or(0); + if cur_rot_len == 0 { + self.preview_rotated_cursor = 0; + if self.preview_viewing_rotated { + self.preview_viewing_rotated = false; + } + } else { + self.preview_rotated_cursor = self.preview_rotated_cursor.min(cur_rot_len - 1); + } + + // Drop state for files that have been rotated out of the active list. + let active_paths = self.preview_log_files.clone(); + self.preview_log_states + .retain(|p, _| active_paths.contains(p)); + + self.preview_last_full_refresh = Instant::now(); + } + + // Called by stop_preview to flush everything before the FUSE mount dies. + fn refresh_log_files(&mut self) { + self.refresh_log_files_full(); + self.refresh_log_files_lightweight(); + } + + fn read_file_incremental(&mut self, path: &PathBuf) { + let Ok(mut file) = std::fs::File::open(path) else { + return; + }; + let Ok(meta) = file.metadata() else { return }; + let new_size = meta.len(); + let state = self.preview_log_states.entry(path.clone()).or_default(); + + if new_size == state.read_pos { + return; // nothing new + } + + if new_size < state.read_pos { + // File was truncated/rotated — reset and re-read from the start. + state.read_pos = 0; + state.content.clear(); + state.total_lines = 0; + } else { + file.seek(SeekFrom::Start(state.read_pos)).ok(); + } + + let mut new_bytes = Vec::new(); + file.read_to_end(&mut new_bytes).ok(); + state.total_bytes = new_size as usize; + state.read_pos = new_size; + + let new_text = String::from_utf8_lossy(&new_bytes); + let new_line_count = new_text.lines().count(); + let mut lines: Vec<&str> = state.content.lines().collect(); + lines.extend(new_text.lines()); + state.total_lines = state.total_lines.saturating_add(new_line_count); + let start = lines.len().saturating_sub(2000); + state.content = lines[start..].join("\n"); + } + + pub fn load_rotated_file(&mut self) { + let path = self + .preview_rotated_files + .get(self.preview_file_tab) + .and_then(|v| v.get(self.preview_rotated_cursor)) + .cloned(); + let Some(path) = path else { return }; + // Attempt a read in case the eager cache missed this file (e.g. it appeared + // after the last refresh). After FUSE unmounts this will silently fail and + // we fall back to whatever is already in the cache. + if let Ok(content) = std::fs::read_to_string(&path) { + let lines = content.lines().count(); + let bytes = content.len(); + self.preview_rotated_cache.insert(path, (content, lines, bytes)); + } + self.preview_viewing_rotated = true; + self.preview_content_scroll = 0; + } + + fn walk_dir(&self, dir: &std::path::Path) -> std::io::Result> { + let mut result = Vec::new(); + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + result.extend(self.walk_dir(&path)?); + } else { + result.push(path); + } + } + Ok(result) + } + + fn start_preview(&mut self) { + let config_path = self.preview_config_input.trim().to_string(); + if config_path.is_empty() { + self.preview_config_error = Some("Config path cannot be empty".into()); + return; + } + + // Read and validate config + let yaml = match std::fs::read_to_string(&config_path) { + Err(e) => { + self.preview_config_error = Some(format!("Cannot read config: {e}")); + return; + } + Ok(y) => y, + }; + + // Require a blackhole section — lading needs a sink for generated traffic + if !config_has_blackhole(&yaml) { + self.preview_config_error = Some( + "Config has no 'blackhole' section — add one before running.\n\ + Example:\n\ + blackhole:\n\ + \x20 - http:\n\ + \x20 binding_addr: \"127.0.0.1:9091\"" + .into(), + ); + return; + } + + // Remap mount_point into /tmp so we don't need root or pre-created dirs + let original_mount = + extract_mount_point(&yaml).unwrap_or_else(|| "/tmp/logrotate".to_string()); + let effective_mount = remap_to_tmp(&original_mount); + // Remove any stale FUSE mount left by a previous unclean exit before creating fresh. + cleanup_mount(&effective_mount); + if let Err(e) = std::fs::create_dir_all(&effective_mount) { + self.preview_config_error = + Some(format!("Cannot create mount dir {effective_mount}: {e}")); + return; + } + self.preview_mount_point = effective_mount.clone(); + + // Write a patched config to a temp file so the original is untouched + let patched_yaml = patch_mount_point(&yaml, &effective_mount); + let run_config = "/tmp/lading_tui_run.yaml".to_string(); + if let Err(e) = std::fs::write(&run_config, &patched_yaml) { + self.preview_config_error = Some(format!("Cannot write run config: {e}")); + return; + } + self.preview_run_config = run_config; + // Compact the seed from block-sequence to flow style for display only; + // the file written to disk above is left verbatim so lading can read it fine. + self.preview_config_yaml = crate::config::compact_seed_in_yaml(&patched_yaml); + self.preview_config_panel_expanded = false; + + self.preview_config_error = None; + self.preview_build_log = String::new(); + self.preview_build_log_rx = None; + self.preview_lading_log = String::new(); + self.preview_lading_log_rx = None; + self.preview_log_files = Vec::new(); + self.preview_log_states.clear(); + self.preview_file_tab = 0; + self.preview_rotated_files = Vec::new(); + self.preview_rotated_cache.clear(); + self.preview_rotated_expanded = false; + self.preview_rotated_cursor = 0; + self.preview_viewing_rotated = false; + self.preview_full_file_content.clear(); + self.preview_last_refresh = Instant::now(); + self.preview_last_full_refresh = Instant::now(); + + match Command::new("cargo") + .args(["build", "-p", "lading", "--features", "logrotate_fs"]) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + { + Ok(mut child) => { + // Spawn a background thread to drain stderr without blocking the UI + if let Some(stderr) = child.stderr.take() { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let reader = BufReader::new(stderr); + for line in reader.lines().flatten() { + if tx.send(line).is_err() { + break; + } + } + }); + self.preview_build_log_rx = Some(rx); + } + self.preview_build_child = Some(child); + self.preview_state = PreviewState::Building; + } + Err(e) => { + self.preview_state = PreviewState::Failed(format!("Failed to spawn cargo: {e}")); + } + } + } + + fn start_lading(&mut self) { + let config_path = self.preview_run_config.clone(); + match Command::new("target/debug/lading") + .args([ + "--config-path", + &config_path, + "--no-target", + "--experiment-duration-seconds", + "600", + "--capture-path", + "/tmp/lading_tui_capture.jsonl", + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + { + Ok(child) => { + self.preview_lading_child = Some(child); + self.preview_state = PreviewState::Running; + self.refresh_log_files(); + } + Err(e) => { + self.preview_state = PreviewState::Failed(format!("Failed to spawn lading: {e}")); + } + } + } + + pub fn stop_preview(&mut self) { + // Do a final refresh before killing lading so all rotated file content + // is cached while the FUSE mount is still live. + self.refresh_log_files(); + + // Capture full contents of all active log files while FUSE is still mounted. + // The rolling 2000-line buffer in LogFileState is a live-tail view; on stop + // we want the complete file so the user can scroll to the real first line. + let active_paths: Vec = self.preview_log_files.clone(); + for path in &active_paths { + if let Ok(content) = std::fs::read_to_string(path) { + let lines = content.lines().count(); + let bytes = content.len(); + self.preview_full_file_content.insert(path.clone(), (content, lines, bytes)); + } + } + + // Cache all rotated files now, while FUSE is still mounted. + // We do this at stop time rather than eagerly every 5s to avoid blocking + // the main thread with potentially GBs of I/O during the run. + let all_rotated: Vec = self.preview_rotated_files + .iter() + .flatten() + .cloned() + .collect(); + for path in all_rotated { + if let Ok(content) = std::fs::read_to_string(&path) { + let lines = content.lines().count(); + let bytes = content.len(); + self.preview_rotated_cache.insert(path, (content, lines, bytes)); + } + } + + if let Some(mut child) = self.preview_lading_child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + if let Some(mut child) = self.preview_build_child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + // Unmount and remove the FUSE mount directory. lading is killed with SIGKILL so + // it never runs its own FUSE teardown; the kernel marks the mount dead but leaves + // it in /proc/mounts. cleanup_mount() clears that stale entry. + if !self.preview_mount_point.is_empty() { + cleanup_mount(&self.preview_mount_point); + } + self.preview_build_log_rx = None; + self.preview_lading_log_rx = None; + self.preview_state = PreviewState::Stopped; + } + + // ----------------------------------------------------------------------- + // Import + // ----------------------------------------------------------------------- + + fn try_import(&mut self) { + let path = self.import_input.trim().to_string(); + if path.is_empty() { + self.import_error = Some("Path cannot be empty".into()); + return; + } + let yaml = match std::fs::read_to_string(&path) { + Ok(y) => y, + Err(e) => { + self.import_error = Some(format!("Cannot read file: {e}")); + return; + } + }; + match parse_config(&yaml) { + Err(e) => { + self.import_error = Some(e); + } + Ok(fields) => { + if self.dirty { + self.import_pending_path = path; + self.import_mode = ImportMode::ConfirmUnsaved; + } else { + self.apply_import(fields); + self.import_mode = ImportMode::Inactive; + // sync preview config input with whatever was imported + self.preview_config_input = path; + } + } + } + } + + pub fn apply_import(&mut self, fields: ImportedFields) { + if let Some(s) = fields.seed { + self.seed = s; + } + if let Some(v) = fields.concurrent_logs { + self.concurrent_logs = v; + } + if let Some(v) = fields.max_bytes_per_log { + self.max_bytes_per_log = v; + } + if let Some(v) = fields.total_rotations { + self.total_rotations = v; + } + if let Some(v) = fields.max_depth { + self.max_depth = v; + } + if let Some(v) = fields.mount_point { + self.mount_point = v; + } + if let Some(kind) = fields.variant_kind { + self.variant_cursor = ALL_VARIANTS.iter().position(|&k| k == kind).unwrap_or(0); + } + if let Some(v) = fields.splunk_enc { + self.splunk_enc_cursor = v; + } + if let Some(v) = fields.static_path { + self.static_path = v; + } + if let Some(v) = fields.template_path { + self.template_path = v; + } + if let Some(v) = fields.load_profile_kind { + self.load_profile_kind = v; + } + if let Some(v) = fields.constant_rate { + self.constant_rate = v; + } + if let Some(v) = fields.linear_initial { + self.linear_initial = v; + } + if let Some(v) = fields.linear_rate { + self.linear_rate = v; + } + if let Some(v) = fields.max_prebuild_cache { + self.max_prebuild_cache = v; + } + if let Some(v) = fields.max_block_size { + self.max_block_size = v; + } + if let Some(entries) = fields.blackhole_entries { + self.blackhole_entries = entries; + } + self.dirty = false; + self.import_mode = ImportMode::Inactive; + self.import_error = None; + } + + // ----------------------------------------------------------------------- + // Key handling + // ----------------------------------------------------------------------- + + pub fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) { + // Import overlay consumes all keys + if self.import_mode != ImportMode::Inactive { + self.handle_import_key(code); + return; + } + + // Template editor consumes all keys when open + if self.template_editor_open { + self.handle_template_editor_key(code, modifiers); + return; + } + + // Ctrl+S → fast-save (Build tab only, any form mode) + if code == KeyCode::Char('s') && modifiers.contains(KeyModifiers::CONTROL) { + if self.tab == 0 { + self.do_save_to_path(); + } + return; + } + + // 'i' → open import overlay (blocked when typing in a text field) + if code == KeyCode::Char('i') && self.tab == 0 && !self.form_editing { + self.import_mode = ImportMode::EnterPath; + self.import_input = String::new(); + self.import_error = None; + return; + } + + if self.tab == 1 { + self.handle_preview_key(code); + return; + } + + self.handle_form_key(code); + } + + fn handle_import_key(&mut self, code: KeyCode) { + match self.import_mode { + ImportMode::EnterPath => match code { + KeyCode::Char(c) => { + self.import_input.push(c); + self.import_error = None; + } + KeyCode::Backspace => { + self.import_input.pop(); + self.import_error = None; + } + KeyCode::Enter => self.try_import(), + KeyCode::Esc => self.import_mode = ImportMode::Inactive, + _ => {} + }, + ImportMode::ConfirmUnsaved => match code { + KeyCode::Enter | KeyCode::Char('y') => { + // Save first, then import + let yaml = self.current_yaml(); + if let Err(e) = std::fs::write(&self.save_path, &yaml) { + self.import_error = Some(format!("Save failed: {e}")); + self.import_mode = ImportMode::EnterPath; + return; + } + self.saved = true; + self.save_notification = Some(Instant::now()); + let path = self.import_pending_path.clone(); + let yaml = match std::fs::read_to_string(&path) { + Ok(y) => y, + Err(e) => { + self.import_error = Some(format!("Cannot read file: {e}")); + self.import_mode = ImportMode::Inactive; + return; + } + }; + match parse_config(&yaml) { + Ok(fields) => { + self.preview_config_input = path.clone(); + self.apply_import(fields); + } + Err(e) => { + self.import_error = Some(e); + self.import_mode = ImportMode::Inactive; + } + } + } + KeyCode::Char('n') => { + let path = self.import_pending_path.clone(); + let yaml = match std::fs::read_to_string(&path) { + Ok(y) => y, + Err(e) => { + self.import_error = Some(format!("Cannot read file: {e}")); + self.import_mode = ImportMode::Inactive; + return; + } + }; + match parse_config(&yaml) { + Ok(fields) => { + self.preview_config_input = path.clone(); + self.apply_import(fields); + } + Err(e) => { + self.import_error = Some(e); + self.import_mode = ImportMode::Inactive; + } + } + } + KeyCode::Esc => { + self.import_mode = ImportMode::Inactive; + } + _ => {} + }, + ImportMode::Inactive => {} + } + } + + fn handle_preview_key(&mut self, code: KeyCode) { + match &self.preview_state.clone() { + PreviewState::Idle => match code { + KeyCode::Enter | KeyCode::Char('r') => { + self.start_preview(); + } + KeyCode::Char('q') => self.quit = true, + KeyCode::Char(c) => { + self.preview_config_input.push(c); + self.preview_config_error = None; + } + KeyCode::Backspace => { + self.preview_config_input.pop(); + self.preview_config_error = None; + } + KeyCode::Tab => self.tab = 0, + _ => {} + }, + PreviewState::Building => match code { + KeyCode::Char('q') => self.stop_preview(), + _ => {} + }, + PreviewState::Running => match code { + KeyCode::Up => { + self.preview_content_scroll = self.preview_content_scroll.saturating_add(3); + } + KeyCode::Down => { + self.preview_content_scroll = self.preview_content_scroll.saturating_sub(3); + } + KeyCode::Left => { + if self.preview_rotated_expanded { + // Navigate between rotated files for this tab + if self.preview_rotated_cursor > 0 { + self.preview_rotated_cursor -= 1; + self.load_rotated_file(); + } + } else if self.preview_file_tab > 0 { + self.preview_file_tab -= 1; + self.preview_content_scroll = 0; + self.preview_viewing_rotated = false; + self.preview_rotated_cursor = 0; + } + } + KeyCode::Right => { + if self.preview_rotated_expanded { + let cur_rot_len = self + .preview_rotated_files + .get(self.preview_file_tab) + .map(Vec::len) + .unwrap_or(0); + if self.preview_rotated_cursor + 1 < cur_rot_len { + self.preview_rotated_cursor += 1; + self.load_rotated_file(); + } + } else if !self.preview_log_files.is_empty() + && self.preview_file_tab + 1 < self.preview_log_files.len() + { + self.preview_file_tab += 1; + self.preview_content_scroll = 0; + self.preview_viewing_rotated = false; + self.preview_rotated_cursor = 0; + } + } + KeyCode::Char('e') => { + self.preview_rotated_expanded = !self.preview_rotated_expanded; + if self.preview_rotated_expanded { + self.preview_rotated_cursor = 0; + self.load_rotated_file(); + } else { + self.preview_viewing_rotated = false; + } + } + KeyCode::Char('t') => { + self.preview_content_scroll = self.preview_max_scroll.get(); + } + KeyCode::Char('b') => { + self.preview_content_scroll = 0; + } + KeyCode::Char('c') => { + self.preview_config_panel_expanded = !self.preview_config_panel_expanded; + } + KeyCode::Char('q') => self.stop_preview(), + _ => {} + }, + PreviewState::Failed(_) | PreviewState::Stopped => match code { + KeyCode::Left => { + if self.preview_rotated_expanded { + if self.preview_rotated_cursor > 0 { + self.preview_rotated_cursor -= 1; + self.load_rotated_file(); + } + } else if self.preview_file_tab > 0 { + self.preview_file_tab -= 1; + self.preview_content_scroll = 0; + self.preview_viewing_rotated = false; + self.preview_rotated_cursor = 0; + } + } + KeyCode::Right => { + if self.preview_rotated_expanded { + let cur_rot_len = self + .preview_rotated_files + .get(self.preview_file_tab) + .map(Vec::len) + .unwrap_or(0); + if self.preview_rotated_cursor + 1 < cur_rot_len { + self.preview_rotated_cursor += 1; + self.load_rotated_file(); + } + } else if !self.preview_log_files.is_empty() + && self.preview_file_tab + 1 < self.preview_log_files.len() + { + self.preview_file_tab += 1; + self.preview_content_scroll = 0; + self.preview_viewing_rotated = false; + self.preview_rotated_cursor = 0; + } + } + KeyCode::Char('e') => { + self.preview_rotated_expanded = !self.preview_rotated_expanded; + if self.preview_rotated_expanded { + self.preview_rotated_cursor = 0; + self.load_rotated_file(); + } else { + self.preview_viewing_rotated = false; + } + } + KeyCode::Up => { + self.preview_content_scroll = self.preview_content_scroll.saturating_add(3); + } + KeyCode::Down => { + self.preview_content_scroll = self.preview_content_scroll.saturating_sub(3); + } + KeyCode::Char('t') => { + self.preview_content_scroll = self.preview_max_scroll.get(); + } + KeyCode::Char('b') => { + self.preview_content_scroll = 0; + } + KeyCode::Char('c') => { + self.preview_config_panel_expanded = !self.preview_config_panel_expanded; + } + KeyCode::Char('p') => { + self.preview_pretty_mode = !self.preview_pretty_mode; + } + KeyCode::Char('r') => { + self.preview_state = PreviewState::Idle; + } + KeyCode::Char('q') => self.quit = true, + KeyCode::Tab => self.tab = 0, + _ => {} + }, + } + } + + fn handle_form_key(&mut self, code: KeyCode) { + match self.form_mode() { + FormMode::Navigate => self.handle_form_navigate(code), + FormMode::VariantSubMenu => self.handle_variant_submenu(code), + FormMode::LoadProfileSubMenu => self.handle_load_profile_submenu(code), + FormMode::Editing => self.handle_form_editing(code), + } + } + + fn handle_form_navigate(&mut self, code: KeyCode) { + let rows = self.form_rows(); + match code { + KeyCode::Up => { + if self.form_row > 0 { + self.form_row -= 1; + } + } + KeyCode::Down => { + if self.form_row + 1 < rows.len() { + self.form_row += 1; + } + } + KeyCode::Enter => { + let row = rows[self.form_row]; + match row { + FormRow::Variant => { + self.variant_sub_cursor = self.variant_cursor; + self.variant_expanded = true; + } + FormRow::LoadProfile => { + self.load_profile_sub_cursor = match self.load_profile_kind { + LoadProfileKind::Constant => 0, + LoadProfileKind::Linear => 1, + }; + self.load_profile_expanded = true; + } + FormRow::SplunkHecEncoding => { + self.splunk_enc_cursor ^= 1; + self.dirty = true; + } + FormRow::GeneratorComponent => { + self.generator_expanded = !self.generator_expanded; + self.clamp_form_row(); + } + FormRow::BlackholeComponent => { + self.blackhole_expanded = !self.blackhole_expanded; + self.clamp_form_row(); + } + FormRow::TemplateEditAction => { + // Load from file if path is set and file exists; otherwise keep current lines + if !self.template_path.is_empty() { + if let Ok(content) = std::fs::read_to_string(&self.template_path) { + self.template_lines = content.lines().map(str::to_string).collect(); + if self.template_lines.is_empty() { + self.template_lines = vec![String::new()]; + } + } + } + self.template_cursor_row = 0; + self.template_cursor_col = 0; + self.template_scroll = 0; + self.template_editor_open = true; + } + FormRow::BlackholeEntry(_) => self.begin_edit(), + FormRow::BlackholeAddEntry => { + let addr = next_blackhole_addr(&self.blackhole_entries); + self.blackhole_entries.push(BlackholeEntry { + kind: BlackholeKind::Http, + addr, + }); + self.dirty = true; + } + _ => self.begin_edit(), + } + } + KeyCode::Left => { + if let Some(FormRow::BlackholeEntry(i)) = rows.get(self.form_row).copied() { + if i < self.blackhole_entries.len() { + self.blackhole_entries[i].kind = self.blackhole_entries[i].kind.prev(); + self.dirty = true; + } + } + } + KeyCode::Right => { + if let Some(FormRow::BlackholeEntry(i)) = rows.get(self.form_row).copied() { + if i < self.blackhole_entries.len() { + self.blackhole_entries[i].kind = self.blackhole_entries[i].kind.next(); + self.dirty = true; + } + } + } + KeyCode::Char('d') => { + if let Some(FormRow::BlackholeEntry(i)) = rows.get(self.form_row).copied() { + self.blackhole_entries.remove(i); + self.dirty = true; + self.clamp_form_row(); + } + } + KeyCode::Tab => self.tab = 1, + KeyCode::Char('r') => self.seed = fresh_seed(), + KeyCode::Char('q') => self.quit = true, + _ => {} + } + } + + fn handle_variant_submenu(&mut self, code: KeyCode) { + match code { + KeyCode::Up => { + if self.variant_sub_cursor > 0 { + self.variant_sub_cursor -= 1; + } + } + KeyCode::Down => { + if self.variant_sub_cursor + 1 < ALL_VARIANTS.len() { + self.variant_sub_cursor += 1; + } + } + KeyCode::Enter => { + self.variant_cursor = self.variant_sub_cursor; + self.variant_expanded = false; + self.dirty = true; + self.clamp_form_row(); + } + KeyCode::Esc => { + self.variant_expanded = false; + } + _ => {} + } + } + + fn handle_load_profile_submenu(&mut self, code: KeyCode) { + match code { + KeyCode::Up | KeyCode::Down => { + self.load_profile_sub_cursor = 1 - self.load_profile_sub_cursor; + } + KeyCode::Enter => { + self.load_profile_kind = match self.load_profile_sub_cursor { + 0 => LoadProfileKind::Constant, + _ => LoadProfileKind::Linear, + }; + self.load_profile_expanded = false; + self.dirty = true; + self.clamp_form_row(); + } + KeyCode::Esc => { + self.load_profile_expanded = false; + } + _ => {} + } + } + + fn handle_template_editor_key(&mut self, code: KeyCode, modifiers: KeyModifiers) { + // Any key clears the saved indicator + self.template_just_saved = false; + + // Ctrl+S → save template to file + if code == KeyCode::Char('s') && modifiers.contains(KeyModifiers::CONTROL) { + self.save_template(); + return; + } + match code { + KeyCode::Esc => { + self.template_editor_open = false; + self.error = None; + } + KeyCode::Up => { + if self.template_cursor_row > 0 { + self.template_cursor_row -= 1; + let len = self.template_lines[self.template_cursor_row].len(); + self.template_cursor_col = self.template_cursor_col.min(len); + } + } + KeyCode::Down => { + if self.template_cursor_row + 1 < self.template_lines.len() { + self.template_cursor_row += 1; + let len = self.template_lines[self.template_cursor_row].len(); + self.template_cursor_col = self.template_cursor_col.min(len); + } + } + KeyCode::Left => { + if self.template_cursor_col > 0 { + self.template_cursor_col -= 1; + } else if self.template_cursor_row > 0 { + self.template_cursor_row -= 1; + self.template_cursor_col = self.template_lines[self.template_cursor_row].len(); + } + } + KeyCode::Right => { + let len = self.template_lines[self.template_cursor_row].len(); + if self.template_cursor_col < len { + self.template_cursor_col += 1; + } else if self.template_cursor_row + 1 < self.template_lines.len() { + self.template_cursor_row += 1; + self.template_cursor_col = 0; + } + } + KeyCode::Home => { + self.template_cursor_col = 0; + } + KeyCode::End => { + self.template_cursor_col = self.template_lines[self.template_cursor_row].len(); + } + KeyCode::Tab => { + // Insert 2 spaces for YAML-friendly indentation + let row = self.template_cursor_row; + let col = self.template_cursor_col; + self.template_lines[row].insert_str(col, " "); + self.template_cursor_col += 2; + } + KeyCode::Char(c) => { + let row = self.template_cursor_row; + let col = self.template_cursor_col; + self.template_lines[row].insert(col, c); + self.template_cursor_col += 1; + } + KeyCode::Enter => { + let row = self.template_cursor_row; + let col = self.template_cursor_col; + let rest = self.template_lines[row].split_off(col); + self.template_lines.insert(row + 1, rest); + self.template_cursor_row += 1; + self.template_cursor_col = 0; + } + KeyCode::Backspace => { + let row = self.template_cursor_row; + let col = self.template_cursor_col; + if col > 0 { + self.template_lines[row].remove(col - 1); + self.template_cursor_col -= 1; + } else if row > 0 { + let line = self.template_lines.remove(row); + self.template_cursor_row -= 1; + let prev_len = self.template_lines[self.template_cursor_row].len(); + self.template_lines[self.template_cursor_row].push_str(&line); + self.template_cursor_col = prev_len; + } + } + KeyCode::Delete => { + let row = self.template_cursor_row; + let col = self.template_cursor_col; + if col < self.template_lines[row].len() { + self.template_lines[row].remove(col); + } else if row + 1 < self.template_lines.len() { + let next = self.template_lines.remove(row + 1); + self.template_lines[row].push_str(&next); + } + } + _ => {} + } + self.template_scroll_to_cursor(); + } + + fn save_template(&mut self) { + if self.template_path.is_empty() { + self.template_path = "/tmp/lading_template.yaml".to_string(); + } + let content = self.template_lines.join("\n"); + match std::fs::write(&self.template_path, content) { + Ok(()) => { + self.error = None; + self.template_just_saved = true; + } + Err(e) => { + self.error = Some(format!("Save failed: {e}")); + } + } + } + + fn template_scroll_to_cursor(&mut self) { + const VIEWPORT: usize = 28; // conservative estimate; actual height varies + if self.template_cursor_row < self.template_scroll { + self.template_scroll = self.template_cursor_row; + } else if self.template_cursor_row >= self.template_scroll + VIEWPORT { + self.template_scroll = self.template_cursor_row.saturating_sub(VIEWPORT - 1); + } + } + + fn handle_form_editing(&mut self, code: KeyCode) { + match code { + KeyCode::Char(c) => { + self.input.insert(self.input_cursor, c); + self.input_cursor += c.len_utf8(); + self.error = None; + } + KeyCode::Backspace => { + if self.input_cursor > 0 { + // Step back by the size of the preceding char. + let prev = self.input[..self.input_cursor] + .chars() + .next_back() + .map(|c| c.len_utf8()) + .unwrap_or(1); + self.input_cursor -= prev; + self.input.remove(self.input_cursor); + self.error = None; + } + } + KeyCode::Delete => { + if self.input_cursor < self.input.len() { + self.input.remove(self.input_cursor); + self.error = None; + } + } + KeyCode::Left => { + if self.input_cursor > 0 { + let prev = self.input[..self.input_cursor] + .chars() + .next_back() + .map(|c| c.len_utf8()) + .unwrap_or(1); + self.input_cursor -= prev; + } + } + KeyCode::Right => { + if self.input_cursor < self.input.len() { + let next = self.input[self.input_cursor..] + .chars() + .next() + .map(|c| c.len_utf8()) + .unwrap_or(1); + self.input_cursor += next; + } + } + KeyCode::Home => { + self.input_cursor = 0; + } + KeyCode::End => { + self.input_cursor = self.input.len(); + } + KeyCode::Enter => self.commit_edit(), + KeyCode::Esc => { + self.form_editing = false; + self.error = None; + } + _ => {} + } + } + + // ----------------------------------------------------------------------- + // Form helpers + // ----------------------------------------------------------------------- + + /// Ordered list of visible form rows based on current config state. + pub fn form_rows(&self) -> Vec { + let mut rows = vec![FormRow::ConfigPath, FormRow::GeneratorComponent]; + + if self.generator_expanded { + rows.push(FormRow::Variant); + // Variant-specific sub-rows appear immediately after Variant + match self.selected_variant() { + VariantKind::SplunkHec => rows.push(FormRow::SplunkHecEncoding), + VariantKind::Static | VariantKind::StaticChunks => rows.push(FormRow::StaticPath), + VariantKind::TemplatedJson => { + rows.push(FormRow::TemplatedJsonPath); + rows.push(FormRow::TemplateEditAction); + } + _ => {} + } + rows.push(FormRow::ConcurrentLogs); + rows.push(FormRow::MaxBytesPerLog); + rows.push(FormRow::TotalRotations); + rows.push(FormRow::MaxDepth); + rows.push(FormRow::MountPoint); + rows.push(FormRow::LoadProfile); + match self.load_profile_kind { + LoadProfileKind::Constant => rows.push(FormRow::ConstantRate), + LoadProfileKind::Linear => { + rows.push(FormRow::LinearInitial); + rows.push(FormRow::LinearRate); + } + } + rows.push(FormRow::MaxPrebuildCache); + rows.push(FormRow::MaxBlockSize); + } + + rows.push(FormRow::BlackholeComponent); + + if self.blackhole_expanded { + for i in 0..self.blackhole_entries.len() { + rows.push(FormRow::BlackholeEntry(i)); + } + rows.push(FormRow::BlackholeAddEntry); + } + + rows.push(FormRow::SavePath); + rows + } + + pub fn form_mode(&self) -> FormMode { + if self.variant_expanded { + FormMode::VariantSubMenu + } else if self.load_profile_expanded { + FormMode::LoadProfileSubMenu + } else if self.form_editing { + FormMode::Editing + } else { + FormMode::Navigate + } + } + + fn clamp_form_row(&mut self) { + let max = self.form_rows().len().saturating_sub(1); + self.form_row = self.form_row.min(max); + } + + fn begin_edit(&mut self) { + let rows = self.form_rows(); + let row = rows[self.form_row]; + self.input = match row { + FormRow::ConfigPath => self.save_path.clone(), + FormRow::ConcurrentLogs => self.concurrent_logs.to_string(), + FormRow::MaxBytesPerLog => self.max_bytes_per_log.clone(), + FormRow::TotalRotations => self.total_rotations.to_string(), + FormRow::MaxDepth => self.max_depth.to_string(), + FormRow::MountPoint => self.mount_point.clone(), + FormRow::ConstantRate => self.constant_rate.clone(), + FormRow::LinearInitial => self.linear_initial.clone(), + FormRow::LinearRate => self.linear_rate.clone(), + FormRow::MaxPrebuildCache => self.max_prebuild_cache.clone(), + FormRow::MaxBlockSize => self.max_block_size.clone(), + FormRow::StaticPath => self.static_path.clone(), + FormRow::TemplatedJsonPath => self.template_path.clone(), + FormRow::SavePath => self.save_path.clone(), + FormRow::BlackholeEntry(i) => { + if i < self.blackhole_entries.len() { + self.blackhole_entries[i].addr.clone() + } else { + String::new() + } + } + _ => String::new(), + }; + self.input_cursor = self.input.len(); + self.form_editing = true; + self.error = None; + } + + fn commit_edit(&mut self) { + let rows = self.form_rows(); + let row = rows[self.form_row]; + match row { + FormRow::ConfigPath => { + let path = self.input.trim().to_string(); + if path.is_empty() { + self.form_editing = false; + return; + } + // Try to auto-import the file at the new path + if let Ok(yaml) = std::fs::read_to_string(&path) { + if let Ok(fields) = parse_config(&yaml) { + self.apply_import(fields); + self.save_path = path.clone(); + self.preview_config_input = path; + self.form_editing = false; + return; + } + } + // Not a valid lading config — just update the path + self.save_path = path.clone(); + self.preview_config_input = path; + self.form_editing = false; + } + FormRow::ConcurrentLogs => match self.input.trim().parse::() { + Ok(v) if v > 0 => { + self.concurrent_logs = v; + self.dirty = true; + self.form_editing = false; + } + _ => self.error = Some("Must be a positive integer (1–65535)".into()), + }, + FormRow::MaxBytesPerLog => { + if self.input.trim().is_empty() { + self.error = Some("Cannot be empty — e.g. 100MiB".into()); + } else { + self.max_bytes_per_log = self.input.trim().into(); + self.dirty = true; + self.form_editing = false; + } + } + FormRow::TotalRotations => match self.input.trim().parse::() { + Ok(v) => { + self.total_rotations = v; + self.dirty = true; + self.form_editing = false; + } + _ => self.error = Some("Must be 0–255".into()), + }, + FormRow::MaxDepth => match self.input.trim().parse::() { + Ok(v) => { + self.max_depth = v; + self.dirty = true; + self.form_editing = false; + } + _ => self.error = Some("Must be 0–255".into()), + }, + FormRow::MountPoint => { + if self.input.trim().is_empty() { + self.error = Some("Path cannot be empty".into()); + } else { + self.mount_point = self.input.trim().into(); + self.dirty = true; + self.form_editing = false; + } + } + FormRow::ConstantRate => { + if self.input.trim().is_empty() { + self.error = Some("Rate cannot be empty — e.g. 1MiB".into()); + } else { + self.constant_rate = self.input.trim().into(); + self.dirty = true; + self.form_editing = false; + } + } + FormRow::LinearInitial => { + if self.input.trim().is_empty() { + self.error = Some("Initial rate cannot be empty — e.g. 1MiB".into()); + } else { + self.linear_initial = self.input.trim().into(); + self.dirty = true; + self.form_editing = false; + } + } + FormRow::LinearRate => { + if self.input.trim().is_empty() { + self.error = Some("Increment cannot be empty — e.g. 100KiB".into()); + } else { + self.linear_rate = self.input.trim().into(); + self.dirty = true; + self.form_editing = false; + } + } + FormRow::MaxPrebuildCache => { + if self.input.trim().is_empty() { + self.error = Some("Cannot be empty — e.g. 1GiB".into()); + } else { + self.max_prebuild_cache = self.input.trim().into(); + self.dirty = true; + self.form_editing = false; + } + } + FormRow::MaxBlockSize => { + if self.input.trim().is_empty() { + self.error = Some("Cannot be empty — e.g. 2MiB".into()); + } else { + self.max_block_size = self.input.trim().into(); + self.dirty = true; + self.form_editing = false; + } + } + FormRow::StaticPath => { + if self.input.trim().is_empty() { + self.error = Some("Path cannot be empty".into()); + } else { + self.static_path = self.input.trim().into(); + self.dirty = true; + self.form_editing = false; + } + } + FormRow::TemplatedJsonPath => { + if self.input.trim().is_empty() { + self.error = Some("Template path cannot be empty".into()); + } else { + self.template_path = self.input.trim().into(); + self.dirty = true; + self.form_editing = false; + } + } + FormRow::SavePath => { + let path = self.input.trim().to_string(); + if path.is_empty() { + self.error = Some("File path cannot be empty".into()); + return; + } + let yaml = self.current_yaml(); + match std::fs::write(&path, &yaml) { + Ok(()) => { + self.save_path = path.clone(); + self.preview_config_input = path; + self.saved = true; + self.dirty = false; + self.save_notification = Some(Instant::now()); + self.form_editing = false; + } + Err(e) => { + self.error = Some(format!("Write failed: {e}")); + } + } + } + FormRow::BlackholeEntry(i) => { + let addr = self.input.trim().to_string(); + if addr.is_empty() { + self.error = Some("Address cannot be empty (e.g. 127.0.0.1:9091)".into()); + } else { + if i < self.blackhole_entries.len() { + self.blackhole_entries[i].addr = addr; + } + self.dirty = true; + self.form_editing = false; + } + } + _ => { + self.form_editing = false; + } + } + } + + // ----------------------------------------------------------------------- + // Save (Ctrl+S fast path) + // ----------------------------------------------------------------------- + + fn do_save_to_path(&mut self) { + if self.save_path.is_empty() { + self.error = + Some("No save path set — navigate to Save Path row and press Enter".into()); + return; + } + let yaml = self.current_yaml(); + match std::fs::write(&self.save_path, &yaml) { + Ok(()) => { + self.preview_config_input = self.save_path.clone(); + self.saved = true; + self.dirty = false; + self.save_notification = Some(Instant::now()); + } + Err(e) => { + self.error = Some(format!("Write failed: {e}")); + } + } + } + + // ----------------------------------------------------------------------- + // Auto-import + // ----------------------------------------------------------------------- + + pub fn try_auto_import(&mut self) { + if self.auto_import_done || self.dirty { + return; + } + let path = self.save_path.clone(); + if path.is_empty() { + return; + } + let Ok(yaml) = std::fs::read_to_string(&path) else { + return; + }; + let Ok(fields) = parse_config(&yaml) else { + return; + }; + self.apply_import(fields); + self.auto_import_done = true; + self.preview_config_input = path; + } +} + +/// Unmount a (possibly stale) FUSE mount and remove the directory. +/// Called before creating the mount dir (startup) and after killing lading (stop). +/// All errors are silently ignored — this is best-effort cleanup. +fn cleanup_mount(path: &str) { + // Try both FUSE 2 and FUSE 3 tools; exactly one will be present on any given system. + let _ = std::process::Command::new("fusermount").args(["-u", path]).output(); + let _ = std::process::Command::new("fusermount3").args(["-u", path]).output(); + let _ = std::fs::remove_dir_all(path); +} + +/// Map an arbitrary mount path into /tmp so no root or pre-created dirs are needed. +/// `/smp-shared` → `/tmp/smp-shared`, `/tmp/logrotate` → `/tmp/logrotate` (unchanged). +fn remap_to_tmp(mount: &str) -> String { + let p = std::path::Path::new(mount); + if p.starts_with("/tmp") { + return mount.to_string(); + } + let name = p + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("lading_mount"); + format!("/tmp/{name}") +} + +/// Return a copy of the YAML with the logrotate_fs mount_point replaced. +fn patch_mount_point(yaml: &str, new_mount: &str) -> String { + let Ok(mut root) = serde_yaml::from_str::(yaml) else { + return yaml.to_string(); + }; + if let Some(lfs) = root + .get_mut("generator") + .and_then(|v| v.get_mut(0)) + .and_then(|v| v.get_mut("file_gen")) + .and_then(|v| v.get_mut("logrotate_fs")) + { + lfs["mount_point"] = serde_yaml::Value::String(new_mount.to_string()); + } + serde_yaml::to_string(&root).unwrap_or_else(|_| yaml.to_string()) +} + +/// Return true if the config has a non-empty `blackhole:` section. +fn config_has_blackhole(yaml: &str) -> bool { + let Ok(root) = serde_yaml::from_str::(yaml) else { + return false; + }; + matches!( + root.get("blackhole"), + Some(serde_yaml::Value::Sequence(s)) if !s.is_empty() + ) +} + +/// Return true if path looks like a rotated log: name ends in `.log.N` where N is all digits. +fn is_rotated_log(path: &std::path::Path) -> bool { + let name = match path.file_name().and_then(|n| n.to_str()) { + Some(n) => n, + None => return false, + }; + // Find the last ".log." in the name + if let Some(pos) = name.rfind(".log.") { + let suffix = &name[pos + 5..]; // skip ".log." + !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit()) + } else { + false + } +} + +/// Return true if `rotated` is a rotated version of `active`. +/// e.g. active=`foo.log`, rotated=`foo.log.1` → true. +fn is_rotated_of(active: &std::path::Path, rotated: &std::path::Path) -> bool { + let Some(active_name) = active.file_name().and_then(|n| n.to_str()) else { + return false; + }; + let Some(rot_name) = rotated.file_name().and_then(|n| n.to_str()) else { + return false; + }; + // rotated name must start with "{active_name}." and the suffix must be all digits. + let prefix = format!("{active_name}."); + if let Some(suffix) = rot_name.strip_prefix(prefix.as_str()) { + !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit()) + } else { + false + } +} + +/// Return a default address for a new blackhole entry based on the last entry's port. +fn next_blackhole_addr(entries: &[BlackholeEntry]) -> String { + let port = entries + .last() + .and_then(|e| e.addr.rsplit(':').next()) + .and_then(|p| p.parse::().ok()) + .unwrap_or(9090); + format!("127.0.0.1:{}", port + 1) +} + +/// Extract the mount_point from a lading YAML config string. +fn extract_mount_point(yaml: &str) -> Option { + let root: serde_yaml::Value = serde_yaml::from_str(yaml).ok()?; + root.get("generator")? + .get(0)? + .get("file_gen")? + .get("logrotate_fs")? + .get("mount_point")? + .as_str() + .map(|s| s.to_string()) +} diff --git a/lading_tui/src/config.rs b/lading_tui/src/config.rs new file mode 100644 index 000000000..a968ed7ca --- /dev/null +++ b/lading_tui/src/config.rs @@ -0,0 +1,512 @@ +use serde::Serialize; +use serde_yaml::Value; + +use crate::variants::VariantKind; + +// --------------------------------------------------------------------------- +// Blackhole types +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum BlackholeKind { + Http, + Tcp, + Udp, +} + +impl BlackholeKind { + pub fn label(self) -> &'static str { + match self { + Self::Http => "http", + Self::Tcp => "tcp", + Self::Udp => "udp", + } + } + pub fn next(self) -> Self { + match self { + Self::Http => Self::Tcp, + Self::Tcp => Self::Udp, + Self::Udp => Self::Http, + } + } + pub fn prev(self) -> Self { + match self { + Self::Http => Self::Udp, + Self::Tcp => Self::Http, + Self::Udp => Self::Tcp, + } + } +} + +#[derive(Clone, Debug)] +pub struct BlackholeEntry { + pub kind: BlackholeKind, + pub addr: String, +} + +// --------------------------------------------------------------------------- +// Mirror types — match lading's YAML structure without importing private types +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +pub struct LadingConfig { + pub generator: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub blackhole: Vec, +} + +#[derive(Serialize)] +pub struct GeneratorEntry { + pub file_gen: FileGenEntry, +} + +#[derive(Serialize)] +pub struct FileGenEntry { + pub logrotate_fs: LogrotateFsConfig, +} + +#[derive(Serialize)] +pub struct LogrotateFsConfig { + pub seed: Vec, + pub concurrent_logs: u16, + pub maximum_bytes_per_log: String, + pub total_rotations: u8, + pub max_depth: u8, + pub variant: Value, + pub maximum_prebuild_cache_size_bytes: String, + pub maximum_block_size: String, + pub mount_point: String, + pub load_profile: Value, +} + +// --------------------------------------------------------------------------- +// Load profile kind +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum LoadProfileKind { + Constant, + Linear, +} + +// --------------------------------------------------------------------------- +// Value builders +// --------------------------------------------------------------------------- + +pub fn build_variant_value( + kind: VariantKind, + splunk_enc: usize, // 0 = text, 1 = json + static_path: &str, + template_path: &str, +) -> Value { + match kind { + VariantKind::Ascii => Value::String("ascii".into()), + VariantKind::Json => Value::String("json".into()), + VariantKind::ApacheCommon => Value::String("apache_common".into()), + VariantKind::Syslog5424 => Value::String("syslog5424".into()), + VariantKind::DatadogLog => Value::String("datadog_log".into()), + VariantKind::SplunkHec => { + let enc = if splunk_enc == 0 { "text" } else { "json" }; + let mut inner = serde_yaml::Mapping::new(); + inner.insert("encoding".into(), enc.into()); + let mut outer = serde_yaml::Mapping::new(); + outer.insert("splunk_hec".into(), Value::Mapping(inner)); + Value::Mapping(outer) + } + VariantKind::TemplatedJson => { + let mut inner = serde_yaml::Mapping::new(); + inner.insert("template_path".into(), template_path.into()); + let mut outer = serde_yaml::Mapping::new(); + outer.insert("templated_json".into(), Value::Mapping(inner)); + Value::Mapping(outer) + } + VariantKind::Static => { + let mut inner = serde_yaml::Mapping::new(); + inner.insert("static_path".into(), static_path.into()); + let mut outer = serde_yaml::Mapping::new(); + outer.insert("static".into(), Value::Mapping(inner)); + Value::Mapping(outer) + } + VariantKind::StaticChunks => { + let mut inner = serde_yaml::Mapping::new(); + inner.insert("static_path".into(), static_path.into()); + let mut outer = serde_yaml::Mapping::new(); + outer.insert("static_chunks".into(), Value::Mapping(inner)); + Value::Mapping(outer) + } + } +} + +pub fn build_load_profile_value( + kind: LoadProfileKind, + constant_rate: &str, + linear_initial: &str, + linear_rate: &str, +) -> Value { + let mut map = serde_yaml::Mapping::new(); + match kind { + LoadProfileKind::Constant => { + map.insert("constant".into(), constant_rate.into()); + } + LoadProfileKind::Linear => { + let mut inner = serde_yaml::Mapping::new(); + inner.insert("initial".into(), linear_initial.into()); + inner.insert("rate".into(), linear_rate.into()); + map.insert("linear".into(), Value::Mapping(inner)); + } + } + Value::Mapping(map) +} + +pub fn build_blackhole_entry_value(entry: &BlackholeEntry) -> Value { + let mut inner = serde_yaml::Mapping::new(); + inner.insert("binding_addr".into(), entry.addr.clone().into()); + let mut outer = serde_yaml::Mapping::new(); + outer.insert(entry.kind.label().into(), Value::Mapping(inner)); + Value::Mapping(outer) +} + +pub fn build_yaml( + seed: &[u8; 32], + concurrent_logs: u16, + max_bytes_per_log: &str, + total_rotations: u8, + max_depth: u8, + mount_point: &str, + max_prebuild_cache: &str, + max_block_size: &str, + variant: Value, + load_profile: Value, + blackhole_entries: &[BlackholeEntry], +) -> String { + let logrotate_fs = LogrotateFsConfig { + seed: seed.to_vec(), + concurrent_logs, + maximum_bytes_per_log: max_bytes_per_log.to_string(), + total_rotations, + max_depth, + variant, + maximum_prebuild_cache_size_bytes: max_prebuild_cache.to_string(), + maximum_block_size: max_block_size.to_string(), + mount_point: mount_point.to_string(), + load_profile, + }; + let blackhole: Vec = blackhole_entries + .iter() + .map(build_blackhole_entry_value) + .collect(); + let config = LadingConfig { + generator: vec![GeneratorEntry { + file_gen: FileGenEntry { logrotate_fs }, + }], + blackhole, + }; + let raw = serde_yaml::to_string(&config).unwrap_or_else(|e| format!("# YAML error: {e}")); + // serde_yaml serialises Vec as a block sequence by default; rewrite to flow style. + seed_to_flow_style(&raw, seed) +} + +// --------------------------------------------------------------------------- +// Import — parse a lading YAML back into editable fields +// --------------------------------------------------------------------------- + +pub struct ImportedFields { + pub seed: Option<[u8; 32]>, + pub concurrent_logs: Option, + pub max_bytes_per_log: Option, + pub total_rotations: Option, + pub max_depth: Option, + pub mount_point: Option, + pub variant_kind: Option, + pub splunk_enc: Option, // 0 = text, 1 = json + pub static_path: Option, + pub template_path: Option, + pub load_profile_kind: Option, + pub constant_rate: Option, + pub linear_initial: Option, + pub linear_rate: Option, + pub max_prebuild_cache: Option, + pub max_block_size: Option, + pub blackhole_entries: Option>, +} + +/// Parse a lading YAML string into `ImportedFields`. +/// Missing fields produce `None`; parse errors return `Err(description)`. +pub fn parse_config(yaml: &str) -> Result { + let root: Value = serde_yaml::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?; + + // Navigate: generator[0].file_gen.logrotate_fs + let lfs = root + .get("generator") + .and_then(|v| v.get(0)) + .and_then(|v| v.get("file_gen")) + .and_then(|v| v.get("logrotate_fs")) + .ok_or_else(|| "Missing generator[0].file_gen.logrotate_fs".to_string())?; + + // seed: either a flow sequence [n, …] or block sequence + let seed = lfs.get("seed").and_then(|v| { + let items: Vec = match v { + Value::Sequence(seq) => seq.iter().filter_map(|x| x.as_u64()).collect(), + _ => return None, + }; + if items.len() == 32 { + let mut arr = [0u8; 32]; + for (i, &val) in items.iter().enumerate() { + arr[i] = val as u8; + } + Some(arr) + } else { + None + } + }); + + let concurrent_logs = lfs + .get("concurrent_logs") + .and_then(|v| v.as_u64()) + .and_then(|n| u16::try_from(n).ok()); + + let max_bytes_per_log = lfs + .get("maximum_bytes_per_log") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let total_rotations = lfs + .get("total_rotations") + .and_then(|v| v.as_u64()) + .and_then(|n| u8::try_from(n).ok()); + + let max_depth = lfs + .get("max_depth") + .and_then(|v| v.as_u64()) + .and_then(|n| u8::try_from(n).ok()); + + let mount_point = lfs + .get("mount_point") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let max_prebuild_cache = lfs + .get("maximum_prebuild_cache_size_bytes") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let max_block_size = lfs + .get("maximum_block_size") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + // variant: either a bare string or a mapping {splunk_hec: {encoding}, static: {static_path}, …} + let variant_val = lfs.get("variant"); + let mut variant_kind: Option = None; + let mut splunk_enc: Option = None; + let mut static_path: Option = None; + let mut template_path: Option = None; + + if let Some(v) = variant_val { + match v { + Value::String(s) => { + variant_kind = match s.as_str() { + "ascii" => Some(VariantKind::Ascii), + "json" => Some(VariantKind::Json), + "apache_common" => Some(VariantKind::ApacheCommon), + "syslog5424" => Some(VariantKind::Syslog5424), + "datadog_log" => Some(VariantKind::DatadogLog), + _ => None, + }; + } + Value::Mapping(m) => { + if let Some(inner) = m.get("splunk_hec") { + variant_kind = Some(VariantKind::SplunkHec); + if let Some(enc) = inner.get("encoding").and_then(|e| e.as_str()) { + splunk_enc = Some(if enc == "text" { 0 } else { 1 }); + } + } else if let Some(inner) = m.get("static") { + variant_kind = Some(VariantKind::Static); + static_path = inner + .get("static_path") + .and_then(|p| p.as_str()) + .map(|s| s.to_string()); + } else if let Some(inner) = m.get("static_chunks") { + variant_kind = Some(VariantKind::StaticChunks); + static_path = inner + .get("static_path") + .and_then(|p| p.as_str()) + .map(|s| s.to_string()); + } else if let Some(inner) = m.get("templated_json") { + variant_kind = Some(VariantKind::TemplatedJson); + template_path = inner + .get("template_path") + .and_then(|p| p.as_str()) + .map(|s| s.to_string()); + } + } + _ => {} + } + } + + // load_profile: {constant: "1MiB"} or {linear: {initial: "1MiB", rate: "100KiB"}} + let lp_val = lfs.get("load_profile"); + let mut load_profile_kind: Option = None; + let mut constant_rate: Option = None; + let mut linear_initial: Option = None; + let mut linear_rate: Option = None; + + if let Some(Value::Mapping(m)) = lp_val { + if let Some(rate) = m.get("constant") { + load_profile_kind = Some(LoadProfileKind::Constant); + constant_rate = rate.as_str().map(|s| s.to_string()); + } else if let Some(inner) = m.get("linear") { + load_profile_kind = Some(LoadProfileKind::Linear); + linear_initial = inner + .get("initial") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + linear_rate = inner + .get("rate") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + } + } + + // blackhole: sequence of {http: {binding_addr}}, {tcp: {binding_addr}}, … + let blackhole_entries = root + .get("blackhole") + .and_then(|v| v.as_sequence()) + .map(|seq| { + seq.iter() + .filter_map(|item| { + let m = item.as_mapping()?; + let (key, val) = m.iter().next()?; + let kind = match key.as_str()? { + "http" => BlackholeKind::Http, + "tcp" => BlackholeKind::Tcp, + "udp" => BlackholeKind::Udp, + _ => return None, + }; + let addr = val + .get("binding_addr") + .and_then(|v| v.as_str()) + .unwrap_or("127.0.0.1:9091") + .to_string(); + Some(BlackholeEntry { kind, addr }) + }) + .collect::>() + }); + + Ok(ImportedFields { + seed, + concurrent_logs, + max_bytes_per_log, + total_rotations, + max_depth, + mount_point, + variant_kind, + splunk_enc, + static_path, + template_path, + load_profile_kind, + constant_rate, + linear_initial, + linear_rate, + max_prebuild_cache, + max_block_size, + blackhole_entries, + }) +} + +/// Replace the first block-sequence `seed:` field with a flow-style array. +/// +/// serde_yaml produces: +/// ```yaml +/// seed: +/// - 2 +/// - 3 +/// ... +/// ``` +/// We want: +/// ```yaml +/// seed: [2, 3, ...] +/// ``` +fn seed_to_flow_style(yaml: &str, seed: &[u8; 32]) -> String { + let flow = format!( + "[{}]", + seed.iter() + .map(|b| b.to_string()) + .collect::>() + .join(", ") + ); + + let mut out = String::with_capacity(yaml.len() + 128); + let mut iter = yaml.lines().peekable(); + + while let Some(line) = iter.next() { + // Match a line whose non-whitespace content is exactly "seed:" + let trimmed = line.trim_start(); + if trimmed == "seed:" { + // Check that the next line looks like a block sequence item + let next_is_item = iter + .peek() + .map(|l| l.trim_start().starts_with("- ")) + .unwrap_or(false); + + if next_is_item { + let indent = &line[..line.len() - trimmed.len()]; + out.push_str(indent); + out.push_str("seed: "); + out.push_str(&flow); + out.push('\n'); + // Consume all "- N" item lines + while let Some(&peek) = iter.peek() { + if peek.trim_start().starts_with("- ") { + iter.next(); + } else { + break; + } + } + continue; + } + } + out.push_str(line); + out.push('\n'); + } + out +} + +/// Rewrite a `seed:` block-sequence in arbitrary YAML to a single-line flow sequence, +/// reading the values directly from the YAML rather than requiring the seed to be passed. +/// Used when displaying a user-supplied config (where we may not have the seed in memory). +/// A seed already on one line (flow style) is left unchanged. +pub fn compact_seed_in_yaml(yaml: &str) -> String { + let mut out = String::with_capacity(yaml.len()); + let mut iter = yaml.lines().peekable(); + + while let Some(line) = iter.next() { + let trimmed = line.trim_start(); + if trimmed == "seed:" { + let next_is_item = iter + .peek() + .map(|l| l.trim_start().starts_with("- ")) + .unwrap_or(false); + if next_is_item { + let indent = &line[..line.len() - trimmed.len()]; + let mut items: Vec = Vec::new(); + while let Some(&peek) = iter.peek() { + if let Some(val) = peek.trim_start().strip_prefix("- ") { + items.push(val.to_string()); + iter.next(); + } else { + break; + } + } + out.push_str(indent); + out.push_str("seed: ["); + out.push_str(&items.join(", ")); + out.push_str("]\n"); + continue; + } + } + out.push_str(line); + out.push('\n'); + } + out +} diff --git a/lading_tui/src/main.rs b/lading_tui/src/main.rs new file mode 100644 index 000000000..a15fcec71 --- /dev/null +++ b/lading_tui/src/main.rs @@ -0,0 +1,123 @@ +mod app; +mod config; +mod ui; +mod variants; + +use std::{io, time::Duration}; + +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind}, + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +use ratatui::{Terminal, backend::CrosstermBackend}; + +use app::App; + +const HELP: &str = "\ +lading_tui — interactive TUI for building and running lading configs + +USAGE: + lading_tui [OPTIONS] + +OPTIONS: + -c, --config Load a lading YAML config on startup and prefill + all form fields from it. The path is also used as + the default save destination. + + -h, --help Print this help message and exit. + +EXAMPLES: + lading_tui + lading_tui --config examples/lading-custom-30-30-40.yaml + lading_tui -c /tmp/my_config.yaml + +KEYS (Build tab): + ↑ ↓ Navigate form rows + Enter Edit field / expand section / confirm + Esc Cancel edit + ← → Cycle option (blackhole type, etc.) + d Delete entry (blackhole rows) + Ctrl+S Save config to the current Config Path + r Re-generate random seed + i Import a config from a file path + Tab Switch to Preview tab + q Quit + +KEYS (Preview tab): + r / Enter Build lading and start a run (10 min max) + q Stop run / quit + ↑ ↓ Scroll log content + ← → Switch between log files + [ ] Step back/forward through snapshots (after stop) + Tab Switch to Build tab +"; + +fn main() -> Result<(), Box> { + let args: Vec = std::env::args().collect(); + + // Handle --help / -h before touching the terminal + if args.iter().any(|a| a == "-h" || a == "--help") { + print!("{HELP}"); + return Ok(()); + } + + // --config or -c + let config_path = args.windows(2).find_map(|w| { + if w[0] == "--config" || w[0] == "-c" { + Some(w[1].clone()) + } else { + None + } + }); + + // --- set up terminal --- + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let mut app = App::new(config_path); + let result = run(&mut terminal, &mut app); + + // --- restore terminal --- + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + + if let Err(err) = result { + eprintln!("Error: {err:?}"); + } + + if app.saved { + println!("Config saved to: {}", app.save_path); + } + + Ok(()) +} + +fn run(terminal: &mut Terminal, app: &mut App) -> io::Result<()> { + loop { + terminal.draw(|f| ui::draw(f, app))?; + + if event::poll(Duration::from_millis(100))? { + if let Event::Key(key) = event::read()? { + // Filter out key-release / repeat events (important on some platforms) + if key.kind == KeyEventKind::Press { + app.handle_key(key.code, key.modifiers); + } + } + } + + app.tick(); + + if app.quit { + return Ok(()); + } + } +} diff --git a/lading_tui/src/ui.rs b/lading_tui/src/ui.rs new file mode 100644 index 000000000..feed19f5b --- /dev/null +++ b/lading_tui/src/ui.rs @@ -0,0 +1,1635 @@ +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{ + Block, Borders, Clear, List, ListItem, ListState, Paragraph, Scrollbar, + ScrollbarOrientation, ScrollbarState, Tabs, Wrap, + }, +}; + +use std::path::PathBuf; + +use crate::app::{App, FormMode, FormRow, ImportMode, PreviewState}; +use crate::config::LoadProfileKind; +use crate::variants::{ALL_VARIANTS, variant_meta}; + +// --------------------------------------------------------------------------- +// Top-level draw +// --------------------------------------------------------------------------- + +pub fn draw(frame: &mut Frame, app: &App) { + let area = frame.area(); + + // Vertical: tab-bar / content / status-bar + let root = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // tab bar + Constraint::Min(0), // content + Constraint::Length(1), // status bar + ]) + .split(area); + + draw_tab_bar(frame, app, root[0]); + draw_content(frame, app, root[1]); + draw_status(frame, app, root[2]); + + // Import overlay drawn on top of everything + if app.import_mode != ImportMode::Inactive { + draw_import_overlay(frame, app, area); + } +} + +// --------------------------------------------------------------------------- +// Tab bar +// --------------------------------------------------------------------------- + +fn draw_tab_bar(frame: &mut Frame, app: &App, area: Rect) { + let titles: Vec = vec![Line::from(" Build "), Line::from(" Preview ")]; + let tabs = Tabs::new(titles) + .select(app.tab) + .style(Style::default().fg(Color::DarkGray)) + .highlight_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .block(Block::default().borders(Borders::ALL)); + frame.render_widget(tabs, area); +} + +// --------------------------------------------------------------------------- +// Content area +// --------------------------------------------------------------------------- + +fn draw_content(frame: &mut Frame, app: &App, area: Rect) { + if app.template_editor_open { + draw_template_editor(frame, app, area); + return; + } + if app.tab == 1 { + draw_preview(frame, app, area); + } else { + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(40), Constraint::Percentage(60)]) + .split(area); + draw_left(frame, app, cols[0]); + draw_right(frame, app, cols[1]); + } +} + +// --------------------------------------------------------------------------- +// Template editor (full content area, shown when template_editor_open) +// --------------------------------------------------------------------------- + +fn draw_template_editor(frame: &mut Frame, app: &App, area: Rect) { + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(65), Constraint::Percentage(35)]) + .split(area); + + draw_template_editor_text(frame, app, cols[0]); + draw_template_reference(frame, cols[1]); +} + +fn draw_template_editor_text(frame: &mut Frame, app: &App, area: Rect) { + let inner_area = Block::default() + .borders(Borders::ALL) + .title(" Template Editor (Ctrl+S save Esc close) ") + .inner(area); + + let viewport_h = inner_area.height as usize; + let line_num_w = 4usize; // "NNN " — enough for ~999 lines + + let mut lines: Vec = Vec::with_capacity(viewport_h); + for row_idx in app.template_scroll..(app.template_scroll + viewport_h) { + let line_content = app + .template_lines + .get(row_idx) + .map(|s| s.as_str()) + .unwrap_or(""); + let num_span = Span::styled( + format!("{:>width$} ", row_idx + 1, width = line_num_w - 1), + Style::default().fg(Color::DarkGray), + ); + + if row_idx == app.template_cursor_row { + let col = app.template_cursor_col.min(line_content.len()); + let before = &line_content[..col]; + let cursor_ch = line_content[col..] + .chars() + .next() + .map(|c| c.to_string()) + .unwrap_or_else(|| " ".to_string()); + let after = if col < line_content.len() { + &line_content[col + cursor_ch.len()..] + } else { + "" + }; + lines.push(Line::from(vec![ + num_span, + Span::raw(before.to_string()), + Span::styled( + cursor_ch, + Style::default().bg(Color::Yellow).fg(Color::Black), + ), + Span::raw(after.to_string()), + ])); + } else { + lines.push(Line::from(vec![ + num_span, + Span::raw(line_content.to_string()), + ])); + } + } + + // Save-path indicator below editor + let title_suffix = if app.template_just_saved { + format!(" → {} ✓ Saved", app.template_path) + } else if app.template_path.is_empty() { + " (Ctrl+S to save to /tmp/lading_template.yaml)".to_string() + } else { + format!(" → {}", app.template_path) + }; + + let title_style = if app.template_just_saved { + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + + let block = Block::default() + .borders(Borders::ALL) + .title(format!(" Template Editor{title_suffix} ")) + .title_style(title_style); + + let para = Paragraph::new(lines).block(block); + frame.render_widget(para, area); + + // Error line at the very bottom of the editor if any + if let Some(err) = &app.error { + let err_area = Rect { + x: area.x + 1, + y: area.y + area.height.saturating_sub(2), + width: area.width.saturating_sub(2), + height: 1, + }; + let err_para = Paragraph::new(Span::styled( + format!("⚠ {err}"), + Style::default().fg(Color::Red), + )); + frame.render_widget(err_para, err_area); + } +} + +fn draw_template_reference(frame: &mut Frame, area: Rect) { + let text = "\ + Quick Reference + + !const value + Fixed JSON literal. + !const \"text\" + !const 42 / true / null + + !choose [a, b, c] + Pick uniformly at random. + + !range {min: N, max: N} + Random integer (inclusive). + + !weighted + - weight: 75 + value: !const \"INFO\" + - weight: 25 + value: !const \"WARN\" + + !format + template: \"{} took {}ms\" + args: [!var svc, !range {..}] + + !object + field: generator + ... + + !array + length: {min: 0, max: 5} + element: generator + + !timestamp + RFC-3339 UTC, advancing. + + !with + bind: {x: generator} + in: uses !var x + + !reference name + Reuse from definitions: + + definitions: + name: generator + + !concat [gen1, gen2] + Merge same-typed results. + + Tip: Tab inserts 2 spaces. +"; + let lines: Vec = text + .lines() + .map(|l| { + Line::from(Span::styled( + l.to_string(), + Style::default().fg(Color::DarkGray), + )) + }) + .collect(); + let para = Paragraph::new(lines) + .block(Block::default().borders(Borders::ALL).title(" Reference ")) + .wrap(Wrap { trim: false }); + frame.render_widget(para, area); +} + +// --------------------------------------------------------------------------- +// Build tab — Left panel (form) +// --------------------------------------------------------------------------- + +const LABEL_W: usize = 16; // fixed label column width (chars) + +fn draw_left(frame: &mut Frame, app: &App, area: Rect) { + let rows = app.form_rows(); + let (items, highlight_idx) = build_form_list_items(app, &rows); + + // Reserve 2 lines for error display if needed + let (list_area, error_area) = if app.error.is_some() { + let split = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(3), Constraint::Length(2)]) + .split(area); + (split[0], Some(split[1])) + } else { + (area, None) + }; + + let mut state = ListState::default(); + state.select(Some(highlight_idx)); + + let list = List::new(items).block(Block::default().borders(Borders::ALL).title(" Build ")); + frame.render_stateful_widget(list, list_area, &mut state); + + if let (Some(err_area), Some(err)) = (error_area, &app.error) { + let err_para = Paragraph::new(Line::from(Span::styled( + format!(" ⚠ {err}"), + Style::default().fg(Color::Red), + ))); + frame.render_widget(err_para, err_area); + } +} + +/// Build the list items for the form and return the rendered-item index to highlight. +fn build_form_list_items(app: &App, rows: &[FormRow]) -> (Vec>, usize) { + let mut items: Vec> = Vec::new(); + let mut highlight_idx = 0usize; + let mut item_idx = 0usize; + + for (ri, &row) in rows.iter().enumerate() { + let focused = ri == app.form_row; + let editing = app.form_editing && focused; + + match row { + FormRow::Variant => { + // Header row + let arrow = if app.variant_expanded { "▼" } else { "▶" }; + let label = variant_meta(app.selected_variant()).label; + let label_str = format!(" {arrow} {: { + let arrow = if app.load_profile_expanded { + "▼" + } else { + "▶" + }; + let lp_label = match app.load_profile_kind { + LoadProfileKind::Constant => "constant", + LoadProfileKind::Linear => "linear", + }; + let label_str = + format!(" {arrow} {: { + let arrow = if app.generator_expanded { "▼" } else { "▶" }; + let label_str = format!(" {arrow} {: { + let arrow = if app.blackhole_expanded { "▼" } else { "▶" }; + let label_str = format!(" {arrow} {: "none".to_string(), + n => format!("{n} entr{}", if n == 1 { "y" } else { "ies" }), + }; + let row_style = if focused { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().add_modifier(Modifier::BOLD) + }; + items.push(ListItem::new(Line::from(vec![ + Span::styled(label_str, row_style), + Span::styled(count_str, Style::default().fg(Color::DarkGray)), + ]))); + if focused { + highlight_idx = item_idx; + } + item_idx += 1; + } + + FormRow::BlackholeEntry(i) => { + let entry = &app.blackhole_entries[i]; + let kind_str = format!(" {:<8}", entry.kind.label()); + let kind_style = if focused { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + let spans = if editing { + let addr = &app.input; + let col = app.input_cursor.min(addr.len()); + let before = &addr[..col]; + let cursor_ch = addr[col..].chars().next().map(|c| c.to_string()).unwrap_or_else(|| " ".to_string()); + let after = if col < addr.len() { &addr[col + cursor_ch.len()..] } else { "" }; + vec![ + Span::styled(kind_str, kind_style), + Span::styled(before.to_string(), Style::default().add_modifier(Modifier::BOLD)), + Span::styled(cursor_ch, Style::default().bg(Color::Yellow).fg(Color::Black)), + Span::styled(after.to_string(), Style::default().add_modifier(Modifier::BOLD)), + ] + } else { + vec![ + Span::styled(kind_str, kind_style), + Span::styled(entry.addr.clone(), Style::default().fg(Color::Cyan)), + ] + }; + items.push(ListItem::new(Line::from(spans))); + if focused { + highlight_idx = item_idx; + } + item_idx += 1; + } + + FormRow::TemplateEditAction => { + let path_hint = if app.template_path.is_empty() { + " (no path set)".to_string() + } else { + format!(" {}", app.template_path) + }; + let style = if focused { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + items.push(ListItem::new(Line::from(vec![ + Span::styled(" Edit template", style), + Span::styled(path_hint, Style::default().fg(Color::DarkGray)), + ]))); + if focused { + highlight_idx = item_idx; + } + item_idx += 1; + } + + FormRow::BlackholeAddEntry => { + let style = if focused { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + items.push(ListItem::new(Line::from(Span::styled( + " + Add entry", + style, + )))); + if focused { + highlight_idx = item_idx; + } + item_idx += 1; + } + + _ => { + // Text / toggle rows + let (label, value) = form_row_display(app, row); + let label_str = format!(" {label: (&'static str, String) { + let editing = app.form_editing && { + let rows = app.form_rows(); + rows.get(app.form_row).copied() == Some(row) + }; + let value = if editing { + app.input.clone() + } else { + match row { + FormRow::ConfigPath => app.save_path.clone(), + FormRow::ConcurrentLogs => app.concurrent_logs.to_string(), + FormRow::MaxBytesPerLog => app.max_bytes_per_log.clone(), + FormRow::TotalRotations => app.total_rotations.to_string(), + FormRow::MaxDepth => app.max_depth.to_string(), + FormRow::MountPoint => app.mount_point.clone(), + FormRow::ConstantRate => app.constant_rate.clone(), + FormRow::LinearInitial => app.linear_initial.clone(), + FormRow::LinearRate => app.linear_rate.clone(), + FormRow::MaxPrebuildCache => app.max_prebuild_cache.clone(), + FormRow::MaxBlockSize => app.max_block_size.clone(), + FormRow::SplunkHecEncoding => { + if app.splunk_enc_cursor == 0 { + "text".into() + } else { + "json".into() + } + } + FormRow::StaticPath => app.static_path.clone(), + FormRow::TemplatedJsonPath => app.template_path.clone(), + FormRow::SavePath => app.save_path.clone(), + _ => String::new(), + } + }; + let label = match row { + FormRow::ConfigPath => "Config Path", + FormRow::ConcurrentLogs => "Concurrent", + FormRow::MaxBytesPerLog => "Max Bytes", + FormRow::TotalRotations => "Rotations", + FormRow::MaxDepth => "Max Depth", + FormRow::MountPoint => "Mount", + FormRow::ConstantRate => "Rate", + FormRow::LinearInitial => "Initial", + FormRow::LinearRate => "Rate/step", + FormRow::MaxPrebuildCache => "Prebuild", + FormRow::MaxBlockSize => "Block Size", + FormRow::SplunkHecEncoding => "Encoding", + FormRow::StaticPath => "File Path", + FormRow::TemplatedJsonPath => "Template", + FormRow::SavePath => "Save Path", + _ => "", + }; + (label, value) +} + +// --------------------------------------------------------------------------- +// Build tab — Right panel +// --------------------------------------------------------------------------- + +fn draw_right(frame: &mut Frame, app: &App, area: Rect) { + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(area); + draw_form_guidance(frame, app, rows[0]); + draw_yaml_preview(frame, app, rows[1]); +} + +fn draw_form_guidance(frame: &mut Frame, app: &App, area: Rect) { + let text = form_row_guidance(app); + let lines: Vec = text.lines().map(|l| Line::from(format!(" {l}"))).collect(); + let para = Paragraph::new(lines) + .block(Block::default().borders(Borders::ALL).title(" Guidance ")) + .wrap(Wrap { trim: false }); + frame.render_widget(para, area); +} + +fn form_row_guidance(app: &App) -> String { + let rows = app.form_rows(); + let row = rows.get(app.form_row).copied(); + match row { + Some(FormRow::ConfigPath) => "Path to a saved lading YAML config file.\n\ + \n\ + Press Enter to edit the path. If the file\n\ + at that path is a valid lading config, all\n\ + fields will be populated automatically.\n\ + \n\ + Ctrl+S saves the current config to this path." + .into(), + Some(FormRow::Variant) if !app.variant_expanded => { + // Show overview of all variants + let mut text = "Select the log payload format.\n\ + Press Enter to open the variant list.\n\ + \n\ + Available variants:\n" + .to_string(); + for &vk in ALL_VARIANTS { + let m = variant_meta(vk); + let first = m.description.lines().next().unwrap_or(""); + text.push_str(&format!("\n {} — {}", m.label, first)); + } + text + } + Some(FormRow::Variant) => { + // Sub-menu open: show details for the currently highlighted variant + let meta = variant_meta(ALL_VARIANTS[app.variant_sub_cursor]); + format!( + "{}\n\ + \n\ + Example:\n {}", + meta.description, meta.example_line + ) + } + Some(FormRow::LoadProfile) if !app.load_profile_expanded => { + "constant — emit at a fixed bytes/s rate\n\ + linear — start slow and ramp up each second\n\ + \n\ + Press Enter to change." + .into() + } + Some(FormRow::LoadProfile) => match app.load_profile_sub_cursor { + 0 => "constant: emit at a steady bytes/s rate\nthroughout the entire test run.".into(), + _ => "linear: start at the Initial rate and add\nRate/step each second.".into(), + }, + Some(FormRow::ConcurrentLogs) => "How many log files to write simultaneously.\n\ + Higher values stress the target's file handling\n\ + but increase lading's CPU and memory usage.\n\ + \n\ + Default: 8" + .into(), + Some(FormRow::MaxBytesPerLog) => "Maximum size of each log file before it is rotated.\n\ + \n\ + Accepted units: KiB MiB GiB\n\ + Examples: 100MiB 1GiB 512KiB\n\ + \n\ + Default: 100MiB" + .into(), + Some(FormRow::TotalRotations) => "How many times each log file is rotated (archived)\n\ + before being deleted.\n\ + \n\ + High counts create more files and test rotation\n\ + handling in the target.\n\ + \n\ + Default: 4" + .into(), + Some(FormRow::MaxDepth) => "Directory depth for log files below the mount point.\n\ + \n\ + 0 = flat: all log files in the root directory.\n\ + 1 = one level of subdirectories, etc.\n\ + \n\ + Default: 0" + .into(), + Some(FormRow::MountPoint) => "Where the FUSE filesystem is mounted.\n\ + \n\ + The path will be remapped under /tmp if it\n\ + is outside /tmp (no root required).\n\ + \n\ + Default: /tmp/logrotate" + .into(), + Some(FormRow::ConstantRate) => "Bytes generated per second (held constant).\n\ + \n\ + Use a byte string: 1MiB 500KiB 10MiB\n\ + \n\ + Default: 1MiB" + .into(), + Some(FormRow::LinearInitial) => "Starting rate for linear load growth.\n\ + \n\ + The generator begins at this rate and increases\n\ + by the Rate/step amount each second.\n\ + \n\ + Default: 1MiB" + .into(), + Some(FormRow::LinearRate) => "How much to increase the rate each second.\n\ + \n\ + Added to the current rate every second.\n\ + \n\ + Default: 100KiB" + .into(), + Some(FormRow::MaxPrebuildCache) => "Maximum size of the pre-built payload cache.\n\ + \n\ + Lading pre-generates payloads to reduce CPU\n\ + overhead during the test. Larger cache means\n\ + more payload variety at higher memory cost.\n\ + \n\ + Default: 1GiB" + .into(), + Some(FormRow::MaxBlockSize) => "Maximum size of a single pre-built payload block.\n\ + \n\ + Smaller blocks → finer granularity.\n\ + Larger blocks → less overhead.\n\ + Should be ≤ Max Bytes.\n\ + \n\ + Default: 2MiB" + .into(), + Some(FormRow::SplunkHecEncoding) => "Encoding for Splunk HEC event payloads.\n\ + \n\ + text — raw string in the event field\n\ + json — structured JSON in the event field\n\ + \n\ + Press Enter to toggle.\n\ + \n\ + Default: json" + .into(), + Some(FormRow::StaticPath) => "Path to the static content file.\n\ + \n\ + The file is read once at startup and its\n\ + content is streamed into the log files.\n\ + \n\ + For static_chunks the file is split by\n\ + lines to fill blocks up to Max Block Size." + .into(), + Some(FormRow::TemplatedJsonPath) => "Path to the YAML template file.\n\ + \n\ + The template defines the JSON schema and\n\ + value distributions for generated records.\n\ + \n\ + See lading_payload docs for template format." + .into(), + Some(FormRow::SavePath) => "Output YAML file path.\n\ + \n\ + Press Enter to write the config file.\n\ + An existing file will be overwritten.\n\ + \n\ + Ctrl+S saves to this path at any time." + .into(), + Some(FormRow::TemplateEditAction) => "Opens the template editor for this file.\n\ + \n\ + Build the template YAML directly in the TUI.\n\ + Ctrl+S saves it to the Template Path above.\n\ + Esc returns to the form.\n\ + \n\ + If the path file exists it will be loaded;\n\ + otherwise the default starter template is used.\n\ + \n\ + See the right panel for a tag quick-reference." + .into(), + Some(FormRow::GeneratorComponent) => "The generator produces log files using the\n\ + logrotate_fs FUSE filesystem.\n\ + \n\ + Press Enter to expand/collapse settings." + .into(), + Some(FormRow::BlackholeComponent) => { + "Blackhole servers absorb traffic generated by lading.\n\ + Each entry is a server that lading starts and\n\ + listens for connections.\n\ + \n\ + Press Enter to expand/collapse.\n\ + \n\ + Use http for most log shippers.\n\ + Use tcp/udp for raw socket targets." + .into() + } + Some(FormRow::BlackholeEntry(_)) => "← → change type (http / tcp / udp)\n\ + Enter edit binding address\n\ + d delete this entry\n\ + \n\ + Use http for most log shippers.\n\ + Use tcp/udp for raw socket targets.\n\ + \n\ + Default address: 127.0.0.1:9091" + .into(), + Some(FormRow::BlackholeAddEntry) => "Press Enter to add a new HTTP blackhole entry.\n\ + \n\ + The address is auto-assigned with the next\n\ + available port after the last entry." + .into(), + _ => String::new(), + } +} + +fn draw_yaml_preview(frame: &mut Frame, app: &App, area: Rect) { + let yaml = app.current_yaml(); + let para = Paragraph::new(yaml) + .block( + Block::default() + .borders(Borders::ALL) + .title(" YAML Preview "), + ) + .style(Style::default().fg(Color::Cyan)) + .wrap(Wrap { trim: false }); + frame.render_widget(para, area); +} + +// --------------------------------------------------------------------------- +// Preview tab +// --------------------------------------------------------------------------- + +fn draw_preview(frame: &mut Frame, app: &App, area: Rect) { + match &app.preview_state { + PreviewState::Idle => draw_preview_idle(frame, app, area), + PreviewState::Building => draw_preview_building(frame, app, area), + PreviewState::Running => draw_preview_running(frame, app, area), + PreviewState::Failed(msg) => draw_preview_failed(frame, app, area, msg.clone()), + PreviewState::Stopped => draw_preview_stopped(frame, app, area), + } +} + +fn preview_cols(area: Rect) -> (Rect, Rect) { + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(35), Constraint::Percentage(65)]) + .split(area); + (cols[0], cols[1]) +} + +fn draw_preview_idle(frame: &mut Frame, app: &App, area: Rect) { + let (left, right) = preview_cols(area); + + // Left: config path input + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(0)]) + .split(left); + + let display = Line::from(vec![ + Span::styled( + app.preview_config_input.clone(), + Style::default().add_modifier(Modifier::BOLD), + ), + Span::styled("█", Style::default().add_modifier(Modifier::RAPID_BLINK)), + ]); + let input_para = Paragraph::new(display).block( + Block::default() + .borders(Borders::ALL) + .title(" Config Path "), + ); + frame.render_widget(input_para, rows[0]); + + let mut info_lines: Vec = vec![ + Line::from(Span::styled( + " r / Enter run lading", + Style::default().fg(Color::Yellow), + )), + Line::from(""), + Line::from(Span::styled( + " i import config into Build tab", + Style::default().fg(Color::DarkGray), + )), + Line::from(Span::styled( + " Tab switch to Build tab", + Style::default().fg(Color::DarkGray), + )), + ]; + if let Some(err) = &app.preview_config_error { + info_lines.push(Line::from("")); + info_lines.push(Line::from(Span::styled( + format!(" ⚠ {err}"), + Style::default().fg(Color::Red), + ))); + } + if app.dirty { + info_lines.push(Line::from("")); + info_lines.push(Line::from(Span::styled( + " ⚠ unsaved changes in Build tab", + Style::default().fg(Color::Yellow), + ))); + } + let info_para = Paragraph::new(info_lines) + .block(Block::default().borders(Borders::ALL)) + .wrap(Wrap { trim: false }); + frame.render_widget(info_para, rows[1]); + + // Right: instructions + let right_lines = vec![ + Line::from(""), + Line::from(Span::styled( + " Enter a saved YAML config path and", + Style::default().fg(Color::DarkGray), + )), + Line::from(Span::styled( + " press r or Enter to run.", + Style::default().fg(Color::DarkGray), + )), + Line::from(""), + Line::from(Span::styled( + " lading will be built from source", + Style::default().fg(Color::DarkGray), + )), + Line::from(Span::styled( + " (cargo build -p lading --features logrotate_fs)", + Style::default().fg(Color::DarkGray), + )), + Line::from(""), + Line::from(Span::styled( + " Press i to import a config into the", + Style::default().fg(Color::DarkGray), + )), + Line::from(Span::styled( + " Build tab for editing.", + Style::default().fg(Color::DarkGray), + )), + ]; + let right_para = Paragraph::new(right_lines) + .block(Block::default().borders(Borders::ALL).title(" Preview ")) + .wrap(Wrap { trim: false }); + frame.render_widget(right_para, right); +} + +fn draw_preview_building(frame: &mut Frame, app: &App, area: Rect) { + let (left, right) = preview_cols(area); + + let left_lines = vec![ + Line::from(""), + Line::from(Span::styled( + " Building lading...", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::styled( + " Once built, lading will pre-generate a block cache", + Style::default().fg(Color::DarkGray), + )), + Line::from(Span::styled( + " before writing any log files. This initial cache", + Style::default().fg(Color::DarkGray), + )), + Line::from(Span::styled( + " build may take a minute — log output will appear", + Style::default().fg(Color::DarkGray), + )), + Line::from(Span::styled( + " once the cache is ready.", + Style::default().fg(Color::DarkGray), + )), + Line::from(""), + Line::from(Span::styled( + " q cancel", + Style::default().fg(Color::DarkGray), + )), + ]; + let left_para = Paragraph::new(left_lines) + .block(Block::default().borders(Borders::ALL).title(" Build ")) + .wrap(Wrap { trim: false }); + frame.render_widget(left_para, left); + + // Right: streaming build output (last N lines) + let log = &app.preview_build_log; + let lines_vec: Vec<&str> = log.lines().collect(); + let start = lines_vec.len().saturating_sub(100); + let display: Vec = lines_vec[start..] + .iter() + .map(|&l| Line::from(format!(" {l}"))) + .collect(); + let right_para = Paragraph::new(display) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Build Output "), + ) + .style(Style::default().fg(Color::DarkGray)) + .wrap(Wrap { trim: false }); + frame.render_widget(right_para, right); +} + +/// Appends a collapsible "▶/▼ Config" section to a left-panel line list. +fn append_config_panel(lines: &mut Vec, app: &App) { + let arrow = if app.preview_config_panel_expanded { + "▼" + } else { + "▶" + }; + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!(" {arrow} Config"), + Style::default().fg(Color::DarkGray), + ))); + if app.preview_config_panel_expanded { + for l in app.preview_config_yaml.lines() { + lines.push(Line::from(Span::styled( + format!(" {l}"), + Style::default().fg(Color::DarkGray), + ))); + } + } +} + +fn draw_preview_running(frame: &mut Frame, app: &App, area: Rect) { + let (left, right) = preview_cols(area); + + // Left: status info + let secs = app.preview_last_refresh.elapsed().as_secs(); + let file_count = app.preview_log_files.len(); + let selected_name = app + .preview_log_files + .get(app.preview_file_tab) + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()) + .unwrap_or("—"); + let selected_path = app + .preview_log_files + .get(app.preview_file_tab) + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "—".into()); + + let mut left_lines = vec![ + Line::from(""), + Line::from(vec![ + Span::styled(" Mount: ", Style::default().fg(Color::DarkGray)), + Span::styled( + app.preview_mount_point.clone(), + Style::default().add_modifier(Modifier::BOLD), + ), + ]), + Line::from(vec![ + Span::styled(" Files: ", Style::default().fg(Color::DarkGray)), + Span::styled( + file_count.to_string(), + Style::default().add_modifier(Modifier::BOLD), + ), + ]), + Line::from(vec![ + Span::styled(" Refresh: ", Style::default().fg(Color::DarkGray)), + Span::styled(format!("{secs}s ago"), Style::default().fg(Color::DarkGray)), + ]), + Line::from(""), + Line::from(vec![ + Span::styled(" File: ", Style::default().fg(Color::DarkGray)), + Span::styled( + selected_name.to_string(), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + ]), + Line::from(vec![ + Span::styled(" Path: ", Style::default().fg(Color::DarkGray)), + Span::styled(selected_path, Style::default().fg(Color::DarkGray)), + ]), + ]; + append_config_panel(&mut left_lines, app); + left_lines.extend([ + Line::from(""), + Line::from(Span::styled( + " ← → switch file (or rotated when e)", + Style::default().fg(Color::DarkGray), + )), + Line::from(Span::styled( + " e toggle rotated view", + Style::default().fg(Color::DarkGray), + )), + Line::from(Span::styled( + " c toggle config", + Style::default().fg(Color::DarkGray), + )), + Line::from(Span::styled( + " q stop lading", + Style::default().fg(Color::DarkGray), + )), + ]); + let left_para = Paragraph::new(left_lines) + .block(Block::default().borders(Borders::ALL).title(" Running ")) + .wrap(Wrap { trim: false }); + frame.render_widget(left_para, left); + + draw_file_tabs_panel(frame, app, right); +} + +fn draw_file_tabs_panel(frame: &mut Frame, app: &App, area: Rect) { + if app.preview_log_files.is_empty() { + let waiting_lines = vec![ + Line::from(""), + Line::from(Span::styled( + " Waiting for log files to appear...", + Style::default().fg(Color::DarkGray), + )), + Line::from(""), + Line::from(Span::styled( + " Lading is pre-generating its block cache.", + Style::default().fg(Color::DarkGray), + )), + Line::from(Span::styled( + " Log files will appear here once the cache is ready.", + Style::default().fg(Color::DarkGray), + )), + ]; + let waiting = Paragraph::new(waiting_lines) + .block(Block::default().borders(Borders::ALL).title(" Log Files ")) + .style(Style::default().fg(Color::DarkGray)); + frame.render_widget(waiting, area); + return; + } + + let cur_rotated: &[PathBuf] = app + .preview_rotated_files + .get(app.preview_file_tab) + .map(Vec::as_slice) + .unwrap_or(&[]); + let rotated_row_h = if app.preview_rotated_expanded && !cur_rotated.is_empty() { + 1u16 + } else { + 0u16 + }; + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // tab bar + Constraint::Length(rotated_row_h), // rotated file row (0 = hidden) + Constraint::Min(0), // file content + ]) + .split(area); + + // Tab bar — show ▶/▼ indicator when rotated files exist for a tab. + let tab_titles: Vec = app + .preview_log_files + .iter() + .enumerate() + .map(|(i, p)| { + let name = p + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("?") + .to_string(); + let rot_count = app + .preview_rotated_files + .get(i) + .map(Vec::len) + .unwrap_or(0); + let indicator = if rot_count == 0 { + String::new() + } else if i == app.preview_file_tab && app.preview_rotated_expanded { + format!(" ▼{rot_count}") + } else { + format!(" ▶{rot_count}") + }; + Line::from(format!(" {name}{indicator} ")) + }) + .collect(); + let file_tabs = Tabs::new(tab_titles) + .select(app.preview_file_tab) + .style(Style::default().fg(Color::DarkGray)) + .highlight_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .block(Block::default().borders(Borders::ALL)); + frame.render_widget(file_tabs, rows[0]); + + // Rotated file row (only when expanded and there are rotated files). + if rotated_row_h > 0 { + let rot_count = cur_rotated.len(); + let mut spans: Vec = vec![ + Span::styled( + format!(" {rot_count} rotated: "), + Style::default().fg(Color::DarkGray), + ), + ]; + for (i, rpath) in cur_rotated.iter().enumerate() { + let suffix = rpath + .file_name() + .and_then(|n| n.to_str()) + .and_then(|name| { + // Show only the .N suffix for brevity. + name.rfind(".log.").map(|pos| &name[pos + 5..]) + }) + .unwrap_or("?"); + let is_cur = i == app.preview_rotated_cursor; + let label = if is_cur { + format!("[.{suffix}]") + } else { + format!(" .{suffix} ") + }; + spans.push(Span::styled( + label, + if is_cur { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }, + )); + } + let rot_para = + Paragraph::new(Line::from(spans)).style(Style::default().fg(Color::DarkGray)); + frame.render_widget(rot_para, rows[1]); + } + + // File content — virtual scrolling: only collect the visible viewport. + let content = app.displayed_content(); + // Pass 1: count lines (O(n) scan, zero allocations) to compute scroll geometry. + let n_lines = content.lines().count(); + // rows[2] height minus top/bottom borders + let visible = rows[2].height.saturating_sub(2); + let max_scroll = n_lines.saturating_sub(visible as usize) as u32; + app.preview_max_scroll.set(max_scroll); + // Convert lines-from-bottom to lines-from-top + let scroll_top = max_scroll.saturating_sub(app.preview_content_scroll) as usize; + // Pass 2: collect only the visible window (~40 lines, not the full file). + let num_width = if app.preview_pretty_mode { + n_lines.max(1).to_string().len() + } else { + 0 + }; + let content_lines: Vec = content + .lines() + .skip(scroll_top as usize) + .take(visible as usize) + .enumerate() + .map(|(i, l)| { + if app.preview_pretty_mode { + let abs_line = scroll_top as usize + i + 1; + let num_span = Span::styled( + format!("{:>width$} ", abs_line, width = num_width), + Style::default().fg(Color::DarkGray), + ); + let bar_span = Span::styled("│ ", Style::default().fg(Color::DarkGray)); + let text_style = if i % 2 == 0 { + Style::default().fg(Color::Reset) + } else { + Style::default().fg(Color::Reset).add_modifier(Modifier::DIM) + }; + Line::from(vec![num_span, bar_span, Span::styled(l, text_style)]) + } else { + Line::from(format!(" {l}")) + } + }) + .collect(); + let total_bytes = app.displayed_total_bytes(); + let bytes_str = format_bytes(total_bytes); + // For Running state use the accumulated total_lines counter (can exceed the 2000-line + // rolling buffer, informing the user how many lines were generated in total). + // For all other states use n_lines — the actual line count of the displayed content — + // so it always matches the pretty-mode line numbers. + let header_lines = if app.preview_state == PreviewState::Running { + app.displayed_total_lines() + } else { + n_lines + }; + let content_title = if app.preview_viewing_rotated { + let rname = app + .current_rotated_path() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()) + .unwrap_or("rotated"); + format!(" {rname} {header_lines} lines {bytes_str} ") + } else if app.preview_content_scroll > 0 { + format!( + " {header_lines} lines {bytes_str} [↑ scrolled {}] ", + app.preview_content_scroll + ) + } else if app.preview_state == PreviewState::Running { + format!(" {header_lines} lines {bytes_str} [live tail – last 2000 lines] ") + } else { + format!(" {header_lines} lines {bytes_str} ") + }; + let content_para = Paragraph::new(content_lines) + .block(Block::default().borders(Borders::ALL).title(content_title)) + .style(if app.preview_pretty_mode { + Style::default() // per-line spans control color; don't force Cyan as base + } else { + Style::default().fg(Color::Cyan) + }); + // No .scroll() — viewport already applied via skip() above. + frame.render_widget(content_para, rows[2]); + + // Scrollbar overlay on the right edge of content + let mut scrollbar_state = ScrollbarState::new(max_scroll as usize) + .position(scroll_top as usize); + frame.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("↑")) + .end_symbol(Some("↓")), + rows[2], + &mut scrollbar_state, + ); + +} + +fn format_bytes(bytes: usize) -> String { + const KIB: usize = 1024; + const MIB: usize = 1024 * KIB; + const GIB: usize = 1024 * MIB; + if bytes >= GIB { + format!("{:.1} GiB", bytes as f64 / GIB as f64) + } else if bytes >= MIB { + format!("{:.1} MiB", bytes as f64 / MIB as f64) + } else if bytes >= KIB { + format!("{:.1} KiB", bytes as f64 / KIB as f64) + } else { + format!("{bytes} B") + } +} + + +fn draw_preview_failed(frame: &mut Frame, app: &App, area: Rect, msg: String) { + let (left, right) = preview_cols(area); + + let left_lines = vec![ + Line::from(""), + Line::from(Span::styled( + " Failed.", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::styled( + " r retry", + Style::default().fg(Color::DarkGray), + )), + Line::from(Span::styled( + " q quit", + Style::default().fg(Color::DarkGray), + )), + Line::from(Span::styled( + " Tab back to Build", + Style::default().fg(Color::DarkGray), + )), + ]; + let left_para = Paragraph::new(left_lines) + .block(Block::default().borders(Borders::ALL).title(" Error ")) + .wrap(Wrap { trim: false }); + frame.render_widget(left_para, left); + + let err_lines: Vec = msg + .lines() + .map(|l| { + Line::from(Span::styled( + format!(" {l}"), + Style::default().fg(Color::Red), + )) + }) + .collect(); + let build_log_lines: Vec = app + .preview_build_log + .lines() + .rev() + .take(50) + .collect::>() + .into_iter() + .rev() + .map(|l| Line::from(format!(" {l}"))) + .collect(); + let mut all_lines = err_lines; + if !all_lines.is_empty() && !build_log_lines.is_empty() { + all_lines.push(Line::from("")); + } + all_lines.extend(build_log_lines); + let right_para = Paragraph::new(all_lines) + .block(Block::default().borders(Borders::ALL).title(" Details ")) + .style(Style::default().fg(Color::DarkGray)) + .wrap(Wrap { trim: false }); + frame.render_widget(right_para, right); +} + +fn draw_preview_stopped(frame: &mut Frame, app: &App, area: Rect) { + let (left, right) = preview_cols(area); + + let mut left_lines = vec![ + Line::from(""), + Line::from(Span::styled( + " Stopped.", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )), + Line::from(""), + ]; + append_config_panel(&mut left_lines, app); + left_lines.extend([ + Line::from(""), + Line::from(Span::styled( + " e view rotated (← → to switch)", + Style::default().fg(Color::DarkGray), + )), + Line::from(Span::styled( + " c toggle config", + Style::default().fg(Color::DarkGray), + )), + Line::from(Span::styled( + if app.preview_pretty_mode { " p pretty mode (on)" } else { " p pretty mode" }, + Style::default().fg(Color::DarkGray), + )), + Line::from(Span::styled( + " ↑↓ scroll content", + Style::default().fg(Color::DarkGray), + )), + Line::from(""), + Line::from(Span::styled( + " r run again", + Style::default().fg(Color::DarkGray), + )), + Line::from(Span::styled( + " Tab back to Build", + Style::default().fg(Color::DarkGray), + )), + Line::from(Span::styled( + " q quit", + Style::default().fg(Color::DarkGray), + )), + ]); + let left_para = Paragraph::new(left_lines) + .block(Block::default().borders(Borders::ALL).title(" Stopped ")) + .wrap(Wrap { trim: false }); + frame.render_widget(left_para, left); + + // Right: file tabs + content + timeline (same widget as Running) + draw_file_tabs_panel(frame, app, right); +} + +// --------------------------------------------------------------------------- +// Import overlay +// --------------------------------------------------------------------------- + +fn draw_import_overlay(frame: &mut Frame, app: &App, area: Rect) { + // Center a 60x10 box + let height = 10_u16; + let width = area.width.min(70); + let x = area.x + (area.width.saturating_sub(width)) / 2; + let y = area.y + (area.height.saturating_sub(height)) / 2; + let overlay_area = Rect { + x, + y, + width, + height, + }; + + frame.render_widget(Clear, overlay_area); + + match app.import_mode { + ImportMode::EnterPath => { + let inner = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(0)]) + .margin(1) + .split(overlay_area); + + let display = Line::from(vec![ + Span::styled( + app.import_input.clone(), + Style::default().add_modifier(Modifier::BOLD), + ), + Span::styled("█", Style::default().add_modifier(Modifier::RAPID_BLINK)), + ]); + let input_para = Paragraph::new(display) + .block(Block::default().borders(Borders::ALL).title(" Path ")); + frame.render_widget(input_para, inner[0]); + + let mut hint_lines = vec![Line::from(Span::styled( + " Enter confirm Esc cancel", + Style::default().fg(Color::DarkGray), + ))]; + if let Some(err) = &app.import_error { + hint_lines.push(Line::from(Span::styled( + format!(" ⚠ {err}"), + Style::default().fg(Color::Red), + ))); + } + let hint_para = Paragraph::new(hint_lines).wrap(Wrap { trim: false }); + frame.render_widget(hint_para, inner[1]); + + let block = Block::default() + .borders(Borders::ALL) + .title(" Import Config ") + .style(Style::default().fg(Color::Yellow)); + frame.render_widget(block, overlay_area); + } + ImportMode::ConfirmUnsaved => { + let inner = Block::default() + .borders(Borders::ALL) + .title(" Unsaved Changes ") + .style(Style::default().fg(Color::Yellow)); + let inner_area = inner.inner(overlay_area); + frame.render_widget(inner, overlay_area); + + let lines = vec![ + Line::from(""), + Line::from(Span::styled( + " You have unsaved changes in the Build tab.", + Style::default().fg(Color::Yellow), + )), + Line::from(""), + Line::from(" Save them before importing?"), + Line::from(""), + Line::from(Span::styled( + " Enter / y save then import", + Style::default().fg(Color::DarkGray), + )), + Line::from(Span::styled( + " n discard and import", + Style::default().fg(Color::DarkGray), + )), + Line::from(Span::styled( + " Esc cancel", + Style::default().fg(Color::DarkGray), + )), + ]; + let para = Paragraph::new(lines).wrap(Wrap { trim: false }); + frame.render_widget(para, inner_area); + } + ImportMode::Inactive => {} + } +} + +// --------------------------------------------------------------------------- +// Status bar +// --------------------------------------------------------------------------- + +fn draw_status(frame: &mut Frame, app: &App, area: Rect) { + // Save notification takes priority + if let Some(_) = app.save_notification { + let msg = format!(" ✓ Config saved to {}", app.save_path); + let para = Paragraph::new(msg).style(Style::default().fg(Color::Green)); + frame.render_widget(para, area); + return; + } + + // Build a list of spans: optional state pill + hint text + let (pill, pill_style, hint): (&str, Style, &str) = if app.template_editor_open { + ("", Style::default(), " ↑↓←→ navigate Enter newline Backspace/Del edit Tab indent Ctrl+S save Esc close") + } else if app.import_mode != ImportMode::Inactive { + ("", Style::default(), " Enter confirm Esc cancel") + } else if app.tab == 1 { + match &app.preview_state { + PreviewState::Idle => ( + "", + Style::default(), + " Type config path r/Enter run (10 min) i import into Build Tab build tab q quit", + ), + PreviewState::Building => ( + " ● BUILDING ", + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + " q cancel build", + ), + PreviewState::Running => ( + " ● RUNNING ", + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + " ↑↓ scroll t/b top/bottom ← → switch file e toggle rotated (← → navigates) q stop", + ), + PreviewState::Stopped => ( + " ● STOPPED ", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + " ↑↓ scroll t/b top/bottom ← → switch file e toggle rotated (← → navigates) p pretty r run again q quit", + ), + PreviewState::Failed(_) => ( + " ● FAILED ", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + " r retry Tab build tab q quit", + ), + } + } else { + let rows = app.form_rows(); + let current_row = rows.get(app.form_row).copied(); + let hint = match (app.form_mode(), current_row) { + (FormMode::Navigate, Some(FormRow::BlackholeEntry(_))) => { + " ← → kind Enter edit addr d delete ↑↓ navigate ^S save q quit" + } + (FormMode::Navigate, _) => { + " ↑↓ navigate Enter edit/expand i import Tab preview r re-seed ^S save q quit" + } + (FormMode::VariantSubMenu, _) => " ↑↓ select variant Enter confirm Esc cancel", + (FormMode::LoadProfileSubMenu, _) => " ↑↓ select profile Enter confirm Esc cancel", + (FormMode::Editing, _) => " Type to edit Enter confirm Esc cancel", + }; + ("", Style::default(), hint) + }; + + let spans = if pill.is_empty() { + vec![Span::styled(hint, Style::default().fg(Color::DarkGray))] + } else { + vec![ + Span::styled(pill, pill_style), + Span::styled(hint, Style::default().fg(Color::DarkGray)), + ] + }; + let para = Paragraph::new(Line::from(spans)); + frame.render_widget(para, area); +} diff --git a/lading_tui/src/variants.rs b/lading_tui/src/variants.rs new file mode 100644 index 000000000..a522cb341 --- /dev/null +++ b/lading_tui/src/variants.rs @@ -0,0 +1,81 @@ +/// Every payload variant the wizard supports. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VariantKind { + Ascii, + Json, + ApacheCommon, + Syslog5424, + DatadogLog, + SplunkHec, + TemplatedJson, + Static, + StaticChunks, +} + +pub const ALL_VARIANTS: &[VariantKind] = &[ + VariantKind::Ascii, + VariantKind::Json, + VariantKind::ApacheCommon, + VariantKind::Syslog5424, + VariantKind::DatadogLog, + VariantKind::SplunkHec, + VariantKind::TemplatedJson, + VariantKind::Static, + VariantKind::StaticChunks, +]; + +pub struct VariantMeta { + pub label: &'static str, + pub description: &'static str, + pub example_line: &'static str, +} + +pub fn variant_meta(kind: VariantKind) -> VariantMeta { + match kind { + VariantKind::Ascii => VariantMeta { + label: "ascii", + description: "Generates lines of random printable ASCII characters (up to 6 KiB each).\nFast and simple — good default for throughput tests with minimal CPU overhead.", + example_line: "k8Qr2mX9pL3fB7nT4vW0jZ5cR1eY6uI8oP2dFgHsA...", + }, + VariantKind::Json => VariantMeta { + label: "json", + description: "Generates JSON-encoded log lines with numeric and array fields.\nUseful for testing JSON-aware consumers and parsers.", + example_line: r#"{"id":12345678,"name":98765432,"seed":4242,"byte_parade":[104,101,108]}"#, + }, + VariantKind::ApacheCommon => VariantMeta { + label: "apache_common", + description: "Generates Apache Common Log Format access log lines.\nRealistic HTTP access logs — ideal for testing log parsers and shippers.", + example_line: r#"192.168.1.42 - frank [21/Mar/2024:13:55:36 -0700] "GET /index.html HTTP/1.1" 200 1234"#, + }, + VariantKind::Syslog5424 => VariantMeta { + label: "syslog5424", + description: "Generates RFC 5424 structured syslog messages.\nStandard syslog format used by many Unix daemons and network devices.", + example_line: "<34>1 2024-03-21T13:55:36Z webserver myapp 1234 ID47 - Application started", + }, + VariantKind::DatadogLog => VariantMeta { + label: "datadog_log", + description: "Generates Datadog JSON log messages with standard agent fields.\nIdeal for testing the Datadog Logs intake pipeline end-to-end.", + example_line: r#"{"message":"conn ok","status":"info","timestamp":"...","hostname":"web-01","service":"api"}"#, + }, + VariantKind::SplunkHec => VariantMeta { + label: "splunk_hec", + description: "Generates Splunk HTTP Event Collector messages.\nRequires an encoding sub-field: text (raw event) or json (structured event object).", + example_line: r#"{"time":1710029736,"host":"web-01","source":"app","event":"request processed"}"#, + }, + VariantKind::TemplatedJson => VariantMeta { + label: "templated_json", + description: "Generates JSON records from a user-supplied YAML template file.\nFull control over schema and field value distributions.", + example_line: "(generated from your template file)", + }, + VariantKind::Static => VariantMeta { + label: "static", + description: "Streams content from a user-supplied static file.\nContent is read as-is — useful for replay of captured production logs.", + example_line: "(content streamed from: )", + }, + VariantKind::StaticChunks => VariantMeta { + label: "static_chunks", + description: "Streams line-by-line chunks from a user-supplied file.\nChunks lines to fill blocks up to maximum_block_size — more efficient for large files.", + example_line: "(line-by-line chunks from: )", + }, + } +}