Migrate frontend to Svelte 5 runes semantics
This commit is contained in:
@@ -42,6 +42,7 @@
|
||||
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { fromStore } from "svelte/store";
|
||||
import { t } from "$lib/i18n";
|
||||
import Icon from "$lib/ui/Icon.svelte";
|
||||
import { openDrawerForTask } from "$lib/stores/taskDrawer.js";
|
||||
@@ -64,28 +65,31 @@
|
||||
const HISTORY_PAGE_SIZE = 30;
|
||||
const CONVERSATIONS_PAGE_SIZE = 20;
|
||||
|
||||
let input = "";
|
||||
let loading = false;
|
||||
let loadingHistory = false;
|
||||
let loadingMoreHistory = false;
|
||||
let loadingConversations = false;
|
||||
let messages = [];
|
||||
let conversations = [];
|
||||
let conversationFilter = "active";
|
||||
let activeConversationsTotal = 0;
|
||||
let archivedConversationsTotal = 0;
|
||||
let historyPage = 1;
|
||||
let historyHasNext = false;
|
||||
let historyLoadVersion = 0;
|
||||
let conversationsPage = 1;
|
||||
let conversationsHasNext = false;
|
||||
let historyViewport = null;
|
||||
let initialized = false;
|
||||
let llmReady = true;
|
||||
let llmStatusReason = "";
|
||||
let input = $state("");
|
||||
let loading = $state(false);
|
||||
let loadingHistory = $state(false);
|
||||
let loadingMoreHistory = $state(false);
|
||||
let loadingConversations = $state(false);
|
||||
let messages = $state([]);
|
||||
let conversations = $state([]);
|
||||
let conversationFilter = $state("active");
|
||||
let activeConversationsTotal = $state(0);
|
||||
let archivedConversationsTotal = $state(0);
|
||||
let historyPage = $state(1);
|
||||
let historyHasNext = $state(false);
|
||||
let historyLoadVersion = $state(0);
|
||||
let conversationsPage = $state(1);
|
||||
let conversationsHasNext = $state(false);
|
||||
let historyViewport = $state(null);
|
||||
let initialized = $state(false);
|
||||
let llmReady = $state(true);
|
||||
let llmStatusReason = $state("");
|
||||
|
||||
$: isOpen = $assistantChatStore?.isOpen || false;
|
||||
$: conversationId = $assistantChatStore?.conversationId || null;
|
||||
const assistantChatState = fromStore(assistantChatStore);
|
||||
let isOpen = $derived(assistantChatState.current?.isOpen || false);
|
||||
let conversationId = $derived(
|
||||
assistantChatState.current?.conversationId || null,
|
||||
);
|
||||
|
||||
// [DEF:loadHistory:Function]
|
||||
/**
|
||||
@@ -191,9 +195,10 @@
|
||||
(c) => c.conversation_id !== conversationIdTemp,
|
||||
);
|
||||
if (conversationId === conversationIdTemp) {
|
||||
$assistantChatStore.conversationId = null;
|
||||
$assistantChatStore.messages = [];
|
||||
$assistantChatStore.state = "idle";
|
||||
assistantChatStore.update((state) => ({
|
||||
...state,
|
||||
conversationId: null,
|
||||
}));
|
||||
}
|
||||
addToast("Conversation deleted", "success");
|
||||
} catch (err) {
|
||||
@@ -246,21 +251,24 @@
|
||||
}
|
||||
// [/DEF:loadOlderMessages:Function]
|
||||
|
||||
$: if (isOpen && !initialized) {
|
||||
loadConversations(true);
|
||||
loadHistory();
|
||||
loadLlmStatus();
|
||||
}
|
||||
|
||||
$: if (isOpen && initialized && conversationId) {
|
||||
// Re-load only when user switched to another conversation.
|
||||
const currentFirstConversationId = messages.length
|
||||
? messages[0].conversation_id
|
||||
: conversationId;
|
||||
if (currentFirstConversationId !== conversationId) {
|
||||
loadHistory();
|
||||
$effect(() => {
|
||||
if (isOpen && !initialized) {
|
||||
void loadConversations(true);
|
||||
void loadHistory();
|
||||
void loadLlmStatus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen && initialized && conversationId) {
|
||||
const currentFirstConversationId = messages.length
|
||||
? messages[0].conversation_id
|
||||
: conversationId;
|
||||
if (currentFirstConversationId !== conversationId) {
|
||||
void loadHistory();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// [DEF:appendLocalUserMessage:Function]
|
||||
/**
|
||||
@@ -566,7 +574,7 @@
|
||||
{#if isOpen}
|
||||
<div
|
||||
class="fixed inset-0 z-[70] bg-slate-900/30"
|
||||
on:click={closeAssistantChat}
|
||||
onclick={closeAssistantChat}
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
@@ -584,7 +592,7 @@
|
||||
</div>
|
||||
<button
|
||||
class="rounded-md p-1 text-slate-500 transition hover:bg-slate-100 hover:text-slate-900"
|
||||
on:click={closeAssistantChat}
|
||||
onclick={closeAssistantChat}
|
||||
aria-label={$t.assistant?.close}
|
||||
>
|
||||
<Icon name="close" size={18} />
|
||||
@@ -622,7 +630,7 @@
|
||||
>
|
||||
<button
|
||||
class="rounded-md border border-slate-300 px-2 py-1 text-[11px] font-medium text-slate-700 transition hover:bg-slate-100"
|
||||
on:click={startNewConversation}
|
||||
onclick={startNewConversation}
|
||||
>
|
||||
{$t.assistant?.new}
|
||||
</button>
|
||||
@@ -633,7 +641,7 @@
|
||||
'active'
|
||||
? 'border-sky-300 bg-sky-50 text-sky-900'
|
||||
: 'border-slate-300 bg-white text-slate-700 hover:bg-slate-100'}"
|
||||
on:click={() => setConversationFilter("active")}
|
||||
onclick={() => setConversationFilter("active")}
|
||||
>
|
||||
{$t.assistant?.active} ({activeConversationsTotal})
|
||||
</button>
|
||||
@@ -642,7 +650,7 @@
|
||||
'archived'
|
||||
? 'border-sky-300 bg-sky-50 text-sky-900'
|
||||
: 'border-slate-300 bg-white text-slate-700 hover:bg-slate-100'}"
|
||||
on:click={() => setConversationFilter("archived")}
|
||||
onclick={() => setConversationFilter("archived")}
|
||||
>
|
||||
{$t.assistant?.archived} ({archivedConversationsTotal})
|
||||
</button>
|
||||
@@ -655,7 +663,7 @@
|
||||
conversationId
|
||||
? 'border-sky-300 bg-sky-50 text-sky-900'
|
||||
: 'border-slate-200 bg-white text-slate-700 hover:bg-slate-50'}"
|
||||
on:click={() => selectConversation(convo)}
|
||||
onclick={() => selectConversation(convo)}
|
||||
title={formatConversationTime(convo.updated_at)}
|
||||
>
|
||||
<div class="truncate font-semibold pr-4">
|
||||
@@ -667,7 +675,7 @@
|
||||
</button>
|
||||
<button
|
||||
class="absolute right-1.5 top-1.5 hidden group-hover:block p-1 text-slate-400 hover:text-red-500 rounded bg-white/80 hover:bg-red-50"
|
||||
on:click={(e) => removeConversation(e, convo.conversation_id)}
|
||||
onclick={(e) => removeConversation(e, convo.conversation_id)}
|
||||
title="Удалить диалог"
|
||||
>
|
||||
<Icon name="trash" size={12} />
|
||||
@@ -684,7 +692,7 @@
|
||||
{#if conversationsHasNext}
|
||||
<button
|
||||
class="rounded-lg border border-slate-300 px-2.5 py-1.5 text-xs font-medium text-slate-700 transition hover:bg-slate-100"
|
||||
on:click={() => loadConversations(false)}
|
||||
onclick={() => loadConversations(false)}
|
||||
>
|
||||
{$t.assistant?.more}
|
||||
</button>
|
||||
@@ -695,7 +703,7 @@
|
||||
<div
|
||||
class="flex-1 space-y-3 overflow-y-auto p-4"
|
||||
bind:this={historyViewport}
|
||||
on:scroll={handleHistoryScroll}
|
||||
onscroll={handleHistoryScroll}
|
||||
>
|
||||
{#if loadingMoreHistory}
|
||||
<div
|
||||
@@ -761,7 +769,7 @@
|
||||
>
|
||||
<button
|
||||
class="text-xs font-medium text-sky-700 hover:text-sky-900"
|
||||
on:click={() => openDrawerForTask(message.task_id)}
|
||||
onclick={() => openDrawerForTask(message.task_id)}
|
||||
>
|
||||
{$t.assistant?.open_task_drawer}
|
||||
</button>
|
||||
@@ -778,7 +786,7 @@
|
||||
: action.type === 'cancel'
|
||||
? 'border-rose-300 bg-rose-50 text-rose-700 hover:bg-rose-100'
|
||||
: 'border-slate-300 bg-white text-slate-700 hover:bg-slate-100'}"
|
||||
on:click={() => handleAction(action, message)}
|
||||
onclick={() => handleAction(action, message)}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
@@ -819,11 +827,11 @@
|
||||
class="min-h-[52px] w-full resize-y rounded-lg border px-3 py-2 text-sm outline-none transition {llmReady
|
||||
? 'border-slate-300 focus:border-sky-400 focus:ring-2 focus:ring-sky-100'
|
||||
: 'border-rose-300 bg-rose-50 focus:border-rose-400 focus:ring-2 focus:ring-rose-100'}"
|
||||
on:keydown={handleKeydown}
|
||||
onkeydown={handleKeydown}
|
||||
></textarea>
|
||||
<button
|
||||
class="rounded-lg bg-sky-600 px-3 py-2 text-sm font-medium text-white transition hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
on:click={handleSend}
|
||||
onclick={handleSend}
|
||||
disabled={loading || !input.trim()}
|
||||
>
|
||||
{loading ? "..." : $t.assistant?.send}
|
||||
|
||||
@@ -12,15 +12,17 @@
|
||||
* @UX_RECOVERY: Click breadcrumb to navigate
|
||||
*/
|
||||
|
||||
import { page } from "$app/stores";
|
||||
import { page } from "$app/state";
|
||||
import { fromStore } from "svelte/store";
|
||||
import { t, _ } from "$lib/i18n";
|
||||
import Icon from "$lib/ui/Icon.svelte";
|
||||
|
||||
let { maxVisible = 3 } = $props();
|
||||
const translationState = fromStore(t);
|
||||
|
||||
// Breadcrumb items derived from current path
|
||||
let breadcrumbItems = $derived(
|
||||
getBreadcrumbs($page?.url?.pathname || "/", maxVisible),
|
||||
getBreadcrumbs(page.url.pathname || "/", maxVisible, translationState.current),
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -28,9 +30,9 @@
|
||||
* @param {string} pathname - Current path
|
||||
* @returns {Array} Array of breadcrumb items
|
||||
*/
|
||||
function getBreadcrumbs(pathname, maxVisible = 3) {
|
||||
function getBreadcrumbs(pathname, maxVisible = 3, translation = null) {
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const allItems = [{ label: $t.nav?.home , path: "/" }];
|
||||
const allItems = [{ label: translation?.nav?.home, path: "/" }];
|
||||
|
||||
let currentPath = "";
|
||||
segments.forEach((segment, index) => {
|
||||
@@ -136,7 +138,7 @@
|
||||
|
||||
<nav
|
||||
class="mx-4 md:mx-6"
|
||||
aria-label={$t.nav?.breadcrumb_nav }
|
||||
aria-label={translationState.current?.nav?.breadcrumb_nav}
|
||||
>
|
||||
<div class="inline-flex max-w-full items-center gap-1.5 rounded-xl border border-slate-200/80 bg-white/85 px-2 py-1.5 shadow-sm backdrop-blur">
|
||||
{#each breadcrumbItems as item, index}
|
||||
|
||||
@@ -28,7 +28,8 @@
|
||||
* @TEST_INVARIANT navigation -> verifies: [idle_state]
|
||||
*/
|
||||
|
||||
import { page } from "$app/stores";
|
||||
import { page } from "$app/state";
|
||||
import { fromStore } from "svelte/store";
|
||||
import {
|
||||
sidebarStore,
|
||||
toggleSidebar,
|
||||
@@ -43,55 +44,62 @@
|
||||
import { browser } from "$app/environment";
|
||||
import Icon from "$lib/ui/Icon.svelte";
|
||||
|
||||
let categories = [];
|
||||
const sidebarState = fromStore(sidebarStore);
|
||||
const translationState = fromStore(t);
|
||||
const authState = fromStore(auth);
|
||||
const failingCountState = fromStore(failingCount);
|
||||
|
||||
let isExpanded = true;
|
||||
let activeCategory = "dashboards";
|
||||
let activeItem = "/dashboards";
|
||||
let isMobileOpen = false;
|
||||
let expandedCategories = new Set(["dashboards"]);
|
||||
let categories = $derived(
|
||||
buildSidebarCategories(
|
||||
translationState.current,
|
||||
authState.current?.user || null,
|
||||
),
|
||||
);
|
||||
|
||||
// Subscribe to sidebar store
|
||||
$: if ($sidebarStore) {
|
||||
isExpanded = $sidebarStore.isExpanded;
|
||||
activeCategory = $sidebarStore.activeCategory;
|
||||
activeItem = $sidebarStore.activeItem;
|
||||
isMobileOpen = $sidebarStore.isMobileOpen;
|
||||
}
|
||||
|
||||
// Reactive categories to update translations and apply RBAC visibility.
|
||||
$: categories = buildSidebarCategories($t, $auth?.user || null);
|
||||
let isExpanded = $derived(sidebarState.current?.isExpanded ?? true);
|
||||
let activeCategory = $derived(
|
||||
sidebarState.current?.activeCategory || "dashboards",
|
||||
);
|
||||
let activeItem = $derived(sidebarState.current?.activeItem || "/dashboards");
|
||||
let isMobileOpen = $derived(Boolean(sidebarState.current?.isMobileOpen));
|
||||
let failingDashboardCount = $derived(failingCountState.current || 0);
|
||||
let expandedCategories = $state(new Set(["dashboards"]));
|
||||
|
||||
// Keep active category valid after RBAC filtering.
|
||||
$: if (
|
||||
categories.length > 0 &&
|
||||
!categories.some((category) => category.id === activeCategory)
|
||||
) {
|
||||
const fallbackCategory = categories[0];
|
||||
const fallbackPath =
|
||||
fallbackCategory.subItems?.[0]?.path || fallbackCategory.path;
|
||||
setActiveItem(fallbackCategory.id, fallbackPath);
|
||||
}
|
||||
$effect(() => {
|
||||
if (
|
||||
categories.length > 0 &&
|
||||
!categories.some((category) => category.id === activeCategory)
|
||||
) {
|
||||
const fallbackCategory = categories[0];
|
||||
const fallbackPath =
|
||||
fallbackCategory.subItems?.[0]?.path || fallbackCategory.path;
|
||||
setActiveItem(fallbackCategory.id, fallbackPath);
|
||||
}
|
||||
});
|
||||
|
||||
// Keep active category expanded after route/user change.
|
||||
$: if (activeCategory && !expandedCategories.has(activeCategory)) {
|
||||
expandedCategories.add(activeCategory);
|
||||
expandedCategories = expandedCategories;
|
||||
}
|
||||
$effect(() => {
|
||||
if (activeCategory && !expandedCategories.has(activeCategory)) {
|
||||
expandedCategories.add(activeCategory);
|
||||
expandedCategories = expandedCategories;
|
||||
}
|
||||
});
|
||||
|
||||
// Update active item when page changes
|
||||
$: if ($page && $page.url.pathname !== activeItem) {
|
||||
const matched = categories.find((cat) => {
|
||||
if ($page.url.pathname.startsWith(cat.path)) return true;
|
||||
return (cat.subItems || []).some((item) =>
|
||||
$page.url.pathname.startsWith(item.path),
|
||||
);
|
||||
});
|
||||
if (matched) {
|
||||
activeCategory = matched.id;
|
||||
activeItem = $page.url.pathname;
|
||||
$effect(() => {
|
||||
if (page.url.pathname !== activeItem) {
|
||||
const matched = categories.find((cat) => {
|
||||
if (page.url.pathname.startsWith(cat.path)) return true;
|
||||
return (cat.subItems || []).some((item) =>
|
||||
page.url.pathname.startsWith(item.path),
|
||||
);
|
||||
});
|
||||
if (matched) {
|
||||
setActiveItem(matched.id, page.url.pathname);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function handleItemClick(category) {
|
||||
console.log(`[Sidebar][Action] Clicked category ${category.id}`);
|
||||
@@ -145,9 +153,12 @@
|
||||
}
|
||||
|
||||
// Close mobile overlay on route change
|
||||
$: if (isMobileOpen && $page) {
|
||||
closeMobile();
|
||||
}
|
||||
$effect(() => {
|
||||
if (isMobileOpen) {
|
||||
page.url.pathname;
|
||||
closeMobile();
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
healthStore.refresh();
|
||||
@@ -161,8 +172,8 @@
|
||||
{#if isMobileOpen}
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 z-20 md:hidden"
|
||||
on:click={handleOverlayClick}
|
||||
on:keydown={(e) => e.key === "Escape" && handleOverlayClick()}
|
||||
onclick={handleOverlayClick}
|
||||
onkeydown={(e) => e.key === "Escape" && handleOverlayClick()}
|
||||
role="presentation"
|
||||
></div>
|
||||
{/if}
|
||||
@@ -188,7 +199,7 @@
|
||||
>
|
||||
<Icon name="layers" size={14} />
|
||||
</span>
|
||||
{$t.nav?.menu}
|
||||
{translationState.current?.nav?.menu}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-xs text-gray-500">M</span>
|
||||
@@ -205,8 +216,8 @@
|
||||
{activeCategory === category.id
|
||||
? 'bg-primary-light text-primary md:border-r-2 md:border-primary'
|
||||
: ''}"
|
||||
on:click={(e) => handleCategoryToggle(category.id, e)}
|
||||
on:keydown={(e) =>
|
||||
onclick={(e) => handleCategoryToggle(category.id, e)}
|
||||
onkeydown={(e) =>
|
||||
(e.key === "Enter" || e.key === " ") &&
|
||||
handleCategoryToggle(category.id, e)}
|
||||
role="button"
|
||||
@@ -219,7 +230,7 @@
|
||||
class="relative inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br ring-1 transition-all {category.tone}"
|
||||
>
|
||||
<Icon name={category.icon} size={16} strokeWidth={2} />
|
||||
{#if !isExpanded && category.id === "dashboards" && $failingCount > 0}
|
||||
{#if !isExpanded && category.id === "dashboards" && failingDashboardCount > 0}
|
||||
<span
|
||||
class="absolute -top-1 -right-1 flex h-3 w-3 items-center justify-center rounded-full bg-red-500 ring-2 ring-white"
|
||||
></span>
|
||||
@@ -229,12 +240,12 @@
|
||||
<span class="ml-3 text-sm font-medium truncate"
|
||||
>{category.label}</span
|
||||
>
|
||||
{#if category.id === "dashboards" && $failingCount > 0}
|
||||
{#if category.id === "dashboards" && failingDashboardCount > 0}
|
||||
<span
|
||||
class="ml-auto mr-2 inline-flex items-center justify-center px-2 py-0.5 text-[10px] font-bold leading-none text-white bg-red-500 rounded-full"
|
||||
title="{$failingCount} failing dashboards"
|
||||
title="{failingDashboardCount} failing dashboards"
|
||||
>
|
||||
{$failingCount}
|
||||
{failingDashboardCount}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -261,8 +272,8 @@
|
||||
{activeItem === subItem.path
|
||||
? 'bg-primary-light text-primary'
|
||||
: ''}"
|
||||
on:click={() => handleSubItemClick(category.id, subItem.path)}
|
||||
on:keydown={(e) =>
|
||||
onclick={() => handleSubItemClick(category.id, subItem.path)}
|
||||
onkeydown={(e) =>
|
||||
(e.key === "Enter" || e.key === " ") &&
|
||||
handleSubItemClick(category.id, subItem.path)}
|
||||
role="button"
|
||||
@@ -282,25 +293,25 @@
|
||||
<div class="border-t border-gray-200 p-4">
|
||||
<button
|
||||
class="flex items-center justify-center w-full px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
on:click={handleToggleClick}
|
||||
onclick={handleToggleClick}
|
||||
>
|
||||
<span
|
||||
class="mr-2 inline-flex h-6 w-6 items-center justify-center rounded-md bg-slate-100 text-slate-600"
|
||||
>
|
||||
<Icon name="chevronLeft" size={14} />
|
||||
</span>
|
||||
{$t.nav?.collapse}
|
||||
{translationState.current?.nav?.collapse}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="border-t border-gray-200 p-4">
|
||||
<button
|
||||
class="flex items-center justify-center w-full px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
on:click={handleToggleClick}
|
||||
aria-label={$t.nav?.expand_sidebar}
|
||||
onclick={handleToggleClick}
|
||||
aria-label={translationState.current?.nav?.expand_sidebar}
|
||||
>
|
||||
<Icon name="chevronRight" size={16} />
|
||||
<span class="ml-2">{$t.nav?.expand}</span>
|
||||
<span class="ml-2">{translationState.current?.nav?.expand}</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
*/
|
||||
|
||||
import { onDestroy } from "svelte";
|
||||
import { fromStore } from "svelte/store";
|
||||
import { taskDrawerStore, closeDrawer } from "$lib/stores/taskDrawer.js";
|
||||
import { assistantChatStore } from "$lib/stores/assistantChat.js";
|
||||
import TaskLogViewer from "../../../components/TaskLogViewer.svelte";
|
||||
@@ -46,38 +47,38 @@
|
||||
import { addToast } from "$lib/toasts.js";
|
||||
import { gitService } from "../../../services/gitService.js";
|
||||
|
||||
let isOpen = false;
|
||||
let activeTaskId = null;
|
||||
let ws = null;
|
||||
let realTimeLogs = [];
|
||||
let taskStatus = null;
|
||||
let recentTasks = [];
|
||||
let loadingTasks = false;
|
||||
let isAssistantOpen = false;
|
||||
let activeTaskDetails = null;
|
||||
let realTimeLogs = $state([]);
|
||||
let taskStatus = $state(null);
|
||||
let recentTasks = $state([]);
|
||||
let loadingTasks = $state(false);
|
||||
let activeTaskDetails = $state(null);
|
||||
let environmentOptions = [];
|
||||
let taskDetailsPollInterval = null;
|
||||
let diffText = "";
|
||||
let showDiff = false;
|
||||
let isDiffLoading = false;
|
||||
let taskSummary = null;
|
||||
let diffText = $state("");
|
||||
let showDiff = $state(false);
|
||||
let isDiffLoading = $state(false);
|
||||
|
||||
// Subscribe to task drawer store
|
||||
$: if ($taskDrawerStore) {
|
||||
isOpen = $taskDrawerStore.isOpen;
|
||||
activeTaskId = $taskDrawerStore.activeTaskId;
|
||||
}
|
||||
$: isAssistantOpen = Boolean($assistantChatStore?.isOpen);
|
||||
$: assistantOffset = isAssistantOpen ? "min(100vw, 28rem)" : "0px";
|
||||
const taskDrawerState = fromStore(taskDrawerStore);
|
||||
const assistantChatState = fromStore(assistantChatStore);
|
||||
|
||||
let isOpen = $derived(Boolean(taskDrawerState.current?.isOpen));
|
||||
let activeTaskId = $derived(taskDrawerState.current?.activeTaskId || null);
|
||||
let isAssistantOpen = $derived(Boolean(assistantChatState.current?.isOpen));
|
||||
let assistantOffset = $derived(
|
||||
isAssistantOpen ? "min(100vw, 28rem)" : "0px",
|
||||
);
|
||||
|
||||
// Derive short task ID for display
|
||||
$: shortTaskId = activeTaskId
|
||||
? typeof activeTaskId === "string"
|
||||
? activeTaskId.substring(0, 8)
|
||||
: (activeTaskId?.id || activeTaskId?.task_id || "")
|
||||
.toString()
|
||||
.substring(0, 8)
|
||||
: "";
|
||||
let shortTaskId = $derived(
|
||||
activeTaskId
|
||||
? typeof activeTaskId === "string"
|
||||
? activeTaskId.substring(0, 8)
|
||||
: (activeTaskId?.id || activeTaskId?.task_id || "")
|
||||
.toString()
|
||||
.substring(0, 8)
|
||||
: "",
|
||||
);
|
||||
|
||||
// Close drawer
|
||||
function handleClose() {
|
||||
@@ -305,7 +306,7 @@
|
||||
}
|
||||
|
||||
async function handleOpenDashboardDeepLink() {
|
||||
if (!taskSummary?.primaryDashboardId || !taskSummary?.targetEnvId) {
|
||||
if (!derivedTaskSummary?.primaryDashboardId || !derivedTaskSummary?.targetEnvId) {
|
||||
addToast(
|
||||
$t.tasks?.summary_link_unavailable || "Deep link unavailable",
|
||||
"error",
|
||||
@@ -313,8 +314,8 @@
|
||||
return;
|
||||
}
|
||||
const href = resolveSupersetDashboardUrl(
|
||||
taskSummary.targetEnvId,
|
||||
taskSummary.primaryDashboardId,
|
||||
derivedTaskSummary.targetEnvId,
|
||||
derivedTaskSummary.primaryDashboardId,
|
||||
);
|
||||
if (!href) {
|
||||
addToast(
|
||||
@@ -327,7 +328,7 @@
|
||||
}
|
||||
|
||||
async function handleShowDiff() {
|
||||
if (!taskSummary?.primaryDashboardId) {
|
||||
if (!derivedTaskSummary?.primaryDashboardId) {
|
||||
addToast(
|
||||
$t.tasks?.summary_link_unavailable || "Diff unavailable",
|
||||
"error",
|
||||
@@ -339,7 +340,7 @@
|
||||
diffText = "";
|
||||
try {
|
||||
const diffPayload = await gitService.getDiff(
|
||||
taskSummary.primaryDashboardId,
|
||||
derivedTaskSummary.primaryDashboardId,
|
||||
);
|
||||
diffText =
|
||||
typeof diffPayload === "string"
|
||||
@@ -484,31 +485,34 @@
|
||||
// [/DEF:goBackToList:Function]
|
||||
|
||||
// Reconnect when active task changes
|
||||
$: if (isOpen) {
|
||||
if (activeTaskId) {
|
||||
disconnectWebSocket();
|
||||
realTimeLogs = [];
|
||||
showDiff = false;
|
||||
diffText = "";
|
||||
taskStatus = "RUNNING";
|
||||
connectWebSocket();
|
||||
loadEnvironmentOptions();
|
||||
loadActiveTaskDetails();
|
||||
stopTaskDetailsPolling();
|
||||
taskDetailsPollInterval = setInterval(loadActiveTaskDetails, 4000);
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
if (activeTaskId) {
|
||||
disconnectWebSocket();
|
||||
realTimeLogs = [];
|
||||
showDiff = false;
|
||||
diffText = "";
|
||||
taskStatus = "RUNNING";
|
||||
connectWebSocket();
|
||||
void loadEnvironmentOptions();
|
||||
void loadActiveTaskDetails();
|
||||
stopTaskDetailsPolling();
|
||||
taskDetailsPollInterval = setInterval(loadActiveTaskDetails, 4000);
|
||||
} else {
|
||||
stopTaskDetailsPolling();
|
||||
activeTaskDetails = null;
|
||||
void loadRecentTasks();
|
||||
}
|
||||
} else {
|
||||
// List mode - load recent tasks
|
||||
stopTaskDetailsPolling();
|
||||
activeTaskDetails = null;
|
||||
loadRecentTasks();
|
||||
}
|
||||
} else {
|
||||
stopTaskDetailsPolling();
|
||||
activeTaskDetails = null;
|
||||
}
|
||||
});
|
||||
|
||||
$: taskSummary = buildTaskSummary(activeTaskDetails);
|
||||
$: activeTaskValidation = resolveLlmValidationStatus(activeTaskDetails);
|
||||
let derivedTaskSummary = $derived(buildTaskSummary(activeTaskDetails));
|
||||
let derivedActiveTaskValidation = $derived(
|
||||
resolveLlmValidationStatus(activeTaskDetails),
|
||||
);
|
||||
|
||||
// Cleanup on destroy
|
||||
onDestroy(() => {
|
||||
@@ -517,10 +521,10 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleGlobalKeydown} />
|
||||
<svelte:window onkeydown={handleGlobalKeydown} />
|
||||
|
||||
{#if isOpen}
|
||||
<aside
|
||||
<div
|
||||
class="fixed top-0 z-[72] flex h-full w-full max-w-[560px] flex-col border-l border-slate-200 bg-white shadow-[-8px_0_30px_rgba(15,23,42,0.15)] transition-[right] duration-300 ease-out"
|
||||
style={`right: ${assistantOffset};`}
|
||||
role="dialog"
|
||||
@@ -541,7 +545,7 @@
|
||||
{:else if activeTaskId}
|
||||
<button
|
||||
class="flex items-center justify-center p-1.5 rounded-md text-slate-500 bg-transparent border-none cursor-pointer transition-all hover:text-slate-100 hover:bg-slate-800"
|
||||
on:click={goBackToList}
|
||||
onclick={goBackToList}
|
||||
aria-label={$t.tasks?.back_to_list}
|
||||
>
|
||||
<Icon name="back" size={16} strokeWidth={2} />
|
||||
@@ -567,30 +571,30 @@
|
||||
>{taskStatus}</span
|
||||
>
|
||||
{/if}
|
||||
{#if activeTaskValidation}
|
||||
{#if derivedActiveTaskValidation}
|
||||
<span
|
||||
class={`text-xs font-semibold uppercase tracking-wider px-2 py-0.5 rounded-full inline-flex items-center gap-1 ${llmValidationBadgeClass(activeTaskValidation.tone)}`}
|
||||
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="inline-flex min-w-[18px] items-center justify-center rounded-full bg-white/70 px-1 text-[10px] font-bold"
|
||||
>
|
||||
{activeTaskValidation.icon}
|
||||
{derivedActiveTaskValidation.icon}
|
||||
</span>
|
||||
{activeTaskValidation.label}
|
||||
{derivedActiveTaskValidation.label}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="rounded-md border border-slate-300 bg-slate-50 px-2.5 py-1 text-xs font-semibold text-slate-700 transition-colors hover:bg-slate-100"
|
||||
on:click={goToReportsPage}
|
||||
onclick={goToReportsPage}
|
||||
>
|
||||
{$t.nav?.reports}
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded-md text-slate-500 bg-transparent border-none cursor-pointer transition-all hover:text-slate-100 hover:bg-slate-800"
|
||||
on:click={handleClose}
|
||||
onclick={handleClose}
|
||||
aria-label={$t.tasks?.close_drawer}
|
||||
>
|
||||
<Icon name="close" size={18} strokeWidth={2} />
|
||||
@@ -601,7 +605,7 @@
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-hidden flex flex-col">
|
||||
{#if activeTaskId}
|
||||
{#if taskSummary}
|
||||
{#if derivedTaskSummary}
|
||||
<div
|
||||
class="mx-4 mt-4 rounded-lg border border-slate-200 bg-slate-50 p-3"
|
||||
>
|
||||
@@ -615,17 +619,17 @@
|
||||
{taskStatus}
|
||||
</span>
|
||||
</div>
|
||||
{#if taskSummary.headline}
|
||||
<p class="mb-2 text-sm text-slate-700">{taskSummary.headline}</p>
|
||||
{#if derivedTaskSummary.headline}
|
||||
<p class="mb-2 text-sm text-slate-700">{derivedTaskSummary.headline}</p>
|
||||
{/if}
|
||||
{#if taskSummary.lines.length > 0}
|
||||
{#if derivedTaskSummary.lines.length > 0}
|
||||
<ul class="mb-2 space-y-1 text-xs text-slate-700">
|
||||
{#each taskSummary.lines as line}
|
||||
{#each derivedTaskSummary.lines as line}
|
||||
<li>{line}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if taskSummary.warnings.length > 0}
|
||||
{#if derivedTaskSummary.warnings.length > 0}
|
||||
<div
|
||||
class="mb-2 rounded-md border border-amber-200 bg-amber-50 p-2 text-xs text-amber-800"
|
||||
>
|
||||
@@ -633,7 +637,7 @@
|
||||
{$t.tasks?.observability_warnings || "Warnings"}
|
||||
</p>
|
||||
<ul class="space-y-1">
|
||||
{#each taskSummary.warnings as warning}
|
||||
{#each derivedTaskSummary.warnings as warning}
|
||||
<li>{warning}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -642,29 +646,29 @@
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
class="rounded-md border border-slate-300 bg-white px-2.5 py-1.5 text-xs font-semibold text-slate-700 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
on:click={handleOpenDashboardDeepLink}
|
||||
disabled={!taskSummary?.primaryDashboardId ||
|
||||
!taskSummary?.targetEnvId}
|
||||
onclick={handleOpenDashboardDeepLink}
|
||||
disabled={!derivedTaskSummary?.primaryDashboardId ||
|
||||
!derivedTaskSummary?.targetEnvId}
|
||||
>
|
||||
{#if taskSummary?.targetEnvName}
|
||||
{#if derivedTaskSummary?.targetEnvName}
|
||||
{(
|
||||
$t.tasks?.open_dashboard_target || "Open dashboard in {env}"
|
||||
).replace("{env}", taskSummary.targetEnvName)}
|
||||
).replace("{env}", derivedTaskSummary.targetEnvName)}
|
||||
{:else}
|
||||
{$t.tasks?.open_dashboard_target_fallback || "Open dashboard"}
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
class="rounded-md border border-slate-300 bg-white px-2.5 py-1.5 text-xs font-semibold text-slate-700 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
on:click={handleShowDiff}
|
||||
disabled={!taskSummary?.primaryDashboardId}
|
||||
onclick={handleShowDiff}
|
||||
disabled={!derivedTaskSummary?.primaryDashboardId}
|
||||
>
|
||||
{$t.tasks?.show_diff || "Show diff"}
|
||||
</button>
|
||||
{#if activeTaskDetails?.plugin_id === "llm_dashboard_validation"}
|
||||
<button
|
||||
class="rounded-md border border-indigo-300 bg-indigo-50 px-2.5 py-1.5 text-xs font-semibold text-indigo-700 transition-colors hover:bg-indigo-100"
|
||||
on:click={handleOpenLlmReport}
|
||||
onclick={handleOpenLlmReport}
|
||||
>
|
||||
{$t.tasks?.open_llm_report || "Open LLM report"}
|
||||
</button>
|
||||
@@ -719,7 +723,7 @@
|
||||
{@const taskValidation = resolveLlmValidationStatus(task)}
|
||||
<button
|
||||
class="flex items-center gap-3 w-full p-3 mb-2 bg-slate-800 border border-slate-700 rounded-lg cursor-pointer transition-all hover:bg-slate-700 hover:border-slate-600 text-left"
|
||||
on:click={() => selectTask(task)}
|
||||
onclick={() => selectTask(task)}
|
||||
>
|
||||
<span class="font-mono text-xs text-slate-500"
|
||||
>{task.id?.substring(0, 8) ||
|
||||
@@ -782,7 +786,6 @@
|
||||
{$t.tasks?.footer_text}
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
<!-- [/DEF:TaskDrawer:Component] -->
|
||||
{/if}
|
||||
```
|
||||
|
||||
@@ -30,9 +30,9 @@
|
||||
* @TEST_INVARIANT ui_consistency -> verifies: [logged_in]
|
||||
*/
|
||||
|
||||
import { createEventDispatcher, onMount } from "svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
import { page } from "$app/state";
|
||||
import { api } from "$lib/api.js";
|
||||
import { getReports } from "$lib/api/reports.js";
|
||||
import { activityStore } from "$lib/stores/activity.js";
|
||||
@@ -55,32 +55,44 @@
|
||||
setSelectedEnvironment,
|
||||
selectedEnvironmentStore,
|
||||
} from "$lib/stores/environmentContext.js";
|
||||
import { fromStore } from "svelte/store";
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let showUserMenu = false;
|
||||
let isSearchFocused = false;
|
||||
let searchQuery = "";
|
||||
let showSearchDropdown = false;
|
||||
let isSearchLoading = false;
|
||||
let groupedSearchResults = [];
|
||||
let showUserMenu = $state(false);
|
||||
let isSearchFocused = $state(false);
|
||||
let searchQuery = $state("");
|
||||
let showSearchDropdown = $state(false);
|
||||
let isSearchLoading = $state(false);
|
||||
let groupedSearchResults = $state([]);
|
||||
let searchTimer = null;
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 250;
|
||||
const SEARCH_MIN_LENGTH = 2;
|
||||
const SEARCH_LIMIT = 5;
|
||||
|
||||
$: isExpanded = $sidebarStore?.isExpanded ?? true;
|
||||
$: activeCount = $activityStore?.activeCount || 0;
|
||||
$: recentTasks = $activityStore?.recentTasks || [];
|
||||
$: user = $auth?.user || null;
|
||||
$: canOpenSettings = hasPermission(user, "admin:settings", "READ");
|
||||
$: globalEnvironments = $environmentContextStore?.environments || [];
|
||||
$: globalSelectedEnvId = $environmentContextStore?.selectedEnvId || "";
|
||||
$: globalSelectedEnv = $selectedEnvironmentStore;
|
||||
$: isProdContext =
|
||||
const sidebarState = fromStore(sidebarStore);
|
||||
const activityState = fromStore(activityStore);
|
||||
const authState = fromStore(auth);
|
||||
const environmentContextState = fromStore(environmentContextStore);
|
||||
const selectedEnvironmentState = fromStore(selectedEnvironmentStore);
|
||||
|
||||
let isExpanded = $derived(sidebarState.current?.isExpanded ?? true);
|
||||
let activeCount = $derived(activityState.current?.activeCount || 0);
|
||||
let recentTasks = $derived(activityState.current?.recentTasks || []);
|
||||
let user = $derived(authState.current?.user || null);
|
||||
let canOpenSettings = $derived(
|
||||
hasPermission(user, "admin:settings", "READ"),
|
||||
);
|
||||
let globalEnvironments = $derived(
|
||||
environmentContextState.current?.environments || [],
|
||||
);
|
||||
let globalSelectedEnvId = $derived(
|
||||
environmentContextState.current?.selectedEnvId || "",
|
||||
);
|
||||
let globalSelectedEnv = $derived(selectedEnvironmentState.current);
|
||||
let isProdContext = $derived(
|
||||
String(globalSelectedEnv?.stage || "").toUpperCase() === "PROD" ||
|
||||
Boolean(globalSelectedEnv?.is_production);
|
||||
Boolean(globalSelectedEnv?.is_production),
|
||||
);
|
||||
|
||||
function toggleUserMenu(event) {
|
||||
event.stopPropagation();
|
||||
@@ -106,7 +118,6 @@
|
||||
} else {
|
||||
openDrawer();
|
||||
}
|
||||
dispatch("activityClick");
|
||||
}
|
||||
|
||||
function handleAssistantClick() {
|
||||
@@ -358,7 +369,7 @@
|
||||
<!-- Hamburger Menu (mobile only) -->
|
||||
<button
|
||||
class="rounded-lg p-2 text-slate-600 transition-colors hover:bg-slate-100 md:hidden"
|
||||
on:click={handleHamburgerClick}
|
||||
onclick={handleHamburgerClick}
|
||||
aria-label={$t.common?.toggle_menu}
|
||||
>
|
||||
<Icon name="menu" size={22} />
|
||||
@@ -388,9 +399,9 @@
|
||||
{isSearchFocused ? 'bg-white border border-primary-ring' : ''}"
|
||||
placeholder={$t.common.search}
|
||||
value={searchQuery}
|
||||
on:input={handleSearchInput}
|
||||
on:focus={handleSearchFocus}
|
||||
on:keydown={handleSearchKeydown}
|
||||
oninput={handleSearchInput}
|
||||
onfocus={handleSearchFocus}
|
||||
onkeydown={handleSearchKeydown}
|
||||
/>
|
||||
{#if showSearchDropdown}
|
||||
<div
|
||||
@@ -415,7 +426,7 @@
|
||||
{#each section.items as result}
|
||||
<button
|
||||
class="flex w-full items-center justify-between px-4 py-2 text-left hover:bg-slate-50"
|
||||
on:click={() => openSearchResult(result)}
|
||||
onclick={() => openSearchResult(result)}
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-medium text-slate-800">
|
||||
@@ -444,7 +455,7 @@
|
||||
? 'border-red-300 bg-red-50 text-red-900 focus:ring-red-200'
|
||||
: 'border-slate-300 bg-white text-slate-700 focus:ring-sky-200'}"
|
||||
value={globalSelectedEnvId}
|
||||
on:change={handleGlobalEnvironmentChange}
|
||||
onchange={handleGlobalEnvironmentChange}
|
||||
aria-label={$t.dashboard?.environment || "Environment"}
|
||||
title={$t.dashboard?.environment || "Environment"}
|
||||
>
|
||||
@@ -469,7 +480,7 @@
|
||||
<!-- Assistant -->
|
||||
<button
|
||||
class="rounded-lg p-2 text-slate-600 transition-colors hover:bg-slate-100"
|
||||
on:click={handleAssistantClick}
|
||||
onclick={handleAssistantClick}
|
||||
aria-label={$t.assistant?.open}
|
||||
title={$t.assistant?.title}
|
||||
>
|
||||
@@ -479,8 +490,8 @@
|
||||
<!-- Activity Indicator -->
|
||||
<div
|
||||
class="relative cursor-pointer rounded-lg p-2 text-slate-600 transition-colors hover:bg-slate-100"
|
||||
on:click={handleActivityClick}
|
||||
on:keydown={(e) =>
|
||||
onclick={handleActivityClick}
|
||||
onkeydown={(e) =>
|
||||
(e.key === "Enter" || e.key === " ") && handleActivityClick()}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@@ -499,8 +510,8 @@
|
||||
<div class="user-menu-container relative">
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-primary text-white flex items-center justify-center cursor-pointer hover:bg-primary-hover transition-colors"
|
||||
on:click={toggleUserMenu}
|
||||
on:keydown={(e) =>
|
||||
onclick={toggleUserMenu}
|
||||
onkeydown={(e) =>
|
||||
(e.key === "Enter" || e.key === " ") && toggleUserMenu(e)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@@ -528,10 +539,10 @@
|
||||
{#if canOpenSettings}
|
||||
<div
|
||||
class="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer"
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
window.location.href = "/settings";
|
||||
}}
|
||||
on:keydown={(e) =>
|
||||
onkeydown={(e) =>
|
||||
(e.key === "Enter" || e.key === " ") &&
|
||||
(window.location.href = "/settings")}
|
||||
role="button"
|
||||
@@ -542,8 +553,8 @@
|
||||
{/if}
|
||||
<div
|
||||
class="px-4 py-2 text-sm text-destructive hover:bg-destructive-light cursor-pointer"
|
||||
on:click={handleLogout}
|
||||
on:keydown={(e) =>
|
||||
onclick={handleLogout}
|
||||
onkeydown={(e) =>
|
||||
(e.key === "Enter" || e.key === " ") && handleLogout()}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
|
||||
@@ -28,12 +28,10 @@
|
||||
* @TEST_INVARIANT render_resilience -> verifies: [valid_report_card, empty_report_object, random_status]
|
||||
*/
|
||||
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { t } from "$lib/i18n";
|
||||
import { getReportTypeProfile } from "./reportTypeProfiles.js";
|
||||
|
||||
let { report, selected = false, onselect } = $props();
|
||||
const dispatch = createEventDispatcher();
|
||||
let { report, selected = false, onselect = () => {} } = $props();
|
||||
|
||||
const profile = $derived(getReportTypeProfile(report?.task_type));
|
||||
const profileLabel = $derived(
|
||||
@@ -68,8 +66,7 @@
|
||||
}
|
||||
|
||||
function onSelect() {
|
||||
if (onselect) onselect({ report });
|
||||
dispatch("select", { report });
|
||||
onselect({ report });
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<ReportCard
|
||||
{report}
|
||||
selected={selectedReportId === report.report_id}
|
||||
on:select={handleSelect}
|
||||
onselect={handleSelect}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script>
|
||||
export let name = "circle";
|
||||
export let size = 20;
|
||||
export let className = "";
|
||||
export let strokeWidth = 1.9;
|
||||
let {
|
||||
name = "circle",
|
||||
size = 20,
|
||||
className = "",
|
||||
strokeWidth = 1.9,
|
||||
} = $props();
|
||||
|
||||
const iconPaths = {
|
||||
home: ["M3 11l9-7 9 7", "M5 10v9h14v-9", "M10 19v-5h4v5"],
|
||||
@@ -64,7 +66,7 @@
|
||||
],
|
||||
};
|
||||
|
||||
$: paths = iconPaths[name] || iconPaths.dashboard;
|
||||
let paths = $derived(iconPaths[name] || iconPaths.dashboard);
|
||||
</script>
|
||||
|
||||
<svg
|
||||
|
||||
Reference in New Issue
Block a user