css refactor

This commit is contained in:
2026-02-19 18:24:36 +03:00
parent 4de5b22d57
commit fdcbe32dfa
45 changed files with 1798 additions and 1857 deletions

View File

@@ -11,19 +11,18 @@
<script lang="ts"> <script lang="ts">
// [SECTION: IMPORTS] // [SECTION: IMPORTS]
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from "svelte";
import type { DashboardMetadata } from '../types/dashboard'; import type { DashboardMetadata } from "../types/dashboard";
import { t } from '../lib/i18n'; import { t } from "../lib/i18n";
import { Button, Input } from '../lib/ui'; import { Button, Input } from "../lib/ui";
import GitManager from './git/GitManager.svelte'; import GitManager from "./git/GitManager.svelte";
import { api } from '../lib/api'; import { api } from "../lib/api";
import { addToast as toast } from '../lib/toasts.js'; import { addToast as toast } from "../lib/toasts.js";
// [/SECTION] // [/SECTION]
// [SECTION: PROPS] // [SECTION: PROPS]
export let dashboards: DashboardMetadata[] = []; let { dashboards = [], selectedIds = [], environmentId = "ss1" } = $props();
export let selectedIds: number[] = [];
export let environmentId: string = "ss1";
// [/SECTION] // [/SECTION]
// [SECTION: STATE] // [SECTION: STATE]
@@ -59,26 +58,29 @@
// Or we pick the first active one. // Or we pick the first active one.
// Fetch active provider first // Fetch active provider first
const providers = await api.fetchApi('/llm/providers'); const providers = await api.fetchApi("/llm/providers");
const activeProvider = providers.find((p: any) => p.is_active); const activeProvider = providers.find((p: any) => p.is_active);
if (!activeProvider) { if (!activeProvider) {
toast('No active LLM provider found. Please configure one in settings.', 'error'); toast(
"No active LLM provider found. Please configure one in settings.",
"error",
);
return; return;
} }
await api.postApi('/tasks', { await api.postApi("/tasks", {
plugin_id: 'llm_dashboard_validation', plugin_id: "llm_dashboard_validation",
params: { params: {
dashboard_id: dashboard.id.toString(), dashboard_id: dashboard.id.toString(),
environment_id: environmentId, environment_id: environmentId,
provider_id: activeProvider.id provider_id: activeProvider.id,
} },
}); });
toast('Validation task started', 'success'); toast("Validation task started", "success");
} catch (e: any) { } catch (e: any) {
toast(e.message || 'Validation failed to start', 'error'); toast(e.message || "Validation failed to start", "error");
} finally { } finally {
validatingIds.delete(dashboard.id); validatingIds.delete(dashboard.id);
validatingIds = validatingIds; validatingIds = validatingIds;
@@ -87,11 +89,14 @@
// [/DEF:handleValidate:Function] // [/DEF:handleValidate:Function]
// [SECTION: DERIVED] // [SECTION: DERIVED]
$: filteredDashboards = dashboards.filter(d => let filteredDashboards = $derived(
d.title.toLowerCase().includes(filterText.toLowerCase()) dashboards.filter((d) =>
d.title.toLowerCase().includes(filterText.toLowerCase()),
),
); );
$: sortedDashboards = [...filteredDashboards].sort((a, b) => { let sortedDashboards = $derived(
[...filteredDashboards].sort((a, b) => {
let aVal = a[sortColumn]; let aVal = a[sortColumn];
let bVal = b[sortColumn]; let bVal = b[sortColumn];
if (sortColumn === "id") { if (sortColumn === "id") {
@@ -101,17 +106,25 @@
if (aVal < bVal) return sortDirection === "asc" ? -1 : 1; if (aVal < bVal) return sortDirection === "asc" ? -1 : 1;
if (aVal > bVal) return sortDirection === "asc" ? 1 : -1; if (aVal > bVal) return sortDirection === "asc" ? 1 : -1;
return 0; return 0;
}); }),
$: paginatedDashboards = sortedDashboards.slice(
currentPage * pageSize,
(currentPage + 1) * pageSize
); );
$: totalPages = Math.ceil(sortedDashboards.length / pageSize); let paginatedDashboards = $derived(
sortedDashboards.slice(
currentPage * pageSize,
(currentPage + 1) * pageSize,
),
);
$: allSelected = paginatedDashboards.length > 0 && paginatedDashboards.every(d => selectedIds.includes(d.id)); let totalPages = $derived(Math.ceil(sortedDashboards.length / pageSize));
$: someSelected = paginatedDashboards.some(d => selectedIds.includes(d.id));
let allSelected = $derived(
paginatedDashboards.length > 0 &&
paginatedDashboards.every((d) => selectedIds.includes(d.id)),
);
let someSelected = $derived(
paginatedDashboards.some((d) => selectedIds.includes(d.id)),
);
// [/SECTION] // [/SECTION]
// [SECTION: EVENTS] // [SECTION: EVENTS]
@@ -141,10 +154,10 @@
if (checked) { if (checked) {
if (!newSelected.includes(id)) newSelected.push(id); if (!newSelected.includes(id)) newSelected.push(id);
} else { } else {
newSelected = newSelected.filter(sid => sid !== id); newSelected = newSelected.filter((sid) => sid !== id);
} }
selectedIds = newSelected; selectedIds = newSelected;
dispatch('selectionChanged', newSelected); dispatch("selectionChanged", newSelected);
} }
// [/DEF:handleSelectionChange:Function] // [/DEF:handleSelectionChange:Function]
@@ -155,16 +168,16 @@
function handleSelectAll(checked: boolean) { function handleSelectAll(checked: boolean) {
let newSelected = [...selectedIds]; let newSelected = [...selectedIds];
if (checked) { if (checked) {
paginatedDashboards.forEach(d => { paginatedDashboards.forEach((d) => {
if (!newSelected.includes(d.id)) newSelected.push(d.id); if (!newSelected.includes(d.id)) newSelected.push(d.id);
}); });
} else { } else {
paginatedDashboards.forEach(d => { paginatedDashboards.forEach((d) => {
newSelected = newSelected.filter(sid => sid !== d.id); newSelected = newSelected.filter((sid) => sid !== d.id);
}); });
} }
selectedIds = newSelected; selectedIds = newSelected;
dispatch('selectionChanged', newSelected); dispatch("selectionChanged", newSelected);
} }
// [/DEF:handleSelectAll:Function] // [/DEF:handleSelectAll:Function]
@@ -189,17 +202,13 @@
showGitManager = true; showGitManager = true;
} }
// [/DEF:openGit:Function] // [/DEF:openGit:Function]
</script> </script>
<!-- [SECTION: TEMPLATE] --> <!-- [SECTION: TEMPLATE] -->
<div class="dashboard-grid"> <div class="dashboard-grid">
<!-- Filter Input --> <!-- Filter Input -->
<div class="mb-6"> <div class="mb-6">
<Input <Input bind:value={filterText} placeholder={$t.dashboard.search} />
bind:value={filterText}
placeholder={$t.dashboard.search}
/>
</div> </div>
<!-- Grid/Table --> <!-- Grid/Table -->
@@ -212,21 +221,52 @@
type="checkbox" type="checkbox"
checked={allSelected} checked={allSelected}
indeterminate={someSelected && !allSelected} indeterminate={someSelected && !allSelected}
on:change={(e) => handleSelectAll((e.target as HTMLInputElement).checked)} on:change={(e) =>
handleSelectAll((e.target as HTMLInputElement).checked)}
class="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" class="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/> />
</th> </th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700 transition-colors" on:click={() => handleSort('title')}> <th
{$t.dashboard.title} {sortColumn === 'title' ? (sortDirection === 'asc' ? '↑' : '↓') : ''} class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700 transition-colors"
on:click={() => handleSort("title")}
>
{$t.dashboard.title}
{sortColumn === "title"
? sortDirection === "asc"
? "↑"
: "↓"
: ""}
</th> </th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700 transition-colors" on:click={() => handleSort('last_modified')}> <th
{$t.dashboard.last_modified} {sortColumn === 'last_modified' ? (sortDirection === 'asc' ? '↑' : '↓') : ''} class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700 transition-colors"
on:click={() => handleSort("last_modified")}
>
{$t.dashboard.last_modified}
{sortColumn === "last_modified"
? sortDirection === "asc"
? "↑"
: "↓"
: ""}
</th> </th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700 transition-colors" on:click={() => handleSort('status')}> <th
{$t.dashboard.status} {sortColumn === 'status' ? (sortDirection === 'asc' ? '↑' : '↓') : ''} class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700 transition-colors"
on:click={() => handleSort("status")}
>
{$t.dashboard.status}
{sortColumn === "status"
? sortDirection === "asc"
? "↑"
: "↓"
: ""}
</th> </th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.dashboard.validation}</th> <th
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.dashboard.git}</th> class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>{$t.dashboard.validation}</th
>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>{$t.dashboard.git}</th
>
</tr> </tr>
</thead> </thead>
<tbody class="bg-white divide-y divide-gray-200"> <tbody class="bg-white divide-y divide-gray-200">
@@ -236,14 +276,28 @@
<input <input
type="checkbox" type="checkbox"
checked={selectedIds.includes(dashboard.id)} checked={selectedIds.includes(dashboard.id)}
on:change={(e) => handleSelectionChange(dashboard.id, (e.target as HTMLInputElement).checked)} on:change={(e) =>
handleSelectionChange(
dashboard.id,
(e.target as HTMLInputElement).checked,
)}
class="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" class="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/> />
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{dashboard.title}</td> <td
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{new Date(dashboard.last_modified).toLocaleDateString()}</td> class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900"
>{dashboard.title}</td
>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"
>{new Date(dashboard.last_modified).toLocaleDateString()}</td
>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<span class="px-2 py-1 text-xs font-medium rounded-full {dashboard.status === 'published' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}"> <span
class="px-2 py-1 text-xs font-medium rounded-full {dashboard.status ===
'published'
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'}"
>
{dashboard.status} {dashboard.status}
</span> </span>
</td> </td>
@@ -255,7 +309,7 @@
disabled={validatingIds.has(dashboard.id)} disabled={validatingIds.has(dashboard.id)}
class="text-purple-600 hover:text-purple-900" class="text-purple-600 hover:text-purple-900"
> >
{validatingIds.has(dashboard.id) ? 'Validating...' : 'Validate'} {validatingIds.has(dashboard.id) ? "Validating..." : "Validate"}
</Button> </Button>
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium"> <td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
@@ -278,9 +332,15 @@
<div class="flex items-center justify-between mt-6"> <div class="flex items-center justify-between mt-6">
<div class="text-sm text-gray-500"> <div class="text-sm text-gray-500">
{($t.dashboard?.showing || "") {($t.dashboard?.showing || "")
.replace('{start}', (currentPage * pageSize + 1).toString()) .replace("{start}", (currentPage * pageSize + 1).toString())
.replace('{end}', Math.min((currentPage + 1) * pageSize, sortedDashboards.length).toString()) .replace(
.replace('{total}', sortedDashboards.length.toString())} "{end}",
Math.min(
(currentPage + 1) * pageSize,
sortedDashboards.length,
).toString(),
)
.replace("{total}", sortedDashboards.length.toString())}
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<Button <Button
@@ -313,8 +373,4 @@
<!-- [/SECTION] --> <!-- [/SECTION] -->
<style>
/* Component styles */
</style>
<!-- [/DEF:DashboardGrid:Component] --> <!-- [/DEF:DashboardGrid:Component] -->

View File

@@ -15,7 +15,10 @@
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
// [/SECTION] // [/SECTION]
export let schema; let {
schema,
} = $props();
let formData = {}; let formData = {};
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();

View File

@@ -14,9 +14,12 @@
// [/SECTION] // [/SECTION]
// [SECTION: PROPS] // [SECTION: PROPS]
export let label: string = "Select Environment"; let {
export let selectedId: string = ""; label = "",
export let environments: Array<{id: string, name: string, url: string}> = []; selectedId = "",
environments = [],
} = $props();
// [/SECTION] // [/SECTION]
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@@ -53,8 +56,5 @@
</div> </div>
<!-- [/SECTION] --> <!-- [/SECTION] -->
<style>
/* Component specific styles */
</style>
<!-- [/DEF:EnvSelector:Component] --> <!-- [/DEF:EnvSelector:Component] -->

View File

@@ -14,10 +14,13 @@
// [/SECTION] // [/SECTION]
// [SECTION: PROPS] // [SECTION: PROPS]
export let sourceDatabases: Array<{uuid: string, database_name: string, engine?: string}> = []; let {
export let targetDatabases: Array<{uuid: string, database_name: string}> = []; sourceDatabases = [],
export let mappings: Array<{source_db_uuid: string, target_db_uuid: string}> = []; targetDatabases = [],
export let suggestions: Array<{source_db_uuid: string, target_db_uuid: string, confidence: number}> = []; mappings = [],
suggestions = [],
} = $props();
// [/SECTION] // [/SECTION]
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@@ -100,8 +103,5 @@
</div> </div>
<!-- [/SECTION] --> <!-- [/SECTION] -->
<style>
/* Component specific styles */
</style>
<!-- [/DEF:MappingTable:Component] --> <!-- [/DEF:MappingTable:Component] -->

View File

@@ -14,10 +14,13 @@
// [/SECTION] // [/SECTION]
// [SECTION: PROPS] // [SECTION: PROPS]
export let show: boolean = false; let {
export let sourceDbName: string = ""; show = false,
export let sourceDbUuid: string = ""; sourceDbName = "",
export let targetDatabases: Array<{uuid: string, database_name: string}> = []; sourceDbUuid = "",
targetDatabases = [],
} = $props();
// [/SECTION] // [/SECTION]
let selectedTargetUuid = ""; let selectedTargetUuid = "";
@@ -111,8 +114,5 @@
{/if} {/if}
<!-- [/SECTION] --> <!-- [/SECTION] -->
<style>
/* Modal specific styles */
</style>
<!-- [/DEF:MissingMappingModal:Component] --> <!-- [/DEF:MissingMappingModal:Component] -->

View File

