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;
|
|
|
|
|
|
|
2026-08-02 00:40:50 +01:00
|
|
|
|
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())
|
2026-08-02 00:40:50 +01:00
|
|
|
|
} 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);
|
2026-07-24 02:21:24 +03:00
|
|
|
|
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");
|
2026-07-24 02:21:24 +03:00
|
|
|
|
let disabled_value = "enable federation first".to_string();
|
2026-07-24 00:54:27 +03:00
|
|
|
|
let devices = state.federation.devices.as_ref();
|
2026-07-24 02:21:24 +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,
|
|
|
|
|
|
"This device name",
|
2026-07-24 02:21:24 +03:00
|
|
|
|
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;
|
2026-07-24 02:21:24 +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,
|
|
|
|
|
|
"Generate device invite",
|
2026-07-24 02:21:24 +03:00
|
|
|
|
if connected_devices_enabled {
|
|
|
|
|
|
"↵".to_string()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
disabled_value.clone()
|
|
|
|
|
|
},
|
|
|
|
|
|
connected_devices_enabled,
|
2026-07-24 00:54:27 +03:00
|
|
|
|
);
|
|
|
|
|
|
cursor += 1;
|
2026-07-24 02:21:24 +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,
|
|
|
|
|
|
"Connect device by invite…",
|
2026-07-24 02:21:24 +03:00
|
|
|
|
if connected_devices_enabled {
|
|
|
|
|
|
"↵".to_string()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
disabled_value.clone()
|
|
|
|
|
|
},
|
|
|
|
|
|
connected_devices_enabled,
|
2026-07-24 00:54:27 +03:00
|
|
|
|
);
|
|
|
|
|
|
cursor += 1;
|
2026-07-24 02:21:24 +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,
|
|
|
|
|
|
"Sync devices now",
|
2026-07-24 02:21:24 +03:00
|
|
|
|
if connected_devices_enabled {
|
2026-07-26 00:45:53 +03:00
|
|
|
|
if state.federation.device_syncing {
|
|
|
|
|
|
format!("{} syncing", state.spinner())
|
|
|
|
|
|
} else {
|
|
|
|
|
|
"↵".to_string()
|
|
|
|
|
|
}
|
2026-07-24 02:21:24 +03:00
|
|
|
|
} 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)
|
|
|
|
|
|
};
|
2026-07-24 02:21:24 +03:00
|
|
|
|
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",
|
|
|
|
|
|
};
|
2026-07-24 02:21:24 +03:00
|
|
|
|
let value = if can_revoke {
|
2026-07-24 04:11:01 +03:00
|
|
|
|
format!("{version} · {presence} · revoke ↵")
|
2026-07-24 02:21:24 +03:00
|
|
|
|
} 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 {
|
2026-07-24 02:21:24 +03:00
|
|
|
|
disabled_value.clone()
|
2026-07-24 00:54:27 +03:00
|
|
|
|
};
|
2026-07-24 02:21:24 +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,
|
2026-07-24 02:21:24 +03:00
|
|
|
|
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-24 02:21:24 +03:00
|
|
|
|
) {
|
2026-07-26 01:41:16 +03:00
|
|
|
|
draw_row_enabled(frame, area, state, y, row_index, cursor, label, value, true);
|
2026-07-24 02:21:24 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn draw_row_enabled(
|
|
|
|
|
|
frame: &mut Frame,
|
|
|
|
|
|
area: Rect,
|
2026-07-26 01:41:16 +03:00
|
|
|
|
state: &AppState,
|
2026-07-24 02:21:24 +03:00
|
|
|
|
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![
|
2026-07-24 02:21:24 +03:00
|
|
|
|
Span::styled(
|
|
|
|
|
|
marker,
|
|
|
|
|
|
if enabled {
|
2026-07-26 01:41:16 +03:00
|
|
|
|
theme::accent_for(state)
|
2026-07-24 02:21:24 +03:00
|
|
|
|
} else {
|
|
|
|
|
|
theme::dim()
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
2026-07-23 18:48:58 +03:00
|
|
|
|
Span::styled(
|
|
|
|
|
|
format!("{label:<label_width$}"),
|
2026-07-24 02:21:24 +03:00
|
|
|
|
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 {
|
2026-07-23 19:43:47 +03:00
|
|
|
|
if bytes >= 1024 * 1024 {
|
2026-07-25 01:23:29 +03:00
|
|
|
|
format!("{:.1} MiB", bytes as f64 / 1024.0 / 1024.0)
|
2026-07-23 19:43:47 +03:00
|
|
|
|
} else if bytes >= 1024 {
|
2026-07-25 01:23:29 +03:00
|
|
|
|
format!("{:.1} KiB", bytes as f64 / 1024.0)
|
2026-07-23 19:43:47 +03:00
|
|
|
|
} 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|