Files
ss-tools/frontend/src/components/RepositoryDashboardGrid.svelte
busya 4fc3356312 refactor(frontend): migrate Svelte stores from .ts to .svelte.ts runes
- Delete legacy .ts stores (auth, activity, assistantChat, datasetReview, environmentContext, health, sidebar, taskDrawer, translationRun)
- Create new .svelte.ts runes-based stores using  reactive primitives
- Migrate i18n: /i18n → /i18n/index.svelte.js
- Migrate toasts: /toasts → /toasts.svelte.js
- Update all component imports across 180+ files: components, pages, routes, lib
- Remove fromStore() wrappers — use store.value directly with Svelte 5 runes
- Update test mocks for new import paths
- Add @DEPRECATED annotation to legacy  alias
2026-06-02 09:54:18 +03:00

699 lines
24 KiB
Svelte

<!-- #region RepositoryDashboardGrid [C:3] [TYPE Component] [SEMANTICS dashboard, grid, pagination, selection, repository] -->
<!-- @BRIEF Component component: components/RepositoryDashboardGrid.svelte -->
<!-- @LAYER UI -->
<!--
@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 { untrack } from "svelte";
import type { DashboardMetadata } from "../types/dashboard";
import { t } from '$lib/i18n/index.svelte.js';
import { Button, Input } from "../lib/ui";
import GitManager from "./git/GitManager.svelte";
import { gitService } from "../services/gitService";
import { addToast } from "$lib/toasts.svelte.js";
// [/SECTION]
// [SECTION: PROPS]
let {
dashboards = [],
selectedIds = [],
statusMode = "dashboard",
envId = null,
repositoriesOnly = false,
} = $props();
// [/SECTION]
// [SECTION: STATE]
let filterText = $state("");
let currentPage = $state(0);
let pageSize = $state(20);
let sortColumn: keyof DashboardMetadata = $state("title");
let sortDirection: "asc" | "desc" = $state("asc");
// [/SECTION]
// [SECTION: UI STATE]
let showGitManager = $state(false);
let gitDashboardId: string | null = $state(null);
let gitDashboardTitle = $state("");
let repositoryStatusByDashboardId = $state<Record<number, string>>({});
let repositoryStatusRequestId = $state(0);
let bulkActionRunning = $state(false);
// [/SECTION]
// [SECTION: DERIVED]
let filteredDashboards = $derived(
dashboards.filter((d) => {
const matchesTitle = d.title.toLowerCase().includes(filterText.toLowerCase());
if (!matchesTitle) return false;
if (!repositoriesOnly || statusMode !== "repository") return true;
return getRepositoryStatusToken(d.id) !== "no_repo";
}),
);
let sortedDashboards = $derived(
[...filteredDashboards].sort((a, b) => {
let aVal =
sortColumn === "status"
? getSortStatusValue(a)
: a[sortColumn];
let bVal =
sortColumn === "status"
? getSortStatusValue(b)
: 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]
// #region handleSort:Function [TYPE 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";
}
}
// #endregion handleSort:Function
// #region handleSelectionChange:Function [TYPE Function]
// @PURPOSE: Handles individual checkbox changes.
// @PRE: dashboard ID and checked status provided.
// @POST: selectedIds array updated.
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;
}
// #endregion handleSelectionChange:Function
// #region handleSelectAll:Function [TYPE Function]
// @PURPOSE: Handles select all checkbox.
// @PRE: checked status provided.
// @POST: selectedIds array updated for all paginated items.
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;
}
// #endregion handleSelectAll:Function
// #region goToPage:Function [TYPE 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;
}
}
// #endregion goToPage:Function
// #region getRepositoryStatusToken:Function [TYPE Function]
/**
* @purpose Returns normalized repository status token for a dashboard.
* @pre Dashboard exists.
* @post Returns one of loading|no_repo|synced|changes|behind_remote|ahead_remote|diverged|error.
*/
function getRepositoryStatusToken(dashboardId: number): string {
return repositoryStatusByDashboardId[dashboardId] || "loading";
}
// #endregion getRepositoryStatusToken:Function
// #region isRepositoryReady:Function [TYPE Function]
// @PURPOSE: Determines whether git actions can run for a dashboard.
function isRepositoryReady(dashboardId: number): boolean {
const token = getRepositoryStatusToken(dashboardId);
return token !== "loading" && token !== "no_repo" && token !== "error";
}
// #endregion isRepositoryReady:Function
// #region invalidateRepositoryStatuses:Function [TYPE Function]
// @PURPOSE: Marks dashboard statuses as loading so they are refetched.
function invalidateRepositoryStatuses(dashboardIds: number[]): void {
if (dashboardIds.length === 0) return;
const nextStatuses = { ...repositoryStatusByDashboardId };
dashboardIds.forEach((dashboardId) => {
nextStatuses[dashboardId] = "loading";
});
repositoryStatusByDashboardId = nextStatuses;
}
// #endregion invalidateRepositoryStatuses:Function
// #region resolveRepositoryStatusToken:Function [TYPE Function]
/**
* @purpose Converts git status payload into a stable UI status token.
*/
function resolveRepositoryStatusToken(status: any): string {
const syncState = String(status?.sync_state || "").toUpperCase();
if (syncState === "DIVERGED") return "diverged";
if (syncState === "BEHIND_REMOTE") return "behind_remote";
if (syncState === "AHEAD_REMOTE") return "ahead_remote";
if (syncState === "CHANGES") return "changes";
if (syncState === "SYNCED") return "synced";
const syncStatus = String(status?.sync_status || "").toUpperCase();
if (syncStatus === "NO_REPO") return "no_repo";
if (syncStatus === "ERROR") return "error";
if (syncStatus === "DIFF") return "changes";
if (syncStatus === "OK") return "synced";
const aheadCount = Number(status?.ahead_count || 0);
const behindCount = Number(status?.behind_count || 0);
if (aheadCount > 0 && behindCount > 0) return "diverged";
if (behindCount > 0) return "behind_remote";
if (aheadCount > 0) return "ahead_remote";
const hasChanges =
Boolean(status?.is_dirty) ||
(status?.untracked_files?.length || 0) > 0 ||
(status?.modified_files?.length || 0) > 0 ||
(status?.staged_files?.length || 0) > 0;
return hasChanges ? "changes" : "synced";
}
// #endregion resolveRepositoryStatusToken:Function
// #region loadRepositoryStatuses:Function [TYPE Function]
/**
* @purpose Hydrates repository status map for dashboards in repository mode.
*/
async function loadRepositoryStatuses() {
if (statusMode !== "repository") {
repositoryStatusByDashboardId = {};
return;
}
const requestId = repositoryStatusRequestId + 1;
repositoryStatusRequestId = requestId;
const visibleDashboards = paginatedDashboards;
const visibleIds = visibleDashboards.map((dashboard) => dashboard.id);
if (visibleDashboards.length === 0) {
repositoryStatusByDashboardId = {};
return;
}
const missingIds = visibleIds.filter((dashboardId) => {
const token = repositoryStatusByDashboardId[dashboardId];
return token === undefined || token === "loading";
});
if (missingIds.length === 0) {
return;
}
const loadingStatuses = { ...repositoryStatusByDashboardId };
missingIds.forEach((dashboardId) => {
loadingStatuses[dashboardId] = "loading";
});
repositoryStatusByDashboardId = loadingStatuses;
let entries: Array<readonly [number, string]> = [];
try {
const batchResult = await gitService.getStatusesBatch(missingIds);
const statusesByDashboardId = batchResult?.statuses || {};
entries = missingIds.map((dashboardId) => {
const status =
statusesByDashboardId[dashboardId] ??
statusesByDashboardId[String(dashboardId)];
if (!status) return [dashboardId, "error"] as const;
return [dashboardId, resolveRepositoryStatusToken(status)] as const;
});
} catch (error) {
entries = missingIds.map((dashboardId) => [dashboardId, "error"] as const);
}
if (requestId !== repositoryStatusRequestId) return;
repositoryStatusByDashboardId = {
...repositoryStatusByDashboardId,
...Object.fromEntries(entries),
};
}
// #endregion loadRepositoryStatuses:Function
// #region runBulkGitAction:Function [TYPE Function]
// @PURPOSE: Executes git action for selected dashboards with limited parallelism.
async function runBulkGitAction(
actionToken: string,
action: (dashboardId: number) => Promise<void>,
): Promise<void> {
if (bulkActionRunning) return;
const selectedDashboardIds = selectedIds.filter((dashboardId) =>
isRepositoryReady(dashboardId),
);
if (selectedDashboardIds.length === 0) {
addToast($t.git?.no_repositories_selected, "error");
return;
}
bulkActionRunning = true;
const concurrency = 3;
const idsQueue = [...selectedDashboardIds];
let successCount = 0;
let failedCount = 0;
const worker = async () => {
while (idsQueue.length > 0) {
const dashboardId = idsQueue.shift();
if (dashboardId === undefined) break;
try {
await action(dashboardId);
successCount += 1;
} catch (_error) {
failedCount += 1;
}
}
};
try {
await Promise.all(
Array.from({ length: Math.min(concurrency, selectedDashboardIds.length) }, () => worker()),
);
invalidateRepositoryStatuses(selectedDashboardIds);
const actionLabel = $t.git?.[`bulk_action_${actionToken}`] || actionToken;
addToast(
$t.git?.bulk_result
.replace("{action}", actionLabel)
.replace("{success}", String(successCount))
.replace("{failed}", String(failedCount)),
failedCount > 0 ? "warning" : "success",
);
} finally {
bulkActionRunning = false;
}
}
// #endregion runBulkGitAction:Function
// #region handleBulkSync:Function [TYPE Function]
async function handleBulkSync(): Promise<void> {
await runBulkGitAction("sync", (dashboardId) => gitService.sync(dashboardId, null, envId));
}
// #endregion handleBulkSync:Function
// #region handleBulkCommit:Function [TYPE Function]
async function handleBulkCommit(): Promise<void> {
const message = prompt($t.git?.commit_message);
if (!message?.trim()) return;
await runBulkGitAction("commit", (dashboardId) =>
gitService.commit(dashboardId, message.trim(), [], envId),
);
}
// #endregion handleBulkCommit:Function
// #region handleBulkPull:Function [TYPE Function]
async function handleBulkPull(): Promise<void> {
await runBulkGitAction("pull", (dashboardId) => gitService.pull(dashboardId, envId));
}
// #endregion handleBulkPull:Function
// #region handleBulkPush:Function [TYPE Function]
async function handleBulkPush(): Promise<void> {
await runBulkGitAction("push", (dashboardId) => gitService.push(dashboardId, envId));
}
// #endregion handleBulkPush:Function
// #region handleBulkDelete:Function [TYPE Function]
// @PURPOSE: Removes selected repositories from storage and binding table.
async function handleBulkDelete(): Promise<void> {
if (!confirm($t.git?.confirm_delete_repo || "Удалить выбранные репозитории?")) return;
const idsToDelete = [...selectedIds];
await runBulkGitAction("delete", (dashboardId) =>
gitService.deleteRepository(dashboardId, envId),
);
dashboards = dashboards.filter((dashboard) => !idsToDelete.includes(dashboard.id));
selectedIds = [];
}
// #endregion handleBulkDelete:Function
// #region handleManageSelected:Function [TYPE Function]
// @PURPOSE: Opens Git manager for exactly one selected dashboard.
async function handleManageSelected(): Promise<void> {
if (selectedIds.length !== 1) {
addToast($t.git?.select_single_for_manage, "warning");
return;
}
const selectedDashboardId = selectedIds[0];
const selectedDashboard = dashboards.find(
(dashboard) => dashboard.id === selectedDashboardId,
);
openGitManagerForDashboard(selectedDashboard || null);
}
// #endregion handleManageSelected:Function
// #region resolveDashboardRef:Function [TYPE Function]
// @PURPOSE: Resolves dashboard slug from payload fields.
// @PRE: Dashboard metadata is provided.
// @POST: Returns slug string or null if unavailable.
function resolveDashboardRef(dashboard: DashboardMetadata): string | null {
const directSlug = String(
dashboard.slug ||
dashboard.dashboard_slug ||
dashboard.url_slug ||
"",
).trim();
if (directSlug) return directSlug;
const dashboardUrl = String(dashboard.url || "").trim();
if (!dashboardUrl) return null;
const slugMatch = dashboardUrl.match(/\/dashboard\/([^/?#]+)/i);
if (!slugMatch?.[1]) return null;
return decodeURIComponent(slugMatch[1]);
}
// #endregion resolveDashboardRef:Function
// #region openGitManagerForDashboard:Function [TYPE Function]
// @PURPOSE: Opens Git manager for provided dashboard metadata.
function openGitManagerForDashboard(dashboard: DashboardMetadata | null): void {
if (!dashboard) return;
const dashboardRef = resolveDashboardRef(dashboard);
if (!dashboardRef) {
addToast($t.git?.select_dashboard_with_slug || "Dashboard slug is required to open GitManager", "error");
return;
}
gitDashboardId = dashboardRef;
gitDashboardTitle = dashboard.title || "";
showGitManager = true;
}
// #endregion openGitManagerForDashboard:Function
// #region handleInitializeRepositories:Function [TYPE Function]
// @PURPOSE: Opens Git manager from bulk actions to initialize selected repository.
async function handleInitializeRepositories(): Promise<void> {
if (selectedIds.length !== 1) {
addToast($t.git?.select_single_for_manage, "warning");
return;
}
const selectedDashboardId = selectedIds[0];
const selectedDashboard = dashboards.find((dashboard) => dashboard.id === selectedDashboardId) || null;
openGitManagerForDashboard(selectedDashboard);
}
// #endregion handleInitializeRepositories:Function
// #region getSortStatusValue:Function [TYPE Function]
/**
* @purpose Returns sort value for status column based on mode.
*/
function getSortStatusValue(dashboard: DashboardMetadata): string {
if (statusMode === "repository") {
return getRepositoryStatusToken(dashboard.id);
}
return String(dashboard.status || "").toLowerCase();
}
// #endregion getSortStatusValue:Function
// #region getStatusLabel:Function [TYPE Function]
/**
* @purpose Returns localized label for status column.
*/
function getStatusLabel(dashboard: DashboardMetadata): string {
if (statusMode !== "repository") {
return String(dashboard.status || "");
}
const token = getRepositoryStatusToken(dashboard.id);
return $t.git?.repo_status?.[token] || token;
}
// #endregion getStatusLabel:Function
// #region getStatusBadgeClass:Function [TYPE Function]
/**
* @purpose Returns badge style for status column.
*/
function getStatusBadgeClass(dashboard: DashboardMetadata): string {
if (statusMode !== "repository") {
return dashboard.status === "published"
? "bg-green-100 text-green-800"
: "bg-gray-100 text-gray-800";
}
const token = getRepositoryStatusToken(dashboard.id);
if (token === "loading") return "bg-slate-100 text-slate-500";
if (token === "no_repo") return "bg-gray-100 text-gray-800";
if (token === "synced") return "bg-green-100 text-green-800";
if (token === "changes") return "bg-amber-100 text-amber-800";
if (token === "behind_remote") return "bg-blue-100 text-blue-800";
if (token === "ahead_remote") return "bg-indigo-100 text-indigo-800";
if (token === "diverged") return "bg-purple-100 text-purple-800";
return "bg-rose-100 text-rose-800";
}
// #endregion getStatusBadgeClass:Function
$effect(() => {
dashboards;
statusMode;
currentPage;
pageSize;
filterText;
sortColumn;
sortDirection;
untrack(() => {
void loadRepositoryStatuses();
});
});
</script>
<!-- [SECTION: TEMPLATE] -->
<div class="dashboard-grid">
<!-- Filter Input -->
<div class="mb-6">
<Input bind:value={filterText} placeholder={$t.dashboard.search} />
</div>
{#if selectedIds.length > 0}
<div class="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-blue-100 bg-primary-light/60 px-3 py-2">
{#if !repositoriesOnly}
<Button
size="sm"
variant="secondary"
onclick={handleManageSelected}
disabled={bulkActionRunning || selectedIds.length !== 1}
class="border-blue-200 bg-white text-blue-700 hover:bg-blue-50 disabled:opacity-40"
>
{$t.git?.manage_selected}
</Button>
<Button
size="sm"
variant="secondary"
onclick={handleInitializeRepositories}
disabled={bulkActionRunning || selectedIds.length !== 1}
class="border-slate-200 bg-white text-slate-700 hover:bg-slate-50 disabled:opacity-40"
>
{$t.git?.init_repo || "Инициализировать Git-репозиторий"}
</Button>
{/if}
<Button size="sm" variant="secondary" onclick={handleBulkSync} disabled={bulkActionRunning} class="border-blue-200 bg-white text-blue-700 hover:bg-blue-50">
{$t.git?.bulk_sync}
</Button>
{#if !repositoriesOnly}
<Button size="sm" variant="secondary" onclick={handleBulkCommit} disabled={bulkActionRunning} class="border-amber-200 bg-white text-amber-700 hover:bg-amber-50">
{$t.git?.bulk_commit}
</Button>
{/if}
<Button size="sm" variant="secondary" onclick={handleBulkPull} disabled={bulkActionRunning} class="border-cyan-200 bg-white text-cyan-700 hover:bg-cyan-50">
{$t.git?.bulk_pull}
</Button>
<Button size="sm" variant="secondary" onclick={handleBulkPush} disabled={bulkActionRunning} class="border-indigo-200 bg-white text-indigo-700 hover:bg-indigo-50">
{$t.git?.bulk_push}
</Button>
{#if repositoriesOnly}
<Button size="sm" variant="secondary" onclick={handleBulkDelete} disabled={bulkActionRunning} class="border-rose-200 bg-white text-rose-700 hover:bg-rose-50">
{$t.git?.delete_repo || "Удалить репозиторий"}
</Button>
{/if}
<span class="ml-1 text-xs font-medium text-slate-600">
{$t.git?.selected_count.replace(
"{count}",
String(selectedIds.length),
)}
</span>
</div>
{/if}
<!-- 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}
onchange={(e) =>
handleSelectAll((e.target as HTMLInputElement).checked)}
class="h-4 w-4 text-primary border-gray-300 rounded focus-visible:ring-primary-ring"
/>
</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"
onclick={() => 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"
onclick={() => 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"
onclick={() => handleSort("status")}
>
{$t.dashboard.status}
{sortColumn === "status"
? sortDirection === "asc"
? "↑"
: "↓"
: ""}
</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)}
onchange={(e) =>
handleSelectionChange(
dashboard.id,
(e.target as HTMLInputElement).checked,
)}
class="h-4 w-4 text-primary border-gray-300 rounded focus-visible:ring-primary-ring"
/>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
<button
type="button"
class="text-left text-blue-700 hover:text-blue-900 hover:underline"
onclick={() => openGitManagerForDashboard(dashboard)}
>
{dashboard.title}
</button>
</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 {getStatusBadgeClass(dashboard)}"
>
{getStatusLabel(dashboard)}
</span>
</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}
onclick={() => goToPage(currentPage - 1)}
>
{$t.dashboard.previous}
</Button>
<Button
variant="secondary"
size="sm"
disabled={currentPage >= totalPages - 1}
onclick={() => goToPage(currentPage + 1)}
>
{$t.dashboard.next}
</Button>
</div>
</div>
</div>
{#if showGitManager && gitDashboardId}
<GitManager
dashboardId={gitDashboardId}
envId={envId}
dashboardTitle={gitDashboardTitle}
bind:show={showGitManager}
/>
{/if}
<!-- [/SECTION] -->
<!-- #endregion RepositoryDashboardGrid -->