Fix git/storage workflows: repos-only page, default dev branch, robust pull/push, and storage path resolution

This commit is contained in:
2026-03-04 19:18:58 +03:00
parent 4fec2e02ad
commit 7ff0dfa8c6
10 changed files with 658 additions and 63 deletions

View File

@@ -21,7 +21,13 @@
// [/SECTION]
// [SECTION: PROPS]
let { dashboards = [], selectedIds = [], statusMode = "dashboard" } = $props();
let {
dashboards = [],
selectedIds = [],
statusMode = "dashboard",
envId = null,
repositoriesOnly = false,
} = $props();
// [/SECTION]
@@ -44,9 +50,12 @@
// [SECTION: DERIVED]
let filteredDashboards = $derived(
dashboards.filter((d) =>
d.title.toLowerCase().includes(filterText.toLowerCase()),
),
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(
@@ -318,7 +327,7 @@
Array.from({ length: Math.min(concurrency, selectedDashboardIds.length) }, () => worker()),
);
invalidateRepositoryStatuses(selectedDashboardIds);
const actionLabel = $t.git?.[`bulk_action_${actionToken}`];
const actionLabel = $t.git?.[`bulk_action_${actionToken}`] || actionToken;
addToast(
$t.git?.bulk_result
.replace("{action}", actionLabel)
@@ -334,7 +343,7 @@
// [DEF:handleBulkSync:Function]
async function handleBulkSync(): Promise<void> {
await runBulkGitAction("sync", (dashboardId) => gitService.sync(dashboardId));
await runBulkGitAction("sync", (dashboardId) => gitService.sync(dashboardId, null, envId));
}
// [/DEF:handleBulkSync:Function]
@@ -343,23 +352,37 @@
const message = prompt($t.git?.commit_message);
if (!message?.trim()) return;
await runBulkGitAction("commit", (dashboardId) =>
gitService.commit(dashboardId, message.trim(), []),
gitService.commit(dashboardId, message.trim(), [], envId),
);
}
// [/DEF:handleBulkCommit:Function]
// [DEF:handleBulkPull:Function]
async function handleBulkPull(): Promise<void> {
await runBulkGitAction("pull", (dashboardId) => gitService.pull(dashboardId));
await runBulkGitAction("pull", (dashboardId) => gitService.pull(dashboardId, envId));
}
// [/DEF:handleBulkPull:Function]
// [DEF:handleBulkPush:Function]
async function handleBulkPush(): Promise<void> {
await runBulkGitAction("push", (dashboardId) => gitService.push(dashboardId));
await runBulkGitAction("push", (dashboardId) => gitService.push(dashboardId, envId));
}
// [/DEF:handleBulkPush:Function]
// [DEF:handleBulkDelete: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 = [];
dispatch("selectionChanged", []);
}
// [/DEF:handleBulkDelete:Function]
// [DEF:handleManageSelected:Function]
// @PURPOSE: Opens Git manager for exactly one selected dashboard.
async function handleManageSelected(): Promise<void> {
@@ -372,13 +395,59 @@
const selectedDashboard = dashboards.find(
(dashboard) => dashboard.id === selectedDashboardId,
);
gitDashboardId = String(selectedDashboard?.slug || selectedDashboardId);
gitDashboardTitle = selectedDashboard?.title || "";
showGitManager = true;
openGitManagerForDashboard(selectedDashboard || null);
}
// [/DEF:handleManageSelected:Function]
// [DEF:resolveDashboardRef: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]);
}
// [/DEF:resolveDashboardRef:Function]
// [DEF:openGitManagerForDashboard: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;
}
// [/DEF:openGitManagerForDashboard:Function]
// [DEF:handleInitializeRepositories: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);
}
// [/DEF:handleInitializeRepositories:Function]
// [DEF:getSortStatusValue:Function]
/**
* @purpose Returns sort value for status column based on mode.
@@ -450,27 +519,45 @@
{#if selectedIds.length > 0}
<div class="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-blue-100 bg-blue-50/60 px-3 py-2">
<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>
{#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>
<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 !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}",
@@ -545,10 +632,15 @@
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 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
>
@@ -603,6 +695,7 @@
{#if showGitManager && gitDashboardId}
<GitManager
dashboardId={gitDashboardId}
envId={envId}
dashboardTitle={gitDashboardTitle}
bind:show={showGitManager}
/>

View File

@@ -25,7 +25,7 @@
dashboardId,
envId = null,
dashboardTitle = '',
show = false,
show = $bindable(false),
} = $props();
// [/SECTION]
@@ -59,6 +59,8 @@
let workspaceLoading = $state(false);
let isPulling = $state(false);
let isPushing = $state(false);
let autoPushAfterCommit = $state(true);
let repositoryProvider = $state('');
// [/SECTION]
const hasWorkspaceChanges = $derived.by(() => {
@@ -277,7 +279,12 @@
committing = true;
try {
await gitService.commit(dashboardId, commitMessage, [], envId);
toast($t.git?.commit_success || 'Коммит успешно создан', 'success');
if (autoPushAfterCommit) {
await gitService.push(dashboardId, envId);
toast($t.git?.commit_and_push_success || 'Коммит создан и отправлен в remote', 'success');
} else {
toast($t.git?.commit_success || 'Коммит успешно создан', 'success');
}
commitMessage = '';
await loadWorkspace();
} catch (e) {
@@ -420,6 +427,20 @@
}
// [/DEF:resolveDefaultConfig:Function]
// [DEF:resolvePushProviderLabel:Function]
/**
* @purpose Resolve lower-case provider label for auto-push checkbox.
* @post Returns provider label, fallback "git".
*/
function resolvePushProviderLabel() {
const selectedConfig = getSelectedConfig() || resolveDefaultConfig(configs);
const provider = String(selectedConfig?.provider || repositoryProvider || '')
.trim()
.toLowerCase();
return provider || 'git';
}
// [/DEF:resolvePushProviderLabel:Function]
// [DEF:buildSuggestedRepoName:Function]
/**
* @purpose Build deterministic repository name from dashboard title/id.
@@ -490,6 +511,8 @@
await gitService.initRepository(dashboardId, selectedConfigId, remoteUrl, envId);
toast($t.git?.init_success || 'Репозиторий инициализирован', 'success');
initialized = true;
const selectedConfig = getSelectedConfig();
repositoryProvider = selectedConfig?.provider || repositoryProvider;
await loadWorkspace();
} catch (e) {
toast(e.message, 'error');
@@ -522,7 +545,25 @@
// [/DEF:handleBackdropClick:Function]
onMount(async () => {
try {
configs = await gitService.getConfigs();
const defaultConfig = resolveDefaultConfig(configs);
if (defaultConfig?.id) selectedConfigId = defaultConfig.id;
} catch (_e) {
configs = [];
}
await Promise.all([checkStatus(), loadCurrentEnvironmentStage()]);
if (initialized) {
try {
const binding = await gitService.getRepositoryBinding(dashboardId, envId);
repositoryProvider = binding?.provider || '';
if (binding?.config_id) selectedConfigId = String(binding.config_id);
} catch (_e) {
repositoryProvider = '';
}
}
});
</script>
@@ -676,6 +717,10 @@
>
Зафиксировать (Commit)
</Button>
<label class="flex items-center gap-2 text-xs text-slate-600">
<input type="checkbox" bind:checked={autoPushAfterCommit} />
{$t.git?.auto_push_after_commit || 'Сделать push после commit в'} {resolvePushProviderLabel()}
</label>
</div>
<div class="flex min-h-[420px] flex-col overflow-hidden rounded-lg border border-slate-200 bg-slate-50">

View File

@@ -49,7 +49,24 @@
if (!envId) return;
fetchingDashboards = true;
try {
dashboards = await api.requestApi(`/environments/${envId}/dashboards`);
const pageSize = 100;
let page = 1;
let aggregatedDashboards: DashboardMetadata[] = [];
let totalPages = 1;
while (page <= totalPages) {
const response = await api.requestApi(
`/dashboards?env_id=${encodeURIComponent(envId)}&page=${page}&page_size=${pageSize}`,
);
const pageDashboards = Array.isArray(response?.dashboards)
? response.dashboards
: [];
aggregatedDashboards = aggregatedDashboards.concat(pageDashboards);
totalPages = Number(response?.total_pages || 1);
page += 1;
}
dashboards = aggregatedDashboards;
} catch (e) {
toast(e.message, 'error');
dashboards = [];

View File

@@ -21,6 +21,7 @@
import RepositoryDashboardGrid from '../../../components/RepositoryDashboardGrid.svelte';
import { addToast as toast } from '$lib/toasts.js';
import { api } from '$lib/api.js';
import { gitService } from '../../../services/gitService.js';
import type { DashboardMetadata } from '$lib/types/dashboard';
import { t } from '$lib/i18n';
import { Button, Card, PageHeader, Select } from '$lib/ui';
@@ -65,7 +66,24 @@
if (!envId) return;
fetchingDashboards = true;
try {
dashboards = await api.requestApi(`/environments/${envId}/dashboards`);
const pageSize = 100;
let page = 1;
let aggregatedDashboards: DashboardMetadata[] = [];
let totalPages = 1;
while (page <= totalPages) {
const response = await api.requestApi(
`/dashboards?env_id=${encodeURIComponent(envId)}&page=${page}&page_size=${pageSize}`,
);
const pageDashboards = Array.isArray(response?.dashboards)
? response.dashboards
: [];
aggregatedDashboards = aggregatedDashboards.concat(pageDashboards);
totalPages = Number(response?.total_pages || 1);
page += 1;
}
dashboards = await filterDashboardsWithRepositories(aggregatedDashboards);
} catch (e) {
toast(e.message, 'error');
dashboards = [];
@@ -75,6 +93,46 @@
}
// [/DEF:fetchDashboards:Function]
// [DEF:filterDashboardsWithRepositories:Function]
/**
* @PURPOSE: Keep only dashboards that already have initialized Git repositories.
* @PRE: dashboards list is loaded for selected environment.
* @POST: Returns dashboards with status != NO_REPO.
*/
async function filterDashboardsWithRepositories(
allDashboards: DashboardMetadata[],
): Promise<DashboardMetadata[]> {
if (allDashboards.length === 0) return [];
const chunkSize = 50;
const repositoryDashboardIds = new Set<number>();
const allIds = allDashboards.map((dashboard) => dashboard.id);
for (let offset = 0; offset < allIds.length; offset += chunkSize) {
const idsChunk = allIds.slice(offset, offset + chunkSize);
try {
const batchResponse = await gitService.getStatusesBatch(idsChunk);
const statuses = batchResponse?.statuses || {};
idsChunk.forEach((dashboardId) => {
const status = statuses[dashboardId] || statuses[String(dashboardId)];
const syncStatus = String(status?.sync_status || status?.sync_state || "").toUpperCase();
if (syncStatus && syncStatus !== "NO_REPO") {
repositoryDashboardIds.add(dashboardId);
}
});
} catch (error) {
console.error(
`[StorageReposPage][Coherence:Failed] Failed to resolve repository statuses chunk: ${error?.message || error}`,
);
}
}
return allDashboards.filter((dashboard) =>
repositoryDashboardIds.has(dashboard.id),
);
}
// [/DEF:filterDashboardsWithRepositories:Function]
onMount(fetchEnvironments);
$: environments = $environmentContextStore?.environments || [];
@@ -107,13 +165,13 @@
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
{:else}
<Card title={$t.git?.select_dashboard }>
<Card title={$t.nav?.repositories || "Репозитории"}>
{#if fetchingDashboards}
<p class="text-gray-500">{$t.common?.loading }</p>
{:else if dashboards.length > 0}
<RepositoryDashboardGrid {dashboards} statusMode="repository" />
<RepositoryDashboardGrid {dashboards} statusMode="repository" envId={selectedEnvId || null} repositoriesOnly={true} />
{:else}
<p class="text-gray-500 italic">{$t.dashboard?.no_dashboards }</p>
<p class="text-gray-500 italic">{$t.git?.no_repositories_selected || "Репозитории не найдены"}</p>
{/if}
</Card>
{/if}

View File

@@ -32,12 +32,35 @@
let isLoading = false;
let currentPath = '';
let uploadCategory = 'backups';
let uploadSubpath = '';
// [DEF:resolveStorageQueryFromPath:Function]
/**
* @purpose Splits UI path into storage API category and category-local subpath.
* @pre uiPath may be empty or start with backups/repositorys.
* @post Returns {category, subpath} compatible with /api/storage/files.
*/
function resolveStorageQueryFromPath(uiPath: string): { category?: string; subpath?: string } {
const segments = String(uiPath || '').split('/').filter(Boolean);
if (segments.length === 0) return {};
const topLevel = segments[0];
if (topLevel !== 'backups' && topLevel !== 'repositorys') {
return {};
}
const subpath = segments.slice(1).join('/');
return {
category: topLevel,
subpath: subpath || undefined,
};
}
// [/DEF:resolveStorageQueryFromPath:Function]
async function loadFiles() {
console.log('[STORAGE-PAGE][LOAD_START] path=%s', currentPath);
isLoading = true;
try {
files = await listFiles(undefined, currentPath);
const query = resolveStorageQueryFromPath(currentPath);
files = await listFiles(query.category, query.subpath);
console.log('[STORAGE-PAGE][LOAD_OK] count=%d', files.length);
} catch (error) {
console.log('[STORAGE-PAGE][LOAD_ERR] error=%s', error.message);
@@ -108,8 +131,11 @@
* @post uploadCategory is either backups or repositorys.
*/
function updateUploadCategory() {
const [topLevel] = currentPath.split('/').filter(Boolean);
const [topLevel, ...rest] = currentPath.split('/').filter(Boolean);
uploadCategory = topLevel === 'repositorys' ? 'repositorys' : 'backups';
uploadSubpath = topLevel === 'repositorys' || topLevel === 'backups'
? rest.join('/')
: '';
}
// [/DEF:updateUploadCategory:Function]
@@ -180,7 +206,7 @@
<div class="lg:col-span-1">
<FileUpload
category={uploadCategory}
path={currentPath}
path={uploadSubpath}
on:uploaded={loadFiles}
/>
</div>

View File

@@ -146,6 +146,19 @@ export const gitService = {
});
},
/**
* [DEF:getRepositoryBinding:Function]
* @purpose Fetches repository binding metadata (config/provider) for dashboard.
* @pre Repository should be initialized for dashboard.
* @post Returns provider and config details for current repository.
* @param {string|number} dashboardRef - Dashboard slug or id.
* @returns {Promise<Object>} Repository binding payload.
*/
async getRepositoryBinding(dashboardRef, envId = null) {
console.log(`[getRepositoryBinding][Action] Fetching repository binding for dashboard ${dashboardRef}`);
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '', envId));
},
/**
* [DEF:getBranches:Function]
* @purpose Retrieves the list of branches for a dashboard's repository.
@@ -219,6 +232,19 @@ export const gitService = {
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/push', envId), 'POST');
},
/**
* [DEF:deleteRepository:Function]
* @purpose Deletes local repository binding and workspace for dashboard.
* @pre Dashboard reference must resolve on backend.
* @post Repository record and local folder are removed.
* @param {string|number} dashboardRef - Dashboard slug or id.
* @returns {Promise<Object>} Deletion result.
*/
async deleteRepository(dashboardRef, envId = null) {
console.log(`[deleteRepository][Action] Deleting repository for dashboard ${dashboardRef}`);
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '', envId), 'DELETE');
},
/**
* [DEF:pull:Function]
* @purpose Pulls changes from the remote repository.

View File

@@ -7,6 +7,10 @@
export interface DashboardMetadata {
id: number;
title: string;
slug?: string;
dashboard_slug?: string;
url_slug?: string;
url?: string;
last_modified: string;
status: string;
}