feat(logs): refactor log actions into separate LogsActionsBar component

This commit is contained in:
Sam Chau
2025-12-28 19:11:37 +10:30
parent 66095f6be1
commit 64cd5d7d04
2 changed files with 214 additions and 94 deletions

View File

@@ -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}

View 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>