Files
ss-tools/frontend/src/components/tasks/TaskLogPanel.svelte
2026-02-20 10:41:15 +03:00

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