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]
|
||||
@@ -47,71 +46,85 @@
|
||||
*/
|
||||
async function handleValidate(dashboard: DashboardMetadata) {
|
||||
if (validatingIds.has(dashboard.id)) return;
|
||||
|
||||
|
||||
validatingIds.add(dashboard.id);
|
||||
validatingIds = validatingIds; // Trigger reactivity
|
||||
|
||||
try {
|
||||
// TODO: Get provider_id from settings or prompt user
|
||||
// For now, we assume a default provider or let the backend handle it if possible,
|
||||
// but the plugin requires provider_id.
|
||||
// In a real implementation, we might open a modal to select provider if not configured globally.
|
||||
// Or we pick the first active one.
|
||||
|
||||
// Fetch active provider first
|
||||
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');
|
||||
return;
|
||||
}
|
||||
|
||||
await api.postApi('/tasks', {
|
||||
plugin_id: 'llm_dashboard_validation',
|
||||
params: {
|
||||
dashboard_id: dashboard.id.toString(),
|
||||
environment_id: environmentId,
|
||||
provider_id: activeProvider.id
|
||||
}
|
||||
});
|
||||
|
||||
toast('Validation task started', 'success');
|
||||
try {
|
||||
// TODO: Get provider_id from settings or prompt user
|
||||
// For now, we assume a default provider or let the backend handle it if possible,
|
||||
// but the plugin requires provider_id.
|
||||
// In a real implementation, we might open a modal to select provider if not configured globally.
|
||||
// Or we pick the first active one.
|
||||
|
||||
// Fetch active provider first
|
||||
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",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await api.postApi("/tasks", {
|
||||
plugin_id: "llm_dashboard_validation",
|
||||
params: {
|
||||
dashboard_id: dashboard.id.toString(),
|
||||
environment_id: environmentId,
|
||||
provider_id: activeProvider.id,
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
validatingIds.delete(dashboard.id);
|
||||
validatingIds = validatingIds;
|
||||
}
|
||||
}
|
||||
// [/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 aVal = a[sortColumn];
|
||||
let bVal = b[sortColumn];
|
||||
if (sortColumn === "id") {
|
||||
aVal = Number(aVal);
|
||||
bVal = Number(bVal);
|
||||
}
|
||||
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
|
||||
let sortedDashboards = $derived(
|
||||
[...filteredDashboards].sort((a, b) => {
|
||||
let aVal = a[sortColumn];
|
||||
let bVal = b[sortColumn];
|
||||
if (sortColumn === "id") {
|
||||
aVal = Number(aVal);
|
||||
bVal = Number(bVal);
|
||||
}
|
||||
if (aVal < bVal) return sortDirection === "asc" ? -1 : 1;
|
||||
if (aVal > bVal) return sortDirection === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
}),
|
||||
);
|
||||
|
||||
$: 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] -->
|
||||
<!-- [/DEF:DashboardGrid:Component] -->
|
||||
|
||||
@@ -1,92 +1,95 @@
|
||||
<!-- [DEF:DynamicForm:Component] -->
|
||||
<!--
|
||||
@SEMANTICS: form, schema, dynamic, json-schema
|
||||
@PURPOSE: Generates a form dynamically based on a JSON schema.
|
||||
@LAYER: UI
|
||||
@RELATION: DEPENDS_ON -> svelte:createEventDispatcher
|
||||
|
||||
@PROPS:
|
||||
- schema: Object - JSON schema for the form.
|
||||
@EVENTS:
|
||||
- submit: Object - Dispatched when the form is submitted, containing the form data.
|
||||
-->
|
||||
<script>
|
||||
// [SECTION: IMPORTS]
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
// [/SECTION]
|
||||
|
||||
export let schema;
|
||||
let formData = {};
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
// [DEF:handleSubmit:Function]
|
||||
/**
|
||||
* @purpose Dispatches the submit event with the form data.
|
||||
* @pre formData contains user input.
|
||||
* @post 'submit' event is dispatched with formData.
|
||||
*/
|
||||
function handleSubmit() {
|
||||
console.log("[DynamicForm][Action] Submitting form data.", { formData });
|
||||
dispatch('submit', formData);
|
||||
}
|
||||
// [/DEF:handleSubmit:Function]
|
||||
|
||||
// [DEF:initializeForm:Function]
|
||||
/**
|
||||
* @purpose Initialize form data with default values from the schema.
|
||||
* @pre schema is provided and contains properties.
|
||||
* @post formData is initialized with default values or empty strings.
|
||||
*/
|
||||
function initializeForm() {
|
||||
if (schema && schema.properties) {
|
||||
for (const key in schema.properties) {
|
||||
formData[key] = schema.properties[key].default || '';
|
||||
}
|
||||
}
|
||||
}
|
||||
// [/DEF:initializeForm:Function]
|
||||
|
||||
initializeForm();
|
||||
</script>
|
||||
|
||||
<!-- [SECTION: TEMPLATE] -->
|
||||
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
|
||||
{#if schema && schema.properties}
|
||||
{#each Object.entries(schema.properties) as [key, prop]}
|
||||
<div class="flex flex-col">
|
||||
<label for={key} class="mb-1 font-semibold text-gray-700">{prop.title || key}</label>
|
||||
{#if prop.type === 'string'}
|
||||
<input
|
||||
type="text"
|
||||
id={key}
|
||||
bind:value={formData[key]}
|
||||
placeholder={prop.description || ''}
|
||||
class="p-2 border rounded-md"
|
||||
/>
|
||||
{:else if prop.type === 'number' || prop.type === 'integer'}
|
||||
<input
|
||||
type="number"
|
||||
id={key}
|
||||
bind:value={formData[key]}
|
||||
placeholder={prop.description || ''}
|
||||
class="p-2 border rounded-md"
|
||||
/>
|
||||
{:else if prop.type === 'boolean'}
|
||||
<input
|
||||
type="checkbox"
|
||||
id={key}
|
||||
bind:checked={formData[key]}
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<button type="submit" class="w-full bg-green-500 text-white p-2 rounded-md hover:bg-green-600">
|
||||
Run Task
|
||||
</button>
|
||||
{/if}
|
||||
</form>
|
||||
<!-- [/SECTION] -->
|
||||
|
||||
<!-- [/DEF:DynamicForm:Component] -->
|
||||
<!-- [DEF:DynamicForm:Component] -->
|
||||
<!--
|
||||
@SEMANTICS: form, schema, dynamic, json-schema
|
||||
@PURPOSE: Generates a form dynamically based on a JSON schema.
|
||||
@LAYER: UI
|
||||
@RELATION: DEPENDS_ON -> svelte:createEventDispatcher
|
||||
|
||||
@PROPS:
|
||||
- schema: Object - JSON schema for the form.
|
||||
@EVENTS:
|
||||
- submit: Object - Dispatched when the form is submitted, containing the form data.
|
||||
-->
|
||||
<script>
|
||||
// [SECTION: IMPORTS]
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
// [/SECTION]
|
||||
|
||||
let {
|
||||
schema,
|
||||
} = $props();
|
||||
|
||||
let formData = {};
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
// [DEF:handleSubmit:Function]
|
||||
/**
|
||||
* @purpose Dispatches the submit event with the form data.
|
||||
* @pre formData contains user input.
|
||||
* @post 'submit' event is dispatched with formData.
|
||||
*/
|
||||
function handleSubmit() {
|
||||
console.log("[DynamicForm][Action] Submitting form data.", { formData });
|
||||
dispatch('submit', formData);
|
||||
}
|
||||
// [/DEF:handleSubmit:Function]
|
||||
|
||||
// [DEF:initializeForm:Function]
|
||||
/**
|
||||
* @purpose Initialize form data with default values from the schema.
|
||||
* @pre schema is provided and contains properties.
|
||||
* @post formData is initialized with default values or empty strings.
|
||||
*/
|
||||
function initializeForm() {
|
||||
if (schema && schema.properties) {
|
||||
for (const key in schema.properties) {
|
||||
formData[key] = schema.properties[key].default || '';
|
||||
}
|
||||
}
|
||||
}
|
||||
// [/DEF:initializeForm:Function]
|
||||
|
||||
initializeForm();
|
||||
</script>
|
||||
|
||||
<!-- [SECTION: TEMPLATE] -->
|
||||
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
|
||||
{#if schema && schema.properties}
|
||||
{#each Object.entries(schema.properties) as [key, prop]}
|
||||
<div class="flex flex-col">
|
||||
<label for={key} class="mb-1 font-semibold text-gray-700">{prop.title || key}</label>
|
||||
{#if prop.type === 'string'}
|
||||
<input
|
||||
type="text"
|
||||
id={key}
|
||||
bind:value={formData[key]}
|
||||
placeholder={prop.description || ''}
|
||||
class="p-2 border rounded-md"
|
||||
/>
|
||||
{:else if prop.type === 'number' || prop.type === 'integer'}
|
||||
<input
|
||||
type="number"
|
||||
id={key}
|
||||
bind:value={formData[key]}
|
||||
placeholder={prop.description || ''}
|
||||
class="p-2 border rounded-md"
|
||||
/>
|
||||
{:else if prop.type === 'boolean'}
|
||||
<input
|
||||
type="checkbox"
|
||||
id={key}
|
||||
bind:checked={formData[key]}
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<button type="submit" class="w-full bg-green-500 text-white p-2 rounded-md hover:bg-green-600">
|
||||
Run Task
|
||||
</button>
|
||||
{/if}
|
||||
</form>
|
||||
<!-- [/SECTION] -->
|
||||
|
||||
<!-- [/DEF:DynamicForm:Component] -->
|
||||
|
||||
@@ -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,90 +7,134 @@
|
||||
@RELATION: EMITS -> resume, cancel
|
||||
-->
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let show = false;
|
||||
export let databases = []; // List of database names requiring passwords
|
||||
export let errorMessage = "";
|
||||
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
let { show = false, databases = [], errorMessage = "" } = $props();
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
|
||||
let passwords = {};
|
||||
let submitting = false;
|
||||
|
||||
|
||||
// [DEF:handleSubmit:Function]
|
||||
// @PURPOSE: Validates and dispatches the passwords to resume the task.
|
||||
// @PRE: All database passwords must be entered.
|
||||
// @POST: 'resume' event is dispatched with passwords.
|
||||
function handleSubmit() {
|
||||
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]
|
||||
|
||||
|
||||
// [DEF:handleCancel:Function]
|
||||
// @PURPOSE: Cancels the password prompt.
|
||||
// @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) {
|
||||
passwords = {};
|
||||
submitting = false;
|
||||
}
|
||||
$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"
|
||||
@@ -130,4 +176,4 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- [/DEF:PasswordPrompt:Component] -->
|
||||
<!-- [/DEF:PasswordPrompt:Component] -->
|
||||
|
||||
@@ -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,60 +23,47 @@
|
||||
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) {
|
||||
const lastLog = realTimeLogs[realTimeLogs.length - 1];
|
||||
const exists = logs.some(
|
||||
(l) =>
|
||||
l.timestamp === lastLog.timestamp &&
|
||||
l.message === lastLog.message,
|
||||
);
|
||||
if (!exists) {
|
||||
logs = [...logs, lastLog];
|
||||
console.log(
|
||||
`[TaskLogViewer][Action] Appended real-time log, total=${logs.length}`,
|
||||
$effect(() => {
|
||||
if (realTimeLogs && realTimeLogs.length > 0) {
|
||||
const lastLog = realTimeLogs[realTimeLogs.length - 1];
|
||||
const exists = logs.some(
|
||||
(l) =>
|
||||
l.timestamp === lastLog.timestamp &&
|
||||
l.message === lastLog.message,
|
||||
);
|
||||
if (!exists) {
|
||||
logs = [...logs, lastLog];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// [/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,34 +72,31 @@
|
||||
|
||||
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) {
|
||||
if (interval) clearInterval(interval);
|
||||
logs = [];
|
||||
loading = true;
|
||||
error = "";
|
||||
fetchLogs();
|
||||
$effect(() => {
|
||||
if (shouldShow && taskId) {
|
||||
if (interval) clearInterval(interval);
|
||||
logs = [];
|
||||
loading = true;
|
||||
error = "";
|
||||
fetchLogs();
|
||||
|
||||
if (
|
||||
taskStatus === "RUNNING" ||
|
||||
taskStatus === "AWAITING_INPUT" ||
|
||||
taskStatus === "AWAITING_MAPPING"
|
||||
) {
|
||||
interval = setInterval(fetchLogs, 5000);
|
||||
if (
|
||||
taskStatus === "RUNNING" ||
|
||||
taskStatus === "AWAITING_INPUT" ||
|
||||
taskStatus === "AWAITING_MAPPING"
|
||||
) {
|
||||
interval = setInterval(fetchLogs, 5000);
|
||||
}
|
||||
} else {
|
||||
if (interval) clearInterval(interval);
|
||||
}
|
||||
} 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,39 +108,50 @@
|
||||
*/
|
||||
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">
|
||||
<!-- Left: Message and Files -->
|
||||
<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,33 +212,52 @@
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 mt-6 pt-4 border-t">
|
||||
<button
|
||||
on:click={() => show = false}
|
||||
<button
|
||||
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] -->
|
||||
<!-- [/DEF:CommitModal:Component] -->
|
||||
|
||||
@@ -10,20 +10,23 @@
|
||||
|
||||
<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]
|
||||
const dispatch = createEventDispatcher();
|
||||
/** @type {Object.<string, 'mine' | 'theirs' | 'manual'>} */
|
||||
let resolutions = {};
|
||||
let resolutions = {};
|
||||
// [/SECTION]
|
||||
|
||||
// [DEF:resolve:Function]
|
||||
@@ -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="p-4 bg-white flex-1 overflow-auto">
|
||||
<pre class="text-xs font-mono whitespace-pre">{conflict.mine}</pre>
|
||||
<div
|
||||
class="bg-blue-50 px-4 py-1 text-[10px] font-bold text-blue-600 uppercase border-b"
|
||||
>
|
||||
Your Changes (Mine)
|
||||
</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')}
|
||||
<div class="p-4 bg-white flex-1 overflow-auto">
|
||||
<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")}
|
||||
>
|
||||
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="p-4 bg-white flex-1 overflow-auto">
|
||||
<pre class="text-xs font-mono whitespace-pre">{conflict.theirs}</pre>
|
||||
<div
|
||||
class="bg-green-50 px-4 py-1 text-[10px] font-bold text-green-600 uppercase border-b"
|
||||
>
|
||||
Remote Changes (Theirs)
|
||||
</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')}
|
||||
<div class="p-4 bg-white flex-1 overflow-auto">
|
||||
<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")}
|
||||
>
|
||||
Keep Theirs
|
||||
</button>
|
||||
@@ -115,13 +160,13 @@
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-4 border-t">
|
||||
<button
|
||||
on:click={() => show = false}
|
||||
<button
|
||||
on:click={() => (show = false)}
|
||||
class="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
on:click={handleSave}
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors shadow-sm"
|
||||
>
|
||||
@@ -133,10 +178,4 @@
|
||||
{/if}
|
||||
<!-- [/SECTION] -->
|
||||
|
||||
<style>
|
||||
pre {
|
||||
tab-size: 4;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- [/DEF:ConflictResolver:Component] -->
|
||||
<!-- [/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}
|
||||
<button
|
||||
on:click={() => (show = false)}
|
||||
class="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300"
|
||||
>
|
||||
Close
|
||||
@@ -105,33 +116,53 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Select Target Environment</label>
|
||||
<select
|
||||
<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}
|
||||
<button
|
||||
on:click={() => (show = false)}
|
||||
class="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
on:click={handleDeploy}
|
||||
disabled={deploying || !selectedEnv}
|
||||
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}
|
||||
@@ -145,4 +176,4 @@
|
||||
{/if}
|
||||
<!-- [/SECTION] -->
|
||||
|
||||
<!-- [/DEF:DeploymentModal:Component] -->
|
||||
<!-- [/DEF:DeploymentModal:Component] -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user