css refactor
This commit is contained in:
@@ -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] -->
|
||||
@@ -15,7 +15,10 @@
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
// [/SECTION]
|
||||
|
||||
export let schema;
|
||||
let {
|
||||
schema,
|
||||
} = $props();
|
||||
|
||||
let formData = {};
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
@@ -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] -->
|
||||
@@ -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] -->
|
||||
|
||||
@@ -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] -->
|
||||
|
||||
@@ -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">​</span>
|
||||
<span
|
||||
class="hidden sm:inline-block sm:align-middle sm:h-screen"
|
||||
aria-hidden="true">​</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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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] -->
|
||||
@@ -19,8 +19,11 @@
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: PROPS]
|
||||
export let dashboardId;
|
||||
export let currentBranch = 'main';
|
||||
let {
|
||||
dashboardId,
|
||||
currentBranch = 'main',
|
||||
} = $props();
|
||||
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: STATE]
|
||||
|
||||
@@ -16,7 +16,10 @@
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: PROPS]
|
||||
export let dashboardId;
|
||||
let {
|
||||
dashboardId,
|
||||
} = $props();
|
||||
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: STATE]
|
||||
|
||||
@@ -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] -->
|
||||
@@ -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] -->
|
||||
@@ -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}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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] -->
|
||||
@@ -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] -->
|
||||
@@ -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>
|
||||
|
||||
@@ -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("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;"
|
||||
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("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;"
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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] -->
|
||||
|
||||
|
||||
@@ -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] -->
|
||||
|
||||
@@ -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] -->
|
||||
|
||||
@@ -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] -->
|
||||
|
||||
|
||||
@@ -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] -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
8
frontend/src/lib/utils.js
Normal file
8
frontend/src/lib/utils.js
Normal 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(" ");
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -230,7 +230,5 @@
|
||||
<!-- [/SECTION: TEMPLATE] -->
|
||||
</ProtectedRoute>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
|
||||
<!-- [/DEF:AdminRolesPage:Component] -->
|
||||
@@ -346,7 +346,5 @@
|
||||
<!-- [/SECTION: TEMPLATE] -->
|
||||
</ProtectedRoute>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
|
||||
<!-- [/DEF:AdminSettingsPage:Component] -->
|
||||
@@ -278,7 +278,5 @@
|
||||
<!-- [/SECTION: TEMPLATE] -->
|
||||
</ProtectedRoute>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
|
||||
<!-- [/DEF:AdminUsersPage:Component] -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -154,8 +154,5 @@
|
||||
</div>
|
||||
<!-- [/SECTION: TEMPLATE] -->
|
||||
|
||||
<style>
|
||||
/* No additional styles needed, using Tailwind */
|
||||
</style>
|
||||
|
||||
<!-- [/DEF:LoginPage:Component] -->
|
||||
@@ -384,8 +384,5 @@
|
||||
|
||||
<!-- [/SECTION] -->
|
||||
|
||||
<style>
|
||||
/* Page specific styles */
|
||||
</style>
|
||||
|
||||
<!-- [/DEF:MigrationDashboard:Component] -->
|
||||
|
||||
@@ -173,8 +173,5 @@
|
||||
</div>
|
||||
<!-- [/SECTION] -->
|
||||
|
||||
<style>
|
||||
/* Page specific styles */
|
||||
</style>
|
||||
|
||||
<!-- [/DEF:MappingManagement:Component] -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -175,8 +175,5 @@
|
||||
</div>
|
||||
<!-- [/SECTION: TEMPLATE] -->
|
||||
|
||||
<style>
|
||||
/* Styles are handled by Tailwind */
|
||||
</style>
|
||||
|
||||
<!-- [/DEF:GitSettingsPage:Component] -->
|
||||
@@ -218,8 +218,5 @@
|
||||
</div>
|
||||
<!-- [/SECTION: TEMPLATE] -->
|
||||
|
||||
<style>
|
||||
/* ... */
|
||||
</style>
|
||||
|
||||
<!-- [/DEF:StoragePage:Component] -->
|
||||
@@ -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: [],
|
||||
}
|
||||
Reference in New Issue
Block a user