mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 10:51:02 +01:00
feat(logs): refactor log actions into separate LogsActionsBar component
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { Search, Download, RefreshCw, Eye, Copy } from 'lucide-svelte';
|
||||
import { Eye, Copy } from 'lucide-svelte';
|
||||
import { alertStore } from '$alerts/store';
|
||||
import Modal from '$ui/modal/Modal.svelte';
|
||||
import LogsActionsBar from './components/LogsActionsBar.svelte';
|
||||
import { createSearchStore } from '$lib/client/stores/search';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
@@ -14,12 +17,25 @@
|
||||
meta?: unknown;
|
||||
}
|
||||
|
||||
// Initialize search store
|
||||
const searchStore = createSearchStore({ debounceMs: 300 });
|
||||
|
||||
// Filter state
|
||||
let selectedLevel: 'ALL' | 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' = 'ALL';
|
||||
let searchQuery = '';
|
||||
let selectedSources: Set<string> = new Set();
|
||||
let isRefreshing = false;
|
||||
|
||||
// Available log levels for filtering
|
||||
const logLevels = ['ALL', 'DEBUG', 'INFO', 'WARN', 'ERROR'] as const;
|
||||
// Extract unique sources from logs (excluding 'ALL' since empty set means all)
|
||||
$: uniqueSources = [...new Set(data.logs.map((log) => log.source).filter(Boolean))] as string[];
|
||||
|
||||
function toggleSource(source: string) {
|
||||
selectedSources = new Set(selectedSources);
|
||||
if (selectedSources.has(source)) {
|
||||
selectedSources.delete(source);
|
||||
} else {
|
||||
selectedSources.add(source);
|
||||
}
|
||||
}
|
||||
|
||||
// Level colors
|
||||
const levelColors: Record<string, string> = {
|
||||
@@ -55,9 +71,11 @@
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// Refresh page to reload logs
|
||||
function refreshLogs() {
|
||||
window.location.reload();
|
||||
// Refresh logs by refetching data
|
||||
async function refreshLogs() {
|
||||
isRefreshing = true;
|
||||
await invalidateAll();
|
||||
isRefreshing = false;
|
||||
}
|
||||
|
||||
// Change log file
|
||||
@@ -67,13 +85,6 @@
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
|
||||
// Format file size
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
// Copy log entry to clipboard
|
||||
async function copyLog(log: LogEntry) {
|
||||
const logText = `[${log.timestamp}] ${log.level} - ${log.message}${log.source ? ` [${log.source}]` : ''}${log.meta ? `\nMeta: ${JSON.stringify(log.meta, null, 2)}` : ''}`;
|
||||
@@ -86,18 +97,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Reactive filtering - use data.logs directly
|
||||
// Reactive filtering
|
||||
$: filteredLogs = data.logs.filter((log) => {
|
||||
// Level filter
|
||||
if (selectedLevel !== 'ALL' && log.level !== selectedLevel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Source filter (empty set means show all)
|
||||
if (selectedSources.size > 0 && !selectedSources.has(log.source || '')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
const matchMessage = log.message.toLowerCase().includes(query);
|
||||
const matchSource = log.source?.toLowerCase().includes(query);
|
||||
const query = $searchStore.query;
|
||||
if (query) {
|
||||
const searchLower = query.toLowerCase();
|
||||
const matchMessage = log.message.toLowerCase().includes(searchLower);
|
||||
const matchSource = log.source?.toLowerCase().includes(searchLower);
|
||||
return matchMessage || matchSource;
|
||||
}
|
||||
|
||||
@@ -114,83 +131,24 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="mb-6 flex flex-wrap items-center gap-4">
|
||||
<!-- Log File Selector -->
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
for="log-file-select"
|
||||
class="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
Log File:
|
||||
</label>
|
||||
<select
|
||||
id="log-file-select"
|
||||
value={data.selectedFile}
|
||||
on:change={(e) => changeLogFile(e.currentTarget.value)}
|
||||
class="min-w-64 rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm text-neutral-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50"
|
||||
>
|
||||
{#each data.logFiles as file (file.filename)}
|
||||
<option value={file.filename}>
|
||||
{file.filename} ({formatFileSize(file.size)})
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Level Filter Buttons -->
|
||||
<div class="flex gap-2">
|
||||
{#each logLevels as level (level)}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => (selectedLevel = level)}
|
||||
class="rounded-lg px-3 py-1.5 text-sm font-medium transition-colors {selectedLevel ===
|
||||
level
|
||||
? 'bg-blue-600 text-white dark:bg-blue-500'
|
||||
: 'border border-neutral-300 bg-white text-neutral-700 hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800'}"
|
||||
>
|
||||
{level}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Search Input -->
|
||||
<div class="flex flex-1 items-center gap-2">
|
||||
<div class="relative max-w-md flex-1">
|
||||
<Search size={18} class="absolute top-1/2 left-3 -translate-y-1/2 text-neutral-400" />
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
placeholder="Search logs..."
|
||||
class="w-full rounded-lg border border-neutral-300 bg-white py-1.5 pr-3 pl-10 text-sm text-neutral-900 placeholder-neutral-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
on:click={refreshLogs}
|
||||
class="flex items-center gap-2 rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
Refresh
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
on:click={downloadLogs}
|
||||
class="flex items-center gap-2 rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<Download size={16} />
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Actions Bar -->
|
||||
<LogsActionsBar
|
||||
{searchStore}
|
||||
logFiles={data.logFiles}
|
||||
selectedFile={data.selectedFile}
|
||||
{selectedLevel}
|
||||
{selectedSources}
|
||||
{uniqueSources}
|
||||
{isRefreshing}
|
||||
onChangeFile={changeLogFile}
|
||||
onChangeLevel={(level) => (selectedLevel = level)}
|
||||
onToggleSource={toggleSource}
|
||||
onRefresh={refreshLogs}
|
||||
onDownload={downloadLogs}
|
||||
/>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="mb-4 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
<div class="mt-6 mb-4 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Showing {filteredLogs.length} of {data.logs.length} logs
|
||||
{#if data.selectedFile}
|
||||
from {data.selectedFile}
|
||||
|
||||
162
src/routes/settings/logs/components/LogsActionsBar.svelte
Normal file
162
src/routes/settings/logs/components/LogsActionsBar.svelte
Normal file
@@ -0,0 +1,162 @@
|
||||
<script lang="ts">
|
||||
import { Download, RefreshCw, FileText, Filter, Layers, Check } from 'lucide-svelte';
|
||||
import ActionsBar from '$ui/actions/ActionsBar.svelte';
|
||||
import SearchAction from '$ui/actions/SearchAction.svelte';
|
||||
import ActionButton from '$ui/actions/ActionButton.svelte';
|
||||
import Dropdown from '$ui/dropdown/Dropdown.svelte';
|
||||
import { type SearchStore } from '$stores/search';
|
||||
|
||||
interface LogFile {
|
||||
filename: string;
|
||||
size: number;
|
||||
modified: Date;
|
||||
}
|
||||
|
||||
export let searchStore: SearchStore;
|
||||
export let logFiles: LogFile[];
|
||||
export let selectedFile: string;
|
||||
export let selectedLevel: string;
|
||||
export let selectedSources: Set<string>;
|
||||
export let uniqueSources: string[];
|
||||
|
||||
export let isRefreshing: boolean = false;
|
||||
|
||||
export let onChangeFile: (filename: string) => void;
|
||||
export let onChangeLevel: (level: string) => void;
|
||||
export let onToggleSource: (source: string) => void;
|
||||
export let onRefresh: () => void;
|
||||
export let onDownload: () => void;
|
||||
|
||||
const logLevels = ['ALL', 'DEBUG', 'INFO', 'WARN', 'ERROR'] as const;
|
||||
|
||||
const levelColors: Record<string, string> = {
|
||||
ALL: 'text-neutral-600 dark:text-neutral-400',
|
||||
DEBUG: 'text-cyan-600 dark:text-cyan-400',
|
||||
INFO: 'text-green-600 dark:text-green-400',
|
||||
WARN: 'text-yellow-600 dark:text-yellow-400',
|
||||
ERROR: 'text-red-600 dark:text-red-400'
|
||||
};
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
const d = new Date(date);
|
||||
return d.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<ActionsBar className="justify-end">
|
||||
<SearchAction {searchStore} placeholder="Search logs..." />
|
||||
|
||||
<!-- Log File Selector -->
|
||||
<ActionButton icon={FileText} hasDropdown={true} dropdownPosition="right">
|
||||
<svelte:fragment slot="dropdown" let:dropdownPosition let:open>
|
||||
<Dropdown position={dropdownPosition} {open} minWidth="20rem">
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
{#each logFiles as file}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => onChangeFile(file.filename)}
|
||||
class="flex w-full items-center justify-between gap-4 border-b border-neutral-200 px-4 py-2.5 text-left transition-colors first:rounded-t-lg last:rounded-b-lg last:border-b-0 dark:border-neutral-700
|
||||
{selectedFile === file.filename
|
||||
? 'bg-neutral-100 dark:bg-neutral-700'
|
||||
: 'hover:bg-neutral-100 dark:hover:bg-neutral-700'}"
|
||||
>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="font-mono text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{file.filename}
|
||||
</span>
|
||||
<span class="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{formatDate(file.modified)}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-neutral-400 dark:text-neutral-500">
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</Dropdown>
|
||||
</svelte:fragment>
|
||||
</ActionButton>
|
||||
|
||||
<!-- Level Filter -->
|
||||
<ActionButton icon={Filter} hasDropdown={true} dropdownPosition="right">
|
||||
<svelte:fragment slot="dropdown" let:dropdownPosition let:open>
|
||||
<Dropdown position={dropdownPosition} {open} minWidth="8rem">
|
||||
{#each logLevels as level}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => onChangeLevel(level)}
|
||||
class="flex w-full items-center justify-between gap-3 border-b border-neutral-200 px-4 py-2 text-left transition-colors first:rounded-t-lg last:rounded-b-lg last:border-b-0 dark:border-neutral-700
|
||||
{selectedLevel === level
|
||||
? 'bg-neutral-100 dark:bg-neutral-700'
|
||||
: 'hover:bg-neutral-100 dark:hover:bg-neutral-700'}"
|
||||
>
|
||||
<span class="font-medium {levelColors[level]}">{level}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</Dropdown>
|
||||
</svelte:fragment>
|
||||
</ActionButton>
|
||||
|
||||
<!-- Source Filter -->
|
||||
<ActionButton icon={Layers} hasDropdown={true} dropdownPosition="right">
|
||||
<svelte:fragment slot="dropdown" let:dropdownPosition let:open>
|
||||
<Dropdown position={dropdownPosition} {open} minWidth="12rem">
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
{#each uniqueSources as source}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => onToggleSource(source)}
|
||||
class="flex w-full items-center justify-between gap-3 border-b border-neutral-200 px-4 py-2 text-left text-sm transition-colors first:rounded-t-lg last:rounded-b-lg last:border-b-0 dark:border-neutral-700
|
||||
{selectedSources.has(source)
|
||||
? 'bg-neutral-100 dark:bg-neutral-700'
|
||||
: 'hover:bg-neutral-100 dark:hover:bg-neutral-700'}"
|
||||
>
|
||||
<span class="text-neutral-700 dark:text-neutral-300">{source}</span>
|
||||
{#if selectedSources.has(source)}
|
||||
<Check size={16} class="text-blue-600 dark:text-blue-400" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</Dropdown>
|
||||
</svelte:fragment>
|
||||
</ActionButton>
|
||||
|
||||
<!-- Refresh -->
|
||||
<ActionButton hasDropdown={true} dropdownPosition="right" on:click={onRefresh}>
|
||||
<RefreshCw
|
||||
size={20}
|
||||
class="text-neutral-700 dark:text-neutral-300 {isRefreshing ? 'animate-spin' : ''}"
|
||||
/>
|
||||
<svelte:fragment slot="dropdown" let:dropdownPosition let:open>
|
||||
<Dropdown position={dropdownPosition} {open} minWidth="6rem">
|
||||
<div class="px-3 py-2 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Refresh logs
|
||||
</div>
|
||||
</Dropdown>
|
||||
</svelte:fragment>
|
||||
</ActionButton>
|
||||
|
||||
<!-- Download -->
|
||||
<ActionButton icon={Download} hasDropdown={true} dropdownPosition="right" on:click={onDownload}>
|
||||
<svelte:fragment slot="dropdown" let:dropdownPosition let:open>
|
||||
<Dropdown position={dropdownPosition} {open} minWidth="8rem">
|
||||
<div class="px-3 py-2 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Download logs as JSON
|
||||
</div>
|
||||
</Dropdown>
|
||||
</svelte:fragment>
|
||||
</ActionButton>
|
||||
</ActionsBar>
|
||||
Reference in New Issue
Block a user