From 17eb6a4fee9a1e9ce6d91a0ff56e62c6e1a76947 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Sun, 26 Jul 2026 00:45:53 +0300 Subject: [PATCH] Reworked statistics --- Cargo.lock | 2 +- src/app/event.rs | 4 + src/app/mod.rs | 75 +++---- src/app/popup.rs | 192 +++++++++++++++++- src/app/state.rs | 88 +++++++- src/app/update.rs | 7 +- src/ui/federation.rs | 472 +++++++++++++++++++++++++++---------------- src/ui/popup.rs | 286 ++++++++++++++++++++++++-- 8 files changed, 867 insertions(+), 259 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6fb8a5e..6577838 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1567,7 +1567,7 @@ dependencies = [ [[package]] name = "furumi_tui" -version = "0.1.5" +version = "0.1.6" dependencies = [ "anyhow", "blake3", diff --git a/src/app/event.rs b/src/app/event.rs index 3a51881..6ca33df 100644 --- a/src/app/event.rs +++ b/src/app/event.rs @@ -147,12 +147,16 @@ pub enum AppEvent { }, /// This peer's connection ticket, requested from the Federation tab. FedTicket(Result), + /// Immediate library publish finished. + FedSyncFinished(String), /// Fresh personal-device sync status snapshot for Settings. DeviceSyncStatus(crate::devices::DeviceSyncStatus), /// Invite link for pairing another device. DeviceInvite(Result), /// Result of `:connect frid://i/...`. DeviceConnectResult(Result), + /// Manual trusted-device sync finished. + DeviceSyncFinished(String), /// Incoming pairing request that passed the invite-secret check. DevicePairingRequest(crate::devices::PendingPairing), /// Trusted device playback state, delivered by personal-device sync. diff --git a/src/app/mod.rs b/src/app/mod.rs index e22bc2f..e832451 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -7,9 +7,9 @@ pub(crate) mod popup; pub mod state; pub mod update; -use std::io::{self, Write as _}; +use std::io; use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; +use std::process::Command; use std::sync::Arc; use std::time::Duration; @@ -1397,6 +1397,8 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) { } Effect::FedApplySettings => fed_apply_settings(state, runtime), Effect::FedSyncNow => { + state.federation.publishing = true; + state.status_message = Some("publishing library…".to_string()); let fed = Arc::clone(&runtime.federation); let tx = runtime.event_tx.clone(); tokio::spawn(async move { @@ -1405,7 +1407,7 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) { Err(err) => format!("federation sync failed: {err:#}"), }; let _ = tx.send(AppEvent::FederationStatus(fed.status().await)); - let _ = tx.send(AppEvent::StatusMessage(message)); + let _ = tx.send(AppEvent::FedSyncFinished(message)); }); } Effect::FedShowTicket => { @@ -1438,6 +1440,8 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) { } Effect::DeviceConnectInvite(invite) => device_connect(runtime, invite), Effect::DeviceSyncNow => { + state.federation.device_syncing = true; + state.status_message = Some("syncing devices…".to_string()); let fed = Arc::clone(&runtime.federation); let devices = Arc::clone(&runtime.devices); let tx = runtime.event_tx.clone(); @@ -1447,7 +1451,7 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) { Err(err) => format!("devices: {err:#}"), }; let _ = tx.send(AppEvent::DeviceSyncStatus(devices.status())); - let _ = tx.send(AppEvent::StatusMessage(message)); + let _ = tx.send(AppEvent::DeviceSyncFinished(message)); }); } Effect::DeviceSetName(name) => { @@ -1667,45 +1671,6 @@ fn shell_quote(path: &Path) -> String { format!("'{}'", value.replace('\'', "'\\''")) } -fn copy_text_to_clipboard(text: &str) -> bool { - let command: &[&str] = if cfg!(target_os = "macos") { - &["pbcopy"] - } else if cfg!(target_os = "windows") { - &["clip"] - } else { - &["wl-copy"] - }; - let Some((program, args)) = command.split_first() else { - return false; - }; - let mut child = match Command::new(program) - .args(args) - .stdin(Stdio::piped()) - .spawn() - { - Ok(child) => child, - Err(_) if !cfg!(target_os = "macos") && !cfg!(target_os = "windows") => { - match Command::new("xclip") - .args(["-selection", "clipboard"]) - .stdin(Stdio::piped()) - .spawn() - { - Ok(child) => child, - Err(_) => return false, - } - } - Err(_) => return false, - }; - let Some(mut stdin) = child.stdin.take() else { - return false; - }; - if stdin.write_all(text.as_bytes()).is_err() { - return false; - } - drop(stdin); - child.wait().is_ok_and(|status| status.success()) -} - /// Start playing `queue[queue_pos]`: open the local file in a background /// task and hand the reader to the audio thread. fn play_current(state: &mut AppState, runtime: &mut Runtime) { @@ -2826,18 +2791,22 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent } clamp_settings_cursor(state); } + AppEvent::FedSyncFinished(message) => { + state.federation.publishing = false; + state.status_message = Some(message); + } + AppEvent::DeviceSyncFinished(message) => { + state.federation.device_syncing = false; + state.status_message = Some(message); + } AppEvent::DeviceInvite(result) => match result { Ok(invite) => { - let copied = copy_text_to_clipboard(&invite); - state.popup = Some(state::Popup::FedText { + state.popup = Some(state::Popup::FedCopyText { title: "Device invite".to_string(), text: invite, + help: "Use this invite on another client within 10 minutes to pair it with this device group.".to_string(), }); - state.status_message = Some(if copied { - "device invite copied to clipboard".to_string() - } else { - "device invite generated".to_string() - }); + state.status_message = Some("device invite generated".to_string()); } Err(message) => state.status_message = Some(format!("device invite: {message}")), }, @@ -3105,9 +3074,11 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent } AppEvent::FedTicket(result) => match result { Ok(ticket) => { - state.popup = Some(state::Popup::FedText { - title: "Federation ticket (share with a peer)".to_string(), + state.popup = Some(state::Popup::FedCopyText { + title: "Connection ticket".to_string(), text: ticket, + help: "Copy this ticket and paste it into Connect to a peer on another client." + .to_string(), }); } Err(message) => state.status_message = Some(message), diff --git a/src/app/popup.rs b/src/app/popup.rs index 02a9d01..8bcf033 100644 --- a/src/app/popup.rs +++ b/src/app/popup.rs @@ -13,7 +13,7 @@ use crate::app::Runtime; use crate::app::event::AppEvent; use crate::app::state::{ self, AppState, DeleteTarget, DevicePresenceSection, EditField, EditTarget, FedInputField, - Loadable, Popup, addable_playlists, + FederationStatusPopupState, Loadable, Popup, StatusDetailFocus, addable_playlists, }; use crate::library::models::{ReleaseEdit, TrackEdit, TrackItem}; @@ -136,8 +136,40 @@ pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) { KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {} _ => state.popup = Some(Popup::FedText { title, text }), }, - Popup::FederationStatusDetails { scroll } => { - handle_federation_status_details(state, scroll, key); + Popup::FedCopyText { title, text, help } => match key.code { + KeyCode::Esc | KeyCode::Char('q') => {} + KeyCode::Enter | KeyCode::Char('c') => match copy_to_clipboard(&text) { + Ok(()) => state.status_message = Some("copied to clipboard".to_string()), + Err(err) => { + state.status_message = Some(format!("copy failed: {err}")); + state.popup = Some(Popup::FedCopyText { title, text, help }); + } + }, + _ => state.popup = Some(Popup::FedCopyText { title, text, help }), + }, + Popup::FederationStatusDetails { + focus, + status_cursor, + devices_scroll, + logs_scroll, + } => handle_federation_status_details( + state, + FederationStatusPopupState { + focus, + status_cursor, + devices_scroll, + logs_scroll, + }, + key, + ), + Popup::FederationStatusText { + parent, + title, + text, + scroll, + } => handle_federation_status_child(state, parent, title, text, scroll, key), + Popup::FederationStatusLog { parent, scroll } => { + handle_federation_status_log(state, parent, scroll, key); } Popup::DevicePairing { request_id, @@ -166,20 +198,162 @@ pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) { } } -fn handle_federation_status_details(state: &mut AppState, scroll: usize, key: KeyEvent) { +fn handle_federation_status_details( + state: &mut AppState, + mut parent: FederationStatusPopupState, + key: KeyEvent, +) { + let action_count = status_detail_action_count(state); + match key.code { + KeyCode::Esc | KeyCode::Char('q') => return, + KeyCode::Left | KeyCode::Char('h') => parent.focus = parent.focus.previous(), + KeyCode::Right | KeyCode::Char('l') | KeyCode::Tab => parent.focus = parent.focus.next(), + KeyCode::Up | KeyCode::Char('k') => match parent.focus { + StatusDetailFocus::Status => { + parent.status_cursor = parent.status_cursor.saturating_sub(1) + } + StatusDetailFocus::Devices => { + parent.devices_scroll = parent.devices_scroll.saturating_sub(1) + } + StatusDetailFocus::Logs => { + if action_count > 0 { + parent.focus = StatusDetailFocus::Status; + parent.status_cursor = action_count - 1; + } + } + }, + KeyCode::Down | KeyCode::Char('j') => match parent.focus { + StatusDetailFocus::Status => { + if parent.status_cursor + 1 < action_count { + parent.status_cursor += 1; + } else { + parent.focus = StatusDetailFocus::Logs; + } + } + StatusDetailFocus::Devices => { + parent.devices_scroll = parent.devices_scroll.saturating_add(1) + } + StatusDetailFocus::Logs => {} + }, + KeyCode::PageUp => match parent.focus { + StatusDetailFocus::Status => parent.status_cursor = 0, + StatusDetailFocus::Devices => { + parent.devices_scroll = parent.devices_scroll.saturating_sub(8) + } + StatusDetailFocus::Logs => {} + }, + KeyCode::PageDown => match parent.focus { + StatusDetailFocus::Status => { + parent.status_cursor = action_count.saturating_sub(1); + parent.focus = StatusDetailFocus::Logs; + } + StatusDetailFocus::Devices => { + parent.devices_scroll = parent.devices_scroll.saturating_add(8) + } + StatusDetailFocus::Logs => {} + }, + KeyCode::Enter if parent.focus == StatusDetailFocus::Status => { + if let Some((title, text)) = status_detail_action_text(state, parent.status_cursor) { + state.popup = Some(Popup::FederationStatusText { + parent, + title, + text, + scroll: 0, + }); + return; + } + } + KeyCode::Enter if parent.focus == StatusDetailFocus::Logs => { + state.popup = Some(Popup::FederationStatusLog { parent, scroll: 0 }); + return; + } + _ => {} + } + parent.status_cursor = parent.status_cursor.min(action_count.saturating_sub(1)); + state.popup = Some(parent.into()); +} + +fn handle_federation_status_child( + state: &mut AppState, + parent: FederationStatusPopupState, + title: String, + text: String, + scroll: usize, + key: KeyEvent, +) { let next_scroll = match key.code { - KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => return, + KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => { + state.popup = Some(parent.into()); + return; + } KeyCode::Up | KeyCode::Char('k') => scroll.saturating_sub(1), - KeyCode::Down | KeyCode::Char('j') => scroll + 1, - KeyCode::PageUp => scroll.saturating_sub(8), - KeyCode::PageDown => scroll + 8, + KeyCode::Down | KeyCode::Char('j') => scroll.saturating_add(1), + KeyCode::PageUp => scroll.saturating_sub(10), + KeyCode::PageDown => scroll.saturating_add(10), _ => scroll, }; - state.popup = Some(Popup::FederationStatusDetails { + state.popup = Some(Popup::FederationStatusText { + parent, + title, + text, scroll: next_scroll, }); } +fn handle_federation_status_log( + state: &mut AppState, + parent: FederationStatusPopupState, + scroll: usize, + key: KeyEvent, +) { + let next_scroll = match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + state.popup = Some(parent.into()); + return; + } + KeyCode::Up | KeyCode::Char('k') => scroll.saturating_sub(1), + KeyCode::Down | KeyCode::Char('j') => scroll.saturating_add(1), + KeyCode::PageUp => scroll.saturating_sub(10), + KeyCode::PageDown => scroll.saturating_add(10), + _ => scroll, + }; + state.popup = Some(Popup::FederationStatusLog { + parent, + scroll: next_scroll, + }); +} + +fn status_detail_action_count(state: &AppState) -> usize { + state + .federation + .status + .as_ref() + .filter(|status| status.running) + .map(|status| 1 + usize::from(!status.connected_peers.is_empty())) + .unwrap_or(0) +} + +fn status_detail_action_text(state: &AppState, cursor: usize) -> Option<(String, String)> { + let status = state + .federation + .status + .as_ref() + .filter(|status| status.running)?; + match cursor { + 0 => Some(( + "Endpoint IDs".to_string(), + format!( + "Endpoint ID\n{}\n\nDHT node ID\n{}", + status.endpoint_id, status.dht_node_id + ), + )), + 1 if !status.connected_peers.is_empty() => { + Some(("Peer IDs".to_string(), status.connected_peers.join("\n"))) + } + _ => None, + } +} + fn handle_connected_devices( state: &mut AppState, runtime: &mut Runtime, diff --git a/src/app/state.rs b/src/app/state.rs index 09f3ec1..bc95b9f 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -711,6 +711,12 @@ pub enum Popup { }, /// Wrapped read-only text (this peer's connection ticket). FedText { title: String, text: String }, + /// Wrapped text that can be copied to the system clipboard. + FedCopyText { + title: String, + text: String, + help: String, + }, /// Incoming trusted-device pairing request. DevicePairing { request_id: String, @@ -725,7 +731,68 @@ pub enum Popup { /// Connected playback devices and their current role/status. ConnectedDevices { cursor: usize }, /// Full federation, transport and device status details. - FederationStatusDetails { scroll: usize }, + FederationStatusDetails { + focus: StatusDetailFocus, + status_cursor: usize, + devices_scroll: usize, + logs_scroll: usize, + }, + /// Full text opened from the full federation status dashboard. + FederationStatusText { + parent: FederationStatusPopupState, + title: String, + text: String, + scroll: usize, + }, + /// Full connection log opened from the full federation status dashboard. + FederationStatusLog { + parent: FederationStatusPopupState, + scroll: usize, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FederationStatusPopupState { + pub focus: StatusDetailFocus, + pub status_cursor: usize, + pub devices_scroll: usize, + pub logs_scroll: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StatusDetailFocus { + Status, + Devices, + Logs, +} + +impl From for Popup { + fn from(value: FederationStatusPopupState) -> Self { + Popup::FederationStatusDetails { + focus: value.focus, + status_cursor: value.status_cursor, + devices_scroll: value.devices_scroll, + logs_scroll: value.logs_scroll, + } + } +} + +impl StatusDetailFocus { + pub fn next(self) -> Self { + match self { + StatusDetailFocus::Status => StatusDetailFocus::Devices, + StatusDetailFocus::Devices => StatusDetailFocus::Logs, + StatusDetailFocus::Logs => StatusDetailFocus::Status, + } + } + + pub fn previous(self) -> Self { + match self { + StatusDetailFocus::Status => StatusDetailFocus::Logs, + StatusDetailFocus::Devices => StatusDetailFocus::Status, + StatusDetailFocus::Logs => StatusDetailFocus::Devices, + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -745,6 +812,23 @@ impl FedInputField { FedInputField::ConnectInvite => "Connect device (paste frid://i invite)", } } + + pub fn help(self) -> &'static str { + match self { + FedInputField::NetworkId => { + "A unique network id. It must match exactly on every client that should see and connect to the same peers." + } + FedInputField::ConnectTicket => { + "Paste a connection ticket generated by another client to connect to that peer directly." + } + FedInputField::DeviceName => { + "A friendly nickname for this device, used only to make connected-device management easier." + } + FedInputField::ConnectInvite => { + "Paste a frid:// invite generated by another client to add this device to its sync group." + } + } + } } /// Rows of the federation block inside Settings, in display order. @@ -944,6 +1028,8 @@ pub struct FederationTab { pub settings: crate::federation::FedSettings, pub status: Option, pub devices: Option, + pub publishing: bool, + pub device_syncing: bool, } /// Playlists eligible as add-targets (the virtual Likes playlist is managed diff --git a/src/app/update.rs b/src/app/update.rs index e59b875..09ea523 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -2579,7 +2579,12 @@ fn federation_select(state: &mut AppState) -> Option { None } SettingsRow::StatusDetails => { - state.popup = Some(Popup::FederationStatusDetails { scroll: 0 }); + state.popup = Some(Popup::FederationStatusDetails { + focus: super::state::StatusDetailFocus::Status, + status_cursor: 0, + devices_scroll: 0, + logs_scroll: 0, + }); None } SettingsRow::DeviceName => { diff --git a/src/ui/federation.rs b/src/ui/federation.rs index 168bc2c..45022b4 100644 --- a/src/ui/federation.rs +++ b/src/ui/federation.rs @@ -73,7 +73,7 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) { let (label, value) = match row { FedRow::Toggle => ("Federation", on_off(settings.enabled).to_string()), FedRow::NetworkId => ( - "Network ID (shared secret)", + "Network ID", if settings.network_id.is_empty() { "(not set — press enter)".to_string() } else { @@ -84,7 +84,14 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) { "Save federated tracks to the library on listen", on_off(settings.save_on_listen).to_string(), ), - FedRow::SyncNow => ("Publish the library now", "↵".to_string()), + FedRow::SyncNow => ( + "Publish the library now", + if state.federation.publishing { + format!("{} publishing", state.spinner()) + } else { + "↵".to_string() + }, + ), FedRow::ShowTicket => ("Show my connection ticket", "↵".to_string()), FedRow::Connect => ("Connect to a peer by ticket…", "↵".to_string()), }; @@ -160,7 +167,11 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) { state.settings_cursor, "Sync devices now", if connected_devices_enabled { - "↵".to_string() + if state.federation.device_syncing { + format!("{} syncing", state.spinner()) + } else { + "↵".to_string() + } } else { disabled_value.clone() }, @@ -428,107 +439,6 @@ fn short_id(id: &str) -> String { id.chars().take(12).collect::() + "…" } -fn push_transport_status(lines: &mut Vec>, status: &crate::federation::FedStatus) { - lines.push(Line::default()); - lines.push(Line::styled("Iroh Transport", theme::header())); - let transport = &status.transport; - if transport.total_samples == 0 { - lines.push(status_line("Streams", "no samples yet".to_string())); - return; - } - let runtime_total = transport - .runtime_tx_bytes - .saturating_add(transport.runtime_rx_bytes); - lines.push(status_line( - "Runtime traffic", - format!( - "{} · tx {} · rx {} · active {}", - short_bytes_label(runtime_total), - short_bytes_label(transport.runtime_tx_bytes), - short_bytes_label(transport.runtime_rx_bytes), - transport.active_streams - ), - )); - if transport.runtime_lost_packets > 0 || transport.runtime_lost_bytes > 0 { - lines.push(status_line( - "Runtime loss", - format!( - "{} pkts · {}", - transport.runtime_lost_packets, - short_bytes_label(transport.runtime_lost_bytes) - ), - )); - } - lines.push(status_line( - "Samples", - format!( - "{} total · direct {} · relay {} · custom {} · unknown {}", - transport.total_samples, - transport.direct_samples, - transport.relay_samples, - transport.custom_samples, - transport.unknown_samples - ), - )); - lines.push(status_line( - "Protocols", - format!( - "audio {} · catalog {} · sync {}", - transport.audio_samples, transport.catalog_samples, transport.sync_samples - ), - )); - if let Some(sample) = transport.last.first() { - lines.push(status_line( - "Last stream", - format!( - "{} {} {} · {} · {}", - sample.protocol, - sample.direction, - sample.phase, - sample.selected_path, - rtt_label(sample.selected_rtt_ms) - ), - )); - lines.push(status_line( - "Last peer", - format!( - "{} · paths d/r/c/open {}/{}/{}/{}", - sample.peer_id, - sample.direct_paths, - sample.relay_paths, - sample.custom_paths, - sample.open_paths - ), - )); - lines.push(status_line( - "Last bytes", - format!( - "sel {}/{} · total {}/{} · lost {} / {}", - short_bytes_label(sample.selected_tx_bytes), - short_bytes_label(sample.selected_rx_bytes), - short_bytes_label(sample.total_tx_bytes), - short_bytes_label(sample.total_rx_bytes), - sample.lost_packets, - short_bytes_label(sample.lost_bytes) - ), - )); - } - for sample in &transport.last { - lines.push(Line::from(vec![ - Span::styled(format!("{:<14}", sample.at), theme::dim()), - Span::raw(format!( - "{} {} {} · {} · tx {} rx {}", - sample.protocol, - sample.direction, - sample.phase, - sample.selected_path, - short_bytes_label(sample.total_tx_bytes), - short_bytes_label(sample.total_rx_bytes) - )), - ])); - } -} - fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) { if area.width == 0 || area.height == 0 { return; @@ -790,65 +700,91 @@ fn first_line(value: &str) -> String { value.lines().next().unwrap_or(value).to_string() } -pub(super) fn status_detail_lines(state: &AppState) -> Vec> { +pub(super) struct StatusDetailSections { + pub status: Vec>, + pub devices: Vec>, + pub logs: Vec>, +} + +pub(super) fn status_detail_sections( + state: &AppState, + status_cursor: usize, +) -> StatusDetailSections { + StatusDetailSections { + status: status_detail_status_lines(state, status_cursor), + devices: status_detail_device_lines(state), + logs: status_detail_transport_logs(state), + } +} + +fn status_detail_status_lines(state: &AppState, status_cursor: usize) -> Vec> { let mut lines: Vec = vec![Line::styled("Status", theme::header())]; match &state.federation.status { None => lines.push(Line::styled("loading…", theme::dim())), Some(status) if !status.running => { lines.push(status_line("Node", "stopped".to_string())); if let Some(error) = &status.last_error { - lines.push(status_line("Error", error.clone())); + lines.push(status_line("Error", first_line(error))); } lines.push(Line::default()); lines.push(Line::styled( - "Enable federation and set a network id — every instance using the", - theme::dim(), - )); - lines.push(Line::styled( - "same id (furumi TUI or furumi-fd) finds the others automatically.", + "Set the same Network ID on each client.", theme::dim(), )); } Some(status) => { lines.push(status_line("Node", format!("running · {}", status.network))); - lines.push(status_line( + lines.push(status_action_line( "Endpoint", - format!("{} · dht {}", status.endpoint_id, status.dht_node_id), - )); - let peers = if status.connected_peers.is_empty() { - format!("none · contacts {}", status.known_contacts) - } else { - let names: Vec = status.connected_peers.iter().map(String::clone).collect(); - let more = status.connected_peers.len().saturating_sub(names.len()); - let more = if more > 0 { - format!(" +{more}") - } else { - String::new() - }; format!( - "{} connected{} · contacts {} · {}", + "{} · dht {}", + short_id(&status.endpoint_id), + short_id(&status.dht_node_id) + ), + status_cursor == 0, + )); + lines.push(status_line( + "Peers", + format!( + "{} connected · {} contacts", status.connected_peers.len(), - more, - status.known_contacts, - names.join(", ") - ) - }; - lines.push(status_line("Peers", peers)); + status.known_contacts + ), + )); + if !status.connected_peers.is_empty() { + let mut peers: Vec = status + .connected_peers + .iter() + .take(4) + .map(|peer| short_id(peer)) + .collect(); + if status.connected_peers.len() > peers.len() { + peers.push(format!("+{}", status.connected_peers.len() - peers.len())); + } + lines.push(status_action_line( + "Peer IDs", + peers.join(", "), + status_cursor == 1, + )); + } lines.push(status_line( "DHT", format!( - "{} records · {} · {} published", + "{} records · {}", status .stored_dht_records .map(|count| count.to_string()) - .unwrap_or_else(|| "unavailable".to_string()), + .unwrap_or_else(|| "n/a".to_string()), status .stored_dht_bytes .map(short_bytes_label) - .unwrap_or_else(|| "unavailable".to_string()), - status.published_items + .unwrap_or_else(|| "n/a".to_string()) ), )); + lines.push(status_line( + "Published", + format!("{} items", status.published_items), + )); lines.push(status_line( "Last sync", status @@ -857,13 +793,148 @@ pub(super) fn status_detail_lines(state: &AppState) -> Vec> { .unwrap_or_else(|| "not yet".to_string()), )); if let Some(error) = &status.last_error { - lines.push(status_line("Error", error.clone())); + lines.push(status_line("Error", first_line(error))); } - push_transport_status(&mut lines, status); + push_transport_summary_status(&mut lines, status); } } + lines +} + +fn status_action_line(label: &str, value: String, selected: bool) -> Line<'static> { + let marker = if selected { "▶" } else { " " }; + Line::from(vec![ + Span::styled( + format!("{marker} {label:<16}"), + if selected { + theme::accent() + } else { + theme::dim() + }, + ), + Span::raw(value), + Span::styled(" ↵", theme::dim()), + ]) +} + +fn push_transport_summary_status( + lines: &mut Vec>, + status: &crate::federation::FedStatus, +) { lines.push(Line::default()); - lines.push(Line::styled("Connected Devices", theme::header())); + lines.push(Line::styled("Iroh Transport", theme::header())); + let transport = &status.transport; + let runtime_total = transport + .runtime_tx_bytes + .saturating_add(transport.runtime_rx_bytes); + lines.push(status_line( + "Traffic", + format!( + "{} · tx {} · rx {}", + short_bytes_label(runtime_total), + short_bytes_label(transport.runtime_tx_bytes), + short_bytes_label(transport.runtime_rx_bytes) + ), + )); + lines.push(status_line( + "Active", + format!("{} streams", transport.active_streams), + )); + if transport.runtime_lost_packets > 0 || transport.runtime_lost_bytes > 0 { + lines.push(status_line( + "Loss", + format!( + "{} pkts · {}", + transport.runtime_lost_packets, + short_bytes_label(transport.runtime_lost_bytes) + ), + )); + } + lines.push(status_line( + "Samples", + format!( + "{} total · {} direct · {} relay · {} unknown", + transport.total_samples, + transport.direct_samples, + transport.relay_samples, + transport.unknown_samples + ), + )); + lines.push(status_line( + "Protocols", + format!( + "audio {} · catalog {} · sync {}", + transport.audio_samples, transport.catalog_samples, transport.sync_samples + ), + )); + if let Some(sample) = transport.last.first() { + lines.push(status_line( + "Last stream", + format!( + "{} {} {} · {} · {}", + sample.protocol, + sample.direction, + sample.phase, + sample.selected_path, + rtt_label(sample.selected_rtt_ms) + ), + )); + lines.push(status_line( + "Last peer", + format!( + "{} · paths {}/{}/{}/{}", + short_id(&sample.peer_id), + sample.direct_paths, + sample.relay_paths, + sample.custom_paths, + sample.open_paths + ), + )); + lines.push(status_line( + "Last bytes", + format!( + "sel {}/{} · total {}/{} · lost {}", + short_bytes_label(sample.selected_tx_bytes), + short_bytes_label(sample.selected_rx_bytes), + short_bytes_label(sample.total_tx_bytes), + short_bytes_label(sample.total_rx_bytes), + short_bytes_label(sample.lost_bytes) + ), + )); + } +} + +pub(super) fn status_detail_transport_logs(state: &AppState) -> Vec> { + let Some(status) = &state.federation.status else { + return vec![Line::styled("transport status is loading", theme::dim())]; + }; + if status.transport.last.is_empty() { + return vec![Line::styled("no connection samples yet", theme::dim())]; + } + status + .transport + .last + .iter() + .map(|sample| { + Line::from(vec![ + Span::styled(format!("{:<12}", sample.at), theme::dim()), + Span::raw(format!( + "{} {} {} · {} · {} · tx {} rx {}", + sample.protocol, + sample.direction, + sample.phase, + sample.selected_path, + rtt_label(sample.selected_rtt_ms), + short_bytes_label(sample.total_tx_bytes), + short_bytes_label(sample.total_rx_bytes) + )), + ]) + }) + .collect() +} + +fn status_detail_device_lines(state: &AppState) -> Vec> { + let mut lines = vec![Line::styled("Connected Devices", theme::header())]; match &state.federation.devices { None => lines.push(Line::styled("loading…", theme::dim())), Some(status) => { @@ -882,30 +953,27 @@ pub(super) fn status_detail_lines(state: &AppState) -> Vec> { lines.push(status_line( "Sync log", format!( - "{} ops · {} outbox · {} tombstones ({} gc)", - status.ops_total, - status.outbox_ops, - status.tombstone_ops, - status.compactable_tombstones + "{} ops · {} outbox · {} tombstones", + status.ops_total, status.outbox_ops, status.tombstone_ops ), )); lines.push(status_line( "Snapshot", format!( - "{} likes, {} playlists, {} items", + "{} likes · {} playlists · {} items", status.snapshot_likes, status.snapshot_playlists, status.snapshot_items ), )); lines.push(status_line( - "Unresolved items", + "Unresolved", status.unresolved_playlist_items.to_string(), )); - lines.push(status_line("Peer ack floor", status.peer_ack_floor.clone())); + lines.push(status_line("Ack floor", status.peer_ack_floor.clone())); if let Some(last_sync) = &status.last_sync { - lines.push(status_line("Last device sync", last_sync.clone())); + lines.push(status_line("Last sync", last_sync.clone())); } if let Some(last_error) = &status.last_error { - lines.push(status_line("Device error", last_error.clone())); + lines.push(status_line("Error", first_line(last_error))); } lines.push(Line::default()); lines.push(Line::styled("Device List", theme::header())); @@ -914,14 +982,29 @@ pub(super) fn status_detail_lines(state: &AppState) -> Vec> { } else { let ordered = crate::app::state::device_status_order(state); let now = crate::app::state::unix_time_ms(); + let mut emitted = Vec::new(); for index in &ordered { if let Some(device) = status.devices.get(*index) { - push_device_detail(&mut lines, state, device, now); + push_device_detail_compact( + &mut lines, + state, + device, + now, + !emitted.is_empty(), + ); + emitted.push(*index); } } for (index, device) in status.devices.iter().enumerate() { - if !ordered.contains(&index) { - push_device_detail(&mut lines, state, device, now); + if !emitted.contains(&index) { + push_device_detail_compact( + &mut lines, + state, + device, + now, + !emitted.is_empty(), + ); + emitted.push(index); } } } @@ -930,47 +1013,80 @@ pub(super) fn status_detail_lines(state: &AppState) -> Vec> { lines } -fn push_device_detail( +fn push_device_detail_compact( lines: &mut Vec>, state: &AppState, device: &crate::devices::DeviceStatusRow, now_ms: i64, + separator: bool, ) { - let mut name = crate::app::state::device_display_name(device); - if device.is_self { - name.push_str(" (this device)"); + if separator { + lines.push(Line::styled( + "────────────────────────────────", + theme::dim(), + )); } - if device.revoked { - name.push_str(" (revoked)"); - } - let presence = match crate::app::state::device_presence_section(state, device, now_ms) { - DevicePresenceSection::Online => "online", - DevicePresenceSection::Offline => "offline", - DevicePresenceSection::Revoked => "revoked", + let presence = crate::app::state::device_presence_section(state, device, now_ms); + let icon = match presence { + DevicePresenceSection::Online => "●", + DevicePresenceSection::Offline => "○", + DevicePresenceSection::Revoked => "×", }; - lines.push(status_line("Device", name)); + let mut badges = Vec::new(); + if device.is_self { + badges.push("this"); + } + match presence { + DevicePresenceSection::Online => badges.push("online"), + DevicePresenceSection::Offline => badges.push("offline"), + DevicePresenceSection::Revoked => badges.push("revoked"), + } + let version = if device.client_version.trim().is_empty() { + "v?".to_string() + } else { + format!("v{}", device.client_version) + }; + lines.push(Line::from(vec![ + Span::styled(format!("{icon} "), theme::accent()), + Span::raw(crate::app::state::device_display_name(device)), + Span::styled( + format!(" · {} · {}", version, badges.join(", ")), + theme::dim(), + ), + ])); lines.push(status_line("Device ID", device.device_id.clone())); lines.push(status_line( - "Endpoint ID", + "Endpoint", if device.endpoint_id.trim().is_empty() { "unavailable".to_string() } else { - device.endpoint_id.clone() + short_id(&device.endpoint_id) }, )); lines.push(status_line( - "Version", - if device.client_version.trim().is_empty() { - "unknown".to_string() - } else { - device.client_version.clone() - }, - )); - lines.push(status_line( - "Presence", - match device.last_seen_ms { - Some(seen) => format!("{presence} · last seen {seen} ms"), - None => format!("{presence} · last seen unavailable"), - }, + "Last seen", + relative_time_label(device.last_seen_ms, now_ms), )); } + +fn relative_time_label(value_ms: Option, now_ms: i64) -> String { + let Some(value_ms) = value_ms else { + return "unavailable".to_string(); + }; + let delta_ms = now_ms.saturating_sub(value_ms); + if delta_ms < 0 { + return "in the future".to_string(); + } + let seconds = delta_ms / 1000; + if seconds < 5 { + "just now".to_string() + } else if seconds < 60 { + format!("{seconds}s ago") + } else if seconds < 60 * 60 { + format!("{}m ago", seconds / 60) + } else if seconds < 24 * 60 * 60 { + format!("{}h ago", seconds / 60 / 60) + } else { + format!("{}d ago", seconds / 60 / 60 / 24) + } +} diff --git a/src/ui/popup.rs b/src/ui/popup.rs index 7a1a476..9514802 100644 --- a/src/ui/popup.rs +++ b/src/ui/popup.rs @@ -6,7 +6,8 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use super::theme; use crate::app::state::{ - AppState, DevicePresenceSection, EditField, Loadable, Popup, addable_playlists, + AppState, DevicePresenceSection, EditField, Loadable, Popup, StatusDetailFocus, + addable_playlists, }; use crate::library::models::{ArtistRef, TrackItem}; @@ -37,10 +38,34 @@ pub fn draw(frame: &mut Frame, state: &AppState) { .. }) => draw_track_artists(frame, tracks, *cursor, *selected), Some(Popup::LogDetail(entry)) => draw_log_detail(frame, entry), - Some(Popup::FedInput { field, input }) => draw_fed_input(frame, field.title(), input), + Some(Popup::FedInput { field, input }) => { + draw_fed_input(frame, field.title(), field.help(), input) + } Some(Popup::FedText { title, text }) => draw_fed_text(frame, title, text), - Some(Popup::FederationStatusDetails { scroll }) => { - draw_federation_status_details(frame, state, *scroll) + Some(Popup::FedCopyText { title, text, help }) => { + draw_fed_copy_text(frame, title, text, help) + } + Some(Popup::FederationStatusDetails { + focus, + status_cursor, + devices_scroll, + logs_scroll, + }) => draw_federation_status_details( + frame, + state, + *focus, + *status_cursor, + *devices_scroll, + *logs_scroll, + ), + Some(Popup::FederationStatusText { + title, + text, + scroll, + parent: _, + }) => draw_federation_status_text(frame, title, text, *scroll), + Some(Popup::FederationStatusLog { scroll, parent: _ }) => { + draw_federation_status_log(frame, state, *scroll) } Some(Popup::DevicePairing { device_id, @@ -65,10 +90,15 @@ pub fn draw(frame: &mut Frame, state: &AppState) { } } -fn draw_federation_status_details(frame: &mut Frame, state: &AppState, scroll: usize) { - let width = frame.area().width.saturating_sub(6).clamp(52, 104); - let height = frame.area().height.saturating_sub(4).clamp(10, 32); - let area = centered(frame.area(), width, height); +fn draw_federation_status_details( + frame: &mut Frame, + state: &AppState, + focus: StatusDetailFocus, + status_cursor: usize, + devices_scroll: usize, + _logs_scroll: usize, +) { + let area = federation_status_area(frame); let block = Block::bordered() .title(" Full status details ") .title_style(theme::header()) @@ -77,18 +107,182 @@ fn draw_federation_status_details(frame: &mut Frame, state: &AppState, scroll: u frame.render_widget(Clear, area); frame.render_widget(block, area); - let [body, footer] = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(inner); - let lines = super::federation::status_detail_lines(state); - let max_scroll = lines.len().saturating_sub(usize::from(body.height)); + let log_height = if inner.height >= 22 { + (inner.height / 3) + .clamp(7, 12) + .min(inner.height.saturating_sub(9)) + } else { + inner.height.saturating_sub(7).clamp(3, 6) + }; + let [summary_area, logs_area, footer] = Layout::vertical([ + Constraint::Min(8), + Constraint::Length(log_height), + Constraint::Length(1), + ]) + .areas(inner); + + let sections = super::federation::status_detail_sections(state, status_cursor); + if summary_area.width >= 78 { + let [left, _, right] = Layout::horizontal([ + Constraint::Percentage(50), + Constraint::Length(1), + Constraint::Percentage(50), + ]) + .areas(summary_area); + draw_status_detail_panel( + frame, + left, + " Status / Transport ", + sections.status, + 0, + focus == StatusDetailFocus::Status, + ); + draw_status_detail_panel( + frame, + right, + " Connected Devices ", + sections.devices, + devices_scroll, + focus == StatusDetailFocus::Devices, + ); + } else { + let [top, bottom] = + Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]) + .areas(summary_area); + draw_status_detail_panel( + frame, + top, + " Status / Transport ", + sections.status, + 0, + focus == StatusDetailFocus::Status, + ); + draw_status_detail_panel( + frame, + bottom, + " Connected Devices ", + sections.devices, + devices_scroll, + focus == StatusDetailFocus::Devices, + ); + } + draw_status_log_panel( + frame, + logs_area, + sections.logs, + focus == StatusDetailFocus::Logs, + ); frame.render_widget( - Paragraph::new(lines) + Paragraph::new(Line::styled( + "h/l focus panels · j/k move/scroll selected · enter open selected detail/log · esc close", + theme::dim(), + )) + .alignment(Alignment::Center), + footer, + ); +} + +fn federation_status_area(frame: &Frame) -> Rect { + let max_width = frame.area().width.saturating_sub(2).max(1); + let max_height = frame.area().height.saturating_sub(2).max(1); + let width = max_width.min(150).max(max_width.min(72)); + let height = max_height.min(44).max(max_height.min(22)); + centered(frame.area(), width, height) +} + +fn draw_status_detail_panel( + frame: &mut Frame, + area: Rect, + title: &'static str, + lines: Vec>, + scroll: usize, + focused: bool, +) { + if area.width == 0 || area.height == 0 { + return; + } + let block = Block::bordered() + .title(title) + .title_style(theme::header()) + .border_style(if focused { + theme::accent() + } else { + theme::dim() + }); + let inner = block.inner(area); + let max_scroll = lines.len().saturating_sub(usize::from(inner.height)); + frame.render_widget(block, area); + frame.render_widget( + Paragraph::new(lines).scroll((scroll.min(max_scroll) as u16, 0)), + inner, + ); +} + +fn draw_status_log_panel(frame: &mut Frame, area: Rect, lines: Vec>, focused: bool) { + if area.width == 0 || area.height == 0 { + return; + } + let block = Block::bordered() + .title(" Connection log ") + .title_style(theme::header()) + .border_style(if focused { + theme::accent() + } else { + theme::dim() + }); + let inner = block.inner(area); + let preview: Vec<_> = lines.into_iter().take(10).collect(); + frame.render_widget(block, area); + frame.render_widget(Paragraph::new(preview), inner); +} + +fn draw_federation_status_text(frame: &mut Frame, title: &str, text: &str, scroll: usize) { + let area = federation_status_area(frame); + let block = Block::bordered() + .title(format!(" {title} ")) + .title_style(theme::header()) + .border_style(theme::accent()); + let inner = block.inner(area); + frame.render_widget(Clear, area); + frame.render_widget(block, area); + let [body, footer] = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(inner); + let line_count = text.lines().count().max(1); + let max_scroll = line_count.saturating_sub(usize::from(body.height)); + frame.render_widget( + Paragraph::new(text.to_string()) .wrap(Wrap { trim: false }) .scroll((scroll.min(max_scroll) as u16, 0)), body, ); frame.render_widget( Paragraph::new(Line::styled( - "j/k scroll - pgup/pgdn page - esc close", + "j/k scroll · pgup/pgdn page · esc return", + theme::dim(), + )) + .alignment(Alignment::Center), + footer, + ); +} + +fn draw_federation_status_log(frame: &mut Frame, state: &AppState, scroll: usize) { + let area = federation_status_area(frame); + let block = Block::bordered() + .title(" Connection log ") + .title_style(theme::header()) + .border_style(theme::accent()); + let inner = block.inner(area); + frame.render_widget(Clear, area); + frame.render_widget(block, area); + let [body, footer] = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(inner); + let lines = super::federation::status_detail_transport_logs(state); + let max_scroll = lines.len().saturating_sub(usize::from(body.height)); + frame.render_widget( + Paragraph::new(lines).scroll((scroll.min(max_scroll) as u16, 0)), + body, + ); + frame.render_widget( + Paragraph::new(Line::styled( + "j/k scroll · pgup/pgdn page · esc return", theme::dim(), )) .alignment(Alignment::Center), @@ -428,8 +622,8 @@ fn draw_library_filters(frame: &mut Frame, state: &AppState, cursor: usize) { } /// One-line text entry on the Federation tab (network id / peer ticket). -fn draw_fed_input(frame: &mut Frame, title: &str, input: &crate::app::input::LineEdit) { - let area = centered(frame.area(), 64, 5); +fn draw_fed_input(frame: &mut Frame, title: &str, help: &str, input: &crate::app::input::LineEdit) { + let area = centered(frame.area(), 72, 8); let block = Block::bordered() .title(format!(" {title} ")) .title_style(theme::header()) @@ -437,8 +631,18 @@ fn draw_fed_input(frame: &mut Frame, title: &str, input: &crate::app::input::Lin let inner = block.inner(area); frame.render_widget(Clear, area); frame.render_widget(block, area); - let [entry_area, hint_area] = - Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner); + let [help_area, entry_area, hint_area] = Layout::vertical([ + Constraint::Length(3), + Constraint::Length(1), + Constraint::Length(1), + ]) + .areas(inner); + frame.render_widget( + Paragraph::new(help.to_string()) + .wrap(Wrap { trim: true }) + .style(theme::dim()), + help_area, + ); let spans = super::line_edit_spans(input, usize::from(entry_area.width.saturating_sub(1))); frame.render_widget(Paragraph::new(Line::from(spans)), entry_area); frame.render_widget( @@ -471,6 +675,54 @@ fn draw_fed_text(frame: &mut Frame, title: &str, text: &str) { ); } +/// Wrapped text with an explicit copy-and-close action. +fn draw_fed_copy_text(frame: &mut Frame, title: &str, text: &str, help: &str) { + let width = frame.area().width.saturating_sub(8).clamp(36, 96); + let text_width = usize::from(width.saturating_sub(2)); + let text_lines = (text.chars().count() / text_width.max(1) + 1) as u16; + let help_lines = (help.chars().count() / text_width.max(1) + 1) as u16; + let height = (text_lines + help_lines + 5).clamp(8, frame.area().height); + let area = centered(frame.area(), width, height); + let block = Block::bordered() + .title(format!(" {title} ")) + .title_style(theme::header()) + .border_style(theme::accent()); + let inner = block.inner(area); + frame.render_widget(Clear, area); + frame.render_widget(block, area); + + let [help_area, body_area, button_area, footer_area] = Layout::vertical([ + Constraint::Length(help_lines), + Constraint::Min(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .areas(inner); + frame.render_widget( + Paragraph::new(help.to_string()) + .wrap(Wrap { trim: true }) + .style(theme::dim()), + help_area, + ); + frame.render_widget( + Paragraph::new(text.to_string()).wrap(Wrap { trim: false }), + body_area, + ); + frame.render_widget( + Paragraph::new(Line::styled( + " Copy to clipboard and close ", + theme::tab_active(), + )) + .alignment(Alignment::Center), + button_area, + ); + frame.render_widget( + Paragraph::new(Line::styled("enter/c copy · esc close", theme::dim())) + .alignment(Alignment::Center), + footer_area, + ); +} + fn draw_device_pairing( frame: &mut Frame, device_id: &str,