diff --git a/src/app/popup.rs b/src/app/popup.rs index 12320e9..7c09c9d 100644 --- a/src/app/popup.rs +++ b/src/app/popup.rs @@ -136,6 +136,9 @@ 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::DevicePairing { request_id, device_id, @@ -163,6 +166,20 @@ pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) { } } +fn handle_federation_status_details(state: &mut AppState, scroll: usize, key: KeyEvent) { + let next_scroll = match key.code { + KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => 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, + _ => scroll, + }; + state.popup = Some(Popup::FederationStatusDetails { + scroll: next_scroll, + }); +} + fn handle_connected_devices( state: &mut AppState, runtime: &mut Runtime, diff --git a/src/app/state.rs b/src/app/state.rs index d9a44a4..dcb67ac 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -544,6 +544,8 @@ pub enum Popup { ConfirmDeviceRevoke { device_id: String, name: String }, /// Connected playback devices and their current role/status. ConnectedDevices { cursor: usize }, + /// Full federation, transport and device status details. + FederationStatusDetails { scroll: usize }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -593,6 +595,7 @@ impl FedRow { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SettingsRow { Federation(FedRow), + StatusDetails, DeviceName, DeviceInvite, DeviceConnect, @@ -751,6 +754,7 @@ pub fn settings_rows(state: &AppState) -> Vec { if !state.visualizer.scripts.is_empty() { rows.push(SettingsRow::VisualizationEdit); } + rows.push(SettingsRow::StatusDetails); rows } diff --git a/src/app/update.rs b/src/app/update.rs index cffb9f2..e89bd5d 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -2452,6 +2452,10 @@ fn federation_select(state: &mut AppState) -> Option { }); None } + SettingsRow::StatusDetails => { + state.popup = Some(Popup::FederationStatusDetails { scroll: 0 }); + None + } SettingsRow::DeviceName => { if !require_connected_devices_enabled(state) { return None; @@ -2851,8 +2855,9 @@ mod tests { cover_path: None, track_count: 1, }; - // columns = 3 in tests (no tty → 80 wide): albums rows [0,1,2],[3], - // compilations row [4,5]. + let columns = grid_columns(); + // The terminal size can be visible to tests. Build enough albums to + // force a short second album row for whichever width this run has. let detail = ArtistDetail { id: 1, name: "a".into(), @@ -2861,45 +2866,50 @@ mod tests { total_play_count: 0, top_tracks: vec![], featured_tracks: vec![], - releases: vec![ - release(10, "album"), - release(11, "album"), - release(12, "album"), - release(13, "album"), - release(14, "compilation"), - release(15, "compilation"), - ], + releases: (0..=columns) + .map(|index| release(10 + index as i64, "album")) + .chain((0..2).map(|index| release(100 + index, "compilation"))) + .collect(), }; let mut state = AppState::default(); state.artist_views.insert(1, Loadable::Ready(detail)); - state - .global - .stack - .push(GlobalView::Artist { id: 1, cursor: 4 }); + state.global.stack.push(GlobalView::Artist { + id: 1, + cursor: columns + 1, + }); // Up from the first compilation lands on the album row directly - // above (position 3), not three flat items back. + // above, not one flat grid-width jump back. update(&mut state, Action::MoveUp); assert_eq!( state.global.stack.last(), - Some(&GlobalView::Artist { id: 1, cursor: 3 }) + Some(&GlobalView::Artist { + id: 1, + cursor: columns + }) ); // And back down returns to the compilation row, same column. update(&mut state, Action::MoveDown); assert_eq!( state.global.stack.last(), - Some(&GlobalView::Artist { id: 1, cursor: 4 }) + Some(&GlobalView::Artist { + id: 1, + cursor: columns + 1 + }) ); // Up from the second compilation clamps to the single tile above. state.global.stack.pop(); - state - .global - .stack - .push(GlobalView::Artist { id: 1, cursor: 5 }); + state.global.stack.push(GlobalView::Artist { + id: 1, + cursor: columns + 2, + }); update(&mut state, Action::MoveUp); assert_eq!( state.global.stack.last(), - Some(&GlobalView::Artist { id: 1, cursor: 3 }) + Some(&GlobalView::Artist { + id: 1, + cursor: columns + }) ); } diff --git a/src/ui/federation.rs b/src/ui/federation.rs index 34a8534..168bc2c 100644 --- a/src/ui/federation.rs +++ b/src/ui/federation.rs @@ -32,7 +32,7 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) { } let rows_height = - (settings_rows(state).len() + 5 + device_presence_sections(state).len()) as u16; + (settings_rows(state).len() + 6 + device_presence_sections(state).len()) as u16; let [rows_area, _, status_area] = Layout::vertical([ Constraint::Length(rows_height.min(inner.height)), Constraint::Length(1), @@ -285,7 +285,19 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) { "Edit selected visualization", "↵".to_string(), ); + cursor += 1; } + + y = y.saturating_add(1); + draw_row( + frame, + area, + &mut y, + cursor, + state.settings_cursor, + "Full status details", + "enter".to_string(), + ); } fn draw_section(frame: &mut Frame, area: Rect, y: &mut u16, title: &'static str) { @@ -481,7 +493,7 @@ fn push_transport_status(lines: &mut Vec>, status: &crate::federat "Last peer", format!( "{} · paths d/r/c/open {}/{}/{}/{}", - short_id(&sample.peer_id), + sample.peer_id, sample.direct_paths, sample.relay_paths, sample.custom_paths, @@ -501,7 +513,7 @@ fn push_transport_status(lines: &mut Vec>, status: &crate::federat ), )); } - for sample in transport.last.iter().take(3) { + for sample in &transport.last { lines.push(Line::from(vec![ Span::styled(format!("{:<14}", sample.at), theme::dim()), Span::raw(format!( @@ -518,6 +530,267 @@ fn push_transport_status(lines: &mut Vec>, status: &crate::federat } fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) { + if area.width == 0 || area.height == 0 { + return; + } + + if area.height < 18 || area.width < 36 { + frame.render_widget( + Paragraph::new(compact_status_lines(state)) + .wrap(ratatui::widgets::Wrap { trim: false }), + area, + ); + return; + } + + let [node_area, _, transport_area, _, devices_area, _] = Layout::vertical([ + Constraint::Length(5), + Constraint::Length(1), + Constraint::Length(5), + Constraint::Length(1), + Constraint::Length(5), + Constraint::Min(0), + ]) + .areas(area); + + draw_summary_card(frame, node_area, " Status ", node_summary_lines(state)); + draw_summary_card( + frame, + transport_area, + " Iroh Transport ", + transport_summary_lines(state), + ); + draw_summary_card( + frame, + devices_area, + " Connected Devices ", + device_summary_lines(state), + ); +} + +fn compact_status_lines(state: &AppState) -> Vec> { + let mut lines = Vec::new(); + lines.push(Line::styled("Status", theme::header())); + lines.extend(node_summary_lines(state).into_iter().take(2)); + lines.push(Line::default()); + lines.push(Line::styled("Iroh Transport", theme::header())); + lines.extend(transport_summary_lines(state).into_iter().take(2)); + lines.push(Line::default()); + lines.push(Line::styled("Connected Devices", theme::header())); + lines.extend(device_summary_lines(state).into_iter().take(2)); + lines +} + +fn draw_summary_card( + frame: &mut Frame, + area: Rect, + title: &'static str, + lines: Vec>, +) { + if area.width == 0 || area.height == 0 { + return; + } + let block = Block::bordered() + .title(title) + .title_style(theme::header()) + .border_style(theme::dim()); + let inner = block.inner(area); + frame.render_widget(block, area); + frame.render_widget(Paragraph::new(lines), inner); +} + +fn summary_line(label: &'static str, value: String) -> Line<'static> { + Line::from(vec![ + Span::styled(format!("{label:<10}"), theme::dim()), + Span::raw(value), + ]) +} + +fn node_summary_lines(state: &AppState) -> Vec> { + match &state.federation.status { + None => vec![ + summary_line("Node", "loading".to_string()), + summary_line("Network", "unknown".to_string()), + summary_line("Peers", "waiting for status".to_string()), + ], + Some(status) if !status.running => { + let network = if state.federation.settings.network_id.trim().is_empty() { + "network id not set".to_string() + } else { + state.federation.settings.network_id.clone() + }; + vec![ + summary_line("Node", "stopped".to_string()), + summary_line("Network", network), + summary_line( + "Problem", + status + .last_error + .as_deref() + .map(first_line) + .unwrap_or_else(|| "disabled".to_string()), + ), + ] + } + Some(status) => vec![ + summary_line("Node", format!("running on {}", status.network)), + summary_line( + "Peers", + format!( + "{} connected / {} contacts", + status.connected_peers.len(), + status.known_contacts + ), + ), + summary_line( + "Library", + format!( + "{} published / {} DHT records", + status.published_items, + status + .stored_dht_records + .map(|count| count.to_string()) + .unwrap_or_else(|| "n/a".to_string()) + ), + ), + ], + } +} + +fn transport_summary_lines(state: &AppState) -> Vec> { + let Some(status) = &state.federation.status else { + return vec![ + summary_line("Traffic", "loading".to_string()), + summary_line("Streams", "loading".to_string()), + summary_line("Path", "loading".to_string()), + ]; + }; + let transport = &status.transport; + if transport.total_samples == 0 { + return vec![ + summary_line("Traffic", "no samples yet".to_string()), + summary_line("Streams", format!("{} active", transport.active_streams)), + summary_line("Path", "waiting for a stream".to_string()), + ]; + } + let runtime_total = transport + .runtime_tx_bytes + .saturating_add(transport.runtime_rx_bytes); + let last_path = transport + .last + .first() + .map(|sample| { + format!( + "{} / {} / {}", + sample.protocol, + sample.selected_path, + rtt_label(sample.selected_rtt_ms) + ) + }) + .unwrap_or_else(|| "no recent stream".to_string()); + vec![ + summary_line( + "Traffic", + format!( + "{} (tx {} / rx {})", + short_bytes_label(runtime_total), + short_bytes_label(transport.runtime_tx_bytes), + short_bytes_label(transport.runtime_rx_bytes) + ), + ), + summary_line( + "Streams", + format!( + "{} active / {} samples", + transport.active_streams, transport.total_samples + ), + ), + summary_line( + "Paths", + format!( + "direct {} / relay {} / custom {}", + transport.direct_samples, transport.relay_samples, transport.custom_samples + ), + ), + summary_line("Last", last_path), + ] +} + +fn device_summary_lines(state: &AppState) -> Vec> { + if !state.connected_devices_enabled() { + return vec![ + summary_line("Sync", "disabled".to_string()), + summary_line("This", state.device_playback.self_device_name.clone()), + summary_line("Devices", "enable federation first".to_string()), + ]; + } + let Some(status) = &state.federation.devices else { + return vec![ + summary_line("Sync", "loading".to_string()), + summary_line("This", state.device_playback.self_device_name.clone()), + summary_line("Devices", "waiting for device status".to_string()), + ]; + }; + let (online, offline, revoked) = device_presence_counts(state); + let this_name = if status.this_device_name.trim().is_empty() { + short_id(&status.this_device_id) + } else { + format!( + "{} / {}", + status.this_device_name, + short_id(&status.this_device_id) + ) + }; + vec![ + summary_line("This", this_name), + summary_line( + "Devices", + format!( + "{} active / {} online / {} pending", + status.active_devices, online, status.pending_requests + ), + ), + summary_line( + "Sync", + format!( + "{} outbox / last {}", + status.outbox_ops, + status + .last_sync + .clone() + .unwrap_or_else(|| "not yet".to_string()) + ), + ), + summary_line( + "Other", + format!("{} offline / {} revoked", offline, revoked), + ), + ] +} + +fn device_presence_counts(state: &AppState) -> (usize, usize, usize) { + let Some(status) = &state.federation.devices else { + return (0, 0, 0); + }; + let now = crate::app::state::unix_time_ms(); + let mut online = 0usize; + let mut offline = 0usize; + let mut revoked = 0usize; + for device in &status.devices { + match crate::app::state::device_presence_section(state, device, now) { + DevicePresenceSection::Online => online += 1, + DevicePresenceSection::Offline => offline += 1, + DevicePresenceSection::Revoked => revoked += 1, + } + } + (online, offline, revoked) +} + +fn first_line(value: &str) -> String { + value.lines().next().unwrap_or(value).to_string() +} + +pub(super) fn status_detail_lines(state: &AppState) -> Vec> { let mut lines: Vec = vec![Line::styled("Status", theme::header())]; match &state.federation.status { None => lines.push(Line::styled("loading…", theme::dim())), @@ -540,21 +813,12 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) { lines.push(status_line("Node", format!("running · {}", status.network))); lines.push(status_line( "Endpoint", - format!( - "{} · dht {}", - short_id(&status.endpoint_id), - short_id(&status.dht_node_id) - ), + 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() - .take(3) - .map(|p| short_id(p)) - .collect(); + 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}") @@ -605,13 +869,9 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) { Some(status) => { lines.push(status_line( "This device", - format!( - "{} · {}", - status.this_device_name, - short_id(&status.this_device_id) - ), + format!("{} · {}", status.this_device_name, status.this_device_id), )); - lines.push(status_line("Sync group", short_id(&status.group_id))); + lines.push(status_line("Sync group", status.group_id.clone())); lines.push(status_line( "Devices", format!( @@ -647,7 +907,70 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) { if let Some(last_error) = &status.last_error { lines.push(status_line("Device error", last_error.clone())); } + lines.push(Line::default()); + lines.push(Line::styled("Device List", theme::header())); + if status.devices.is_empty() { + lines.push(status_line("Devices", "none recorded".to_string())); + } else { + let ordered = crate::app::state::device_status_order(state); + let now = crate::app::state::unix_time_ms(); + for index in &ordered { + if let Some(device) = status.devices.get(*index) { + push_device_detail(&mut lines, state, device, now); + } + } + for (index, device) in status.devices.iter().enumerate() { + if !ordered.contains(&index) { + push_device_detail(&mut lines, state, device, now); + } + } + } } } - frame.render_widget(Paragraph::new(lines), area); + lines +} + +fn push_device_detail( + lines: &mut Vec>, + state: &AppState, + device: &crate::devices::DeviceStatusRow, + now_ms: i64, +) { + let mut name = crate::app::state::device_display_name(device); + if device.is_self { + name.push_str(" (this device)"); + } + 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", + }; + lines.push(status_line("Device", name)); + lines.push(status_line("Device ID", device.device_id.clone())); + lines.push(status_line( + "Endpoint ID", + if device.endpoint_id.trim().is_empty() { + "unavailable".to_string() + } else { + device.endpoint_id.clone() + }, + )); + 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"), + }, + )); } diff --git a/src/ui/popup.rs b/src/ui/popup.rs index ed91f1f..4651b80 100644 --- a/src/ui/popup.rs +++ b/src/ui/popup.rs @@ -39,6 +39,9 @@ pub fn draw(frame: &mut Frame, state: &AppState) { Some(Popup::LogDetail(entry)) => draw_log_detail(frame, entry), Some(Popup::FedInput { field, input }) => draw_fed_input(frame, field.title(), 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::DevicePairing { device_id, name, @@ -62,6 +65,37 @@ 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); + let block = Block::bordered() + .title(" Full status details ") + .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_lines(state); + let max_scroll = lines.len().saturating_sub(usize::from(body.height)); + frame.render_widget( + Paragraph::new(lines) + .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", + theme::dim(), + )) + .alignment(Alignment::Center), + footer, + ); +} + fn draw_connected_devices(frame: &mut Frame, state: &AppState, cursor: usize) { let rows = crate::app::popup::connected_device_rows(state); let self_row = rows.iter().find(|row| row.is_self);