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] -->
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<!-- [DEF:DebugPage:Component] -->
|
||||
<!--
|
||||
@TIER: TRIVIAL
|
||||
@SEMANTICS: debug, page, tool
|
||||
@PURPOSE: Page for system diagnostics and debugging.
|
||||
@LAYER: UI
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<!-- [DEF:MapperPage:Component] -->
|
||||
<!--
|
||||
@TIER: TRIVIAL
|
||||
@SEMANTICS: mapper, page, tool
|
||||
@PURPOSE: Page for the dataset column mapper tool.
|
||||
@LAYER: UI
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
@@ -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
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
Reference in New Issue
Block a user