mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-27 11:07:08 +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) {
|
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) => {
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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,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";
|
||||||
|
|||||||
@@ -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 {
|
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>
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user