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">
// [SECTION: IMPORTS]
import { createEventDispatcher } from 'svelte';
import type { DashboardMetadata } from '../types/dashboard';
import { t } from '../lib/i18n';
import { Button, Input } from '../lib/ui';
import GitManager from './git/GitManager.svelte';
import { api } from '../lib/api';
import { addToast as toast } from '../lib/toasts.js';
import { createEventDispatcher } from "svelte";
import type { DashboardMetadata } from "../types/dashboard";
import { t } from "../lib/i18n";
import { Button, Input } from "../lib/ui";
import GitManager from "./git/GitManager.svelte";
import { api } from "../lib/api";
import { addToast as toast } from "../lib/toasts.js";
// [/SECTION]
// [SECTION: PROPS]
export let dashboards: DashboardMetadata[] = [];
export let selectedIds: number[] = [];
export let environmentId: string = "ss1";
let { dashboards = [], selectedIds = [], environmentId = "ss1" } = $props();
// [/SECTION]
// [SECTION: STATE]
@@ -59,26 +58,29 @@
// Or we pick the first active one.
// 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);
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;
}
await api.postApi('/tasks', {
plugin_id: 'llm_dashboard_validation',
await api.postApi("/tasks", {
plugin_id: "llm_dashboard_validation",
params: {
dashboard_id: dashboard.id.toString(),
environment_id: environmentId,
provider_id: activeProvider.id
}
provider_id: activeProvider.id,
},
});
toast('Validation task started', 'success');
toast("Validation task started", "success");
} catch (e: any) {
toast(e.message || 'Validation failed to start', 'error');
toast(e.message || "Validation failed to start", "error");
} finally {
validatingIds.delete(dashboard.id);
validatingIds = validatingIds;
@@ -87,11 +89,14 @@
// [/DEF:handleValidate:Function]
// [SECTION: DERIVED]
$: filteredDashboards = dashboards.filter(d =>
d.title.toLowerCase().includes(filterText.toLowerCase())
let filteredDashboards = $derived(
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 bVal = b[sortColumn];
if (sortColumn === "id") {
@@ -101,17 +106,25 @@
if (aVal < bVal) return sortDirection === "asc" ? -1 : 1;
if (aVal > bVal) return sortDirection === "asc" ? 1 : -1;
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));
$: someSelected = paginatedDashboards.some(d => selectedIds.includes(d.id));
let totalPages = $derived(Math.ceil(sortedDashboards.length / pageSize));
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: EVENTS]
@@ -141,10 +154,10 @@
if (checked) {
if (!newSelected.includes(id)) newSelected.push(id);
} else {
newSelected = newSelected.filter(sid => sid !== id);
newSelected = newSelected.filter((sid) => sid !== id);
}
selectedIds = newSelected;
dispatch('selectionChanged', newSelected);
dispatch("selectionChanged", newSelected);
}
// [/DEF:handleSelectionChange:Function]
@@ -155,16 +168,16 @@
function handleSelectAll(checked: boolean) {
let newSelected = [...selectedIds];
if (checked) {
paginatedDashboards.forEach(d => {
paginatedDashboards.forEach((d) => {
if (!newSelected.includes(d.id)) newSelected.push(d.id);
});
} else {
paginatedDashboards.forEach(d => {
newSelected = newSelected.filter(sid => sid !== d.id);
paginatedDashboards.forEach((d) => {
newSelected = newSelected.filter((sid) => sid !== d.id);
});
}
selectedIds = newSelected;
dispatch('selectionChanged', newSelected);
dispatch("selectionChanged", newSelected);
}
// [/DEF:handleSelectAll:Function]
@@ -189,17 +202,13 @@
showGitManager = true;
}
// [/DEF:openGit:Function]
</script>
<!-- [SECTION: TEMPLATE] -->
<div class="dashboard-grid">
<!-- Filter Input -->
<div class="mb-6">
<Input
bind:value={filterText}
placeholder={$t.dashboard.search}
/>
<Input bind:value={filterText} placeholder={$t.dashboard.search} />
</div>
<!-- Grid/Table -->
@@ -212,21 +221,52 @@
type="checkbox"
checked={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"
/>
</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')}>
{$t.dashboard.title} {sortColumn === 'title' ? (sortDirection === 'asc' ? '↑' : '↓') : ''}
<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")}
>
{$t.dashboard.title}
{sortColumn === "title"
? sortDirection === "asc"
? "↑"
: "↓"
: ""}
</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')}>
{$t.dashboard.last_modified} {sortColumn === 'last_modified' ? (sortDirection === 'asc' ? '↑' : '↓') : ''}
<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")}
>
{$t.dashboard.last_modified}
{sortColumn === "last_modified"
? sortDirection === "asc"
? "↑"
: "↓"
: ""}
</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')}>
{$t.dashboard.status} {sortColumn === 'status' ? (sortDirection === 'asc' ? '↑' : '↓') : ''}
<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")}
>
{$t.dashboard.status}
{sortColumn === "status"
? sortDirection === "asc"
? "↑"
: "↓"
: ""}
</th>
<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>
<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>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@@ -236,14 +276,28 @@
<input
type="checkbox"
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"
/>
</td>
<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 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">
<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}
</span>
</td>
@@ -255,7 +309,7 @@
disabled={validatingIds.has(dashboard.id)}
class="text-purple-600 hover:text-purple-900"
>
{validatingIds.has(dashboard.id) ? 'Validating...' : 'Validate'}
{validatingIds.has(dashboard.id) ? "Validating..." : "Validate"}
</Button>
</td>
<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="text-sm text-gray-500">
{($t.dashboard?.showing || "")
.replace('{start}', (currentPage * pageSize + 1).toString())
.replace('{end}', Math.min((currentPage + 1) * pageSize, sortedDashboards.length).toString())
.replace('{total}', sortedDashboards.length.toString())}
.replace("{start}", (currentPage * pageSize + 1).toString())
.replace(
"{end}",
Math.min(
(currentPage + 1) * pageSize,
sortedDashboards.length,
).toString(),
)
.replace("{total}", sortedDashboards.length.toString())}
</div>
<div class="flex gap-2">
<Button
@@ -313,8 +373,4 @@
<!-- [/SECTION] -->
<style>
/* Component styles */
</style>
<!-- [/DEF:DashboardGrid:Component] -->

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,11 +7,9 @@
@RELATION: EMITS -> resume, cancel
-->
<script>
import { createEventDispatcher } from 'svelte';
import { createEventDispatcher } from "svelte";
export let show = false;
export let databases = []; // List of database names requiring passwords
export let errorMessage = "";
let { show = false, databases = [], errorMessage = "" } = $props();
const dispatch = createEventDispatcher();
@@ -26,14 +24,14 @@
if (submitting) return;
// Validate all passwords entered
const missing = databases.filter(db => !passwords[db]);
const missing = databases.filter((db) => !passwords[db]);
if (missing.length > 0) {
alert(`Please enter passwords for: ${missing.join(', ')}`);
alert(`Please enter passwords for: ${missing.join(", ")}`);
return;
}
submitting = true;
dispatch('resume', { passwords });
dispatch("resume", { passwords });
// Reset submitting state is handled by parent or on close
}
// [/DEF:handleSubmit:Function]
@@ -43,54 +41,100 @@
// @PRE: Modal is open.
// @POST: 'cancel' event is dispatched and show is set to false.
function handleCancel() {
dispatch('cancel');
dispatch("cancel");
show = false;
}
// [/DEF:handleCancel:Function]
// Reset passwords when modal opens/closes
$: if (!show) {
$effect(() => {
if (!show) {
passwords = {};
submitting = false;
}
});
</script>
{#if show}
<div 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">
<div
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 -->
<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="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 -->
<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">
<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
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>
</div>
<div 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">
<div
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
</h3>
<div class="mt-2">
<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>
{#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}
</div>
{/if}
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<form
on:submit|preventDefault={handleSubmit}
class="space-y-4"
>
{#each databases as dbName}
<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}
</label>
<input
@@ -108,14 +152,16 @@
</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
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"
on:click={handleSubmit}
disabled={submitting}
>
{submitting ? 'Resuming...' : 'Resume Migration'}
{submitting ? "Resuming..." : "Resume Migration"}
</button>
<button
type="button"

View File

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

View File

@@ -23,25 +23,27 @@
import { t } from "../lib/i18n";
import TaskLogPanel from "./tasks/TaskLogPanel.svelte";
export let show = false;
export let inline = false;
export let taskId = null;
export let taskStatus = null;
export let realTimeLogs = [];
let {
show = $bindable(false),
inline = false,
taskId = null,
taskStatus = null,
realTimeLogs = [],
} = $props();
const dispatch = createEventDispatcher();
let logs = [];
let loading = false;
let error = "";
let logs = $state([]);
let loading = $state(false);
let error = $state("");
let interval;
let autoScroll = true;
let autoScroll = $state(true);
$: shouldShow = inline || show;
let shouldShow = $derived(inline || show);
// [DEF:handleRealTimeLogs:Action]
/** @PURPOSE Append real-time logs as they arrive from WebSocket, preventing duplicates */
$: if (realTimeLogs && realTimeLogs.length > 0) {
$effect(() => {
if (realTimeLogs && realTimeLogs.length > 0) {
const lastLog = realTimeLogs[realTimeLogs.length - 1];
const exists = logs.some(
(l) =>
@@ -50,33 +52,18 @@
);
if (!exists) {
logs = [...logs, lastLog];
console.log(
`[TaskLogViewer][Action] Appended real-time log, total=${logs.length}`,
);
}
}
});
// [/DEF:handleRealTimeLogs:Action]
// [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() {
if (!taskId) return;
console.log(`[TaskLogViewer][Action] Fetching logs for task=${taskId}`);
try {
logs = await getTaskLogs(taskId);
console.log(
`[TaskLogViewer][Coherence:OK] Logs fetched count=${logs.length}`,
);
} catch (e) {
error = e.message;
console.error(
`[TaskLogViewer][Coherence:Failed] Error: ${e.message}`,
);
} finally {
loading = false;
}
@@ -85,18 +72,14 @@
function handleFilterChange(event) {
const { source, level } = event.detail;
console.log(
`[TaskLogViewer][Action] Filter changed: source=${source}, level=${level}`,
);
}
function handleRefresh() {
console.log(`[TaskLogViewer][Action] Manual refresh`);
fetchLogs();
}
// React to changes in show/taskId/taskStatus
$: if (shouldShow && taskId) {
$effect(() => {
if (shouldShow && taskId) {
if (interval) clearInterval(interval);
logs = [];
loading = true;
@@ -113,6 +96,7 @@
} else {
if (interval) clearInterval(interval);
}
});
onDestroy(() => {
if (interval) clearInterval(interval);
@@ -121,18 +105,25 @@
{#if shouldShow}
{#if inline}
<div class="log-viewer-inline">
<div class="flex flex-col h-full w-full">
{#if loading && logs.length === 0}
<div class="loading-state">
<div class="loading-spinner"></div>
<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>
</div>
{:else if error}
<div class="error-state">
<span class="error-icon"></span>
<div
class="flex items-center justify-center gap-2 h-full text-log-error text-sm"
>
<span class="text-xl"></span>
<span>{error}</span>
<button class="retry-btn" on:click={handleRefresh}
>Retry</button
<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>
{: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"
>
<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"
on:click={() => {
show = false;
@@ -210,67 +201,3 @@
{/if}
<!-- [/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[]}
* @description Array of backup objects to display.
*/
export let backups: Backup[] = [];
let {
backups = [],
} = $props();
// [/SECTION]
</script>
@@ -78,7 +81,5 @@
</div>
<!-- [/SECTION] -->
<style>
</style>
<!-- [/DEF:BackupList:Component] -->

View File

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

View File

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

View File

@@ -12,22 +12,22 @@
<script>
// [SECTION: IMPORTS]
import { createEventDispatcher, onMount } from 'svelte';
import { gitService } from '../../services/gitService';
import { addToast as toast } from '../../lib/toasts.js';
import { api } from '../../lib/api';
import { createEventDispatcher, onMount } from "svelte";
import { gitService } from "../../services/gitService";
import { addToast as toast } from "../../lib/toasts.js";
import { api } from "../../lib/api";
// [/SECTION]
// [SECTION: PROPS]
export let dashboardId;
export let show = false;
let { dashboardId, show = false } = $props();
// [/SECTION]
// [SECTION: STATE]
let message = '';
let message = "";
let committing = false;
let status = null;
let diff = '';
let diff = "";
let loading = false;
let generatingMessage = false;
// [/SECTION]
@@ -41,14 +41,18 @@
async function handleGenerateMessage() {
generatingMessage = true;
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
const data = await api.postApi(`/git/repositories/${dashboardId}/generate-message`);
const data = await api.postApi(
`/git/repositories/${dashboardId}/generate-message`,
);
message = data.message;
toast('Commit message generated', 'success');
toast("Commit message generated", "success");
} catch (e) {
console.error(`[CommitModal][Coherence:Failed] ${e.message}`);
toast(e.message || 'Failed to generate message', 'error');
toast(e.message || "Failed to generate message", "error");
} finally {
generatingMessage = false;
}
@@ -64,20 +68,32 @@
if (!dashboardId || !show) return;
loading = true;
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);
// Fetch both unstaged and staged diffs to show complete picture
const unstagedDiff = await gitService.getDiff(dashboardId, null, false);
const stagedDiff = await gitService.getDiff(dashboardId, null, true);
const unstagedDiff = await gitService.getDiff(
dashboardId,
null,
false,
);
const stagedDiff = await gitService.getDiff(
dashboardId,
null,
true,
);
diff = "";
if (stagedDiff) diff += "--- STAGED CHANGES ---\n" + stagedDiff + "\n\n";
if (unstagedDiff) diff += "--- UNSTAGED CHANGES ---\n" + unstagedDiff;
if (stagedDiff)
diff += "--- STAGED CHANGES ---\n" + stagedDiff + "\n\n";
if (unstagedDiff)
diff += "--- UNSTAGED CHANGES ---\n" + unstagedDiff;
if (!diff) diff = "";
} catch (e) {
console.error(`[CommitModal][Coherence:Failed] ${e.message}`);
toast('Failed to load changes', 'error');
toast("Failed to load changes", "error");
} finally {
loading = false;
}
@@ -92,31 +108,39 @@
*/
async function handleCommit() {
if (!message) return;
console.log(`[CommitModal][Action] Committing changes for dashboard ${dashboardId}`);
console.log(
`[CommitModal][Action] Committing changes for dashboard ${dashboardId}`,
);
committing = true;
try {
await gitService.commit(dashboardId, message, []);
toast('Changes committed successfully', 'success');
dispatch('commit');
toast("Changes committed successfully", "success");
dispatch("commit");
show = false;
message = '';
message = "";
console.log(`[CommitModal][Coherence:OK] Committed`);
} catch (e) {
console.error(`[CommitModal][Coherence:Failed] ${e.message}`);
toast(e.message, 'error');
toast(e.message, "error");
} finally {
committing = false;
}
}
// [/DEF:handleCommit:Function]
$: if (show) loadStatus();
$effect(() => {
if (show) loadStatus();
});
</script>
<!-- [SECTION: TEMPLATE] -->
{#if show}
<div 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">
<div
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>
<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="mb-4">
<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
on:click={handleGenerateMessage}
disabled={generatingMessage || loading}
@@ -146,21 +173,37 @@
{#if status}
<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">
{#each status.staged_files as file}
<li class="text-green-600 flex items-center font-semibold" title="Staged">
<span class="mr-2">S</span> {file}
<li
class="text-green-600 flex items-center font-semibold"
title="Staged"
>
<span class="mr-2">S</span>
{file}
</li>
{/each}
{#each status.modified_files as file}
<li class="text-yellow-600 flex items-center" title="Modified (Unstaged)">
<span class="mr-2">M</span> {file}
<li
class="text-yellow-600 flex items-center"
title="Modified (Unstaged)"
>
<span class="mr-2">M</span>
{file}
</li>
{/each}
{#each status.untracked_files as file}
<li class="text-blue-600 flex items-center" title="Untracked">
<span class="mr-2">?</span> {file}
<li
class="text-blue-600 flex items-center"
title="Untracked"
>
<span class="mr-2">?</span>
{file}
</li>
{/each}
</ul>
@@ -169,15 +212,30 @@
</div>
<!-- Right: Diff Viewer -->
<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="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">
{#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}
<pre class="text-xs font-mono whitespace-pre-wrap">{diff}</pre>
<pre
class="text-xs font-mono whitespace-pre-wrap">{diff}</pre>
{: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}
</div>
</div>
@@ -185,17 +243,21 @@
<div class="flex justify-end space-x-3 mt-6 pt-4 border-t">
<button
on:click={() => show = false}
on:click={() => (show = false)}
class="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded"
>
Cancel
</button>
<button
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"
>
{committing ? 'Committing...' : 'Commit'}
{committing ? "Committing..." : "Commit"}
</button>
</div>
</div>
@@ -203,10 +265,4 @@
{/if}
<!-- [/SECTION] -->
<style>
pre {
tab-size: 4;
}
</style>
<!-- [/DEF:CommitModal:Component] -->

View File

@@ -10,14 +10,17 @@
<script>
// [SECTION: IMPORTS]
import { createEventDispatcher } from 'svelte';
import { addToast as toast } from '../../lib/toasts.js';
import { createEventDispatcher } from "svelte";
import { addToast as toast } from "../../lib/toasts.js";
// [/SECTION]
// [SECTION: PROPS]
/** @type {Array<{file_path: string, mine: string, theirs: string}>} */
export let conflicts = [];
export let show = false;
let {
conflicts = [],
show = false,
} = $props();
// [/SECTION]
// [SECTION: STATE]
@@ -36,7 +39,9 @@
* @side_effect Updates resolutions state.
*/
function resolve(file, strategy) {
console.log(`[ConflictResolver][Action] Resolving ${file} with ${strategy}`);
console.log(
`[ConflictResolver][Action] Resolving ${file} with ${strategy}`,
);
resolutions[file] = strategy;
resolutions = { ...resolutions }; // Trigger update
}
@@ -51,16 +56,21 @@
*/
function handleSave() {
// 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) {
console.warn(`[ConflictResolver][Coherence:Failed] ${unresolved.length} unresolved conflicts`);
toast(`Please resolve all conflicts first. (${unresolved.length} remaining)`, 'error');
console.warn(
`[ConflictResolver][Coherence:Failed] ${unresolved.length} unresolved conflicts`,
);
toast(
`Please resolve all conflicts first. (${unresolved.length} remaining)`,
"error",
);
return;
}
// 2. Implementation
console.log(`[ConflictResolver][Coherence:OK] All conflicts resolved`);
dispatch('resolve', resolutions);
dispatch("resolve", resolutions);
show = false;
}
// [/DEF:handleSave:Function]
@@ -68,43 +78,78 @@
<!-- [SECTION: TEMPLATE] -->
{#if show}
<div 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-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="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-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">
{#each conflicts as conflict}
<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>
{#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]}
</span>
{/if}
</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="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">
<pre class="text-xs font-mono whitespace-pre">{conflict.mine}</pre>
<pre
class="text-xs font-mono whitespace-pre">{conflict.mine}</pre>
</div>
<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'}"
on:click={() => resolve(conflict.file_path, 'mine')}
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'}"
on:click={() =>
resolve(conflict.file_path, "mine")}
>
Keep Mine
</button>
</div>
<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">
<pre class="text-xs font-mono whitespace-pre">{conflict.theirs}</pre>
<pre
class="text-xs font-mono whitespace-pre">{conflict.theirs}</pre>
</div>
<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'}"
on:click={() => resolve(conflict.file_path, 'theirs')}
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'}"
on:click={() =>
resolve(conflict.file_path, "theirs")}
>
Keep Theirs
</button>
@@ -116,7 +161,7 @@
<div class="flex justify-end space-x-3 pt-4 border-t">
<button
on:click={() => show = false}
on:click={() => (show = false)}
class="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded transition-colors"
>
Cancel
@@ -133,10 +178,4 @@
{/if}
<!-- [/SECTION] -->
<style>
pre {
tab-size: 4;
}
</style>
<!-- [/DEF:ConflictResolver:Component] -->

View File

@@ -11,19 +11,19 @@
<script>
// [SECTION: IMPORTS]
import { onMount, createEventDispatcher } from 'svelte';
import { gitService } from '../../services/gitService';
import { addToast as toast } from '../../lib/toasts.js';
import { onMount, createEventDispatcher } from "svelte";
import { gitService } from "../../services/gitService";
import { addToast as toast } from "../../lib/toasts.js";
// [/SECTION]
// [SECTION: PROPS]
export let dashboardId;
export let show = false;
let { dashboardId, show = false } = $props();
// [/SECTION]
// [SECTION: STATE]
let environments = [];
let selectedEnv = '';
let selectedEnv = "";
let loading = false;
let deploying = false;
// [/SECTION]
@@ -31,7 +31,9 @@
const dispatch = createEventDispatcher();
// [DEF:loadStatus:Watcher]
$: if (show) loadEnvironments();
$effect(() => {
if (show) loadEnvironments();
});
// [/DEF:loadStatus:Watcher]
// [DEF:loadEnvironments:Function]
@@ -48,10 +50,12 @@
if (environments.length > 0) {
selectedEnv = environments[0].id;
}
console.log(`[DeploymentModal][Coherence:OK] Loaded ${environments.length} environments`);
console.log(
`[DeploymentModal][Coherence:OK] Loaded ${environments.length} environments`,
);
} catch (e) {
console.error(`[DeploymentModal][Coherence:Failed] ${e.message}`);
toast('Failed to load environments', 'error');
toast("Failed to load environments", "error");
} finally {
loading = false;
}
@@ -71,13 +75,16 @@
deploying = true;
try {
const result = await gitService.deploy(dashboardId, selectedEnv);
toast(result.message || 'Deployment triggered successfully', 'success');
dispatch('deploy');
toast(
result.message || "Deployment triggered successfully",
"success",
);
dispatch("deploy");
show = false;
console.log(`[DeploymentModal][Coherence:OK] Deployment triggered`);
} catch (e) {
console.error(`[DeploymentModal][Coherence:Failed] ${e.message}`);
toast(e.message, 'error');
toast(e.message, "error");
} finally {
deploying = false;
}
@@ -87,17 +94,21 @@
<!-- [SECTION: TEMPLATE] -->
{#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">
<h2 class="text-xl font-bold mb-4">Deploy Dashboard</h2>
{#if loading}
<p class="text-gray-500">Loading environments...</p>
{: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">
<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"
>
Close
@@ -105,20 +116,24 @@
</div>
{:else}
<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
bind:value={selectedEnv}
class="w-full border rounded p-2 focus:ring-2 focus:ring-blue-500 outline-none bg-white"
>
{#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}
</select>
</div>
<div class="flex justify-end space-x-3">
<button
on:click={() => show = false}
on:click={() => (show = false)}
class="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded"
>
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"
>
{#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">
<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
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"
>
<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>
Deploying...
{:else}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,14 +8,10 @@
-->
<script>
/** @type {Object} log - The log entry object */
export let log;
/** @type {boolean} showSource - Whether to show the source tag */
export let showSource = true;
let { log, showSource = true } = $props();
// [DEF:formatTime:Function]
/** @PURPOSE Format ISO timestamp to HH:MM:SS */
$: formattedTime = formatTime(log.timestamp);
function formatTime(timestamp) {
if (!timestamp) return "";
const date = new Date(timestamp);
@@ -28,180 +24,77 @@
}
// [/DEF:formatTime:Function]
$: levelClass = getLevelClass(log.level);
let formattedTime = $derived(formatTime(log.timestamp));
function getLevelClass(level) {
switch (level?.toUpperCase()) {
case "DEBUG":
return "level-debug";
case "INFO":
return "level-info";
case "WARNING":
return "level-warning";
case "ERROR":
return "level-error";
default:
return "level-info";
}
}
const levelStyles = {
DEBUG: "text-log-debug bg-log-debug/15",
INFO: "text-log-info bg-log-info/10",
WARNING: "text-log-warning bg-log-warning/10",
ERROR: "text-log-error bg-log-error/10",
};
$: 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) {
if (!source) return "source-default";
return `source-${source.toLowerCase().replace(/[^a-z0-9]/g, "-")}`;
}
let levelClass = $derived(
levelStyles[log.level?.toUpperCase()] || levelStyles.INFO,
);
let sourceClass = $derived(
sourceStyles[log.source?.toLowerCase()] ||
"bg-log-debug/15 text-terminal-text-subtle",
);
$: hasProgress = log.metadata?.progress !== undefined;
$: progressPercent = log.metadata?.progress || 0;
let hasProgress = $derived(log.metadata?.progress !== undefined);
let progressPercent = $derived(log.metadata?.progress || 0);
</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 -->
<div class="log-meta">
<span class="log-time">{formattedTime}</span>
<span class="log-level {levelClass}">{log.level || "INFO"}</span>
<div class="flex items-center gap-2 mb-1">
<span class="font-mono text-[0.6875rem] text-terminal-text-muted shrink-0"
>{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}
<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}
</div>
<!-- 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) -->
{#if hasProgress}
<div class="progress-container">
<div class="progress-track">
<div class="progress-fill" style="width: {progressPercent}%"></div>
<div class="flex items-center gap-2 mt-1.5">
<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>
<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>
{/if}
</div>
<!-- [/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>
import { createEventDispatcher } from "svelte";
/** @type {string[]} availableSources - List of available source options */
export let availableSources = [];
/** @type {string} selectedLevel - Currently selected log level filter */
export let selectedLevel = "";
/** @type {string} selectedSource - Currently selected source filter */
export let selectedSource = "";
/** @type {string} searchText - Current search text */
export let searchText = "";
let {
availableSources = [],
selectedLevel = $bindable(""),
selectedSource = $bindable(""),
searchText = $bindable(""),
} = $props();
const dispatch = createEventDispatcher();
@@ -63,13 +61,18 @@
dispatch("filter-change", { level: "", source: "", search: "" });
}
$: hasActiveFilters = selectedLevel || selectedSource || searchText;
let hasActiveFilters = $derived(
selectedLevel || selectedSource || searchText,
);
</script>
<div class="filter-bar">
<div class="filter-controls">
<div
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
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}
on:change={handleLevelChange}
aria-label="Filter by level"
@@ -80,7 +83,8 @@
</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}
on:change={handleSourceChange}
aria-label="Filter by source"
@@ -91,9 +95,9 @@
{/each}
</select>
<div class="search-wrapper">
<div class="relative flex-1 min-w-0">
<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"
width="14"
height="14"
@@ -107,7 +111,7 @@
</svg>
<input
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..."
value={searchText}
on:input={handleSearchChange}
@@ -118,7 +122,7 @@
{#if hasActiveFilters}
<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}
aria-label="Clear filters"
>
@@ -137,98 +141,3 @@
{/if}
</div>
<!-- [/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
-->
<script>
import { createEventDispatcher, onMount, afterUpdate } from "svelte";
import { createEventDispatcher, onMount, tick } from "svelte";
import LogFilterBar from "./LogFilterBar.svelte";
import LogEntryRow from "./LogEntryRow.svelte";
/**
* @PURPOSE Component properties and state.
* @PRE logs is an array of LogEntry objects.
*/
export let logs = [];
export let autoScroll = true;
let { logs = [], autoScroll = $bindable(true) } = $props();
const dispatch = createEventDispatcher();
let scrollContainer;
let selectedSource = "all";
let selectedLevel = "all";
let searchText = "";
let selectedSource = $state("all");
let selectedLevel = $state("all");
let searchText = $state("");
// Filtered logs based on current filters
$: filteredLogs = filterLogs(logs, selectedLevel, selectedSource, searchText);
let filteredLogs = $derived(
filterLogs(logs, selectedLevel, selectedSource, searchText),
);
function filterLogs(allLogs, level, source, search) {
return allLogs.filter((log) => {
@@ -52,17 +48,15 @@
});
}
// Extract unique sources from logs
$: availableSources = [...new Set(logs.map((l) => l.source).filter(Boolean))];
let availableSources = $derived([
...new Set(logs.map((l) => l.source).filter(Boolean)),
]);
function handleFilterChange(event) {
const { source, level, search } = event.detail;
selectedSource = source || "all";
selectedLevel = level || "all";
searchText = search || "";
console.log(
`[TaskLogPanel][Action] Filter: level=${selectedLevel}, source=${selectedSource}, search=${searchText}`,
);
dispatch("filterChange", { source, level });
}
@@ -77,8 +71,11 @@
if (autoScroll) scrollToBottom();
}
afterUpdate(() => {
scrollToBottom();
// Use $effect instead of afterUpdate for runes mode
$effect(() => {
// Track filteredLogs length to trigger scroll
filteredLogs.length;
tick().then(scrollToBottom);
});
onMount(() => {
@@ -86,14 +83,19 @@
});
</script>
<div class="log-panel">
<div class="flex flex-col h-full bg-terminal-bg overflow-hidden">
<!-- Filter Bar -->
<LogFilterBar {availableSources} on:filter-change={handleFilterChange} />
<!-- 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}
<div class="empty-logs">
<div
class="flex flex-col items-center justify-center py-12 px-4 text-terminal-border gap-3"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
@@ -109,7 +111,9 @@
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
<span>No logs available</span>
<span class="text-[0.8125rem] text-terminal-text-muted"
>No logs available</span
>
</div>
{:else}
{#each filteredLogs as log}
@@ -119,129 +123,27 @@
</div>
<!-- Footer Stats -->
<div class="log-footer">
<span class="log-count">
<div
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
? ` / ${logs.length}`
: ""} entries
</span>
<button
class="autoscroll-btn"
class:active={autoScroll}
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
{autoScroll ? 'text-terminal-accent' : ''}"
on:click={toggleAutoScroll}
aria-label="Toggle auto-scroll"
>
{#if autoScroll}
<span class="pulse-dot"></span>
<span
class="inline-block w-[5px] h-[5px] rounded-full bg-terminal-accent animate-pulse"
></span>
{/if}
Auto-scroll {autoScroll ? "on" : "off"}
</button>
</div>
</div>
<!-- [/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
*/
import { page } from '$app/stores';
import { t, _ } from '$lib/i18n';
import { page } from "$app/stores";
import { t, _ } from "$lib/i18n";
export let maxVisible = 3;
let { maxVisible = 3 } = $props();
// 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
@@ -26,42 +28,26 @@
* @returns {Array} Array of breadcrumb items
*/
function getBreadcrumbs(pathname, maxVisible = 3) {
const segments = pathname.split('/').filter(Boolean);
const allItems = [
{ label: 'Home', path: '/' }
];
const segments = pathname.split("/").filter(Boolean);
const allItems = [{ label: "Home", path: "/" }];
let currentPath = '';
let currentPath = "";
segments.forEach((segment, index) => {
currentPath += `/${segment}`;
// Convert segment to readable label
const label = formatBreadcrumbLabel(segment);
allItems.push({
label,
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) {
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 = [];
itemsToShow.push(firstItem);
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);
for (let i = startFromIndex; i < allItems.length; i++) {
itemsToShow.push(allItems[i]);
@@ -78,63 +64,46 @@
* @returns {string} Formatted label
*/
function formatBreadcrumbLabel(segment) {
// Handle special cases
const specialCases = {
'dashboards': 'nav.dashboard',
'datasets': 'nav.tools_mapper',
'storage': 'nav.tools_storage',
'admin': 'nav.admin',
'settings': 'nav.settings',
'git': 'nav.git'
dashboards: "nav.dashboard",
datasets: "nav.tools_mapper",
storage: "nav.tools_storage",
admin: "nav.admin",
settings: "nav.settings",
git: "nav.git",
};
if (specialCases[segment]) {
return _(specialCases[segment]) || segment;
}
// Default: capitalize and replace hyphens with spaces
return segment
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
</script>
<style>
.breadcrumbs {
@apply flex items-center space-x-2 text-sm text-gray-600;
}
.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">
<nav
class="flex items-center space-x-2 text-sm text-gray-600"
aria-label="Breadcrumb navigation"
>
{#each breadcrumbItems as item, index}
<div class="breadcrumb-item">
<div class="flex items-center">
{#if item.isEllipsis}
<span class="breadcrumb-separator">...</span>
<span class="text-gray-400">...</span>
{:else if item.isLast}
<span class="breadcrumb-current">{item.label}</span>
<span class="text-gray-900 font-medium">{item.label}</span>
{: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}
</div>
{#if index < breadcrumbItems.length - 1}
<span class="breadcrumb-separator">/</span>
<span class="text-gray-400">/</span>
{/if}
{/each}
</nav>

View File

@@ -30,7 +30,7 @@
{
id: "dashboards",
label: $t.nav?.dashboards || "DASHBOARDS",
icon: "M3 3h18v18H3V3zm16 16V5H5v14h14z", // Grid icon
icon: "M3 3h18v18H3V3zm16 16V5H5v14h14z",
path: "/dashboards",
subItems: [
{ label: $t.nav?.overview || "Overview", path: "/dashboards" },
@@ -39,7 +39,7 @@
{
id: "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",
subItems: [
{ label: $t.nav?.all_datasets || "All Datasets", path: "/datasets" },
@@ -48,7 +48,7 @@
{
id: "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",
subItems: [
{ label: $t.nav?.backups || "Backups", path: "/storage/backups" },
@@ -61,7 +61,7 @@
{
id: "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",
subItems: [
{ label: $t.nav?.admin_users || "Users", path: "/admin/users" },
@@ -75,7 +75,7 @@
let activeCategory = "dashboards";
let activeItem = "/dashboards";
let isMobileOpen = false;
let expandedCategories = new Set(["dashboards"]); // Track expanded categories
let expandedCategories = new Set(["dashboards"]);
// Subscribe to sidebar store
$: if ($sidebarStore) {
@@ -90,7 +90,7 @@
{
id: "dashboards",
label: $t.nav?.dashboards || "DASHBOARDS",
icon: "M3 3h18v18H3V3zm16 16V5H5v14h14z", // Grid icon
icon: "M3 3h18v18H3V3zm16 16V5H5v14h14z",
path: "/dashboards",
subItems: [
{ label: $t.nav?.overview || "Overview", path: "/dashboards" },
@@ -99,7 +99,7 @@
{
id: "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",
subItems: [
{ label: $t.nav?.all_datasets || "All Datasets", path: "/datasets" },
@@ -108,7 +108,7 @@
{
id: "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",
subItems: [
{ label: $t.nav?.backups || "Backups", path: "/storage/backups" },
@@ -121,7 +121,7 @@
{
id: "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",
subItems: [
{ label: $t.nav?.admin_users || "Users", path: "/admin/users" },
@@ -133,7 +133,6 @@
// Update active item when page changes
$: if ($page && $page.url.pathname !== activeItem) {
// Find matching category
const matched = categories.find((cat) =>
$page.url.pathname.startsWith(cat.path),
);
@@ -143,7 +142,6 @@
}
}
// Handle click on sidebar item
function handleItemClick(category) {
console.log(`[Sidebar][Action] Clicked category ${category.id}`);
setActiveItem(category.id, category.path);
@@ -153,7 +151,6 @@
}
}
// Handle click on category header to toggle expansion
function handleCategoryToggle(categoryId, event) {
event.stopPropagation();
@@ -173,28 +170,24 @@
} else {
expandedCategories.add(categoryId);
}
expandedCategories = expandedCategories; // Trigger reactivity
expandedCategories = expandedCategories;
}
// Handle click on sub-item
function handleSubItemClick(categoryId, path) {
console.log(`[Sidebar][Action] Clicked sub-item ${path}`);
setActiveItem(categoryId, path);
closeMobile();
// Force navigation if it's a link
if (browser) {
window.location.href = path;
}
}
// Handle toggle button click
function handleToggleClick(event) {
event.stopPropagation();
console.log("[Sidebar][Action] Toggle sidebar");
toggleSidebar();
}
// Handle mobile overlay click
function handleOverlayClick() {
console.log("[Sidebar][Action] Close mobile overlay");
closeMobile();
@@ -209,7 +202,7 @@
<!-- Mobile overlay (only on mobile) -->
{#if isMobileOpen}
<div
class="mobile-overlay"
class="fixed inset-0 bg-black/50 z-20 md:hidden"
on:click={handleOverlayClick}
on:keydown={(e) => e.key === "Escape" && handleOverlayClick()}
role="presentation"
@@ -218,12 +211,18 @@
<!-- Sidebar -->
<div
class="sidebar {isExpanded ? 'expanded' : 'collapsed'} {isMobileOpen
? 'mobile'
: 'mobile-hidden'}"
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
{isExpanded ? 'w-sidebar' : 'w-sidebar-collapsed'}
{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}
<span class="font-semibold text-gray-800">Menu</span>
{:else}
@@ -232,13 +231,14 @@
</div>
<!-- Navigation items -->
<nav class="nav-section">
<nav class="flex-1 overflow-y-auto py-2">
{#each categories as category}
<div class="category">
<div>
<!-- Category Header -->
<div
class="category-header {activeCategory === category.id
? 'active'
class="flex items-center justify-between px-4 py-3 cursor-pointer transition-colors hover:bg-gray-100
{activeCategory === category.id
? 'bg-primary-light text-primary md:border-r-2 md:border-primary'
: ''}"
on:click={(e) => handleCategoryToggle(category.id, e)}
on:keydown={(e) =>
@@ -251,7 +251,7 @@
>
<div class="flex items-center">
<svg
class="nav-icon"
class="w-5 h-5 shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
@@ -261,13 +261,17 @@
<path d={category.icon} />
</svg>
{#if isExpanded}
<span class="nav-label">{category.label}</span>
<span class="ml-3 text-sm font-medium truncate"
>{category.label}</span
>
{/if}
</div>
{#if isExpanded}
<svg
class="category-toggle {expandedCategories.has(category.id)
? 'expanded'
class="text-gray-400 transition-transform duration-200 {expandedCategories.has(
category.id,
)
? 'rotate-180'
: ''}"
xmlns="http://www.w3.org/2000/svg"
width="16"
@@ -284,10 +288,13 @@
<!-- Sub Items (only when expanded) -->
{#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}
<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:keydown={(e) =>
(e.key === "Enter" || e.key === " ") &&
@@ -306,8 +313,11 @@
<!-- Footer with Collapse button -->
{#if isExpanded}
<div class="sidebar-footer">
<button class="collapse-btn" on:click={handleToggleClick}>
<div class="border-t border-gray-200 p-4">
<button
class="flex items-center justify-center w-full px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
on:click={handleToggleClick}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
@@ -324,8 +334,12 @@
</button>
</div>
{:else}
<div class="sidebar-footer">
<button class="collapse-btn" on:click={handleToggleClick} aria-label="Expand sidebar">
<div class="border-t border-gray-200 p-4">
<button
class="flex items-center justify-center w-full px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
on:click={handleToggleClick}
aria-label="Expand sidebar"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
@@ -337,101 +351,10 @@
>
<path d="M9 18l6-6-6-6" />
</svg>
<span class="collapse-btn-text">Expand</span>
<span class="ml-2">Expand</span>
</button>
</div>
{/if}
</div>
<!-- [/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 isSearchFocused = false;
// Subscribe to sidebar store for responsive layout
$: isExpanded = $sidebarStore?.isExpanded ?? true;
// Subscribe to activity store
$: activeCount = $activityStore?.activeCount || 0;
$: recentTasks = $activityStore?.recentTasks || [];
// Get user from auth store
$: user = $auth?.user || null;
// Toggle user menu
function toggleUserMenu(event) {
event.stopPropagation();
showUserMenu = !showUserMenu;
console.log(`[TopNavbar][Action] Toggle user menu: ${showUserMenu}`);
}
// Close user menu
function closeUserMenu() {
showUserMenu = false;
}
// Handle logout
function handleLogout() {
console.log("[TopNavbar][Action] Logout");
auth.logout();
closeUserMenu();
// Navigate to login
window.location.href = "/login";
}
// Handle activity indicator click - open Task Drawer with most recent task
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");
if (runningTask) {
openDrawerForTask(runningTask.taskId);
} else if (recentTasks.length > 0) {
openDrawerForTask(recentTasks[recentTasks.length - 1].taskId);
} else {
// No tracked tasks — open in list mode to show recent tasks from API
openDrawer();
}
dispatch("activityClick");
}
// Handle search focus
function handleSearchFocus() {
isSearchFocused = true;
}
@@ -87,34 +71,31 @@
isSearchFocused = false;
}
// Close dropdowns when clicking outside
function handleDocumentClick(event) {
if (!event.target.closest(".user-menu-container")) {
closeUserMenu();
}
}
// Listen for document clicks
if (typeof document !== "undefined") {
document.addEventListener("click", handleDocumentClick);
}
// Handle hamburger menu click for mobile
function handleHamburgerClick(event) {
event.stopPropagation();
console.log("[TopNavbar][Action] Toggle mobile sidebar");
toggleMobileSidebar();
}
</script>
<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 -->
<div class="flex items-center gap-2">
<!-- Hamburger Menu (mobile only) -->
<button
class="hamburger-btn"
class="p-2 rounded-lg hover:bg-gray-100 text-gray-600 md:hidden"
on:click={handleHamburgerClick}
aria-label="Toggle menu"
>
@@ -134,9 +115,12 @@
</button>
<!-- 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
class="logo-icon"
class="w-8 h-8 mr-2 text-primary"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
@@ -148,10 +132,11 @@
</div>
<!-- Search placeholder (non-functional for now) -->
<div class="search-container">
<div class="flex-1 max-w-xl mx-4 hidden md:block">
<input
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..."}
on:focus={handleSearchFocus}
on:blur={handleSearchBlur}
@@ -159,10 +144,10 @@
</div>
<!-- Nav Actions -->
<div class="nav-actions">
<div class="flex items-center space-x-4">
<!-- Activity Indicator -->
<div
class="activity-indicator"
class="relative cursor-pointer p-2 rounded-lg hover:bg-gray-100 transition-colors"
on:click={handleActivityClick}
on:keydown={(e) =>
(e.key === "Enter" || e.key === " ") && handleActivityClick()}
@@ -171,7 +156,7 @@
aria-label="Activity"
>
<svg
class="activity-icon"
class="w-6 h-6 text-gray-600"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
@@ -183,14 +168,17 @@
/>
</svg>
{#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}
</div>
<!-- User Menu -->
<div class="user-menu-container">
<div class="user-menu-container relative">
<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:keydown={(e) =>
(e.key === "Enter" || e.key === " ") && toggleUserMenu(e)}
@@ -208,13 +196,17 @@
</div>
<!-- User Dropdown -->
<div class="user-dropdown {showUserMenu ? '' : 'hidden'}">
<div class="dropdown-item">
<div
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>
</div>
<div class="dropdown-divider"></div>
<div class="border-t border-gray-200 my-1"></div>
<div
class="dropdown-item"
class="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer"
on:click={() => {
window.location.href = "/settings";
}}
@@ -227,7 +219,7 @@
{$t.nav?.settings || "Settings"}
</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:keydown={(e) =>
(e.key === "Enter" || e.key === " ") && handleLogout()}
@@ -242,96 +234,3 @@
</nav>
<!-- [/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.
-->
<script lang="ts">
<script>
// [SECTION: IMPORTS]
import { cn } from '$lib/utils.js';
// [/SECTION: IMPORTS]
// [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';
export let size: 'sm' | 'md' | 'lg' = 'md';
export let isLoading: boolean = false;
export let disabled: boolean = false;
export let type: 'button' | 'submit' | 'reset' = 'button';
let className: string = "";
export { className as class };
let {
variant = 'primary',
size = 'md',
isLoading = false,
disabled = false,
type = 'button',
class: className = '',
children,
onclick,
...rest
} = $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 = {
primary: "bg-blue-600 text-white hover:bg-blue-700 focus-visible:ring-blue-500",
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200 focus-visible:ring-gray-500",
danger: "bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500",
ghost: "bg-transparent hover:bg-gray-100 text-gray-700 focus-visible:ring-gray-500"
primary: 'bg-primary text-white hover:bg-primary-hover focus-visible:ring-primary-ring',
secondary: 'bg-secondary text-secondary-text hover:bg-secondary-hover focus-visible:ring-secondary-ring',
danger: 'bg-destructive text-white hover:bg-destructive-hover focus-visible:ring-destructive-ring',
ghost: 'bg-transparent hover:bg-ghost-hover text-ghost-text focus-visible:ring-ghost-ring',
};
const sizes = {
sm: "h-8 px-3 text-xs",
md: "h-10 px-4 py-2 text-sm",
lg: "h-12 px-6 text-base"
sm: 'h-8 px-3 text-xs',
md: 'h-10 px-4 py-2 text-sm',
lg: 'h-12 px-6 text-base',
};
</script>
<!-- [SECTION: TEMPLATE] -->
<button
{type}
class="{baseStyles} {variants[variant]} {sizes[size]} {className}"
class={cn(baseStyles, variants[variant], sizes[size], className)}
disabled={disabled || isLoading}
on:click
{onclick}
{...rest}
>
{#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">
@@ -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>
</svg>
{/if}
<slot />
{@render children?.()}
</button>
<!-- [/SECTION: TEMPLATE] -->

View File

@@ -6,29 +6,44 @@
@LAYER: Atom
-->
<script lang="ts">
<script>
// [SECTION: IMPORTS]
import { cn } from "$lib/utils.js";
// [/SECTION: IMPORTS]
// [SECTION: PROPS]
export let title: string = "";
export let padding: 'none' | 'sm' | 'md' | 'lg' = 'md';
let {
title = "",
padding = "md",
class: className = "",
children,
...rest
} = $props();
// [/SECTION: PROPS]
const paddings = {
none: "p-0",
sm: "p-3",
md: "p-6",
lg: "p-8"
lg: "p-8",
};
</script>
<!-- [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}
<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>
</div>
{/if}
<div class="{paddings[padding]}">
<slot />
<div class={paddings[padding]}>
{@render children?.()}
</div>
</div>
<!-- [/SECTION: TEMPLATE] -->

View File

@@ -8,14 +8,22 @@
@INVARIANT: Consistent spacing and focus states.
-->
<script lang="ts">
<script>
// [SECTION: IMPORTS]
import { cn } from "$lib/utils.js";
// [/SECTION: IMPORTS]
// [SECTION: PROPS]
export let label: string = "";
export let value: string = "";
export let placeholder: string = "";
export let error: string = "";
export let disabled: boolean = false;
export let type: 'text' | 'password' | 'email' | 'number' = 'text';
let {
label = "",
value = $bindable(""),
placeholder = "",
error = "",
disabled = false,
type = "text",
class: className = "",
...rest
} = $props();
// [/SECTION: PROPS]
let id = "input-" + Math.random().toString(36).substr(2, 9);
@@ -35,11 +43,16 @@
{placeholder}
{disabled}
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}
<span class="text-xs text-red-500">{error}</span>
<span class="text-xs text-destructive">{error}</span>
{/if}
</div>
<!-- [/SECTION: TEMPLATE] -->

View File

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

View File

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

View File

@@ -6,12 +6,20 @@
@LAYER: Atom
-->
<script lang="ts">
<script>
// [SECTION: IMPORTS]
import { cn } from "$lib/utils.js";
// [/SECTION: IMPORTS]
// [SECTION: PROPS]
export let label: string = "";
export let value: string | number = "";
export let options: Array<{ value: string | number, label: string }> = [];
export let disabled: boolean = false;
let {
label = "",
value = $bindable(""),
options = [],
disabled = false,
class: className = "",
...rest
} = $props();
// [/SECTION: PROPS]
let id = "select-" + Math.random().toString(36).substr(2, 9);
@@ -29,7 +37,11 @@
{id}
{disabled}
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}
<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
*/
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { onMount } from "svelte";
import { goto } from "$app/navigation";
onMount(() => {
// Redirect to Dashboard Hub as per UX requirements
goto('/dashboards', { replaceState: true });
goto("/dashboards", { replaceState: true });
});
</script>
<style>
.loading {
@apply flex items-center justify-center min-h-screen;
}
</style>
<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 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>
<div class="flex items-center justify-center min-h-screen">
<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
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>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,14 +14,14 @@
* @UX_RECOVERY: Refresh button reloads settings data
*/
import { onMount } from 'svelte';
import { t } from '$lib/i18n';
import { api } from '$lib/api.js';
import { addToast } from '$lib/toasts';
import ProviderConfig from '../../components/llm/ProviderConfig.svelte';
import { onMount } from "svelte";
import { t } from "$lib/i18n";
import { api } from "$lib/api.js";
import { addToast } from "$lib/toasts";
import ProviderConfig from "../../components/llm/ProviderConfig.svelte";
// State
let activeTab = 'environments';
let activeTab = "environments";
let settings = null;
let isLoading = true;
let error = null;
@@ -30,16 +30,16 @@
let editingEnvId = null;
let isAddingEnv = false;
let newEnv = {
id: '',
name: '',
url: '',
username: '',
password: '',
id: "",
name: "",
url: "",
username: "",
password: "",
is_default: false,
backup_schedule: {
enabled: false,
cron_expression: '0 0 * * *'
}
cron_expression: "0 0 * * *",
},
};
// Load settings on mount
@@ -55,8 +55,8 @@
const response = await api.getConsolidatedSettings();
settings = response;
} catch (err) {
error = err.message || 'Failed to load settings';
console.error('[SettingsPage][Coherence:Failed]', err);
error = err.message || "Failed to load settings";
console.error("[SettingsPage][Coherence:Failed]", err);
} finally {
isLoading = false;
}
@@ -70,39 +70,42 @@
// Get tab class
function getTabClass(tab) {
return activeTab === tab
? 'text-blue-600 border-b-2 border-blue-600'
: 'text-gray-600 hover:text-gray-800 border-transparent hover:border-gray-300';
? "text-blue-600 border-b-2 border-blue-600"
: "text-gray-600 hover:text-gray-800 border-transparent hover:border-gray-300";
}
// Handle global settings save (Logging, Storage)
async function handleSave() {
console.log('[SettingsPage][Action] Saving settings');
console.log("[SettingsPage][Action] Saving settings");
try {
// In a real app we might want to only send the changed section,
// but updateConsolidatedSettings expects full object or we can use specific endpoints.
// For now we use the consolidated update.
await api.updateConsolidatedSettings(settings);
addToast($t.settings?.save_success || 'Settings saved', 'success');
addToast($t.settings?.save_success || "Settings saved", "success");
} catch (err) {
console.error('[SettingsPage][Coherence:Failed]', err);
addToast($t.settings?.save_failed || 'Failed to save settings', 'error');
console.error("[SettingsPage][Coherence:Failed]", err);
addToast($t.settings?.save_failed || "Failed to save settings", "error");
}
}
// Handle environment actions
async function handleTestEnv(id) {
console.log(`[SettingsPage][Action] Test environment ${id}`);
addToast('Testing connection...', 'info');
addToast("Testing connection...", "info");
try {
const result = await api.testEnvironmentConnection(id);
if (result.status === 'success') {
addToast('Connection successful', 'success');
if (result.status === "success") {
addToast("Connection successful", "success");
} else {
addToast(`Connection failed: ${result.message}`, 'error');
addToast(`Connection failed: ${result.message}`, "error");
}
} catch (err) {
console.error('[SettingsPage][Coherence:Failed] Error testing connection:', err);
addToast('Failed to test connection', 'error');
console.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
// Ensure backup_schedule exists
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;
isAddingEnv = false;
@@ -119,36 +122,38 @@
function resetEnvForm() {
newEnv = {
id: '',
name: '',
url: '',
username: '',
password: '',
id: "",
name: "",
url: "",
username: "",
password: "",
is_default: false,
backup_schedule: {
enabled: false,
cron_expression: '0 0 * * *'
}
cron_expression: "0 0 * * *",
},
};
editingEnvId = null;
}
async function handleAddOrUpdateEnv() {
try {
console.log(`[SettingsPage][Action] ${editingEnvId ? 'Updating' : 'Adding'} environment.`);
console.log(
`[SettingsPage][Action] ${editingEnvId ? "Updating" : "Adding"} environment.`,
);
// Basic validation
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;
}
if (editingEnvId) {
await api.updateEnvironment(editingEnvId, newEnv);
addToast('Environment updated', 'success');
addToast("Environment updated", "success");
} else {
await api.addEnvironment(newEnv);
addToast('Environment added', 'success');
addToast("Environment added", "success");
}
resetEnvForm();
@@ -156,148 +161,138 @@
isAddingEnv = false;
await loadSettings();
} catch (error) {
console.error("[SettingsPage][Coherence:Failed] Failed to save environment:", error);
addToast(error.message || 'Failed to save environment', 'error');
console.error(
"[SettingsPage][Coherence:Failed] Failed to save environment:",
error,
);
addToast(error.message || "Failed to save environment", "error");
}
}
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}`);
try {
await api.deleteEnvironment(id);
addToast('Environment deleted', 'success');
addToast("Environment deleted", "success");
await loadSettings();
} catch (error) {
console.error("[SettingsPage][Coherence:Failed] Failed to delete environment:", error);
addToast('Failed to delete environment', 'error');
console.error(
"[SettingsPage][Coherence:Failed] Failed to delete environment:",
error,
);
addToast("Failed to delete environment", "error");
}
}
}
</script>
<style>
.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">
<div class="max-w-7xl mx-auto px-4 py-6">
<!-- Header -->
<div class="header">
<h1 class="title">{$t.settings?.title || 'Settings'}</h1>
<button class="refresh-btn" on:click={loadSettings}>
{$t.common?.refresh || 'Refresh'}
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">
{$t.settings?.title || "Settings"}
</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>
</div>
<!-- Error Banner -->
{#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>
<button class="retry-btn" on:click={loadSettings}>
{$t.common?.retry || 'Retry'}
<button
class="px-4 py-2 bg-destructive text-white rounded hover:bg-destructive-hover transition-colors"
on:click={loadSettings}
>
{$t.common?.retry || "Retry"}
</button>
</div>
{/if}
<!-- Loading State -->
{#if isLoading}
<div class="tab-content">
<div class="skeleton h-8"></div>
<div class="skeleton h-8"></div>
<div class="skeleton h-8"></div>
<div class="skeleton h-8"></div>
<div class="skeleton h-8"></div>
<div class="bg-white rounded-lg p-6 border border-gray-200">
<div class="animate-pulse bg-gray-200 rounded h-8"></div>
<div class="animate-pulse bg-gray-200 rounded h-8"></div>
<div class="animate-pulse bg-gray-200 rounded h-8"></div>
<div class="animate-pulse bg-gray-200 rounded h-8"></div>
<div class="animate-pulse bg-gray-200 rounded h-8"></div>
</div>
{:else if settings}
<!-- Tabs -->
<div class="tabs">
<div class="border-b border-gray-200 mb-6">
<button
class="tab-btn {getTabClass('environments')}"
on:click={() => handleTabChange('environments')}
class="px-4 py-2 text-sm font-medium transition-colors focus:outline-none {getTabClass(
'environments',
)}"
on:click={() => handleTabChange("environments")}
>
{$t.settings?.environments || 'Environments'}
{$t.settings?.environments || "Environments"}
</button>
<button
class="tab-btn {getTabClass('logging')}"
on:click={() => handleTabChange('logging')}
class="px-4 py-2 text-sm font-medium transition-colors focus:outline-none {getTabClass(
'logging',
)}"
on:click={() => handleTabChange("logging")}
>
{$t.settings?.logging || 'Logging'}
{$t.settings?.logging || "Logging"}
</button>
<button
class="tab-btn {getTabClass('connections')}"
on:click={() => handleTabChange('connections')}
class="px-4 py-2 text-sm font-medium transition-colors focus:outline-none {getTabClass(
'connections',
)}"
on:click={() => handleTabChange("connections")}
>
{$t.settings?.connections || 'Connections'}
{$t.settings?.connections || "Connections"}
</button>
<button
class="tab-btn {getTabClass('llm')}"
on:click={() => handleTabChange('llm')}
class="px-4 py-2 text-sm font-medium transition-colors focus:outline-none {getTabClass(
'llm',
)}"
on:click={() => handleTabChange("llm")}
>
{$t.settings?.llm || 'LLM'}
{$t.settings?.llm || "LLM"}
</button>
<button
class="tab-btn {getTabClass('storage')}"
on:click={() => handleTabChange('storage')}
class="px-4 py-2 text-sm font-medium transition-colors focus:outline-none {getTabClass(
'storage',
)}"
on:click={() => handleTabChange("storage")}
>
{$t.settings?.storage || 'Storage'}
{$t.settings?.storage || "Storage"}
</button>
</div>
<!-- Tab Content -->
<div class="tab-content">
{#if activeTab === 'environments'}
<div class="bg-white rounded-lg p-6 border border-gray-200">
{#if activeTab === "environments"}
<!-- Environments Tab -->
<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">
{$t.settings?.env_description || 'Configure Superset environments for dashboards and datasets.'}
{$t.settings?.env_description ||
"Configure Superset environments for dashboards and datasets."}
</p>
{#if !editingEnvId && !isAddingEnv}
<div class="flex justify-end mb-6">
<button
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>
</div>
{/if}
@@ -305,10 +300,15 @@
{#if editingEnvId || isAddingEnv}
<!-- Add/Edit Environment Form -->
<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>
<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
type="text"
id="env_id"
@@ -318,43 +318,111 @@
/>
</div>
<div>
<label 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" />
<label
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>
<label 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" />
<label
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>
<label 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" />
<label
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>
<label 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" />
<label
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 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" />
<label for="env_default" class="ml-2 block text-sm text-gray-900">Default Environment</label>
<input
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>
<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="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" />
<label for="backup_enabled" class="ml-2 block text-sm text-gray-900">Enable Automatic Backups</label>
<input
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>
<label for="cron_expression" 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>
<label
for="cron_expression"
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 class="mt-6 flex gap-2 justify-end">
<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"
>
Cancel
@@ -363,7 +431,7 @@
on:click={handleAddOrUpdateEnv}
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
{editingEnvId ? 'Update' : 'Add'} Environment
{editingEnvId ? "Update" : "Add"} Environment
</button>
</div>
</div>
@@ -374,11 +442,26 @@
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<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 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>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>{$t.connections?.name || "Name"}</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>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@@ -386,24 +469,38 @@
<tr>
<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.username}</td>
<td class="px-6 py-4 whitespace-nowrap">{env.username}</td
>
<td class="px-6 py-4 whitespace-nowrap">
{#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
</span>
{:else}
<span class="text-gray-500">No</span>
{/if}
</td>
<td 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)}>
<td
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"}
</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"}
</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"}
</button>
</td>
@@ -413,25 +510,41 @@
</table>
</div>
{: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>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>
{/if}
</div>
{:else if activeTab === 'logging'}
{:else if activeTab === "logging"}
<!-- Logging Tab -->
<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">
{$t.settings?.logging_description || 'Configure logging and task log levels.'}
{$t.settings?.logging_description ||
"Configure logging and task log levels."}
</p>
<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>
<label 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">
<label
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="INFO">INFO</option>
<option value="WARNING">WARNING</option>
@@ -440,8 +553,16 @@
</select>
</div>
<div>
<label 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">
<label
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="INFO">INFO</option>
<option value="WARNING">WARNING</option>
@@ -450,10 +571,19 @@
</div>
<div class="md:col-span-2">
<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" />
<span class="ml-2 block text-sm text-gray-900">Enable Belief State Logging (Beta)</span>
<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"
/>
<span class="ml-2 block text-sm text-gray-900"
>Enable Belief State Logging (Beta)</span
>
</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>
@@ -467,57 +597,103 @@
</div>
</div>
</div>
{:else if activeTab === 'connections'}
{:else if activeTab === "connections"}
<!-- Connections Tab -->
<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">
{$t.settings?.connections_description || 'Configure database connections for data mapping.'}
{$t.settings?.connections_description ||
"Configure database connections for data mapping."}
</p>
{#if settings.connections && settings.connections.length > 0}
<!-- 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}
<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>
<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
</button>
</div>
{/if}
</div>
{:else if activeTab === 'llm'}
{:else if activeTab === "llm"}
<!-- LLM Tab -->
<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">
{$t.settings?.llm_description || 'Configure LLM providers for dataset documentation.'}
{$t.settings?.llm_description ||
"Configure LLM providers for dataset documentation."}
</p>
<ProviderConfig providers={settings.llm_providers || []} onSave={loadSettings} />
<ProviderConfig
providers={settings.llm_providers || []}
onSave={loadSettings}
/>
</div>
{:else if activeTab === 'storage'}
{:else if activeTab === "storage"}
<!-- Storage Tab -->
<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">
{$t.settings?.storage_description || 'Configure file storage paths and patterns.'}
{$t.settings?.storage_description ||
"Configure file storage paths and patterns."}
</p>
<div class="bg-gray-50 p-6 rounded-lg border border-gray-200">
<div class="grid grid-cols-1 gap-4">
<div>
<label 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" />
<label
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>
<label 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" />
<label
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>
<label 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" />
<label
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>

View File

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

View File

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

View File

@@ -5,7 +5,69 @@ export default {
"./src/**/*.{svelte,js,ts,jsx,tsx}",
],
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: [],
}