test contracts

This commit is contained in:
2026-02-26 19:40:00 +03:00
parent 81d62c1345
commit 36173c0880
35 changed files with 1811 additions and 759 deletions

View File

@@ -59,6 +59,20 @@ export function normalizeApiError(error) {
// @PURPOSE: Fetch unified report list using existing request wrapper.
// @PRE: valid auth context for protected endpoint.
// @POST: Returns parsed payload or structured error for UI-state mapping.
//
// @TEST_CONTRACT: GetReportsApi ->
// {
// required_fields: {},
// optional_fields: {options: Object},
// invariants: [
// "Fetches from /reports with built query string",
// "Returns response payload on success",
// "Catches and normalizes errors using normalizeApiError"
// ]
// }
// @TEST_FIXTURE: valid_get_reports -> {"options": {"page": 1}}
// @TEST_EDGE: api_fetch_failure -> api.fetchApi throws error
// @TEST_INVARIANT: error_normalization -> verifies: [api_fetch_failure]
export async function getReports(options = {}) {
try {
console.log("[reports][api][getReports:STARTED]", options);

View File

@@ -23,6 +23,21 @@
* @UX_TEST: NeedsConfirmation -> {click: confirm action, expected: started response with task_id}
* @TEST_DATA: assistant_llm_ready -> {"llmStatus":{"configured":true,"reason":"ok"},"messages":[{"role":"assistant","text":"Ready","state":"success"}]}
* @TEST_DATA: assistant_llm_not_configured -> {"llmStatus":{"configured":false,"reason":"invalid_api_key"}}
*
* @TEST_CONTRACT Component_AssistantChatPanel ->
* {
* required_props: {},
* optional_props: {},
* invariants: [
* "Loads history and LLM status on mount/open",
* "Appends messages and sends to API correctly",
* "Handles action buttons (confirm, open task) properly"
* ]
* }
* @TEST_FIXTURE chat_open -> {"isOpen": true, "messages": [{"role": "assistant", "text": "Hello"}]}
* @TEST_EDGE server_error -> Appends error message with "failed" state
* @TEST_EDGE llm_not_ready -> Renders LLM config warning banner
* @TEST_INVARIANT action_handling -> verifies: [chat_open]
*/
import { onMount } from "svelte";
@@ -551,15 +566,22 @@
<div class="flex h-[calc(100%-56px)] flex-col">
{#if !llmReady}
<div class="mx-3 mt-3 rounded-lg border border-rose-300 bg-rose-50 px-3 py-2 text-xs text-rose-800">
<div class="font-semibold">{$t.dashboard?.llm_not_configured || "LLM is not configured"}</div>
<div
class="mx-3 mt-3 rounded-lg border border-rose-300 bg-rose-50 px-3 py-2 text-xs text-rose-800"
>
<div class="font-semibold">
{$t.dashboard?.llm_not_configured || "LLM is not configured"}
</div>
<div class="mt-1 text-rose-700">
{#if llmStatusReason === "no_active_provider"}
{$t.dashboard?.llm_configure_provider || "No active LLM provider. Configure it in Admin -> LLM Settings."}
{$t.dashboard?.llm_configure_provider ||
"No active LLM provider. Configure it in Admin -> LLM Settings."}
{:else if llmStatusReason === "invalid_api_key"}
{$t.dashboard?.llm_configure_key || "Invalid LLM API key. Update and save a real key in Admin -> LLM Settings."}
{$t.dashboard?.llm_configure_key ||
"Invalid LLM API key. Update and save a real key in Admin -> LLM Settings."}
{:else}
{$t.dashboard?.llm_status_unavailable || "LLM status is unavailable. Check settings and backend logs."}
{$t.dashboard?.llm_status_unavailable ||
"LLM status is unavailable. Check settings and backend logs."}
{/if}
</div>
</div>
@@ -758,7 +780,9 @@
bind:value={input}
rows="2"
placeholder={$t.assistant?.input_placeholder}
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'}"
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}
></textarea>
<button

View File

@@ -12,6 +12,20 @@
* @UX_STATE: Toggling -> Animation plays for 200ms
* @UX_FEEDBACK: Active item highlighted with different background
* @UX_RECOVERY: Click outside on mobile closes overlay
*
* @TEST_CONTRACT Component_Sidebar ->
* {
* required_props: {},
* optional_props: {},
* invariants: [
* "Highlights active category and sub-item based on current page URL",
* "Toggles sidebar via toggleSidebar store action",
* "Closes mobile overlay on click outside"
* ]
* }
* @TEST_FIXTURE idle_state -> {}
* @TEST_EDGE mobile_open -> shows mobile overlay mask
* @TEST_INVARIANT navigation -> verifies: [idle_state]
*/
import { onMount } from "svelte";
@@ -30,56 +44,52 @@
return [
{
id: "dashboards",
label: $t.nav?.dashboards ,
label: $t.nav?.dashboards,
icon: "dashboard",
tone: "from-sky-100 to-sky-200 text-sky-700 ring-sky-200",
path: "/dashboards",
subItems: [
{ label: $t.nav?.overview , path: "/dashboards" },
],
subItems: [{ label: $t.nav?.overview, path: "/dashboards" }],
},
{
id: "datasets",
label: $t.nav?.datasets ,
label: $t.nav?.datasets,
icon: "database",
tone: "from-emerald-100 to-emerald-200 text-emerald-700 ring-emerald-200",
path: "/datasets",
subItems: [
{ label: $t.nav?.all_datasets , path: "/datasets" },
],
subItems: [{ label: $t.nav?.all_datasets, path: "/datasets" }],
},
{
id: "storage",
label: $t.nav?.storage ,
label: $t.nav?.storage,
icon: "storage",
tone: "from-amber-100 to-amber-200 text-amber-800 ring-amber-200",
path: "/storage",
subItems: [
{ label: $t.nav?.backups , path: "/storage/backups" },
{ label: $t.nav?.backups, path: "/storage/backups" },
{
label: $t.nav?.repositories ,
label: $t.nav?.repositories,
path: "/storage/repos",
},
],
},
{
id: "reports",
label: $t.nav?.reports ,
label: $t.nav?.reports,
icon: "reports",
tone: "from-violet-100 to-fuchsia-100 text-violet-700 ring-violet-200",
path: "/reports",
subItems: [{ label: $t.nav?.reports , path: "/reports" }],
subItems: [{ label: $t.nav?.reports, path: "/reports" }],
},
{
id: "admin",
label: $t.nav?.admin ,
label: $t.nav?.admin,
icon: "admin",
tone: "from-rose-100 to-rose-200 text-rose-700 ring-rose-200",
path: "/admin",
subItems: [
{ label: $t.nav?.admin_users , path: "/admin/users" },
{ label: $t.nav?.admin_roles , path: "/admin/roles" },
{ label: $t.nav?.settings , path: "/settings" },
{ label: $t.nav?.admin_users, path: "/admin/users" },
{ label: $t.nav?.admin_roles, path: "/admin/roles" },
{ label: $t.nav?.settings, path: "/settings" },
],
},
];
@@ -198,10 +208,12 @@
>
{#if isExpanded}
<span class="font-semibold text-gray-800 flex items-center gap-2">
<span class="inline-flex h-6 w-6 items-center justify-center rounded-md bg-gradient-to-br from-slate-100 to-slate-200 text-slate-700 ring-1 ring-slate-200">
<span
class="inline-flex h-6 w-6 items-center justify-center rounded-md bg-gradient-to-br from-slate-100 to-slate-200 text-slate-700 ring-1 ring-slate-200"
>
<Icon name="layers" size={14} />
</span>
{$t.nav?.menu }
{$t.nav?.menu}
</span>
{:else}
<span class="text-xs text-gray-500">M</span>
@@ -228,7 +240,9 @@
aria-expanded={expandedCategories.has(category.id)}
>
<div class="flex items-center">
<span class="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br ring-1 transition-all {category.tone}">
<span
class="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} />
</span>
{#if isExpanded}
@@ -282,10 +296,12 @@
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}
>
<span class="mr-2 inline-flex h-6 w-6 items-center justify-center rounded-md bg-slate-100 text-slate-600">
<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 }
{$t.nav?.collapse}
</button>
</div>
{:else}
@@ -293,10 +309,10 @@
<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 }
aria-label={$t.nav?.expand_sidebar}
>
<Icon name="chevronRight" size={16} />
<span class="ml-2">{$t.nav?.expand }</span>
<span class="ml-2">{$t.nav?.expand}</span>
</button>
</div>
{/if}

View File

@@ -18,6 +18,21 @@
* @UX_RECOVERY: Back button shows task list when viewing task details
* @TEST_DATA: llm_task_success_with_fail_result -> {"activeTaskDetails":{"plugin_id":"llm_dashboard_validation","status":"SUCCESS","result":{"status":"FAIL"}}}
* @TEST_DATA: llm_task_success_with_pass_result -> {"activeTaskDetails":{"plugin_id":"llm_dashboard_validation","status":"SUCCESS","result":{"status":"PASS"}}}
*
* @TEST_CONTRACT Component_TaskDrawer ->
* {
* required_props: {},
* optional_props: {},
* invariants: [
* "Binds successfully to taskDrawerStore",
* "Renders list mode when no activeTaskId is set",
* "Renders detail mode and websocket stream when activeTaskId is set",
* "Closes properly on handleClose"
* ]
* }
* @TEST_FIXTURE init_state -> {}
* @TEST_EDGE empty_task_list -> displays \"No recent tasks\"
* @TEST_INVARIANT default_rendering -> verifies: [init_state]
*/
import { onDestroy } from "svelte";
@@ -112,9 +127,12 @@
}
function llmValidationBadgeClass(tone) {
if (tone === "fail") return "text-rose-700 bg-rose-100 border border-rose-200";
if (tone === "warn") return "text-amber-700 bg-amber-100 border border-amber-200";
if (tone === "pass") return "text-emerald-700 bg-emerald-100 border border-emerald-200";
if (tone === "fail")
return "text-rose-700 bg-rose-100 border border-rose-200";
if (tone === "warn")
return "text-amber-700 bg-amber-100 border border-amber-200";
if (tone === "pass")
return "text-emerald-700 bg-emerald-100 border border-emerald-200";
return "text-slate-700 bg-slate-100 border border-slate-200";
}
@@ -169,7 +187,10 @@
function extractPrimaryDashboardId(task) {
const result = task?.result || {};
const params = task?.params || {};
if (Array.isArray(result?.migrated_dashboards) && result.migrated_dashboards[0]?.id) {
if (
Array.isArray(result?.migrated_dashboards) &&
result.migrated_dashboards[0]?.id
) {
return result.migrated_dashboards[0].id;
}
if (Array.isArray(result?.dashboards) && result.dashboards[0]?.id) {
@@ -215,7 +236,10 @@
if (failed > 0) {
summary.warnings = (result.failed_dashboards || [])
.slice(0, 3)
.map((item) => `${item?.title || item?.id}: ${item?.error || $t.common?.unknown || "Unknown error"}`);
.map(
(item) =>
`${item?.title || item?.id}: ${item?.error || $t.common?.unknown || "Unknown error"}`,
);
}
return summary;
}
@@ -237,17 +261,19 @@
if (Array.isArray(result?.failures) && result.failures.length > 0) {
summary.warnings = result.failures
.slice(0, 3)
.map((item) => `${item?.title || item?.id}: ${item?.error || $t.common?.unknown || "Unknown error"}`);
.map(
(item) =>
`${item?.title || item?.id}: ${item?.error || $t.common?.unknown || "Unknown error"}`,
);
}
return summary;
}
if (task.plugin_id === "llm_dashboard_validation") {
summary.targetEnvId = resolveEnvironmentId(params?.environment_id || null);
summary.targetEnvName = resolveEnvironmentName(
summary.targetEnvId,
null,
summary.targetEnvId = resolveEnvironmentId(
params?.environment_id || null,
);
summary.targetEnvName = resolveEnvironmentName(summary.targetEnvId, null);
if (result?.summary) {
summary.lines.push(result.summary);
}
@@ -263,7 +289,10 @@
async function handleOpenDashboardDeepLink() {
if (!taskSummary?.primaryDashboardId || !taskSummary?.targetEnvId) {
addToast($t.tasks?.summary_link_unavailable || "Deep link unavailable", "error");
addToast(
$t.tasks?.summary_link_unavailable || "Deep link unavailable",
"error",
);
return;
}
const href = `/dashboards/${encodeURIComponent(String(taskSummary.primaryDashboardId))}?env_id=${encodeURIComponent(String(taskSummary.targetEnvId))}`;
@@ -272,14 +301,19 @@
async function handleShowDiff() {
if (!taskSummary?.primaryDashboardId) {
addToast($t.tasks?.summary_link_unavailable || "Diff unavailable", "error");
addToast(
$t.tasks?.summary_link_unavailable || "Diff unavailable",
"error",
);
return;
}
showDiff = true;
isDiffLoading = true;
diffText = "";
try {
const diffPayload = await gitService.getDiff(taskSummary.primaryDashboardId);
const diffPayload = await gitService.getDiff(
taskSummary.primaryDashboardId,
);
diffText =
typeof diffPayload === "string"
? diffPayload
@@ -295,10 +329,17 @@
function handleOpenLlmReport() {
const taskId = normalizeTaskId(activeTaskId);
if (!taskId) {
addToast($t.tasks?.summary_link_unavailable || "Report unavailable", "error");
addToast(
$t.tasks?.summary_link_unavailable || "Report unavailable",
"error",
);
return;
}
window.open(`/reports/llm/${encodeURIComponent(taskId)}`, "_blank", "noopener,noreferrer");
window.open(
`/reports/llm/${encodeURIComponent(taskId)}`,
"_blank",
"noopener,noreferrer",
);
}
// Connect to WebSocket for real-time logs
@@ -457,7 +498,7 @@
style={`right: ${assistantOffset};`}
role="dialog"
aria-modal="false"
aria-label={$t.tasks?.drawer }
aria-label={$t.tasks?.drawer}
>
<!-- Header -->
<div
@@ -474,15 +515,13 @@
<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}
aria-label={$t.tasks?.back_to_list }
aria-label={$t.tasks?.back_to_list}
>
<Icon name="back" size={16} strokeWidth={2} />
</button>
{/if}
<h2 class="text-sm font-semibold tracking-tight text-slate-900">
{activeTaskId
? $t.tasks?.details_logs
: $t.tasks?.recent }
{activeTaskId ? $t.tasks?.details_logs : $t.tasks?.recent}
</h2>
{#if shortTaskId}
<span
@@ -506,7 +545,9 @@
class={`text-xs font-semibold uppercase tracking-wider px-2 py-0.5 rounded-full inline-flex items-center gap-1 ${llmValidationBadgeClass(activeTaskValidation.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">
<span
class="inline-flex min-w-[18px] items-center justify-center rounded-full bg-white/70 px-1 text-[10px] font-bold"
>
{activeTaskValidation.icon}
</span>
{activeTaskValidation.label}
@@ -518,12 +559,12 @@
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}
>
{$t.nav?.reports }
{$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}
aria-label={$t.tasks?.close_drawer }
aria-label={$t.tasks?.close_drawer}
>
<Icon name="close" size={18} strokeWidth={2} />
</button>
@@ -534,12 +575,16 @@
<div class="flex-1 overflow-hidden flex flex-col">
{#if activeTaskId}
{#if taskSummary}
<div class="mx-4 mt-4 rounded-lg border border-slate-200 bg-slate-50 p-3">
<div
class="mx-4 mt-4 rounded-lg border border-slate-200 bg-slate-50 p-3"
>
<div class="mb-2 flex items-center justify-between gap-2">
<h3 class="text-sm font-semibold text-slate-900">
{$t.tasks?.summary_report || "Summary report"}
</h3>
<span class="rounded-full bg-green-100 px-2 py-0.5 text-[11px] font-semibold text-green-700">
<span
class="rounded-full bg-green-100 px-2 py-0.5 text-[11px] font-semibold text-green-700"
>
{taskStatus}
</span>
</div>
@@ -554,7 +599,9 @@
</ul>
{/if}
{#if taskSummary.warnings.length > 0}
<div class="mb-2 rounded-md border border-amber-200 bg-amber-50 p-2 text-xs text-amber-800">
<div
class="mb-2 rounded-md border border-amber-200 bg-amber-50 p-2 text-xs text-amber-800"
>
<p class="mb-1 font-semibold">
{$t.tasks?.observability_warnings || "Warnings"}
</p>
@@ -569,13 +616,13 @@
<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}
disabled={!taskSummary?.primaryDashboardId ||
!taskSummary?.targetEnvId}
>
{#if taskSummary?.targetEnvName}
{($t.tasks?.open_dashboard_target || "Open dashboard in {env}").replace(
"{env}",
taskSummary.targetEnvName,
)}
{(
$t.tasks?.open_dashboard_target || "Open dashboard in {env}"
).replace("{env}", taskSummary.targetEnvName)}
{:else}
{$t.tasks?.open_dashboard_target_fallback || "Open dashboard"}
{/if}
@@ -598,15 +645,22 @@
</div>
{#if showDiff}
<div class="mt-3 rounded-md border border-slate-200 bg-white p-2">
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
<p
class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500"
>
{$t.tasks?.diff_preview || "Diff preview"}
</p>
{#if isDiffLoading}
<p class="text-xs text-slate-500">{$t.git?.loading_diff || "Loading diff..."}</p>
<p class="text-xs text-slate-500">
{$t.git?.loading_diff || "Loading diff..."}
</p>
{:else if diffText}
<pre class="max-h-40 overflow-auto rounded bg-slate-900 p-2 text-[11px] text-slate-100">{diffText}</pre>
<pre
class="max-h-40 overflow-auto rounded bg-slate-900 p-2 text-[11px] text-slate-100">{diffText}</pre>
{:else}
<p class="text-xs text-slate-500">{$t.tasks?.no_diff_available || "No diff available"}</p>
<p class="text-xs text-slate-500">
{$t.tasks?.no_diff_available || "No diff available"}
</p>
{/if}
</div>
{/if}
@@ -625,14 +679,14 @@
<div
class="mb-4 h-8 w-8 animate-spin rounded-full border-2 border-slate-200 border-t-blue-500"
></div>
<p>{$t.tasks?.loading }</p>
<p>{$t.tasks?.loading}</p>
</div>
{:else if recentTasks.length > 0}
<div class="p-4">
<h3
class="text-sm font-semibold text-slate-100 mb-4 pb-2 border-b border-slate-800"
>
{$t.tasks?.recent }
{$t.tasks?.recent}
</h3>
{#each recentTasks as task}
{@const taskValidation = resolveLlmValidationStatus(task)}
@@ -646,7 +700,7 @@
"N/A"}...</span
>
<span class="flex-1 text-sm text-slate-100 font-medium"
>{task.plugin_id || $t.common?.unknown }</span
>{task.plugin_id || $t.common?.unknown}</span
>
<span
class="text-xs font-semibold uppercase px-2 py-1 rounded-full {task.status?.toLowerCase() ===
@@ -659,14 +713,16 @@
task.status?.toLowerCase() === 'error'
? 'bg-red-500/15 text-red-400'
: 'bg-slate-500/15 text-slate-400'}"
>{task.status || $t.common?.unknown }</span
>{task.status || $t.common?.unknown}</span
>
{#if taskValidation}
<span
class={`text-[10px] font-semibold uppercase px-2 py-1 rounded-full inline-flex items-center gap-1 ${llmValidationBadgeClass(taskValidation.tone)}`}
title="Dashboard validation result"
>
<span class="inline-flex min-w-[16px] items-center justify-center rounded-full bg-white/70 px-1 text-[9px] font-bold">
<span
class="inline-flex min-w-[16px] items-center justify-center rounded-full bg-white/70 px-1 text-[9px] font-bold"
>
{taskValidation.icon}
</span>
{taskValidation.label}
@@ -685,7 +741,7 @@
strokeWidth={1.6}
className="mb-3 text-slate-700"
/>
<p>{$t.tasks?.select_task }</p>
<p>{$t.tasks?.select_task}</p>
</div>
{/if}
</div>
@@ -696,7 +752,7 @@
>
<div class="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse"></div>
<p class="text-xs text-slate-500">
{$t.tasks?.footer_text }
{$t.tasks?.footer_text}
</p>
</div>
</aside>

View File

@@ -14,6 +14,20 @@
* @UX_RECOVERY: Click outside closes dropdowns
* @UX_TEST: SearchFocused -> {focus: search input, expected: focused style class applied}
* @UX_TEST: ActivityClick -> {click: activity button, expected: task drawer opens}
*
* @TEST_CONTRACT Component_TopNavbar ->
* {
* required_props: {},
* optional_props: {},
* invariants: [
* "Displays user menu and handles logical toggling",
* "Initiates global search successfully taking debounce into account",
* "Correctly handles activity notification badge visibility"
* ]
* }
* @TEST_FIXTURE logged_in -> {"user": {"username": "admin"}}
* @TEST_EDGE network_down -> search fetch fails, handles error state
* @TEST_INVARIANT ui_consistency -> verifies: [logged_in]
*/
import { createEventDispatcher, onMount } from "svelte";
@@ -157,7 +171,8 @@
const tasks = (tasksResponse || []).slice(0, 30);
const taskItems = tasks
.filter((task) => {
const haystack = `${task?.id || ""} ${task?.plugin_id || ""} ${task?.status || ""}`.toLowerCase();
const haystack =
`${task?.id || ""} ${task?.plugin_id || ""} ${task?.status || ""}`.toLowerCase();
return q && haystack.includes(q);
})
.slice(0, SEARCH_LIMIT)
@@ -219,8 +234,12 @@
isSearchLoading = true;
showSearchDropdown = true;
try {
const [dashboardResponse, datasetResponse, tasksResponse, reportsResponse] =
await Promise.all([
const [
dashboardResponse,
datasetResponse,
tasksResponse,
reportsResponse,
] = await Promise.all([
api.getDashboards(globalSelectedEnvId, {
search: normalizedQuery,
page: 1,
@@ -248,7 +267,10 @@
normalizedQuery,
);
} catch (error) {
console.error("[TopNavbar][Coherence:Failed] Global search failed", error);
console.error(
"[TopNavbar][Coherence:Failed] Global search failed",
error,
);
groupedSearchResults = [];
} finally {
isSearchLoading = false;
@@ -318,7 +340,7 @@
<button
class="rounded-lg p-2 text-slate-600 transition-colors hover:bg-slate-100 md:hidden"
on:click={handleHamburgerClick}
aria-label={$t.common?.toggle_menu }
aria-label={$t.common?.toggle_menu}
>
<Icon name="menu" size={22} />
</button>
@@ -328,35 +350,47 @@
href="/"
class="flex items-center text-xl font-bold text-slate-800 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">
<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>{$t.common?.brand}</span>
</a>
</div>
<!-- Global search -->
<div class="global-search-container relative flex-1 max-w-xl mx-4 hidden md:block">
<div
class="global-search-container relative flex-1 max-w-xl mx-4 hidden md:block"
>
<input
type="text"
class="w-full px-4 py-2 bg-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-ring transition-all
{isSearchFocused ? 'bg-white border border-primary-ring' : ''}"
placeholder={$t.common.search }
placeholder={$t.common.search}
value={searchQuery}
on:input={handleSearchInput}
on:focus={handleSearchFocus}
on:keydown={handleSearchKeydown}
/>
{#if showSearchDropdown}
<div class="absolute left-0 right-0 top-12 z-50 rounded-lg border border-slate-200 bg-white shadow-lg">
<div
class="absolute left-0 right-0 top-12 z-50 rounded-lg border border-slate-200 bg-white shadow-lg"
>
{#if isSearchLoading}
<div class="px-4 py-3 text-sm text-slate-500">{$t.common?.loading || "Loading..."}</div>
<div class="px-4 py-3 text-sm text-slate-500">
{$t.common?.loading || "Loading..."}
</div>
{:else if groupedSearchResults.length === 0}
<div class="px-4 py-3 text-sm text-slate-500">{$t.common?.not_found || "No results found"}</div>
<div class="px-4 py-3 text-sm text-slate-500">
{$t.common?.not_found || "No results found"}
</div>
{:else}
{#each groupedSearchResults as section}
<div class="border-b border-slate-100 last:border-b-0">
<div class="px-4 py-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
<div
class="px-4 py-2 text-xs font-semibold uppercase tracking-wide text-slate-500"
>
{section.label}
</div>
{#each section.items as result}
@@ -365,8 +399,12 @@
on:click={() => openSearchResult(result)}
>
<div class="min-w-0">
<div class="truncate text-sm font-medium text-slate-800">{result.title}</div>
<div class="truncate text-xs text-slate-500">{result.subtitle}</div>
<div class="truncate text-sm font-medium text-slate-800">
{result.title}
</div>
<div class="truncate text-xs text-slate-500">
{result.subtitle}
</div>
</div>
</button>
{/each}
@@ -384,8 +422,8 @@
<select
class="h-9 rounded-lg border px-3 text-sm font-medium focus:outline-none focus:ring-2
{isProdContext
? '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'}"
? '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}
aria-label={$t.dashboard?.environment || "Environment"}
@@ -398,7 +436,9 @@
{/each}
</select>
{#if isProdContext}
<span class="rounded-md bg-red-600 px-2 py-1 text-xs font-bold text-white">
<span
class="rounded-md bg-red-600 px-2 py-1 text-xs font-bold text-white"
>
PROD
</span>
{/if}
@@ -411,8 +451,8 @@
<button
class="rounded-lg p-2 text-slate-600 transition-colors hover:bg-slate-100"
on:click={handleAssistantClick}
aria-label={$t.assistant?.open }
title={$t.assistant?.title }
aria-label={$t.assistant?.open}
title={$t.assistant?.title}
>
<Icon name="clipboard" size={22} />
</button>
@@ -425,7 +465,7 @@
(e.key === "Enter" || e.key === " ") && handleActivityClick()}
role="button"
tabindex="0"
aria-label={$t.common?.activity }
aria-label={$t.common?.activity}
>
<Icon name="activity" size={22} />
{#if activeCount > 0}
@@ -445,7 +485,7 @@
(e.key === "Enter" || e.key === " ") && toggleUserMenu(e)}
role="button"
tabindex="0"
aria-label={$t.common?.user_menu }
aria-label={$t.common?.user_menu}
>
{#if user}
<span
@@ -463,7 +503,7 @@
: 'hidden'}"
>
<div class="px-4 py-2 text-sm text-gray-700">
<strong>{user?.username || ($t.common?.user )}</strong>
<strong>{user?.username || $t.common?.user}</strong>
</div>
<div class="border-t border-gray-200 my-1"></div>
<div
@@ -477,7 +517,7 @@
role="button"
tabindex="0"
>
{$t.nav?.settings }
{$t.nav?.settings}
</div>
<div
class="px-4 py-2 text-sm text-destructive hover:bg-destructive-light cursor-pointer"
@@ -487,7 +527,7 @@
role="button"
tabindex="0"
>
{$t.common?.logout }
{$t.common?.logout}
</div>
</div>
</div>

View File

@@ -11,6 +11,21 @@
*
* @UX_STATE: Ready -> Card displays summary/status/type.
* @UX_RECOVERY: Missing fields are rendered with explicit placeholder text.
*
* @TEST_CONTRACT Component_ReportCard ->
* {
* required_props: {report: Object},
* optional_props: {selected: boolean, onselect: function},
* invariants: [
* "Renders properly even if report fields are missing",
* "Fires select event when clicked with the report payload",
* "Applies selected styling when selected prop is true"
* ]
* }
* @TEST_FIXTURE valid_report_card -> {"report": {"task_type": "migration", "status": "success", "summary": "Test", "updated_at": "2024-01-01"}}
* @TEST_EDGE empty_report_object -> {"report": {}}
* @TEST_EDGE random_status -> {"report": {"status": "unknown_status_code"}}
* @TEST_INVARIANT render_resilience -> verifies: [valid_report_card, empty_report_object, random_status]
*/
import { createEventDispatcher } from "svelte";

View File

@@ -10,14 +10,29 @@
*
* @UX_STATE: Ready -> Report detail content visible.
* @UX_RECOVERY: Failed/partial report shows next actions and placeholder-safe diagnostics.
*
* @TEST_CONTRACT Component_ReportDetailPanel ->
* {
* required_props: {},
* optional_props: {detail: Object},
* invariants: [
* "Renders properly even if detail or nested report object is null",
* "Displays placeholders for missing data fields",
* "Renders next_actions list if available either at root or inside report.error_context"
* ]
* }
* @TEST_FIXTURE valid_detail -> {"detail": {"report": {"report_id": "1", "task_type": "migration", "status": "success", "summary": "Done"}, "diagnostics": {"time": 123}, "next_actions": []}}
* @TEST_EDGE no_detail_prop -> {"detail": null}
* @TEST_EDGE detail_with_error_context -> {"detail": {"report": {"error_context": {"next_actions": ["Retry"]}}}}
* @TEST_INVARIANT render_resilience -> verifies: [valid_detail, no_detail_prop, detail_with_error_context]
*/
import { t } from '$lib/i18n';
import { t } from "$lib/i18n";
let { detail = null } = $props();
function notProvided(value) {
if (value === null || value === undefined || value === '') {
if (value === null || value === undefined || value === "") {
return $t.reports?.not_provided;
}
return value;
@@ -32,29 +47,55 @@
</script>
<div class="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<h3 class="mb-3 text-sm font-semibold text-slate-700">{$t.reports?.view_details}</h3>
<h3 class="mb-3 text-sm font-semibold text-slate-700">
{$t.reports?.view_details}
</h3>
{#if !detail || !detail.report}
<p class="text-sm text-slate-500">{$t.reports?.not_provided}</p>
{:else}
<div class="space-y-2 text-sm text-slate-700">
<p><span class="text-slate-500">{$t.reports?.id}:</span> {notProvided(detail.report.report_id)}</p>
<p><span class="text-slate-500">{$t.reports?.type}:</span> {notProvided(detail.report.task_type)}</p>
<p><span class="text-slate-500">{$t.reports?.status}:</span> {notProvided(detail.report.status)}</p>
<p><span class="text-slate-500">{$t.reports?.summary}:</span> {notProvided(detail.report.summary)}</p>
<p><span class="text-slate-500">{$t.reports?.updated}:</span> {formatDate(detail.report.updated_at)}</p>
<p>
<span class="text-slate-500">{$t.reports?.id}:</span>
{notProvided(detail.report.report_id)}
</p>
<p>
<span class="text-slate-500">{$t.reports?.type}:</span>
{notProvided(detail.report.task_type)}
</p>
<p>
<span class="text-slate-500">{$t.reports?.status}:</span>
{notProvided(detail.report.status)}
</p>
<p>
<span class="text-slate-500">{$t.reports?.summary}:</span>
{notProvided(detail.report.summary)}
</p>
<p>
<span class="text-slate-500">{$t.reports?.updated}:</span>
{formatDate(detail.report.updated_at)}
</p>
</div>
<div class="mt-4">
<p class="mb-1 text-xs font-semibold uppercase text-slate-500">{$t.reports?.diagnostics}</p>
<pre class="max-h-48 overflow-auto rounded-lg border border-slate-200 bg-slate-50 p-2 text-xs text-slate-700">{JSON.stringify(detail.diagnostics || { note: $t.reports?.not_provided }, null, 2)}</pre>
<p class="mb-1 text-xs font-semibold uppercase text-slate-500">
{$t.reports?.diagnostics}
</p>
<pre
class="max-h-48 overflow-auto rounded-lg border border-slate-200 bg-slate-50 p-2 text-xs text-slate-700">{JSON.stringify(
detail.diagnostics || { note: $t.reports?.not_provided },
null,
2,
)}</pre>
</div>
{#if (detail.next_actions && detail.next_actions.length > 0) || (detail.report.error_context && detail.report.error_context.next_actions && detail.report.error_context.next_actions.length > 0)}
<div class="mt-4">
<p class="mb-1 text-xs font-semibold uppercase text-slate-500">{$t.reports?.next_actions}</p>
<p class="mb-1 text-xs font-semibold uppercase text-slate-500">
{$t.reports?.next_actions}
</p>
<ul class="list-disc space-y-1 pl-5 text-sm text-slate-700">
{#each (detail.next_actions && detail.next_actions.length > 0 ? detail.next_actions : detail.report.error_context.next_actions) as action}
{#each detail.next_actions && detail.next_actions.length > 0 ? detail.next_actions : detail.report.error_context.next_actions as action}
<li>{action}</li>
{/each}
</ul>

View File

@@ -11,16 +11,30 @@
* @UX_STATE: Ready -> Mixed-type list visible and scannable.
* @UX_FEEDBACK: Click on report emits select event.
* @UX_RECOVERY: Unknown/missing values rendered with explicit placeholders.
*
* @TEST_CONTRACT Component_ReportsList ->
* {
* required_props: {},
* optional_props: {reports: Array, selectedReportId: string},
* invariants: [
* "Iterates over reports and renders ReportCard for each",
* "Passes down selected prop based on selectedReportId",
* "Forwards select events from children"
* ]
* }
* @TEST_FIXTURE renders_list -> {"reports": [{"report_id": "1", "task_type": "migration", "status": "success"}, {"report_id": "2", "task_type": "backup", "status": "failed"}], "selectedReportId": "2"}
* @TEST_EDGE empty_list -> {"reports": []}
* @TEST_INVARIANT correct_iteration -> verifies: [renders_list, empty_list]
*/
import { createEventDispatcher } from 'svelte';
import ReportCard from './ReportCard.svelte';
import { createEventDispatcher } from "svelte";
import ReportCard from "./ReportCard.svelte";
let { reports = [], selectedReportId = null } = $props();
const dispatch = createEventDispatcher();
function handleSelect(event) {
dispatch('select', { report: event.detail.report });
dispatch("select", { report: event.detail.report });
}
</script>
@@ -34,4 +48,4 @@
{/each}
</div>
<!-- [/DEF:ReportsList:Component] -->
<!-- [/DEF:ReportsList:Component] -->

View File

@@ -50,6 +50,18 @@ export const REPORT_TYPE_PROFILES = {
// @PURPOSE: Resolve visual profile by task type with guaranteed fallback.
// @PRE: taskType may be known/unknown/empty.
// @POST: Returns one profile object.
//
// @TEST_CONTRACT: GetReportTypeProfileModel ->
// {
// required_fields: {taskType: string},
// invariants: [
// "Returns correct profile for known taskType",
// "Returns 'unknown' profile for invalid or missing taskType"
// ]
// }
// @TEST_FIXTURE: valid_type -> {"taskType": "migration"}
// @TEST_EDGE: invalid_type -> {"taskType": "invalid"}
// @TEST_INVARIANT: fallbacks_to_unknown -> verifies: [invalid_type]
export function getReportTypeProfile(taskType) {
const key = typeof taskType === 'string' ? taskType : 'unknown';
console.log("[reports][ui][getReportTypeProfile][STATE:START]");

View File

@@ -7,6 +7,19 @@
// @UX_STATE: Closed -> Drawer hidden, no active task
// @UX_STATE: Open -> Drawer visible, logs streaming
// @UX_STATE: InputRequired -> Interactive form rendered in drawer
//
// @TEST_CONTRACT: TaskDrawerStore ->
// {
// required_fields: {isOpen: boolean, activeTaskId: string|null, resourceTaskMap: Object},
// invariants: [
// "Updates isOpen and activeTaskId properly on openDrawerForTask",
// "Updates isOpen and activeTaskId=null on openDrawer",
// "Properly sets isOpen=false on closeDrawer",
// "Maintains mapping in resourceTaskMap correctly via updateResourceTask"
// ]
// }
// @TEST_FIXTURE: valid_store_state -> {"isOpen": true, "activeTaskId": "test_1", "resourceTaskMap": {"res1": {"taskId": "test_1", "status": "RUNNING"}}}
// @TEST_INVARIANT: state_management -> verifies: [valid_store_state]
import { writable, derived } from 'svelte/store';