mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
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:
@@ -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) => {
|
||||
|
||||
@@ -154,6 +154,7 @@ pub enum ActiveView {
|
||||
Scheduler,
|
||||
#[serde(rename = "speedLimiter")]
|
||||
SpeedLimiter,
|
||||
Diagnostics,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
|
||||
+34
-1
@@ -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")
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user