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) { fn emit_failed(&self, id: Uuid, error: String) {
match self { match self {
Self::Tauri(app_handle) => { 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()); let _ = app_handle.emit("download-failed", id.to_string());
} }
Self::Headless(event_tx) => { Self::Headless(event_tx) => {
+1
View File
@@ -154,6 +154,7 @@ pub enum ActiveView {
Scheduler, Scheduler,
#[serde(rename = "speedLimiter")] #[serde(rename = "speedLimiter")]
SpeedLimiter, SpeedLimiter,
Diagnostics,
} }
#[derive(Clone, Debug, Serialize, Deserialize, TS)] #[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] #[tauri::command]
fn toggle_tray_icon(app_handle: tauri::AppHandle, show: bool) -> Result<(), String> { fn toggle_tray_icon(app_handle: tauri::AppHandle, show: bool) -> Result<(), String> {
use tauri::tray::TrayIconBuilder; use tauri::tray::TrayIconBuilder;
@@ -1785,6 +1808,15 @@ pub fn run() {
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Webview), tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Webview),
]) ])
.level(if cfg!(debug_assertions) { log::LevelFilter::Debug } else { log::LevelFilter::Info }) .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(), .build(),
) )
.plugin(tauri_plugin_store::Builder::new().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, 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, 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::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!()) .build(tauri::generate_context!())
.expect("error while building tauri application") .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 { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification';
import SchedulerView from "./components/SchedulerView"; import SchedulerView from "./components/SchedulerView";
import SpeedLimiterView from "./components/SpeedLimiterView"; import SpeedLimiterView from "./components/SpeedLimiterView";
import DiagnosticsView from "./components/DiagnosticsView";
function App() { function App() {
const [filter, setFilter] = useState<SidebarFilter>('all'); const [filter, setFilter] = useState<SidebarFilter>('all');
@@ -305,6 +306,7 @@ function App() {
{activeView === 'settings' && <SettingsView />} {activeView === 'settings' && <SettingsView />}
{activeView === 'scheduler' && <SchedulerView />} {activeView === 'scheduler' && <SchedulerView />}
{activeView === 'speedLimiter' && <SpeedLimiterView />} {activeView === 'speedLimiter' && <SpeedLimiterView />}
{activeView === 'diagnostics' && <DiagnosticsView />}
</div> </div>
{/* Status Bar */} {/* 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. // 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 { import {
Inbox, Zap, CheckCircle2, CircleDashed, Inbox, Zap, CheckCircle2, CircleDashed,
Film, Music, FileText, Box, Image as ImageIcon, Archive, FileQuestion, 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 type LucideIcon
} from 'lucide-react'; } from 'lucide-react';
import { useDownloadStore, DownloadCategory, Queue } from '../store/useDownloadStore'; 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> <div className="sidebar-section-label">Tools</div>
<ToolItem icon={CalendarClock} label="Scheduler" view="scheduler" /> <ToolItem icon={CalendarClock} label="Scheduler" view="scheduler" />
<ToolItem icon={Gauge} label="Speed Limiter" view="speedLimiter" /> <ToolItem icon={Gauge} label="Speed Limiter" view="speedLimiter" />
<ToolItem icon={Bug} label="Diagnostics" view="diagnostics" />
</section> </section>
</div> </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 { @keyframes fade-in {
from { opacity: 0; } from { opacity: 0; }
to { opacity: 1; } to { opacity: 1; }
+1
View File
@@ -100,6 +100,7 @@ type CommandMap = {
db_save_queue: { args: { id: string; data: string }; result: void }; db_save_queue: { args: { id: string; data: string }; result: void };
db_delete_queue: { args: { id: string }; result: void }; db_delete_queue: { args: { id: string }; result: void };
create_category_directories: { args: { paths: 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[] }; get_pending_order: { args: undefined; result: string[] };
enqueue_download: { args: { item: any }; result: string }; enqueue_download: { args: { item: any }; result: string };
enqueue_many: { args: { items: any[] }; result: void }; enqueue_many: { args: { items: any[] }; result: void };