feat(observability): add centralized diagnostics logging engine and native log viewer panel

- Configure tauri-plugin-log with 10MB rotation and 3-file retention
- Add high-frequency format filter to strip [download]% progress ticks
- Hook yt-dlp, aria2c, and native reqwest runners with lifecycle log macros
- Add export_logs Tauri command for async log file export via save dialog
- Create DiagnosticsView React component with streaming monospace console
- Apply severity highlighting: ERROR=red, WARN=yellow, INFO=grey, DEBUG=dim
- Wire Diagnostics tab into sidebar navigation and ActiveView routing
- Log native download failures to log::error! in download.rs
This commit is contained in:
NimBold
2026-06-17 10:57:32 +03:30
parent 69a7192fda
commit cb39113117
9 changed files with 207 additions and 4 deletions
+1 -1
View File
@@ -189,7 +189,7 @@ impl CoordinatorEventSink {
fn emit_failed(&self, id: Uuid, error: String) {
match self {
Self::Tauri(app_handle) => {
eprintln!("download {id} failed: {error}");
log::error!("native download {} failed: {}", id, error);
let _ = app_handle.emit("download-failed", id.to_string());
}
Self::Headless(event_tx) => {
+1
View File
@@ -154,6 +154,7 @@ pub enum ActiveView {
Scheduler,
#[serde(rename = "speedLimiter")]
SpeedLimiter,
Diagnostics,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
+34 -1
View File
@@ -1301,6 +1301,29 @@ fn delete_file(app_handle: tauri::AppHandle, path: String) -> Result<(), String>
}
}
#[tauri::command]
async fn export_logs(app_handle: tauri::AppHandle, dest_path: String) -> Result<String, String> {
use tauri::Manager;
let log_dir = app_handle.path().app_log_dir().map_err(|e| e.to_string())?;
let log_file = log_dir.join("firelink.log");
let src = if log_file.exists() {
log_file
} else {
let mut found = None;
if let Ok(mut entries) = tokio::fs::read_dir(&log_dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
if entry.path().extension().is_some_and(|e| e == "log") {
found = Some(entry.path());
break;
}
}
}
found.ok_or_else(|| "No log file found in app log directory".to_string())?
};
tokio::fs::copy(&src, &dest_path).await.map_err(|e| e.to_string())?;
Ok(dest_path)
}
#[tauri::command]
fn toggle_tray_icon(app_handle: tauri::AppHandle, show: bool) -> Result<(), String> {
use tauri::tray::TrayIconBuilder;
@@ -1785,6 +1808,15 @@ pub fn run() {
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Webview),
])
.level(if cfg!(debug_assertions) { log::LevelFilter::Debug } else { log::LevelFilter::Info })
.max_file_size(10_000_000)
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepSome(3))
.format(move |out, message, _record| {
let msg = message.to_string();
if msg.contains("[download]") && msg.contains('%') {
return;
}
out.finish(format_args!("{}\n", msg));
})
.build(),
)
.plugin(tauri_plugin_store::Builder::new().build())
@@ -1811,7 +1843,8 @@ pub fn run() {
enqueue_download, enqueue_many, move_in_queue, remove_from_queue, get_pending_order,
commands::reveal_in_file_manager, commands::open_downloaded_file, commands::trash_download_assets,
parity::get_system_proxy, parity::get_file_category, parity::check_for_updates, parity::is_supported_media, parity::get_supported_media_domains,
parity::create_category_directories
parity::create_category_directories,
export_logs
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
+2
View File
@@ -14,6 +14,7 @@ import { useSettingsStore } from "./store/useSettingsStore";
import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification';
import SchedulerView from "./components/SchedulerView";
import SpeedLimiterView from "./components/SpeedLimiterView";
import DiagnosticsView from "./components/DiagnosticsView";
function App() {
const [filter, setFilter] = useState<SidebarFilter>('all');
@@ -305,6 +306,7 @@ function App() {
{activeView === 'settings' && <SettingsView />}
{activeView === 'scheduler' && <SchedulerView />}
{activeView === 'speedLimiter' && <SpeedLimiterView />}
{activeView === 'diagnostics' && <DiagnosticsView />}
</div>
{/* Status Bar */}
+1 -1
View File
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ActiveView = "downloads" | "settings" | "scheduler" | "speedLimiter";
export type ActiveView = "downloads" | "settings" | "scheduler" | "speedLimiter" | "diagnostics";
+105
View File
@@ -0,0 +1,105 @@
import { useEffect, useRef, useState } from 'react';
import { listen } from '@tauri-apps/api/event';
import { invokeCommand as invoke } from '../ipc';
import { save } from '@tauri-apps/plugin-dialog';
import { FileDown, Trash2, Terminal } from 'lucide-react';
import { WindowDragRegion } from './WindowDragRegion';
interface LogEntry {
level: 'Trace' | 'Debug' | 'Info' | 'Warn' | 'Error';
message: string;
}
export default function DiagnosticsView() {
const [logs, setLogs] = useState<LogEntry[]>([]);
const scrollRef = useRef<HTMLDivElement>(null);
const MAX_LOG_LINES = 2000;
useEffect(() => {
const unlisten = listen<{ level: string; message: string }>('log', (event) => {
const level = event.payload.level as LogEntry['level'];
const message = event.payload.message;
if (message.includes('[download]') && message.includes('%')) return;
setLogs(prev => {
const next = [...prev, { level, message }];
return next.length > MAX_LOG_LINES ? next.slice(-MAX_LOG_LINES) : next;
});
});
return () => { unlisten.then(f => f()); };
}, []);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [logs]);
const handleExport = async () => {
try {
const path = await save({
defaultPath: 'Firelink-Diagnostics.log',
filters: [{ name: 'Log Files', extensions: ['log'] }],
});
if (!path) return;
await invoke('export_logs', { destPath: path });
} catch (e) {
console.error('Export failed:', e);
}
};
const handleClear = () => setLogs([]);
const severityClass = (level: string) => {
switch (level) {
case 'Error': return 'log-error';
case 'Warn': return 'log-warn';
case 'Info': return 'log-info';
default: return 'log-debug';
}
};
return (
<div className="diagnostics-view flex-1 flex flex-col h-full overflow-hidden">
<WindowDragRegion />
{/* Toolbar */}
<div className="diagnostics-toolbar flex items-center justify-between px-4 py-2 shrink-0">
<div className="flex items-center gap-2 text-text-secondary">
<Terminal size={16} strokeWidth={1.8} />
<span className="text-[13px] font-semibold text-text-primary">Diagnostics Console</span>
<span className="text-[11px] text-text-muted">({logs.length} entries)</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleClear}
className="app-icon-button"
title="Clear console"
>
<Trash2 size={14} />
</button>
<button
onClick={handleExport}
className="app-button px-3 text-[11px] gap-1.5"
title="Export logs"
>
<FileDown size={13} />
Export Logs
</button>
</div>
</div>
{/* Console */}
<div ref={scrollRef} className="diagnostics-console flex-1 overflow-y-auto p-3 font-mono text-[11px] leading-[1.5]">
{logs.length === 0 && (
<div className="text-text-muted italic select-none">Waiting for log entries...</div>
)}
{logs.map((entry, i) => (
<div key={i} className={`log-line ${severityClass(entry.level)}`}>
<span className="log-level-tag">[{entry.level}]</span>
<span className="log-message">{entry.message}</span>
</div>
))}
</div>
</div>
);
}
+2 -1
View File
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
import {
Inbox, Zap, CheckCircle2, CircleDashed,
Film, Music, FileText, Box, Image as ImageIcon, Archive, FileQuestion,
List, CalendarClock, Gauge, Settings, Plus, Play, Pause, Edit2, Trash2, PanelLeft,
List, CalendarClock, Gauge, Bug, Settings, Plus, Play, Pause, Edit2, Trash2, PanelLeft,
type LucideIcon
} from 'lucide-react';
import { useDownloadStore, DownloadCategory, Queue } from '../store/useDownloadStore';
@@ -229,6 +229,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
<div className="sidebar-section-label">Tools</div>
<ToolItem icon={CalendarClock} label="Scheduler" view="scheduler" />
<ToolItem icon={Gauge} label="Speed Limiter" view="speedLimiter" />
<ToolItem icon={Bug} label="Diagnostics" view="diagnostics" />
</section>
</div>
+60
View File
@@ -1169,6 +1169,66 @@
}
}
/* Diagnostics Console */
.diagnostics-toolbar {
height: 42px;
border-bottom: 1px solid hsl(var(--border-color));
background: hsl(var(--statusbar-bg));
}
.diagnostics-console {
background: hsl(0 0% 7%);
color: hsl(0 0% 82%);
font-family: "SF Mono", Monaco, "Cascadia Code", "Fira Code", "JetBrains Mono", monospace;
}
.diagnostics-console .log-line {
display: flex;
gap: 8px;
padding: 0 4px;
min-height: 18px;
align-items: baseline;
border-radius: 2px;
white-space: pre-wrap;
word-break: break-all;
}
.diagnostics-console .log-level-tag {
flex-shrink: 0;
font-weight: 600;
min-width: 52px;
font-size: 10px;
}
.diagnostics-console .log-message {
flex: 1;
min-width: 0;
}
.diagnostics-console .log-error .log-level-tag,
.diagnostics-console .log-error .log-message {
color: hsl(0 72% 58%);
}
.diagnostics-console .log-warn .log-level-tag,
.diagnostics-console .log-warn .log-message {
color: hsl(45 100% 50%);
}
.diagnostics-console .log-info .log-level-tag,
.diagnostics-console .log-info .log-message {
color: hsl(0 0% 75%);
}
.diagnostics-console .log-debug .log-level-tag,
.diagnostics-console .log-debug .log-message {
color: hsl(0 0% 45%);
}
.diagnostics-console .log-line:hover {
background: hsl(0 0% 100% / 0.04);
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
+1
View File
@@ -100,6 +100,7 @@ type CommandMap = {
db_save_queue: { args: { id: string; data: string }; result: void };
db_delete_queue: { args: { id: string }; result: void };
create_category_directories: { args: { paths: string[] }; result: void };
export_logs: { args: { destPath: string }; result: string };
get_pending_order: { args: undefined; result: string[] };
enqueue_download: { args: { item: any }; result: string };
enqueue_many: { args: { items: any[] }; result: void };