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

View File

@@ -1,4 +1,5 @@
// [DEF:api_module:Module]
// @TIER: STANDARD
// @SEMANTICS: api, client, fetch, rest
// @PURPOSE: Handles all communication with the backend API.
// @LAYER: Infra-API

View File

@@ -1,4 +1,5 @@
// [DEF:stores_module:Module]
// @TIER: STANDARD
// @SEMANTICS: state, stores, svelte, plugins, tasks
// @PURPOSE: Global state management using Svelte stores.
// @LAYER: UI-State

View File

@@ -1,7 +1,8 @@
<!-- [DEF:AdminSettingsPage:Component] -->
<!--
@SEMANTICS: admin, adfs, mappings, configuration
@PURPOSE: UI for configuring Active Directory Group to local Role mappings for ADFS SSO.
@TIER: STANDARD
@SEMANTICS: admin, adfs, mappings, configuration, logging
@PURPOSE: UI for configuring Active Directory Group to local Role mappings for ADFS SSO and logging settings.
@LAYER: Feature
@RELATION: DEPENDS_ON -> frontend.src.services.adminService
@RELATION: DEPENDS_ON -> frontend.src.components.auth.ProtectedRoute
@@ -28,6 +29,19 @@
role_id: ''
};
// [SECTION: LOGGING_CONFIG]
let loggingConfig = {
level: 'INFO',
task_log_level: 'INFO',
enable_belief_state: true
};
let loggingConfigLoading = false;
let loggingConfigSaving = false;
let loggingConfigSaved = false;
// [/SECTION]
const LOG_LEVELS = ['DEBUG', 'INFO', 'WARNING', 'ERROR'];
// [DEF:loadData:Function]
/**
* @purpose Fetches AD mappings and roles from the backend to populate the UI.
@@ -93,7 +107,64 @@
}
// [/DEF:handleCreateMapping:Function]
onMount(loadData);
// [DEF:loadLoggingConfig:Function]
/**
* @purpose Fetches current logging configuration from the backend.
* @pre Component is mounted and user has active session.
* @post loggingConfig variable is updated with backend data.
* @returns {Promise<void>}
* @relation CALLS -> adminService.getLoggingConfig
*/
async function loadLoggingConfig() {
console.log('[AdminSettingsPage][loadLoggingConfig][Entry]');
loggingConfigLoading = true;
try {
const config = await adminService.getLoggingConfig();
loggingConfig = {
level: config.level || 'INFO',
task_log_level: config.task_log_level || 'INFO',
enable_belief_state: config.enable_belief_state ?? true
};
console.log('[AdminSettingsPage][loadLoggingConfig][Coherence:OK]');
} catch (e) {
console.error('[AdminSettingsPage][loadLoggingConfig][Coherence:Failed]', e);
} finally {
loggingConfigLoading = false;
}
}
// [/DEF:loadLoggingConfig:Function]
// [DEF:saveLoggingConfig:Function]
/**
* @purpose Saves logging configuration to the backend.
* @pre loggingConfig contains valid values.
* @post Configuration is saved and feedback is shown.
* @returns {Promise<void>}
* @relation CALLS -> adminService.updateLoggingConfig
*/
async function saveLoggingConfig() {
console.log('[AdminSettingsPage][saveLoggingConfig][Entry]');
loggingConfigSaving = true;
loggingConfigSaved = false;
try {
await adminService.updateLoggingConfig(loggingConfig);
loggingConfigSaved = true;
console.log('[AdminSettingsPage][saveLoggingConfig][Coherence:OK]');
// Reset saved indicator after 2 seconds
setTimeout(() => { loggingConfigSaved = false; }, 2000);
} catch (e) {
alert("Failed to save logging configuration: " + (e.message || "Unknown error"));
console.error('[AdminSettingsPage][saveLoggingConfig][Coherence:Failed]', e);
} finally {
loggingConfigSaving = false;
}
}
// [/DEF:saveLoggingConfig:Function]
onMount(() => {
loadData();
loadLoggingConfig();
});
</script>
<ProtectedRoute requiredPermission="admin:settings">
@@ -155,6 +226,74 @@
</div>
{/if}
<!-- [SECTION: LOGGING_CONFIG_UI] -->
<div class="mt-8 bg-white shadow rounded-lg border border-gray-200 p-6">
<h2 class="text-xl font-bold mb-4 text-gray-800">Logging Configuration</h2>
{#if loggingConfigLoading}
<div class="flex justify-center py-4">
<p class="text-gray-500 animate-pulse">Loading logging configuration...</p>
</div>
{:else}
<div class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Application Log Level</label>
<select
bind:value={loggingConfig.level}
class="w-full border border-gray-300 p-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
{#each LOG_LEVELS as level}
<option value={level}>{level}</option>
{/each}
</select>
<p class="text-xs text-gray-500 mt-1">Controls the verbosity of application logs.</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Task Log Level</label>
<select
bind:value={loggingConfig.task_log_level}
class="w-full border border-gray-300 p-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
{#each LOG_LEVELS as level}
<option value={level}>{level}</option>
{/each}
</select>
<p class="text-xs text-gray-500 mt-1">Minimum level for logs stored in task history. DEBUG shows all logs.</p>
</div>
</div>
<div class="flex items-center">
<input
type="checkbox"
id="enable_belief_state"
bind:checked={loggingConfig.enable_belief_state}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label for="enable_belief_state" class="ml-2 block text-sm text-gray-700">
Enable Belief State Logging (Entry/Exit/Coherence logs)
</label>
</div>
<p class="text-xs text-gray-500 -mt-2">When disabled, belief scope logs are hidden. Requires DEBUG level to see in task logs.</p>
<div class="flex items-center gap-3 pt-2">
<button
on:click={saveLoggingConfig}
disabled={loggingConfigSaving}
class="px-4 py-2 bg-blue-600 text-white rounded font-medium hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{loggingConfigSaving ? 'Saving...' : 'Save Configuration'}
</button>
{#if loggingConfigSaved}
<span class="text-green-600 text-sm font-medium">✓ Saved</span>
{/if}
</div>
</div>
{/if}
</div>
<!-- [/SECTION: LOGGING_CONFIG_UI] -->
{#if showCreateModal}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div class="bg-white rounded-lg shadow-xl p-6 max-w-md w-full">

View File

@@ -1,6 +1,7 @@
<!-- [DEF:LLMSettingsPage:Component] -->
<!--
<!--
@TIER: STANDARD
@SEMANTICS: admin, llm, settings, provider, configuration
@PURPOSE: Admin settings page for LLM provider configuration.
@LAYER: UI
@RELATION: CALLS -> frontend/src/components/llm/ProviderConfig.svelte

View File

@@ -1,5 +1,6 @@
<!-- [DEF:DebugPage:Component] -->
<!--
@TIER: TRIVIAL
@SEMANTICS: debug, page, tool
@PURPOSE: Page for system diagnostics and debugging.
@LAYER: UI

View File

@@ -1,5 +1,6 @@
<!-- [DEF:MapperPage:Component] -->
<!--
@TIER: TRIVIAL
@SEMANTICS: mapper, page, tool
@PURPOSE: Page for the dataset column mapper tool.
@LAYER: UI

View File

@@ -25,6 +25,7 @@
// [DEF:loadFiles:Function]
/**
* @purpose Fetches the list of files from the server.
* @pre The activeTab is set to a valid category.
* @post Updates the `files` array with the latest data.
*/
let files = [];
@@ -33,6 +34,7 @@
let currentPath = 'backups'; // Relative to storage root
async function loadFiles() {
console.log('[STORAGE-PAGE][LOAD_START] category=%s path=%s', activeTab, currentPath);
isLoading = true;
try {
const category = activeTab;
@@ -51,7 +53,9 @@
: effectivePath;
files = await listFiles(category, subpath);
console.log('[STORAGE-PAGE][LOAD_OK] count=%d', files.length);
} catch (error) {
console.log('[STORAGE-PAGE][LOAD_ERR] error=%s', error.message);
addToast($t.storage.messages.load_failed.replace('{error}', error.message), 'error');
} finally {
isLoading = false;
@@ -62,17 +66,22 @@
// [DEF:handleDelete:Function]
/**
* @purpose Handles the file deletion process.
* @pre The event contains valid category and path.
* @post File is deleted and file list is refreshed.
* @param {CustomEvent} event - The delete event containing category and path.
*/
async function handleDelete(event) {
const { category, path, name } = event.detail;
console.log('[STORAGE-PAGE][DELETE_START] category=%s path=%s', category, path);
if (!confirm($t.storage.messages.delete_confirm.replace('{name}', name))) return;
try {
await deleteFile(category, path);
console.log('[STORAGE-PAGE][DELETE_OK] name=%s', name);
addToast($t.storage.messages.delete_success.replace('{name}', name), 'success');
await loadFiles();
} catch (error) {
console.log('[STORAGE-PAGE][DELETE_ERR] error=%s', error.message);
addToast($t.storage.messages.delete_failed.replace('{error}', error.message), 'error');
}
}
@@ -81,9 +90,12 @@
// [DEF:handleNavigate:Function]
/**
* @purpose Updates the current path and reloads files when navigating into a directory.
* @pre The event contains a valid path string.
* @post currentPath is updated and files are reloaded.
* @param {CustomEvent} event - The navigation event containing the new path.
*/
function handleNavigate(event) {
console.log('[STORAGE-PAGE][NAVIGATE] path=%s', event.detail);
currentPath = event.detail;
loadFiles();
}

View File

@@ -58,6 +58,8 @@ async function createUser(userData) {
// [DEF:getRoles:Function]
/**
* @purpose Fetches all available system roles.
* @pre User must be authenticated with Admin privileges.
* @post Returns an array of role objects.
* @returns {Promise<Array>}
* @relation CALLS -> backend.src.api.routes.admin.list_roles
*/
@@ -77,6 +79,8 @@ async function getRoles() {
// [DEF:getADGroupMappings:Function]
/**
* @purpose Fetches mappings between AD groups and local roles.
* @pre User must be authenticated with Admin privileges.
* @post Returns an array of AD group mapping objects.
* @returns {Promise<Array>}
*/
async function getADGroupMappings() {
@@ -95,6 +99,8 @@ async function getADGroupMappings() {
// [DEF:createADGroupMapping:Function]
/**
* @purpose Creates or updates an AD group to Role mapping.
* @pre User must be authenticated with Admin privileges.
* @post New or updated mapping created in auth.db.
* @param {Object} mappingData - Mapping details (ad_group, role_id).
* @returns {Promise<Object>}
*/
@@ -114,6 +120,8 @@ async function createADGroupMapping(mappingData) {
// [DEF:updateUser:Function]
/**
* @purpose Updates an existing user.
* @pre User must be authenticated with Admin privileges.
* @post User record updated in auth.db.
* @param {string} userId - Target user ID.
* @param {Object} userData - Updated user data.
* @returns {Promise<Object>}
@@ -134,6 +142,8 @@ async function updateUser(userId, userData) {
// [DEF:deleteUser:Function]
/**
* @purpose Deletes a user.
* @pre User must be authenticated with Admin privileges.
* @post User record removed from auth.db.
* @param {string} userId - Target user ID.
* @returns {Promise<void>}
*/
@@ -152,6 +162,8 @@ async function deleteUser(userId) {
// [DEF:createRole:Function]
/**
* @purpose Creates a new role.
* @pre User must be authenticated with Admin privileges.
* @post New role created in auth.db.
* @param {Object} roleData - Role details (name, description, permissions).
* @returns {Promise<Object>}
*/
@@ -224,6 +236,45 @@ async function getPermissions() {
}
// [/DEF:getPermissions:Function]
// [DEF:getLoggingConfig:Function]
/**
* @purpose Fetches current logging configuration.
* @returns {Promise<Object>} - Logging config with level, task_log_level, enable_belief_state.
* @relation CALLS -> backend.src.api.routes.settings.get_logging_config
*/
async function getLoggingConfig() {
console.log('[getLoggingConfig][Entry]');
try {
const config = await api.requestApi('/settings/logging', 'GET');
console.log('[getLoggingConfig][Coherence:OK]');
return config;
} catch (e) {
console.error('[getLoggingConfig][Coherence:Failed]', e);
throw e;
}
}
// [/DEF:getLoggingConfig:Function]
// [DEF:updateLoggingConfig:Function]
/**
* @purpose Updates logging configuration.
* @param {Object} configData - Logging config (level, task_log_level, enable_belief_state).
* @returns {Promise<Object>}
* @relation CALLS -> backend.src.api.routes.settings.update_logging_config
*/
async function updateLoggingConfig(configData) {
console.log('[updateLoggingConfig][Entry]');
try {
const config = await api.requestApi('/settings/logging', 'PATCH', configData);
console.log('[updateLoggingConfig][Coherence:OK]');
return config;
} catch (e) {
console.error('[updateLoggingConfig][Coherence:Failed]', e);
throw e;
}
}
// [/DEF:updateLoggingConfig:Function]
export const adminService = {
getUsers,
createUser,
@@ -235,7 +286,9 @@ export const adminService = {
deleteRole,
getPermissions,
getADGroupMappings,
createADGroupMapping
createADGroupMapping,
getLoggingConfig,
updateLoggingConfig
};
// [/DEF:adminService:Module]

View File

@@ -1,5 +1,6 @@
// [DEF:GitServiceClient:Module]
/**
* @TIER: STANDARD
* @SEMANTICS: git, service, api, client
* @PURPOSE: API client for Git operations, managing the communication between frontend and backend.
* @LAYER: Service

View File

@@ -1,5 +1,6 @@
// [DEF:storageService:Module]
/**
* @TIER: STANDARD
* @purpose Frontend API client for file storage management.
* @layer Service
* @relation DEPENDS_ON -> backend.api.storage
@@ -8,6 +9,25 @@
const API_BASE = '/api/storage';
// [DEF:getStorageAuthHeaders:Function]
/**
* @purpose Returns headers with Authorization for storage API calls.
* @returns {Object} Headers object with Authorization if token exists.
* @NOTE Unlike api.js getAuthHeaders, this doesn't set Content-Type
* to allow FormData to set its own multipart boundary.
*/
function getStorageAuthHeaders() {
const headers = {};
if (typeof window !== 'undefined') {
const token = localStorage.getItem('auth_token');
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
}
return headers;
}
// [/DEF:getStorageAuthHeaders:Function]
// [DEF:listFiles:Function]
/**
* @purpose Fetches the list of files for a given category and subpath.
@@ -25,7 +45,9 @@ export async function listFiles(category, path) {
if (path) {
params.append('path', path);
}
const response = await fetch(`${API_BASE}/files?${params.toString()}`);
const response = await fetch(`${API_BASE}/files?${params.toString()}`, {
headers: getStorageAuthHeaders()
});
if (!response.ok) {
throw new Error(`Failed to fetch files: ${response.statusText}`);
}
@@ -53,6 +75,7 @@ export async function uploadFile(file, category, path) {
const response = await fetch(`${API_BASE}/upload`, {
method: 'POST',
headers: getStorageAuthHeaders(),
body: formData
});
@@ -75,7 +98,8 @@ export async function uploadFile(file, category, path) {
*/
export async function deleteFile(category, path) {
const response = await fetch(`${API_BASE}/files/${category}/${path}`, {
method: 'DELETE'
method: 'DELETE',
headers: getStorageAuthHeaders()
});
if (!response.ok) {
@@ -93,6 +117,8 @@ export async function deleteFile(category, path) {
* @returns {string}
* @PRE category and path must identify an existing file.
* @POST Returns a valid API URL for file download.
* @NOTE Downloads use browser navigation, so auth is handled via cookies
* or the backend must allow unauthenticated downloads for valid paths.
*/
export function downloadFileUrl(category, path) {
return `${API_BASE}/download/${category}/${path}`;