686 lines
23 KiB
Rust
686 lines
23 KiB
Rust
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
|
|
use freya::prelude::*;
|
|
use freya::query::UseQuery;
|
|
use oneclient_common::search::SearchQuery;
|
|
use oneclient_core::{LogFileInfo, LogKind, LogLevel};
|
|
|
|
use crate::components::upload_mclogs::{UploadToMclogs, use_mclogs_feedback};
|
|
use crate::components::{
|
|
Button, Icon, IconType, LogViewer, OverlayPopup, ScrollArea, Segment, SegmentedControl,
|
|
TextInput, open_folder_button,
|
|
};
|
|
use crate::hooks::{
|
|
ClusterLogsQuery, LogAction, UploadLogKeys, UseLogAction, UseUploadLog,
|
|
invalidate_logs_queries, try_cluster_logs, try_log_content, use_cluster_logs, use_log_action,
|
|
use_log_content, use_upload_log,
|
|
};
|
|
use crate::layout::cluster_content;
|
|
use crate::theme::colors;
|
|
use crate::ui;
|
|
use crate::utils::format_size;
|
|
|
|
use super::cluster_not_found;
|
|
use crate::hooks::use_cluster;
|
|
const CARD_BG: Color = Color::from_rgb(26, 34, 41);
|
|
const PICKER_ROW_H: f32 = 44.;
|
|
const PICKER_ROW_SPACING: f32 = 2.;
|
|
const PICKER_LIST_MAX_H: f32 = 300.;
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum LevelFilter {
|
|
All,
|
|
Error,
|
|
Warn,
|
|
Info,
|
|
Debug,
|
|
}
|
|
|
|
impl LevelFilter {
|
|
pub fn to_level(self) -> Option<LogLevel> {
|
|
match self {
|
|
Self::All => None,
|
|
Self::Error => Some(LogLevel::Error),
|
|
Self::Warn => Some(LogLevel::Warn),
|
|
Self::Info => Some(LogLevel::Info),
|
|
Self::Debug => Some(LogLevel::Debug),
|
|
}
|
|
}
|
|
|
|
pub fn label(self) -> &'static str {
|
|
match self {
|
|
Self::All => "All",
|
|
Self::Error => "Errors",
|
|
Self::Warn => "Warnings",
|
|
Self::Info => "Info",
|
|
Self::Debug => "Debug",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq)]
|
|
pub enum Confirm {
|
|
Clear,
|
|
Upload,
|
|
}
|
|
|
|
/// A crash report buried among thirty rotated `.log.gz` files is one nobody finds
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum Category {
|
|
Logs,
|
|
CrashReports,
|
|
}
|
|
|
|
impl Category {
|
|
fn accepts(self, kind: LogKind) -> bool {
|
|
kind.is_crash_report() == matches!(self, Self::CrashReports)
|
|
}
|
|
|
|
fn label(self) -> &'static str {
|
|
match self {
|
|
Self::Logs => "Logs",
|
|
Self::CrashReports => "Crash reports",
|
|
}
|
|
}
|
|
|
|
fn empty_label(self) -> &'static str {
|
|
match self {
|
|
Self::Logs => "No logs",
|
|
Self::CrashReports => "No crash reports",
|
|
}
|
|
}
|
|
|
|
fn empty_note(self) -> &'static str {
|
|
match self {
|
|
Self::Logs => "No logs recorded yet",
|
|
Self::CrashReports => "No crash reports, which is the good outcome",
|
|
}
|
|
}
|
|
}
|
|
|
|
fn kind_badge(kind: LogKind) -> &'static str {
|
|
match kind {
|
|
LogKind::Game { .. } => "Game output",
|
|
LogKind::Minecraft => "Minecraft",
|
|
LogKind::CrashReport => "Crash report",
|
|
LogKind::Other => "Other",
|
|
}
|
|
}
|
|
|
|
#[derive(PartialEq)]
|
|
pub struct ClusterLogs {
|
|
pub cluster_id: i64,
|
|
}
|
|
|
|
impl Component for ClusterLogs {
|
|
fn render(&self) -> impl IntoElement {
|
|
let cluster_id = self.cluster_id;
|
|
|
|
let logs_query = use_cluster_logs(cluster_id);
|
|
let upload = use_upload_log();
|
|
let action = use_log_action();
|
|
let search = use_state(String::new);
|
|
let level = use_state(|| LevelFilter::All);
|
|
let confirm = use_state(|| None::<Confirm>);
|
|
let category = use_state(|| Category::Logs);
|
|
let mut selected = use_state(PathBuf::new);
|
|
|
|
use_mclogs_feedback(upload);
|
|
|
|
// Reads `category` too so switching the picker re-picks a file instead of keeping a stale selection
|
|
use_side_effect(move || {
|
|
let files = visible_files(&logs_query, *category.read());
|
|
|
|
if files.is_empty() {
|
|
if !selected.peek().as_os_str().is_empty() {
|
|
selected.set(PathBuf::new());
|
|
}
|
|
return;
|
|
}
|
|
|
|
let current = selected.peek().clone();
|
|
let valid = !current.as_os_str().is_empty() && files.iter().any(|f| f.path == current);
|
|
|
|
if !valid {
|
|
let pick = files
|
|
.iter()
|
|
.find(|f| f.name == "latest.log")
|
|
.or_else(|| files.first());
|
|
|
|
if let Some(file) = pick {
|
|
selected.set(file.path.clone());
|
|
}
|
|
}
|
|
});
|
|
|
|
let selected_path = selected.read().clone();
|
|
let search_query = {
|
|
let value = search.read().clone();
|
|
(!value.trim().is_empty()).then_some(value)
|
|
};
|
|
|
|
let content_query = use_log_content(
|
|
selected_path.clone(),
|
|
level.read().to_level(),
|
|
search_query,
|
|
None,
|
|
);
|
|
|
|
let Some(cluster) = use_cluster(cluster_id) else {
|
|
return cluster_not_found();
|
|
};
|
|
let folder = cluster.dir().ok().map(|d| d.join("logs"));
|
|
|
|
let files = visible_files(&logs_query, *category.read());
|
|
let has_log = !files.is_empty();
|
|
let selected_info = files.iter().find(|f| f.path == selected_path).cloned();
|
|
|
|
let lines = try_log_content(&content_query).unwrap_or_default();
|
|
let viewer_lines: Arc<Vec<Arc<str>>> = Arc::new(
|
|
lines
|
|
.iter()
|
|
.map(|line| Arc::<str>::from(line.text.as_str()))
|
|
.collect(),
|
|
);
|
|
|
|
let body = rect()
|
|
.vertical()
|
|
.width(Size::fill())
|
|
.height(Size::fill())
|
|
.spacing(12.)
|
|
.child(top_bar(
|
|
files.clone(),
|
|
selected_info.clone(),
|
|
folder,
|
|
selected,
|
|
confirm,
|
|
category,
|
|
))
|
|
.child(
|
|
rect().width(Size::fill()).height(Size::flex(1.0)).child(
|
|
LogViewer::new("", viewer_lines)
|
|
.header(viewer_header(search, level, confirm, has_log)),
|
|
),
|
|
);
|
|
|
|
cluster_content()
|
|
.child(body.into_element())
|
|
.maybe_child((*confirm.read()).map(move |kind| {
|
|
confirm_overlay(kind, confirm, action, upload, selected_path.clone())
|
|
}))
|
|
.into_element()
|
|
}
|
|
}
|
|
|
|
pub fn visible_files(query: &UseQuery<ClusterLogsQuery>, category: Category) -> Vec<LogFileInfo> {
|
|
try_cluster_logs(query)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.filter(|file| category.accepts(file.kind))
|
|
.collect()
|
|
}
|
|
|
|
fn top_bar(
|
|
files: Vec<LogFileInfo>,
|
|
selected_info: Option<LogFileInfo>,
|
|
folder: Option<PathBuf>,
|
|
selected: State<PathBuf>,
|
|
mut confirm: State<Option<Confirm>>,
|
|
category: State<Category>,
|
|
) -> impl IntoElement {
|
|
let has_log = !files.is_empty();
|
|
|
|
let picker_label = selected_info
|
|
.as_ref()
|
|
.map(|i| i.name.clone())
|
|
.unwrap_or_else(|| category.read().empty_label().to_string());
|
|
|
|
let size_text = selected_info
|
|
.as_ref()
|
|
.map(|i| format!("{} on disk", format_size(i.size_bytes)))
|
|
.unwrap_or_else(|| category.read().empty_note().to_string());
|
|
|
|
rect()
|
|
.horizontal()
|
|
.width(Size::fill())
|
|
.cross_align(Alignment::Center)
|
|
.content(Content::Flex)
|
|
.spacing(10.)
|
|
.child(
|
|
LogPicker {
|
|
label: picker_label,
|
|
files,
|
|
category,
|
|
on_select: (move |path: PathBuf| {
|
|
let mut selected = selected;
|
|
selected.set(path);
|
|
})
|
|
.into(),
|
|
}
|
|
.into_element(),
|
|
)
|
|
.child(
|
|
label()
|
|
.text(size_text)
|
|
.font_size(11.)
|
|
.max_lines(1)
|
|
.color(colors::fg_secondary()),
|
|
)
|
|
.child(rect().width(Size::flex(1.0)))
|
|
.maybe_child(folder.map(open_folder_button))
|
|
.child(
|
|
Button::new()
|
|
.secondary()
|
|
.on_press(move |_| {
|
|
spawn(async move {
|
|
invalidate_logs_queries().await;
|
|
});
|
|
})
|
|
.child(Icon::new(IconType::RefreshCw01).size(15.))
|
|
.text("Refresh"),
|
|
)
|
|
.child(
|
|
Button::new()
|
|
.danger()
|
|
.enabled(has_log)
|
|
.on_press(move |_| confirm.set(Some(Confirm::Clear)))
|
|
.child(Icon::new(IconType::Trash01).size(15.))
|
|
.text("Clear log"),
|
|
)
|
|
.into_element()
|
|
}
|
|
|
|
pub fn viewer_header(
|
|
search: State<String>,
|
|
level: State<LevelFilter>,
|
|
confirm: State<Option<Confirm>>,
|
|
has_log: bool,
|
|
) -> impl IntoElement {
|
|
let search_input = {
|
|
let base = TextInput::new(search).enabled(has_log).leading(
|
|
Icon::new(IconType::SearchMd)
|
|
.size(15.)
|
|
.color(colors::fg_secondary())
|
|
.into_element(),
|
|
);
|
|
if has_log {
|
|
base.placeholder("Search this log...")
|
|
} else {
|
|
base
|
|
}
|
|
};
|
|
|
|
let row = rect()
|
|
.horizontal()
|
|
.width(Size::fill())
|
|
.cross_align(Alignment::Center)
|
|
.content(Content::Flex)
|
|
.spacing(10.)
|
|
.padding(Gaps::new_symmetric(10., 14.))
|
|
.child(rect().width(Size::flex(1.0)).child(search_input))
|
|
.child(
|
|
SegmentedControl::new(level)
|
|
.no_tint()
|
|
.disabled(!has_log)
|
|
.segments(
|
|
[
|
|
LevelFilter::All,
|
|
LevelFilter::Error,
|
|
LevelFilter::Warn,
|
|
LevelFilter::Info,
|
|
LevelFilter::Debug,
|
|
]
|
|
.into_iter()
|
|
.map(|filter| Segment::new(filter).label(filter.label())),
|
|
)
|
|
.into_element(),
|
|
)
|
|
.child(
|
|
UploadToMclogs::new(has_log, confirm)
|
|
);
|
|
|
|
rect()
|
|
.vertical()
|
|
.width(Size::fill())
|
|
.child(row)
|
|
.child(
|
|
rect()
|
|
.width(Size::fill())
|
|
.height(Size::px(1.))
|
|
.background(colors::component_border()),
|
|
)
|
|
.into_element()
|
|
}
|
|
|
|
#[derive(PartialEq)]
|
|
struct LogPicker {
|
|
label: String,
|
|
files: Vec<LogFileInfo>,
|
|
/// Owned by the page it filters `files` by this so both must see the same value
|
|
category: State<Category>,
|
|
on_select: EventHandler<PathBuf>,
|
|
}
|
|
|
|
impl Component for LogPicker {
|
|
fn render(&self) -> impl IntoElement {
|
|
let mut open = use_state(|| false);
|
|
let filter = use_state(String::new);
|
|
|
|
let a11y_id = use_a11y();
|
|
let focus = use_focus(a11y_id);
|
|
|
|
let category = self.category;
|
|
let label_text = self.label.clone();
|
|
let files = self.files.clone();
|
|
let on_select = self.on_select.clone();
|
|
// Stays usable with an empty list the category selector lives inside the panel
|
|
let is_open = open();
|
|
|
|
let query = SearchQuery::new(&filter.read());
|
|
let no_files = files.is_empty();
|
|
let rows: Vec<Element> = files
|
|
.into_iter()
|
|
.filter(|f| query.matches(&f.name) || query.matches(kind_badge(f.kind)))
|
|
.map(|file| {
|
|
let on_select = on_select.clone();
|
|
let path = file.path.clone();
|
|
PickerRow {
|
|
file,
|
|
on_press: (move |_: Event<PressEventData>| {
|
|
on_select.call(path.clone());
|
|
open.set(false);
|
|
})
|
|
.into(),
|
|
}
|
|
.into_element()
|
|
})
|
|
.collect();
|
|
let list_h = ((rows.len() as f32 * (PICKER_ROW_H + PICKER_ROW_SPACING))
|
|
- PICKER_ROW_SPACING)
|
|
.clamp(0., PICKER_LIST_MAX_H);
|
|
|
|
rect()
|
|
.width(Size::px(260.))
|
|
.child(
|
|
rect()
|
|
.width(Size::fill())
|
|
.height(Size::px(36.))
|
|
.horizontal()
|
|
.cross_align(Alignment::Center)
|
|
.content(Content::Flex)
|
|
.spacing(8.)
|
|
.padding(Gaps::new_symmetric(0., 12.))
|
|
.corner_radius(CornerRadius::new_all(9.))
|
|
.background(colors::component_bg())
|
|
.a11y_id(a11y_id)
|
|
.a11y_focusable(true)
|
|
.a11y_role(AccessibilityRole::Button)
|
|
.border(ui::border_all_color(
|
|
1.,
|
|
if focus().is_focused() {
|
|
colors::brand()
|
|
} else {
|
|
colors::component_border()
|
|
},
|
|
))
|
|
.cursor(CursorIcon::Pointer)
|
|
.on_all_press(move |e: Event<PressEventData>| {
|
|
e.stop_propagation();
|
|
open.toggle();
|
|
})
|
|
.child(
|
|
rect().width(Size::flex(1.0)).child(
|
|
label()
|
|
.text(label_text)
|
|
.font_size(13.)
|
|
.max_lines(1)
|
|
.color(colors::fg_primary()),
|
|
),
|
|
)
|
|
.child(
|
|
Icon::new(IconType::ChevronDown)
|
|
.size(15.)
|
|
.color(colors::fg_secondary()),
|
|
),
|
|
)
|
|
.maybe_child(is_open.then(|| {
|
|
rect()
|
|
.height(Size::px(0.))
|
|
.width(Size::px(0.))
|
|
.layer(Layer::Overlay)
|
|
.child(
|
|
rect()
|
|
.position(Position::new_global().top(0.).left(0.))
|
|
.width(Size::window_percent(100.))
|
|
.height(Size::window_percent(100.))
|
|
.layer(Layer::OverlayLevel(10))
|
|
.on_press(move |_| open.set(false)),
|
|
)
|
|
.child(
|
|
rect()
|
|
.margin(Gaps::new(4., 0., 0., 0.))
|
|
.width(Size::px(320.))
|
|
// Room for the category selector above the search box
|
|
.max_height(Size::px(400.))
|
|
.layer(Layer::OverlayLevel(12))
|
|
.vertical()
|
|
.spacing(4.)
|
|
.padding(6.)
|
|
.corner_radius(CornerRadius::new_all(10.))
|
|
.border(ui::border_all_color(1., colors::component_border()))
|
|
.background(colors::page_elevated())
|
|
.overflow(Overflow::Clip)
|
|
.child(
|
|
SegmentedControl::new(category)
|
|
.no_tint()
|
|
.equal_width(147.)
|
|
.segments(
|
|
[Category::Logs, Category::CrashReports]
|
|
.into_iter()
|
|
.map(|value| Segment::new(value).label(value.label())),
|
|
),
|
|
)
|
|
.child(
|
|
TextInput::new(filter)
|
|
.auto_focus(true)
|
|
.placeholder("Search logs...")
|
|
.leading(
|
|
Icon::new(IconType::SearchMd)
|
|
.size(14.)
|
|
.color(colors::fg_secondary())
|
|
.into_element(),
|
|
),
|
|
)
|
|
.maybe_child(rows.is_empty().then(|| {
|
|
label()
|
|
.text(if no_files {
|
|
category.read().empty_note()
|
|
} else {
|
|
"No matches"
|
|
})
|
|
.font_size(12.)
|
|
.width(Size::fill())
|
|
.text_align(TextAlign::Center)
|
|
.padding(Gaps::new_symmetric(12., 0.))
|
|
.color(colors::fg_secondary())
|
|
.into_element()
|
|
}))
|
|
.child(
|
|
ScrollArea::new()
|
|
.height(Size::px(list_h))
|
|
.spacing(PICKER_ROW_SPACING)
|
|
.children(rows),
|
|
),
|
|
)
|
|
}))
|
|
}
|
|
}
|
|
|
|
struct PickerRow {
|
|
file: LogFileInfo,
|
|
on_press: EventHandler<Event<PressEventData>>,
|
|
}
|
|
|
|
impl PartialEq for PickerRow {
|
|
fn eq(&self, other: &Self) -> bool {
|
|
self.file.path == other.file.path
|
|
}
|
|
}
|
|
|
|
impl Component for PickerRow {
|
|
fn render(&self) -> impl IntoElement {
|
|
let mut hovering = use_state(|| false);
|
|
let file = self.file.clone();
|
|
|
|
let a11y_id = use_a11y();
|
|
let focus = use_focus(a11y_id);
|
|
|
|
rect()
|
|
.width(Size::fill())
|
|
.horizontal()
|
|
.cross_align(Alignment::Center)
|
|
.content(Content::Flex)
|
|
.spacing(8.)
|
|
.padding(Gaps::new_symmetric(6., 8.))
|
|
.corner_radius(CornerRadius::new_all(7.))
|
|
.a11y_id(a11y_id)
|
|
.a11y_focusable(true)
|
|
.a11y_role(AccessibilityRole::Button)
|
|
.background(if hovering() || focus().is_focused() {
|
|
colors::ghost_overlay_hover()
|
|
} else {
|
|
Color::TRANSPARENT
|
|
})
|
|
.cursor(CursorIcon::Pointer)
|
|
.on_pointer_enter(move |_| hovering.set(true))
|
|
.on_pointer_leave(move |_| hovering.set(false))
|
|
.on_all_press(self.on_press.clone())
|
|
.child(
|
|
rect()
|
|
.vertical()
|
|
.width(Size::flex(1.0))
|
|
.spacing(1.)
|
|
.child(
|
|
label()
|
|
.text(file.name.clone())
|
|
.font_size(13.)
|
|
.max_lines(1)
|
|
.color(colors::fg_primary()),
|
|
)
|
|
.child(
|
|
label()
|
|
.text(format!(
|
|
"{} • {}",
|
|
kind_badge(file.kind),
|
|
format_size(file.size_bytes)
|
|
))
|
|
.font_size(10.)
|
|
.color(colors::fg_secondary()),
|
|
),
|
|
)
|
|
.into_element()
|
|
}
|
|
}
|
|
|
|
pub fn confirm_overlay(
|
|
kind: Confirm,
|
|
mut confirm: State<Option<Confirm>>,
|
|
action: UseLogAction,
|
|
upload: UseUploadLog,
|
|
path: PathBuf,
|
|
) -> impl IntoElement {
|
|
let (title, body, confirm_label, danger) = match kind {
|
|
Confirm::Clear => (
|
|
"Delete this log?",
|
|
"This log file will be permanently removed from disk.".to_string(),
|
|
"Delete",
|
|
true,
|
|
),
|
|
Confirm::Upload => (
|
|
"Upload to mclo.gs?",
|
|
"This log will be uploaded to mclo.gs and hosted publicly. Anyone with the link can read it. Don't upload logs containing sensitive information.".to_string(),
|
|
"Upload",
|
|
false,
|
|
),
|
|
};
|
|
|
|
let run = move |_| {
|
|
match kind {
|
|
Confirm::Clear => action.mutate(LogAction::Delete { path: path.clone() }),
|
|
Confirm::Upload => upload.mutate(UploadLogKeys { path: path.clone() }),
|
|
}
|
|
confirm.set(None);
|
|
};
|
|
|
|
OverlayPopup::new()
|
|
.on_close(move |_| confirm.set(None))
|
|
.child(
|
|
rect()
|
|
.width(Size::window_percent(100.))
|
|
.height(Size::window_percent(100.))
|
|
.center()
|
|
.child(
|
|
rect()
|
|
.vertical()
|
|
.width(Size::px(440.))
|
|
.max_width(Size::window_percent(90.))
|
|
.spacing(14.)
|
|
.padding(Gaps::new_all(20.))
|
|
.corner_radius(CornerRadius::new_all(14.))
|
|
.background(CARD_BG)
|
|
.border(ui::border_all_color(1., colors::component_border()))
|
|
.child(
|
|
rect()
|
|
.horizontal()
|
|
.cross_align(Alignment::Center)
|
|
.spacing(10.)
|
|
.child(Icon::new(IconType::AlertTriangle).size(20.).color(
|
|
if danger {
|
|
colors::code_error()
|
|
} else {
|
|
colors::code_warn()
|
|
},
|
|
))
|
|
.child(
|
|
label()
|
|
.text(title)
|
|
.font_size(16.)
|
|
.font_weight(FontWeight::SEMI_BOLD)
|
|
.color(colors::fg_primary()),
|
|
),
|
|
)
|
|
.child(
|
|
label()
|
|
.text(body)
|
|
.font_size(12.)
|
|
.max_lines(6)
|
|
.width(Size::fill())
|
|
.color(colors::fg_secondary()),
|
|
)
|
|
.child(
|
|
rect()
|
|
.horizontal()
|
|
.width(Size::fill())
|
|
.main_align(Alignment::End)
|
|
.spacing(8.)
|
|
.child(
|
|
Button::new()
|
|
.secondary()
|
|
.on_press(move |_| confirm.set(None))
|
|
.text("Cancel"),
|
|
)
|
|
.child(
|
|
if danger {
|
|
Button::new().danger()
|
|
} else {
|
|
Button::new().primary()
|
|
}
|
|
.on_press(run)
|
|
.text(confirm_label),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
.into_element()
|
|
}
|