Files
ss-tools/frontend/src/lib/components/dashboard/RepositoryDashboardGrid.svelte
busya 87ac90bb8d fix(git): fix 17 missing async/await bugs + UX overhaul
Backend:
- fix 17 missing 'await' in git route handlers causing silent no-ops
  (branches, diff, history, commit, push, pull, merge, promote, sync)
- fix async coroutine passed to run_blocking in git_plugin.py

Frontend:
- add collapsible 'How it works' onboarding (GitHelpPanel)
- add status legend with color-coded repository statuses
- i18n: add 50+ missing keys, replace hardcoded strings
- add Refresh button in modal header
- add PROD deploy confirmation dialog (replaces browser prompt())
- add CommitHistory to workspace tab with timeline nodes
- add post-commit success banner with next-step guidance
- increase success toast duration to 8s
- group local/remote branches in selector (optgroup)
- format last_modified dates timezone-aware
- change PROD badge from red to neutral indigo
- extract shared resolveGitStatusToken to git-utils.ts
- fix 'slug' label regression
- remove dead init_repo_button key

UI/UX audit fixes:
- add descriptions to Create/Init buttons in init panel
- add actionable CTA to server mismatch warning
- improve checkbox text phrasing
2026-07-01 20:47:25 +03:00

350 lines
15 KiB
Svelte

