377 lines
12 KiB
Svelte
377 lines
12 KiB
Svelte
<!-- [DEF:DashboardGrid:Component] -->
|
|
<!--
|
|
@TIER: STANDARD
|
|
@SEMANTICS: dashboard, grid, selection, pagination
|
|
@PURPOSE: Displays a grid of dashboards with selection and pagination.
|
|
@LAYER: Component
|
|
@RELATION: USED_BY -> frontend/src/routes/migration/+page.svelte
|
|
|
|
@INVARIANT: Selected IDs must be a subset of available dashboards.
|
|
-->
|
|
|
|
<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";
|
|
// [/SECTION]
|
|
|
|
// [SECTION: PROPS]
|
|
let { dashboards = [], selectedIds = [], environmentId = "ss1" } = $props();
|
|
|
|
// [/SECTION]
|
|
|
|
// [SECTION: STATE]
|
|
let filterText = "";
|
|
let currentPage = 0;
|
|
let pageSize = 20;
|
|
let sortColumn: keyof DashboardMetadata = "title";
|
|
let sortDirection: "asc" | "desc" = "asc";
|
|
// [/SECTION]
|
|
|
|
// [SECTION: UI STATE]
|
|
let showGitManager = false;
|
|
let gitDashboardId: number | null = null;
|
|
let gitDashboardTitle = "";
|
|
let validatingIds: Set<number> = new Set();
|
|
// [/SECTION]
|
|
|
|
// [DEF:handleValidate:Function]
|
|
/**
|
|
* @purpose Triggers dashboard validation task.
|
|
*/
|
|
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");
|
|
} catch (e: any) {
|
|
toast(e.message || "Validation failed to start", "error");
|
|
} finally {
|
|
validatingIds.delete(dashboard.id);
|
|
validatingIds = validatingIds;
|
|
}
|
|
}
|
|
// [/DEF:handleValidate:Function]
|
|
|
|
// [SECTION: DERIVED]
|
|
let filteredDashboards = $derived(
|
|
dashboards.filter((d) =>
|
|
d.title.toLowerCase().includes(filterText.toLowerCase()),
|
|
),
|
|
);
|
|
|
|
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;
|
|
}),
|
|
);
|
|
|
|
let paginatedDashboards = $derived(
|
|
sortedDashboards.slice(
|
|
currentPage * pageSize,
|
|
(currentPage + 1) * pageSize,
|
|
),
|
|
);
|
|
|
|
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]
|
|
const dispatch = createEventDispatcher<{ selectionChanged: number[] }>();
|
|
// [/SECTION]
|
|
|
|
// [DEF:handleSort:Function]
|
|
// @PURPOSE: Toggles sort direction or changes sort column.
|
|
// @PRE: column name is provided.
|
|
// @POST: sortColumn and sortDirection state updated.
|
|
function handleSort(column: keyof DashboardMetadata) {
|
|
if (sortColumn === column) {
|
|
sortDirection = sortDirection === "asc" ? "desc" : "asc";
|
|
} else {
|
|
sortColumn = column;
|
|
sortDirection = "asc";
|
|
}
|
|
}
|
|
// [/DEF:handleSort:Function]
|
|
|
|
// [DEF:handleSelectionChange:Function]
|
|
// @PURPOSE: Handles individual checkbox changes.
|
|
// @PRE: dashboard ID and checked status provided.
|
|
// @POST: selectedIds array updated and selectionChanged event dispatched.
|
|
function handleSelectionChange(id: number, checked: boolean) {
|
|
let newSelected = [...selectedIds];
|
|
if (checked) {
|
|
if (!newSelected.includes(id)) newSelected.push(id);
|
|
} else {
|
|
newSelected = newSelected.filter((sid) => sid !== id);
|
|
}
|
|
selectedIds = newSelected;
|
|
dispatch("selectionChanged", newSelected);
|
|
}
|
|
// [/DEF:handleSelectionChange:Function]
|
|
|
|
// [DEF:handleSelectAll:Function]
|
|
// @PURPOSE: Handles select all checkbox.
|
|
// @PRE: checked status provided.
|
|
// @POST: selectedIds array updated for all paginated items and event dispatched.
|
|
function handleSelectAll(checked: boolean) {
|
|
let newSelected = [...selectedIds];
|
|
if (checked) {
|
|
paginatedDashboards.forEach((d) => {
|
|
if (!newSelected.includes(d.id)) newSelected.push(d.id);
|
|
});
|
|
} else {
|
|
paginatedDashboards.forEach((d) => {
|
|
newSelected = newSelected.filter((sid) => sid !== d.id);
|
|
});
|
|
}
|
|
selectedIds = newSelected;
|
|
dispatch("selectionChanged", newSelected);
|
|
}
|
|
// [/DEF:handleSelectAll:Function]
|
|
|
|
// [DEF:goToPage:Function]
|
|
// @PURPOSE: Changes current page.
|
|
// @PRE: page index is provided.
|
|
// @POST: currentPage state updated if within valid range.
|
|
function goToPage(page: number) {
|
|
if (page >= 0 && page < totalPages) {
|
|
currentPage = page;
|
|
}
|
|
}
|
|
// [/DEF:goToPage:Function]
|
|
|
|
// [DEF:openGit:Function]
|
|
/**
|
|
* @purpose Opens the Git management modal for a dashboard.
|
|
*/
|
|
function openGit(dashboard: DashboardMetadata) {
|
|
gitDashboardId = dashboard.id;
|
|
gitDashboardTitle = dashboard.title;
|
|
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} />
|
|
</div>
|
|
|
|
<!-- Grid/Table -->
|
|
<div class="overflow-x-auto rounded-lg border border-gray-200">
|
|
<table class="min-w-full divide-y divide-gray-200">
|
|
<thead class="bg-gray-50">
|
|
<tr>
|
|
<th class="px-6 py-3 text-left">
|
|
<input
|
|
type="checkbox"
|
|
checked={allSelected}
|
|
indeterminate={someSelected && !allSelected}
|
|
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>
|
|
<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>
|
|
<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">
|
|
{#each paginatedDashboards as dashboard (dashboard.id)}
|
|
<tr class="hover:bg-gray-50 transition-colors">
|
|
<td class="px-6 py-4 whitespace-nowrap">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedIds.includes(dashboard.id)}
|
|
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 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'}"
|
|
>
|
|
{dashboard.status}
|
|
</span>
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
on:click={() => handleValidate(dashboard)}
|
|
disabled={validatingIds.has(dashboard.id)}
|
|
class="text-purple-600 hover:text-purple-900"
|
|
>
|
|
{validatingIds.has(dashboard.id) ? "Validating..." : "Validate"}
|
|
</Button>
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
on:click={() => openGit(dashboard)}
|
|
class="text-blue-600 hover:text-blue-900"
|
|
>
|
|
{$t.git.manage}
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Pagination Controls -->
|
|
<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())}
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
disabled={currentPage === 0}
|
|
on:click={() => goToPage(currentPage - 1)}
|
|
>
|
|
{$t.dashboard.previous}
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
disabled={currentPage >= totalPages - 1}
|
|
on:click={() => goToPage(currentPage + 1)}
|
|
>
|
|
{$t.dashboard.next}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{#if showGitManager && gitDashboardId}
|
|
<GitManager
|
|
dashboardId={gitDashboardId}
|
|
dashboardTitle={gitDashboardTitle}
|
|
bind:show={showGitManager}
|
|
/>
|
|
{/if}
|
|
|
|
<!-- [/SECTION] -->
|
|
|
|
<!-- [/DEF:DashboardGrid:Component] -->
|