@@ -7,11 +7,9 @@
@RELATION: EMITS -> resume, cancel @RELATION: EMITS -> resume, cancel
--> -->
<script> <script>
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from "svelte";
export let show = false; let { show = false, databases = [], errorMessage = "" } = $props();
export let databases = []; // List of database names requiring passwords
export let errorMessage = "";
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@@ -26,14 +24,14 @@
if (submitting) return; if (submitting) return;
// Validate all passwords entered // Validate all passwords entered
const missing = databases.filter(db => !passwords[db]); const missing = databases.filter((db) => !passwords[db]);
if (missing.length > 0) { if (missing.length > 0) {
alert(`Please enter passwords for: ${missing.join(', ')}`); alert(`Please enter passwords for: ${missing.join(", ")}`);
return; return;
} }
submitting = true; submitting = true;
dispatch('resume', { passwords }); dispatch("resume", { passwords });
// Reset submitting state is handled by parent or on close // Reset submitting state is handled by parent or on close
} }
// [/DEF:handleSubmit:Function] // [/DEF:handleSubmit:Function]
@@ -43,54 +41,100 @@
// @PRE: Modal is open. // @PRE: Modal is open.
// @POST: 'cancel' event is dispatched and show is set to false. // @POST: 'cancel' event is dispatched and show is set to false.
function handleCancel() { function handleCancel() {
dispatch('cancel'); dispatch("cancel");
show = false; show = false;
} }
// [/DEF:handleCancel:Function] // [/DEF:handleCancel:Function]
// Reset passwords when modal opens/closes // Reset passwords when modal opens/closes
$: if (!show) { $effect(() => {
if (!show) {
passwords = {}; passwords = {};
submitting = false; submitting = false;
} }
});
</script> </script>
{#if show} {#if show}
<div class="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true"> <div
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"> class="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true"
>
<div
class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"
>
<!-- Background overlay --> <!-- Background overlay -->
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true" on:click={handleCancel}></div> <div
class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
aria-hidden="true"
on:click={handleCancel}
></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span> <span
class="hidden sm:inline-block sm:align-middle sm:h-screen"
aria-hidden="true">&#8203;</span
>
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"> <div
class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"
>
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"> <div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start"> <div class="sm:flex sm:items-start">
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10"> <div
class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10"
>
<!-- Heroicon name: outline/lock-closed --> <!-- Heroicon name: outline/lock-closed -->
<svg class="h-6 w-6 text-red-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /> class="h-6 w-6 text-red-600"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg> </svg>
</div> </div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full"> <div
<h3 class="text-lg leading-6 font-medium text-gray-900" id="modal-title"> class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full"
>
<h3
class="text-lg leading-6 font-medium text-gray-900"
id="modal-title"
>
Database Password Required Database Password Required
</h3> </h3>
<div class="mt-2"> <div class="mt-2">
<p class="text-sm text-gray-500 mb-4"> <p class="text-sm text-gray-500 mb-4">
The migration process requires passwords for the following databases to proceed. The migration process requires passwords for
the following databases to proceed.
</p> </p>
{#if errorMessage} {#if errorMessage}
<div class="mb-4 p-2 bg-red-50 text-red-700 text-xs rounded border border-red-200"> <div
class="mb-4 p-2 bg-red-50 text-red-700 text-xs rounded border border-red-200"
>
Error: {errorMessage} Error: {errorMessage}
</div> </div>
{/if} {/if}
<form on:submit|preventDefault={handleSubmit} class="space-y-4"> <form
on:submit|preventDefault={handleSubmit}
class="space-y-4"
>
{#each databases as dbName} {#each databases as dbName}
<div> <div>
<label for="password-{dbName}" class="block text-sm font-medium text-gray-700"> <label
for="password-{dbName}"
class="block text-sm font-medium text-gray-700"
>
Password for {dbName} Password for {dbName}
</label> </label>
<input <input
@@ -108,14 +152,16 @@
</div> </div>
</div> </div>
</div> </div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse"> <div
class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse"
>
<button <button
type="button" type="button"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50"
on:click={handleSubmit} on:click={handleSubmit}
disabled={submitting} disabled={submitting}
> >
{submitting ? 'Resuming...' : 'Resume Migration'} {submitting ? "Resuming..." : "Resume Migration"}
</button> </button>
<button <button
type="button" type="button"

View File

@@ -11,8 +11,11 @@
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { t } from '../lib/i18n'; import { t } from '../lib/i18n';
export let tasks: Array<any> = []; let {
export let loading: boolean = false; tasks = [],
loading = false,
} = $props();
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();

View File

@@ -23,25 +23,27 @@
import { t } from "../lib/i18n"; import { t } from "../lib/i18n";
import TaskLogPanel from "./tasks/TaskLogPanel.svelte"; import TaskLogPanel from "./tasks/TaskLogPanel.svelte";
export let show = false; let {
export let inline = false; show = $bindable(false),
export let taskId = null; inline = false,
export let taskStatus = null; taskId = null,
export let realTimeLogs = []; taskStatus = null,
realTimeLogs = [],
} = $props();
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
let logs = []; let logs = $state([]);
let loading = false; let loading = $state(false);
let error = ""; let error = $state("");
let interval; let interval;
let autoScroll = true; let autoScroll = $state(true);
$: shouldShow = inline || show; let shouldShow = $derived(inline || show);
// [DEF:handleRealTimeLogs:Action] // [DEF:handleRealTimeLogs:Action]
/** @PURPOSE Append real-time logs as they arrive from WebSocket, preventing duplicates */ $effect(() => {
$: if (realTimeLogs && realTimeLogs.length > 0) { if (realTimeLogs && realTimeLogs.length > 0) {
const lastLog = realTimeLogs[realTimeLogs.length - 1]; const lastLog = realTimeLogs[realTimeLogs.length - 1];
const exists = logs.some( const exists = logs.some(
(l) => (l) =>
@@ -50,33 +52,18 @@
); );
if (!exists) { if (!exists) {
logs = [...logs, lastLog]; logs = [...logs, lastLog];
console.log(
`[TaskLogViewer][Action] Appended real-time log, total=${logs.length}`,
);
} }
} }
});
// [/DEF:handleRealTimeLogs:Action] // [/DEF:handleRealTimeLogs:Action]
// [DEF:fetchLogs:Function] // [DEF:fetchLogs:Function]
/**
* @PURPOSE Fetches logs for the current task from API (polling fallback).
* @PRE taskId must be set.
* @POST logs array is updated with data from taskService.
* @SIDE_EFFECT Updates logs, loading, and error state.
*/
async function fetchLogs() { async function fetchLogs() {
if (!taskId) return; if (!taskId) return;
console.log(`[TaskLogViewer][Action] Fetching logs for task=${taskId}`);
try { try {
logs = await getTaskLogs(taskId); logs = await getTaskLogs(taskId);
console.log(
`[TaskLogViewer][Coherence:OK] Logs fetched count=${logs.length}`,
);
} catch (e) { } catch (e) {
error = e.message; error = e.message;
console.error(
`[TaskLogViewer][Coherence:Failed] Error: ${e.message}`,
);
} finally { } finally {
loading = false; loading = false;
} }
@@ -85,18 +72,14 @@
function handleFilterChange(event) { function handleFilterChange(event) {
const { source, level } = event.detail; const { source, level } = event.detail;
console.log(
`[TaskLogViewer][Action] Filter changed: source=${source}, level=${level}`,
);
} }
function handleRefresh() { function handleRefresh() {
console.log(`[TaskLogViewer][Action] Manual refresh`);
fetchLogs(); fetchLogs();
} }
// React to changes in show/taskId/taskStatus $effect(() => {
$: if (shouldShow && taskId) { if (shouldShow && taskId) {
if (interval) clearInterval(interval); if (interval) clearInterval(interval);
logs = []; logs = [];
loading = true; loading = true;
@@ -113,6 +96,7 @@
} else { } else {
if (interval) clearInterval(interval); if (interval) clearInterval(interval);
} }
});
onDestroy(() => { onDestroy(() => {
if (interval) clearInterval(interval); if (interval) clearInterval(interval);
@@ -121,18 +105,25 @@
{#if shouldShow} {#if shouldShow}
{#if inline} {#if inline}
<div class="log-viewer-inline"> <div class="flex flex-col h-full w-full">
{#if loading && logs.length === 0} {#if loading && logs.length === 0}
<div class="loading-state"> <div
<div class="loading-spinner"></div> class="flex items-center justify-center gap-3 h-full text-terminal-text-subtle text-sm"
>
<div
class="w-5 h-5 border-2 border-terminal-border border-t-primary rounded-full animate-spin"
></div>
<span>{$t.tasks?.loading || "Loading logs..."}</span> <span>{$t.tasks?.loading || "Loading logs..."}</span>
</div> </div>
{:else if error} {:else if error}
<div class="error-state"> <div
<span class="error-icon"></span> class="flex items-center justify-center gap-2 h-full text-log-error text-sm"
>
<span class="text-xl"></span>
<span>{error}</span> <span>{error}</span>
<button class="retry-btn" on:click={handleRefresh} <button
>Retry</button class="bg-terminal-surface text-terminal-text-subtle border border-terminal-border rounded-md px-3 py-1 text-xs cursor-pointer transition-all hover:bg-terminal-border hover:text-terminal-text-bright"
on:click={handleRefresh}>Retry</button
> >
</div> </div>
{:else} {:else}
@@ -156,7 +147,7 @@
class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0" class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"
> >
<div <div
class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" class="fixed inset-0 bg-gray-500/75 transition-opacity"
aria-hidden="true" aria-hidden="true"
on:click={() => { on:click={() => {
show = false; show = false;
@@ -210,67 +201,3 @@
{/if} {/if}
<!-- [/DEF:TaskLogViewer:Component] --> <!-- [/DEF:TaskLogViewer:Component] -->
<style>
.log-viewer-inline {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
}
.loading-state {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
height: 100%;
color: #64748b;
font-size: 0.875rem;
}
.loading-spinner {
width: 1.25rem;
height: 1.25rem;
border: 2px solid #334155;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.error-state {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
height: 100%;
color: #f87171;
font-size: 0.875rem;
}
.error-icon {
font-size: 1.25rem;
}
.retry-btn {
background-color: #1e293b;
color: #94a3b8;
border: 1px solid #334155;
border-radius: 0.375rem;
padding: 0.25rem 0.75rem;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.15s;
}
.retry-btn:hover {
background-color: #334155;
color: #e2e8f0;
}
</style>

View File

@@ -19,7 +19,10 @@
* @type {Backup[]} * @type {Backup[]}
* @description Array of backup objects to display. * @description Array of backup objects to display.
*/ */
export let backups: Backup[] = []; let {
backups = [],
} = $props();
// [/SECTION] // [/SECTION]
</script> </script>
@@ -78,7 +81,5 @@
</div> </div>
<!-- [/SECTION] --> <!-- [/SECTION] -->
<style>
</style>
<!-- [/DEF:BackupList:Component] --> <!-- [/DEF:BackupList:Component] -->

View File

@@ -19,8 +19,11 @@
// [/SECTION] // [/SECTION]
// [SECTION: PROPS] // [SECTION: PROPS]
export let dashboardId; let {
export let currentBranch = 'main'; dashboardId,
currentBranch = 'main',
} = $props();
// [/SECTION] // [/SECTION]
// [SECTION: STATE] // [SECTION: STATE]

View File

@@ -16,7 +16,10 @@
// [/SECTION] // [/SECTION]
// [SECTION: PROPS] // [SECTION: PROPS]
export let dashboardId; let {
dashboardId,
} = $props();
// [/SECTION] // [/SECTION]
// [SECTION: STATE] // [SECTION: STATE]

View File

@@ -12,22 +12,22 @@
<script> <script>
// [SECTION: IMPORTS] // [SECTION: IMPORTS]
import { createEventDispatcher, onMount } from 'svelte'; import { createEventDispatcher, onMount } from "svelte";
import { gitService } from '../../services/gitService'; import { gitService } from "../../services/gitService";
import { addToast as toast } from '../../lib/toasts.js'; import { addToast as toast } from "../../lib/toasts.js";
import { api } from '../../lib/api'; import { api } from "../../lib/api";
// [/SECTION] // [/SECTION]
// [SECTION: PROPS] // [SECTION: PROPS]
export let dashboardId; let { dashboardId, show = false } = $props();
export let show = false;
// [/SECTION] // [/SECTION]
// [SECTION: STATE] // [SECTION: STATE]
let message = ''; let message = "";
let committing = false; let committing = false;
let status = null; let status = null;
let diff = ''; let diff = "";
let loading = false; let loading = false;
let generatingMessage = false; let generatingMessage = false;
// [/SECTION] // [/SECTION]
@@ -41,14 +41,18 @@
async function handleGenerateMessage() { async function handleGenerateMessage() {
generatingMessage = true; generatingMessage = true;
try { try {
console.log(`[CommitModal][Action] Generating commit message for dashboard ${dashboardId}`); console.log(
`[CommitModal][Action] Generating commit message for dashboard ${dashboardId}`,
);
// postApi returns the JSON data directly or throws an error // postApi returns the JSON data directly or throws an error
const data = await api.postApi(`/git/repositories/${dashboardId}/generate-message`); const data = await api.postApi(
`/git/repositories/${dashboardId}/generate-message`,
);
message = data.message; message = data.message;
toast('Commit message generated', 'success'); toast("Commit message generated", "success");
} catch (e) { } catch (e) {
console.error(`[CommitModal][Coherence:Failed] ${e.message}`); console.error(`[CommitModal][Coherence:Failed] ${e.message}`);
toast(e.message || 'Failed to generate message', 'error'); toast(e.message || "Failed to generate message", "error");
} finally { } finally {
generatingMessage = false; generatingMessage = false;
} }
@@ -64,20 +68,32 @@
if (!dashboardId || !show) return; if (!dashboardId || !show) return;
loading = true; loading = true;
try { try {
console.log(`[CommitModal][Action] Loading status and diff for ${dashboardId}`); console.log(
`[CommitModal][Action] Loading status and diff for ${dashboardId}`,
);
status = await gitService.getStatus(dashboardId); status = await gitService.getStatus(dashboardId);
// Fetch both unstaged and staged diffs to show complete picture // Fetch both unstaged and staged diffs to show complete picture
const unstagedDiff = await gitService.getDiff(dashboardId, null, false); const unstagedDiff = await gitService.getDiff(
const stagedDiff = await gitService.getDiff(dashboardId, null, true); dashboardId,
null,
false,
);
const stagedDiff = await gitService.getDiff(
dashboardId,
null,
true,
);
diff = ""; diff = "";
if (stagedDiff) diff += "--- STAGED CHANGES ---\n" + stagedDiff + "\n\n"; if (stagedDiff)
if (unstagedDiff) diff += "--- UNSTAGED CHANGES ---\n" + unstagedDiff; diff += "--- STAGED CHANGES ---\n" + stagedDiff + "\n\n";
if (unstagedDiff)
diff += "--- UNSTAGED CHANGES ---\n" + unstagedDiff;
if (!diff) diff = ""; if (!diff) diff = "";
} catch (e) { } catch (e) {
console.error(`[CommitModal][Coherence:Failed] ${e.message}`); console.error(`[CommitModal][Coherence:Failed] ${e.message}`);
toast('Failed to load changes', 'error'); toast("Failed to load changes", "error");
} finally { } finally {
loading = false; loading = false;
} }
@@ -92,31 +108,39 @@
*/ */
async function handleCommit() { async function handleCommit() {
if (!message) return; if (!message) return;
console.log(`[CommitModal][Action] Committing changes for dashboard ${dashboardId}`); console.log(
`[CommitModal][Action] Committing changes for dashboard ${dashboardId}`,
);
committing = true; committing = true;
try { try {
await gitService.commit(dashboardId, message, []); await gitService.commit(dashboardId, message, []);
toast('Changes committed successfully', 'success'); toast("Changes committed successfully", "success");
dispatch('commit'); dispatch("commit");
show = false; show = false;
message = ''; message = "";
console.log(`[CommitModal][Coherence:OK] Committed`); console.log(`[CommitModal][Coherence:OK] Committed`);
} catch (e) { } catch (e) {
console.error(`[CommitModal][Coherence:Failed] ${e.message}`); console.error(`[CommitModal][Coherence:Failed] ${e.message}`);
toast(e.message, 'error'); toast(e.message, "error");
} finally { } finally {
committing = false; committing = false;
} }
} }
// [/DEF:handleCommit:Function] // [/DEF:handleCommit:Function]
$: if (show) loadStatus(); $effect(() => {
if (show) loadStatus();
});
</script> </script>
<!-- [SECTION: TEMPLATE] --> <!-- [SECTION: TEMPLATE] -->
{#if show} {#if show}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> <div
<div class="bg-white p-6 rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] flex flex-col"> class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
>
<div
class="bg-white p-6 rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] flex flex-col"
>
<h2 class="text-xl font-bold mb-4">Commit Changes</h2> <h2 class="text-xl font-bold mb-4">Commit Changes</h2>
<div class="flex flex-col md:flex-row gap-4 flex-1 overflow-hidden"> <div class="flex flex-col md:flex-row gap-4 flex-1 overflow-hidden">
@@ -124,7 +148,10 @@
<div class="w-full md:w-1/3 flex flex-col"> <div class="w-full md:w-1/3 flex flex-col">
<div class="mb-4"> <div class="mb-4">
<div class="flex justify-between items-center mb-1"> <div class="flex justify-between items-center mb-1">
<label class="block text-sm font-medium text-gray-700">Commit Message</label> <label
class="block text-sm font-medium text-gray-700"
>Commit Message</label
>
<button <button
on:click={handleGenerateMessage} on:click={handleGenerateMessage}
disabled={generatingMessage || loading} disabled={generatingMessage || loading}
@@ -146,21 +173,37 @@
{#if status} {#if status}
<div class="flex-1 overflow-y-auto"> <div class="flex-1 overflow-y-auto">
<h3 class="text-sm font-bold text-gray-500 uppercase mb-2">Changed Files</h3> <h3
class="text-sm font-bold text-gray-500 uppercase mb-2"
>
Changed Files
</h3>
<ul class="text-xs space-y-1"> <ul class="text-xs space-y-1">
{#each status.staged_files as file} {#each status.staged_files as file}
<li class="text-green-600 flex items-center font-semibold" title="Staged"> <li
<span class="mr-2">S</span> {file} class="text-green-600 flex items-center font-semibold"
title="Staged"
>
<span class="mr-2">S</span>
{file}
</li> </li>
{/each} {/each}
{#each status.modified_files as file} {#each status.modified_files as file}
<li class="text-yellow-600 flex items-center" title="Modified (Unstaged)"> <li
<span class="mr-2">M</span> {file} class="text-yellow-600 flex items-center"
title="Modified (Unstaged)"
>
<span class="mr-2">M</span>
{file}
</li> </li>
{/each} {/each}
{#each status.untracked_files as file} {#each status.untracked_files as file}
<li class="text-blue-600 flex items-center" title="Untracked"> <li
<span class="mr-2">?</span> {file} class="text-blue-600 flex items-center"
title="Untracked"
>
<span class="mr-2">?</span>
{file}
</li> </li>
{/each} {/each}
</ul> </ul>
@@ -169,15 +212,30 @@
</div> </div>
<!-- Right: Diff Viewer --> <!-- Right: Diff Viewer -->
<div class="w-full md:w-2/3 flex flex-col overflow-hidden border rounded bg-gray-50"> <div
<div class="bg-gray-200 px-3 py-1 text-xs font-bold text-gray-600 border-b">Changes Preview</div> class="w-full md:w-2/3 flex flex-col overflow-hidden border rounded bg-gray-50"
>
<div
class="bg-gray-200 px-3 py-1 text-xs font-bold text-gray-600 border-b"
>
Changes Preview
</div>
<div class="flex-1 overflow-auto p-2"> <div class="flex-1 overflow-auto p-2">
{#if loading} {#if loading}
<div class="flex items-center justify-center h-full text-gray-500">Loading diff...</div> <div
class="flex items-center justify-center h-full text-gray-500"
>
Loading diff...
</div>
{:else if diff} {:else if diff}
<pre class="text-xs font-mono whitespace-pre-wrap">{diff}</pre> <pre
class="text-xs font-mono whitespace-pre-wrap">{diff}</pre>
{:else} {:else}
<div class="flex items-center justify-center h-full text-gray-500 italic">No changes detected</div> <div
class="flex items-center justify-center h-full text-gray-500 italic"
>
No changes detected
</div>
{/if} {/if}
</div> </div>
</div> </div>
@@ -185,17 +243,21 @@
<div class="flex justify-end space-x-3 mt-6 pt-4 border-t"> <div class="flex justify-end space-x-3 mt-6 pt-4 border-t">
<button <button
on:click={() => show = false} on:click={() => (show = false)}
class="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded" class="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded"
> >
Cancel Cancel
</button> </button>
<button <button
on:click={handleCommit} on:click={handleCommit}
disabled={committing || !message || loading || (!status?.is_dirty && status?.staged_files?.length === 0)} disabled={committing ||
!message ||
loading ||
(!status?.is_dirty &&
status?.staged_files?.length === 0)}
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
> >
{committing ? 'Committing...' : 'Commit'} {committing ? "Committing..." : "Commit"}
</button> </button>
</div> </div>
</div> </div>
@@ -203,10 +265,4 @@
{/if} {/if}
<!-- [/SECTION] --> <!-- [/SECTION] -->
<style>
pre {
tab-size: 4;
}
</style>
<!-- [/DEF:CommitModal:Component] --> <!-- [/DEF:CommitModal:Component] -->

View File

@@ -10,14 +10,17 @@
<script> <script>
// [SECTION: IMPORTS] // [SECTION: IMPORTS]
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from "svelte";
import { addToast as toast } from '../../lib/toasts.js'; import { addToast as toast } from "../../lib/toasts.js";
// [/SECTION] // [/SECTION]
// [SECTION: PROPS] // [SECTION: PROPS]
/** @type {Array<{file_path: string, mine: string, theirs: string}>} */ /** @type {Array<{file_path: string, mine: string, theirs: string}>} */
export let conflicts = []; let {
export let show = false; conflicts = [],
show = false,
} = $props();
// [/SECTION] // [/SECTION]
// [SECTION: STATE] // [SECTION: STATE]
@@ -36,7 +39,9 @@
* @side_effect Updates resolutions state. * @side_effect Updates resolutions state.
*/ */
function resolve(file, strategy) { function resolve(file, strategy) {
console.log(`[ConflictResolver][Action] Resolving ${file} with ${strategy}`); console.log(
`[ConflictResolver][Action] Resolving ${file} with ${strategy}`,
);
resolutions[file] = strategy; resolutions[file] = strategy;
resolutions = { ...resolutions }; // Trigger update resolutions = { ...resolutions }; // Trigger update
} }
@@ -51,16 +56,21 @@
*/ */
function handleSave() { function handleSave() {
// 1. Guard Clause (@PRE) // 1. Guard Clause (@PRE)
const unresolved = conflicts.filter(c => !resolutions[c.file_path]); const unresolved = conflicts.filter((c) => !resolutions[c.file_path]);
if (unresolved.length > 0) { if (unresolved.length > 0) {
console.warn(`[ConflictResolver][Coherence:Failed] ${unresolved.length} unresolved conflicts`); console.warn(
toast(`Please resolve all conflicts first. (${unresolved.length} remaining)`, 'error'); `[ConflictResolver][Coherence:Failed] ${unresolved.length} unresolved conflicts`,
);
toast(
`Please resolve all conflicts first. (${unresolved.length} remaining)`,
"error",
);
return; return;
} }
// 2. Implementation // 2. Implementation
console.log(`[ConflictResolver][Coherence:OK] All conflicts resolved`); console.log(`[ConflictResolver][Coherence:OK] All conflicts resolved`);
dispatch('resolve', resolutions); dispatch("resolve", resolutions);
show = false; show = false;
} }
// [/DEF:handleSave:Function] // [/DEF:handleSave:Function]
@@ -68,43 +78,78 @@
<!-- [SECTION: TEMPLATE] --> <!-- [SECTION: TEMPLATE] -->
{#if show} {#if show}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> <div
<div class="bg-white p-6 rounded-lg shadow-xl w-full max-w-5xl max-h-[90vh] flex flex-col"> class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
<h2 class="text-xl font-bold mb-4 text-red-600">Merge Conflicts Detected</h2> >
<p class="text-gray-600 mb-4">The following files have conflicts. Please choose how to resolve them.</p> <div
class="bg-white p-6 rounded-lg shadow-xl w-full max-w-5xl max-h-[90vh] flex flex-col"
>
<h2 class="text-xl font-bold mb-4 text-red-600">
Merge Conflicts Detected
</h2>
<p class="text-gray-600 mb-4">
The following files have conflicts. Please choose how to resolve
them.
</p>
<div class="flex-1 overflow-y-auto space-y-6 mb-4 pr-2"> <div class="flex-1 overflow-y-auto space-y-6 mb-4 pr-2">
{#each conflicts as conflict} {#each conflicts as conflict}
<div class="border rounded-lg overflow-hidden"> <div class="border rounded-lg overflow-hidden">
<div class="bg-gray-100 px-4 py-2 font-medium border-b flex justify-between items-center"> <div
class="bg-gray-100 px-4 py-2 font-medium border-b flex justify-between items-center"
>
<span>{conflict.file_path}</span> <span>{conflict.file_path}</span>
{#if resolutions[conflict.file_path]} {#if resolutions[conflict.file_path]}
<span class="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full uppercase font-bold"> <span
class="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full uppercase font-bold"
>
Resolved: {resolutions[conflict.file_path]} Resolved: {resolutions[conflict.file_path]}
</span> </span>
{/if} {/if}
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-0 divide-x"> <div
class="grid grid-cols-1 md:grid-cols-2 gap-0 divide-x"
>
<div class="p-0 flex flex-col"> <div class="p-0 flex flex-col">
<div class="bg-blue-50 px-4 py-1 text-[10px] font-bold text-blue-600 uppercase border-b">Your Changes (Mine)</div> <div
class="bg-blue-50 px-4 py-1 text-[10px] font-bold text-blue-600 uppercase border-b"
>
Your Changes (Mine)
</div>
<div class="p-4 bg-white flex-1 overflow-auto"> <div class="p-4 bg-white flex-1 overflow-auto">
<pre class="text-xs font-mono whitespace-pre">{conflict.mine}</pre> <pre
class="text-xs font-mono whitespace-pre">{conflict.mine}</pre>
</div> </div>
<button <button
class="w-full py-2 text-sm font-medium border-t transition-colors {resolutions[conflict.file_path] === 'mine' ? 'bg-blue-600 text-white' : 'bg-gray-50 hover:bg-blue-50 text-blue-600'}" class="w-full py-2 text-sm font-medium border-t transition-colors {resolutions[
on:click={() => resolve(conflict.file_path, 'mine')} conflict.file_path
] === 'mine'
? 'bg-blue-600 text-white'
: 'bg-gray-50 hover:bg-blue-50 text-blue-600'}"
on:click={() =>
resolve(conflict.file_path, "mine")}
> >
Keep Mine Keep Mine
</button> </button>
</div> </div>
<div class="p-0 flex flex-col"> <div class="p-0 flex flex-col">
<div class="bg-green-50 px-4 py-1 text-[10px] font-bold text-green-600 uppercase border-b">Remote Changes (Theirs)</div> <div
class="bg-green-50 px-4 py-1 text-[10px] font-bold text-green-600 uppercase border-b"
>
Remote Changes (Theirs)
</div>
<div class="p-4 bg-white flex-1 overflow-auto"> <div class="p-4 bg-white flex-1 overflow-auto">
<pre class="text-xs font-mono whitespace-pre">{conflict.theirs}</pre> <pre
class="text-xs font-mono whitespace-pre">{conflict.theirs}</pre>
</div> </div>
<button <button
class="w-full py-2 text-sm font-medium border-t transition-colors {resolutions[conflict.file_path] === 'theirs' ? 'bg-green-600 text-white' : 'bg-gray-50 hover:bg-green-50 text-green-600'}" class="w-full py-2 text-sm font-medium border-t transition-colors {resolutions[
on:click={() => resolve(conflict.file_path, 'theirs')} conflict.file_path
] === 'theirs'
? 'bg-green-600 text-white'
: 'bg-gray-50 hover:bg-green-50 text-green-600'}"
on:click={() =>
resolve(conflict.file_path, "theirs")}
> >
Keep Theirs Keep Theirs
</button> </button>
@@ -116,7 +161,7 @@
<div class="flex justify-end space-x-3 pt-4 border-t"> <div class="flex justify-end space-x-3 pt-4 border-t">
<button <button
on:click={() => show = false} on:click={() => (show = false)}
class="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded transition-colors" class="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded transition-colors"
> >
Cancel Cancel
@@ -133,10 +178,4 @@
{/if} {/if}
<!-- [/SECTION] --> <!-- [/SECTION] -->
<style>
pre {
tab-size: 4;
}
</style>
<!-- [/DEF:ConflictResolver:Component] --> <!-- [/DEF:ConflictResolver:Component] -->

View File

@@ -11,19 +11,19 @@
<script> <script>
// [SECTION: IMPORTS] // [SECTION: IMPORTS]
import { onMount, createEventDispatcher } from 'svelte'; import { onMount, createEventDispatcher } from "svelte";
import { gitService } from '../../services/gitService'; import { gitService } from "../../services/gitService";
import { addToast as toast } from '../../lib/toasts.js'; import { addToast as toast } from "../../lib/toasts.js";
// [/SECTION] // [/SECTION]
// [SECTION: PROPS] // [SECTION: PROPS]
export let dashboardId; let { dashboardId, show = false } = $props();
export let show = false;
// [/SECTION] // [/SECTION]
// [SECTION: STATE] // [SECTION: STATE]
let environments = []; let environments = [];
let selectedEnv = ''; let selectedEnv = "";
let loading = false; let loading = false;
let deploying = false; let deploying = false;
// [/SECTION] // [/SECTION]
@@ -31,7 +31,9 @@
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
// [DEF:loadStatus:Watcher] // [DEF:loadStatus:Watcher]
$: if (show) loadEnvironments(); $effect(() => {
if (show) loadEnvironments();
});
// [/DEF:loadStatus:Watcher] // [/DEF:loadStatus:Watcher]
// [DEF:loadEnvironments:Function] // [DEF:loadEnvironments:Function]
@@ -48,10 +50,12 @@
if (environments.length > 0) { if (environments.length > 0) {
selectedEnv = environments[0].id; selectedEnv = environments[0].id;
} }
console.log(`[DeploymentModal][Coherence:OK] Loaded ${environments.length} environments`); console.log(
`[DeploymentModal][Coherence:OK] Loaded ${environments.length} environments`,
);
} catch (e) { } catch (e) {
console.error(`[DeploymentModal][Coherence:Failed] ${e.message}`); console.error(`[DeploymentModal][Coherence:Failed] ${e.message}`);
toast('Failed to load environments', 'error'); toast("Failed to load environments", "error");
} finally { } finally {
loading = false; loading = false;
} }
@@ -71,13 +75,16 @@
deploying = true; deploying = true;
try { try {
const result = await gitService.deploy(dashboardId, selectedEnv); const result = await gitService.deploy(dashboardId, selectedEnv);
toast(result.message || 'Deployment triggered successfully', 'success'); toast(
dispatch('deploy'); result.message || "Deployment triggered successfully",
"success",
);
dispatch("deploy");
show = false; show = false;
console.log(`[DeploymentModal][Coherence:OK] Deployment triggered`); console.log(`[DeploymentModal][Coherence:OK] Deployment triggered`);
} catch (e) { } catch (e) {
console.error(`[DeploymentModal][Coherence:Failed] ${e.message}`); console.error(`[DeploymentModal][Coherence:Failed] ${e.message}`);
toast(e.message, 'error'); toast(e.message, "error");
} finally { } finally {
deploying = false; deploying = false;
} }
@@ -87,17 +94,21 @@
<!-- [SECTION: TEMPLATE] --> <!-- [SECTION: TEMPLATE] -->
{#if show} {#if show}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
>
<div class="bg-white p-6 rounded-lg shadow-xl w-96"> <div class="bg-white p-6 rounded-lg shadow-xl w-96">
<h2 class="text-xl font-bold mb-4">Deploy Dashboard</h2> <h2 class="text-xl font-bold mb-4">Deploy Dashboard</h2>
{#if loading} {#if loading}
<p class="text-gray-500">Loading environments...</p> <p class="text-gray-500">Loading environments...</p>
{:else if environments.length === 0} {:else if environments.length === 0}
<p class="text-red-500 mb-4">No deployment environments configured.</p> <p class="text-red-500 mb-4">
No deployment environments configured.
</p>
<div class="flex justify-end"> <div class="flex justify-end">
<button <button
on:click={() => show = false} on:click={() => (show = false)}
class="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300" class="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300"
> >
Close Close
@@ -105,20 +116,24 @@
</div> </div>
{:else} {:else}
<div class="mb-6"> <div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2">Select Target Environment</label> <label class="block text-sm font-medium text-gray-700 mb-2"
>Select Target Environment</label
>
<select <select
bind:value={selectedEnv} bind:value={selectedEnv}
class="w-full border rounded p-2 focus:ring-2 focus:ring-blue-500 outline-none bg-white" class="w-full border rounded p-2 focus:ring-2 focus:ring-blue-500 outline-none bg-white"
> >
{#each environments as env} {#each environments as env}
<option value={env.id}>{env.name} ({env.superset_url})</option> <option value={env.id}
>{env.name} ({env.superset_url})</option
>
{/each} {/each}
</select> </select>
</div> </div>
<div class="flex justify-end space-x-3"> <div class="flex justify-end space-x-3">
<button <button
on:click={() => show = false} on:click={() => (show = false)}
class="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded" class="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded"
> >
Cancel Cancel
@@ -129,9 +144,25 @@
class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 flex items-center" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 flex items-center"
> >
{#if deploying} {#if deploying}
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <svg
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> class="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg> </svg>
Deploying... Deploying...
{:else} {:else}

View File

@@ -26,9 +26,12 @@
// [/SECTION] // [/SECTION]
// [SECTION: PROPS] // [SECTION: PROPS]
export let dashboardId; let {
export let dashboardTitle = ""; dashboardId,
export let show = false; dashboardTitle = "",
show = false,
} = $props();
// [/SECTION] // [/SECTION]
// [SECTION: STATE] // [SECTION: STATE]

View File

@@ -10,9 +10,12 @@
import { t } from '../../lib/i18n'; import { t } from '../../lib/i18n';
/** @type {Object} */ /** @type {Object} */
export let documentation = null; let {
export let onSave = async (doc) => {}; content = "",
export let onCancel = () => {}; type = 'markdown',
format = 'text',
} = $props();
let isSaving = false; let isSaving = false;

View File

@@ -12,8 +12,11 @@
import { requestApi } from '../../lib/api'; import { requestApi } from '../../lib/api';
/** @type {Array} */ /** @type {Array} */
export let providers = []; let {
export let onSave = () => {}; provider,
config = {},
} = $props();
let editingProvider = null; let editingProvider = null;
let showForm = false; let showForm = false;

View File

@@ -3,7 +3,10 @@
<!-- @PURPOSE: Displays the results of an LLM-based dashboard validation task. --> <!-- @PURPOSE: Displays the results of an LLM-based dashboard validation task. -->
<script> <script>
export let result = null; let {
report,
} = $props();
function getStatusColor(status) { function getStatusColor(status) {
switch (status) { switch (status) {

View File

@@ -17,7 +17,10 @@
import { t } from '../../lib/i18n'; import { t } from '../../lib/i18n';
// [/SECTION: IMPORTS] // [/SECTION: IMPORTS]
export let files = []; let {
files = [],
} = $props();
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
// [DEF:isDirectory:Function] // [DEF:isDirectory:Function]
@@ -137,8 +140,5 @@
</div> </div>
<!-- [/SECTION: TEMPLATE] --> <!-- [/SECTION: TEMPLATE] -->
<style>
/* ... */
</style>
<!-- [/DEF:FileList:Component] --> <!-- [/DEF:FileList:Component] -->

View File

@@ -26,8 +26,11 @@
*/ */
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
let fileInput; let fileInput;
export let category = 'backups'; let {
export let path = ''; category = 'backups',
path = '',
} = $props();
let isUploading = false; let isUploading = false;
let dragOver = false; let dragOver = false;
@@ -128,8 +131,5 @@
</div> </div>
<!-- [/SECTION: TEMPLATE] --> <!-- [/SECTION: TEMPLATE] -->
<style>
/* ... */
</style>
<!-- [/DEF:FileUpload:Component] --> <!-- [/DEF:FileUpload:Component] -->

View File

@@ -8,14 +8,10 @@
--> -->
<script> <script>
/** @type {Object} log - The log entry object */ /** @type {Object} log - The log entry object */
export let log; let { log, showSource = true } = $props();
/** @type {boolean} showSource - Whether to show the source tag */
export let showSource = true;
// [DEF:formatTime:Function] // [DEF:formatTime:Function]
/** @PURPOSE Format ISO timestamp to HH:MM:SS */ /** @PURPOSE Format ISO timestamp to HH:MM:SS */
$: formattedTime = formatTime(log.timestamp);
function formatTime(timestamp) { function formatTime(timestamp) {
if (!timestamp) return ""; if (!timestamp) return "";
const date = new Date(timestamp); const date = new Date(timestamp);
@@ -28,180 +24,77 @@
} }
// [/DEF:formatTime:Function] // [/DEF:formatTime:Function]
$: levelClass = getLevelClass(log.level); let formattedTime = $derived(formatTime(log.timestamp));
function getLevelClass(level) { const levelStyles = {
switch (level?.toUpperCase()) { DEBUG: "text-log-debug bg-log-debug/15",
case "DEBUG": INFO: "text-log-info bg-log-info/10",
return "level-debug"; WARNING: "text-log-warning bg-log-warning/10",
case "INFO": ERROR: "text-log-error bg-log-error/10",
return "level-info"; };
case "WARNING":
return "level-warning";
case "ERROR":
return "level-error";
default:
return "level-info";
}
}
$: sourceClass = getSourceClass(log.source); const sourceStyles = {
plugin: "bg-source-plugin/10 text-source-plugin",
"superset-api": "bg-source-api/10 text-source-api",
superset_api: "bg-source-api/10 text-source-api",
git: "bg-source-git/10 text-source-git",
system: "bg-source-system/10 text-source-system",
};
function getSourceClass(source) { let levelClass = $derived(
if (!source) return "source-default"; levelStyles[log.level?.toUpperCase()] || levelStyles.INFO,
return `source-${source.toLowerCase().replace(/[^a-z0-9]/g, "-")}`; );
} let sourceClass = $derived(
sourceStyles[log.source?.toLowerCase()] ||
"bg-log-debug/15 text-terminal-text-subtle",
);
$: hasProgress = log.metadata?.progress !== undefined; let hasProgress = $derived(log.metadata?.progress !== undefined);
$: progressPercent = log.metadata?.progress || 0; let progressPercent = $derived(log.metadata?.progress || 0);
</script> </script>
<div class="log-row"> <div
class="py-2 px-3 border-b border-terminal-surface/60 transition-colors hover:bg-terminal-surface/50"
>
<!-- Meta line: time + level + source --> <!-- Meta line: time + level + source -->
<div class="log-meta"> <div class="flex items-center gap-2 mb-1">
<span class="log-time">{formattedTime}</span> <span class="font-mono text-[0.6875rem] text-terminal-text-muted shrink-0"
<span class="log-level {levelClass}">{log.level || "INFO"}</span> >{formattedTime}</span
>
<span
class="font-mono font-semibold uppercase text-[0.625rem] px-1.5 py-px rounded-sm tracking-wider shrink-0 {levelClass}"
>{log.level || "INFO"}</span
>
{#if showSource && log.source} {#if showSource && log.source}
<span class="log-source {sourceClass}">{log.source}</span> <span
class="text-[0.625rem] px-1.5 py-px rounded-sm shrink-0 {sourceClass}"
>{log.source}</span
>
{/if} {/if}
</div> </div>
<!-- Message --> <!-- Message -->
<div class="log-message">{log.message}</div> <div
class="font-mono text-[0.8125rem] leading-relaxed text-terminal-text break-words whitespace-pre-wrap"
>
{log.message}
</div>
<!-- Progress bar (if applicable) --> <!-- Progress bar (if applicable) -->
{#if hasProgress} {#if hasProgress}
<div class="progress-container"> <div class="flex items-center gap-2 mt-1.5">
<div class="progress-track"> <div
<div class="progress-fill" style="width: {progressPercent}%"></div> class="flex-1 h-1.5 bg-terminal-surface rounded-full overflow-hidden"
>
<div
class="h-full bg-gradient-to-r from-primary to-purple-500 rounded-full transition-[width] duration-300 ease-out"
style="width: {progressPercent}%"
></div>
</div> </div>
<span class="progress-text">{progressPercent.toFixed(0)}%</span> <span class="font-mono text-[0.625rem] text-terminal-text-subtle shrink-0"
>{progressPercent.toFixed(0)}%</span
>
</div> </div>
{/if} {/if}
</div> </div>
<!-- [/DEF:LogEntryRow:Component] --> <!-- [/DEF:LogEntryRow:Component] -->
<style>
.log-row {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid rgba(30, 41, 59, 0.6);
transition: background-color 0.1s;
}
.log-row:hover {
background-color: rgba(30, 41, 59, 0.5);
}
.log-meta {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.log-time {
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 0.6875rem;
color: #475569;
flex-shrink: 0;
}
.log-level {
font-family: "JetBrains Mono", "Fira Code", monospace;
font-weight: 600;
text-transform: uppercase;
font-size: 0.625rem;
padding: 0.0625rem 0.375rem;
border-radius: 0.1875rem;
letter-spacing: 0.03em;
flex-shrink: 0;
}
.level-debug {
color: #64748b;
background-color: rgba(100, 116, 139, 0.15);
}
.level-info {
color: #38bdf8;
background-color: rgba(56, 189, 248, 0.1);
}
.level-warning {
color: #fbbf24;
background-color: rgba(251, 191, 36, 0.1);
}
.level-error {
color: #f87171;
background-color: rgba(248, 113, 113, 0.1);
}
.log-source {
font-size: 0.625rem;
padding: 0.0625rem 0.375rem;
border-radius: 0.1875rem;
background-color: rgba(100, 116, 139, 0.15);
color: #64748b;
flex-shrink: 0;
}
.source-plugin {
background-color: rgba(34, 197, 94, 0.1);
color: #4ade80;
}
.source-superset-api,
.source-superset_api {
background-color: rgba(168, 85, 247, 0.1);
color: #c084fc;
}
.source-git {
background-color: rgba(249, 115, 22, 0.1);
color: #fb923c;
}
.source-system {
background-color: rgba(56, 189, 248, 0.1);
color: #38bdf8;
}
.log-message {
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 0.8125rem;
line-height: 1.5;
color: #cbd5e1;
word-break: break-word;
white-space: pre-wrap;
}
.progress-container {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.375rem;
}
.progress-track {
flex: 1;
height: 0.375rem;
background-color: #1e293b;
border-radius: 9999px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
border-radius: 9999px;
transition: width 0.3s ease;
}
.progress-text {
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 0.625rem;
color: #64748b;
flex-shrink: 0;
}
</style>

View File

@@ -10,14 +10,12 @@
<script> <script>
import { createEventDispatcher } from "svelte"; import { createEventDispatcher } from "svelte";
/** @type {string[]} availableSources - List of available source options */ let {
export let availableSources = []; availableSources = [],
/** @type {string} selectedLevel - Currently selected log level filter */ selectedLevel = $bindable(""),
export let selectedLevel = ""; selectedSource = $bindable(""),
/** @type {string} selectedSource - Currently selected source filter */ searchText = $bindable(""),
export let selectedSource = ""; } = $props();
/** @type {string} searchText - Current search text */
export let searchText = "";
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@@ -63,13 +61,18 @@
dispatch("filter-change", { level: "", source: "", search: "" }); dispatch("filter-change", { level: "", source: "", search: "" });
} }
$: hasActiveFilters = selectedLevel || selectedSource || searchText; let hasActiveFilters = $derived(
selectedLevel || selectedSource || searchText,
);
</script> </script>
<div class="filter-bar"> <div
<div class="filter-controls"> class="flex items-center gap-1.5 px-3 py-2 bg-terminal-bg border-b border-terminal-surface"
>
<div class="flex items-center gap-1.5 flex-1 min-w-0">
<select <select
class="filter-select" class="bg-terminal-surface text-terminal-text-subtle border border-terminal-border rounded px-2 py-[0.3125rem] text-xs cursor-pointer shrink-0 appearance-none pr-6 focus:outline-none focus:border-primary-ring"
style="background-image: url(&quot;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E&quot;); background-repeat: no-repeat; background-position: right 0.375rem center;"
value={selectedLevel} value={selectedLevel}
on:change={handleLevelChange} on:change={handleLevelChange}
aria-label="Filter by level" aria-label="Filter by level"
@@ -80,7 +83,8 @@
</select> </select>
<select <select
class="filter-select" class="bg-terminal-surface text-terminal-text-subtle border border-terminal-border rounded px-2 py-[0.3125rem] text-xs cursor-pointer shrink-0 appearance-none pr-6 focus:outline-none focus:border-primary-ring"
style="background-image: url(&quot;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E&quot;); background-repeat: no-repeat; background-position: right 0.375rem center;"
value={selectedSource} value={selectedSource}
on:change={handleSourceChange} on:change={handleSourceChange}
aria-label="Filter by source" aria-label="Filter by source"
@@ -91,9 +95,9 @@
{/each} {/each}
</select> </select>
<div class="search-wrapper"> <div class="relative flex-1 min-w-0">
<svg <svg
class="search-icon" class="absolute left-2 top-1/2 -translate-y-1/2 text-terminal-text-muted pointer-events-none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="14" width="14"
height="14" height="14"
@@ -107,7 +111,7 @@
</svg> </svg>
<input <input
type="text" type="text"
class="search-input" class="w-full bg-terminal-surface text-terminal-text-bright border border-terminal-border rounded py-[0.3125rem] px-2 pl-7 text-xs placeholder:text-terminal-text-muted focus:outline-none focus:border-primary-ring"
placeholder="Search..." placeholder="Search..."
value={searchText} value={searchText}
on:input={handleSearchChange} on:input={handleSearchChange}
@@ -118,7 +122,7 @@
{#if hasActiveFilters} {#if hasActiveFilters}
<button <button
class="clear-btn" class="flex items-center justify-center p-[0.3125rem] bg-transparent border border-terminal-border rounded text-terminal-text-subtle shrink-0 cursor-pointer transition-all hover:text-log-error hover:border-log-error hover:bg-log-error/10"
on:click={clearFilters} on:click={clearFilters}
aria-label="Clear filters" aria-label="Clear filters"
> >
@@ -137,98 +141,3 @@
{/if} {/if}
</div> </div>
<!-- [/DEF:LogFilterBar:Component] --> <!-- [/DEF:LogFilterBar:Component] -->
<style>
.filter-bar {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
background-color: #0f172a;
border-bottom: 1px solid #1e293b;
}
.filter-controls {
display: flex;
align-items: center;
gap: 0.375rem;
flex: 1;
min-width: 0;
}
.filter-select {
background-color: #1e293b;
color: #94a3b8;
border: 1px solid #334155;
border-radius: 0.25rem;
padding: 0.3125rem 0.5rem;
font-size: 0.75rem;
cursor: pointer;
flex-shrink: 0;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.375rem center;
padding-right: 1.5rem;
}
.filter-select:focus {
outline: none;
border-color: #3b82f6;
}
.search-wrapper {
position: relative;
flex: 1;
min-width: 0;
}
.search-icon {
position: absolute;
left: 0.5rem;
top: 50%;
transform: translateY(-50%);
color: #475569;
pointer-events: none;
}
.search-input {
width: 100%;
background-color: #1e293b;
color: #e2e8f0;
border: 1px solid #334155;
border-radius: 0.25rem;
padding: 0.3125rem 0.5rem 0.3125rem 1.75rem;
font-size: 0.75rem;
}
.search-input::placeholder {
color: #475569;
}
.search-input:focus {
outline: none;
border-color: #3b82f6;
}
.clear-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.3125rem;
background: none;
border: 1px solid #334155;
border-radius: 0.25rem;
color: #64748b;
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s;
}
.clear-btn:hover {
color: #f87171;
border-color: #f87171;
background-color: rgba(248, 113, 113, 0.1);
}
</style>

View File

@@ -12,25 +12,21 @@
@UX_STATE: AutoScroll -> Automatically scrolls to bottom on new logs @UX_STATE: AutoScroll -> Automatically scrolls to bottom on new logs
--> -->
<script> <script>
import { createEventDispatcher, onMount, afterUpdate } from "svelte"; import { createEventDispatcher, onMount, tick } from "svelte";
import LogFilterBar from "./LogFilterBar.svelte"; import LogFilterBar from "./LogFilterBar.svelte";
import LogEntryRow from "./LogEntryRow.svelte"; import LogEntryRow from "./LogEntryRow.svelte";
/** let { logs = [], autoScroll = $bindable(true) } = $props();
* @PURPOSE Component properties and state.
* @PRE logs is an array of LogEntry objects.
*/
export let logs = [];
export let autoScroll = true;
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
let scrollContainer; let scrollContainer;
let selectedSource = "all"; let selectedSource = $state("all");
let selectedLevel = "all"; let selectedLevel = $state("all");
let searchText = ""; let searchText = $state("");
// Filtered logs based on current filters let filteredLogs = $derived(
$: filteredLogs = filterLogs(logs, selectedLevel, selectedSource, searchText); filterLogs(logs, selectedLevel, selectedSource, searchText),
);
function filterLogs(allLogs, level, source, search) { function filterLogs(allLogs, level, source, search) {
return allLogs.filter((log) => { return allLogs.filter((log) => {
@@ -52,17 +48,15 @@
}); });
} }
// Extract unique sources from logs let availableSources = $derived([
$: availableSources = [...new Set(logs.map((l) => l.source).filter(Boolean))]; ...new Set(logs.map((l) => l.source).filter(Boolean)),
]);
function handleFilterChange(event) { function handleFilterChange(event) {
const { source, level, search } = event.detail; const { source, level, search } = event.detail;
selectedSource = source || "all"; selectedSource = source || "all";
selectedLevel = level || "all"; selectedLevel = level || "all";
searchText = search || ""; searchText = search || "";
console.log(
`[TaskLogPanel][Action] Filter: level=${selectedLevel}, source=${selectedSource}, search=${searchText}`,
);
dispatch("filterChange", { source, level }); dispatch("filterChange", { source, level });
} }
@@ -77,8 +71,11 @@
if (autoScroll) scrollToBottom(); if (autoScroll) scrollToBottom();
} }
afterUpdate(() => { // Use $effect instead of afterUpdate for runes mode
scrollToBottom(); $effect(() => {
// Track filteredLogs length to trigger scroll
filteredLogs.length;
tick().then(scrollToBottom);
}); });
onMount(() => { onMount(() => {
@@ -86,14 +83,19 @@
}); });
</script> </script>
<div class="log-panel"> <div class="flex flex-col h-full bg-terminal-bg overflow-hidden">
<!-- Filter Bar --> <!-- Filter Bar -->
<LogFilterBar {availableSources} on:filter-change={handleFilterChange} /> <LogFilterBar {availableSources} on:filter-change={handleFilterChange} />
<!-- Log List --> <!-- Log List -->
<div bind:this={scrollContainer} class="log-list"> <div
bind:this={scrollContainer}
class="flex-1 overflow-y-auto overflow-x-hidden"
>
{#if filteredLogs.length === 0} {#if filteredLogs.length === 0}
<div class="empty-logs"> <div
class="flex flex-col items-center justify-center py-12 px-4 text-terminal-border gap-3"
>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="32" width="32"
@@ -109,7 +111,9 @@
<line x1="16" y1="17" x2="8" y2="17" /> <line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" /> <polyline points="10 9 9 9 8 9" />
</svg> </svg>
<span>No logs available</span> <span class="text-[0.8125rem] text-terminal-text-muted"
>No logs available</span
>
</div> </div>
{:else} {:else}
{#each filteredLogs as log} {#each filteredLogs as log}
@@ -119,129 +123,27 @@
</div> </div>
<!-- Footer Stats --> <!-- Footer Stats -->
<div class="log-footer"> <div
<span class="log-count"> class="flex items-center justify-between py-1.5 px-3 border-t border-terminal-surface bg-terminal-bg"
>
<span class="font-mono text-[0.6875rem] text-terminal-text-muted">
{filteredLogs.length}{filteredLogs.length !== logs.length {filteredLogs.length}{filteredLogs.length !== logs.length
? ` / ${logs.length}` ? ` / ${logs.length}`
: ""} entries : ""} entries
</span> </span>
<button <button
class="autoscroll-btn" class="flex items-center gap-1.5 bg-transparent border-none text-terminal-text-muted text-[0.6875rem] cursor-pointer py-px px-1.5 rounded transition-all hover:bg-terminal-surface hover:text-terminal-text-subtle
class:active={autoScroll} {autoScroll ? 'text-terminal-accent' : ''}"
on:click={toggleAutoScroll} on:click={toggleAutoScroll}
aria-label="Toggle auto-scroll" aria-label="Toggle auto-scroll"
> >
{#if autoScroll} {#if autoScroll}
<span class="pulse-dot"></span> <span
class="inline-block w-[5px] h-[5px] rounded-full bg-terminal-accent animate-pulse"
></span>
{/if} {/if}
Auto-scroll {autoScroll ? "on" : "off"} Auto-scroll {autoScroll ? "on" : "off"}
</button> </button>
</div> </div>
</div> </div>
<!-- [/DEF:TaskLogPanel:Component] --> <!-- [/DEF:TaskLogPanel:Component] -->
<style>
.log-panel {
display: flex;
flex-direction: column;
height: 100%;
background-color: #0f172a;
overflow: hidden;
}
.log-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
/* Custom scrollbar */
.log-list::-webkit-scrollbar {
width: 6px;
}
.log-list::-webkit-scrollbar-track {
background: #0f172a;
}
.log-list::-webkit-scrollbar-thumb {
background: #334155;
border-radius: 3px;
}
.log-list::-webkit-scrollbar-thumb:hover {
background: #475569;
}
.empty-logs {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 1rem;
color: #334155;
gap: 0.75rem;
}
.empty-logs span {
font-size: 0.8125rem;
color: #475569;
}
.log-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.375rem 0.75rem;
border-top: 1px solid #1e293b;
background-color: #0f172a;
}
.log-count {
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 0.6875rem;
color: #475569;
}
.autoscroll-btn {
display: flex;
align-items: center;
gap: 0.375rem;
background: none;
border: none;
color: #475569;
font-size: 0.6875rem;
cursor: pointer;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
transition: all 0.15s;
}
.autoscroll-btn:hover {
background-color: #1e293b;
color: #94a3b8;
}
.autoscroll-btn.active {
color: #22d3ee;
}
.pulse-dot {
display: inline-block;
width: 5px;
height: 5px;
border-radius: 50%;
background-color: #22d3ee;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
</style>

View File

@@ -12,13 +12,15 @@
* @UX_RECOVERY: Click breadcrumb to navigate * @UX_RECOVERY: Click breadcrumb to navigate
*/ */
import { page } from '$app/stores'; import { page } from "$app/stores";
import { t, _ } from '$lib/i18n'; import { t, _ } from "$lib/i18n";
export let maxVisible = 3; let { maxVisible = 3 } = $props();
// Breadcrumb items derived from current path // Breadcrumb items derived from current path
$: breadcrumbItems = getBreadcrumbs($page?.url?.pathname || '/', maxVisible); let breadcrumbItems = $derived(
getBreadcrumbs($page?.url?.pathname || "/", maxVisible),
);
/** /**
* Generate breadcrumb items from path * Generate breadcrumb items from path
@@ -26,42 +28,26 @@
* @returns {Array} Array of breadcrumb items * @returns {Array} Array of breadcrumb items
*/ */
function getBreadcrumbs(pathname, maxVisible = 3) { function getBreadcrumbs(pathname, maxVisible = 3) {
const segments = pathname.split('/').filter(Boolean); const segments = pathname.split("/").filter(Boolean);
const allItems = [ const allItems = [{ label: "Home", path: "/" }];
{ label: 'Home', path: '/' }
];
let currentPath = ''; let currentPath = "";
segments.forEach((segment, index) => { segments.forEach((segment, index) => {
currentPath += `/${segment}`; currentPath += `/${segment}`;
// Convert segment to readable label
const label = formatBreadcrumbLabel(segment); const label = formatBreadcrumbLabel(segment);
allItems.push({ allItems.push({
label, label,
path: currentPath, path: currentPath,
isLast: index === segments.length - 1 isLast: index === segments.length - 1,
}); });
}); });
// Handle truncation if too many items
// If we have more than maxVisible items, we truncate the middle ones
// Always show Home (first) and Current (last)
if (allItems.length > maxVisible) { if (allItems.length > maxVisible) {
const firstItem = allItems[0]; const firstItem = allItems[0];
const lastItem = allItems[allItems.length - 1];
// Calculate how many items we can show in the middle
// We reserve 1 for first, 1 for last, and 1 for ellipsis
// But ellipsis isn't a real item in terms of logic, it just replaces hidden ones
// Actually, let's keep it simple: First ... [Last - (maxVisible - 2) .. Last]
const itemsToShow = []; const itemsToShow = [];
itemsToShow.push(firstItem); itemsToShow.push(firstItem);
itemsToShow.push({ isEllipsis: true }); itemsToShow.push({ isEllipsis: true });
// Add the last (maxVisible - 2) items
// e.g. if maxVisible is 3, we show Start ... End
// if maxVisible is 4, we show Start ... SecondLast End
const startFromIndex = allItems.length - (maxVisible - 1); const startFromIndex = allItems.length - (maxVisible - 1);
for (let i = startFromIndex; i < allItems.length; i++) { for (let i = startFromIndex; i < allItems.length; i++) {
itemsToShow.push(allItems[i]); itemsToShow.push(allItems[i]);
@@ -78,63 +64,46 @@
* @returns {string} Formatted label * @returns {string} Formatted label
*/ */
function formatBreadcrumbLabel(segment) { function formatBreadcrumbLabel(segment) {
// Handle special cases
const specialCases = { const specialCases = {
'dashboards': 'nav.dashboard', dashboards: "nav.dashboard",
'datasets': 'nav.tools_mapper', datasets: "nav.tools_mapper",
'storage': 'nav.tools_storage', storage: "nav.tools_storage",
'admin': 'nav.admin', admin: "nav.admin",
'settings': 'nav.settings', settings: "nav.settings",
'git': 'nav.git' git: "nav.git",
}; };
if (specialCases[segment]) { if (specialCases[segment]) {
return _(specialCases[segment]) || segment; return _(specialCases[segment]) || segment;
} }
// Default: capitalize and replace hyphens with spaces
return segment return segment
.split('-') .split("-")
.map(word => word.charAt(0).toUpperCase() + word.slice(1)) .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' '); .join(" ");
} }
</script> </script>
<style> <nav
.breadcrumbs { class="flex items-center space-x-2 text-sm text-gray-600"
@apply flex items-center space-x-2 text-sm text-gray-600; aria-label="Breadcrumb navigation"
} >
.breadcrumb-item {
@apply flex items-center;
}
.breadcrumb-link {
@apply hover:text-blue-600 hover:underline cursor-pointer transition-colors;
}
.breadcrumb-current {
@apply text-gray-900 font-medium;
}
.breadcrumb-separator {
@apply text-gray-400;
}
</style>
<nav class="breadcrumbs" aria-label="Breadcrumb navigation">
{#each breadcrumbItems as item, index} {#each breadcrumbItems as item, index}
<div class="breadcrumb-item"> <div class="flex items-center">
{#if item.isEllipsis} {#if item.isEllipsis}
<span class="breadcrumb-separator">...</span> <span class="text-gray-400">...</span>
{:else if item.isLast} {:else if item.isLast}
<span class="breadcrumb-current">{item.label}</span> <span class="text-gray-900 font-medium">{item.label}</span>
{:else} {:else}
<a href={item.path} class="breadcrumb-link">{item.label}</a> <a
href={item.path}
class="hover:text-primary hover:underline cursor-pointer transition-colors"
>{item.label}</a
>
{/if} {/if}
</div> </div>
{#if index < breadcrumbItems.length - 1} {#if index < breadcrumbItems.length - 1}
<span class="breadcrumb-separator">/</span> <span class="text-gray-400">/</span>
{/if} {/if}
{/each} {/each}
</nav> </nav>

View File

@@ -30,7 +30,7 @@
{ {
id: "dashboards", id: "dashboards",
label: $t.nav?.dashboards || "DASHBOARDS", label: $t.nav?.dashboards || "DASHBOARDS",
icon: "M3 3h18v18H3V3zm16 16V5H5v14h14z", // Grid icon icon: "M3 3h18v18H3V3zm16 16V5H5v14h14z",
path: "/dashboards", path: "/dashboards",
subItems: [ subItems: [
{ label: $t.nav?.overview || "Overview", path: "/dashboards" }, { label: $t.nav?.overview || "Overview", path: "/dashboards" },
@@ -39,7 +39,7 @@
{ {
id: "datasets", id: "datasets",
label: $t.nav?.datasets || "DATASETS", label: $t.nav?.datasets || "DATASETS",
icon: "M3 3h18v18H3V3zm2 2v14h14V5H5zm2 2h10v2H7V7zm0 4h10v2H7v-2zm0 4h6v2H7v-2z", // List icon icon: "M3 3h18v18H3V3zm2 2v14h14V5H5zm2 2h10v2H7V7zm0 4h10v2H7v-2zm0 4h6v2H7v-2z",
path: "/datasets", path: "/datasets",
subItems: [ subItems: [
{ label: $t.nav?.all_datasets || "All Datasets", path: "/datasets" }, { label: $t.nav?.all_datasets || "All Datasets", path: "/datasets" },
@@ -48,7 +48,7 @@
{ {
id: "storage", id: "storage",
label: $t.nav?.storage || "STORAGE", label: $t.nav?.storage || "STORAGE",
icon: "M4 4h16v16H4V4zm2 2v12h12V6H6zm2 2h8v2H8V8zm0 4h8v2H8v-2zm0 4h5v2H8v-2z", // Folder icon icon: "M4 4h16v16H4V4zm2 2v12h12V6H6zm2 2h8v2H8V8zm0 4h8v2H8v-2zm0 4h5v2H8v-2z",
path: "/storage", path: "/storage",
subItems: [ subItems: [
{ label: $t.nav?.backups || "Backups", path: "/storage/backups" }, { label: $t.nav?.backups || "Backups", path: "/storage/backups" },
@@ -61,7 +61,7 @@
{ {
id: "admin", id: "admin",
label: $t.nav?.admin || "ADMIN", label: $t.nav?.admin || "ADMIN",
icon: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z", // User icon icon: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z",
path: "/admin", path: "/admin",
subItems: [ subItems: [
{ label: $t.nav?.admin_users || "Users", path: "/admin/users" }, { label: $t.nav?.admin_users || "Users", path: "/admin/users" },
@@ -75,7 +75,7 @@
let activeCategory = "dashboards"; let activeCategory = "dashboards";
let activeItem = "/dashboards"; let activeItem = "/dashboards";
let isMobileOpen = false; let isMobileOpen = false;
let expandedCategories = new Set(["dashboards"]); // Track expanded categories let expandedCategories = new Set(["dashboards"]);
// Subscribe to sidebar store // Subscribe to sidebar store
$: if ($sidebarStore) { $: if ($sidebarStore) {
@@ -90,7 +90,7 @@
{ {
id: "dashboards", id: "dashboards",
label: $t.nav?.dashboards || "DASHBOARDS", label: $t.nav?.dashboards || "DASHBOARDS",
icon: "M3 3h18v18H3V3zm16 16V5H5v14h14z", // Grid icon icon: "M3 3h18v18H3V3zm16 16V5H5v14h14z",
path: "/dashboards", path: "/dashboards",
subItems: [ subItems: [
{ label: $t.nav?.overview || "Overview", path: "/dashboards" }, { label: $t.nav?.overview || "Overview", path: "/dashboards" },
@@ -99,7 +99,7 @@
{ {
id: "datasets", id: "datasets",
label: $t.nav?.datasets || "DATASETS", label: $t.nav?.datasets || "DATASETS",
icon: "M3 3h18v18H3V3zm2 2v14h14V5H5zm2 2h10v2H7V7zm0 4h10v2H7v-2zm0 4h6v2H7v-2z", // List icon icon: "M3 3h18v18H3V3zm2 2v14h14V5H5zm2 2h10v2H7V7zm0 4h10v2H7v-2zm0 4h6v2H7v-2z",
path: "/datasets", path: "/datasets",
subItems: [ subItems: [
{ label: $t.nav?.all_datasets || "All Datasets", path: "/datasets" }, { label: $t.nav?.all_datasets || "All Datasets", path: "/datasets" },
@@ -108,7 +108,7 @@
{ {
id: "storage", id: "storage",
label: $t.nav?.storage || "STORAGE", label: $t.nav?.storage || "STORAGE",
icon: "M4 4h16v16H4V4zm2 2v12h12V6H6zm2 2h8v2H8V8zm0 4h8v2H8v-2zm0 4h5v2H8v-2z", // Folder icon icon: "M4 4h16v16H4V4zm2 2v12h12V6H6zm2 2h8v2H8V8zm0 4h8v2H8v-2zm0 4h5v2H8v-2z",
path: "/storage", path: "/storage",
subItems: [ subItems: [
{ label: $t.nav?.backups || "Backups", path: "/storage/backups" }, { label: $t.nav?.backups || "Backups", path: "/storage/backups" },
@@ -121,7 +121,7 @@
{ {
id: "admin", id: "admin",
label: $t.nav?.admin || "ADMIN", label: $t.nav?.admin || "ADMIN",
icon: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z", // User icon icon: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z",
path: "/admin", path: "/admin",
subItems: [ subItems: [
{ label: $t.nav?.admin_users || "Users", path: "/admin/users" }, { label: $t.nav?.admin_users || "Users", path: "/admin/users" },
@@ -133,7 +133,6 @@
// Update active item when page changes // Update active item when page changes
$: if ($page && $page.url.pathname !== activeItem) { $: if ($page && $page.url.pathname !== activeItem) {
// Find matching category
const matched = categories.find((cat) => const matched = categories.find((cat) =>
$page.url.pathname.startsWith(cat.path), $page.url.pathname.startsWith(cat.path),
); );
@@ -143,7 +142,6 @@
} }
} }
// Handle click on sidebar item
function handleItemClick(category) { function handleItemClick(category) {
console.log(`[Sidebar][Action] Clicked category ${category.id}`); console.log(`[Sidebar][Action] Clicked category ${category.id}`);
setActiveItem(category.id, category.path); setActiveItem(category.id, category.path);
@@ -153,7 +151,6 @@
} }
} }
// Handle click on category header to toggle expansion
function handleCategoryToggle(categoryId, event) { function handleCategoryToggle(categoryId, event) {
event.stopPropagation(); event.stopPropagation();
@@ -173,28 +170,24 @@
} else { } else {
expandedCategories.add(categoryId); expandedCategories.add(categoryId);
} }
expandedCategories = expandedCategories; // Trigger reactivity expandedCategories = expandedCategories;
} }
// Handle click on sub-item
function handleSubItemClick(categoryId, path) { function handleSubItemClick(categoryId, path) {
console.log(`[Sidebar][Action] Clicked sub-item ${path}`); console.log(`[Sidebar][Action] Clicked sub-item ${path}`);
setActiveItem(categoryId, path); setActiveItem(categoryId, path);
closeMobile(); closeMobile();
// Force navigation if it's a link
if (browser) { if (browser) {
window.location.href = path; window.location.href = path;
} }
} }
// Handle toggle button click
function handleToggleClick(event) { function handleToggleClick(event) {
event.stopPropagation(); event.stopPropagation();
console.log("[Sidebar][Action] Toggle sidebar"); console.log("[Sidebar][Action] Toggle sidebar");
toggleSidebar(); toggleSidebar();
} }
// Handle mobile overlay click
function handleOverlayClick() { function handleOverlayClick() {
console.log("[Sidebar][Action] Close mobile overlay"); console.log("[Sidebar][Action] Close mobile overlay");
closeMobile(); closeMobile();
@@ -209,7 +202,7 @@
<!-- Mobile overlay (only on mobile) --> <!-- Mobile overlay (only on mobile) -->
{#if isMobileOpen} {#if isMobileOpen}
<div <div
class="mobile-overlay" class="fixed inset-0 bg-black/50 z-20 md:hidden"
on:click={handleOverlayClick} on:click={handleOverlayClick}
on:keydown={(e) => e.key === "Escape" && handleOverlayClick()} on:keydown={(e) => e.key === "Escape" && handleOverlayClick()}
role="presentation" role="presentation"
@@ -218,12 +211,18 @@
<!-- Sidebar --> <!-- Sidebar -->
<div <div
class="sidebar {isExpanded ? 'expanded' : 'collapsed'} {isMobileOpen class="bg-white border-r border-gray-200 flex flex-col h-screen fixed left-0 top-0 z-30 transition-[width] duration-200 ease-in-out
? 'mobile' {isExpanded ? 'w-sidebar' : 'w-sidebar-collapsed'}
: 'mobile-hidden'}" {isMobileOpen
? 'translate-x-0 w-sidebar'
: '-translate-x-full md:translate-x-0'}"
>
<!-- Header -->
<div
class="flex items-center p-4 border-b border-gray-200 {isExpanded
? 'justify-between'
: 'justify-center'}"
> >
<!-- Header (simplified, toggle moved to footer) -->
<div class="sidebar-header {isExpanded ? '' : 'collapsed'}">
{#if isExpanded} {#if isExpanded}
<span class="font-semibold text-gray-800">Menu</span> <span class="font-semibold text-gray-800">Menu</span>
{:else} {:else}
@@ -232,13 +231,14 @@
</div> </div>
<!-- Navigation items --> <!-- Navigation items -->
<nav class="nav-section"> <nav class="flex-1 overflow-y-auto py-2">
{#each categories as category} {#each categories as category}
<div class="category"> <div>
<!-- Category Header --> <!-- Category Header -->
<div <div
class="category-header {activeCategory === category.id class="flex items-center justify-between px-4 py-3 cursor-pointer transition-colors hover:bg-gray-100
? 'active' {activeCategory === category.id
? 'bg-primary-light text-primary md:border-r-2 md:border-primary'
: ''}" : ''}"
on:click={(e) => handleCategoryToggle(category.id, e)} on:click={(e) => handleCategoryToggle(category.id, e)}
on:keydown={(e) => on:keydown={(e) =>
@@ -251,7 +251,7 @@
> >
<div class="flex items-center"> <div class="flex items-center">
<svg <svg
class="nav-icon" class="w-5 h-5 shrink-0"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
@@ -261,13 +261,17 @@
<path d={category.icon} /> <path d={category.icon} />
</svg> </svg>
{#if isExpanded} {#if isExpanded}
<span class="nav-label">{category.label}</span> <span class="ml-3 text-sm font-medium truncate"
>{category.label}</span
>
{/if} {/if}
</div> </div>
{#if isExpanded} {#if isExpanded}
<svg <svg
class="category-toggle {expandedCategories.has(category.id) class="text-gray-400 transition-transform duration-200 {expandedCategories.has(
? 'expanded' category.id,
)
? 'rotate-180'
: ''}" : ''}"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="16" width="16"
@@ -284,10 +288,13 @@
<!-- Sub Items (only when expanded) --> <!-- Sub Items (only when expanded) -->
{#if isExpanded && expandedCategories.has(category.id)} {#if isExpanded && expandedCategories.has(category.id)}
<div class="sub-items"> <div class="bg-gray-50 overflow-hidden transition-all duration-200">
{#each category.subItems as subItem} {#each category.subItems as subItem}
<div <div
class="sub-item {activeItem === subItem.path ? 'active' : ''}" class="flex items-center px-4 py-2 pl-12 cursor-pointer transition-colors text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900
{activeItem === subItem.path
? 'bg-primary-light text-primary'
: ''}"
on:click={() => handleSubItemClick(category.id, subItem.path)} on:click={() => handleSubItemClick(category.id, subItem.path)}
on:keydown={(e) => on:keydown={(e) =>
(e.key === "Enter" || e.key === " ") && (e.key === "Enter" || e.key === " ") &&
@@ -306,8 +313,11 @@
<!-- Footer with Collapse button --> <!-- Footer with Collapse button -->
{#if isExpanded} {#if isExpanded}
<div class="sidebar-footer"> <div class="border-t border-gray-200 p-4">
<button class="collapse-btn" on:click={handleToggleClick}> <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}
>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="16" width="16"
@@ -324,8 +334,12 @@
</button> </button>
</div> </div>
{:else} {:else}
<div class="sidebar-footer"> <div class="border-t border-gray-200 p-4">
<button class="collapse-btn" on:click={handleToggleClick} aria-label="Expand sidebar"> <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="Expand sidebar"
>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="16" width="16"
@@ -337,101 +351,10 @@
> >
<path d="M9 18l6-6-6-6" /> <path d="M9 18l6-6-6-6" />
</svg> </svg>
<span class="collapse-btn-text">Expand</span> <span class="ml-2">Expand</span>
</button> </button>
</div> </div>
{/if} {/if}
</div> </div>
<!-- [/DEF:Sidebar:Component] --> <!-- [/DEF:Sidebar:Component] -->
<style>
.sidebar {
@apply bg-white border-r border-gray-200 flex flex-col h-screen fixed left-0 top-0 z-30;
transition: width 0.2s ease-in-out;
}
.sidebar.expanded {
width: 240px;
}
.sidebar.collapsed {
width: 64px;
}
.sidebar.mobile {
@apply translate-x-0;
width: 240px;
}
.sidebar.mobile-hidden {
@apply -translate-x-full md:translate-x-0;
}
.sidebar-header {
@apply flex items-center justify-between p-4 border-b border-gray-200;
}
.sidebar-header.collapsed {
@apply justify-center;
}
.nav-icon {
@apply w-5 h-5 flex-shrink-0;
}
.nav-label {
@apply ml-3 text-sm font-medium truncate;
}
.category-header {
@apply flex items-center justify-between px-4 py-3 cursor-pointer transition-colors hover:bg-gray-100;
}
.category-header.active {
@apply bg-blue-50 text-blue-600 md:border-r-2 md:border-blue-600;
}
.category-toggle {
@apply text-gray-400 transition-transform duration-200;
}
.category-toggle.expanded {
@apply rotate-180;
}
.sub-items {
@apply bg-gray-50 overflow-hidden transition-all duration-200;
}
.sub-item {
@apply flex items-center px-4 py-2 pl-12 cursor-pointer transition-colors text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900;
}
.sub-item.active {
@apply bg-blue-50 text-blue-600;
}
.sidebar-footer {
@apply border-t border-gray-200 p-4;
}
.collapse-btn {
@apply flex items-center justify-center w-full px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors;
}
.collapse-btn-text {
@apply ml-2;
}
/* Mobile overlay */
.mobile-overlay {
@apply fixed inset-0 bg-black bg-opacity-50 z-20;
}
@media (min-width: 768px) {
.mobile-overlay {
@apply hidden;
}
}
</style>

View File

@@ -31,54 +31,38 @@
let showUserMenu = false; let showUserMenu = false;
let isSearchFocused = false; let isSearchFocused = false;
// Subscribe to sidebar store for responsive layout
$: isExpanded = $sidebarStore?.isExpanded ?? true; $: isExpanded = $sidebarStore?.isExpanded ?? true;
// Subscribe to activity store
$: activeCount = $activityStore?.activeCount || 0; $: activeCount = $activityStore?.activeCount || 0;
$: recentTasks = $activityStore?.recentTasks || []; $: recentTasks = $activityStore?.recentTasks || [];
// Get user from auth store
$: user = $auth?.user || null; $: user = $auth?.user || null;
// Toggle user menu
function toggleUserMenu(event) { function toggleUserMenu(event) {
event.stopPropagation(); event.stopPropagation();
showUserMenu = !showUserMenu; showUserMenu = !showUserMenu;
console.log(`[TopNavbar][Action] Toggle user menu: ${showUserMenu}`);
} }
// Close user menu
function closeUserMenu() { function closeUserMenu() {
showUserMenu = false; showUserMenu = false;
} }
// Handle logout
function handleLogout() { function handleLogout() {
console.log("[TopNavbar][Action] Logout");
auth.logout(); auth.logout();
closeUserMenu(); closeUserMenu();
// Navigate to login
window.location.href = "/login"; window.location.href = "/login";
} }
// Handle activity indicator click - open Task Drawer with most recent task
function handleActivityClick() { function handleActivityClick() {
console.log("[TopNavbar][Action] Activity indicator clicked");
// Open drawer with the most recent running task, or list mode
const runningTask = recentTasks.find((t) => t.status === "RUNNING"); const runningTask = recentTasks.find((t) => t.status === "RUNNING");
if (runningTask) { if (runningTask) {
openDrawerForTask(runningTask.taskId); openDrawerForTask(runningTask.taskId);
} else if (recentTasks.length > 0) { } else if (recentTasks.length > 0) {
openDrawerForTask(recentTasks[recentTasks.length - 1].taskId); openDrawerForTask(recentTasks[recentTasks.length - 1].taskId);
} else { } else {
// No tracked tasks — open in list mode to show recent tasks from API
openDrawer(); openDrawer();
} }
dispatch("activityClick"); dispatch("activityClick");
} }
// Handle search focus
function handleSearchFocus() { function handleSearchFocus() {
isSearchFocused = true; isSearchFocused = true;
} }
@@ -87,34 +71,31 @@
isSearchFocused = false; isSearchFocused = false;
} }
// Close dropdowns when clicking outside
function handleDocumentClick(event) { function handleDocumentClick(event) {
if (!event.target.closest(".user-menu-container")) { if (!event.target.closest(".user-menu-container")) {
closeUserMenu(); closeUserMenu();
} }
} }
// Listen for document clicks
if (typeof document !== "undefined") { if (typeof document !== "undefined") {
document.addEventListener("click", handleDocumentClick); document.addEventListener("click", handleDocumentClick);
} }
// Handle hamburger menu click for mobile
function handleHamburgerClick(event) { function handleHamburgerClick(event) {
event.stopPropagation(); event.stopPropagation();
console.log("[TopNavbar][Action] Toggle mobile sidebar");
toggleMobileSidebar(); toggleMobileSidebar();
} }
</script> </script>
<nav <nav
class="navbar {isExpanded ? 'with-sidebar' : 'with-collapsed-sidebar'} mobile" class="bg-white border-b border-gray-200 fixed top-0 right-0 left-0 h-16 flex items-center justify-between px-4 z-40
{isExpanded ? 'md:left-[240px]' : 'md:left-16'}"
> >
<!-- Left section: Hamburger (mobile) + Logo --> <!-- Left section: Hamburger (mobile) + Logo -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<!-- Hamburger Menu (mobile only) --> <!-- Hamburger Menu (mobile only) -->
<button <button
class="hamburger-btn" class="p-2 rounded-lg hover:bg-gray-100 text-gray-600 md:hidden"
on:click={handleHamburgerClick} on:click={handleHamburgerClick}
aria-label="Toggle menu" aria-label="Toggle menu"
> >
@@ -134,9 +115,12 @@
</button> </button>
<!-- Logo/Brand --> <!-- Logo/Brand -->
<a href="/" class="logo-link"> <a
href="/"
class="flex items-center text-xl font-bold text-gray-800 hover:text-primary transition-colors"
>
<svg <svg
class="logo-icon" class="w-8 h-8 mr-2 text-primary"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="currentColor" fill="currentColor"
@@ -148,10 +132,11 @@
</div> </div>
<!-- Search placeholder (non-functional for now) --> <!-- Search placeholder (non-functional for now) -->
<div class="search-container"> <div class="flex-1 max-w-xl mx-4 hidden md:block">
<input <input
type="text" type="text"
class="search-input {isSearchFocused ? 'focused' : ''}" 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 || "Search..."} placeholder={$t.common.search || "Search..."}
on:focus={handleSearchFocus} on:focus={handleSearchFocus}
on:blur={handleSearchBlur} on:blur={handleSearchBlur}
@@ -159,10 +144,10 @@
</div> </div>
<!-- Nav Actions --> <!-- Nav Actions -->
<div class="nav-actions"> <div class="flex items-center space-x-4">
<!-- Activity Indicator --> <!-- Activity Indicator -->
<div <div
class="activity-indicator" class="relative cursor-pointer p-2 rounded-lg hover:bg-gray-100 transition-colors"
on:click={handleActivityClick} on:click={handleActivityClick}
on:keydown={(e) => on:keydown={(e) =>
(e.key === "Enter" || e.key === " ") && handleActivityClick()} (e.key === "Enter" || e.key === " ") && handleActivityClick()}
@@ -171,7 +156,7 @@
aria-label="Activity" aria-label="Activity"
> >
<svg <svg
class="activity-icon" class="w-6 h-6 text-gray-600"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
@@ -183,14 +168,17 @@
/> />
</svg> </svg>
{#if activeCount > 0} {#if activeCount > 0}
<span class="activity-badge">{activeCount}</span> <span
class="absolute -top-1 -right-1 bg-destructive text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center"
>{activeCount}</span
>
{/if} {/if}
</div> </div>
<!-- User Menu --> <!-- User Menu -->
<div class="user-menu-container"> <div class="user-menu-container relative">
<div <div
class="user-avatar" 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:click={toggleUserMenu}
on:keydown={(e) => on:keydown={(e) =>
(e.key === "Enter" || e.key === " ") && toggleUserMenu(e)} (e.key === "Enter" || e.key === " ") && toggleUserMenu(e)}
@@ -208,13 +196,17 @@
</div> </div>
<!-- User Dropdown --> <!-- User Dropdown -->
<div class="user-dropdown {showUserMenu ? '' : 'hidden'}"> <div
<div class="dropdown-item"> class="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50 {showUserMenu
? ''
: 'hidden'}"
>
<div class="px-4 py-2 text-sm text-gray-700">
<strong>{user?.username || "User"}</strong> <strong>{user?.username || "User"}</strong>
</div> </div>
<div class="dropdown-divider"></div> <div class="border-t border-gray-200 my-1"></div>
<div <div
class="dropdown-item" class="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer"
on:click={() => { on:click={() => {
window.location.href = "/settings"; window.location.href = "/settings";
}} }}
@@ -227,7 +219,7 @@
{$t.nav?.settings || "Settings"} {$t.nav?.settings || "Settings"}
</div> </div>
<div <div
class="dropdown-item danger" class="px-4 py-2 text-sm text-destructive hover:bg-destructive-light cursor-pointer"
on:click={handleLogout} on:click={handleLogout}
on:keydown={(e) => on:keydown={(e) =>
(e.key === "Enter" || e.key === " ") && handleLogout()} (e.key === "Enter" || e.key === " ") && handleLogout()}
@@ -242,96 +234,3 @@
</nav> </nav>
<!-- [/DEF:TopNavbar:Component] --> <!-- [/DEF:TopNavbar:Component] -->
<style>
.navbar {
@apply bg-white border-b border-gray-200 fixed top-0 right-0 left-0 h-16 flex items-center justify-between px-4 z-40;
}
.navbar.with-sidebar {
@apply md:left-64;
}
.navbar.with-collapsed-sidebar {
@apply md:left-16;
}
.navbar.mobile {
@apply left-0;
}
.logo-link {
@apply flex items-center text-xl font-bold text-gray-800 hover:text-blue-600 transition-colors;
}
.logo-icon {
@apply w-8 h-8 mr-2 text-blue-600;
}
.search-container {
@apply flex-1 max-w-xl mx-4;
}
.search-input {
@apply w-full px-4 py-2 bg-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all;
}
.search-input.focused {
@apply bg-white border border-blue-500;
}
.nav-actions {
@apply flex items-center space-x-4;
}
.hamburger-btn {
@apply p-2 rounded-lg hover:bg-gray-100 text-gray-600 md:hidden;
}
.activity-indicator {
@apply relative cursor-pointer p-2 rounded-lg hover:bg-gray-100 transition-colors;
}
.activity-badge {
@apply absolute -top-1 -right-1 bg-red-500 text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center;
}
.activity-icon {
@apply w-6 h-6 text-gray-600;
}
.user-menu-container {
@apply relative;
}
.user-avatar {
@apply w-8 h-8 rounded-full bg-blue-600 text-white flex items-center justify-center cursor-pointer hover:bg-blue-700 transition-colors;
}
.user-dropdown {
@apply absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50;
}
.user-dropdown.hidden {
display: none;
}
.dropdown-item {
@apply px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer;
}
.dropdown-item.danger {
@apply text-red-600 hover:bg-red-50;
}
.dropdown-divider {
@apply border-t border-gray-200 my-1;
}
/* Mobile responsive */
@media (max-width: 768px) {
.search-container {
display: none;
}
}
</style>

View File

@@ -9,45 +9,51 @@
@INVARIANT: Supports accessible labels and keyboard navigation. @INVARIANT: Supports accessible labels and keyboard navigation.
--> -->
<script lang="ts"> <script>
// [SECTION: IMPORTS] // [SECTION: IMPORTS]
import { cn } from '$lib/utils.js';
// [/SECTION: IMPORTS] // [/SECTION: IMPORTS]
// [SECTION: PROPS] // [SECTION: PROPS]
/** /**
* @purpose Define component interface and default values. * @purpose Define component interface and default values (Svelte 5 Runes).
*/ */
export let variant: 'primary' | 'secondary' | 'danger' | 'ghost' = 'primary'; let {
export let size: 'sm' | 'md' | 'lg' = 'md'; variant = 'primary',
export let isLoading: boolean = false; size = 'md',
export let disabled: boolean = false; isLoading = false,
export let type: 'button' | 'submit' | 'reset' = 'button'; disabled = false,
let className: string = ""; type = 'button',
export { className as class }; class: className = '',
children,
onclick,
...rest
} = $props();
// [/SECTION: PROPS] // [/SECTION: PROPS]
const baseStyles = "inline-flex items-center justify-center font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 rounded-md"; const baseStyles = 'inline-flex items-center justify-center font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 rounded-md';
const variants = { const variants = {
primary: "bg-blue-600 text-white hover:bg-blue-700 focus-visible:ring-blue-500", primary: 'bg-primary text-white hover:bg-primary-hover focus-visible:ring-primary-ring',
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200 focus-visible:ring-gray-500", secondary: 'bg-secondary text-secondary-text hover:bg-secondary-hover focus-visible:ring-secondary-ring',
danger: "bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500", danger: 'bg-destructive text-white hover:bg-destructive-hover focus-visible:ring-destructive-ring',
ghost: "bg-transparent hover:bg-gray-100 text-gray-700 focus-visible:ring-gray-500" ghost: 'bg-transparent hover:bg-ghost-hover text-ghost-text focus-visible:ring-ghost-ring',
}; };
const sizes = { const sizes = {
sm: "h-8 px-3 text-xs", sm: 'h-8 px-3 text-xs',
md: "h-10 px-4 py-2 text-sm", md: 'h-10 px-4 py-2 text-sm',
lg: "h-12 px-6 text-base" lg: 'h-12 px-6 text-base',
}; };
</script> </script>
<!-- [SECTION: TEMPLATE] --> <!-- [SECTION: TEMPLATE] -->
<button <button
{type} {type}
class="{baseStyles} {variants[variant]} {sizes[size]} {className}" class={cn(baseStyles, variants[variant], sizes[size], className)}
disabled={disabled || isLoading} disabled={disabled || isLoading}
on:click {onclick}
{...rest}
> >
{#if isLoading} {#if isLoading}
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-current" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-current" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
@@ -55,7 +61,7 @@
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg> </svg>
{/if} {/if}
<slot /> {@render children?.()}
</button> </button>
<!-- [/SECTION: TEMPLATE] --> <!-- [/SECTION: TEMPLATE] -->

View File

@@ -6,29 +6,44 @@
@LAYER: Atom @LAYER: Atom
--> -->
<script lang="ts"> <script>
// [SECTION: IMPORTS]
import { cn } from "$lib/utils.js";
// [/SECTION: IMPORTS]
// [SECTION: PROPS] // [SECTION: PROPS]
export let title: string = ""; let {
export let padding: 'none' | 'sm' | 'md' | 'lg' = 'md'; title = "",
padding = "md",
class: className = "",
children,
...rest
} = $props();
// [/SECTION: PROPS] // [/SECTION: PROPS]
const paddings = { const paddings = {
none: "p-0", none: "p-0",
sm: "p-3", sm: "p-3",
md: "p-6", md: "p-6",
lg: "p-8" lg: "p-8",
}; };
</script> </script>
<!-- [SECTION: TEMPLATE] --> <!-- [SECTION: TEMPLATE] -->
<div class="rounded-lg border border-gray-200 bg-white text-gray-950 shadow-sm"> <div
class={cn(
"rounded-lg border border-gray-200 bg-white text-gray-950 shadow-sm",
className,
)}
{...rest}
>
{#if title} {#if title}
<div class="flex flex-col space-y-1.5 p-6 border-b border-gray-100"> <div class="flex flex-col space-y-1.5 p-6 border-b border-gray-100">
<h3 class="text-lg font-semibold leading-none tracking-tight">{title}</h3> <h3 class="text-lg font-semibold leading-none tracking-tight">{title}</h3>
</div> </div>
{/if} {/if}
<div class="{paddings[padding]}"> <div class={paddings[padding]}>
<slot /> {@render children?.()}
</div> </div>
</div> </div>
<!-- [/SECTION: TEMPLATE] --> <!-- [/SECTION: TEMPLATE] -->

View File

@@ -8,14 +8,22 @@
@INVARIANT: Consistent spacing and focus states. @INVARIANT: Consistent spacing and focus states.
--> -->
<script lang="ts"> <script>
// [SECTION: IMPORTS]
import { cn } from "$lib/utils.js";
// [/SECTION: IMPORTS]
// [SECTION: PROPS] // [SECTION: PROPS]
export let label: string = ""; let {
export let value: string = ""; label = "",
export let placeholder: string = ""; value = $bindable(""),
export let error: string = ""; placeholder = "",
export let disabled: boolean = false; error = "",
export let type: 'text' | 'password' | 'email' | 'number' = 'text'; disabled = false,
type = "text",
class: className = "",
...rest
} = $props();
// [/SECTION: PROPS] // [/SECTION: PROPS]
let id = "input-" + Math.random().toString(36).substr(2, 9); let id = "input-" + Math.random().toString(36).substr(2, 9);
@@ -35,11 +43,16 @@
{placeholder} {placeholder}
{disabled} {disabled}
bind:value bind:value
class="flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 {error ? 'border-red-500' : ''}" class={cn(
"flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
error ? "border-destructive" : "",
className,
)}
{...rest}
/> />
{#if error} {#if error}
<span class="text-xs text-red-500">{error}</span> <span class="text-xs text-destructive">{error}</span>
{/if} {/if}
</div> </div>
<!-- [/SECTION: TEMPLATE] --> <!-- [/SECTION: TEMPLATE] -->

View File

@@ -7,24 +7,21 @@
@RELATION: BINDS_TO -> i18n.locale @RELATION: BINDS_TO -> i18n.locale
--> -->
<script lang="ts"> <script>
// [SECTION: IMPORTS] // [SECTION: IMPORTS]
import { locale } from '$lib/i18n'; import { locale } from "$lib/i18n";
import Select from './Select.svelte'; import Select from "./Select.svelte";
// [/SECTION: IMPORTS] // [/SECTION: IMPORTS]
const options = [ const options = [
{ value: 'ru', label: 'Русский' }, { value: "ru", label: "Русский" },
{ value: 'en', label: 'English' } { value: "en", label: "English" },
]; ];
</script> </script>
<!-- [SECTION: TEMPLATE] --> <!-- [SECTION: TEMPLATE] -->
<div class="w-32"> <div class="w-32">
<Select <Select bind:value={$locale} {options} />
bind:value={$locale}
{options}
/>
</div> </div>
<!-- [/SECTION: TEMPLATE] --> <!-- [/SECTION: TEMPLATE] -->

View File

@@ -6,20 +6,33 @@
@LAYER: Atom @LAYER: Atom
--> -->
<script lang="ts"> <script>
// [SECTION: IMPORTS]
import { cn } from "$lib/utils.js";
// [/SECTION: IMPORTS]
// [SECTION: PROPS] // [SECTION: PROPS]
export let title: string = ""; let {
title = "",
class: className = "",
subtitle,
actions,
...rest
} = $props();
// [/SECTION: PROPS] // [/SECTION: PROPS]
</script> </script>
<!-- [SECTION: TEMPLATE] --> <!-- [SECTION: TEMPLATE] -->
<header class="flex items-center justify-between mb-8"> <header
class={cn("flex items-center justify-between mb-8", className)}
{...rest}
>
<div class="space-y-1"> <div class="space-y-1">
<h1 class="text-3xl font-bold tracking-tight text-gray-900">{title}</h1> <h1 class="text-3xl font-bold tracking-tight text-gray-900">{title}</h1>
<slot name="subtitle" /> {@render subtitle?.()}
</div> </div>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<slot name="actions" /> {@render actions?.()}
</div> </div>
</header> </header>
<!-- [/SECTION: TEMPLATE] --> <!-- [/SECTION: TEMPLATE] -->

View File

@@ -6,12 +6,20 @@
@LAYER: Atom @LAYER: Atom
--> -->
<script lang="ts"> <script>
// [SECTION: IMPORTS]
import { cn } from "$lib/utils.js";
// [/SECTION: IMPORTS]
// [SECTION: PROPS] // [SECTION: PROPS]
export let label: string = ""; let {
export let value: string | number = ""; label = "",
export let options: Array<{ value: string | number, label: string }> = []; value = $bindable(""),
export let disabled: boolean = false; options = [],
disabled = false,
class: className = "",
...rest
} = $props();
// [/SECTION: PROPS] // [/SECTION: PROPS]
let id = "select-" + Math.random().toString(36).substr(2, 9); let id = "select-" + Math.random().toString(36).substr(2, 9);
@@ -29,7 +37,11 @@
{id} {id}
{disabled} {disabled}
bind:value bind:value
class="flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" class={cn(
"flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...rest}
> >
{#each options as option} {#each options as option}
<option value={option.value}>{option.label}</option> <option value={option.value}>{option.label}</option>

View File

@@ -0,0 +1,8 @@
/**
* Merges class names into a single string.
* @param {...(string | undefined | null | false)} inputs
* @returns {string}
*/
export function cn(...inputs) {
return inputs.filter(Boolean).join(" ");
}

View File

@@ -10,25 +10,35 @@
* @UX_FEEDBACK: Redirects to /dashboards * @UX_FEEDBACK: Redirects to /dashboards
*/ */
import { onMount } from 'svelte'; import { onMount } from "svelte";
import { goto } from '$app/navigation'; import { goto } from "$app/navigation";
onMount(() => { onMount(() => {
// Redirect to Dashboard Hub as per UX requirements // Redirect to Dashboard Hub as per UX requirements
goto('/dashboards', { replaceState: true }); goto("/dashboards", { replaceState: true });
}); });
</script> </script>
<style> <div class="flex items-center justify-center min-h-screen">
.loading { <svg
@apply flex items-center justify-center min-h-screen; class="animate-spin h-8 w-8 text-blue-600"
} xmlns="http://www.w3.org/2000/svg"
</style> fill="none"
viewBox="0 0 24 24"
<div class="loading"> >
<svg class="animate-spin h-8 w-8 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <circle
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> class="opacity-25"
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg> </svg>
</div> </div>

View File

@@ -230,7 +230,5 @@
<!-- [/SECTION: TEMPLATE] --> <!-- [/SECTION: TEMPLATE] -->
</ProtectedRoute> </ProtectedRoute>
<style>
</style>
<!-- [/DEF:AdminRolesPage:Component] --> <!-- [/DEF:AdminRolesPage:Component] -->

View File

@@ -346,7 +346,5 @@
<!-- [/SECTION: TEMPLATE] --> <!-- [/SECTION: TEMPLATE] -->
</ProtectedRoute> </ProtectedRoute>
<style>
</style>
<!-- [/DEF:AdminSettingsPage:Component] --> <!-- [/DEF:AdminSettingsPage:Component] -->

View File

@@ -278,7 +278,5 @@
<!-- [/SECTION: TEMPLATE] --> <!-- [/SECTION: TEMPLATE] -->
</ProtectedRoute> </ProtectedRoute>
<style>
</style>
<!-- [/DEF:AdminUsersPage:Component] --> <!-- [/DEF:AdminUsersPage:Component] -->

View File

@@ -14,16 +14,16 @@
* @UX_RECOVERY: Refresh button reloads dataset details * @UX_RECOVERY: Refresh button reloads dataset details
*/ */
import { onMount } from 'svelte'; import { onMount } from "svelte";
import { goto } from '$app/navigation'; import { goto } from "$app/navigation";
import { page } from '$app/stores'; import { page } from "$app/stores";
import { t } from '$lib/i18n'; import { t } from "$lib/i18n";
import { api } from '$lib/api.js'; import { api } from "$lib/api.js";
import { openDrawerForTask } from '$lib/stores/taskDrawer.js'; import { openDrawerForTask } from "$lib/stores/taskDrawer.js";
// Get dataset ID from URL params // Get dataset ID from URL params
$: datasetId = $page.params.id; $: datasetId = $page.params.id;
$: envId = $page.url.searchParams.get('env_id') || ''; $: envId = $page.url.searchParams.get("env_id") || "";
// State // State
let dataset = null; let dataset = null;
@@ -38,7 +38,7 @@
// Load dataset details from API // Load dataset details from API
async function loadDatasetDetail() { async function loadDatasetDetail() {
if (!datasetId || !envId) { if (!datasetId || !envId) {
error = 'Missing dataset ID or environment ID'; error = "Missing dataset ID or environment ID";
isLoading = false; isLoading = false;
return; return;
} }
@@ -49,8 +49,8 @@
const response = await api.getDatasetDetail(envId, datasetId); const response = await api.getDatasetDetail(envId, datasetId);
dataset = response; dataset = response;
} catch (err) { } catch (err) {
error = err.message || 'Failed to load dataset details'; error = err.message || "Failed to load dataset details";
console.error('[DatasetDetail][Coherence:Failed]', err); console.error("[DatasetDetail][Coherence:Failed]", err);
} finally { } finally {
isLoading = false; isLoading = false;
} }
@@ -68,290 +68,241 @@
// Get column type icon/color // Get column type icon/color
function getColumnTypeClass(type) { function getColumnTypeClass(type) {
if (!type) return 'text-gray-500'; if (!type) return "text-gray-500";
const lowerType = type.toLowerCase(); const lowerType = type.toLowerCase();
if (lowerType.includes('int') || lowerType.includes('float') || lowerType.includes('num')) { if (
return 'text-blue-600 bg-blue-50'; lowerType.includes("int") ||
} else if (lowerType.includes('date') || lowerType.includes('time')) { lowerType.includes("float") ||
return 'text-green-600 bg-green-50'; lowerType.includes("num")
} else if (lowerType.includes('str') || lowerType.includes('text') || lowerType.includes('char')) { ) {
return 'text-purple-600 bg-purple-50'; return "text-blue-600 bg-blue-50";
} else if (lowerType.includes('bool')) { } else if (lowerType.includes("date") || lowerType.includes("time")) {
return 'text-orange-600 bg-orange-50'; return "text-green-600 bg-green-50";
} else if (
lowerType.includes("str") ||
lowerType.includes("text") ||
lowerType.includes("char")
) {
return "text-purple-600 bg-purple-50";
} else if (lowerType.includes("bool")) {
return "text-orange-600 bg-orange-50";
} }
return 'text-gray-600 bg-gray-50'; return "text-gray-600 bg-gray-50";
} }
// Get mapping progress percentage // Get mapping progress percentage
function getMappingProgress(column) { function getMappingProgress(column) {
// Placeholder: In real implementation, this would check if column has mapping
return column.description ? 100 : 0; return column.description ? 100 : 0;
} }
</script> </script>
<style> <div class="max-w-7xl mx-auto px-4 py-6">
.container {
@apply max-w-7xl mx-auto px-4 py-6;
}
.header {
@apply flex items-center justify-between mb-6;
}
.back-btn {
@apply flex items-center gap-2 text-gray-600 hover:text-gray-900 transition-colors;
}
.title {
@apply text-2xl font-bold text-gray-900;
}
.subtitle {
@apply text-sm text-gray-500 mt-1;
}
.detail-grid {
@apply grid grid-cols-1 lg:grid-cols-3 gap-6;
}
.detail-card {
@apply bg-white border border-gray-200 rounded-lg p-6;
}
.card-title {
@apply text-lg font-semibold text-gray-900 mb-4;
}
.info-row {
@apply flex justify-between py-2 border-b border-gray-100 last:border-0;
}
.info-label {
@apply text-sm text-gray-500;
}
.info-value {
@apply text-sm font-medium text-gray-900;
}
.columns-section {
@apply lg:col-span-2;
}
.columns-grid {
@apply grid grid-cols-1 md:grid-cols-2 gap-3;
}
.column-item {
@apply p-3 border border-gray-200 rounded-lg hover:border-blue-300 transition-colors;
}
.column-header {
@apply flex items-center justify-between mb-2;
}
.column-name {
@apply font-medium text-gray-900;
}
.column-type {
@apply text-xs px-2 py-1 rounded;
}
.column-meta {
@apply flex items-center gap-2 text-xs text-gray-500;
}
.column-description {
@apply text-sm text-gray-600 mt-2;
}
.mapping-badge {
@apply inline-flex items-center px-2 py-0.5 text-xs rounded-full;
}
.mapping-badge.mapped {
@apply bg-green-100 text-green-800;
}
.mapping-badge.unmapped {
@apply bg-gray-100 text-gray-600;
}
.linked-dashboards-list {
@apply space-y-2;
}
.linked-dashboard-item {
@apply flex items-center gap-3 p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors;
}
.dashboard-icon {
@apply w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center text-blue-600;
}
.dashboard-info {
@apply flex-1;
}
.dashboard-title {
@apply font-medium text-gray-900;
}
.dashboard-id {
@apply text-xs text-gray-500;
}
.sql-section {
@apply mt-6;
}
.sql-code {
@apply bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm font-mono;
}
.empty-state {
@apply py-8 text-center text-gray-500;
}
.skeleton {
@apply animate-pulse bg-gray-200 rounded;
}
.error-banner {
@apply bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4 flex items-center justify-between;
}
.retry-btn {
@apply px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors;
}
</style>
<div class="container">
<!-- Header --> <!-- Header -->
<div class="header"> <div class="flex items-center justify-between mb-6">
<div> <div>
<button class="back-btn" on:click={goBack}> <button
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> class="flex items-center gap-2 text-gray-600 hover:text-gray-900 transition-colors"
on:click={goBack}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" /> <path d="M19 12H5M12 19l-7-7 7-7" />
</svg> </svg>
{$t.common?.back || 'Back to Datasets'} {$t.common?.back || "Back to Datasets"}
</button> </button>
{#if dataset} {#if dataset}
<h1 class="title mt-4">{dataset.table_name}</h1> <h1 class="text-2xl font-bold text-gray-900 mt-4">
<p class="subtitle">{dataset.schema} {dataset.database}</p> {dataset.table_name}
</h1>
<p class="text-sm text-gray-500 mt-1">
{dataset.schema}{dataset.database}
</p>
{:else if !isLoading} {:else if !isLoading}
<h1 class="title mt-4">{$t.datasets?.detail_title || 'Dataset Details'}</h1> <h1 class="text-2xl font-bold text-gray-900 mt-4">
{$t.datasets?.detail_title || "Dataset Details"}
</h1>
{/if} {/if}
</div> </div>
<button class="retry-btn" on:click={loadDatasetDetail}> <button
{$t.common?.refresh || 'Refresh'} class="px-4 py-2 bg-destructive text-white rounded hover:bg-destructive-hover transition-colors"
on:click={loadDatasetDetail}
>
{$t.common?.refresh || "Refresh"}
</button> </button>
</div> </div>
<!-- Error Banner --> <!-- Error Banner -->
{#if error} {#if error}
<div class="error-banner"> <div
class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4 flex items-center justify-between"
>
<span>{error}</span> <span>{error}</span>
<button class="retry-btn" on:click={loadDatasetDetail}> <button
{$t.common?.retry || 'Retry'} class="px-4 py-2 bg-destructive text-white rounded hover:bg-destructive-hover transition-colors"
on:click={loadDatasetDetail}
>
{$t.common?.retry || "Retry"}
</button> </button>
</div> </div>
{/if} {/if}
<!-- Loading State --> <!-- Loading State -->
{#if isLoading} {#if isLoading}
<div class="detail-grid"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="detail-card"> <div class="bg-white border border-gray-200 rounded-lg p-6">
<div class="skeleton h-6 w-1/2 mb-4"></div> <div class="animate-pulse bg-gray-200 rounded h-6 w-1/2 mb-4"></div>
{#each Array(5) as _} {#each Array(5) as _}
<div class="info-row"> <div
<div class="skeleton h-4 w-20"></div> class="flex justify-between py-2 border-b border-gray-100 last:border-0"
<div class="skeleton h-4 w-32"></div> >
<div class="animate-pulse bg-gray-200 rounded h-4 w-20"></div>
<div class="animate-pulse bg-gray-200 rounded h-4 w-32"></div>
</div> </div>
{/each} {/each}
</div> </div>
<div class="detail-card columns-section"> <div class="bg-white border border-gray-200 rounded-lg p-6 lg:col-span-2">
<div class="skeleton h-6 w-1/3 mb-4"></div> <div class="animate-pulse bg-gray-200 rounded h-6 w-1/3 mb-4"></div>
<div class="columns-grid"> <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{#each Array(4) as _} {#each Array(4) as _}
<div class="column-item"> <div class="p-3 border border-gray-200 rounded-lg">
<div class="skeleton h-4 w-full mb-2"></div> <div
<div class="skeleton h-3 w-16"></div> class="animate-pulse bg-gray-200 rounded h-4 w-full mb-2"
></div>
<div class="animate-pulse bg-gray-200 rounded h-3 w-16"></div>
</div> </div>
{/each} {/each}
</div> </div>
</div> </div>
</div> </div>
{:else if dataset} {:else if dataset}
<div class="detail-grid"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Dataset Info Card --> <!-- Dataset Info Card -->
<div class="detail-card"> <div class="bg-white border border-gray-200 rounded-lg p-6">
<h2 class="card-title">{$t.datasets?.info || 'Dataset Information'}</h2> <h2 class="text-lg font-semibold text-gray-900 mb-4">
<div class="info-row"> {$t.datasets?.info || "Dataset Information"}
<span class="info-label">{$t.datasets?.table_name || 'Table Name'}</span> </h2>
<span class="info-value">{dataset.table_name}</span> <div class="flex justify-between py-2 border-b border-gray-100">
<span class="text-sm text-gray-500"
>{$t.datasets?.table_name || "Table Name"}</span
>
<span class="text-sm font-medium text-gray-900"
>{dataset.table_name}</span
>
</div> </div>
<div class="info-row"> <div class="flex justify-between py-2 border-b border-gray-100">
<span class="info-label">{$t.datasets?.schema || 'Schema'}</span> <span class="text-sm text-gray-500"
<span class="info-value">{dataset.schema || '-'}</span> >{$t.datasets?.schema || "Schema"}</span
>
<span class="text-sm font-medium text-gray-900"
>{dataset.schema || "-"}</span
>
</div> </div>
<div class="info-row"> <div class="flex justify-between py-2 border-b border-gray-100">
<span class="info-label">{$t.datasets?.database || 'Database'}</span> <span class="text-sm text-gray-500"
<span class="info-value">{dataset.database}</span> >{$t.datasets?.database || "Database"}</span
>
<span class="text-sm font-medium text-gray-900"
>{dataset.database}</span
>
</div> </div>
<div class="info-row"> <div class="flex justify-between py-2 border-b border-gray-100">
<span class="info-label">{$t.datasets?.columns_count || 'Columns'}</span> <span class="text-sm text-gray-500"
<span class="info-value">{dataset.column_count}</span> >{$t.datasets?.columns_count || "Columns"}</span
>
<span class="text-sm font-medium text-gray-900"
>{dataset.column_count}</span
>
</div> </div>
<div class="info-row"> <div class="flex justify-between py-2 border-b border-gray-100">
<span class="info-label">{$t.datasets?.linked_dashboards || 'Linked Dashboards'}</span> <span class="text-sm text-gray-500"
<span class="info-value">{dataset.linked_dashboard_count}</span> >{$t.datasets?.linked_dashboards || "Linked Dashboards"}</span
>
<span class="text-sm font-medium text-gray-900"
>{dataset.linked_dashboard_count}</span
>
</div> </div>
{#if dataset.is_sqllab_view} {#if dataset.is_sqllab_view}
<div class="info-row"> <div class="flex justify-between py-2 border-b border-gray-100">
<span class="info-label">{$t.datasets?.type || 'Type'}</span> <span class="text-sm text-gray-500"
<span class="info-value">SQL Lab View</span> >{$t.datasets?.type || "Type"}</span
>
<span class="text-sm font-medium text-gray-900">SQL Lab View</span>
</div> </div>
{/if} {/if}
{#if dataset.created_on} {#if dataset.created_on}
<div class="info-row"> <div class="flex justify-between py-2 border-b border-gray-100">
<span class="info-label">{$t.datasets?.created || 'Created'}</span> <span class="text-sm text-gray-500"
<span class="info-value">{new Date(dataset.created_on).toLocaleDateString()}</span> >{$t.datasets?.created || "Created"}</span
>
<span class="text-sm font-medium text-gray-900"
>{new Date(dataset.created_on).toLocaleDateString()}</span
>
</div> </div>
{/if} {/if}
{#if dataset.changed_on} {#if dataset.changed_on}
<div class="info-row"> <div class="flex justify-between py-2">
<span class="info-label">{$t.datasets?.updated || 'Updated'}</span> <span class="text-sm text-gray-500"
<span class="info-value">{new Date(dataset.changed_on).toLocaleDateString()}</span> >{$t.datasets?.updated || "Updated"}</span
>
<span class="text-sm font-medium text-gray-900"
>{new Date(dataset.changed_on).toLocaleDateString()}</span
>
</div> </div>
{/if} {/if}
</div> </div>
<!-- Linked Dashboards Card --> <!-- Linked Dashboards Card -->
{#if dataset.linked_dashboards && dataset.linked_dashboards.length > 0} {#if dataset.linked_dashboards && dataset.linked_dashboards.length > 0}
<div class="detail-card"> <div class="bg-white border border-gray-200 rounded-lg p-6">
<h2 class="card-title">{$t.datasets?.linked_dashboards || 'Linked Dashboards'} ({dataset.linked_dashboard_count})</h2> <h2 class="text-lg font-semibold text-gray-900 mb-4">
<div class="linked-dashboards-list"> {$t.datasets?.linked_dashboards || "Linked Dashboards"} ({dataset.linked_dashboard_count})
</h2>
<div class="space-y-2">
{#each dataset.linked_dashboards as dashboard} {#each dataset.linked_dashboards as dashboard}
<div <div
class="linked-dashboard-item" class="flex items-center gap-3 p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors"
on:click={() => navigateToDashboard(dashboard.id)} on:click={() => navigateToDashboard(dashboard.id)}
role="button" role="button"
tabindex="0" tabindex="0"
> >
<div class="dashboard-icon"> <div
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center text-blue-600"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" /> <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<line x1="3" y1="9" x2="21" y2="9" /> <line x1="3" y1="9" x2="21" y2="9" />
<line x1="9" y1="21" x2="9" y2="9" /> <line x1="9" y1="21" x2="9" y2="9" />
</svg> </svg>
</div> </div>
<div class="dashboard-info"> <div class="flex-1">
<div class="dashboard-title">{dashboard.title}</div> <div class="font-medium text-gray-900">{dashboard.title}</div>
<div class="dashboard-id">ID: {dashboard.id}{#if dashboard.slug}{dashboard.slug}{/if}</div> <div class="text-xs text-gray-500">
ID: {dashboard.id}{#if dashboard.slug}
{dashboard.slug}{/if}
</div> </div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-gray-400"> </div>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="text-gray-400"
>
<path d="M9 18l6-6-6-6" /> <path d="M9 18l6-6-6-6" />
</svg> </svg>
</div> </div>
@@ -361,56 +312,80 @@
{/if} {/if}
<!-- Columns Card --> <!-- Columns Card -->
<div class="detail-card columns-section"> <div class="bg-white border border-gray-200 rounded-lg p-6 lg:col-span-2">
<h2 class="card-title">{$t.datasets?.columns || 'Columns'} ({dataset.column_count})</h2> <h2 class="text-lg font-semibold text-gray-900 mb-4">
{$t.datasets?.columns || "Columns"} ({dataset.column_count})
</h2>
{#if dataset.columns && dataset.columns.length > 0} {#if dataset.columns && dataset.columns.length > 0}
<div class="columns-grid"> <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{#each dataset.columns as column} {#each dataset.columns as column}
<div class="column-item"> <div
<div class="column-header"> class="p-3 border border-gray-200 rounded-lg hover:border-blue-300 transition-colors"
<span class="column-name">{column.name}</span> >
<div class="flex items-center justify-between mb-2">
<span class="font-medium text-gray-900">{column.name}</span>
{#if column.type} {#if column.type}
<span class="column-type {getColumnTypeClass(column.type)}">{column.type}</span> <span
class="text-xs px-2 py-1 rounded {getColumnTypeClass(
column.type,
)}">{column.type}</span
>
{/if} {/if}
</div> </div>
<div class="column-meta"> <div class="flex items-center gap-2 text-xs text-gray-500">
{#if column.is_dttm} {#if column.is_dttm}
<span class="text-xs text-green-600">📅 Date/Time</span> <span class="text-xs text-green-600">📅 Date/Time</span>
{/if} {/if}
{#if !column.is_active} {#if !column.is_active}
<span class="text-xs text-gray-400">(Inactive)</span> <span class="text-xs text-gray-400">(Inactive)</span>
{/if} {/if}
<span class="mapping-badge {column.description ? 'mapped' : 'unmapped'}"> <span
{column.description ? '✓ Mapped' : 'Unmapped'} class="inline-flex items-center px-2 py-0.5 text-xs rounded-full {column.description
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-600'}"
>
{column.description ? "✓ Mapped" : "Unmapped"}
</span> </span>
</div> </div>
{#if column.description} {#if column.description}
<p class="column-description">{column.description}</p> <p class="text-sm text-gray-600 mt-2">{column.description}</p>
{/if} {/if}
</div> </div>
{/each} {/each}
</div> </div>
{:else} {:else}
<div class="empty-state"> <div class="py-8 text-center text-gray-500">
{$t.datasets?.no_columns || 'No columns found'} {$t.datasets?.no_columns || "No columns found"}
</div> </div>
{/if} {/if}
</div> </div>
<!-- SQL Section (for SQL Lab views) --> <!-- SQL Section (for SQL Lab views) -->
{#if dataset.sql} {#if dataset.sql}
<div class="detail-card sql-section lg:col-span-3"> <div
<h2 class="card-title">{$t.datasets?.sql_query || 'SQL Query'}</h2> class="bg-white border border-gray-200 rounded-lg p-6 mt-6 lg:col-span-3"
<pre class="sql-code">{dataset.sql}</pre> >
<h2 class="text-lg font-semibold text-gray-900 mb-4">
{$t.datasets?.sql_query || "SQL Query"}
</h2>
<pre
class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm font-mono">{dataset.sql}</pre>
</div> </div>
{/if} {/if}
</div> </div>
{:else} {:else}
<div class="empty-state"> <div class="py-8 text-center text-gray-500">
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg
class="w-16 h-16 mx-auto mb-4 text-gray-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 3h18v18H3V3zm16 16V5H5v14h14z" /> <path d="M3 3h18v18H3V3zm16 16V5H5v14h14z" />
</svg> </svg>
<p>{$t.datasets?.not_found || 'Dataset not found'}</p> <p>{$t.datasets?.not_found || "Dataset not found"}</p>
</div> </div>
{/if} {/if}
</div> </div>

View File

@@ -154,8 +154,5 @@
</div> </div>
<!-- [/SECTION: TEMPLATE] --> <!-- [/SECTION: TEMPLATE] -->
<style>
/* No additional styles needed, using Tailwind */
</style>
<!-- [/DEF:LoginPage:Component] --> <!-- [/DEF:LoginPage:Component] -->

View File

@@ -384,8 +384,5 @@
<!-- [/SECTION] --> <!-- [/SECTION] -->
<style>
/* Page specific styles */
</style>
<!-- [/DEF:MigrationDashboard:Component] --> <!-- [/DEF:MigrationDashboard:Component] -->

View File

@@ -173,8 +173,5 @@
</div> </div>
<!-- [/SECTION] --> <!-- [/SECTION] -->
<style>
/* Page specific styles */
</style>
<!-- [/DEF:MappingManagement:Component] --> <!-- [/DEF:MappingManagement:Component] -->

View File

@@ -14,14 +14,14 @@
* @UX_RECOVERY: Refresh button reloads settings data * @UX_RECOVERY: Refresh button reloads settings data
*/ */
import { onMount } from 'svelte'; import { onMount } from "svelte";
import { t } from '$lib/i18n'; import { t } from "$lib/i18n";
import { api } from '$lib/api.js'; import { api } from "$lib/api.js";
import { addToast } from '$lib/toasts'; import { addToast } from "$lib/toasts";
import ProviderConfig from '../../components/llm/ProviderConfig.svelte'; import ProviderConfig from "../../components/llm/ProviderConfig.svelte";
// State // State
let activeTab = 'environments'; let activeTab = "environments";
let settings = null; let settings = null;
let isLoading = true; let isLoading = true;
let error = null; let error = null;
@@ -30,16 +30,16 @@
let editingEnvId = null; let editingEnvId = null;
let isAddingEnv = false; let isAddingEnv = false;
let newEnv = { let newEnv = {
id: '', id: "",
name: '', name: "",
url: '', url: "",
username: '', username: "",
password: '', password: "",
is_default: false, is_default: false,
backup_schedule: { backup_schedule: {
enabled: false, enabled: false,
cron_expression: '0 0 * * *' cron_expression: "0 0 * * *",
} },
}; };
// Load settings on mount // Load settings on mount
@@ -55,8 +55,8 @@
const response = await api.getConsolidatedSettings(); const response = await api.getConsolidatedSettings();
settings = response; settings = response;
} catch (err) { } catch (err) {
error = err.message || 'Failed to load settings'; error = err.message || "Failed to load settings";
console.error('[SettingsPage][Coherence:Failed]', err); console.error("[SettingsPage][Coherence:Failed]", err);
} finally { } finally {
isLoading = false; isLoading = false;
} }
@@ -70,39 +70,42 @@
// Get tab class // Get tab class
function getTabClass(tab) { function getTabClass(tab) {
return activeTab === tab return activeTab === tab
? 'text-blue-600 border-b-2 border-blue-600' ? "text-blue-600 border-b-2 border-blue-600"
: 'text-gray-600 hover:text-gray-800 border-transparent hover:border-gray-300'; : "text-gray-600 hover:text-gray-800 border-transparent hover:border-gray-300";
} }
// Handle global settings save (Logging, Storage) // Handle global settings save (Logging, Storage)
async function handleSave() { async function handleSave() {
console.log('[SettingsPage][Action] Saving settings'); console.log("[SettingsPage][Action] Saving settings");
try { try {
// In a real app we might want to only send the changed section, // In a real app we might want to only send the changed section,
// but updateConsolidatedSettings expects full object or we can use specific endpoints. // but updateConsolidatedSettings expects full object or we can use specific endpoints.
// For now we use the consolidated update. // For now we use the consolidated update.
await api.updateConsolidatedSettings(settings); await api.updateConsolidatedSettings(settings);
addToast($t.settings?.save_success || 'Settings saved', 'success'); addToast($t.settings?.save_success || "Settings saved", "success");
} catch (err) { } catch (err) {
console.error('[SettingsPage][Coherence:Failed]', err); console.error("[SettingsPage][Coherence:Failed]", err);
addToast($t.settings?.save_failed || 'Failed to save settings', 'error'); addToast($t.settings?.save_failed || "Failed to save settings", "error");
} }
} }
// Handle environment actions // Handle environment actions
async function handleTestEnv(id) { async function handleTestEnv(id) {
console.log(`[SettingsPage][Action] Test environment ${id}`); console.log(`[SettingsPage][Action] Test environment ${id}`);
addToast('Testing connection...', 'info'); addToast("Testing connection...", "info");
try { try {
const result = await api.testEnvironmentConnection(id); const result = await api.testEnvironmentConnection(id);
if (result.status === 'success') { if (result.status === "success") {
addToast('Connection successful', 'success'); addToast("Connection successful", "success");
} else { } else {
addToast(`Connection failed: ${result.message}`, 'error'); addToast(`Connection failed: ${result.message}`, "error");
} }
} catch (err) { } catch (err) {
console.error('[SettingsPage][Coherence:Failed] Error testing connection:', err); console.error(
addToast('Failed to test connection', 'error'); "[SettingsPage][Coherence:Failed] Error testing connection:",
err,
);
addToast("Failed to test connection", "error");
} }
} }
@@ -111,7 +114,7 @@
newEnv = JSON.parse(JSON.stringify(env)); // Deep copy newEnv = JSON.parse(JSON.stringify(env)); // Deep copy
// Ensure backup_schedule exists // Ensure backup_schedule exists
if (!newEnv.backup_schedule) { if (!newEnv.backup_schedule) {
newEnv.backup_schedule = { enabled: false, cron_expression: '0 0 * * *' }; newEnv.backup_schedule = { enabled: false, cron_expression: "0 0 * * *" };
} }
editingEnvId = env.id; editingEnvId = env.id;
isAddingEnv = false; isAddingEnv = false;
@@ -119,36 +122,38 @@
function resetEnvForm() { function resetEnvForm() {
newEnv = { newEnv = {
id: '', id: "",
name: '', name: "",
url: '', url: "",
username: '', username: "",
password: '', password: "",
is_default: false, is_default: false,
backup_schedule: { backup_schedule: {
enabled: false, enabled: false,
cron_expression: '0 0 * * *' cron_expression: "0 0 * * *",
} },
}; };
editingEnvId = null; editingEnvId = null;
} }
async function handleAddOrUpdateEnv() { async function handleAddOrUpdateEnv() {
try { try {
console.log(`[SettingsPage][Action] ${editingEnvId ? 'Updating' : 'Adding'} environment.`); console.log(
`[SettingsPage][Action] ${editingEnvId ? "Updating" : "Adding"} environment.`,
);
// Basic validation // Basic validation
if (!newEnv.id || !newEnv.name || !newEnv.url) { if (!newEnv.id || !newEnv.name || !newEnv.url) {
addToast('Please fill in all required fields (ID, Name, URL)', 'error'); addToast("Please fill in all required fields (ID, Name, URL)", "error");
return; return;
} }
if (editingEnvId) { if (editingEnvId) {
await api.updateEnvironment(editingEnvId, newEnv); await api.updateEnvironment(editingEnvId, newEnv);
addToast('Environment updated', 'success'); addToast("Environment updated", "success");
} else { } else {
await api.addEnvironment(newEnv); await api.addEnvironment(newEnv);
addToast('Environment added', 'success'); addToast("Environment added", "success");
} }
resetEnvForm(); resetEnvForm();
@@ -156,148 +161,138 @@
isAddingEnv = false; isAddingEnv = false;
await loadSettings(); await loadSettings();
} catch (error) { } catch (error) {
console.error("[SettingsPage][Coherence:Failed] Failed to save environment:", error); console.error(
addToast(error.message || 'Failed to save environment', 'error'); "[SettingsPage][Coherence:Failed] Failed to save environment:",
error,
);
addToast(error.message || "Failed to save environment", "error");
} }
} }
async function handleDeleteEnv(id) { async function handleDeleteEnv(id) {
if (confirm('Are you sure you want to delete this environment?')) { if (confirm("Are you sure you want to delete this environment?")) {
console.log(`[SettingsPage][Action] Delete environment ${id}`); console.log(`[SettingsPage][Action] Delete environment ${id}`);
try { try {
await api.deleteEnvironment(id); await api.deleteEnvironment(id);
addToast('Environment deleted', 'success'); addToast("Environment deleted", "success");
await loadSettings(); await loadSettings();
} catch (error) { } catch (error) {
console.error("[SettingsPage][Coherence:Failed] Failed to delete environment:", error); console.error(
addToast('Failed to delete environment', 'error'); "[SettingsPage][Coherence:Failed] Failed to delete environment:",
error,
);
addToast("Failed to delete environment", "error");
} }
} }
} }
</script> </script>
<style> <div class="max-w-7xl mx-auto px-4 py-6">
.container {
@apply max-w-7xl mx-auto px-4 py-6;
}
.header {
@apply flex items-center justify-between mb-6;
}
.title {
@apply text-2xl font-bold text-gray-900;
}
.refresh-btn {
@apply px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors;
}
.error-banner {
@apply bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4 flex items-center justify-between;
}
.retry-btn {
@apply px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors;
}
.tabs {
@apply border-b border-gray-200 mb-6;
}
.tab-btn {
@apply px-4 py-2 text-sm font-medium transition-colors focus:outline-none;
}
.tab-content {
@apply bg-white rounded-lg p-6 border border-gray-200;
}
.skeleton {
@apply animate-pulse bg-gray-200 rounded;
}
</style>
<div class="container">
<!-- Header --> <!-- Header -->
<div class="header"> <div class="flex items-center justify-between mb-6">
<h1 class="title">{$t.settings?.title || 'Settings'}</h1> <h1 class="text-2xl font-bold text-gray-900">
<button class="refresh-btn" on:click={loadSettings}> {$t.settings?.title || "Settings"}
{$t.common?.refresh || 'Refresh'} </h1>
<button
class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors"
on:click={loadSettings}
>
{$t.common?.refresh || "Refresh"}
</button> </button>
</div> </div>
<!-- Error Banner --> <!-- Error Banner -->
{#if error} {#if error}
<div class="error-banner"> <div
class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4 flex items-center justify-between"
>
<span>{error}</span> <span>{error}</span>
<button class="retry-btn" on:click={loadSettings}> <button
{$t.common?.retry || 'Retry'} class="px-4 py-2 bg-destructive text-white rounded hover:bg-destructive-hover transition-colors"
on:click={loadSettings}
>
{$t.common?.retry || "Retry"}
</button> </button>
</div> </div>
{/if} {/if}
<!-- Loading State --> <!-- Loading State -->
{#if isLoading} {#if isLoading}
<div class="tab-content"> <div class="bg-white rounded-lg p-6 border border-gray-200">
<div class="skeleton h-8"></div> <div class="animate-pulse bg-gray-200 rounded h-8"></div>
<div class="skeleton h-8"></div> <div class="animate-pulse bg-gray-200 rounded h-8"></div>
<div class="skeleton h-8"></div> <div class="animate-pulse bg-gray-200 rounded h-8"></div>
<div class="skeleton h-8"></div> <div class="animate-pulse bg-gray-200 rounded h-8"></div>
<div class="skeleton h-8"></div> <div class="animate-pulse bg-gray-200 rounded h-8"></div>
</div> </div>
{:else if settings} {:else if settings}
<!-- Tabs --> <!-- Tabs -->
<div class="tabs"> <div class="border-b border-gray-200 mb-6">
<button <button
class="tab-btn {getTabClass('environments')}" class="px-4 py-2 text-sm font-medium transition-colors focus:outline-none {getTabClass(
on:click={() => handleTabChange('environments')} 'environments',
)}"
on:click={() => handleTabChange("environments")}
> >
{$t.settings?.environments || 'Environments'} {$t.settings?.environments || "Environments"}
</button> </button>
<button <button
class="tab-btn {getTabClass('logging')}" class="px-4 py-2 text-sm font-medium transition-colors focus:outline-none {getTabClass(
on:click={() => handleTabChange('logging')} 'logging',
)}"
on:click={() => handleTabChange("logging")}
> >
{$t.settings?.logging || 'Logging'} {$t.settings?.logging || "Logging"}
</button> </button>
<button <button
class="tab-btn {getTabClass('connections')}" class="px-4 py-2 text-sm font-medium transition-colors focus:outline-none {getTabClass(
on:click={() => handleTabChange('connections')} 'connections',
)}"
on:click={() => handleTabChange("connections")}
> >
{$t.settings?.connections || 'Connections'} {$t.settings?.connections || "Connections"}
</button> </button>
<button <button
class="tab-btn {getTabClass('llm')}" class="px-4 py-2 text-sm font-medium transition-colors focus:outline-none {getTabClass(
on:click={() => handleTabChange('llm')} 'llm',
)}"
on:click={() => handleTabChange("llm")}
> >
{$t.settings?.llm || 'LLM'} {$t.settings?.llm || "LLM"}
</button> </button>
<button <button
class="tab-btn {getTabClass('storage')}" class="px-4 py-2 text-sm font-medium transition-colors focus:outline-none {getTabClass(
on:click={() => handleTabChange('storage')} 'storage',
)}"
on:click={() => handleTabChange("storage")}
> >
{$t.settings?.storage || 'Storage'} {$t.settings?.storage || "Storage"}
</button> </button>
</div> </div>
<!-- Tab Content --> <!-- Tab Content -->
<div class="tab-content"> <div class="bg-white rounded-lg p-6 border border-gray-200">
{#if activeTab === 'environments'} {#if activeTab === "environments"}
<!-- Environments Tab --> <!-- Environments Tab -->
<div class="text-lg font-medium mb-4"> <div class="text-lg font-medium mb-4">
<h2 class="text-xl font-bold mb-4">{$t.settings?.environments || 'Superset Environments'}</h2> <h2 class="text-xl font-bold mb-4">
{$t.settings?.environments || "Superset Environments"}
</h2>
<p class="text-gray-600 mb-6"> <p class="text-gray-600 mb-6">
{$t.settings?.env_description || 'Configure Superset environments for dashboards and datasets.'} {$t.settings?.env_description ||
"Configure Superset environments for dashboards and datasets."}
</p> </p>
{#if !editingEnvId && !isAddingEnv} {#if !editingEnvId && !isAddingEnv}
<div class="flex justify-end mb-6"> <div class="flex justify-end mb-6">
<button <button
class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700"
on:click={() => { isAddingEnv = true; resetEnvForm(); }} on:click={() => {
isAddingEnv = true;
resetEnvForm();
}}
> >
{$t.settings?.env_add || 'Add Environment'} {$t.settings?.env_add || "Add Environment"}
</button> </button>
</div> </div>
{/if} {/if}
@@ -305,10 +300,15 @@
{#if editingEnvId || isAddingEnv} {#if editingEnvId || isAddingEnv}
<!-- Add/Edit Environment Form --> <!-- Add/Edit Environment Form -->
<div class="bg-gray-50 p-6 rounded-lg mb-6 border border-gray-200"> <div class="bg-gray-50 p-6 rounded-lg mb-6 border border-gray-200">
<h3 class="text-lg font-medium mb-4">{editingEnvId ? 'Edit' : 'Add'} Environment</h3> <h3 class="text-lg font-medium mb-4">
{editingEnvId ? "Edit" : "Add"} Environment
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label for="env_id" class="block text-sm font-medium text-gray-700">ID</label> <label
for="env_id"
class="block text-sm font-medium text-gray-700">ID</label
>
<input <input
type="text" type="text"
id="env_id" id="env_id"
@@ -318,43 +318,111 @@
/> />
</div> </div>
<div> <div>
<label for="env_name" class="block text-sm font-medium text-gray-700">Name</label> <label
<input type="text" id="env_name" bind:value={newEnv.name} class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" /> for="env_name"
class="block text-sm font-medium text-gray-700">Name</label
>
<input
type="text"
id="env_name"
bind:value={newEnv.name}
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
/>
</div> </div>
<div> <div>
<label for="env_url" class="block text-sm font-medium text-gray-700">URL</label> <label
<input type="text" id="env_url" bind:value={newEnv.url} class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" /> for="env_url"
class="block text-sm font-medium text-gray-700">URL</label
>
<input
type="text"
id="env_url"
bind:value={newEnv.url}
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
/>
</div> </div>
<div> <div>
<label for="env_user" class="block text-sm font-medium text-gray-700">Username</label> <label
<input type="text" id="env_user" bind:value={newEnv.username} class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" /> for="env_user"
class="block text-sm font-medium text-gray-700"
>Username</label
>
<input
type="text"
id="env_user"
bind:value={newEnv.username}
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
/>
</div> </div>
<div> <div>
<label for="env_pass" class="block text-sm font-medium text-gray-700">Password</label> <label
<input type="password" id="env_pass" bind:value={newEnv.password} class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" /> for="env_pass"
class="block text-sm font-medium text-gray-700"
>Password</label
>
<input
type="password"
id="env_pass"
bind:value={newEnv.password}
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
/>
</div> </div>
<div class="flex items-center mt-6"> <div class="flex items-center mt-6">
<input type="checkbox" id="env_default" bind:checked={newEnv.is_default} class="h-4 w-4 text-blue-600 border-gray-300 rounded" /> <input
<label for="env_default" class="ml-2 block text-sm text-gray-900">Default Environment</label> type="checkbox"
id="env_default"
bind:checked={newEnv.is_default}
class="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<label
for="env_default"
class="ml-2 block text-sm text-gray-900"
>Default Environment</label
>
</div> </div>
</div> </div>
<h3 class="text-lg font-medium mb-4 mt-6">Backup Schedule</h3> <h3 class="text-lg font-medium mb-4 mt-6">Backup Schedule</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex items-center"> <div class="flex items-center">
<input type="checkbox" id="backup_enabled" bind:checked={newEnv.backup_schedule.enabled} class="h-4 w-4 text-blue-600 border-gray-300 rounded" /> <input
<label for="backup_enabled" class="ml-2 block text-sm text-gray-900">Enable Automatic Backups</label> type="checkbox"
id="backup_enabled"
bind:checked={newEnv.backup_schedule.enabled}
class="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<label
for="backup_enabled"
class="ml-2 block text-sm text-gray-900"
>Enable Automatic Backups</label
>
</div> </div>
<div> <div>
<label for="cron_expression" class="block text-sm font-medium text-gray-700">Cron Expression</label> <label
<input type="text" id="cron_expression" bind:value={newEnv.backup_schedule.cron_expression} placeholder="0 0 * * *" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" /> for="cron_expression"
<p class="text-xs text-gray-500 mt-1">Example: 0 0 * * * (daily at midnight)</p> class="block text-sm font-medium text-gray-700"
>Cron Expression</label
>
<input
type="text"
id="cron_expression"
bind:value={newEnv.backup_schedule.cron_expression}
placeholder="0 0 * * *"
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
/>
<p class="text-xs text-gray-500 mt-1">
Example: 0 0 * * * (daily at midnight)
</p>
</div> </div>
</div> </div>
<div class="mt-6 flex gap-2 justify-end"> <div class="mt-6 flex gap-2 justify-end">
<button <button
on:click={() => { isAddingEnv = false; editingEnvId = null; resetEnvForm(); }} on:click={() => {
isAddingEnv = false;
editingEnvId = null;
resetEnvForm();
}}
class="bg-gray-200 text-gray-700 px-4 py-2 rounded hover:bg-gray-300" class="bg-gray-200 text-gray-700 px-4 py-2 rounded hover:bg-gray-300"
> >
Cancel Cancel
@@ -363,7 +431,7 @@
on:click={handleAddOrUpdateEnv} on:click={handleAddOrUpdateEnv}
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
> >
{editingEnvId ? 'Update' : 'Add'} Environment {editingEnvId ? "Update" : "Add"} Environment
</button> </button>
</div> </div>
</div> </div>
@@ -374,11 +442,26 @@
<table class="min-w-full divide-y divide-gray-200"> <table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50"> <thead class="bg-gray-50">
<tr> <tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.connections?.name || "Name"}</th> <th
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">URL</th> class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.connections?.user || "Username"}</th> >{$t.connections?.name || "Name"}</th
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Default</th> >
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.settings?.env_actions || "Actions"}</th> <th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>URL</th
>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>{$t.connections?.user || "Username"}</th
>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>Default</th
>
<th
class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"
>{$t.settings?.env_actions || "Actions"}</th
>
</tr> </tr>
</thead> </thead>
<tbody class="bg-white divide-y divide-gray-200"> <tbody class="bg-white divide-y divide-gray-200">
@@ -386,24 +469,38 @@
<tr> <tr>
<td class="px-6 py-4 whitespace-nowrap">{env.name}</td> <td class="px-6 py-4 whitespace-nowrap">{env.name}</td>
<td class="px-6 py-4 whitespace-nowrap">{env.url}</td> <td class="px-6 py-4 whitespace-nowrap">{env.url}</td>
<td class="px-6 py-4 whitespace-nowrap">{env.username}</td> <td class="px-6 py-4 whitespace-nowrap">{env.username}</td
>
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
{#if env.is_default} {#if env.is_default}
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800"> <span
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800"
>
Yes Yes
</span> </span>
{:else} {:else}
<span class="text-gray-500">No</span> <span class="text-gray-500">No</span>
{/if} {/if}
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <td
<button class="text-green-600 hover:text-green-900 mr-4" on:click={() => handleTestEnv(env.id)}> class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"
>
<button
class="text-green-600 hover:text-green-900 mr-4"
on:click={() => handleTestEnv(env.id)}
>
{$t.settings?.env_test || "Test"} {$t.settings?.env_test || "Test"}
</button> </button>
<button class="text-indigo-600 hover:text-indigo-900 mr-4" on:click={() => editEnv(env)}> <button
class="text-indigo-600 hover:text-indigo-900 mr-4"
on:click={() => editEnv(env)}
>
{$t.common.edit || "Edit"} {$t.common.edit || "Edit"}
</button> </button>
<button class="text-red-600 hover:text-red-900" on:click={() => handleDeleteEnv(env.id)}> <button
class="text-red-600 hover:text-red-900"
on:click={() => handleDeleteEnv(env.id)}
>
{$t.settings?.env_delete || "Delete"} {$t.settings?.env_delete || "Delete"}
</button> </button>
</td> </td>
@@ -413,25 +510,41 @@
</table> </table>
</div> </div>
{:else if !isAddingEnv} {:else if !isAddingEnv}
<div class="mb-4 p-4 bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700"> <div
class="mb-4 p-4 bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700"
>
<p class="font-bold">Warning</p> <p class="font-bold">Warning</p>
<p>No Superset environments configured. You must add at least one environment to perform backups or migrations.</p> <p>
No Superset environments configured. You must add at least one
environment to perform backups or migrations.
</p>
</div> </div>
{/if} {/if}
</div> </div>
{:else if activeTab === 'logging'} {:else if activeTab === "logging"}
<!-- Logging Tab --> <!-- Logging Tab -->
<div class="text-lg font-medium mb-4"> <div class="text-lg font-medium mb-4">
<h2 class="text-xl font-bold mb-4">{$t.settings?.logging || 'Logging Configuration'}</h2> <h2 class="text-xl font-bold mb-4">
{$t.settings?.logging || "Logging Configuration"}
</h2>
<p class="text-gray-600 mb-6"> <p class="text-gray-600 mb-6">
{$t.settings?.logging_description || 'Configure logging and task log levels.'} {$t.settings?.logging_description ||
"Configure logging and task log levels."}
</p> </p>
<div class="bg-gray-50 p-6 rounded-lg border border-gray-200"> <div class="bg-gray-50 p-6 rounded-lg border border-gray-200">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label for="log_level" class="block text-sm font-medium text-gray-700">Log Level</label> <label
<select id="log_level" bind:value={settings.logging.level} class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"> for="log_level"
class="block text-sm font-medium text-gray-700"
>Log Level</label
>
<select
id="log_level"
bind:value={settings.logging.level}
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
>
<option value="DEBUG">DEBUG</option> <option value="DEBUG">DEBUG</option>
<option value="INFO">INFO</option> <option value="INFO">INFO</option>
<option value="WARNING">WARNING</option> <option value="WARNING">WARNING</option>
@@ -440,8 +553,16 @@
</select> </select>
</div> </div>
<div> <div>
<label for="task_log_level" class="block text-sm font-medium text-gray-700">Task Log Level</label> <label
<select id="task_log_level" bind:value={settings.logging.task_log_level} class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"> for="task_log_level"
class="block text-sm font-medium text-gray-700"
>Task Log Level</label
>
<select
id="task_log_level"
bind:value={settings.logging.task_log_level}
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
>
<option value="DEBUG">DEBUG</option> <option value="DEBUG">DEBUG</option>
<option value="INFO">INFO</option> <option value="INFO">INFO</option>
<option value="WARNING">WARNING</option> <option value="WARNING">WARNING</option>
@@ -450,10 +571,19 @@
</div> </div>
<div class="md:col-span-2"> <div class="md:col-span-2">
<label class="flex items-center"> <label class="flex items-center">
<input type="checkbox" id="enable_belief_state" bind:checked={settings.logging.enable_belief_state} class="h-4 w-4 text-blue-600 border-gray-300 rounded" /> <input
<span class="ml-2 block text-sm text-gray-900">Enable Belief State Logging (Beta)</span> type="checkbox"
id="enable_belief_state"
bind:checked={settings.logging.enable_belief_state}
class="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span class="ml-2 block text-sm text-gray-900"
>Enable Belief State Logging (Beta)</span
>
</label> </label>
<p class="text-xs text-gray-500 mt-1 ml-6">Logs agent reasoning and internal state changes for debugging.</p> <p class="text-xs text-gray-500 mt-1 ml-6">
Logs agent reasoning and internal state changes for debugging.
</p>
</div> </div>
</div> </div>
@@ -467,57 +597,103 @@
</div> </div>
</div> </div>
</div> </div>
{:else if activeTab === 'connections'} {:else if activeTab === "connections"}
<!-- Connections Tab --> <!-- Connections Tab -->
<div class="text-lg font-medium mb-4"> <div class="text-lg font-medium mb-4">
<h2 class="text-xl font-bold mb-4">{$t.settings?.connections || 'Database Connections'}</h2> <h2 class="text-xl font-bold mb-4">
{$t.settings?.connections || "Database Connections"}
</h2>
<p class="text-gray-600 mb-6"> <p class="text-gray-600 mb-6">
{$t.settings?.connections_description || 'Configure database connections for data mapping.'} {$t.settings?.connections_description ||
"Configure database connections for data mapping."}
</p> </p>
{#if settings.connections && settings.connections.length > 0} {#if settings.connections && settings.connections.length > 0}
<!-- Connections list would go here --> <!-- Connections list would go here -->
<p class="text-gray-500 italic">No additional connections configured. Superset database connections are used by default.</p> <p class="text-gray-500 italic">
No additional connections configured. Superset database
connections are used by default.
</p>
{:else} {:else}
<div class="text-center py-8 bg-gray-50 rounded-lg border border-dashed border-gray-300"> <div
class="text-center py-8 bg-gray-50 rounded-lg border border-dashed border-gray-300"
>
<p class="text-gray-500">No external connections configured.</p> <p class="text-gray-500">No external connections configured.</p>
<button class="mt-4 px-4 py-2 border border-blue-600 text-blue-600 rounded hover:bg-blue-50"> <button
class="mt-4 px-4 py-2 border border-blue-600 text-blue-600 rounded hover:bg-blue-50"
>
Add Connection Add Connection
</button> </button>
</div> </div>
{/if} {/if}
</div> </div>
{:else if activeTab === 'llm'} {:else if activeTab === "llm"}
<!-- LLM Tab --> <!-- LLM Tab -->
<div class="text-lg font-medium mb-4"> <div class="text-lg font-medium mb-4">
<h2 class="text-xl font-bold mb-4">{$t.settings?.llm || 'LLM Providers'}</h2> <h2 class="text-xl font-bold mb-4">
{$t.settings?.llm || "LLM Providers"}
</h2>
<p class="text-gray-600 mb-6"> <p class="text-gray-600 mb-6">
{$t.settings?.llm_description || 'Configure LLM providers for dataset documentation.'} {$t.settings?.llm_description ||
"Configure LLM providers for dataset documentation."}
</p> </p>
<ProviderConfig providers={settings.llm_providers || []} onSave={loadSettings} /> <ProviderConfig
providers={settings.llm_providers || []}
onSave={loadSettings}
/>
</div> </div>
{:else if activeTab === 'storage'} {:else if activeTab === "storage"}
<!-- Storage Tab --> <!-- Storage Tab -->
<div class="text-lg font-medium mb-4"> <div class="text-lg font-medium mb-4">
<h2 class="text-xl font-bold mb-4">{$t.settings?.storage || 'File Storage Configuration'}</h2> <h2 class="text-xl font-bold mb-4">
{$t.settings?.storage || "File Storage Configuration"}
</h2>
<p class="text-gray-600 mb-6"> <p class="text-gray-600 mb-6">
{$t.settings?.storage_description || 'Configure file storage paths and patterns.'} {$t.settings?.storage_description ||
"Configure file storage paths and patterns."}
</p> </p>
<div class="bg-gray-50 p-6 rounded-lg border border-gray-200"> <div class="bg-gray-50 p-6 rounded-lg border border-gray-200">
<div class="grid grid-cols-1 gap-4"> <div class="grid grid-cols-1 gap-4">
<div> <div>
<label for="storage_path" class="block text-sm font-medium text-gray-700">Root Path</label> <label
<input type="text" id="storage_path" bind:value={settings.storage.root_path} class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" /> for="storage_path"
class="block text-sm font-medium text-gray-700"
>Root Path</label
>
<input
type="text"
id="storage_path"
bind:value={settings.storage.root_path}
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
/>
</div> </div>
<div> <div>
<label for="backup_path" class="block text-sm font-medium text-gray-700">Backup Path</label> <label
<input type="text" id="backup_path" bind:value={settings.storage.backup_path} class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" /> for="backup_path"
class="block text-sm font-medium text-gray-700"
>Backup Path</label
>
<input
type="text"
id="backup_path"
bind:value={settings.storage.backup_path}
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
/>
</div> </div>
<div> <div>
<label for="repo_path" class="block text-sm font-medium text-gray-700">Repository Path</label> <label
<input type="text" id="repo_path" bind:value={settings.storage.repo_path} class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" /> for="repo_path"
class="block text-sm font-medium text-gray-700"
>Repository Path</label
>
<input
type="text"
id="repo_path"
bind:value={settings.storage.repo_path}
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
/>
</div> </div>
</div> </div>

View File

@@ -175,8 +175,5 @@
</div> </div>
<!-- [/SECTION: TEMPLATE] --> <!-- [/SECTION: TEMPLATE] -->
<style>
/* Styles are handled by Tailwind */
</style>
<!-- [/DEF:GitSettingsPage:Component] --> <!-- [/DEF:GitSettingsPage:Component] -->

View File

@@ -218,8 +218,5 @@
</div> </div>
<!-- [/SECTION: TEMPLATE] --> <!-- [/SECTION: TEMPLATE] -->
<style>
/* ... */
</style>
<!-- [/DEF:StoragePage:Component] --> <!-- [/DEF:StoragePage:Component] -->

View File

@@ -5,7 +5,69 @@ export default {
"./src/**/*.{svelte,js,ts,jsx,tsx}", "./src/**/*.{svelte,js,ts,jsx,tsx}",
], ],
theme: { theme: {
extend: {}, extend: {
colors: {
// Semantic UI colors (light surfaces)
primary: {
DEFAULT: '#2563eb', // blue-600
hover: '#1d4ed8', // blue-700
ring: '#3b82f6', // blue-500
light: '#eff6ff', // blue-50
},
secondary: {
DEFAULT: '#f3f4f6', // gray-100
hover: '#e5e7eb', // gray-200
text: '#111827', // gray-900
ring: '#6b7280', // gray-500
},
destructive: {
DEFAULT: '#dc2626', // red-600
hover: '#b91c1c', // red-700
ring: '#ef4444', // red-500
light: '#fef2f2', // red-50
},
ghost: {
hover: '#f3f4f6', // gray-100
text: '#374151', // gray-700
ring: '#6b7280', // gray-500
},
// Dark terminal palette (log viewer, task drawer)
terminal: {
bg: '#0f172a', // slate-900
surface: '#1e293b', // slate-800
border: '#334155', // slate-700
text: {
DEFAULT: '#cbd5e1', // slate-300
muted: '#475569', // slate-600
subtle: '#64748b', // slate-500
bright: '#e2e8f0', // slate-200
heading: '#f1f5f9', // slate-100
},
accent: '#22d3ee', // cyan-400
},
// Log level palette
log: {
debug: '#64748b',
info: '#38bdf8',
warning: '#fbbf24',
error: '#f87171',
},
// Log source palette
source: {
plugin: '#4ade80',
api: '#c084fc',
git: '#fb923c',
system: '#38bdf8',
},
},
width: {
sidebar: '240px',
'sidebar-collapsed': '64px',
},
fontFamily: {
mono: ['"JetBrains Mono"', '"Fira Code"', 'monospace'],
},
},
}, },
plugins: [], plugins: [],
} }