<!-- #region Dashboard.RepositoryGrid [C:3] [TYPE Component] [SEMANTICS dashboard,grid,pagination,selection,repository,git] -->
<!-- @ingroup Dashboard -->
<!-- @BRIEF Dashboard grid with git repository integration — status fetching, bulk git actions, GitManager modal. -->
<!-- @LAYER UI -->
<!-- @RATIONALE Now delegates grid rendering to Dashboard.DataGrid. Keeps ~200 lines of git-specific logic (status fetching via gitService, bulk git operations, GitManager modal). Filter/sort/paginate/select/selection tracking handled by Dashboard.DataGrid. -->
<!-- @RELATION USED_BY -> [frontend/src/routes/git/+page.svelte] -->
<!-- @RELATION DEPENDS_ON -> [$lib/components/git/GitManager] -->
<!-- @RELATION DEPENDS_ON -> [gitService] -->
<!-- @RELATION DEPENDS_ON -> [Dashboard.DataGrid] -->
<!-- @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 type { Column } from "./DashboardDataGrid.svelte";
import { t } from '$lib/i18n/index.svelte.js';
import { Button, ConfirmDialog } from "$lib/ui";
import DashboardDataGrid from "./DashboardDataGrid.svelte";
import GitManager from "$lib/components/git/GitManager.svelte";
import { gitService } from "../../../services/gitService";
import { resolveGitStatusToken } from "../../../services/git-utils";
import { formatDateTime } from "$lib/utils/dateFormat";
import { addToast } from "$lib/toasts.svelte.js";
// [/SECTION]
// [SECTION: PROPS]
let {
dashboards = [] as DashboardMetadata[],
selectedIds = [] as number[],
statusMode = "dashboard" as "dashboard" | "repository",
envId = null as string | null,
repositoriesOnly = false,
} = $props();
// [/SECTION]
// [SECTION: STATE]
let filterText = $state("");
let currentPage = $state(0);
let sortColumn = $state("title");
let sortDirection: "asc" | "desc" = $state("asc");
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);
let showBulkDeleteConfirm = $state(false);
// [/SECTION]
// ── Column definitions ─────────────────────────────────────────
let columns: Column[] = $derived([
{ key: "title", label: $t.dashboard?.title || "Title", sortable: true },
{ key: "last_modified", label: $t.dashboard?.last_modified || "Last Modified", sortable: true, render: (item: DashboardMetadata) => formatDateTime(item.last_modified) },
{ key: "status", label: $t.dashboard?.status || "Status", sortable: true, raw: true, render: (item: DashboardMetadata) => renderStatusBadge(item) },
]);
// ── Actions per row ────────────────────────────────────────────
let rowActions = $derived([
{
label: $t.git?.manage || "Git",
variant: "ghost" as const,
handler: (item: DashboardMetadata) => openGitManagerForDashboard(item),
condition: (item: DashboardMetadata) => statusMode === "repository",
},
]);
// ── Pre-filtered data (repositoriesOnly) ──────────────────────
let preFilteredData = $derived<DashboardMetadata[]>(
repositoriesOnly && statusMode === "repository"
? dashboards.filter(d => getRepositoryStatusToken(d.id) !== "no_repo")
: dashboards,
);
// [SECTION: GIT-SPECIFIC FUNCTIONS]
// #region getRepositoryStatusToken:Function [TYPE Function]
function getRepositoryStatusToken(dashboardId: number): string {
return repositoryStatusByDashboardId[dashboardId] || "loading";
}
// #endregion getRepositoryStatusToken:Function
// #region isRepositoryReady:Function [TYPE Function]
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]
function invalidateRepositoryStatuses(dashboardIds: number[]): void {
if (dashboardIds.length === 0) return;
const nextStatuses = { ...repositoryStatusByDashboardId };
dashboardIds.forEach((did) => { nextStatuses[did] = "loading"; });
repositoryStatusByDashboardId = nextStatuses;
}
// #endregion invalidateRepositoryStatuses:Function
// #region resolveRepositoryStatusToken:Function [TYPE Function]
// @BRIEF Delegates to shared resolveGitStatusToken (git-utils) — single source of truth for grid + modal.
function resolveRepositoryStatusToken(status: any): string {
return resolveGitStatusToken(status);
}
// #endregion resolveRepositoryStatusToken:Function
// #region loadRepositoryStatuses:Function [TYPE Function]
async function loadRepositoryStatuses() {
if (statusMode !== "repository") {
repositoryStatusByDashboardId = {};
return;
}
const requestId = ++repositoryStatusRequestId;
// Load statuses for all pre-filtered data (not just current page — DashboardDataGrid owns pagination)
const allIds = dashboards.map(d => d.id);
const missingIds = allIds.filter(id => {
const tkn = repositoryStatusByDashboardId[id];
return tkn === undefined || tkn === "loading";
});
if (missingIds.length === 0) return;
const loadingStatuses = { ...repositoryStatusByDashboardId };
missingIds.forEach(id => { loadingStatuses[id] = "loading"; });
repositoryStatusByDashboardId = loadingStatuses;
let entries: Array<readonly [number, string]> = [];
try {
const batchResult = await gitService.getStatusesBatch(missingIds);
const statusesByDashboardId = batchResult?.statuses || {};
entries = missingIds.map((id) => {
const s = statusesByDashboardId[id] ?? statusesByDashboardId[String(id)];
return [id, s ? resolveRepositoryStatusToken(s) : "error"] as const;
});
} catch {
entries = missingIds.map((id) => [id, "error"] as const);
}
if (requestId !== repositoryStatusRequestId) return;
repositoryStatusByDashboardId = {
...repositoryStatusByDashboardId,
...Object.fromEntries(entries),
};
}
// #endregion loadRepositoryStatuses:Function
// #region runBulkGitAction:Function [TYPE Function]
async function runBulkGitAction(actionToken: string, action: (_id: number) => Promise<void>): Promise<void> {
if (bulkActionRunning) return;
const readyIds = selectedIds.filter((id) => isRepositoryReady(id));
if (readyIds.length === 0) { addToast($t.git?.no_repositories_selected, "error"); return; }
bulkActionRunning = true;
const concurrency = 3;
const queue = [...readyIds];
let successCount = 0, failedCount = 0;
const worker = async () => {
while (queue.length > 0) {
const id = queue.shift();
if (id === undefined) break;
try { await action(id); successCount++; }
catch { failedCount++; }
}
};
try {
await Promise.all(Array.from({ length: Math.min(concurrency, readyIds.length) }, () => worker()));
invalidateRepositoryStatuses(readyIds);
const al = $t.git?.[`bulk_action_${actionToken}`] || actionToken;
addToast($t.git?.bulk_result.replace("{action}", al).replace("{success}", String(successCount)).replace("{failed}", String(failedCount)),
failedCount > 0 ? "warning" : "success");
} finally { bulkActionRunning = false; }
}
// #endregion runBulkGitAction:Function
// #region handleBulkAction:Function [TYPE Function]
async function handleBulkSync() { await runBulkGitAction("sync", (id) => gitService.sync(id, null, envId)); }
async function handleBulkCommit() {
const msg = prompt($t.git?.commit_message);
if (!msg?.trim()) return;
await runBulkGitAction("commit", (id) => gitService.commit(id, msg.trim(), [], envId));
}
async function handleBulkPull() { await runBulkGitAction("pull", (id) => gitService.pull(id, envId)); }
async function handleBulkPush() { await runBulkGitAction("push", (id) => gitService.push(id, envId)); }
async function onConfirmBulkDelete() {
const idsToDelete = [...selectedIds];
await runBulkGitAction("delete", (id) => gitService.deleteRepository(id, envId));
dashboards = dashboards.filter((d) => !idsToDelete.includes(d.id));
selectedIds = [];
}
// #endregion handleBulkAction:Function
// #region handleManageSelected:Function [TYPE Function]
function handleManageSelected(): void {
if (selectedIds.length !== 1) { addToast($t.git?.select_single_for_manage, "warning"); return; }
const d = dashboards.find(dash => dash.id === selectedIds[0]);
openGitManagerForDashboard(d || null);
}
// #endregion handleManageSelected:Function
// #region handleInitializeRepositories:Function [TYPE Function]
function handleInitializeRepositories(): void {
if (selectedIds.length !== 1) { addToast($t.git?.select_single_for_manage, "warning"); return; }
const d = dashboards.find(dash => dash.id === selectedIds[0]) || null;
openGitManagerForDashboard(d);
}
// #endregion handleInitializeRepositories:Function
// #region resolveDashboardRef:Function [TYPE Function]
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);
return slugMatch ? decodeURIComponent(slugMatch[1]) : null;
}
// #endregion resolveDashboardRef:Function
// #region openGitManagerForDashboard:Function [TYPE Function]
function openGitManagerForDashboard(dashboard: DashboardMetadata | null): void {
if (!dashboard) return;
const ref = resolveDashboardRef(dashboard);
if (!ref) { addToast($t.git?.select_dashboard_with_slug || "Dashboard slug required", "error"); return; }
gitDashboardId = ref;
gitDashboardTitle = dashboard.title || "";
showGitManager = true;
}
// #endregion openGitManagerForDashboard:Function
// #region getSortStatusValue:Function [TYPE Function]
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]
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]
function getStatusBadgeClass(dashboard: DashboardMetadata): string {
if (statusMode !== "repository") {
return dashboard.status === "published" ? "bg-success-light text-success" : "bg-surface-muted text-text";
}
const token = getRepositoryStatusToken(dashboard.id);
if (token === "loading" || token === "no_repo") return "bg-surface-muted text-text-muted";
if (token === "synced") return "bg-success-light text-success";
if (token === "changes") return "bg-warning-light text-warning";
if (token === "behind_remote" || token === "ahead_remote") return "bg-primary-light text-primary";
if (token === "diverged") return "bg-info-light text-info";
return "bg-destructive-light text-destructive";
}
// #endregion getStatusBadgeClass:Function
// #region renderStatusBadge:Function [TYPE Function]
function renderStatusBadge(dashboard: DashboardMetadata): string {
const classes = getStatusBadgeClass(dashboard);
const label = getStatusLabel(dashboard);
return `<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${classes}">${label}</span>`;
}
// #endregion renderStatusBadge:Function
// ── Reactive: load repository statuses ─────────────────────────
$effect(() => {
dashboards;
statusMode;
untrack(() => { void loadRepositoryStatuses(); });
});
</script>
<!-- [SECTION: TEMPLATE] -->
<div class="repository-dashboard-grid">
<DashboardDataGrid
data={preFilteredData}
{columns}
selectable
bind:selectedIds
sortable
bind:sortColumn
bind:sortDirection
filterable
bind:filterText
paginated
bind:page={currentPage}
pageSize={20}
filterPlaceholder={$t.dashboard?.search}
actions={rowActions}
loading={bulkActionRunning}
>
{#snippet bulkActions()}
{#if selectedIds.length > 0}
<div class="flex flex-wrap items-center gap-2 rounded-lg border border-primary-ring bg-primary-light/60 px-3 py-2">
{#if !repositoriesOnly}
<Button size="sm" variant="secondary" onclick={handleManageSelected}
disabled={bulkActionRunning || selectedIds.length !== 1}>
{$t.git?.manage_selected}
</Button>
<Button size="sm" variant="secondary" onclick={handleInitializeRepositories}
disabled={bulkActionRunning || selectedIds.length !== 1}>
{$t.git?.init_repo || "Init Git"}
</Button>
{/if}
<Button size="sm" variant="secondary" onclick={handleBulkSync} disabled={bulkActionRunning}>
{$t.git?.bulk_sync}
</Button>
{#if !repositoriesOnly}
<Button size="sm" variant="secondary" onclick={handleBulkCommit} disabled={bulkActionRunning}>
{$t.git?.bulk_commit}
</Button>
{/if}
<Button size="sm" variant="secondary" onclick={handleBulkPull} disabled={bulkActionRunning}>
{$t.git?.bulk_pull}
</Button>
<Button size="sm" variant="secondary" onclick={handleBulkPush} disabled={bulkActionRunning}>
{$t.git?.bulk_push}
</Button>
{#if repositoriesOnly}
<Button size="sm" variant="destructive" onclick={() => showBulkDeleteConfirm = true} disabled={bulkActionRunning}>
{$t.git?.delete_repo || "Delete"}
</Button>
{/if}
<span class="ml-1 text-xs font-medium text-text-muted">
{$t.git?.selected_count.replace("{count}", String(selectedIds.length))}
</span>
</div>
{/if}
{/snippet}
</DashboardDataGrid>
</div>
{#if showGitManager && gitDashboardId}
<GitManager
dashboardId={gitDashboardId}
envId={envId}
dashboardTitle={gitDashboardTitle}
bind:show={showGitManager}
/>
{/if}
<ConfirmDialog
bind:show={showBulkDeleteConfirm}
title="Delete repositories?"
message={$t.git?.confirm_delete_repo || "Delete selected repositories?"}
variant="destructive"
confirmLabel="Delete"
cancelLabel="Cancel"
onConfirm={onConfirmBulkDelete}
onCancel={() => {}}
/>
<!-- #endregion Dashboard.RepositoryGrid -->