diff --git a/backend/src/api/routes/git.py b/backend/src/api/routes/git.py index 65ac99f..90500d0 100644 --- a/backend/src/api/routes/git.py +++ b/backend/src/api/routes/git.py @@ -23,6 +23,7 @@ from src.api.routes.git_schemas import ( BranchSchema, BranchCreate, BranchCheckout, CommitSchema, CommitCreate, DeploymentEnvironmentSchema, DeployRequest, RepoInitRequest, + RepositoryBindingSchema, RepoStatusBatchRequest, RepoStatusBatchResponse, GiteaRepoCreateRequest, GiteaRepoSchema, RemoteRepoCreateRequest, RemoteRepoSchema, @@ -468,13 +469,15 @@ async def init_repository( dashboard_id=dashboard_id, config_id=config.id, remote_url=init_data.remote_url, - local_path=repo_path + local_path=repo_path, + current_branch="dev", ) db.add(db_repo) else: db_repo.config_id = config.id db_repo.remote_url = init_data.remote_url db_repo.local_path = repo_path + db_repo.current_branch = "dev" db.commit() logger.info(f"[init_repository][Coherence:OK] Repository initialized for dashboard {dashboard_id}") @@ -487,6 +490,64 @@ async def init_repository( _handle_unexpected_git_route_error("init_repository", e) # [/DEF:init_repository:Function] +# [DEF:get_repository_binding:Function] +# @PURPOSE: Return repository binding with provider metadata for selected dashboard. +# @PRE: `dashboard_ref` resolves to a valid dashboard and repository is initialized. +# @POST: Returns dashboard repository binding and linked provider. +# @PARAM: dashboard_ref (str) +# @RETURN: RepositoryBindingSchema +@router.get("/repositories/{dashboard_ref}", response_model=RepositoryBindingSchema) +async def get_repository_binding( + dashboard_ref: str, + env_id: Optional[str] = None, + config_manager=Depends(get_config_manager), + db: Session = Depends(get_db), + _ = Depends(has_permission("plugin:git", "EXECUTE")) +): + with belief_scope("get_repository_binding"): + try: + dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id) + db_repo = db.query(GitRepository).filter(GitRepository.dashboard_id == dashboard_id).first() + if not db_repo: + raise HTTPException(status_code=404, detail="Repository not initialized") + config = _get_git_config_or_404(db, db_repo.config_id) + return RepositoryBindingSchema( + dashboard_id=db_repo.dashboard_id, + config_id=db_repo.config_id, + provider=config.provider, + remote_url=db_repo.remote_url, + local_path=db_repo.local_path, + ) + except HTTPException: + raise + except Exception as e: + _handle_unexpected_git_route_error("get_repository_binding", e) +# [/DEF:get_repository_binding:Function] + +# [DEF:delete_repository:Function] +# @PURPOSE: Delete local repository workspace and DB binding for selected dashboard. +# @PRE: `dashboard_ref` resolves to a valid dashboard. +# @POST: Repository files and binding record are removed when present. +# @PARAM: dashboard_ref (str) +# @RETURN: dict +@router.delete("/repositories/{dashboard_ref}") +async def delete_repository( + dashboard_ref: str, + env_id: Optional[str] = None, + config_manager=Depends(get_config_manager), + _ = Depends(has_permission("plugin:git", "EXECUTE")) +): + with belief_scope("delete_repository"): + try: + dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id) + git_service.delete_repo(dashboard_id) + return {"status": "success"} + except HTTPException: + raise + except Exception as e: + _handle_unexpected_git_route_error("delete_repository", e) +# [/DEF:delete_repository:Function] + # [DEF:get_branches:Function] # @PURPOSE: List all branches for a dashboard's repository. # @PRE: Repository for `dashboard_ref` is initialized. diff --git a/backend/src/models/git.py b/backend/src/models/git.py index aaedeb5..222f5dc 100644 --- a/backend/src/models/git.py +++ b/backend/src/models/git.py @@ -53,7 +53,7 @@ class GitRepository(Base): config_id = Column(String(36), ForeignKey("git_server_configs.id"), nullable=False) remote_url = Column(String(255), nullable=False) local_path = Column(String(255), nullable=False) - current_branch = Column(String(255), default="main") + current_branch = Column(String(255), default="dev") sync_status = Column(Enum(SyncStatus), default=SyncStatus.CLEAN) # [/DEF:GitRepository:Class] diff --git a/backend/src/services/git_service.py b/backend/src/services/git_service.py index ab57565..3a95c2d 100644 --- a/backend/src/services/git_service.py +++ b/backend/src/services/git_service.py @@ -18,6 +18,8 @@ import httpx import re import shutil from git import Repo +from git.exc import GitCommandError +from git.exc import InvalidGitRepositoryError, NoSuchPathError from fastapi import HTTPException from typing import Any, Dict, List, Optional from datetime import datetime @@ -167,6 +169,90 @@ class GitService: return target_abs # [/DEF:_migrate_repo_directory:Function] + # [DEF:_ensure_gitflow_branches:Function] + # @PURPOSE: Ensure standard GitFlow branches (main/dev/preprod) exist locally and on origin. + # @PRE: repo is a valid GitPython Repo instance. + # @POST: main, dev, preprod are available in local repository and pushed to origin when available. + # @RETURN: None + def _ensure_gitflow_branches(self, repo: Repo, dashboard_id: int) -> None: + with belief_scope("GitService._ensure_gitflow_branches"): + required_branches = ["main", "dev", "preprod"] + local_heads = {head.name: head for head in getattr(repo, "heads", [])} + + base_commit = None + try: + base_commit = repo.head.commit + except Exception: + base_commit = None + + if "main" in local_heads: + base_commit = local_heads["main"].commit + + if base_commit is None: + logger.warning( + f"[_ensure_gitflow_branches][Action] Skipping branch bootstrap for dashboard {dashboard_id}: repository has no commits" + ) + return + + if "main" not in local_heads: + local_heads["main"] = repo.create_head("main", base_commit) + logger.info(f"[_ensure_gitflow_branches][Action] Created local branch main for dashboard {dashboard_id}") + + for branch_name in ("dev", "preprod"): + if branch_name in local_heads: + continue + local_heads[branch_name] = repo.create_head(branch_name, local_heads["main"].commit) + logger.info( + f"[_ensure_gitflow_branches][Action] Created local branch {branch_name} for dashboard {dashboard_id}" + ) + + try: + origin = repo.remote(name="origin") + except ValueError: + logger.info( + f"[_ensure_gitflow_branches][Action] Remote origin is not configured for dashboard {dashboard_id}; skipping remote branch creation" + ) + return + + remote_branch_names = set() + try: + origin.fetch() + for ref in origin.refs: + remote_head = getattr(ref, "remote_head", None) + if remote_head: + remote_branch_names.add(str(remote_head)) + except Exception as e: + logger.warning(f"[_ensure_gitflow_branches][Action] Failed to fetch origin refs: {e}") + + for branch_name in required_branches: + if branch_name in remote_branch_names: + continue + try: + origin.push(refspec=f"{branch_name}:{branch_name}") + logger.info( + f"[_ensure_gitflow_branches][Action] Pushed branch {branch_name} to origin for dashboard {dashboard_id}" + ) + except Exception as e: + logger.error( + f"[_ensure_gitflow_branches][Coherence:Failed] Failed to push branch {branch_name} to origin: {e}" + ) + raise HTTPException( + status_code=500, + detail=f"Failed to create default branch '{branch_name}' on remote: {str(e)}", + ) + + # Keep default working branch on DEV for day-to-day changes. + try: + repo.git.checkout("dev") + logger.info( + f"[_ensure_gitflow_branches][Action] Checked out default branch dev for dashboard {dashboard_id}" + ) + except Exception as e: + logger.warning( + f"[_ensure_gitflow_branches][Action] Could not checkout dev branch for dashboard {dashboard_id}: {e}" + ) + # [/DEF:_ensure_gitflow_branches:Function] + # [DEF:_get_repo_path:Function] # @PURPOSE: Resolves the local filesystem path for a dashboard's repository. # @PARAM: dashboard_id (int) @@ -239,12 +325,74 @@ class GitService: if os.path.exists(repo_path): logger.info(f"[init_repo][Action] Opening existing repo at {repo_path}") - return Repo(repo_path) + try: + repo = Repo(repo_path) + except (InvalidGitRepositoryError, NoSuchPathError): + logger.warning( + f"[init_repo][Action] Existing path is not a Git repository, recreating: {repo_path}" + ) + if os.path.isdir(repo_path): + shutil.rmtree(repo_path) + else: + os.remove(repo_path) + repo = Repo.clone_from(auth_url, repo_path) + self._ensure_gitflow_branches(repo, dashboard_id) + return repo logger.info(f"[init_repo][Action] Cloning {remote_url} to {repo_path}") - return Repo.clone_from(auth_url, repo_path) + repo = Repo.clone_from(auth_url, repo_path) + self._ensure_gitflow_branches(repo, dashboard_id) + return repo # [/DEF:init_repo:Function] + # [DEF:delete_repo:Function] + # @PURPOSE: Remove local repository and DB binding for a dashboard. + # @PRE: dashboard_id is a valid integer. + # @POST: Local path is deleted when present and GitRepository row is removed. + # @RETURN: None + def delete_repo(self, dashboard_id: int) -> None: + with belief_scope("GitService.delete_repo"): + repo_path = self._get_repo_path(dashboard_id) + removed_files = False + if os.path.exists(repo_path): + if os.path.isdir(repo_path): + shutil.rmtree(repo_path) + else: + os.remove(repo_path) + removed_files = True + + session = SessionLocal() + try: + db_repo = ( + session.query(GitRepository) + .filter(GitRepository.dashboard_id == int(dashboard_id)) + .first() + ) + if db_repo: + session.delete(db_repo) + session.commit() + return + + if removed_files: + return + + raise HTTPException( + status_code=404, + detail=f"Repository for dashboard {dashboard_id} not found", + ) + except HTTPException: + session.rollback() + raise + except Exception as e: + session.rollback() + logger.error( + f"[delete_repo][Coherence:Failed] Failed to delete repository for dashboard {dashboard_id}: {e}" + ) + raise HTTPException(status_code=500, detail=f"Failed to delete repository: {str(e)}") + finally: + session.close() + # [/DEF:delete_repo:Function] + # [DEF:get_repo:Function] # @PURPOSE: Get Repo object for a dashboard. # @PRE: Repository must exist on disk for the given dashboard_id. @@ -308,7 +456,7 @@ class GitService: # If everything else failed and list is still empty, add default if not branches: branches.append({ - "name": "main", + "name": "dev", "commit_hash": "0000000", "is_remote": False, "last_updated": datetime.utcnow() @@ -428,6 +576,19 @@ class GitService: if info.flags & info.ERROR: logger.error(f"[push_changes][Coherence:Failed] Error pushing ref {info.remote_ref_string}: {info.summary}") raise Exception(f"Git push error for {info.remote_ref_string}: {info.summary}") + except GitCommandError as e: + details = str(e) + lowered = details.lower() + if "non-fast-forward" in lowered or "rejected" in lowered: + raise HTTPException( + status_code=409, + detail=( + "Push rejected: remote branch contains newer commits. " + "Run Pull first, resolve conflicts if any, then push again." + ), + ) + logger.error(f"[push_changes][Coherence:Failed] Failed to push changes: {e}") + raise HTTPException(status_code=500, detail=f"Git push failed: {details}") except Exception as e: logger.error(f"[push_changes][Coherence:Failed] Failed to push changes: {e}") raise HTTPException(status_code=500, detail=f"Git push failed: {str(e)}") @@ -443,6 +604,7 @@ class GitService: try: origin = repo.remote(name='origin') current_branch = repo.active_branch.name + origin.fetch(prune=True) remote_ref = f"origin/{current_branch}" has_remote_branch = any(ref.name == remote_ref for ref in repo.refs) if not has_remote_branch: @@ -452,14 +614,24 @@ class GitService: ) logger.info(f"[pull_changes][Action] Pulling changes from origin/{current_branch}") - fetch_info = origin.pull(current_branch) - for info in fetch_info: - if info.flags & info.ERROR: - logger.error(f"[pull_changes][Coherence:Failed] Error pulling ref {info.ref}: {info.note}") - raise Exception(f"Git pull error for {info.ref}: {info.note}") + # Force deterministic merge strategy for modern git versions. + repo.git.pull("--no-rebase", "origin", current_branch) except ValueError: logger.error(f"[pull_changes][Coherence:Failed] Remote 'origin' not found for dashboard {dashboard_id}") raise HTTPException(status_code=400, detail="Remote 'origin' not configured") + except GitCommandError as e: + details = str(e) + lowered = details.lower() + if "conflict" in lowered or "not possible to fast-forward" in lowered: + raise HTTPException( + status_code=409, + detail=( + "Pull requires conflict resolution. Resolve conflicts in repository " + "and repeat operation." + ), + ) + logger.error(f"[pull_changes][Coherence:Failed] Failed to pull changes: {e}") + raise HTTPException(status_code=500, detail=f"Git pull failed: {details}") except HTTPException: raise except Exception as e: @@ -805,6 +977,66 @@ class GitService: ) # [/DEF:delete_gitea_repository:Function] + # [DEF:_gitea_branch_exists:Function] + # @PURPOSE: Check whether a branch exists in Gitea repository. + # @PRE: owner/repo/branch are non-empty. + # @POST: Returns True when branch exists, False when 404. + # @RETURN: bool + async def _gitea_branch_exists( + self, + server_url: str, + pat: str, + owner: str, + repo: str, + branch: str, + ) -> bool: + if not owner or not repo or not branch: + return False + endpoint = f"/repos/{owner}/{repo}/branches/{quote(branch, safe='')}" + try: + await self._gitea_request("GET", server_url, pat, endpoint) + return True + except HTTPException as exc: + if exc.status_code == 404: + return False + raise + # [/DEF:_gitea_branch_exists:Function] + + # [DEF:_build_gitea_pr_404_detail:Function] + # @PURPOSE: Build actionable error detail for Gitea PR 404 responses. + # @PRE: owner/repo/from_branch/to_branch are provided. + # @POST: Returns specific branch-missing message when detected. + # @RETURN: Optional[str] + async def _build_gitea_pr_404_detail( + self, + server_url: str, + pat: str, + owner: str, + repo: str, + from_branch: str, + to_branch: str, + ) -> Optional[str]: + source_exists = await self._gitea_branch_exists( + server_url=server_url, + pat=pat, + owner=owner, + repo=repo, + branch=from_branch, + ) + target_exists = await self._gitea_branch_exists( + server_url=server_url, + pat=pat, + owner=owner, + repo=repo, + branch=to_branch, + ) + if not source_exists: + return f"Gitea branch not found: source branch '{from_branch}' in {owner}/{repo}" + if not target_exists: + return f"Gitea branch not found: target branch '{to_branch}' in {owner}/{repo}" + return None + # [/DEF:_build_gitea_pr_404_detail:Function] + # [DEF:create_github_repository:Function] # @PURPOSE: Create repository in GitHub or GitHub Enterprise. # @PRE: PAT has repository create permission. @@ -1061,11 +1293,12 @@ class GitService: "base": to_branch, "body": description or "", } - endpoint = f"/repos/{identity['namespace']}/{identity['repo']}/pulls" + endpoint = f"/repos/{identity['owner']}/{identity['repo']}/pulls" + active_server_url = server_url try: data = await self._gitea_request( "POST", - server_url, + active_server_url, pat, endpoint, payload=payload, @@ -1073,20 +1306,52 @@ class GitService: except HTTPException as exc: fallback_url = self._derive_server_url_from_remote(remote_url) normalized_primary = self._normalize_git_server_url(server_url) - if exc.status_code != 404 or not fallback_url or fallback_url == normalized_primary: + should_retry_with_fallback = ( + exc.status_code == 404 and fallback_url and fallback_url != normalized_primary + ) + if should_retry_with_fallback: + logger.warning( + "[create_gitea_pull_request][Action] Primary Gitea URL not found, retrying with remote host: %s", + fallback_url, + ) + active_server_url = fallback_url + try: + data = await self._gitea_request( + "POST", + active_server_url, + pat, + endpoint, + payload=payload, + ) + except HTTPException as retry_exc: + if retry_exc.status_code == 404: + branch_detail = await self._build_gitea_pr_404_detail( + server_url=active_server_url, + pat=pat, + owner=identity["owner"], + repo=identity["repo"], + from_branch=from_branch, + to_branch=to_branch, + ) + if branch_detail: + raise HTTPException(status_code=400, detail=branch_detail) + raise + else: + if exc.status_code == 404: + branch_detail = await self._build_gitea_pr_404_detail( + server_url=active_server_url, + pat=pat, + owner=identity["owner"], + repo=identity["repo"], + from_branch=from_branch, + to_branch=to_branch, + ) + if branch_detail: + raise HTTPException(status_code=400, detail=branch_detail) raise - logger.warning( - "[create_gitea_pull_request][Action] Primary Gitea URL not found, retrying with remote host: %s", - fallback_url, - ) - data = await self._gitea_request( - "POST", - fallback_url, - pat, - endpoint, - payload=payload, - ) + if not isinstance(data, dict): + raise HTTPException(status_code=500, detail="Unexpected Gitea response while creating pull request") return { "id": data.get("number") or data.get("id"), "url": data.get("html_url") or data.get("url"), diff --git a/frontend/src/components/RepositoryDashboardGrid.svelte b/frontend/src/components/RepositoryDashboardGrid.svelte index 962a69a..aea2c2d 100644 --- a/frontend/src/components/RepositoryDashboardGrid.svelte +++ b/frontend/src/components/RepositoryDashboardGrid.svelte @@ -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 { - 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 { - 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 { - 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 { + 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 { @@ -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 { + 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}
- + {#if !repositoriesOnly} + + + {/if} - + {#if !repositoriesOnly} + + {/if} + {#if repositoriesOnly} + + {/if} {$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" /> - {dashboard.title} + + + {new Date(dashboard.last_modified).toLocaleDateString()} @@ -603,6 +695,7 @@ {#if showGitManager && gitDashboardId} diff --git a/frontend/src/components/git/GitManager.svelte b/frontend/src/components/git/GitManager.svelte index 35feae8..72d6dc7 100644 --- a/frontend/src/components/git/GitManager.svelte +++ b/frontend/src/components/git/GitManager.svelte @@ -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 = ''; + } + } }); @@ -676,6 +717,10 @@ > Зафиксировать (Commit) +
diff --git a/frontend/src/routes/git/+page.svelte b/frontend/src/routes/git/+page.svelte index 747be37..8334187 100644 --- a/frontend/src/routes/git/+page.svelte +++ b/frontend/src/routes/git/+page.svelte @@ -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 = []; diff --git a/frontend/src/routes/storage/repos/+page.svelte b/frontend/src/routes/storage/repos/+page.svelte index 501a3d9..df54d6a 100644 --- a/frontend/src/routes/storage/repos/+page.svelte +++ b/frontend/src/routes/storage/repos/+page.svelte @@ -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 { + if (allDashboards.length === 0) return []; + + const chunkSize = 50; + const repositoryDashboardIds = new Set(); + 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 @@
{:else} - + {#if fetchingDashboards}

{$t.common?.loading }

{:else if dashboards.length > 0} - + {:else} -

{$t.dashboard?.no_dashboards }

+

{$t.git?.no_repositories_selected || "Репозитории не найдены"}

{/if}
{/if} diff --git a/frontend/src/routes/tools/storage/+page.svelte b/frontend/src/routes/tools/storage/+page.svelte index dfe9344..2abb7de 100644 --- a/frontend/src/routes/tools/storage/+page.svelte +++ b/frontend/src/routes/tools/storage/+page.svelte @@ -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 @@
diff --git a/frontend/src/services/gitService.js b/frontend/src/services/gitService.js index e9774e1..c20be6d 100644 --- a/frontend/src/services/gitService.js +++ b/frontend/src/services/gitService.js @@ -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} 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} 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. diff --git a/frontend/src/types/dashboard.ts b/frontend/src/types/dashboard.ts index d02fbfa..ce636df 100644 --- a/frontend/src/types/dashboard.ts +++ b/frontend/src/types/dashboard.ts @@ -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; }