Files
openbitdo/sdk/crates/bitdo_tui/src/ui/screens/diagnostics.rs

544 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use crate::app::action::QuickAction;
use crate::app::state::{AppState, DiagnosticsFilter};
use crate::ui::layout::{
action_grid_height, inner_rect, panel_block, render_action_strip, truncate_to_width,
ActionDescriptor, HitMap, HitTarget,
};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{List, ListItem, Paragraph};
use ratatui::Frame;
pub fn render(frame: &mut Frame<'_>, state: &AppState, area: Rect) -> HitMap {
let action_height = action_grid_height(area.width, state.quick_actions.len()).max(4);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(5),
Constraint::Min(9),
Constraint::Length(action_height),
])
.split(area);
render_summary(frame, state, rows[0]);
let body = if rows[1].width >= 92 {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(44), Constraint::Percentage(56)])
.split(rows[1])
} else {
Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(rows[1])
};
let mut map = HitMap::default();
render_check_panel(frame, state, body[0], &mut map);
let detail_sections = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(58), Constraint::Percentage(42)])
.split(body[1]);
render_selected_check(frame, state, detail_sections[0]);
render_next_steps(frame, state, detail_sections[1]);
let actions = state
.quick_actions
.iter()
.enumerate()
.map(|(idx, action)| ActionDescriptor {
action: action.action,
label: action.action.label().to_owned(),
caption: diagnostics_action_caption(action.action).to_owned(),
enabled: action.enabled,
active: idx == state.selected_action_index,
})
.collect::<Vec<_>>();
map.extend(render_action_strip(frame, rows[2], &actions));
map
}
fn render_summary(frame: &mut Frame<'_>, state: &AppState, area: Rect) {
let Some(diagnostics) = state.diagnostics_state.as_ref() else {
let empty = Paragraph::new("No diagnostics result loaded.").block(panel_block(
"Diagnostics",
Some("summary"),
true,
));
frame.render_widget(empty, area);
return;
};
let total = diagnostics.result.command_checks.len();
let passed = diagnostics
.result
.command_checks
.iter()
.filter(|check| check.ok)
.count();
let issues = diagnostics
.result
.command_checks
.iter()
.filter(|check| !check.ok || check.severity != bitdo_proto::DiagSeverity::Ok)
.count();
let experimental = diagnostics
.result
.command_checks
.iter()
.filter(|check| check.is_experimental)
.count();
let transport = if diagnostics.result.transport_ready {
"ready"
} else {
"degraded"
};
let lines = vec![
Line::from(vec![
Span::styled(
format!("{passed}/{total} passed"),
crate::ui::theme::screen_title_style(),
),
Span::raw(""),
Span::styled(format!("{issues} issues"), severity_style(issues > 0)),
Span::raw(""),
Span::styled(
format!("{experimental} experimental"),
crate::ui::theme::subtle_style(),
),
]),
Line::from(vec![
Span::styled(
format!(
"Tier: {}",
support_tier_label(diagnostics.result.support_tier)
),
crate::ui::theme::subtle_style(),
),
Span::raw(""),
Span::styled(
format!("Family: {:?}", diagnostics.result.protocol_family),
crate::ui::theme::subtle_style(),
),
Span::raw(""),
Span::styled(
format!("Transport: {transport}"),
crate::ui::theme::subtle_style(),
),
]),
];
let panel = Paragraph::new(lines).block(panel_block("Diagnostics", Some("summary"), true));
frame.render_widget(panel, area);
}
fn render_check_panel(frame: &mut Frame<'_>, state: &AppState, area: Rect, map: &mut HitMap) {
if area.height < 8 {
render_compact_check_panel(frame, state, area, map);
return;
}
let sections = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(5)])
.split(area);
render_filter_row(frame, state, sections[0], map);
let filtered = state.diagnostics_filtered_indices();
let items = filtered
.iter()
.map(|check_index| {
let check = &state
.diagnostics_state
.as_ref()
.expect("diagnostics state present")
.result
.command_checks[*check_index];
let selected = state
.diagnostics_state
.as_ref()
.map(|diagnostics| diagnostics.selected_check_index == *check_index)
.unwrap_or(false);
let marker = if selected { "" } else { " " };
let experimental = if check.is_experimental { " exp" } else { "" };
let line = format!(
"{marker} {} {:?}{experimental} {}",
severity_badge(check.severity),
check.command,
truncate_to_width(&check.detail, sections[1].width.saturating_sub(26) as usize)
);
let style = if selected {
crate::ui::theme::selected_row_style()
} else {
severity_row_style(check.severity)
};
ListItem::new(line).style(style)
})
.collect::<Vec<_>>();
let list = if items.is_empty() {
List::new(vec![ListItem::new("No checks in this filter")])
} else {
List::new(items)
}
.block(panel_block("Checks", Some("click a row"), true));
frame.render_widget(list, sections[1]);
let list_inner = inner_rect(sections[1], 1, 1);
let visible_rows = list_inner.height as usize;
for filtered_index in 0..filtered.len().min(visible_rows) {
map.push(
Rect::new(
list_inner.x,
list_inner.y + filtered_index as u16,
list_inner.width,
1,
),
HitTarget::DiagnosticsCheck(filtered_index),
);
}
}
fn render_compact_check_panel(
frame: &mut Frame<'_>,
state: &AppState,
area: Rect,
map: &mut HitMap,
) {
let diagnostics = state
.diagnostics_state
.as_ref()
.expect("diagnostics state present");
let filtered = state.diagnostics_filtered_indices();
let inner = inner_rect(area, 1, 1);
let filter_segments = compact_filter_segments(diagnostics);
let filter_line = Line::from(
filter_segments
.iter()
.enumerate()
.map(|(idx, (filter, label))| {
let prefix = if idx == 0 { "" } else { " " };
Span::styled(
format!("{prefix}{label}"),
if diagnostics.active_filter == *filter {
crate::ui::theme::screen_title_style()
} else {
crate::ui::theme::subtle_style()
},
)
})
.collect::<Vec<_>>(),
);
let mut lines = vec![filter_line];
let visible_rows = inner.height.saturating_sub(1) as usize;
if filtered.is_empty() {
lines.push(Line::from("No checks in this filter"));
} else {
for check_index in filtered.iter().take(visible_rows) {
let check = &diagnostics.result.command_checks[*check_index];
let marker = if diagnostics.selected_check_index == *check_index {
""
} else {
" "
};
let experimental = if check.is_experimental { " exp" } else { "" };
lines.push(Line::from(Span::styled(
format!(
"{marker} {} {:?}{experimental} {}",
severity_badge(check.severity),
check.command,
truncate_to_width(&check.detail, inner.width.saturating_sub(24) as usize,)
),
if diagnostics.selected_check_index == *check_index {
crate::ui::theme::selected_row_style()
} else {
severity_row_style(check.severity)
},
)));
}
}
let panel = Paragraph::new(lines).block(panel_block("Checks", Some("tab cycles filter"), true));
frame.render_widget(panel, area);
let mut x = inner.x;
for (idx, (filter, label)) in filter_segments.iter().enumerate() {
let text = if idx == 0 {
label.clone()
} else {
format!(" {label}")
};
let width = (text.len() as u16).min(inner.width.saturating_sub(x.saturating_sub(inner.x)));
if width == 0 {
break;
}
map.push(
Rect::new(x, inner.y, width, 1),
HitTarget::DiagnosticsFilter(*filter),
);
x = x.saturating_add(text.len() as u16);
}
for (row, _) in filtered.iter().take(visible_rows).enumerate() {
map.push(
Rect::new(
inner.x,
inner.y.saturating_add(row as u16).saturating_add(1),
inner.width,
1,
),
HitTarget::DiagnosticsCheck(row),
);
}
}
fn render_filter_row(frame: &mut Frame<'_>, state: &AppState, area: Rect, map: &mut HitMap) {
let diagnostics = state
.diagnostics_state
.as_ref()
.expect("diagnostics state present");
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(34),
Constraint::Percentage(33),
Constraint::Percentage(33),
])
.split(area);
for (filter, rect) in DiagnosticsFilter::ALL
.into_iter()
.zip(chunks.iter().copied())
{
let active = diagnostics.active_filter == filter;
let count = diagnostics
.result
.command_checks
.iter()
.filter(|check| filter.matches(check))
.count();
let chip = Paragraph::new(vec![
Line::from(Span::styled(
filter.label(),
if active {
crate::ui::theme::screen_title_style()
} else {
Style::default()
},
)),
Line::from(Span::styled(
format!("{count} checks"),
crate::ui::theme::subtle_style(),
)),
])
.block(panel_block("Filter", Some(filter.label()), active));
frame.render_widget(chip, rect);
map.push(inner_rect(rect, 1, 1), HitTarget::DiagnosticsFilter(filter));
}
}
fn render_selected_check(frame: &mut Frame<'_>, state: &AppState, area: Rect) {
let lines = if let Some(check) = state.selected_diagnostics_check() {
let mut lines = vec![
Line::from(vec![
Span::styled(
format!("{:?}", check.command),
crate::ui::theme::screen_title_style(),
),
Span::raw(" "),
Span::styled(
severity_badge(check.severity),
severity_row_style(check.severity),
),
]),
Line::from(format!("Severity: {:?}", check.severity)),
Line::from(format!("Confidence: {:?}", check.confidence)),
Line::from(format!(
"Experimental: {}",
if check.is_experimental { "yes" } else { "no" }
)),
Line::from(format!(
"Error code: {}",
check
.error_code
.map(|code| format!("{code:?}"))
.unwrap_or_else(|| "none".to_owned())
)),
Line::from(format!(
"Response: {:?} • attempts {}",
check.response_status, check.attempts
)),
Line::from(format!(
"IO: wrote {}B, read {}B",
check.bytes_written, check.bytes_read
)),
Line::from(format!(
"Validator: {}",
truncate_to_width(&check.validator, area.width.saturating_sub(13) as usize)
)),
Line::from(""),
Line::from(Span::styled("Detail", crate::ui::theme::title_style())),
Line::from(check.detail.clone()),
];
if !check.parsed_facts.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Parsed facts",
crate::ui::theme::title_style(),
)));
for (key, value) in &check.parsed_facts {
lines.push(Line::from(format!("{key}: {value}")));
}
}
lines
} else {
vec![
Line::from("No diagnostics check selected."),
Line::from(""),
Line::from("Change filters or run diagnostics again."),
]
};
let detail = Paragraph::new(lines).block(panel_block("Selected Check", Some("detail"), true));
frame.render_widget(detail, area);
}
fn render_next_steps(frame: &mut Frame<'_>, state: &AppState, area: Rect) {
let Some(diagnostics) = state.diagnostics_state.as_ref() else {
let empty = Paragraph::new("No diagnostics guidance available.").block(panel_block(
"Next Steps",
Some("guidance"),
true,
));
frame.render_widget(empty, area);
return;
};
let report_line = diagnostics
.latest_report_path
.as_ref()
.map(|path| format!("Saved report: {}", path.display()))
.unwrap_or_else(|| "Saved report: not yet saved in this screen".to_owned());
let content_width = area.width.saturating_sub(4) as usize;
let action_line = format!(
"Action: {}",
truncate_to_width(
recommended_next_action(diagnostics),
content_width.saturating_sub(8)
)
);
let summary_line = format!(
"Summary: {}",
truncate_to_width(&diagnostics.summary, content_width.saturating_sub(9))
);
let report_line = truncate_to_width(&report_line, content_width);
let inner_height = area.height.saturating_sub(2);
let lines = match inner_height {
0 => Vec::new(),
1 => vec![Line::from(if diagnostics.latest_report_path.is_some() {
report_line.clone()
} else {
action_line.clone()
})],
2 => vec![
Line::from(action_line),
Line::from(Span::styled(report_line, crate::ui::theme::subtle_style())),
],
_ => vec![
Line::from(action_line),
Line::from(summary_line),
Line::from(Span::styled(report_line, crate::ui::theme::subtle_style())),
],
};
let panel = Paragraph::new(lines).block(panel_block("Next Steps", Some("guidance"), true));
frame.render_widget(panel, area);
}
fn diagnostics_action_caption(action: QuickAction) -> &'static str {
match action {
QuickAction::RunAgain => "run the safe checks again",
QuickAction::SaveReport => "save a shareable support report",
QuickAction::Back => "return to dashboard",
_ => "available",
}
}
fn recommended_next_action(diagnostics: &crate::app::state::DiagnosticsState) -> &'static str {
match diagnostics.result.support_tier {
bitdo_proto::SupportTier::Full => {
"Return to the dashboard for update or mapping if this device still needs work."
}
bitdo_proto::SupportTier::CandidateReadOnly => {
"Save or share the report. Update and mapping stay blocked until this device family is hardware-confirmed."
}
bitdo_proto::SupportTier::DetectOnly => {
"Use diagnostics only. This device is not ready for update or mapping flows."
}
}
}
fn severity_badge(severity: bitdo_proto::DiagSeverity) -> &'static str {
match severity {
bitdo_proto::DiagSeverity::Ok => "OK",
bitdo_proto::DiagSeverity::Warning => "WARN",
bitdo_proto::DiagSeverity::NeedsAttention => "ATTN",
}
}
fn severity_row_style(severity: bitdo_proto::DiagSeverity) -> Style {
match severity {
bitdo_proto::DiagSeverity::Ok => Style::default().fg(Color::White),
bitdo_proto::DiagSeverity::Warning => crate::ui::theme::warning_style(),
bitdo_proto::DiagSeverity::NeedsAttention => crate::ui::theme::danger_style(),
}
}
fn severity_style(has_issues: bool) -> Style {
if has_issues {
crate::ui::theme::warning_style()
} else {
crate::ui::theme::positive_style()
}
}
fn support_tier_label(tier: bitdo_proto::SupportTier) -> &'static str {
match tier {
bitdo_proto::SupportTier::Full => "supported",
bitdo_proto::SupportTier::CandidateReadOnly => "read-only candidate",
bitdo_proto::SupportTier::DetectOnly => "detect-only",
}
}
fn compact_filter_segments(
diagnostics: &crate::app::state::DiagnosticsState,
) -> Vec<(DiagnosticsFilter, String)> {
DiagnosticsFilter::ALL
.into_iter()
.map(|filter| {
let count = diagnostics
.result
.command_checks
.iter()
.filter(|check| filter.matches(check))
.count();
let label = match filter {
DiagnosticsFilter::All => format!("All {count}"),
DiagnosticsFilter::Issues => format!("Issues {count}"),
DiagnosticsFilter::Experimental => format!("Exp {count}"),
};
(filter, label)
})
.collect()
}