semantic update

This commit is contained in:
2026-02-08 22:53:54 +03:00
parent e6087bd3c1
commit 235b0e3c9f
71 changed files with 68034 additions and 62417 deletions

View File

@@ -1,5 +1,6 @@
<!-- [DEF:DashboardGrid:Component] -->
<!--
@TIER: STANDARD
@SEMANTICS: dashboard, grid, selection, pagination
@PURPOSE: Displays a grid of dashboards with selection and pagination.
@LAYER: Component

View File

@@ -1,5 +1,6 @@
<!-- [DEF:Footer:Component] -->
<!--
@TIER: TRIVIAL
@SEMANTICS: footer, layout, copyright
@PURPOSE: Displays the application footer with copyright information.
@LAYER: UI

View File

@@ -1,5 +1,6 @@
<!-- [DEF:Navbar:Component] -->
<!--
@TIER: STANDARD
@SEMANTICS: navbar, navigation, header, layout
@PURPOSE: Main navigation bar for the application.
@LAYER: UI

View File

@@ -1,15 +1,16 @@
<!-- [DEF:TaskLogViewer:Component] -->
<!--
@SEMANTICS: task, log, viewer, modal, inline
@PURPOSE: Displays detailed logs for a specific task in a modal or inline.
@PURPOSE: Displays detailed logs for a specific task in a modal or inline using TaskLogPanel.
@LAYER: UI
@RELATION: USES -> frontend/src/services/taskService.js
@RELATION: USES -> frontend/src/services/taskService.js, frontend/src/components/tasks/TaskLogPanel.svelte
-->
<script>
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
import { getTaskLogs } from '../services/taskService.js';
import { t } from '../lib/i18n';
import { Button } from '../lib/ui';
import TaskLogPanel from './tasks/TaskLogPanel.svelte';
export let show = false;
export let inline = false;
@@ -23,7 +24,8 @@
let error = "";
let interval;
let autoScroll = true;
let logContainer;
let selectedSource = 'all';
let selectedLevel = 'all';
$: shouldShow = inline || show;
@@ -36,12 +38,11 @@
*/
async function fetchLogs() {
if (!taskId) return;
console.log(`[fetchLogs][Action] Fetching logs for task context={{'taskId': '${taskId}'}}`);
console.log(`[fetchLogs][Action] Fetching logs for task context={{'taskId': '${taskId}', 'source': '${selectedSource}', 'level': '${selectedLevel}'}}`);
try {
// Note: getTaskLogs currently doesn't support filters, but we can filter client-side for now
// or update taskService later. For US1, the WebSocket handles real-time filtering.
logs = await getTaskLogs(taskId);
if (autoScroll) {
scrollToBottom();
}
console.log(`[fetchLogs][Coherence:OK] Logs fetched context={{'count': ${logs.length}}}`);
} catch (e) {
error = e.message;
@@ -52,35 +53,14 @@
}
// [/DEF:fetchLogs:Function]
// [DEF:scrollToBottom:Function]
/**
* @purpose Scrolls the log container to the bottom.
* @pre logContainer element must be bound.
* @post logContainer scrollTop is set to scrollHeight.
*/
function scrollToBottom() {
if (logContainer) {
setTimeout(() => {
logContainer.scrollTop = logContainer.scrollHeight;
}, 0);
}
function handleFilterChange(event) {
const { source, level } = event.detail;
selectedSource = source;
selectedLevel = level;
// Re-fetch or re-filter if needed.
// For now, we just log it as the WebSocket will handle real-time updates with filters.
console.log(`[TaskLogViewer] Filter changed: source=${source}, level=${level}`);
}
// [/DEF:scrollToBottom:Function]
// [DEF:handleScroll:Function]
/**
* @purpose Updates auto-scroll preference based on scroll position.
* @pre logContainer scroll event fired.
* @post autoScroll boolean is updated.
*/
function handleScroll() {
if (!logContainer) return;
// If user scrolls up, disable auto-scroll
const { scrollTop, scrollHeight, clientHeight } = logContainer;
const atBottom = scrollHeight - scrollTop - clientHeight < 50;
autoScroll = atBottom;
}
// [/DEF:handleScroll:Function]
// [DEF:close:Function]
/**
@@ -94,23 +74,6 @@
}
// [/DEF:close:Function]
// [DEF:getLogLevelColor:Function]
/**
* @purpose Returns the CSS color class for a given log level.
* @pre level string is provided.
* @post Returns tailwind color class string.
*/
function getLogLevelColor(level) {
switch (level) {
case 'INFO': return 'text-blue-600';
case 'WARNING': return 'text-yellow-600';
case 'ERROR': return 'text-red-600';
case 'DEBUG': return 'text-gray-500';
default: return 'text-gray-800';
}
}
// [/DEF:getLogLevelColor:Function]
// React to changes in show/taskId/taskStatus
$: if (shouldShow && taskId) {
if (interval) clearInterval(interval);
@@ -120,7 +83,7 @@
error = "";
fetchLogs();
// Poll if task is running
// Poll if task is running (Fallback for when WS is not used)
if (taskStatus === 'RUNNING' || taskStatus === 'AWAITING_INPUT' || taskStatus === 'AWAITING_MAPPING') {
interval = setInterval(fetchLogs, 3000);
}
@@ -150,34 +113,18 @@
<Button variant="ghost" size="sm" on:click={fetchLogs} class="text-blue-600">{$t.tasks?.refresh}</Button>
</div>
<div class="flex-1 border rounded-md bg-gray-50 p-4 overflow-y-auto font-mono text-sm"
bind:this={logContainer}
on:scroll={handleScroll}>
<div class="flex-1 min-h-[400px]">
{#if loading && logs.length === 0}
<p class="text-gray-500 text-center">{$t.tasks?.loading}</p>
{:else if error}
<p class="text-red-500 text-center">{error}</p>
{:else if logs.length === 0}
<p class="text-gray-500 text-center">{$t.tasks?.no_logs}</p>
{:else}
{#each logs as log}
<div class="mb-1 hover:bg-gray-100 p-1 rounded">
<span class="text-gray-400 text-xs mr-2">
{new Date(log.timestamp).toLocaleTimeString()}
</span>
<span class="font-bold text-xs mr-2 w-16 inline-block {getLogLevelColor(log.level)}">
[{log.level}]
</span>
<span class="text-gray-800 break-words">
{log.message}
</span>
{#if log.context}
<div class="ml-24 text-xs text-gray-500 mt-1 bg-gray-100 p-1 rounded overflow-x-auto">
<pre>{JSON.stringify(log.context, null, 2)}</pre>
</div>
{/if}
</div>
{/each}
<TaskLogPanel
{taskId}
{logs}
{autoScroll}
on:filterChange={handleFilterChange}
/>
{/if}
</div>
</div>
@@ -193,39 +140,23 @@
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
<h3 class="text-lg leading-6 font-medium text-gray-900 flex justify-between items-center" id="modal-title">
<h3 class="text-lg leading-6 font-medium text-gray-900 flex justify-between items-center mb-4" id="modal-title">
<span>{$t.tasks.logs_title} <span class="text-sm text-gray-500 font-normal">({taskId})</span></span>
<Button variant="ghost" size="sm" on:click={fetchLogs} class="text-blue-600">{$t.tasks.refresh}</Button>
</h3>
<div class="mt-4 border rounded-md bg-gray-50 p-4 h-96 overflow-y-auto font-mono text-sm"
bind:this={logContainer}
on:scroll={handleScroll}>
<div class="h-[500px]">
{#if loading && logs.length === 0}
<p class="text-gray-500 text-center">{$t.tasks.loading}</p>
{:else if error}
<p class="text-red-500 text-center">{error}</p>
{:else if logs.length === 0}
<p class="text-gray-500 text-center">{$t.tasks.no_logs}</p>
{:else}
{#each logs as log}
<div class="mb-1 hover:bg-gray-100 p-1 rounded">
<span class="text-gray-400 text-xs mr-2">
{new Date(log.timestamp).toLocaleTimeString()}
</span>
<span class="font-bold text-xs mr-2 w-16 inline-block {getLogLevelColor(log.level)}">
[{log.level}]
</span>
<span class="text-gray-800 break-words">
{log.message}
</span>
{#if log.context}
<div class="ml-24 text-xs text-gray-500 mt-1 bg-gray-100 p-1 rounded overflow-x-auto">
<pre>{JSON.stringify(log.context, null, 2)}</pre>
</div>
{/if}
</div>
{/each}
<TaskLogPanel
{taskId}
{logs}
{autoScroll}
on:filterChange={handleFilterChange}
/>
{/if}
</div>
</div>

View File

@@ -1,12 +1,10 @@
<!-- [DEF:TaskRunner:Component] -->
<!--
@TIER: STANDARD
@SEMANTICS: task, runner, logs, websocket
@PURPOSE: Connects to a WebSocket to display real-time logs for a running task.
@PURPOSE: Connects to a WebSocket to display real-time logs for a running task with filtering support.
@LAYER: UI
@RELATION: DEPENDS_ON -> frontend/src/lib/stores.js
@PROPS: None
@EVENTS: None
@RELATION: DEPENDS_ON -> frontend/src/lib/stores.js, frontend/src/components/tasks/TaskLogPanel.svelte
-->
<script>
// [SECTION: IMPORTS]
@@ -17,6 +15,7 @@
import { addToast } from '../lib/toasts.js';
import MissingMappingModal from './MissingMappingModal.svelte';
import PasswordPrompt from './PasswordPrompt.svelte';
import TaskLogPanel from './tasks/TaskLogPanel.svelte';
// [/SECTION]
let ws;
@@ -35,9 +34,12 @@
let showPasswordPrompt = false;
let passwordPromptData = { databases: [], errorMessage: '' };
let selectedSource = 'all';
let selectedLevel = 'all';
// [DEF:connect:Function]
/**
* @purpose Establishes WebSocket connection with exponential backoff.
* @purpose Establishes WebSocket connection with exponential backoff and filter parameters.
* @pre selectedTask must be set in the store.
* @post WebSocket instance created and listeners attached.
*/
@@ -45,10 +47,21 @@
const task = get(selectedTask);
if (!task || connectionStatus === 'completed') return;
console.log(`[TaskRunner][Entry] Connecting to logs for task: ${task.id} (Attempt ${reconnectAttempts + 1})`);
console.log(`[TaskRunner][Entry] Connecting to logs for task: ${task.id} (Attempt ${reconnectAttempts + 1}) filters: source=${selectedSource}, level=${selectedLevel}`);
connectionStatus = 'connecting';
const wsUrl = getWsUrl(task.id);
let wsUrl = getWsUrl(task.id);
// Append filter parameters to WebSocket URL
const params = new URLSearchParams();
if (selectedSource !== 'all') params.append('source', selectedSource);
if (selectedLevel !== 'all') params.append('level', selectedLevel);
const queryString = params.toString();
if (queryString) {
wsUrl += (wsUrl.includes('?') ? '&' : '?') + queryString;
}
ws = new WebSocket(wsUrl);
ws.onopen = () => {
@@ -81,7 +94,6 @@
}
// Check for password request via log context or message
// Note: The backend logs "Task paused for user input" with context
if (logEntry.message && logEntry.message.includes('Task paused for user input') && logEntry.context && logEntry.context.input_request) {
const request = logEntry.context.input_request;
if (request.type === 'database_password') {
@@ -95,8 +107,6 @@
}
};
// Check if task is already awaiting input (e.g. when re-selecting task)
// We use the 'task' variable from the outer scope (connect function)
if (task && task.status === 'AWAITING_INPUT' && task.input_request && task.input_request.type === 'database_password') {
connectionStatus = 'awaiting_input';
passwordPromptData = {
@@ -131,16 +141,43 @@
}
// [/DEF:connect:Function]
// [DEF:handleFilterChange:Function]
/**
* @purpose Handles filter changes and reconnects WebSocket with new parameters.
* @pre event.detail contains source and level filter values.
* @post WebSocket reconnected with new filter parameters, logs cleared.
*/
function handleFilterChange(event) {
const { source, level } = event.detail;
if (selectedSource === source && selectedLevel === level) return;
selectedSource = source;
selectedLevel = level;
console.log(`[TaskRunner] Filter changed, reconnecting WebSocket: source=${source}, level=${level}`);
// Clear current logs when filter changes to avoid confusion
taskLogs.set([]);
if (ws) {
ws.close(); // This will trigger reconnection via onclose if not completed
} else {
connect();
}
}
// [/DEF:handleFilterChange:Function]
// [DEF:fetchTargetDatabases:Function]
// @PURPOSE: Fetches the list of databases in the target environment.
// @PRE: task must be selected and have a target environment parameter.
// @POST: targetDatabases array is populated with database objects.
/**
* @purpose Fetches available databases from target environment for mapping.
* @pre selectedTask must have to_env parameter set.
* @post targetDatabases array populated with available databases.
*/
async function fetchTargetDatabases() {
const task = get(selectedTask);
if (!task || !task.params.to_env) return;
try {
// We need to find the environment ID by name first
const envs = await api.fetchApi('/environments');
const targetEnv = envs.find(e => e.name === task.params.to_env);
@@ -154,15 +191,16 @@
// [/DEF:fetchTargetDatabases:Function]
// [DEF:handleMappingResolve:Function]
// @PURPOSE: Handles the resolution of a missing database mapping.
// @PRE: event.detail contains sourceDbUuid, targetDbUuid, and targetDbName.
// @POST: Mapping is saved and task is resumed.
/**
* @purpose Resolves missing database mapping and continues migration.
* @pre event.detail contains sourceDbUuid, targetDbUuid, targetDbName.
* @post Mapping created in backend, task resumed with resolution params.
*/
async function handleMappingResolve(event) {
const task = get(selectedTask);
const { sourceDbUuid, targetDbUuid, targetDbName } = event.detail;
try {
// 1. Save mapping to backend
const envs = await api.fetchApi('/environments');
const srcEnv = envs.find(e => e.name === task.params.from_env);
const tgtEnv = envs.find(e => e.name === task.params.to_env);
@@ -176,7 +214,6 @@
target_db_name: targetDbName
});
// 2. Resolve task
await api.postApi(`/tasks/${task.id}/resolve`, {
resolution_params: { resolved_mapping: { [sourceDbUuid]: targetDbUuid } }
});
@@ -190,9 +227,11 @@
// [/DEF:handleMappingResolve:Function]
// [DEF:handlePasswordResume:Function]
// @PURPOSE: Handles the submission of database passwords to resume a task.
// @PRE: event.detail contains passwords dictionary.
// @POST: Task resume endpoint is called with passwords.
/**
* @purpose Submits passwords and resumes paused migration task.
* @pre event.detail contains passwords object.
* @post Task resumed with passwords, connection status restored to connected.
*/
async function handlePasswordResume(event) {
const task = get(selectedTask);
const { passwords } = event.detail;
@@ -210,9 +249,11 @@
// [/DEF:handlePasswordResume:Function]
// [DEF:startDataTimeout:Function]
// @PURPOSE: Starts a timeout to detect when the log stream has stalled.
// @PRE: None.
// @POST: dataTimeout is set to check connection status after 5s.
/**
* @purpose Starts timeout timer to detect idle connection.
* @pre connectionStatus is 'connected'.
* @post waitingForData set to true after 5 seconds if no data received.
*/
function startDataTimeout() {
waitingForData = false;
dataTimeout = setTimeout(() => {
@@ -224,9 +265,11 @@
// [/DEF:startDataTimeout:Function]
// [DEF:resetDataTimeout:Function]
// @PURPOSE: Resets the data stall timeout.
// @PRE: dataTimeout must be active.
// @POST: dataTimeout is cleared and restarted.
/**
* @purpose Resets data timeout timer when new data arrives.
* @pre dataTimeout must be set.
* @post waitingForData reset to false, new timeout started.
*/
function resetDataTimeout() {
clearTimeout(dataTimeout);
waitingForData = false;
@@ -235,11 +278,12 @@
// [/DEF:resetDataTimeout:Function]
// [DEF:onMount:Function]
// @PURPOSE: Initializes the component and subscribes to task selection changes.
// @PRE: Svelte component is mounting.
// @POST: Store subscription is created and returned for cleanup.
/**
* @purpose Initializes WebSocket connection when component mounts.
* @pre Component must be mounted in DOM.
* @post WebSocket connection established, subscription to selectedTask active.
*/
onMount(() => {
// Subscribe to selectedTask changes
const unsubscribe = selectedTask.subscribe(task => {
if (task) {
console.log(`[TaskRunner][Action] Task selected: ${task.id}. Initializing connection.`);
@@ -248,7 +292,6 @@
reconnectAttempts = 0;
connectionStatus = 'disconnected';
// Initialize logs from the task object if available
if (task.logs && Array.isArray(task.logs)) {
console.log(`[TaskRunner] Loaded ${task.logs.length} existing logs.`);
taskLogs.set(task.logs);
@@ -264,11 +307,6 @@
// [/DEF:onMount:Function]
// [DEF:onDestroy:Function]
/**
* @purpose Close WebSocket connection when the component is destroyed.
* @pre Component is being destroyed.
* @post WebSocket is closed and timeouts are cleared.
*/
onDestroy(() => {
clearTimeout(reconnectTimeout);
clearTimeout(dataTimeout);
@@ -330,26 +368,16 @@
</details>
</div>
<div class="bg-gray-900 text-white font-mono text-sm p-4 rounded-md h-96 overflow-y-auto relative shadow-inner">
{#if $taskLogs.length === 0}
<div class="text-gray-500 italic text-center mt-10">No logs available for this task.</div>
{/if}
{#each $taskLogs as log}
<div class="hover:bg-gray-800 px-1 rounded">
<span class="text-gray-500 select-none text-xs w-20 inline-block">{new Date(log.timestamp).toLocaleTimeString()}</span>
<span class="{log.level === 'ERROR' ? 'text-red-500 font-bold' : log.level === 'WARNING' ? 'text-yellow-400' : 'text-green-400'} w-16 inline-block">[{log.level}]</span>
<span>{log.message}</span>
{#if log.context}
<details class="ml-24">
<summary class="text-xs text-gray-500 cursor-pointer hover:text-gray-300">Context</summary>
<pre class="text-xs text-gray-400 pl-2 border-l border-gray-700 mt-1">{JSON.stringify(log.context, null, 2)}</pre>
</details>
{/if}
</div>
{/each}
<div class="h-[500px]">
<TaskLogPanel
taskId={$selectedTask.id}
logs={$taskLogs}
autoScroll={true}
on:filterChange={handleFilterChange}
/>
{#if waitingForData && connectionStatus === 'connected'}
<div class="text-gray-500 italic mt-2 animate-pulse border-t border-gray-800 pt-2">
<div class="text-gray-500 italic mt-2 animate-pulse text-xs">
Waiting for new logs...
</div>
{/if}

View File

@@ -1,5 +1,6 @@
<!-- [DEF:Toast:Component] -->
<!--
@TIER: TRIVIAL
@SEMANTICS: toast, notification, feedback, ui
@PURPOSE: Displays transient notifications (toasts) in the bottom-right corner.
@LAYER: UI

View File

@@ -1,5 +1,6 @@
<!-- [DEF:ProtectedRoute:Component] -->
<!--
@TIER: TRIVIAL
@SEMANTICS: auth, guard, route, protection
@PURPOSE: Wraps content to ensure only authenticated users can access it.
@LAYER: Component

View File

@@ -1,5 +1,6 @@
<!-- [DEF:CommitModal:Component] -->
<!--
@TIER: STANDARD
@SEMANTICS: git, commit, modal, version_control, diff
@PURPOSE: Модальное окно для создания коммита с просмотром изменений (diff).
@LAYER: Component

View File

@@ -0,0 +1,196 @@
<!-- [DEF:LogEntryRow:Component] -->
<!-- @SEMANTICS: log, entry, row, ui, svelte -->
<!-- @PURPOSE: Optimized row rendering for a single log entry with color coding and progress bar support. -->
<!-- @TIER: STANDARD -->
<!-- @LAYER: UI -->
<!-- @UX_STATE: Idle -> (displays log entry) -->
<script>
/** @type {Object} log - The log entry object */
export let log;
/** @type {boolean} showSource - Whether to show the source tag */
export let showSource = true;
// Format timestamp for display
$: formattedTime = formatTime(log.timestamp);
function formatTime(timestamp) {
if (!timestamp) return '';
const date = new Date(timestamp);
return date.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
// Get level class for styling
$: levelClass = getLevelClass(log.level);
function getLevelClass(level) {
switch (level?.toUpperCase()) {
case 'DEBUG': return 'level-debug';
case 'INFO': return 'level-info';
case 'WARNING': return 'level-warning';
case 'ERROR': return 'level-error';
default: return 'level-info';
}
}
// Get source class for styling
$: sourceClass = getSourceClass(log.source);
function getSourceClass(source) {
if (!source) return 'source-default';
return `source-${source.toLowerCase().replace(/[^a-z0-9]/g, '-')}`;
}
// Check if log has progress metadata
$: hasProgress = log.metadata?.progress !== undefined;
$: progressPercent = log.metadata?.progress || 0;
</script>
<div class="log-entry-row {levelClass}" class:has-progress={hasProgress}>
<span class="log-time">{formattedTime}</span>
<span class="log-level {levelClass}">{log.level || 'INFO'}</span>
{#if showSource}
<span class="log-source {sourceClass}">{log.source || 'system'}</span>
{/if}
<span class="log-message">
{log.message}
{#if hasProgress}
<div class="progress-bar-container">
<div class="progress-bar" style="width: {progressPercent}%"></div>
<span class="progress-text">{progressPercent.toFixed(0)}%</span>
</div>
{/if}
</span>
</div>
<style>
.log-entry-row {
display: grid;
grid-template-columns: 80px 70px auto 1fr;
gap: 0.75rem;
padding: 0.375rem 0.75rem;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.8125rem;
border-bottom: 1px solid #1e293b;
align-items: start;
}
.log-entry-row.has-progress {
grid-template-columns: 80px 70px auto 1fr;
}
.log-entry-row:hover {
background-color: rgba(30, 41, 59, 0.5);
}
/* Alternating row backgrounds handled by parent */
.log-time {
color: #64748b;
font-size: 0.75rem;
white-space: nowrap;
}
.log-level {
font-weight: 600;
text-transform: uppercase;
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
text-align: center;
}
.level-debug {
color: #64748b;
background-color: rgba(100, 116, 139, 0.2);
}
.level-info {
color: #3b82f6;
background-color: rgba(59, 130, 246, 0.15);
}
.level-warning {
color: #f59e0b;
background-color: rgba(245, 158, 11, 0.15);
}
.level-error {
color: #ef4444;
background-color: rgba(239, 68, 68, 0.15);
}
.log-source {
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
background-color: rgba(100, 116, 139, 0.2);
color: #94a3b8;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
max-width: 120px;
}
.source-plugin {
background-color: rgba(34, 197, 94, 0.15);
color: #22c55e;
}
.source-superset-api, .source-superset_api {
background-color: rgba(168, 85, 247, 0.15);
color: #a855f7;
}
.source-git {
background-color: rgba(249, 115, 22, 0.15);
color: #f97316;
}
.source-system {
background-color: rgba(59, 130, 246, 0.15);
color: #3b82f6;
}
.log-message {
color: #e2e8f0;
word-break: break-word;
white-space: pre-wrap;
}
.progress-bar-container {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.25rem;
background-color: #1e293b;
border-radius: 0.25rem;
overflow: hidden;
height: 1rem;
}
.progress-bar {
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
height: 100%;
transition: width 0.3s ease;
}
.progress-text {
font-size: 0.625rem;
color: #94a3b8;
padding: 0 0.25rem;
position: absolute;
right: 0.25rem;
}
.progress-bar-container {
position: relative;
}
</style>
<!-- [/DEF:LogEntryRow:Component] -->

View File

@@ -0,0 +1,161 @@
<!-- [DEF:LogFilterBar:Component] -->
<!-- @SEMANTICS: log, filter, ui, svelte -->
<!-- @PURPOSE: UI component for filtering logs by level, source, and text search. -->
<!-- @TIER: STANDARD -->
<!-- @LAYER: UI -->
<!-- @UX_STATE: Idle -> FilterChanged -> (parent applies filter) -->
<script>
import { createEventDispatcher } from 'svelte';
// Props
/** @type {string[]} availableSources - List of available source options */
export let availableSources = [];
/** @type {string} selectedLevel - Currently selected log level filter */
export let selectedLevel = '';
/** @type {string} selectedSource - Currently selected source filter */
export let selectedSource = '';
/** @type {string} searchText - Current search text */
export let searchText = '';
const dispatch = createEventDispatcher();
// Log level options
const levelOptions = [
{ value: '', label: 'All Levels' },
{ value: 'DEBUG', label: 'Debug' },
{ value: 'INFO', label: 'Info' },
{ value: 'WARNING', label: 'Warning' },
{ value: 'ERROR', label: 'Error' }
];
// Handle filter changes
function handleLevelChange(event) {
selectedLevel = event.target.value;
dispatch('filter-change', { level: selectedLevel, source: selectedSource, search: searchText });
}
function handleSourceChange(event) {
selectedSource = event.target.value;
dispatch('filter-change', { level: selectedLevel, source: selectedSource, search: searchText });
}
function handleSearchChange(event) {
searchText = event.target.value;
dispatch('filter-change', { level: selectedLevel, source: selectedSource, search: searchText });
}
function clearFilters() {
selectedLevel = '';
selectedSource = '';
searchText = '';
dispatch('filter-change', { level: '', source: '', search: '' });
}
</script>
<div class="log-filter-bar">
<div class="filter-group">
<label for="level-filter" class="filter-label">Level:</label>
<select id="level-filter" class="filter-select" value={selectedLevel} on:change={handleLevelChange}>
{#each levelOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<div class="filter-group">
<label for="source-filter" class="filter-label">Source:</label>
<select id="source-filter" class="filter-select" value={selectedSource} on:change={handleSourceChange}>
<option value="">All Sources</option>
{#each availableSources as source}
<option value={source}>{source}</option>
{/each}
</select>
</div>
<div class="filter-group search-group">
<label for="search-filter" class="filter-label">Search:</label>
<input
id="search-filter"
type="text"
class="filter-input"
placeholder="Search logs..."
value={searchText}
on:input={handleSearchChange}
/>
</div>
{#if selectedLevel || selectedSource || searchText}
<button class="clear-btn" on:click={clearFilters}>
Clear Filters
</button>
{/if}
</div>
<style>
.log-filter-bar {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: center;
padding: 0.75rem;
background-color: #1e293b;
border-radius: 0.5rem;
margin-bottom: 0.5rem;
}
.filter-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.filter-label {
font-size: 0.875rem;
color: #94a3b8;
font-weight: 500;
}
.filter-select, .filter-input {
background-color: #334155;
color: #e2e8f0;
border: 1px solid #475569;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
min-width: 120px;
}
.filter-select:focus, .filter-input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
.search-group {
flex: 1;
min-width: 200px;
}
.filter-input {
width: 100%;
max-width: 300px;
}
.clear-btn {
background-color: #475569;
color: #e2e8f0;
border: none;
border-radius: 0.375rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
cursor: pointer;
transition: background-color 0.2s;
}
.clear-btn:hover {
background-color: #64748b;
}
</style>
<!-- [/DEF:LogFilterBar:Component] -->

View File

@@ -0,0 +1,119 @@
<!-- [DEF:TaskLogPanel:Component] -->
<!--
@TIER: STANDARD
@SEMANTICS: task, log, panel, filter, list
@PURPOSE: Combines log filtering and display into a single cohesive 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.
-->
<script>
import { createEventDispatcher, onMount, afterUpdate } from 'svelte';
import LogFilterBar from './LogFilterBar.svelte';
import LogEntryRow from './LogEntryRow.svelte';
/**
* @PURPOSE: Component properties and state.
* @PRE: taskId is a valid string, logs is an array of LogEntry objects.
* @UX_STATE: [Empty] -> Displays "No logs available" message.
* @UX_STATE: [Populated] -> Displays list of LogEntryRow components.
* @UX_STATE: [AutoScroll] -> Automatically scrolls to bottom on new logs.
*/
export let taskId = '';
export let logs = [];
export let autoScroll = true;
const dispatch = createEventDispatcher();
let scrollContainer;
let selectedSource = 'all';
let selectedLevel = 'all';
/**
* @PURPOSE: Handles filter changes from LogFilterBar.
* @PRE: event.detail contains source and level.
* @POST: Dispatches filterChange event to parent.
* @SIDE_EFFECT: Updates local filter state.
*/
function handleFilterChange(event) {
const { source, level } = event.detail;
selectedSource = source;
selectedLevel = level;
console.log(`[TaskLogPanel][STATE] Filter changed: source=${source}, level=${level}`);
dispatch('filterChange', { source, level });
}
/**
* @PURPOSE: Scrolls the log container to the bottom.
* @PRE: autoScroll is true and scrollContainer is bound.
* @POST: scrollContainer.scrollTop is set to scrollHeight.
*/
function scrollToBottom() {
if (autoScroll && scrollContainer) {
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}
}
afterUpdate(() => {
scrollToBottom();
});
onMount(() => {
scrollToBottom();
});
</script>
<div class="flex flex-col h-full bg-gray-900 text-gray-100 rounded-lg overflow-hidden border border-gray-700">
<!-- Header / Filter Bar -->
<div class="p-2 bg-gray-800 border-b border-gray-700">
<LogFilterBar
{taskId}
on:filter={handleFilterChange}
/>
</div>
<!-- Log List -->
<div
bind:this={scrollContainer}
class="flex-1 overflow-y-auto p-2 font-mono text-sm space-y-0.5"
>
{#if logs.length === 0}
<div class="text-gray-500 italic text-center py-4">
No logs available for this task.
</div>
{:else}
{#each logs as log}
<LogEntryRow {log} />
{/each}
{/if}
</div>
<!-- Footer / Stats -->
<div class="px-3 py-1 bg-gray-800 border-t border-gray-700 text-xs text-gray-400 flex justify-between items-center">
<span>Total: {logs.length} entries</span>
{#if autoScroll}
<span class="text-green-500 flex items-center gap-1">
<span class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
Auto-scroll active
</span>
{/if}
</div>
</div>
<style>
/* Custom scrollbar for the log container */
div::-webkit-scrollbar {
width: 8px;
}
div::-webkit-scrollbar-track {
background: #1f2937;
}
div::-webkit-scrollbar-thumb {
background: #4b5563;
border-radius: 4px;
}
div::-webkit-scrollbar-thumb:hover {
background: #6b7280;
}
</style>
<!-- [/DEF:TaskLogPanel:Component] -->