Files
furumi_tui/src/ui/federation.rs
T

1543 lines
47 KiB
Rust
Raw Normal View History

2026-07-23 18:48:58 +03:00
//! The Settings tab: federation settings, visualization scripts and status.
2026-07-16 20:29:51 +03:00
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
2026-07-28 22:18:29 +01:00
use ratatui::style::{Color, Modifier, Style};
2026-07-16 20:29:51 +03:00
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use super::theme;
2026-08-10 00:22:20 +01:00
use crate::app::state::{AppState, DevicePresenceSection, FedRow, SimilarityRow, settings_rows};
2026-07-16 20:29:51 +03:00
pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
let block = Block::bordered()
2026-07-23 18:48:58 +03:00
.title(" Settings ")
2026-07-26 01:41:16 +03:00
.title_style(theme::header_for(state))
.border_style(theme::border_for(state));
2026-07-16 20:29:51 +03:00
let inner = block.inner(area);
frame.render_widget(block, area);
2026-07-25 01:23:29 +03:00
if inner.width >= 132 {
let desired_settings_width = ((inner.width as usize * 44) / 100).clamp(68, 92) as u16;
let settings_width = desired_settings_width.min(inner.width.saturating_sub(56));
let [rows_area, _, status_area] = Layout::horizontal([
Constraint::Length(settings_width),
Constraint::Length(2),
Constraint::Min(0),
])
.areas(inner);
draw_settings_rows(frame, rows_area, state);
2026-08-10 00:22:20 +01:00
draw_status_column(frame, status_area, state);
2026-07-25 01:23:29 +03:00
return;
}
2026-07-24 04:11:01 +03:00
let rows_height =
2026-08-10 00:22:20 +01:00
(settings_rows(state).len() + 10 + device_presence_sections(state).len()) as u16;
2026-07-16 20:29:51 +03:00
let [rows_area, _, status_area] = Layout::vertical([
2026-07-23 18:48:58 +03:00
Constraint::Length(rows_height.min(inner.height)),
2026-07-16 20:29:51 +03:00
Constraint::Length(1),
Constraint::Min(0),
])
.areas(inner);
2026-07-23 18:48:58 +03:00
draw_settings_rows(frame, rows_area, state);
2026-08-10 00:22:20 +01:00
draw_status_column(frame, status_area, state);
2026-07-23 18:48:58 +03:00
}
2026-07-24 04:11:01 +03:00
fn device_presence_sections(state: &AppState) -> Vec<DevicePresenceSection> {
let Some(status) = &state.federation.devices else {
return Vec::new();
};
let now = crate::app::state::unix_time_ms();
let mut sections = Vec::new();
for index in crate::app::state::device_status_order(state) {
let Some(device) = status.devices.get(index) else {
continue;
};
let section = crate::app::state::device_presence_section(state, device, now);
if sections.last().copied() != Some(section) {
sections.push(section);
}
}
sections
}
2026-07-23 18:48:58 +03:00
fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) {
2026-07-16 20:29:51 +03:00
let settings = &state.federation.settings;
let on_off = |on: bool| if on { "on" } else { "off" };
2026-07-23 18:48:58 +03:00
let mut y = area.y;
let mut cursor = 0usize;
draw_section(frame, area, state, &mut y, "Library");
draw_row(
frame,
area,
state,
&mut y,
cursor,
state.settings_cursor,
"Music save directory",
if state.music_dir_changing {
2026-08-02 03:04:16 +01:00
format!("{} checking/changing…", state.spinner())
} else {
state.music_dir.to_string_lossy().into_owned()
},
);
cursor += 1;
y = y.saturating_add(1);
2026-08-10 00:22:20 +01:00
draw_section(frame, area, state, &mut y, "Similarity Search");
let similarity = &state.similarity.settings;
for row in SimilarityRow::ALL {
let (label, value) = match row {
SimilarityRow::Toggle => ("Similarity search", on_off(similarity.enabled).to_string()),
SimilarityRow::Model => (
"Embedding model",
crate::similarity::model_by_id(&similarity.model)
.map(|model| format!("{} · {}", model.id, model.license))
.unwrap_or_else(|| similarity.model.clone()),
),
SimilarityRow::Profile => (
"Preprocessing profile",
format!("{} (enter for details)", similarity.profile),
),
SimilarityRow::Workers => ("Background workers", similarity.workers.to_string()),
SimilarityRow::Clear => ("Clear all stored embeddings", "↵".to_string()),
};
draw_row(
frame,
area,
state,
&mut y,
cursor,
state.settings_cursor,
label,
value,
);
cursor += 1;
}
y = y.saturating_add(1);
2026-07-26 01:41:16 +03:00
draw_section(frame, area, state, &mut y, "Federation");
2026-07-23 18:48:58 +03:00
for row in FedRow::ALL {
2026-07-16 20:29:51 +03:00
let (label, value) = match row {
FedRow::Toggle => ("Federation", on_off(settings.enabled).to_string()),
FedRow::NetworkId => (
2026-07-26 00:45:53 +03:00
"Network ID",
2026-07-16 20:29:51 +03:00
if settings.network_id.is_empty() {
"(not set — press enter)".to_string()
} else {
settings.network_id.clone()
},
),
FedRow::SaveOnListen => (
"Save federated tracks to the library on listen",
on_off(settings.save_on_listen).to_string(),
),
2026-07-26 00:45:53 +03:00
FedRow::SyncNow => (
"Publish the library now",
if state.federation.publishing {
format!("{} publishing", state.spinner())
} else {
"↵".to_string()
},
),
2026-07-16 20:29:51 +03:00
FedRow::ShowTicket => ("Show my connection ticket", "↵".to_string()),
FedRow::Connect => ("Connect to a peer by ticket…", "↵".to_string()),
};
2026-07-23 18:48:58 +03:00
draw_row(
frame,
area,
2026-07-26 01:41:16 +03:00
state,
2026-07-23 18:48:58 +03:00
&mut y,
cursor,
state.settings_cursor,
label,
value,
);
cursor += 1;
2026-07-16 20:29:51 +03:00
}
2026-07-24 00:54:27 +03:00
y = y.saturating_add(1);
let connected_devices_enabled = state.connected_devices_enabled();
2026-07-26 01:41:16 +03:00
draw_section(frame, area, state, &mut y, "Connected Devices");
let disabled_value = "enable federation first".to_string();
2026-07-24 00:54:27 +03:00
let devices = state.federation.devices.as_ref();
draw_row_enabled(
2026-07-24 00:54:27 +03:00
frame,
area,
2026-07-26 01:41:16 +03:00
state,
2026-07-24 00:54:27 +03:00
&mut y,
cursor,
state.settings_cursor,
"This device name",
if connected_devices_enabled {
devices
.map(|status| status.this_device_name.clone())
.unwrap_or_else(|| "loading…".to_string())
} else {
disabled_value.clone()
},
connected_devices_enabled,
2026-07-24 00:54:27 +03:00
);
cursor += 1;
draw_row_enabled(
2026-07-24 00:54:27 +03:00
frame,
area,
2026-07-26 01:41:16 +03:00
state,
2026-07-24 00:54:27 +03:00
&mut y,
cursor,
state.settings_cursor,
"Generate device invite",
if connected_devices_enabled {
"↵".to_string()
} else {
disabled_value.clone()
},
connected_devices_enabled,
2026-07-24 00:54:27 +03:00
);
cursor += 1;
draw_row_enabled(
2026-07-24 00:54:27 +03:00
frame,
area,
2026-07-26 01:41:16 +03:00
state,
2026-07-24 00:54:27 +03:00
&mut y,
cursor,
state.settings_cursor,
"Connect device by invite…",
if connected_devices_enabled {
"↵".to_string()
} else {
disabled_value.clone()
},
connected_devices_enabled,
2026-07-24 00:54:27 +03:00
);
cursor += 1;
draw_row_enabled(
2026-07-24 00:54:27 +03:00
frame,
area,
2026-07-26 01:41:16 +03:00
state,
2026-07-24 00:54:27 +03:00
&mut y,
cursor,
state.settings_cursor,
"Sync devices now",
if connected_devices_enabled {
2026-07-26 00:45:53 +03:00
if state.federation.device_syncing {
format!("{} syncing", state.spinner())
} else {
"↵".to_string()
}
} else {
disabled_value.clone()
},
connected_devices_enabled,
2026-07-24 00:54:27 +03:00
);
cursor += 1;
2026-07-26 01:41:16 +03:00
draw_row_enabled(
frame,
area,
state,
&mut y,
cursor,
state.settings_cursor,
"Leave device group",
if connected_devices_enabled {
"↵".to_string()
} else {
disabled_value.clone()
},
connected_devices_enabled,
);
cursor += 1;
2026-07-24 00:54:27 +03:00
if let Some(status) = devices {
2026-07-24 04:11:01 +03:00
let now = crate::app::state::unix_time_ms();
let mut current_section = None;
for index in crate::app::state::device_status_order(state) {
let Some(device) = status.devices.get(index) else {
continue;
};
let section = crate::app::state::device_presence_section(state, device, now);
if current_section != Some(section) {
draw_subsection(frame, area, &mut y, section.title());
current_section = Some(section);
}
let name = crate::app::state::device_display_name(device);
2026-07-24 00:54:27 +03:00
let label = if device.is_self {
2026-07-24 04:11:01 +03:00
format!("* {name}")
2026-07-24 00:54:27 +03:00
} else if device.revoked {
2026-07-24 04:11:01 +03:00
format!(" {name} (revoked)")
2026-07-24 00:54:27 +03:00
} else {
2026-07-24 04:11:01 +03:00
format!(" {name}")
2026-07-24 00:54:27 +03:00
};
let version = if device.client_version.is_empty() {
"unknown".to_string()
} else {
format!("v{}", device.client_version)
};
let can_revoke = connected_devices_enabled && !device.is_self && !device.revoked;
2026-07-24 04:11:01 +03:00
let presence = match section {
DevicePresenceSection::Online => "online",
DevicePresenceSection::Offline => "offline",
DevicePresenceSection::Revoked => "revoked",
};
let value = if can_revoke {
2026-07-24 04:11:01 +03:00
format!("{version} · {presence} · revoke ↵")
} else if connected_devices_enabled {
2026-07-24 04:11:01 +03:00
format!("{version} · {presence}")
2026-07-24 00:54:27 +03:00
} else {
disabled_value.clone()
2026-07-24 00:54:27 +03:00
};
draw_row_enabled(
2026-07-24 00:54:27 +03:00
frame,
area,
2026-07-26 01:41:16 +03:00
state,
2026-07-24 00:54:27 +03:00
&mut y,
cursor,
state.settings_cursor,
&label,
value,
connected_devices_enabled,
2026-07-24 00:54:27 +03:00
);
cursor += 1;
}
}
2026-07-23 18:48:58 +03:00
y = y.saturating_add(1);
2026-07-26 01:41:16 +03:00
draw_section(frame, area, state, &mut y, "Visualizations");
2026-07-23 18:48:58 +03:00
draw_row(
frame,
area,
2026-07-26 01:41:16 +03:00
state,
2026-07-23 18:48:58 +03:00
&mut y,
cursor,
state.settings_cursor,
"Show clock",
if state.visualizer.config.show_clock {
"[x]".to_string()
} else {
"[ ]".to_string()
},
);
cursor += 1;
for (index, script) in state.visualizer.scripts.iter().enumerate() {
let selected_script = state
.visualizer
.selected_script_index()
.is_some_and(|selected| selected == index);
let label = if selected_script {
format!("* {}", script.name)
} else {
format!(" {}", script.name)
};
let value = script
.path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("")
.to_string();
draw_row(
frame,
area,
2026-07-26 01:41:16 +03:00
state,
2026-07-23 18:48:58 +03:00
&mut y,
cursor,
state.settings_cursor,
&label,
value,
);
cursor += 1;
}
draw_row(
frame,
area,
2026-07-26 01:41:16 +03:00
state,
2026-07-23 18:48:58 +03:00
&mut y,
cursor,
state.settings_cursor,
"+ New visualization script",
"↵".to_string(),
);
cursor += 1;
if state.visualizer.selected_script().is_some() {
draw_row(
frame,
area,
2026-07-26 01:41:16 +03:00
state,
2026-07-23 18:48:58 +03:00
&mut y,
cursor,
state.settings_cursor,
"Edit selected visualization",
"↵".to_string(),
);
2026-07-25 02:10:20 +03:00
cursor += 1;
2026-07-23 18:48:58 +03:00
}
2026-07-25 02:10:20 +03:00
y = y.saturating_add(1);
draw_row(
frame,
area,
2026-07-26 01:41:16 +03:00
state,
2026-07-25 02:10:20 +03:00
&mut y,
cursor,
state.settings_cursor,
"Full status details",
"enter".to_string(),
);
2026-07-23 18:48:58 +03:00
}
2026-07-28 22:18:29 +01:00
fn protocol_label(id: &str) -> &str {
match id {
"federation_net" => "Federation transport",
"ticket" => "Peer ticket",
"rendezvous" => "Rendezvous",
"music_dht" => "Music DHT",
"catalog" => "Catalog",
"audio" => "Audio transfer",
2026-08-10 00:22:20 +01:00
"similarity" => "Similarity search",
2026-07-28 22:18:29 +01:00
"device_sync" => "Device sync",
"jam" => "Jam",
other => other,
}
}
2026-07-26 01:41:16 +03:00
fn draw_section(frame: &mut Frame, area: Rect, state: &AppState, y: &mut u16, title: &'static str) {
2026-07-23 18:48:58 +03:00
if *y >= area.y + area.height {
return;
}
let rect = Rect {
x: area.x,
y: *y,
width: area.width,
height: 1,
};
2026-07-26 01:41:16 +03:00
frame.render_widget(
Paragraph::new(Line::styled(title, theme::header_for(state))),
rect,
);
2026-07-23 18:48:58 +03:00
*y = (*y).saturating_add(1);
}
2026-07-24 04:11:01 +03:00
fn draw_subsection(frame: &mut Frame, area: Rect, y: &mut u16, title: &'static str) {
if *y >= area.y + area.height {
return;
}
let rect = Rect {
x: area.x,
y: *y,
width: area.width,
height: 1,
};
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(" "),
Span::styled(title, theme::dim()),
])),
rect,
);
*y = (*y).saturating_add(1);
}
2026-07-23 18:48:58 +03:00
fn draw_row(
frame: &mut Frame,
area: Rect,
2026-07-26 01:41:16 +03:00
state: &AppState,
2026-07-23 18:48:58 +03:00
y: &mut u16,
row_index: usize,
cursor: usize,
label: &str,
value: String,
) {
2026-07-26 01:41:16 +03:00
draw_row_enabled(frame, area, state, y, row_index, cursor, label, value, true);
}
fn draw_row_enabled(
frame: &mut Frame,
area: Rect,
2026-07-26 01:41:16 +03:00
state: &AppState,
y: &mut u16,
row_index: usize,
cursor: usize,
label: &str,
value: String,
enabled: bool,
2026-07-23 18:48:58 +03:00
) {
if *y >= area.y + area.height {
return;
}
let selected = row_index == cursor;
let rect = Rect {
x: area.x,
y: *y,
width: area.width,
height: 1,
};
let marker = if selected { "▶ " } else { " " };
2026-07-25 01:23:29 +03:00
let label_width = settings_label_width(area.width);
2026-07-23 18:48:58 +03:00
let line = Line::from(vec![
Span::styled(
marker,
if enabled {
2026-07-26 01:41:16 +03:00
theme::accent_for(state)
} else {
theme::dim()
},
),
2026-07-23 18:48:58 +03:00
Span::styled(
format!("{label:<label_width$}"),
if !enabled {
theme::dim()
} else if selected {
2026-07-26 01:41:16 +03:00
theme::accent_for(state)
2026-07-23 18:48:58 +03:00
} else {
ratatui::style::Style::default()
},
),
Span::styled(value, theme::dim()),
]);
frame.render_widget(Paragraph::new(line), rect);
*y = (*y).saturating_add(1);
2026-07-16 20:29:51 +03:00
}
2026-07-25 01:23:29 +03:00
fn settings_label_width(width: u16) -> usize {
let width = width as usize;
if width >= 88 {
48
} else {
width.saturating_sub(28).clamp(24, 48)
}
}
2026-07-16 20:29:51 +03:00
fn status_line(label: &str, value: String) -> Line<'static> {
Line::from(vec![
2026-07-25 01:23:29 +03:00
Span::styled(format!("{label:<18}"), theme::dim()),
2026-07-16 20:29:51 +03:00
Span::raw(value),
])
}
2026-07-25 01:23:29 +03:00
fn short_bytes_label(bytes: u64) -> String {
if bytes >= 1024 * 1024 {
2026-07-25 01:23:29 +03:00
format!("{:.1} MiB", bytes as f64 / 1024.0 / 1024.0)
} else if bytes >= 1024 {
2026-07-25 01:23:29 +03:00
format!("{:.1} KiB", bytes as f64 / 1024.0)
} else {
format!("{bytes} B")
}
}
2026-07-25 01:23:29 +03:00
fn rtt_label(ms: Option<u64>) -> String {
ms.map(|ms| format!("{ms} ms"))
.unwrap_or_else(|| "rtt n/a".to_string())
}
2026-07-16 20:29:51 +03:00
fn short_id(id: &str) -> String {
id.chars().take(12).collect::<String>() + "…"
}
2026-08-10 00:22:20 +01:00
fn draw_status_column(frame: &mut Frame, area: Rect, state: &AppState) {
if area.height < 12 {
draw_status(frame, area, state);
return;
}
let [similarity_area, _, federation_area] = Layout::vertical([
Constraint::Length(8),
Constraint::Length(1),
Constraint::Min(0),
])
.areas(area);
draw_similarity_status(frame, similarity_area, state);
draw_status(frame, federation_area, state);
}
fn draw_similarity_status(frame: &mut Frame, area: Rect, state: &AppState) {
let status = &state.similarity.status;
let progress = if status.total_tracks == 0 {
"0 / 0".to_string()
} else {
format!("{} / {}", status.completed_tracks, status.total_tracks)
};
let active = status
.active_profile
.as_deref()
.map(short_id)
.unwrap_or_else(|| "not ready".to_string());
let target = status
.target_profile
.as_deref()
.map(short_id)
.unwrap_or_else(|| "—".to_string());
draw_summary_card(
frame,
area,
state,
" Similarity Processing ",
vec![
status_line("State", status.phase.label().to_string()),
status_line("Progress", progress),
status_line("Active", active),
status_line("Processing", target),
status_line(
"Stored",
format!(
"{} vectors / {}",
status.stored_vectors,
short_bytes_label(status.stored_bytes)
),
),
status_line(
"Current / errors",
status
.current_track
.clone()
.or_else(|| status.last_error.clone())
.unwrap_or_else(|| format!("{} errors", status.failed_tracks)),
),
],
);
}
2026-07-16 20:29:51 +03:00
fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
2026-07-25 02:10:20 +03:00
if area.width == 0 || area.height == 0 {
return;
}
2026-07-26 01:41:16 +03:00
if area.height < 15 || area.width < 36 {
2026-07-25 02:10:20 +03:00
frame.render_widget(
Paragraph::new(compact_status_lines(state))
.wrap(ratatui::widgets::Wrap { trim: false }),
area,
);
return;
}
2026-07-28 22:18:29 +01:00
if area.width >= 60 && area.height >= 20 {
let protocols_height =
protocol_card_height(state, area.width.saturating_sub(2), area.height);
let [top_area, _, bottom_area, _, protocols_area, _] = Layout::vertical([
2026-07-26 01:41:16 +03:00
Constraint::Length(7),
Constraint::Length(1),
Constraint::Length(7),
2026-07-28 22:18:29 +01:00
Constraint::Length(1),
Constraint::Length(protocols_height),
Constraint::Min(0),
2026-07-26 01:41:16 +03:00
])
.areas(area);
let [status_area, _, local_area] = Layout::horizontal([
Constraint::Percentage(50),
Constraint::Length(1),
Constraint::Percentage(50),
])
.areas(top_area);
let [transport_area, _, devices_area] = Layout::horizontal([
Constraint::Percentage(50),
Constraint::Length(1),
Constraint::Percentage(50),
])
.areas(bottom_area);
draw_summary_card(
frame,
status_area,
state,
" Status ",
node_summary_lines(state),
);
draw_summary_card(
frame,
local_area,
state,
" Local Data ",
local_data_summary_lines(state),
);
draw_summary_card(
frame,
transport_area,
state,
" Iroh Transport ",
transport_summary_lines(state),
);
draw_summary_card(
frame,
devices_area,
state,
" Connected Devices ",
device_summary_lines(state),
);
2026-07-28 22:18:29 +01:00
draw_summary_card(
frame,
protocols_area,
state,
" Protocol Versions ",
protocol_summary_lines(state, protocols_area.width.saturating_sub(2)),
);
2026-07-26 01:41:16 +03:00
return;
}
2026-07-28 22:18:29 +01:00
if area.height < 39 {
2026-07-26 01:41:16 +03:00
frame.render_widget(
Paragraph::new(compact_status_lines(state))
.wrap(ratatui::widgets::Wrap { trim: false }),
area,
);
return;
}
let [
node_area,
_,
transport_area,
_,
devices_area,
_,
local_area,
_,
2026-07-28 22:18:29 +01:00
protocols_area,
_,
2026-07-26 01:41:16 +03:00
] = Layout::vertical([
Constraint::Length(7),
2026-07-25 02:10:20 +03:00
Constraint::Length(1),
2026-07-26 01:41:16 +03:00
Constraint::Length(7),
2026-07-25 02:10:20 +03:00
Constraint::Length(1),
2026-07-26 01:41:16 +03:00
Constraint::Length(7),
Constraint::Length(1),
Constraint::Length(7),
2026-07-28 22:18:29 +01:00
Constraint::Length(1),
Constraint::Length(protocol_card_height(
state,
area.width.saturating_sub(2),
area.height,
)),
2026-07-25 02:10:20 +03:00
Constraint::Min(0),
])
.areas(area);
2026-07-26 01:41:16 +03:00
draw_summary_card(
frame,
node_area,
state,
" Status ",
node_summary_lines(state),
);
2026-07-25 02:10:20 +03:00
draw_summary_card(
frame,
transport_area,
2026-07-26 01:41:16 +03:00
state,
2026-07-25 02:10:20 +03:00
" Iroh Transport ",
transport_summary_lines(state),
);
draw_summary_card(
frame,
devices_area,
2026-07-26 01:41:16 +03:00
state,
2026-07-25 02:10:20 +03:00
" Connected Devices ",
device_summary_lines(state),
);
2026-07-26 01:41:16 +03:00
draw_summary_card(
frame,
local_area,
state,
" Local Data ",
local_data_summary_lines(state),
);
2026-07-28 22:18:29 +01:00
draw_summary_card(
frame,
protocols_area,
state,
" Protocol Versions ",
protocol_summary_lines(state, protocols_area.width.saturating_sub(2)),
);
2026-07-25 02:10:20 +03:00
}
fn compact_status_lines(state: &AppState) -> Vec<Line<'static>> {
let mut lines = Vec::new();
2026-07-26 01:41:16 +03:00
lines.push(Line::styled("Status", theme::header_for(state)));
2026-07-25 02:10:20 +03:00
lines.extend(node_summary_lines(state).into_iter().take(2));
lines.push(Line::default());
2026-07-26 01:41:16 +03:00
lines.push(Line::styled("Local Data", theme::header_for(state)));
lines.extend(local_data_summary_lines(state).into_iter().take(3));
lines.push(Line::default());
lines.push(Line::styled("Iroh Transport", theme::header_for(state)));
2026-07-25 02:10:20 +03:00
lines.extend(transport_summary_lines(state).into_iter().take(2));
lines.push(Line::default());
2026-07-26 01:41:16 +03:00
lines.push(Line::styled("Connected Devices", theme::header_for(state)));
2026-07-25 02:10:20 +03:00
lines.extend(device_summary_lines(state).into_iter().take(2));
2026-07-28 22:18:29 +01:00
lines.push(Line::default());
lines.push(Line::styled("Protocol Versions", theme::header_for(state)));
lines.extend(protocol_summary_lines(state, 0).into_iter().take(3));
2026-07-25 02:10:20 +03:00
lines
}
fn draw_summary_card(
frame: &mut Frame,
area: Rect,
2026-07-26 01:41:16 +03:00
state: &AppState,
2026-07-25 02:10:20 +03:00
title: &'static str,
lines: Vec<Line<'static>>,
) {
if area.width == 0 || area.height == 0 {
return;
}
let block = Block::bordered()
.title(title)
2026-07-26 01:41:16 +03:00
.title_style(theme::header_for(state))
.border_style(theme::border_for(state));
2026-07-25 02:10:20 +03:00
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),
])
}
2026-07-28 22:18:29 +01:00
fn protocol_summary_line(
label: &str,
value: String,
style: Style,
label_width: usize,
) -> Line<'static> {
Line::from(vec![
Span::styled(
format!("{:<label_width$}", protocol_label(label)),
theme::dim(),
),
Span::styled(value, style),
])
}
fn protocol_summary_lines(state: &AppState, width: u16) -> Vec<Line<'static>> {
let Some(status) = state.federation.status.as_ref() else {
return vec![protocol_summary_line(
"status",
"[UNKNOWN]".to_string(),
theme::dim(),
22,
)];
};
let protocols = &status.protocols;
let newer = !protocols.newer.is_empty();
let badge = if newer {
"[NEWER VERSION SEEN]"
} else if status.running && protocols.observed_peers == 0 {
"[CURRENT · waiting for peers]"
} else {
"[CURRENT]"
};
let badge_style = Style::new()
.fg(if newer { Color::LightRed } else { Color::Green })
.add_modifier(Modifier::BOLD);
let mut lines = vec![protocol_summary_line(
"status",
badge.to_string(),
badge_style,
22,
)];
let mut entries = Vec::new();
for (id, local) in &protocols.local {
let observed = protocols.observed.get(id).copied();
let value = match observed {
Some(remote) if remote > *local => format!("local {local} · network {remote}"),
Some(remote) => format!("{local} · seen {remote}"),
None => local.to_string(),
};
let style = if observed.is_some_and(|remote| remote > *local) {
Style::new()
.fg(Color::LightRed)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
entries.push((id.as_str(), value, style));
}
if width >= 58 {
let cell_width = width as usize / 2;
let label_width = 22.min(cell_width.saturating_sub(3));
let value_width = cell_width.saturating_sub(label_width + 2);
for pair in entries.chunks(2) {
let mut spans =
protocol_cell_spans(pair[0].0, &pair[0].1, pair[0].2, label_width, value_width);
if let Some(second) = pair.get(1) {
spans.push(Span::styled(" ", theme::dim()));
spans.extend(protocol_cell_spans(
second.0,
&second.1,
second.2,
label_width,
value_width,
));
}
lines.push(Line::from(spans));
}
} else {
lines.extend(
entries
.into_iter()
.map(|(id, value, style)| protocol_summary_line(id, value, style, 22)),
);
}
if newer {
lines.push(Line::styled(
"A newer protocol was observed; update Furumi for compatibility.",
Style::new().fg(Color::LightRed),
));
}
lines
}
fn protocol_card_height(state: &AppState, width: u16, available: u16) -> u16 {
let content = protocol_summary_lines(state, width).len() as u16;
content.saturating_add(2).min(available)
}
fn protocol_cell_spans(
label: &str,
value: &str,
style: Style,
label_width: usize,
value_width: usize,
) -> Vec<Span<'static>> {
let value = value.chars().take(value_width).collect::<String>();
vec![
Span::styled(
format!("{:<label_width$}", protocol_label(label)),
theme::dim(),
),
Span::styled(format!("{value:<value_width$}"), style),
]
}
2026-07-25 02:10:20 +03:00
fn node_summary_lines(state: &AppState) -> Vec<Line<'static>> {
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<Line<'static>> {
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),
]
}
2026-07-26 01:41:16 +03:00
fn local_data_summary_lines(state: &AppState) -> Vec<Line<'static>> {
match &state.local_library_stats {
None | Some(crate::app::state::Loadable::Loading) => vec![
summary_line("Counts", "loading".to_string()),
summary_line("Media", "loading".to_string()),
summary_line("SQLite", "loading".to_string()),
summary_line("Total", "loading".to_string()),
],
Some(crate::app::state::Loadable::Failed(message)) => vec![
summary_line("Counts", "unavailable".to_string()),
summary_line("Problem", first_line(message)),
],
Some(crate::app::state::Loadable::Ready(stats)) => {
let media = if stats.tracks_without_size > 0 {
format!(
"{} · {} unknown",
short_bytes_label(stats.audio_bytes),
stats.tracks_without_size
)
} else {
short_bytes_label(stats.audio_bytes)
};
vec![
summary_line(
"Counts",
format!(
"{} artists / {} releases / {} tracks",
stats.artist_count, stats.release_count, stats.track_count
),
),
summary_line("Media", media),
summary_line("SQLite", short_bytes_label(stats.database_bytes)),
summary_line(
"Total",
format!(
"{} (covers {})",
short_bytes_label(stats.total_bytes()),
short_bytes_label(stats.cover_bytes)
),
),
]
}
}
}
2026-07-25 02:10:20 +03:00
fn device_summary_lines(state: &AppState) -> Vec<Line<'static>> {
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()
}
2026-07-26 00:45:53 +03:00
pub(super) struct StatusDetailSections {
pub status: Vec<Line<'static>>,
pub devices: Vec<Line<'static>>,
pub logs: Vec<Line<'static>>,
}
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<Line<'static>> {
2026-07-26 01:41:16 +03:00
let mut lines: Vec<Line> = vec![Line::styled("Status", theme::header_for(state))];
2026-07-16 20:29:51 +03:00
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 {
2026-07-26 00:45:53 +03:00
lines.push(status_line("Error", first_line(error)));
2026-07-16 20:29:51 +03:00
}
lines.push(Line::default());
lines.push(Line::styled(
2026-07-26 00:45:53 +03:00
"Set the same Network ID on each client.",
2026-07-16 20:29:51 +03:00
theme::dim(),
));
}
Some(status) => {
2026-07-25 01:23:29 +03:00
lines.push(status_line("Node", format!("running · {}", status.network)));
2026-07-26 00:45:53 +03:00
lines.push(status_action_line(
2026-07-25 01:23:29 +03:00
"Endpoint",
format!(
2026-07-26 00:45:53 +03:00
"{} · dht {}",
short_id(&status.endpoint_id),
short_id(&status.dht_node_id)
),
2026-07-26 01:41:16 +03:00
state,
2026-07-26 00:45:53 +03:00
status_cursor == 0,
));
lines.push(status_line(
"Peers",
format!(
"{} connected · {} contacts",
2026-07-25 01:23:29 +03:00
status.connected_peers.len(),
2026-07-26 00:45:53 +03:00
status.known_contacts
),
));
if !status.connected_peers.is_empty() {
let mut peers: Vec<String> = 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(", "),
2026-07-26 01:41:16 +03:00
state,
2026-07-26 00:45:53 +03:00
status_cursor == 1,
));
}
2026-07-16 20:29:51 +03:00
lines.push(status_line(
2026-07-25 01:23:29 +03:00
"DHT",
format!(
2026-07-26 00:45:53 +03:00
"{} records · {}",
2026-07-25 01:23:29 +03:00
status
.stored_dht_records
.map(|count| count.to_string())
2026-07-26 00:45:53 +03:00
.unwrap_or_else(|| "n/a".to_string()),
2026-07-25 01:23:29 +03:00
status
.stored_dht_bytes
.map(short_bytes_label)
2026-07-26 00:45:53 +03:00
.unwrap_or_else(|| "n/a".to_string())
2026-07-25 01:23:29 +03:00
),
2026-07-16 20:29:51 +03:00
));
2026-07-26 00:45:53 +03:00
lines.push(status_line(
"Published",
format!("{} items", status.published_items),
));
2026-07-16 20:29:51 +03:00
lines.push(status_line(
"Last sync",
status
.last_sync
.clone()
.unwrap_or_else(|| "not yet".to_string()),
));
if let Some(error) = &status.last_error {
2026-07-26 00:45:53 +03:00
lines.push(status_line("Error", first_line(error)));
2026-07-16 20:29:51 +03:00
}
2026-07-26 01:41:16 +03:00
push_transport_summary_status(&mut lines, state, status);
2026-07-16 20:29:51 +03:00
}
}
2026-07-26 00:45:53 +03:00
lines
}
2026-07-26 01:41:16 +03:00
fn status_action_line(
label: &str,
value: String,
state: &AppState,
selected: bool,
) -> Line<'static> {
2026-07-26 00:45:53 +03:00
let marker = if selected { "▶" } else { " " };
Line::from(vec![
Span::styled(
format!("{marker} {label:<16}"),
if selected {
2026-07-26 01:41:16 +03:00
theme::accent_for(state)
2026-07-26 00:45:53 +03:00
} else {
theme::dim()
},
),
Span::raw(value),
Span::styled(" ↵", theme::dim()),
])
}
fn push_transport_summary_status(
lines: &mut Vec<Line<'static>>,
2026-07-26 01:41:16 +03:00
state: &AppState,
2026-07-26 00:45:53 +03:00
status: &crate::federation::FedStatus,
) {
2026-07-24 00:54:27 +03:00
lines.push(Line::default());
2026-07-26 01:41:16 +03:00
lines.push(Line::styled("Iroh Transport", theme::header_for(state)));
2026-07-26 00:45:53 +03:00
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<Line<'static>> {
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<Line<'static>> {
2026-07-26 01:41:16 +03:00
let mut lines = vec![Line::styled("Connected Devices", theme::header_for(state))];
2026-07-24 00:54:27 +03:00
match &state.federation.devices {
None => lines.push(Line::styled("loading…", theme::dim())),
Some(status) => {
lines.push(status_line(
2026-07-25 01:23:29 +03:00
"This device",
2026-07-25 02:10:20 +03:00
format!("{} · {}", status.this_device_name, status.this_device_id),
2026-07-25 01:23:29 +03:00
));
2026-07-25 02:10:20 +03:00
lines.push(status_line("Sync group", status.group_id.clone()));
2026-07-25 01:23:29 +03:00
lines.push(status_line(
"Devices",
format!(
"{} active · {} revoked · {} pending",
status.active_devices, status.revoked_devices, status.pending_requests
),
));
lines.push(status_line(
"Sync log",
format!(
2026-07-26 00:45:53 +03:00
"{} ops · {} outbox · {} tombstones",
status.ops_total, status.outbox_ops, status.tombstone_ops
2026-07-24 00:54:27 +03:00
),
));
lines.push(status_line(
"Snapshot",
format!(
2026-07-26 00:45:53 +03:00
"{} likes · {} playlists · {} items",
2026-07-24 00:54:27 +03:00
status.snapshot_likes, status.snapshot_playlists, status.snapshot_items
),
));
lines.push(status_line(
2026-07-26 00:45:53 +03:00
"Unresolved",
2026-07-24 00:54:27 +03:00
status.unresolved_playlist_items.to_string(),
));
2026-07-26 00:45:53 +03:00
lines.push(status_line("Ack floor", status.peer_ack_floor.clone()));
2026-07-24 00:54:27 +03:00
if let Some(last_sync) = &status.last_sync {
2026-07-26 00:45:53 +03:00
lines.push(status_line("Last sync", last_sync.clone()));
2026-07-24 00:54:27 +03:00
}
if let Some(last_error) = &status.last_error {
2026-07-26 00:45:53 +03:00
lines.push(status_line("Error", first_line(last_error)));
2026-07-24 00:54:27 +03:00
}
2026-07-25 02:10:20 +03:00
lines.push(Line::default());
2026-07-26 01:41:16 +03:00
lines.push(Line::styled("Device List", theme::header_for(state)));
2026-07-25 02:10:20 +03:00
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();
2026-07-26 00:45:53 +03:00
let mut emitted = Vec::new();
2026-07-25 02:10:20 +03:00
for index in &ordered {
if let Some(device) = status.devices.get(*index) {
2026-07-26 00:45:53 +03:00
push_device_detail_compact(
&mut lines,
state,
device,
now,
!emitted.is_empty(),
);
emitted.push(*index);
2026-07-25 02:10:20 +03:00
}
}
for (index, device) in status.devices.iter().enumerate() {
2026-07-26 00:45:53 +03:00
if !emitted.contains(&index) {
push_device_detail_compact(
&mut lines,
state,
device,
now,
!emitted.is_empty(),
);
emitted.push(index);
2026-07-25 02:10:20 +03:00
}
}
}
2026-07-24 00:54:27 +03:00
}
}
2026-07-25 02:10:20 +03:00
lines
}
2026-07-26 00:45:53 +03:00
fn push_device_detail_compact(
2026-07-25 02:10:20 +03:00
lines: &mut Vec<Line<'static>>,
state: &AppState,
device: &crate::devices::DeviceStatusRow,
now_ms: i64,
2026-07-26 00:45:53 +03:00
separator: bool,
2026-07-25 02:10:20 +03:00
) {
2026-07-26 00:45:53 +03:00
if separator {
lines.push(Line::styled(
"────────────────────────────────",
theme::dim(),
));
2026-07-25 02:10:20 +03:00
}
2026-07-26 00:45:53 +03:00
let presence = crate::app::state::device_presence_section(state, device, now_ms);
let icon = match presence {
DevicePresenceSection::Online => "●",
DevicePresenceSection::Offline => "○",
DevicePresenceSection::Revoked => "×",
2026-07-25 02:10:20 +03:00
};
2026-07-26 00:45:53 +03:00
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![
2026-07-26 01:41:16 +03:00
Span::styled(format!("{icon} "), theme::accent_for(state)),
2026-07-26 00:45:53 +03:00
Span::raw(crate::app::state::device_display_name(device)),
Span::styled(
format!(" · {} · {}", version, badges.join(", ")),
theme::dim(),
),
]));
2026-07-25 02:10:20 +03:00
lines.push(status_line("Device ID", device.device_id.clone()));
lines.push(status_line(
2026-07-26 00:45:53 +03:00
"Endpoint",
2026-07-25 02:10:20 +03:00
if device.endpoint_id.trim().is_empty() {
"unavailable".to_string()
} else {
2026-07-26 00:45:53 +03:00
short_id(&device.endpoint_id)
2026-07-25 02:10:20 +03:00
},
));
lines.push(status_line(
2026-07-26 00:45:53 +03:00
"Last seen",
relative_time_label(device.last_seen_ms, now_ms),
2026-07-25 02:10:20 +03:00
));
2026-07-16 20:29:51 +03:00
}
2026-07-26 00:45:53 +03:00
fn relative_time_label(value_ms: Option<i64>, 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)
}
}