test contracts
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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] -->
|
||||
|
||||
@@ -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]");
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user