150 lines
4.5 KiB
Svelte
150 lines
4.5 KiB
Svelte
<!-- [DEF:TaskLogPanel:Component] -->
|
|
<!--
|
|
@TIER: STANDARD
|
|
@SEMANTICS: task, log, panel, filter, list
|
|
@PURPOSE: Combines log filtering and display into a single cohesive dark-themed panel.
|
|
@LAYER: UI
|
|
@RELATION: USES -> frontend/src/components/tasks/LogFilterBar.svelte
|
|
@RELATION: USES -> frontend/src/components/tasks/LogEntryRow.svelte
|
|
@INVARIANT: Must always display logs in chronological order and respect auto-scroll preference.
|
|
@UX_STATE: Empty -> Displays "No logs" message
|
|
@UX_STATE: Populated -> Displays list of LogEntryRow components
|
|
@UX_STATE: AutoScroll -> Automatically scrolls to bottom on new logs
|
|
-->
|
|
<script>
|
|
import { createEventDispatcher, onMount, tick } from "svelte";
|
|
import LogFilterBar from "./LogFilterBar.svelte";
|
|
import LogEntryRow from "./LogEntryRow.svelte";
|
|
|
|
let { logs = [], autoScroll = $bindable(true) } = $props();
|
|
|
|
const dispatch = createEventDispatcher();
|
|
let scrollContainer;
|
|
let selectedSource = $state("all");
|
|
let selectedLevel = $state("all");
|
|
let searchText = $state("");
|
|
|
|
let filteredLogs = $derived(
|
|
filterLogs(logs, selectedLevel, selectedSource, searchText),
|
|
);
|
|
|
|
function filterLogs(allLogs, level, source, search) {
|
|
return allLogs.filter((log) => {
|
|
if (
|
|
level &&
|
|
level !== "all" &&
|
|
log.level?.toUpperCase() !== level.toUpperCase()
|
|
)
|
|
return false;
|
|
if (
|
|
source &&
|
|
source !== "all" &&
|
|
log.source?.toLowerCase() !== source.toLowerCase()
|
|
)
|
|
return false;
|
|
if (search && !log.message?.toLowerCase().includes(search.toLowerCase()))
|
|
return false;
|
|
return true;
|
|
});
|
|
}
|
|
|
|
let availableSources = $derived([
|
|
...new Set(logs.map((l) => l.source).filter(Boolean)),
|
|
]);
|
|
|
|
function handleFilterChange(event) {
|
|
const { source, level, search } = event.detail;
|
|
selectedSource = source || "all";
|
|
selectedLevel = level || "all";
|
|
searchText = search || "";
|
|
dispatch("filterChange", { source, level });
|
|
}
|
|
|
|
function scrollToBottom() {
|
|
if (autoScroll && scrollContainer) {
|
|
scrollContainer.scrollTop = scrollContainer.scrollHeight;
|
|
}
|
|
}
|
|
|
|
function toggleAutoScroll() {
|
|
autoScroll = !autoScroll;
|
|
if (autoScroll) scrollToBottom();
|
|
}
|
|
|
|
// Use $effect instead of afterUpdate for runes mode
|
|
$effect(() => {
|
|
// Track filteredLogs length to trigger scroll
|
|
filteredLogs.length;
|
|
tick().then(scrollToBottom);
|
|
});
|
|
|
|
onMount(() => {
|
|
scrollToBottom();
|
|
});
|
|
</script>
|
|
|
|
<div class="flex flex-col h-full bg-terminal-bg overflow-hidden">
|
|
<!-- Filter Bar -->
|
|
<LogFilterBar {availableSources} on:filter-change={handleFilterChange} />
|
|
|
|
<!-- Log List -->
|
|
<div
|
|
bind:this={scrollContainer}
|
|
class="flex-1 overflow-y-auto overflow-x-hidden"
|
|
>
|
|
{#if filteredLogs.length === 0}
|
|
<div
|
|
class="flex flex-col items-center justify-center py-12 px-4 text-terminal-border gap-3"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="32"
|
|
height="32"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="1.5"
|
|
>
|
|
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
|
<polyline points="14 2 14 8 20 8" />
|
|
<line x1="16" y1="13" x2="8" y2="13" />
|
|
<line x1="16" y1="17" x2="8" y2="17" />
|
|
<polyline points="10 9 9 9 8 9" />
|
|
</svg>
|
|
<span class="text-[0.8125rem] text-terminal-text-muted"
|
|
>No logs available</span
|
|
>
|
|
</div>
|
|
{:else}
|
|
{#each filteredLogs as log}
|
|
<LogEntryRow {log} />
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Footer Stats -->
|
|
<div
|
|
class="flex items-center justify-between py-1.5 px-3 border-t border-terminal-surface bg-terminal-bg"
|
|
>
|
|
<span class="font-mono text-[0.6875rem] text-terminal-text-muted">
|
|
{filteredLogs.length}{filteredLogs.length !== logs.length
|
|
? ` / ${logs.length}`
|
|
: ""} entries
|
|
</span>
|
|
<button
|
|
class="flex items-center gap-1.5 bg-transparent border-none text-terminal-text-muted text-[0.6875rem] cursor-pointer py-px px-1.5 rounded transition-all hover:bg-terminal-surface hover:text-terminal-text-subtle
|
|
{autoScroll ? 'text-terminal-accent' : ''}"
|
|
onclick={toggleAutoScroll}
|
|
aria-label="Toggle auto-scroll"
|
|
>
|
|
{#if autoScroll}
|
|
<span
|
|
class="inline-block w-[5px] h-[5px] rounded-full bg-terminal-accent animate-pulse"
|
|
></span>
|
|
{/if}
|
|
Auto-scroll {autoScroll ? "on" : "off"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<!-- [/DEF:TaskLogPanel:Component] -->
|