semantic update
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<!-- [DEF:Footer:Component] -->
|
||||
<!--
|
||||
@TIER: TRIVIAL
|
||||
@SEMANTICS: footer, layout, copyright
|
||||
@PURPOSE: Displays the application footer with copyright information.
|
||||
@LAYER: UI
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<!-- [DEF:Navbar:Component] -->
|
||||
<!--
|
||||
@TIER: STANDARD
|
||||
@SEMANTICS: navbar, navigation, header, layout
|
||||
@PURPOSE: Main navigation bar for the application.
|
||||
@LAYER: UI
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<!-- [DEF:CommitModal:Component] -->
|
||||
<!--
|
||||
@TIER: STANDARD
|
||||
@SEMANTICS: git, commit, modal, version_control, diff
|
||||
@PURPOSE: Модальное окно для создания коммита с просмотром изменений (diff).
|
||||
@LAYER: Component
|
||||
|
||||
196
frontend/src/components/tasks/LogEntryRow.svelte
Normal file
196
frontend/src/components/tasks/LogEntryRow.svelte
Normal 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] -->
|
||||
161
frontend/src/components/tasks/LogFilterBar.svelte
Normal file
161
frontend/src/components/tasks/LogFilterBar.svelte
Normal 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] -->
|
||||
119
frontend/src/components/tasks/TaskLogPanel.svelte
Normal file
119
frontend/src/components/tasks/TaskLogPanel.svelte
Normal 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] -->
|
||||
Reference in New Issue
Block a user