fix(logs): include the current session in the viewer
Read the active launcher, osu!, and patcher logs directly so the current session appears before rotation.
This commit is contained in:
1 parent
8616253065
commit
660fb557a6
2 files changed
+138
-31
No files matched your search
+130
-23
@@ -393,7 +393,97 @@ pub struct LogBundleEntry {
|
|||||||
pub content: String,
|
pub content: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List the rotating ZIP bundles, newest first, for the in-app log viewer.
|
const MAX_LOG_VIEW_BYTES: u64 = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
fn modified_millis(metadata: &fs::Metadata) -> u64 {
|
||||||
|
metadata
|
||||||
|
.modified()
|
||||||
|
.ok()
|
||||||
|
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
|
||||||
|
.map(|duration| duration.as_millis() as u64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Describe the active session. Unlike archived bundles, these files are still
|
||||||
|
/// being written and appear in the viewer without waiting for a rotation.
|
||||||
|
fn current_log_bundle(dir: &Path) -> Option<LogBundleInfo> {
|
||||||
|
let mut size = 0;
|
||||||
|
let mut modified = 0;
|
||||||
|
let mut has_logs = false;
|
||||||
|
|
||||||
|
for stream in Stream::ALL {
|
||||||
|
let Ok(metadata) = fs::metadata(dir.join(stream.file_name())) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if metadata.len() == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
has_logs = true;
|
||||||
|
size += metadata.len();
|
||||||
|
modified = modified.max(modified_millis(&metadata));
|
||||||
|
}
|
||||||
|
|
||||||
|
has_logs.then(|| LogBundleInfo {
|
||||||
|
path: dir.to_string_lossy().into_owned(),
|
||||||
|
name: "Current session".to_string(),
|
||||||
|
size,
|
||||||
|
modified,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_log_entry(
|
||||||
|
name: String,
|
||||||
|
reader: &mut impl Read,
|
||||||
|
size: u64,
|
||||||
|
) -> Result<LogBundleEntry, String> {
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
reader
|
||||||
|
.take(MAX_LOG_VIEW_BYTES)
|
||||||
|
.read_to_end(&mut bytes)
|
||||||
|
.map_err(|error| format!("Could not read a log entry: {error}"))?;
|
||||||
|
let mut content = String::from_utf8_lossy(&bytes).into_owned();
|
||||||
|
if size > bytes.len() as u64 {
|
||||||
|
content.push_str("\n… log truncated in the viewer …\n");
|
||||||
|
}
|
||||||
|
Ok(LogBundleEntry { name, content })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_current_logs(root: &Path) -> Result<Vec<LogBundleEntry>, String> {
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
|
||||||
|
for stream in Stream::ALL {
|
||||||
|
let path = root.join(stream.file_name());
|
||||||
|
let metadata = match fs::metadata(&path) {
|
||||||
|
Ok(metadata) => metadata,
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
|
||||||
|
Err(error) => {
|
||||||
|
return Err(format!(
|
||||||
|
"Could not read the current {}: {error}",
|
||||||
|
stream.file_name()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if metadata.len() == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut file = File::open(&path).map_err(|error| {
|
||||||
|
format!("Could not read the current {}: {error}", stream.file_name())
|
||||||
|
})?;
|
||||||
|
entries.push(read_log_entry(
|
||||||
|
stream.file_name().to_string(),
|
||||||
|
&mut file,
|
||||||
|
metadata.len(),
|
||||||
|
)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List the active session followed by the rotating ZIP bundles, newest first,
|
||||||
|
/// for the in-app log viewer.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn list_log_bundles() -> Result<Vec<LogBundleInfo>, String> {
|
pub fn list_log_bundles() -> Result<Vec<LogBundleInfo>, String> {
|
||||||
let dir = logs_directory();
|
let dir = logs_directory();
|
||||||
@@ -401,6 +491,7 @@ pub fn list_log_bundles() -> Result<Vec<LogBundleInfo>, String> {
|
|||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let current = current_log_bundle(&dir);
|
||||||
let mut bundles = fs::read_dir(&dir)
|
let mut bundles = fs::read_dir(&dir)
|
||||||
.map_err(|error| format!("Could not read the logs folder: {error}"))?
|
.map_err(|error| format!("Could not read the logs folder: {error}"))?
|
||||||
.filter_map(|entry| entry.ok())
|
.filter_map(|entry| entry.ok())
|
||||||
@@ -411,26 +502,23 @@ pub fn list_log_bundles() -> Result<Vec<LogBundleInfo>, String> {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let metadata = entry.metadata().ok()?;
|
let metadata = entry.metadata().ok()?;
|
||||||
let modified = metadata
|
|
||||||
.modified()
|
|
||||||
.ok()
|
|
||||||
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
|
|
||||||
.map(|duration| duration.as_millis() as u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
Some(LogBundleInfo {
|
Some(LogBundleInfo {
|
||||||
path: path.to_string_lossy().into_owned(),
|
path: path.to_string_lossy().into_owned(),
|
||||||
name,
|
name,
|
||||||
size: metadata.len(),
|
size: metadata.len(),
|
||||||
modified,
|
modified: modified_millis(&metadata),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
bundles.sort_by_key(|b| std::cmp::Reverse(b.modified));
|
bundles.sort_by_key(|b| std::cmp::Reverse(b.modified));
|
||||||
|
if let Some(current) = current {
|
||||||
|
bundles.insert(0, current);
|
||||||
|
}
|
||||||
Ok(bundles)
|
Ok(bundles)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read the launcher/osu/patcher logs out of one bundle.
|
/// Read the active logs or the launcher/osu/patcher logs out of an archive.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn read_log_bundle(path: String) -> Result<Vec<LogBundleEntry>, String> {
|
pub fn read_log_bundle(path: String) -> Result<Vec<LogBundleEntry>, String> {
|
||||||
let dir = logs_directory();
|
let dir = logs_directory();
|
||||||
@@ -441,6 +529,9 @@ pub fn read_log_bundle(path: String) -> Result<Vec<LogBundleEntry>, String> {
|
|||||||
if !requested.starts_with(&root) {
|
if !requested.starts_with(&root) {
|
||||||
return Err("The log bundle is outside the logs folder".to_string());
|
return Err("The log bundle is outside the logs folder".to_string());
|
||||||
}
|
}
|
||||||
|
if requested == root {
|
||||||
|
return read_current_logs(&root);
|
||||||
|
}
|
||||||
|
|
||||||
let file = File::open(&requested)
|
let file = File::open(&requested)
|
||||||
.map_err(|error| format!("Could not read the log bundle: {error}"))?;
|
.map_err(|error| format!("Could not read the log bundle: {error}"))?;
|
||||||
@@ -456,20 +547,9 @@ pub fn read_log_bundle(path: String) -> Result<Vec<LogBundleEntry>, String> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut bytes = Vec::new();
|
let name = entry.name().to_string();
|
||||||
entry
|
let size = entry.size();
|
||||||
.by_ref()
|
entries.push(read_log_entry(name, &mut entry, size)?);
|
||||||
.take(2 * 1024 * 1024)
|
|
||||||
.read_to_end(&mut bytes)
|
|
||||||
.map_err(|error| format!("Could not read a log entry: {error}"))?;
|
|
||||||
let mut content = String::from_utf8_lossy(&bytes).into_owned();
|
|
||||||
if entry.size() > bytes.len() as u64 {
|
|
||||||
content.push_str("\n… log truncated in the viewer …\n");
|
|
||||||
}
|
|
||||||
entries.push(LogBundleEntry {
|
|
||||||
name: entry.name().to_string(),
|
|
||||||
content,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
entries.sort_by(|a, b| a.name.cmp(&b.name));
|
entries.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
@@ -634,4 +714,31 @@ mod tests {
|
|||||||
drop(current);
|
drop(current);
|
||||||
fs::remove_dir_all(dir).unwrap();
|
fs::remove_dir_all(dir).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn current_session_includes_active_logs() {
|
||||||
|
let dir = temp_dir("current");
|
||||||
|
let logs = Arc::new(LogSet::new(dir.clone()));
|
||||||
|
write_line(&logs, Stream::Launcher, "launcher current\n");
|
||||||
|
write_line(&logs, Stream::Osu, "osu current\n");
|
||||||
|
|
||||||
|
let current = current_log_bundle(&dir).expect("active logs should have a session");
|
||||||
|
assert_eq!(current.name, "Current session");
|
||||||
|
assert_eq!(current.path, dir.to_string_lossy());
|
||||||
|
assert!(current.size > 0);
|
||||||
|
|
||||||
|
let entries = read_current_logs(&dir).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.map(|entry| entry.name.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
["launcher.log", "osu.log"]
|
||||||
|
);
|
||||||
|
assert_eq!(entries[0].content, "launcher current\n");
|
||||||
|
assert_eq!(entries[1].content, "osu current\n");
|
||||||
|
|
||||||
|
drop(logs);
|
||||||
|
fs::remove_dir_all(dir).unwrap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -154,9 +154,9 @@
|
|||||||
<Terminal class="size-4" />
|
<Terminal class="size-4" />
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<h2 id="log-viewer-title" class="text-sm font-semibold">Log bundles</h2>
|
<h2 id="log-viewer-title" class="text-sm font-semibold">Logs</h2>
|
||||||
<p class="truncate text-[10px] text-muted-foreground">
|
<p class="truncate text-[10px] text-muted-foreground">
|
||||||
Launcher, osu! and patcher output, grouped by session
|
Current session plus archived launcher, osu! and patcher output
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -191,7 +191,7 @@
|
|||||||
<div
|
<div
|
||||||
class="flex shrink-0 items-center justify-between border-b border-theme-800 px-3 py-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"
|
class="flex shrink-0 items-center justify-between border-b border-theme-800 px-3 py-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"
|
||||||
>
|
>
|
||||||
<span>Bundles</span>
|
<span>Sessions</span>
|
||||||
<span>{bundles.length}</span>
|
<span>{bundles.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="min-h-0 flex-1 overflow-y-auto p-1.5">
|
<div class="min-h-0 flex-1 overflow-y-auto p-1.5">
|
||||||
@@ -202,8 +202,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if bundles.length === 0}
|
{:else if bundles.length === 0}
|
||||||
<div class="p-3 text-[11px] leading-relaxed text-muted-foreground">
|
<div class="p-3 text-[11px] leading-relaxed text-muted-foreground">
|
||||||
No bundles yet. Logs are zipped on the next launcher restart or when a log reaches 5
|
No logs yet. The current session appears once the launcher writes its first line;
|
||||||
MiB.
|
archived bundles are created when logs rotate.
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each bundles as bundle (bundle.path)}
|
{#each bundles as bundle (bundle.path)}
|
||||||
@@ -291,14 +291,14 @@
|
|||||||
class="flex h-full items-center justify-center gap-2 text-xs text-muted-foreground"
|
class="flex h-full items-center justify-center gap-2 text-xs text-muted-foreground"
|
||||||
>
|
>
|
||||||
<LoaderCircle class="size-4 animate-spin" />
|
<LoaderCircle class="size-4 animate-spin" />
|
||||||
Reading bundle…
|
Reading logs…
|
||||||
</div>
|
</div>
|
||||||
{:else if !selectedBundle}
|
{:else if !selectedBundle}
|
||||||
<div
|
<div
|
||||||
class="flex h-full flex-col items-center justify-center gap-2 text-center text-muted-foreground"
|
class="flex h-full flex-col items-center justify-center gap-2 text-center text-muted-foreground"
|
||||||
>
|
>
|
||||||
<Terminal class="size-8 opacity-40" />
|
<Terminal class="size-8 opacity-40" />
|
||||||
<p class="text-xs">Choose a log bundle to inspect it.</p>
|
<p class="text-xs">Choose a log session to inspect it.</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if filteredLines.length === 0}
|
{:else if filteredLines.length === 0}
|
||||||
<div
|
<div
|
||||||
@@ -328,7 +328,7 @@
|
|||||||
<span>
|
<span>
|
||||||
{selectedBundle
|
{selectedBundle
|
||||||
? `${selectedBundle.name} · ${formatBytes(selectedBundle.size)}`
|
? `${selectedBundle.name} · ${formatBytes(selectedBundle.size)}`
|
||||||
: 'No bundle selected'}
|
: 'No session selected'}
|
||||||
</span>
|
</span>
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
Reference in new issue
Block a user