Improve reports UI and task drawer UX

This commit is contained in:
2026-07-03 14:50:05 +03:00
parent 33ee976c48
commit 53eb2b1cca
24 changed files with 785 additions and 188 deletions

View File

@@ -53,7 +53,7 @@
* @TEST_INVARIANT default_rendering -> verifies: [init_state]
*/
import { onDestroy } from "svelte";
import { onDestroy, onMount } from "svelte";
import { log } from "$lib/cot-logger";
import { taskDrawerStore, closeDrawer } from "$lib/stores/taskDrawer.svelte.js";
import { assistantChatStore } from "$lib/stores/assistantChat.svelte.js";
@@ -75,14 +75,22 @@
let recentTasks = $state([]);
let loadingTasks = $state(false);
let activeTaskDetails = $state(null);
let activeTaskDetailsLoading = $state(false);
let activeTaskDetailsError = $state("");
let environmentOptions = [];
let diffText = $state("");
let diffError = $state("");
let showDiff = $state(false);
let isDiffLoading = $state(false);
let wsConnectionState = $state("idle");
let taskDetailRequestSeq = 0;
let lastHandledDrawerKey = "";
let unsubscribeTaskDrawer = null;
let drawerState = $state(taskDrawerStore.value);
let isOpen = $derived(Boolean(taskDrawerStore.value?.isOpen));
let activeTaskId = $derived(taskDrawerStore.value?.activeTaskId || null);
let scrollToHint = $derived(taskDrawerStore.value?.scrollToHint || null);
let isOpen = $derived(Boolean(drawerState?.isOpen));
let activeTaskId = $derived(drawerState?.activeTaskId || null);
let scrollToHint = $derived(drawerState?.scrollToHint || null);
// Scroll to last error when opening a FAILED task
$effect(() => {
@@ -145,12 +153,13 @@
}
function isTaskFinished(status) {
return (
status === "SUCCESS" ||
status === "FAILED" ||
status === "ERROR" ||
status === "PARTIAL_SUCCESS"
);
const s = String(status || "").toUpperCase();
return s === "SUCCESS" || s === "COMPLETED" || s === "FAILED" || s === "ERROR" || s === "PARTIAL_SUCCESS";
}
function isTaskRunning(status) {
const s = String(status || "").toUpperCase();
return s === "RUNNING" || s === "IN_PROGRESS" || s === "PENDING" || s === "AWAITING_INPUT";
}
function resolveLlmValidationStatus(task) {
@@ -168,7 +177,7 @@
if (raw === "FAIL") return { label: "FAIL", tone: "fail", icon: "!" };
if (raw === "WARN") return { label: "WARN", tone: "warn", icon: "!" };
if (raw === "PASS") return { label: "PASS", tone: "pass", icon: "OK" };
return { label: "UNKNOWN", tone: "unknown", icon: "?" };
return null;
}
function llmValidationBadgeClass(tone) {
@@ -194,6 +203,39 @@
return "bg-surface-muted text-text ring-1 ring-secondary-ring";
}
function getTaskStatusLabel(status) {
const s = status?.toLowerCase();
if (s === "success" || s === "completed")
return $t.reports?.status_success || "Success";
if (s === "failed" || s === "error")
return $t.reports?.status_failed || "Failed";
if (s === "running" || s === "in_progress")
return $t.reports?.status_in_progress || "In progress";
if (s === "partial" || s === "partial_success")
return $t.reports?.status_partial || "Partial";
return status || $t.common?.unknown;
}
function getConnectionLabel(state) {
if (state === "live") return $t.tasks?.live || "Live";
if (state === "connecting") return $t.tasks?.connecting || "Connecting...";
if (state === "reconnecting") return $t.tasks?.reconnecting || "Reconnecting...";
if (state === "error") return $t.tasks?.disconnected || "Disconnected";
if (state === "closed") return $t.tasks?.completed || "Completed";
return $t.tasks?.waiting_logs || "Waiting for logs...";
}
function getConnectionDotClass(state) {
if (state === "live") return "bg-success";
if (state === "connecting" || state === "reconnecting") return "bg-primary-ring animate-pulse";
if (state === "error") return "bg-destructive";
return "bg-text-subtle";
}
function normalizeTaskPayload(payload) {
return payload?.task || payload;
}
async function loadEnvironmentOptions() {
try {
const response = await api.getEnvironments();
@@ -377,6 +419,7 @@
showDiff = true;
isDiffLoading = true;
diffText = "";
diffError = "";
try {
const diffPayload = await gitService.getDiff(
derivedTaskSummary.primaryDashboardId,
@@ -390,7 +433,7 @@
diffText = "";
}
} catch (err) {
addToast(err?.message || "Failed to load diff", "error");
diffError = err?.message || ($t.tasks?.no_diff_available || "No diff available");
diffText = "";
} finally {
isDiffLoading = false;
@@ -426,23 +469,56 @@
);
}
// Connect to WebSocket for real-time logs
function connectWebSocket() {
if (!activeTaskId) return;
async function loadActiveTaskDetails(taskId, requestSeq) {
if (!taskId) return;
activeTaskDetailsLoading = true;
activeTaskDetailsError = "";
try {
log("TaskDrawer", "REASON", "Load active task details", { taskId });
const payload = await api.getTask(taskId);
if (requestSeq !== taskDetailRequestSeq || normalizeTaskId(activeTaskId) !== taskId) return;
const task = normalizeTaskPayload(payload);
activeTaskDetails = task;
if (task?.status) taskStatus = task.status;
log("TaskDrawer", "REFLECT", "Active task details loaded", { taskId, status: task?.status });
} catch (err) {
if (requestSeq !== taskDetailRequestSeq || normalizeTaskId(activeTaskId) !== taskId) return;
activeTaskDetailsError = err instanceof Error
? err.message
: ($t.tasks?.fetch_task_details_failed || "Failed to load task details");
log("TaskDrawer", "EXPLORE", "Active task details failed", { taskId }, activeTaskDetailsError);
} finally {
if (requestSeq === taskDetailRequestSeq && normalizeTaskId(activeTaskId) === taskId) {
activeTaskDetailsLoading = false;
}
}
}
const taskId = normalizeTaskId(activeTaskId);
// Connect to WebSocket for real-time logs
function connectWebSocket(taskId, requestSeq) {
if (!taskId) return;
const wsUrl = getWsUrl(taskId);
log("TaskDrawer", "REASON", "Connecting to WebSocket", { wsUrl });
wsConnectionState = "connecting";
ws = new WebSocket(wsUrl);
ws.onopen = () => {
if (requestSeq !== taskDetailRequestSeq || normalizeTaskId(activeTaskId) !== taskId) return;
wsConnectionState = "live";
log("TaskDrawer", "REFLECT", "WebSocket connected");
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (requestSeq !== taskDetailRequestSeq || normalizeTaskId(activeTaskId) !== taskId) return;
let data;
try {
data = JSON.parse(event.data);
} catch (err) {
log("TaskDrawer", "EXPLORE", "WebSocket message parse failed", { taskId }, err instanceof Error ? err.message : "Invalid JSON");
return;
}
// ── Task status update from WebSocket ──
if (data.type === "task_status") {
@@ -450,6 +526,9 @@
log("TaskDrawer", "REFLECT", "WebSocket status update", { status: taskData.status });
activeTaskDetails = taskData;
taskStatus = taskData.status;
if (isTaskFinished(taskData.status)) {
void loadActiveTaskDetails(taskId, requestSeq);
}
return;
}
@@ -459,28 +538,37 @@
if (data.message?.includes("Task completed successfully")) {
taskStatus = "SUCCESS";
void loadActiveTaskDetails(taskId, requestSeq);
} else if (data.message?.includes("Task failed")) {
taskStatus = "FAILED";
void loadActiveTaskDetails(taskId, requestSeq);
}
};
ws.onerror = (error) => {
if (requestSeq !== taskDetailRequestSeq || normalizeTaskId(activeTaskId) !== taskId) return;
wsConnectionState = "error";
log("TaskDrawer", "EXPLORE", "WebSocket error", {}, error instanceof Error ? error.message : "Unknown websocket error");
};
ws.onclose = (event) => {
if (requestSeq !== taskDetailRequestSeq || normalizeTaskId(activeTaskId) !== taskId) return;
// event.code 1000 = normal closure (task finished) — stop reconnecting
// event.code 1006 = abnormal close (network issue) — reconnect
const isNormalClosure = event.code === 1000;
const isTerminal = taskStatus === "SUCCESS" || taskStatus === "FAILED";
const isTerminal = isTaskFinished(taskStatus);
if (isNormalClosure || isTerminal) {
wsConnectionState = "closed";
log("TaskDrawer", "REFLECT", "WebSocket closed normally - stream ended", { code: event.code });
} else {
wsConnectionState = "reconnecting";
log("TaskDrawer", "REASON", "WebSocket disconnected, reconnecting in 10s", { code: event.code });
ws = null;
wsReconnectTimer = setTimeout(() => {
if (activeTaskId) connectWebSocket();
if (requestSeq === taskDetailRequestSeq && normalizeTaskId(activeTaskId) === taskId) {
connectWebSocket(taskId, requestSeq);
}
}, 10000);
}
};
@@ -506,6 +594,7 @@
ws.close();
ws = null;
}
wsConnectionState = "idle";
}
// #endregion disconnectWebSocket:Function
@@ -570,26 +659,59 @@
}
// #endregion goBackToList:Function
// Reconnect when active task changes
$effect(() => {
if (isOpen) {
if (activeTaskId) {
function buildDrawerKey(state) {
const nextTaskId = normalizeTaskId(state?.activeTaskId || null);
return `${state?.isOpen ? "open" : "closed"}:${nextTaskId || "list"}`;
}
onMount(() => {
unsubscribeTaskDrawer = taskDrawerStore.subscribe((state) => {
drawerState = state;
const drawerKey = buildDrawerKey(state);
if (drawerKey === lastHandledDrawerKey) return;
lastHandledDrawerKey = drawerKey;
handleDrawerKeyChange(drawerKey);
});
});
function handleDrawerKeyChange(drawerKey) {
const [openState, taskId] = drawerKey.split(":");
const drawerIsOpen = openState === "open";
const hasActiveTask = drawerIsOpen && taskId && taskId !== "list";
if (drawerIsOpen) {
if (hasActiveTask) {
const requestSeq = ++taskDetailRequestSeq;
disconnectWebSocket();
realTimeLogs = [];
showDiff = false;
diffText = "";
taskStatus = "RUNNING";
diffError = "";
taskStatus = null;
activeTaskDetails = null;
connectWebSocket();
activeTaskDetailsError = "";
activeTaskDetailsLoading = true;
void loadActiveTaskDetails(taskId, requestSeq);
connectWebSocket(taskId, requestSeq);
void loadEnvironmentOptions();
} else {
taskDetailRequestSeq += 1;
disconnectWebSocket();
activeTaskDetails = null;
activeTaskDetailsError = "";
activeTaskDetailsLoading = false;
taskStatus = null;
void loadRecentTasks();
}
} else {
taskDetailRequestSeq += 1;
disconnectWebSocket();
activeTaskDetails = null;
activeTaskDetailsError = "";
activeTaskDetailsLoading = false;
taskStatus = null;
}
});
}
let derivedTaskSummary = $derived(buildTaskSummary(activeTaskDetails));
let derivedActiveTaskValidation = $derived(
@@ -598,6 +720,10 @@
// Cleanup on destroy
onDestroy(() => {
if (unsubscribeTaskDrawer) {
unsubscribeTaskDrawer();
unsubscribeTaskDrawer = null;
}
if (wsReconnectTimer) {
clearTimeout(wsReconnectTimer);
}
@@ -609,17 +735,17 @@
{#if isOpen}
<div
class="fixed top-0 z-[72] flex h-full w-full max-w-[560px] flex-col border-l border-border bg-surface-card shadow-[-12px_0_40px_rgba(15,23,42,0.1)] transition-[right] duration-300 ease-out"
class="fixed top-0 z-[72] flex h-full w-full max-w-[500px] flex-col border-l border-border bg-surface-card shadow-[-12px_0_40px_rgba(15,23,42,0.1)] transition-[right] duration-300 ease-out"
style={`right: ${assistantOffset};`}
role="dialog"
aria-modal="false"
aria-modal="true"
aria-label={$t.tasks?.drawer}
>
<!-- Header -->
<div
class="flex items-center justify-between border-b border-border bg-surface-card px-5 py-3.5"
class="flex items-start justify-between gap-3 border-b border-border bg-surface-card px-5 py-3.5"
>
<div class="flex items-center gap-2.5">
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-2.5">
{#if !activeTaskId && recentTasks.length > 0}
<span
class="flex items-center justify-center p-1.5 mr-1 text-info"
@@ -628,11 +754,12 @@
</span>
{:else if activeTaskId}
<button
class="flex items-center justify-center p-1.5 rounded-md text-text-muted bg-transparent border-none cursor-pointer transition-all hover:text-text hover:bg-surface-muted"
class="flex h-11 w-11 items-center justify-center rounded-md text-text-muted bg-transparent border-none cursor-pointer transition-all hover:text-text hover:bg-surface-muted"
onclick={goBackToList}
aria-label={$t.tasks?.back_to_list}
title={$t.tasks?.back_to_list}
>
<Icon name="back" size={16} strokeWidth={2} />
<Icon name="back" size={18} strokeWidth={2} />
</button>
{/if}
<h2 class="text-sm font-bold tracking-tight text-text">
@@ -646,9 +773,11 @@
{/if}
{#if taskStatus}
<span
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded {getStatusClass(taskStatus)}"
class="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded {getStatusClass(taskStatus)}"
title="Task execution status"
>
{taskStatus}
<span class="text-[9px] opacity-70">{$t.tasks?.task_label || "Task"}</span>
{getTaskStatusLabel(taskStatus)}
</span>
{/if}
{#if derivedActiveTaskValidation}
@@ -656,6 +785,7 @@
class={`text-xs font-semibold uppercase tracking-wider px-2 py-0.5 rounded-full inline-flex items-center gap-1 ${llmValidationBadgeClass(derivedActiveTaskValidation.tone)}`}
title="Dashboard validation result"
>
<span class="text-[9px] opacity-70">LLM</span>
<span
class="inline-flex min-w-[18px] items-center justify-center rounded-full bg-surface-card/70 px-1 text-[10px] font-bold"
>
@@ -667,17 +797,12 @@
</div>
<div class="flex items-center gap-2">
<button
class="rounded-md border border-border-strong bg-surface-page px-2.5 py-1 text-xs font-semibold text-text transition-colors hover:bg-surface-muted"
onclick={goToReportsPage}
>
{$t.nav?.reports}
</button>
<button
class="p-1.5 rounded-md text-text-muted bg-transparent border-none cursor-pointer transition-all hover:text-text hover:bg-surface-muted"
class="flex h-11 w-11 items-center justify-center rounded-md text-text-muted bg-transparent border-none cursor-pointer transition-all hover:text-text hover:bg-surface-muted"
onclick={handleClose}
aria-label={$t.tasks?.close_drawer}
title={$t.tasks?.close_drawer}
>
<Icon name="close" size={18} strokeWidth={2} />
<Icon name="close" size={20} strokeWidth={2} />
</button>
</div>
</div>
@@ -685,6 +810,41 @@
<!-- Content -->
<div class="flex-1 overflow-hidden flex flex-col">
{#if activeTaskId}
{#if activeTaskDetailsLoading && !activeTaskDetails}
<div class="mx-4 mt-4 rounded-xl border border-border bg-surface-card p-4 shadow-sm">
<div class="mb-3 flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<div class="h-7 w-7 animate-pulse rounded-lg bg-surface-muted"></div>
<div class="h-4 w-28 animate-pulse rounded bg-surface-muted"></div>
</div>
<div class="h-5 w-16 animate-pulse rounded-full bg-surface-muted"></div>
</div>
<div class="space-y-2">
<div class="h-3 w-3/4 animate-pulse rounded bg-surface-muted"></div>
<div class="h-3 w-1/2 animate-pulse rounded bg-surface-muted"></div>
</div>
</div>
{:else if activeTaskDetailsError}
<div class="mx-4 mt-4 rounded-xl border border-warning-ring bg-warning-light p-4 text-xs text-warning">
<div class="flex items-start gap-3">
<Icon name="warning" size={16} strokeWidth={2} />
<div class="min-w-0">
<p class="font-semibold">{$t.tasks?.fetch_task_details_failed || "Failed to load task details"}</p>
<p class="mt-1 break-words">{activeTaskDetailsError}</p>
<button
class="mt-3 rounded-md border border-warning-ring bg-surface-card px-2.5 py-1 text-xs font-semibold text-warning transition-colors hover:bg-warning-light"
onclick={() => {
const taskId = normalizeTaskId(activeTaskId);
const requestSeq = ++taskDetailRequestSeq;
void loadActiveTaskDetails(taskId, requestSeq);
}}
>
{$t.common?.retry || "Retry"}
</button>
</div>
</div>
</div>
{/if}
{#if derivedTaskSummary}
<div
class="mx-4 mt-4 rounded-xl border border-border bg-surface-card p-4 shadow-sm"
@@ -701,7 +861,7 @@
<span
class="rounded-full px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wider {getStatusClass(taskStatus)}"
>
{taskStatus}
{getTaskStatusLabel(taskStatus)}
</span>
</div>
{#if derivedTaskSummary.headline}
@@ -773,8 +933,15 @@
{:else if diffText}
<pre
class="max-h-40 overflow-auto rounded bg-terminal-bg p-2 text-[11px] text-text-subtle">{diffText}</pre>
{:else if diffError}
<div class="rounded-md border border-warning-ring bg-warning-light p-3 text-xs text-warning">
<p class="font-semibold">
{$t.tasks?.no_diff_available || "Diff unavailable"}
</p>
<p class="mt-1 text-warning">{diffError}</p>
</div>
{:else}
<p class="text-xs text-text-muted">
<p class="rounded-md border border-border bg-surface-muted p-3 text-xs text-text-muted">
{$t.tasks?.no_diff_available || "No diff available"}
</p>
{/if}
@@ -787,13 +954,14 @@
taskId={activeTaskId}
{taskStatus}
{realTimeLogs}
showErrorSummary={true}
/>
{:else if loadingTasks}
<div
class="flex flex-col items-center justify-center p-12 text-text-muted"
>
<div
class="mb-4 h-8 w-8 animate-spin rounded-full border-2 border-border border-t-blue-500"
class="mb-4 h-8 w-8 animate-spin rounded-full border-2 border-border border-t-primary"
></div>
<p>{$t.tasks?.loading}</p>
</div>
@@ -804,7 +972,7 @@
{$t.tasks?.recent}
</h3>
{#if loadingTasks}
<div class="h-3 w-3 animate-spin rounded-full border border-border border-t-blue-500"></div>
<div class="h-3 w-3 animate-spin rounded-full border border-border border-t-primary"></div>
{/if}
</div>
<div class="grid gap-4">
@@ -848,7 +1016,7 @@
task.status,
)}"
>
{task.status || $t.common?.unknown}
{getTaskStatusLabel(task.status)}
</span>
</div>
</div>
@@ -898,13 +1066,13 @@
</div>
<!-- Footer — hidden for terminal states -->
{#if taskStatus !== "SUCCESS" && taskStatus !== "FAILED"}
{#if activeTaskId && (isTaskRunning(taskStatus) || (!taskStatus && wsConnectionState !== "idle"))}
<div
class="flex items-center gap-2 justify-center px-4 py-3 border-t border-border bg-surface-page"
>
<div class="w-1.5 h-1.5 rounded-full bg-primary-ring animate-pulse"></div>
<div class="w-1.5 h-1.5 rounded-full {getConnectionDotClass(wsConnectionState)}"></div>
<p class="text-[11px] font-medium text-text-muted">
{$t.tasks?.footer_text}
{getConnectionLabel(wsConnectionState)}
</p>
</div>
{/if}

View File

@@ -381,11 +381,11 @@
</script>
<nav
class="fixed left-0 right-0 top-0 z-40 flex h-16 items-center justify-between border-b border-border bg-surface-card px-4 shadow-sm
class="fixed left-0 right-0 top-0 z-40 flex h-16 max-w-full items-center justify-between overflow-hidden border-b border-border bg-surface-card px-3 shadow-sm sm:px-4
{isExpanded ? 'md:left-[240px]' : 'md:left-16'}"
>
<!-- Left section: Hamburger (mobile) + Logo -->
<div class="flex items-center gap-2">
<div class="flex min-w-0 items-center gap-2">
<!-- Hamburger Menu (mobile only) -->
<button
class="rounded-lg p-2 text-text-muted transition-colors hover:bg-surface-muted md:hidden"
@@ -398,14 +398,14 @@
<!-- Logo/Brand -->
<a
href="/"
class="flex items-center text-xl font-bold text-text transition-colors hover:text-primary"
class="flex min-w-0 items-center text-xl font-bold text-text transition-colors hover:text-primary"
>
<span
class="mr-2 inline-flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-sky-500 via-cyan-500 to-indigo-600 text-white shadow-sm"
>
<Icon name="layers" size={18} strokeWidth={2.1} />
</span>
<span>{$t.common?.brand}</span>
<span class="hidden sm:inline">{$t.common?.brand}</span>
</a>
</div>
@@ -469,7 +469,7 @@
</div>
<!-- Nav Actions -->
<div class="flex items-center gap-3 md:gap-4">
<div class="flex shrink-0 items-center gap-1 sm:gap-2 md:gap-4">
{#if globalEnvironments.length > 0}
<div class="hidden lg:flex items-center gap-2">
<select

View File

@@ -68,45 +68,43 @@
</script>
<button
class="w-full rounded-xl border p-4 text-left transition hover:border-border-strong hover:bg-surface-page hover:shadow {selected
class="w-full min-w-0 overflow-hidden rounded-lg border p-3 text-left transition hover:border-border-strong hover:bg-surface-page hover:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-ring {selected
? 'border-primary-ring bg-primary-light'
: 'border-border bg-surface-card'} {report?.status === 'failed' ? 'border-l-4 border-l-destructive-ring' : ''}"
onclick={onSelect}
aria-label={`${$t.reports?.title} ${report?.report_id || ""} ${$t.reports?.type} ${profileLabel || $t.reports?.unknown_type}`}
>
<div class="mb-2 flex items-center justify-between gap-2">
<div class="grid min-w-0 gap-2 sm:grid-cols-[minmax(8rem,11rem)_minmax(0,1fr)_minmax(5rem,7rem)_auto] sm:items-center">
<span
class="rounded px-2 py-0.5 text-xs font-semibold {profile?.variant ||
class="w-fit rounded px-2 py-0.5 text-xs font-semibold {profile?.variant ||
'bg-surface-muted text-text'}"
>
{profileLabel || $t.reports?.unknown_type}
</span>
<span class="min-w-0 text-sm font-medium text-text">
<span class="block truncate">{report?.summary || $t.reports?.not_provided}</span>
{#if report?.status === 'in_progress' && report?.error_context?.code === 'AWAITING_INPUT'}
<span class="mt-1 inline-flex bg-warning-light text-warning rounded-full px-2 py-0.5 text-xs font-medium">
Ожидает ввода
</span>
{/if}
</span>
<span class="flex items-center justify-between gap-2 text-[11px] text-text-muted sm:block sm:text-right">
<span class="font-mono">{report?.task_id?.slice(0, 8) || report?.report_id?.slice(0, 8) || 'N/A'}</span>
{#if report?.started_at && report?.updated_at}
<span class="sm:mt-1 sm:block">{formatDuration(report.started_at, report.updated_at)}</span>
{:else if report?.updated_at}
<span class="sm:mt-1 sm:block">{formatDateTime(report.updated_at)}</span>
{/if}
</span>
<span
class="rounded px-2 py-0.5 text-xs font-semibold {getStatusClass(
class="w-fit rounded px-2 py-0.5 text-xs font-semibold sm:justify-self-end {getStatusClass(
report?.status,
)}"
>
{getStatusLabel(report?.status)}
</span>
</div>
<p class="text-sm font-medium text-text">
{report?.summary || $t.reports?.not_provided}
</p>
{#if report?.status === 'in_progress' && report?.error_context?.code === 'AWAITING_INPUT'}
<div class="mt-1.5 flex items-center gap-2">
<span class="bg-warning-light text-warning rounded-full px-2 py-0.5 text-xs font-medium">
Ожидает ввода
</span>
</div>
{/if}
<div class="mt-1.5 flex items-center justify-between gap-2 text-[11px] text-text-muted">
<span class="font-mono truncate">{report?.task_id?.slice(0, 8) || report?.report_id?.slice(0, 8) || 'N/A'}</span>
{#if report?.started_at && report?.updated_at}
<span>{formatDuration(report.started_at, report.updated_at)}</span>
{:else if report?.updated_at}
<span>{formatDateTime(report.updated_at)}</span>
{/if}
</div>
</button>
<!-- #endregion ReportCard -->

View File

@@ -41,11 +41,11 @@
}
</script>
<div class="space-y-2">
<div class="min-w-0 space-y-2">
{#each reports as report (report.report_id)}
<ReportCard
{report}
selected={selectedReportId === report.report_id}
selected={selectedReportId === report.report_id || selectedReportId === report.task_id}
onselect={handleSelect}
/>
{/each}

View File

@@ -40,6 +40,16 @@
function handleCardClick(typeSummary: TaskSummary['by_type'][0], statusKey: keyof StatusCounts): void {
// Map summary status keys to ReportStatus for filtering
onFilterByTypeAndStatus(typeSummary.task_type, mapStatusKeyToReportStatus(statusKey));
}
function isStatusActive(typeSummary: TaskSummary['by_type'][0], statusKey: keyof StatusCounts): boolean {
const status = mapStatusKeyToReportStatus(statusKey);
const typeMatches = activeFilters.task_types.length === 0 || activeFilters.task_types.includes(typeSummary.task_type);
return typeMatches && activeFilters.statuses.includes(status);
}
function mapStatusKeyToReportStatus(statusKey: keyof StatusCounts): ReportStatus {
const statusMap: Record<string, ReportStatus> = {
running: 'in_progress',
pending: 'in_progress',
@@ -47,7 +57,7 @@
success: 'success',
failed: 'failed',
};
onFilterByTypeAndStatus(typeSummary.task_type, statusMap[statusKey] ?? 'in_progress');
return statusMap[statusKey] ?? 'in_progress';
}
</script>
@@ -65,14 +75,15 @@
<span class="text-text text-sm font-medium">{typeSummary.display_label}</span>
<span class="text-text-muted text-xs">({typeSummary.total})</span>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-2">
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
{#each STATUS_CONFIG as status (status.key)}
{@const count = getCount(typeSummary, status.key)}
{@const isActive = activeFilters.task_types.includes(typeSummary.task_type) && activeFilters.statuses.includes(status.key as ReportStatus)}
{@const isActive = isStatusActive(typeSummary, status.key)}
<button
class="flex items-center gap-2 p-2 rounded-lg border cursor-pointer transition-all hover:border-border-strong {screenState === 'disconnected' ? 'opacity-60' : ''} {status.colorClass} {isActive ? 'ring-2 ring-primary ring-offset-1' : ''}"
class="flex min-h-11 items-center gap-2 rounded-lg border px-2 py-2 text-left cursor-pointer transition-all hover:border-border-strong focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-ring {screenState === 'disconnected' ? 'opacity-60' : ''} {status.colorClass} {isActive ? 'border-primary-ring ring-2 ring-primary-ring/50' : 'border-border'}"
onclick={() => handleCardClick(typeSummary, status.key)}
aria-label="{typeSummary.display_label}, {status.label}: {count}"
aria-pressed={isActive}
>
<span class="text-sm font-mono min-w-[1.5rem] text-center font-semibold">{count}</span>
<span class="text-xs truncate">{status.label}</span>

View File

@@ -45,12 +45,14 @@ describe('ReportCard UX Contract', () => {
// @UX_STATE: Ready -> Card displays summary/status/type.
it('should display summary, status and type in Ready state', () => {
render(ReportCard, { report: mockReport, onselect: vi.fn() });
const { container } = render(ReportCard, { report: mockReport, onselect: vi.fn() });
expect(screen.getByText(mockReport.summary)).toBeDefined();
// mockReport.status is "success", getStatusLabel(status) returns $t.reports?.status_success
expect(screen.getByText('Success')).toBeDefined();
// Profile label for llm_verification is 'LLM'
expect(screen.getByText('LLM')).toBeDefined();
expect(container.querySelector('button')?.className).toContain('min-w-0');
expect(container.querySelector('button')?.className).toContain('overflow-hidden');
});
// @UX_FEEDBACK: Click on report emits select event.

View File

@@ -60,6 +60,7 @@ describe('ReportsList UX Contract', () => {
// Root div should have space-y-2 class but be empty
const div = container.querySelector('.space-y-2');
expect(div).toBeDefined();
expect(div?.className).toContain('min-w-0');
expect(div.children.length).toBe(0);
});

View File

@@ -59,6 +59,13 @@
let hasProgress = $derived(log.metadata?.progress !== undefined);
let progressPercent = $derived(log.metadata?.progress || 0);
function sanitizeMessage(message) {
const text = String(message || "");
return text.replace(/API Key decrypted \(first \d+ chars\):\s*\S+/gi, "API key decrypted: [redacted]");
}
let sanitizedMessage = $derived(sanitizeMessage(log.message));
</script>
<div class="py-2.5 px-4 border-b border-border transition-colors hover:bg-surface-muted">
@@ -83,7 +90,7 @@
<div
class="font-mono text-sm leading-relaxed text-text break-words whitespace-pre-wrap"
>
{log.message}
{sanitizedMessage}
</div>
<!-- Progress bar (if applicable) -->
@@ -91,7 +98,7 @@
<div class="flex items-center gap-2 mt-1.5">
<div class="flex-1 h-1.5 bg-surface-muted rounded-full overflow-hidden">
<div
class="h-full bg-gradient-to-r from-primary to-purple-500 rounded-full transition-[width] duration-300 ease-out"
class="h-full bg-primary rounded-full transition-[width] duration-300 ease-out"
style="width: {progressPercent}%"
></div>
</div>

View File

@@ -12,6 +12,8 @@
@UX_STATE: Active -> Filters applied, clear button visible
-->
<script lang="ts">
import { t } from "$lib/i18n/index.svelte.js";
let {
availableSources = [],
selectedLevel = $bindable(""),
@@ -21,11 +23,11 @@
} = $props();
const levelOptions = [
{ value: "", label: "All" },
{ value: "DEBUG", label: "Debug" },
{ value: "INFO", label: "Info" },
{ value: "WARNING", label: "Warn" },
{ value: "ERROR", label: "Error" },
{ value: "all", labelKey: "log_level_all", fallback: "All" },
{ value: "DEBUG", labelKey: "log_level_debug", fallback: "Debug" },
{ value: "INFO", labelKey: "log_level_info", fallback: "Info" },
{ value: "WARNING", labelKey: "log_level_warning", fallback: "Warn" },
{ value: "ERROR", labelKey: "log_level_error", fallback: "Error" },
];
function handleLevelChange(event) {
@@ -56,41 +58,45 @@
}
function clearFilters() {
selectedLevel = "";
selectedSource = "";
selectedLevel = "all";
selectedSource = "all";
searchText = "";
onfilterchange({ level: "", source: "", search: "" });
onfilterchange({ level: selectedLevel, source: selectedSource, search: "" });
}
let hasActiveFilters = $derived(
selectedLevel || selectedSource || searchText,
selectedLevel !== "all" || selectedSource !== "all" || searchText,
);
</script>
<div class="flex items-center gap-2 px-3 py-2 bg-surface-card border-b border-border">
<div class="flex items-center gap-2 flex-1 min-w-0">
<div class="grid flex-1 grid-cols-[minmax(4.5rem,auto)_minmax(7rem,auto)_minmax(0,1fr)] items-center gap-2 min-w-0">
<!-- Level filter -->
<select
class="bg-surface-card text-text-muted border border-border-strong rounded-md px-2 py-1.5 text-xs cursor-pointer shrink-0 appearance-none pr-6 focus:outline-none focus:ring-2 focus:ring-primary-ring focus:border-primary-ring"
id="task-log-level-filter"
name="task-log-level-filter"
class="h-11 bg-surface-card text-text-muted border border-border-strong rounded-md px-2 py-2 text-xs cursor-pointer shrink-0 appearance-none pr-6 focus:outline-none focus:ring-2 focus:ring-primary-ring focus:border-primary-ring"
style="background-image: url(&quot;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E&quot;); background-repeat: no-repeat; background-position: right 0.375rem center;"
value={selectedLevel}
onchange={handleLevelChange}
aria-label="Filter by level"
aria-label={$t.tasks?.filter_by_level || "Filter by level"}
>
{#each levelOptions as option}
<option value={option.value}>{option.label}</option>
<option value={option.value}>{$t.tasks?.[option.labelKey] || option.fallback}</option>
{/each}
</select>
<!-- Source filter -->
<select
class="bg-surface-card text-text-muted border border-border-strong rounded-md px-2 py-1.5 text-xs cursor-pointer shrink-0 appearance-none pr-6 focus:outline-none focus:ring-2 focus:ring-primary-ring focus:border-primary-ring"
id="task-log-source-filter"
name="task-log-source-filter"
class="h-11 bg-surface-card text-text-muted border border-border-strong rounded-md px-2 py-2 text-xs cursor-pointer shrink-0 appearance-none pr-6 focus:outline-none focus:ring-2 focus:ring-primary-ring focus:border-primary-ring"
style="background-image: url(&quot;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E&quot;); background-repeat: no-repeat; background-position: right 0.375rem center;"
value={selectedSource}
onchange={handleSourceChange}
aria-label="Filter by source"
aria-label={$t.tasks?.filter_by_source || "Filter by source"}
>
<option value="">All Sources</option>
<option value="all">{$t.tasks?.all_sources || "All Sources"}</option>
{#each availableSources as source}
<option value={source}>{source}</option>
{/each}
@@ -112,12 +118,14 @@
<path d="M21 21l-4.35-4.35" />
</svg>
<input
id="task-log-search"
name="task-log-search"
type="text"
class="w-full bg-surface-card text-text border border-border-strong rounded-md py-1.5 px-2 pl-7 text-xs placeholder:text-text-subtle focus:outline-none focus:ring-2 focus:ring-primary-ring focus:border-primary-ring"
placeholder="Search..."
class="h-11 w-full bg-surface-card text-text border border-border-strong rounded-md py-2 px-2 pl-7 text-xs placeholder:text-text-subtle focus:outline-none focus:ring-2 focus:ring-primary-ring focus:border-primary-ring"
placeholder={$t.tasks?.search_logs || "Search logs..."}
value={searchText}
oninput={handleSearchChange}
aria-label="Search logs"
aria-label={$t.tasks?.search_logs || "Search logs"}
/>
</div>
</div>
@@ -126,7 +134,7 @@
<button
class="flex items-center justify-center p-1.5 bg-surface-card border border-border-strong rounded-md text-text-subtle shrink-0 cursor-pointer transition-all hover:text-destructive hover:border-destructive-ring hover:bg-destructive-light"
onclick={clearFilters}
aria-label="Clear filters"
aria-label={$t.tasks?.clear_log_filters || "Clear filters"}
>
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -15,6 +15,7 @@
-->
<script lang="ts">
import { onMount, tick } from "svelte";
import { t } from "$lib/i18n/index.svelte.js";
import LogFilterBar from "./LogFilterBar.svelte";
import LogEntryRow from "./LogEntryRow.svelte";
@@ -27,6 +28,14 @@
let selectedSource = $state("all");
let selectedLevel = $state("all");
let searchText = $state("");
const quickFilters = [
{ id: "all", labelKey: "quick_filter_all", fallback: "All", source: "all", level: "all" },
{ id: "error", labelKey: "quick_filter_errors", fallback: "Errors", source: "all", level: "ERROR" },
{ id: "warning", labelKey: "quick_filter_warn", fallback: "Warn", source: "all", level: "WARNING" },
{ id: "llm", labelKey: "quick_filter_llm", fallback: "LLM", source: "llm", level: "all" },
{ id: "plugin", labelKey: "quick_filter_plugin", fallback: "Plugin", source: "plugin", level: "all" },
{ id: "system", labelKey: "quick_filter_system", fallback: "System", source: "system", level: "all" },
];
let filteredLogs = $derived(
filterLogs(logs, selectedLevel, selectedSource, searchText),
@@ -55,6 +64,11 @@
let availableSources = $derived([
...new Set(logs.map((l) => l.source).filter(Boolean)),
]);
let activeQuickFilter = $derived(
quickFilters.find(
(filter) => filter.source === selectedSource && filter.level === selectedLevel,
)?.id || "",
);
function handleFilterChange(event) {
const { source, level, search } = event;
@@ -64,6 +78,12 @@
onfilterchange({ source, level, search });
}
function applyQuickFilter(filter) {
selectedSource = filter.source;
selectedLevel = filter.level;
onfilterchange({ source: selectedSource, level: selectedLevel, search: searchText });
}
function scrollToBottom() {
if (autoScroll && scrollContainer) {
scrollContainer.scrollTop = scrollContainer.scrollHeight;
@@ -88,7 +108,24 @@
<div class="flex flex-col h-full bg-surface-card overflow-hidden rounded-lg border border-border shadow-sm">
<!-- Filter Bar -->
<LogFilterBar {availableSources} onfilterchange={handleFilterChange} />
<LogFilterBar
{availableSources}
bind:selectedLevel
bind:selectedSource
bind:searchText
onfilterchange={handleFilterChange}
/>
<div class="flex flex-wrap gap-2 border-b border-border bg-surface-card px-3 py-2" aria-label="Быстрые фильтры логов">
{#each quickFilters as filter}
<button
class="min-h-11 shrink-0 rounded-full border px-3 text-xs font-semibold transition-colors {activeQuickFilter === filter.id ? 'border-primary-ring bg-primary-light text-primary' : 'border-border bg-surface-card text-text-muted hover:border-border-strong hover:text-text'}"
onclick={() => applyQuickFilter(filter)}
aria-pressed={activeQuickFilter === filter.id}
>
{$t.tasks?.[filter.labelKey] || filter.fallback}
</button>
{/each}
</div>
<!-- Log List -->
<div
@@ -114,7 +151,7 @@
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
<span class="text-sm text-text-subtle">No logs available</span>
<span class="text-sm text-text-subtle">{$t.tasks?.no_logs || "No logs available"}</span>
</div>
{:else}
{#each filteredLogs as log}
@@ -125,25 +162,27 @@
<!-- Footer Stats -->
<div
class="flex items-center justify-between py-1.5 px-4 border-t border-border bg-surface-muted"
class="flex items-center justify-between gap-3 px-4 py-2 border-t border-border bg-surface-muted"
>
<span class="font-mono text-xs text-text-subtle">
{filteredLogs.length}{filteredLogs.length !== logs.length
? ` / ${logs.length}`
: ""} entries
: ""} {$t.tasks?.log_entries || "entries"}
</span>
<button
class="flex items-center gap-1.5 bg-transparent border-none text-xs cursor-pointer py-1 px-1.5 rounded transition-all hover:bg-surface-muted hover:text-text
class="flex min-h-11 items-center gap-1.5 bg-transparent border-none text-xs cursor-pointer px-2 rounded transition-all hover:bg-surface-card hover:text-text
{autoScroll ? 'text-primary font-medium' : 'text-text-subtle'}"
onclick={toggleAutoScroll}
aria-label="Toggle auto-scroll"
aria-label={$t.tasks?.toggle_autoscroll || "Toggle auto-scroll"}
>
{#if autoScroll}
<span
class="inline-block w-1.5 h-1.5 rounded-full bg-primary animate-pulse"
></span>
{/if}
Auto-scroll {autoScroll ? "on" : "off"}
{$t.tasks?.auto_scroll || "Auto-scroll"} {autoScroll
? ($t.tasks?.auto_scroll_on || "on")
: ($t.tasks?.auto_scroll_off || "off")}
</button>
</div>
</div>

View File

@@ -13,9 +13,9 @@
@INVARIANT: Real-time logs are always appended without duplicates.
-->
<script lang="ts">
import { getTaskLogs } from "../../../services/taskService.js";
import { t } from '$lib/i18n/index.svelte.js';
import { log } from "$lib/cot-logger";
import { api } from "$lib/api.js";
import TaskLogPanel from "./TaskLogPanel.svelte";
import Icon from "$lib/ui/Icon.svelte";
@@ -24,6 +24,8 @@
inline = false,
taskId = null,
realTimeLogs = [],
taskStatus = null,
showErrorSummary = false,
onclose = () => {},
} = $props();
@@ -33,15 +35,53 @@
let autoScroll = $state(true);
let shouldShow = $derived(inline || show);
let errorLogs = $derived(
logs
.filter((entry) => String(entry?.level || "").toUpperCase() === "ERROR" || /failed|error|exception|validation error/i.test(String(entry?.message || "")))
.slice(-3),
);
let analysisSummary = $derived(
logs
.map((entry) => String(entry?.message || ""))
.find((message) => message.includes("[ANALYSIS_SUMMARY] Summary:")) || "",
);
let normalizedAnalysisSummary = $derived(
analysisSummary.replace("[ANALYSIS_SUMMARY] Summary:", "").trim(),
);
function normalizeLogsPayload(payload) {
if (Array.isArray(payload)) return payload;
if (Array.isArray(payload?.logs)) return payload.logs;
return [];
}
function logEntryKey(entry) {
const metadataProgress = entry?.metadata?.progress ?? "";
return [
String(entry?.level || "INFO").toUpperCase(),
String(entry?.source || ""),
String(entry?.message || ""),
String(metadataProgress),
].join("|");
}
function mergeUniqueLogs(existingLogs, incomingLogs) {
const seen = new Set();
const merged = [];
for (const entry of [...existingLogs, ...incomingLogs]) {
const key = logEntryKey(entry);
if (seen.has(key)) continue;
seen.add(key);
merged.push(entry);
}
return merged;
}
$effect(() => {
if (realTimeLogs && realTimeLogs.length > 0) {
const lastLog = realTimeLogs[realTimeLogs.length - 1];
const exists = logs.some(
(l) =>
l.timestamp === lastLog.timestamp &&
l.message === lastLog.message,
);
const lastLogKey = logEntryKey(lastLog);
const exists = logs.some((entry) => logEntryKey(entry) === lastLogKey);
if (!exists) {
logs = [...logs, lastLog];
}
@@ -52,7 +92,8 @@
if (!taskId) return;
try {
log("TaskLogViewer", "REASON", "Fetch logs started", { taskId });
logs = await getTaskLogs(taskId);
const payload = await api.getTaskLogs(taskId, { limit: 1000 });
logs = mergeUniqueLogs(logs, normalizeLogsPayload(payload));
log("TaskLogViewer", "REFLECT", "Fetch logs success", { taskId });
} catch (e) {
log("TaskLogViewer", "EXPLORE", "Fetch logs failed", { taskId }, e instanceof Error ? e.message : "Unknown");
@@ -95,6 +136,44 @@
onclick={handleRefresh}>{$t.common?.retry}</button>
</div>
{:else}
{#if showErrorSummary && String(taskStatus || "").toUpperCase() === "FAILED" && (errorLogs.length > 0 || normalizedAnalysisSummary)}
<div class="border-b border-destructive-ring bg-destructive-light px-4 py-3">
<div class="flex items-start gap-3">
<div class="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-surface-card text-destructive">
<Icon name="warning" size={16} strokeWidth={2} />
</div>
<div class="min-w-0 flex-1">
<p class="text-xs font-bold uppercase tracking-wide text-destructive">
{$t.tasks?.failure_summary || "Failure summary"}
</p>
{#if errorLogs.length > 0}
<div class="mt-2 rounded-md border border-destructive-ring bg-surface-card p-2">
<p class="mb-1 text-[10px] font-bold uppercase tracking-wide text-destructive">
{$t.tasks?.failure_cause || "Причина падения"}
</p>
<ul class="space-y-1 text-xs text-destructive">
{#each errorLogs as entry}
<li class="break-words font-mono leading-relaxed">
{entry.message}
</li>
{/each}
</ul>
</div>
{/if}
{#if normalizedAnalysisSummary}
<div class="mt-2 rounded-md border border-border bg-surface-card p-2">
<p class="mb-1 text-[10px] font-bold uppercase tracking-wide text-text-muted">
{$t.tasks?.llm_summary || "LLM-сводка"}
</p>
<p class="text-xs text-text">
{normalizedAnalysisSummary}
</p>
</div>
{/if}
</div>
</div>
</div>
{/if}
<TaskLogPanel {taskId} {logs} {autoScroll} />
{/if}
</div>

View File

@@ -0,0 +1,29 @@
// #region LogEntryRowTest [C:2] [TYPE Module] [SEMANTICS test,tasks,logs,redaction]
// @BRIEF Unit tests for LogEntryRow rendering and sensitive log redaction.
// @LAYER Tests
// @RELATION BINDS_TO -> [LogEntryRow]
// @TEST_CONTRACT: LogEntry -> RenderedLogRow
// @TEST_SCENARIO: api_key_redaction -> Decrypted API key preview is not rendered.
// @TEST_FIXTURE: decrypted_key_log -> INLINE_JSON
// @TEST_INVARIANT: sensitive_log_values_are_redacted -> VERIFIED_BY: [api_key_redaction]
import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import LogEntryRow from '../LogEntryRow.svelte';
describe('LogEntryRow redaction contract', () => {
it('redacts decrypted API key previews from log messages', () => {
render(LogEntryRow, {
log: {
timestamp: '2026-07-03T08:00:00Z',
level: 'INFO',
source: 'system',
message: 'API Key decrypted (first 8 chars): sk-live-secret-value'
}
});
expect(screen.getByText('API key decrypted: [redacted]')).toBeDefined();
expect(screen.queryByText(/sk-live-secret-value/)).toBeNull();
});
});
// #endregion LogEntryRowTest

View File

@@ -13,15 +13,17 @@
// @TEST_INVARIANT: no_duplicate_log_rows -> VERIFIED_BY: [historical_and_realtime_merge, duplicate_realtime_entry]
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/svelte';
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
import TaskLogViewer from '../TaskLogViewer.svelte';
import { getTaskLogs } from '../../../../services/taskService.js';
import { api } from '$lib/api.js';
vi.mock('../../../../services/taskService.js', () => ({
getTaskLogs: vi.fn()
vi.mock('$lib/api.js', () => ({
api: {
getTaskLogs: vi.fn()
}
}));
const getTaskLogsMock = vi.mocked(getTaskLogs);
const getTaskLogsMock = vi.mocked(api.getTaskLogs);
vi.mock('$lib/i18n/index.svelte.js', () => ({
t: {
@@ -29,7 +31,10 @@ vi.mock('$lib/i18n/index.svelte.js', () => ({
fn({
tasks: {
loading: 'Loading...',
logs_title: 'Task Logs'
logs_title: 'Task Logs',
filter_by_level: 'Фильтр по уровню',
filter_by_source: 'Фильтр по источнику',
quick_filter_errors: 'Ошибки'
},
common: {
retry: 'Retry',
@@ -63,7 +68,22 @@ describe('TaskLogViewer Component', () => {
expect(screen.getByText(/Historical log entry/)).toBeDefined();
});
expect(getTaskLogs).toHaveBeenCalledWith('task-123');
expect(api.getTaskLogs).toHaveBeenCalledWith('task-123', { limit: 1000 });
});
it('normalizes paginated log endpoint response', async () => {
getTaskLogsMock.mockResolvedValue({
logs: [
{ timestamp: '2024-01-01T00:00:00', level: 'INFO', message: 'Paginated log entry' }
],
total: 1
});
render(TaskLogViewer, { inline: true, taskId: 'task-123' });
await waitFor(() => {
expect(screen.getByText(/Paginated log entry/)).toBeDefined();
});
});
it('displays error message on fetch failure', async () => {
@@ -138,6 +158,55 @@ describe('TaskLogViewer Component', () => {
expect(() => screen.getByText(/Duplicate log entry/)).not.toThrow();
});
it('deduplicates websocket backlog entries even when timestamps differ', async () => {
getTaskLogsMock.mockResolvedValue([
{ timestamp: '2024-01-01T00:00:00Z', level: 'INFO', source: 'plugin', message: 'Backlog log entry' }
]);
const { rerender } = render(TaskLogViewer, {
inline: true,
taskId: 'task-123',
realTimeLogs: []
});
await waitFor(() => {
expect(screen.getByText(/Backlog log entry/)).toBeDefined();
});
await rerender({
inline: true,
taskId: 'task-123',
realTimeLogs: [
{ timestamp: '2024-01-01T00:00:02Z', level: 'INFO', source: 'plugin', message: 'Backlog log entry' }
]
});
await new Promise((r) => setTimeout(r, 50));
expect(screen.getAllByText(/Backlog log entry/)).toHaveLength(1);
});
it('filters log rows with the quick error filter', async () => {
getTaskLogsMock.mockResolvedValue([
{ timestamp: '2024-01-01T00:00:00Z', level: 'INFO', source: 'plugin', message: 'Informational log entry' },
{ timestamp: '2024-01-01T00:00:01Z', level: 'ERROR', source: 'system', message: 'Task failed with details' }
]);
render(TaskLogViewer, { inline: true, taskId: 'task-123' });
await waitFor(() => {
expect(screen.getByText(/Informational log entry/)).toBeDefined();
expect(screen.getByText(/Task failed with details/)).toBeDefined();
});
await fireEvent.click(screen.getByRole('button', { name: 'Ошибки' }));
expect(screen.queryByText(/Informational log entry/)).toBeNull();
expect(screen.getByText(/Task failed with details/)).toBeDefined();
expect((screen.getByRole('combobox', { name: 'Фильтр по уровню' }) as HTMLSelectElement).value).toBe('ERROR');
expect((screen.getByRole('combobox', { name: 'Фильтр по источнику' }) as HTMLSelectElement).value).toBe('all');
});
// @TEST_FIXTURE: valid_viewer -> INLINE_JSON
it('fetches and displays historical logs in modal mode under valid_viewer fixture', async () => {
getTaskLogsMock.mockResolvedValue([
@@ -151,13 +220,13 @@ describe('TaskLogViewer Component', () => {
expect(screen.getByText('Task Logs')).toBeDefined();
});
expect(getTaskLogs).toHaveBeenCalledWith('task-123');
expect(api.getTaskLogs).toHaveBeenCalledWith('task-123', { limit: 1000 });
});
// @TEST_EDGE: no_task_id -> Null taskId does not trigger fetch.
it('does not fetch logs if taskId is null', () => {
render(TaskLogViewer, { inline: true, taskId: null });
expect(getTaskLogs).not.toHaveBeenCalled();
expect(api.getTaskLogs).not.toHaveBeenCalled();
});
// @UX_FEEDBACK

View File

@@ -29,6 +29,7 @@
"resume_failed": "Failed to resume task: {error}",
"task_label": "Task",
"connecting": "Connecting...",
"reconnecting": "Reconnecting...",
"live": "Live",
"completed": "Completed",
"awaiting_mapping": "Awaiting Mapping",
@@ -71,5 +72,27 @@
"no_diff_available": "Diff is not available",
"summary_link_unavailable": "Deep link or diff is unavailable for this task",
"open_llm_report": "LLM report",
"failure_summary": "Failure summary",
"filter_by_level": "Filter by level",
"filter_by_source": "Filter by source",
"log_level_all": "All",
"log_level_debug": "Debug",
"log_level_info": "Info",
"log_level_warning": "Warn",
"log_level_error": "Error",
"all_sources": "All Sources",
"search_logs": "Search logs...",
"clear_log_filters": "Clear log filters",
"log_entries": "entries",
"quick_filter_all": "All",
"quick_filter_errors": "Errors",
"quick_filter_warn": "Warn",
"quick_filter_llm": "LLM",
"quick_filter_plugin": "Plugin",
"quick_filter_system": "System",
"toggle_autoscroll": "Toggle auto-scroll",
"auto_scroll": "Auto-scroll",
"auto_scroll_on": "on",
"auto_scroll_off": "off",
"result": "Check"
}

View File

@@ -29,6 +29,7 @@
"resume_failed": "Не удалось возобновить задачу: {error}",
"task_label": "Задача",
"connecting": "Подключение...",
"reconnecting": "Переподключение...",
"live": "Онлайн",
"completed": "Завершено",
"awaiting_mapping": "Ожидание маппинга",
@@ -71,5 +72,27 @@
"no_diff_available": "Diff недоступен",
"summary_link_unavailable": "Ссылка или diff недоступны для этой задачи",
"open_llm_report": "LLM отчет",
"failure_summary": "Сводка ошибки",
"filter_by_level": "Фильтр по уровню",
"filter_by_source": "Фильтр по источнику",
"log_level_all": "Все",
"log_level_debug": "Debug",
"log_level_info": "Info",
"log_level_warning": "Warn",
"log_level_error": "Error",
"all_sources": "Все источники",
"search_logs": "Поиск в логах...",
"clear_log_filters": "Сбросить фильтры логов",
"log_entries": "записей",
"quick_filter_all": "Все",
"quick_filter_errors": "Ошибки",
"quick_filter_warn": "Warn",
"quick_filter_llm": "LLM",
"quick_filter_plugin": "Plugin",
"quick_filter_system": "System",
"toggle_autoscroll": "Переключить автопрокрутку",
"auto_scroll": "Автопрокрутка",
"auto_scroll_on": "вкл",
"auto_scroll_off": "выкл",
"result": "Проверка"
}

View File

@@ -18,6 +18,7 @@
// @REJECTED Submodels from day one (premature), global store (page-scoped), polling (spec requires WS real-time).
import { log } from '$lib/cot-logger';
import { replaceState } from '$app/navigation';
import { api } from '$lib/api.js';
import { getTaskEventsWsUrl } from '$lib/api.js';
import { openDrawerForTask } from '$lib/stores/taskDrawer.svelte.js';
@@ -376,14 +377,14 @@ export class TaskCenterModel {
private _syncFiltersToUrl(): void {
if (this._urlSyncTimeout) clearTimeout(this._urlSyncTimeout);
this._urlSyncTimeout = setTimeout(() => {
if (typeof history === 'undefined') return;
if (typeof window === 'undefined') return;
const p = this._buildQueryParams();
if (this.filters.time_range !== 'all') p.set('range', this.filters.time_range);
if (this.filters.sort_by !== 'updated_at') p.set('sort', this.filters.sort_by);
if (this.filters.sort_order !== 'desc') p.set('order', this.filters.sort_order);
if (this.page > 1) p.set('page', String(this.page));
const qs = p.toString();
history.replaceState(null, '', qs ? `${window.location.pathname}?${qs}` : window.location.pathname);
replaceState(qs ? `${window.location.pathname}?${qs}` : window.location.pathname, {});
}, URL_SYNC_MS);
}

View File

@@ -32,12 +32,14 @@
disabled = false,
type = "text",
id: externalId = undefined,
name: externalName = undefined,
class: className = "",
...rest
} = $props();
const assignId = () => externalId ?? `input-${++_inputSeq}`;
let id = $state(assignId());
let name = $derived(externalName ?? id);
</script>
<!-- [SECTION: TEMPLATE] -->
@@ -50,6 +52,7 @@
<input
{id}
{name}
{type}
{placeholder}
{disabled}

View File

@@ -46,9 +46,9 @@
});
</script>
<div class="flex items-center justify-between {className}">
<div class="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between {className}">
<!-- Summary -->
<div class="text-sm text-text-muted">
<div class="min-w-0 text-sm text-text-muted">
{#if total > 0}
{(showingText || $t.dashboard?.showing || "Showing {start}-{end} of {total}")
.replace("{start}", String(start))
@@ -59,7 +59,7 @@
{/if}
</div>
<div class="flex items-center gap-2">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<!-- Page size selector -->
{#if pageSizeOptions.length > 0}
<select
@@ -78,7 +78,7 @@
<!-- Page number buttons (only when > 1 page) -->
{#if totalPages > 1}
<div class="flex gap-1">
<div class="flex max-w-full flex-wrap gap-1">
{#each pageNumbers as p, i (String(p) + "-" + i)}
{#if p === "..."}
<span class="px-2 py-1 text-sm text-text-muted"></span>

View File

@@ -27,12 +27,14 @@
options = [],
disabled = false,
id: externalId = undefined,
name: externalName = undefined,
class: className = "",
...rest
} = $props();
const assignId = () => externalId ?? `select-${++_selectSeq}`;
let id = $state(assignId());
let name = $derived(externalName ?? id);
</script>
<!-- [SECTION: TEMPLATE] -->
@@ -45,6 +47,7 @@
<select
{id}
{name}
{disabled}
bind:value
class={cn(

View File

@@ -78,6 +78,15 @@ describe('Input', () => {
expect(input.id).toBe('my-custom-id');
});
it('sets name from explicit prop or generated id', () => {
const explicit = render(Input, { props: { id: 'email-input', name: 'email' } });
expect((explicit.container.querySelector('input') as HTMLInputElement).name).toBe('email');
const generated = render(Input);
const input = generated.container.querySelector('input') as HTMLInputElement;
expect(input.name).toBe(input.id);
});
it('applies custom class name', () => {
const { container } = render(Input, { props: { class: 'custom-class' } });
const input = container.querySelector('input') as HTMLInputElement;

View File

@@ -0,0 +1,45 @@
// #region PaginationTest [C:2] [TYPE Module] [SEMANTICS test, ui, pagination, responsive]
// @BRIEF Unit tests for Pagination responsive layout contracts.
// @LAYER Tests
// @RELATION BINDS_TO -> [Pagination]
// @TEST_CONTRACT: PaginationProps -> ResponsivePagingControls
// @TEST_SCENARIO: mobile_width_contract -> Pagination root and controls can wrap without horizontal overflow.
// @TEST_FIXTURE: five_page_result_set -> INLINE_JSON
// @TEST_INVARIANT: pagination_does_not_force_single_row -> VERIFIED_BY: [mobile_width_contract]
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import Pagination from '../Pagination.svelte';
vi.mock('$lib/i18n/index.svelte.js', () => ({
t: {
subscribe: (fn) => {
fn({
common: { no_data: 'No items' },
dashboard: { previous: 'Назад', next: 'Вперед', showing: 'Showing {start}-{end} of {total}' }
});
return () => {};
}
}
}));
describe('Pagination responsive contract', () => {
it('allows summary and controls to wrap instead of forcing horizontal overflow', () => {
const { container } = render(Pagination, {
page: 0,
pageSize: 20,
total: 100,
showingText: 'Показано с {start} по {end} из {total} задач'
});
const root = container.firstElementChild as HTMLElement;
expect(root.className).toContain('min-w-0');
expect(root.className).toContain('flex-col');
expect(root.className).toContain('sm:flex-row');
const controls = screen.getByRole('button', { name: 'Назад' }).parentElement as HTMLElement;
expect(controls.className).toContain('flex-wrap');
expect(screen.getByText('Показано с 1 по 20 из 100 задач')).toBeDefined();
});
});
// #endregion PaginationTest

View File

@@ -67,6 +67,15 @@ describe('Select', () => {
expect(select.id).toBe('my-select');
});
it('sets name from explicit prop or generated id', () => {
const explicit = render(Select, { props: { id: 'type-select', name: 'task_type' } });
expect((explicit.container.querySelector('select') as HTMLSelectElement).name).toBe('task_type');
const generated = render(Select);
const select = generated.container.querySelector('select') as HTMLSelectElement;
expect(select.name).toBe(select.id);
});
it('applies custom class name', () => {
const { container } = render(Select, { props: { class: 'custom-class' } });
const select = container.querySelector('select') as HTMLSelectElement;

View File

@@ -21,11 +21,26 @@
import SummaryPanel from '$lib/components/reports/SummaryPanel.svelte';
import ReportsList from '$lib/components/reports/ReportsList.svelte';
import { PageHeader, Button, EmptyState, Pagination, Input, Select } from '$lib/ui';
import { taskDrawerStore } from '$lib/stores/taskDrawer.svelte.js';
import type { PageData } from './+page.ts';
import type { TaskCenterFilter } from '$types/reports';
let { data }: { data: PageData } = $props();
const m = new TaskCenterModel();
const sortOptions = [
{ value: 'updated_at', label: 'По обновлению' },
{ value: 'created_at', label: 'По созданию' },
{ value: 'status', label: 'По статусу' },
{ value: 'task_type', label: 'По типу' },
];
const timeRangeOptions = [
{ value: 'all', label: 'Всё время' },
{ value: '1h', label: 'Час' },
{ value: '24h', label: '24 часа' },
{ value: '7d', label: '7 дней' },
{ value: '30d', label: '30 дней' },
];
const drawerOpen = $derived(Boolean(taskDrawerStore.value?.isOpen));
onMount(() => {
m.loadInitialData(data.initialFilters);
@@ -68,11 +83,29 @@
function handleTimeRangeChange(e: Event & { currentTarget: HTMLSelectElement }): void {
m.applyFilter({ time_range: e.currentTarget.value as TaskCenterFilter['time_range'] });
}
function statusTotal(status: 'failed' | 'in_progress' | 'success'): number {
const summary = m.visibleSummary;
if (!summary) return 0;
return summary.by_type.reduce((sum, type) => {
if (status === 'failed') return sum + type.counts.failed;
if (status === 'success') return sum + type.counts.success;
return sum + type.counts.pending + type.counts.running + type.counts.awaiting_input;
}, 0);
}
function toggleStatus(status: TaskCenterFilter['statuses'][number]): void {
m.applyFilter({ statuses: m.filters.statuses.includes(status) ? [] : [status] });
}
</script>
<div class="max-w-7xl mx-auto px-4 py-6 space-y-4">
<PageHeader title="Центр статусов">
<div class="flex items-center gap-2">
<div
class="mx-auto w-full min-w-0 overflow-x-hidden px-4 py-6 space-y-4 transition-[max-width,margin] duration-300 {drawerOpen
? 'max-w-7xl md:max-w-[max(24rem,calc(100vw-35rem))] md:ml-4 md:mr-auto'
: 'max-w-7xl'}"
>
<PageHeader title="Отчеты задач">
<div class="flex flex-wrap items-center justify-end gap-2">
{#if m.wsConnected}
<span class="w-3 h-3 rounded-full bg-success" aria-label="Соединение установлено"></span>
{:else}
@@ -84,7 +117,7 @@
Обновлено: {Math.round((Date.now() - m.lastUpdated.getTime()) / 60000)} мин назад
</span>
{/if}
<Button variant="ghost" onclick={() => m.refreshSummary()}>Обновить</Button>
<Button variant="ghost" size="sm" onclick={() => m.refreshSummary()}>Обновить</Button>
</div>
</PageHeader>
@@ -111,74 +144,81 @@
activeFilters={{ task_types: m.filters.task_types, statuses: m.filters.statuses }}
/>
{#if m.screenState === 'ready' || m.screenState === 'disconnected'}
<div class="flex flex-wrap gap-2">
<button
class="rounded-full px-3 py-1 text-xs font-medium border transition-all cursor-pointer {m.filters.statuses.includes('failed') ? 'border-destructive-ring bg-destructive-light text-destructive' : 'border-border text-text-muted hover:border-destructive-ring hover:text-destructive'}"
onclick={() => m.applyFilter({ statuses: m.filters.statuses.includes('failed') ? [] : ['failed'] })}
<div class="flex flex-wrap gap-2" aria-label="Быстрые фильтры по статусу">
<Button
variant="ghost"
size="sm"
class="min-h-11 rounded-full border px-3 {m.filters.statuses.includes('failed') ? 'border-destructive-ring bg-destructive-light text-destructive ring-2 ring-destructive-ring/40' : 'border-border text-text-muted hover:border-destructive-ring hover:text-destructive'}"
aria-pressed={m.filters.statuses.includes('failed')}
onclick={() => toggleStatus('failed')}
>
Упавшие {m.visibleSummary?.by_type.reduce((s, t) => s + t.counts.failed, 0)}
</button>
<button
class="rounded-full px-3 py-1 text-xs font-medium border transition-all cursor-pointer {m.filters.statuses.includes('in_progress') ? 'border-primary-ring bg-primary/10 text-primary' : 'border-border text-text-muted hover:border-primary-ring hover:text-primary'}"
onclick={() => m.applyFilter({ statuses: m.filters.statuses.includes('in_progress') ? [] : ['in_progress'] })}
Упавшие {statusTotal('failed')}
</Button>
<Button
variant="ghost"
size="sm"
class="min-h-11 rounded-full border px-3 {m.filters.statuses.includes('in_progress') ? 'border-primary-ring bg-primary-light text-primary ring-2 ring-primary-ring/40' : 'border-border text-text-muted hover:border-primary-ring hover:text-primary'}"
aria-pressed={m.filters.statuses.includes('in_progress')}
onclick={() => toggleStatus('in_progress')}
>
В работе {m.visibleSummary?.by_type.reduce((s, t) => s + t.counts.running, 0)}
</button>
<button
class="rounded-full px-3 py-1 text-xs font-medium border transition-all cursor-pointer {m.filters.statuses.includes('success') ? 'border-success-ring bg-success-light text-success' : 'border-border text-text-muted hover:border-success-ring hover:text-success'}"
onclick={() => m.applyFilter({ statuses: m.filters.statuses.includes('success') ? [] : ['success'] })}
В работе {statusTotal('in_progress')}
</Button>
<Button
variant="ghost"
size="sm"
class="min-h-11 rounded-full border px-3 {m.filters.statuses.includes('success') ? 'border-success-ring bg-success-light text-success ring-2 ring-success-ring/40' : 'border-border text-text-muted hover:border-success-ring hover:text-success'}"
aria-pressed={m.filters.statuses.includes('success')}
onclick={() => toggleStatus('success')}
>
Успешные {m.visibleSummary?.by_type.reduce((s, t) => s + t.counts.success, 0)}
</button>
Успешные {statusTotal('success')}
</Button>
</div>
{/if}
<!-- Filter bar -->
<div class="bg-surface-muted rounded-lg p-3 flex flex-wrap gap-3 items-center">
<Input
type="search"
placeholder="Поиск по задачам..."
value={m.filters.search}
oninput={handleSearchInput}
class="w-48"
/>
<div class="flex flex-col gap-1">
<label class="text-[10px] uppercase tracking-wide text-text-muted font-medium">Сортировка</label>
<div class="bg-surface-muted rounded-lg p-3 grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-[minmax(12rem,1fr)_10rem_9rem_auto] sm:items-end">
<div class="min-w-0">
<Input
id="reports-task-search"
name="reports-task-search"
type="search"
placeholder="Поиск по задачам..."
value={m.filters.search}
oninput={handleSearchInput}
/>
</div>
<div class="flex flex-col gap-1 min-w-0">
<label for="sort-select" class="text-[10px] uppercase tracking-wide text-text-muted font-medium">Сортировка</label>
<Select
id="sort-select"
name="reports-sort"
value={m.filters.sort_by}
options={sortOptions}
onchange={handleSortChange}
class="w-36"
>
<option value="updated_at">По обновлению</option>
<option value="created_at">По созданию</option>
<option value="status">По статусу</option>
<option value="task_type">По типу</option>
</Select>
/>
</div>
<div class="flex flex-col gap-1">
<label class="text-[10px] uppercase tracking-wide text-text-muted font-medium">Период</label>
<div class="flex flex-col gap-1 min-w-0">
<label for="time-range-select" class="text-[10px] uppercase tracking-wide text-text-muted font-medium">Период</label>
<Select
id="time-range-select"
name="reports-time-range"
value={m.filters.time_range}
options={timeRangeOptions}
onchange={handleTimeRangeChange}
class="w-32"
>
<option value="all">Всё время</option>
<option value="1h">Час</option>
<option value="24h">24 часа</option>
<option value="7d">7 дней</option>
<option value="30d">30 дней</option>
</Select>
/>
</div>
<div class="flex flex-wrap items-center justify-between gap-2 sm:justify-end">
<span class="text-xs text-text-muted whitespace-nowrap">
Показано {m.filteredTasks.length} из {m.totalItems} задач
</span>
{#if m.hasActiveFilters}
<Button variant="ghost" size="sm" onclick={() => m.clearFilters()}>Сбросить</Button>
{/if}
</div>
<span class="text-xs text-text-muted ml-auto">{m.filteredTasks.length} задач(-и)</span>
{#if m.hasActiveFilters}
<Button variant="ghost" onclick={() => m.clearFilters()}>Сбросить фильтры</Button>
{/if}
</div>
<!-- Task list (basic for US1) -->
<div class="bg-surface-card border border-border rounded-lg p-4">
<div class="min-w-0 overflow-hidden bg-surface-card border border-border rounded-lg p-2 sm:p-3">
{#if m.screenState === 'loading'}
<div class="space-y-3">
{#each [1, 2, 3, 4, 5] as n (n)}
@@ -204,7 +244,7 @@
onselect={(e) => m.selectTask(e.report.task_id || e.report.report_id)}
/>
{#if m.totalPages > 1}
<div class="mt-4">
<div class="mt-4 min-w-0">
<Pagination
bind:page={pageZero}
pageSize={m.pageSize}

View File

@@ -0,0 +1,30 @@
// #region ReportsLayoutContractTest [C:2] [TYPE Module] [SEMANTICS test,reports,layout,responsive]
// @BRIEF Regression tests for /reports responsive layout contracts that prevent page-level horizontal scroll.
// @LAYER Tests
// @RELATION BINDS_TO -> [TaskCenterReportsPage]
// @TEST_CONTRACT: ReportsPageSource -> ResponsiveLayoutClasses
// @TEST_SCENARIO: drawer_shift_desktop_only -> Drawer margin compensation is restricted to md+ viewports.
// @TEST_SCENARIO: list_shell_clips_horizontal_overflow -> Report list shell cannot expand document width.
// @TEST_FIXTURE: reports_page_source -> file:../+page.svelte
// @TEST_INVARIANT: reports_page_has_no_mobile_horizontal_scroll -> VERIFIED_BY: [drawer_shift_desktop_only, list_shell_clips_horizontal_overflow]
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(resolve(__dirname, '../+page.svelte'), 'utf8');
describe('/reports responsive layout contract', () => {
it('keeps drawer margin compensation desktop-only', () => {
expect(source).toContain('md:ml-4 md:mr-auto');
expect(source).not.toContain("max-w-[max(24rem,calc(100vw-35rem))] ml-4 mr-auto");
});
it('clips report list shell horizontal overflow', () => {
expect(source).toContain('min-w-0 overflow-hidden bg-surface-card');
expect(source).toContain('mx-auto w-full min-w-0 overflow-x-hidden');
});
});
// #endregion ReportsLayoutContractTest