Improve reports UI and task drawer UX
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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("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"); 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("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"); 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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Проверка"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
45
frontend/src/lib/ui/__tests__/Pagination.test.ts
Normal file
45
frontend/src/lib/ui/__tests__/Pagination.test.ts
Normal 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
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user