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 f34f9c1b2e
commit 42def69dcc
10 changed files with 658 additions and 63 deletions

View File

@@ -23,6 +23,7 @@ from src.api.routes.git_schemas import (
BranchSchema, BranchCreate, BranchSchema, BranchCreate,
BranchCheckout, CommitSchema, CommitCreate, BranchCheckout, CommitSchema, CommitCreate,
DeploymentEnvironmentSchema, DeployRequest, RepoInitRequest, DeploymentEnvironmentSchema, DeployRequest, RepoInitRequest,
RepositoryBindingSchema,
RepoStatusBatchRequest, RepoStatusBatchResponse, RepoStatusBatchRequest, RepoStatusBatchResponse,
GiteaRepoCreateRequest, GiteaRepoSchema, GiteaRepoCreateRequest, GiteaRepoSchema,
RemoteRepoCreateRequest, RemoteRepoSchema, RemoteRepoCreateRequest, RemoteRepoSchema,
@@ -468,13 +469,15 @@ async def init_repository(
dashboard_id=dashboard_id, dashboard_id=dashboard_id,
config_id=config.id, config_id=config.id,
remote_url=init_data.remote_url, remote_url=init_data.remote_url,
local_path=repo_path local_path=repo_path,
current_branch="dev",
) )
db.add(db_repo) db.add(db_repo)
else: else:
db_repo.config_id = config.id db_repo.config_id = config.id
db_repo.remote_url = init_data.remote_url db_repo.remote_url = init_data.remote_url
db_repo.local_path = repo_path db_repo.local_path = repo_path
db_repo.current_branch = "dev"
db.commit() db.commit()
logger.info(f"[init_repository][Coherence:OK] Repository initialized for dashboard {dashboard_id}") 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) _handle_unexpected_git_route_error("init_repository", e)
# [/DEF:init_repository:Function] # [/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] # [DEF:get_branches:Function]
# @PURPOSE: List all branches for a dashboard's repository. # @PURPOSE: List all branches for a dashboard's repository.
# @PRE: Repository for `dashboard_ref` is initialized. # @PRE: Repository for `dashboard_ref` is initialized.

View File

@@ -53,7 +53,7 @@ class GitRepository(Base):
config_id = Column(String(36), ForeignKey("git_server_configs.id"), nullable=False) config_id = Column(String(36), ForeignKey("git_server_configs.id"), nullable=False)
remote_url = Column(String(255), nullable=False) remote_url = Column(String(255), nullable=False)
local_path = 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) sync_status = Column(Enum(SyncStatus), default=SyncStatus.CLEAN)
# [/DEF:GitRepository:Class] # [/DEF:GitRepository:Class]

View File

@@ -18,6 +18,8 @@ import httpx
import re import re
import shutil import shutil
from git import Repo from git import Repo
from git.exc import GitCommandError
from git.exc import InvalidGitRepositoryError, NoSuchPathError
from fastapi import HTTPException from fastapi import HTTPException
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from datetime import datetime from datetime import datetime
@@ -167,6 +169,90 @@ class GitService:
return target_abs return target_abs
# [/DEF:_migrate_repo_directory:Function] # [/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] # [DEF:_get_repo_path:Function]
# @PURPOSE: Resolves the local filesystem path for a dashboard's repository. # @PURPOSE: Resolves the local filesystem path for a dashboard's repository.
# @PARAM: dashboard_id (int) # @PARAM: dashboard_id (int)
@@ -239,12 +325,74 @@ class GitService:
if os.path.exists(repo_path): if os.path.exists(repo_path):
logger.info(f"[init_repo][Action] Opening existing repo at {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}") 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: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] # [DEF:get_repo:Function]
# @PURPOSE: Get Repo object for a dashboard. # @PURPOSE: Get Repo object for a dashboard.
# @PRE: Repository must exist on disk for the given dashboard_id. # @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 everything else failed and list is still empty, add default
if not branches: if not branches:
branches.append({ branches.append({
"name": "main", "name": "dev",
"commit_hash": "0000000", "commit_hash": "0000000",
"is_remote": False, "is_remote": False,
"last_updated": datetime.utcnow() "last_updated": datetime.utcnow()
@@ -428,6 +576,19 @@ class GitService:
if info.flags & info.ERROR: if info.flags & info.ERROR:
logger.error(f"[push_changes][Coherence:Failed] Error pushing ref {info.remote_ref_string}: {info.summary}") 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}") 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: except Exception as e:
logger.error(f"[push_changes][Coherence:Failed] Failed to push changes: {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)}") raise HTTPException(status_code=500, detail=f"Git push failed: {str(e)}")
@@ -443,6 +604,7 @@ class GitService:
try: try:
origin = repo.remote(name='origin') origin = repo.remote(name='origin')
current_branch = repo.active_branch.name current_branch = repo.active_branch.name
origin.fetch(prune=True)
remote_ref = f"origin/{current_branch}" remote_ref = f"origin/{current_branch}"
has_remote_branch = any(ref.name == remote_ref for ref in repo.refs) has_remote_branch = any(ref.name == remote_ref for ref in repo.refs)
if not has_remote_branch: if not has_remote_branch:
@@ -452,14 +614,24 @@ class GitService:
) )
logger.info(f"[pull_changes][Action] Pulling changes from origin/{current_branch}") logger.info(f"[pull_changes][Action] Pulling changes from origin/{current_branch}")
fetch_info = origin.pull(current_branch) # Force deterministic merge strategy for modern git versions.
for info in fetch_info: repo.git.pull("--no-rebase", "origin", current_branch)
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}")
except ValueError: except ValueError:
logger.error(f"[pull_changes][Coherence:Failed] Remote 'origin' not found for dashboard {dashboard_id}") 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") 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: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@@ -805,6 +977,66 @@ class GitService:
) )
# [/DEF:delete_gitea_repository:Function] # [/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] # [DEF:create_github_repository:Function]
# @PURPOSE: Create repository in GitHub or GitHub Enterprise. # @PURPOSE: Create repository in GitHub or GitHub Enterprise.
# @PRE: PAT has repository create permission. # @PRE: PAT has repository create permission.
@@ -1061,11 +1293,12 @@ class GitService:
"base": to_branch, "base": to_branch,
"body": description or "", "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: try:
data = await self._gitea_request( data = await self._gitea_request(
"POST", "POST",
server_url, active_server_url,
pat, pat,
endpoint, endpoint,
payload=payload, payload=payload,
@@ -1073,20 +1306,52 @@ class GitService:
except HTTPException as exc: except HTTPException as exc:
fallback_url = self._derive_server_url_from_remote(remote_url) fallback_url = self._derive_server_url_from_remote(remote_url)
normalized_primary = self._normalize_git_server_url(server_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 = (
raise exc.status_code == 404 and fallback_url and fallback_url != normalized_primary
)
if should_retry_with_fallback:
logger.warning( logger.warning(
"[create_gitea_pull_request][Action] Primary Gitea URL not found, retrying with remote host: %s", "[create_gitea_pull_request][Action] Primary Gitea URL not found, retrying with remote host: %s",
fallback_url, fallback_url,
) )
active_server_url = fallback_url
try:
data = await self._gitea_request( data = await self._gitea_request(
"POST", "POST",
fallback_url, active_server_url,
pat, pat,
endpoint, endpoint,
payload=payload, 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
if not isinstance(data, dict):
raise HTTPException(status_code=500, detail="Unexpected Gitea response while creating pull request")
return { return {
"id": data.get("number") or data.get("id"), "id": data.get("number") or data.get("id"),
"url": data.get("html_url") or data.get("url"), "url": data.get("html_url") or data.get("url"),

View File

@@ -21,7 +21,13 @@
// [/SECTION] // [/SECTION]
// [SECTION: PROPS] // [SECTION: PROPS]
let { dashboards = [], selectedIds = [], statusMode = "dashboard" } = $props(); let {
dashboards = [],
selectedIds = [],
statusMode = "dashboard",
envId = null,
repositoriesOnly = false,
} = $props();
// [/SECTION] // [/SECTION]
@@ -44,9 +50,12 @@
// [SECTION: DERIVED] // [SECTION: DERIVED]
let filteredDashboards = $derived( let filteredDashboards = $derived(
dashboards.filter((d) => dashboards.filter((d) => {
d.title.toLowerCase().includes(filterText.toLowerCase()), 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( let sortedDashboards = $derived(
@@ -318,7 +327,7 @@
Array.from({ length: Math.min(concurrency, selectedDashboardIds.length) }, () => worker()), Array.from({ length: Math.min(concurrency, selectedDashboardIds.length) }, () => worker()),
); );
invalidateRepositoryStatuses(selectedDashboardIds); invalidateRepositoryStatuses(selectedDashboardIds);
const actionLabel = $t.git?.[`bulk_action_${actionToken}`]; const actionLabel = $t.git?.[`bulk_action_${actionToken}`] || actionToken;
addToast( addToast(
$t.git?.bulk_result $t.git?.bulk_result
.replace("{action}", actionLabel) .replace("{action}", actionLabel)
@@ -334,7 +343,7 @@
// [DEF:handleBulkSync:Function] // [DEF:handleBulkSync:Function]
async function handleBulkSync(): Promise<void> { async function handleBulkSync(): Promise<void> {
await runBulkGitAction("sync", (dashboardId) => gitService.sync(dashboardId)); await runBulkGitAction("sync", (dashboardId) => gitService.sync(dashboardId, null, envId));
} }
// [/DEF:handleBulkSync:Function] // [/DEF:handleBulkSync:Function]
@@ -343,23 +352,37 @@
const message = prompt($t.git?.commit_message); const message = prompt($t.git?.commit_message);
if (!message?.trim()) return; if (!message?.trim()) return;
await runBulkGitAction("commit", (dashboardId) => await runBulkGitAction("commit", (dashboardId) =>
gitService.commit(dashboardId, message.trim(), []), gitService.commit(dashboardId, message.trim(), [], envId),
); );
} }
// [/DEF:handleBulkCommit:Function] // [/DEF:handleBulkCommit:Function]
// [DEF:handleBulkPull:Function] // [DEF:handleBulkPull:Function]
async function handleBulkPull(): Promise<void> { async function handleBulkPull(): Promise<void> {
await runBulkGitAction("pull", (dashboardId) => gitService.pull(dashboardId)); await runBulkGitAction("pull", (dashboardId) => gitService.pull(dashboardId, envId));
} }
// [/DEF:handleBulkPull:Function] // [/DEF:handleBulkPull:Function]
// [DEF:handleBulkPush:Function] // [DEF:handleBulkPush:Function]
async function handleBulkPush(): Promise<void> { async function handleBulkPush(): Promise<void> {
await runBulkGitAction("push", (dashboardId) => gitService.push(dashboardId)); await runBulkGitAction("push", (dashboardId) => gitService.push(dashboardId, envId));
} }
// [/DEF:handleBulkPush:Function] // [/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] // [DEF:handleManageSelected:Function]
// @PURPOSE: Opens Git manager for exactly one selected dashboard. // @PURPOSE: Opens Git manager for exactly one selected dashboard.
async function handleManageSelected(): Promise<void> { async function handleManageSelected(): Promise<void> {
@@ -372,13 +395,59 @@
const selectedDashboard = dashboards.find( const selectedDashboard = dashboards.find(
(dashboard) => dashboard.id === selectedDashboardId, (dashboard) => dashboard.id === selectedDashboardId,
); );
openGitManagerForDashboard(selectedDashboard || null);
gitDashboardId = String(selectedDashboard?.slug || selectedDashboardId);
gitDashboardTitle = selectedDashboard?.title || "";
showGitManager = true;
} }
// [/DEF:handleManageSelected:Function] // [/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] // [DEF:getSortStatusValue:Function]
/** /**
* @purpose Returns sort value for status column based on mode. * @purpose Returns sort value for status column based on mode.
@@ -450,6 +519,7 @@
{#if selectedIds.length > 0} {#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"> <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">
{#if !repositoriesOnly}
<Button <Button
size="sm" size="sm"
variant="secondary" variant="secondary"
@@ -459,18 +529,35 @@
> >
{$t.git?.manage_selected} {$t.git?.manage_selected}
</Button> </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"> <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} {$t.git?.bulk_sync}
</Button> </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"> <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} {$t.git?.bulk_commit}
</Button> </Button>
{/if}
<Button size="sm" variant="secondary" onclick={handleBulkPull} disabled={bulkActionRunning} class="border-cyan-200 bg-white text-cyan-700 hover:bg-cyan-50"> <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} {$t.git?.bulk_pull}
</Button> </Button>
<Button size="sm" variant="secondary" onclick={handleBulkPush} disabled={bulkActionRunning} class="border-indigo-200 bg-white text-indigo-700 hover:bg-indigo-50"> <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} {$t.git?.bulk_push}
</Button> </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"> <span class="ml-1 text-xs font-medium text-slate-600">
{$t.git?.selected_count.replace( {$t.git?.selected_count.replace(
"{count}", "{count}",
@@ -545,10 +632,15 @@
class="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" class="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/> />
</td> </td>
<td <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900" <button
>{dashboard.title}</td 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" <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"
>{new Date(dashboard.last_modified).toLocaleDateString()}</td >{new Date(dashboard.last_modified).toLocaleDateString()}</td
> >
@@ -603,6 +695,7 @@
{#if showGitManager && gitDashboardId} {#if showGitManager && gitDashboardId}
<GitManager <GitManager
dashboardId={gitDashboardId} dashboardId={gitDashboardId}
envId={envId}
dashboardTitle={gitDashboardTitle} dashboardTitle={gitDashboardTitle}
bind:show={showGitManager} bind:show={showGitManager}
/> />

View File

@@ -25,7 +25,7 @@
dashboardId, dashboardId,
envId = null, envId = null,
dashboardTitle = '', dashboardTitle = '',
show = false, show = $bindable(false),
} = $props(); } = $props();
// [/SECTION] // [/SECTION]
@@ -59,6 +59,8 @@
let workspaceLoading = $state(false); let workspaceLoading = $state(false);
let isPulling = $state(false); let isPulling = $state(false);
let isPushing = $state(false); let isPushing = $state(false);
let autoPushAfterCommit = $state(true);
let repositoryProvider = $state('');
// [/SECTION] // [/SECTION]
const hasWorkspaceChanges = $derived.by(() => { const hasWorkspaceChanges = $derived.by(() => {
@@ -277,7 +279,12 @@
committing = true; committing = true;
try { try {
await gitService.commit(dashboardId, commitMessage, [], envId); await gitService.commit(dashboardId, commitMessage, [], envId);
if (autoPushAfterCommit) {
await gitService.push(dashboardId, envId);
toast($t.git?.commit_and_push_success || 'Коммит создан и отправлен в remote', 'success');
} else {
toast($t.git?.commit_success || 'Коммит успешно создан', 'success'); toast($t.git?.commit_success || 'Коммит успешно создан', 'success');
}
commitMessage = ''; commitMessage = '';
await loadWorkspace(); await loadWorkspace();
} catch (e) { } catch (e) {
@@ -420,6 +427,20 @@
} }
// [/DEF:resolveDefaultConfig:Function] // [/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] // [DEF:buildSuggestedRepoName:Function]
/** /**
* @purpose Build deterministic repository name from dashboard title/id. * @purpose Build deterministic repository name from dashboard title/id.
@@ -490,6 +511,8 @@
await gitService.initRepository(dashboardId, selectedConfigId, remoteUrl, envId); await gitService.initRepository(dashboardId, selectedConfigId, remoteUrl, envId);
toast($t.git?.init_success || 'Репозиторий инициализирован', 'success'); toast($t.git?.init_success || 'Репозиторий инициализирован', 'success');
initialized = true; initialized = true;
const selectedConfig = getSelectedConfig();
repositoryProvider = selectedConfig?.provider || repositoryProvider;
await loadWorkspace(); await loadWorkspace();
} catch (e) { } catch (e) {
toast(e.message, 'error'); toast(e.message, 'error');
@@ -522,7 +545,25 @@
// [/DEF:handleBackdropClick:Function] // [/DEF:handleBackdropClick:Function]
onMount(async () => { 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()]); 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> </script>
@@ -676,6 +717,10 @@
> >
Зафиксировать (Commit) Зафиксировать (Commit)
</Button> </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>
<div class="flex min-h-[420px] flex-col overflow-hidden rounded-lg border border-slate-200 bg-slate-50"> <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; if (!envId) return;
fetchingDashboards = true; fetchingDashboards = true;
try { 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) { } catch (e) {
toast(e.message, 'error'); toast(e.message, 'error');
dashboards = []; dashboards = [];

View File

@@ -21,6 +21,7 @@
import RepositoryDashboardGrid from '../../../components/RepositoryDashboardGrid.svelte'; import RepositoryDashboardGrid from '../../../components/RepositoryDashboardGrid.svelte';
import { addToast as toast } from '$lib/toasts.js'; import { addToast as toast } from '$lib/toasts.js';
import { api } from '$lib/api.js'; import { api } from '$lib/api.js';
import { gitService } from '../../../services/gitService.js';
import type { DashboardMetadata } from '$lib/types/dashboard'; import type { DashboardMetadata } from '$lib/types/dashboard';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { Button, Card, PageHeader, Select } from '$lib/ui'; import { Button, Card, PageHeader, Select } from '$lib/ui';
@@ -65,7 +66,24 @@
if (!envId) return; if (!envId) return;
fetchingDashboards = true; fetchingDashboards = true;
try { 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) { } catch (e) {
toast(e.message, 'error'); toast(e.message, 'error');
dashboards = []; dashboards = [];
@@ -75,6 +93,46 @@
} }
// [/DEF:fetchDashboards:Function] // [/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); onMount(fetchEnvironments);
$: environments = $environmentContextStore?.environments || []; $: 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 class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div> </div>
{:else} {:else}
<Card title={$t.git?.select_dashboard }> <Card title={$t.nav?.repositories || "Репозитории"}>
{#if fetchingDashboards} {#if fetchingDashboards}
<p class="text-gray-500">{$t.common?.loading }</p> <p class="text-gray-500">{$t.common?.loading }</p>
{:else if dashboards.length > 0} {:else if dashboards.length > 0}
<RepositoryDashboardGrid {dashboards} statusMode="repository" /> <RepositoryDashboardGrid {dashboards} statusMode="repository" envId={selectedEnvId || null} repositoriesOnly={true} />
{:else} {: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} {/if}
</Card> </Card>
{/if} {/if}

View File

@@ -32,12 +32,35 @@
let isLoading = false; let isLoading = false;
let currentPath = ''; let currentPath = '';
let uploadCategory = 'backups'; 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() { async function loadFiles() {
console.log('[STORAGE-PAGE][LOAD_START] path=%s', currentPath); console.log('[STORAGE-PAGE][LOAD_START] path=%s', currentPath);
isLoading = true; isLoading = true;
try { 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); console.log('[STORAGE-PAGE][LOAD_OK] count=%d', files.length);
} catch (error) { } catch (error) {
console.log('[STORAGE-PAGE][LOAD_ERR] error=%s', error.message); console.log('[STORAGE-PAGE][LOAD_ERR] error=%s', error.message);
@@ -108,8 +131,11 @@
* @post uploadCategory is either backups or repositorys. * @post uploadCategory is either backups or repositorys.
*/ */
function updateUploadCategory() { function updateUploadCategory() {
const [topLevel] = currentPath.split('/').filter(Boolean); const [topLevel, ...rest] = currentPath.split('/').filter(Boolean);
uploadCategory = topLevel === 'repositorys' ? 'repositorys' : 'backups'; uploadCategory = topLevel === 'repositorys' ? 'repositorys' : 'backups';
uploadSubpath = topLevel === 'repositorys' || topLevel === 'backups'
? rest.join('/')
: '';
} }
// [/DEF:updateUploadCategory:Function] // [/DEF:updateUploadCategory:Function]
@@ -180,7 +206,7 @@
<div class="lg:col-span-1"> <div class="lg:col-span-1">
<FileUpload <FileUpload
category={uploadCategory} category={uploadCategory}
path={currentPath} path={uploadSubpath}
on:uploaded={loadFiles} on:uploaded={loadFiles}
/> />
</div> </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] * [DEF:getBranches:Function]
* @purpose Retrieves the list of branches for a dashboard's repository. * @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'); 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] * [DEF:pull:Function]
* @purpose Pulls changes from the remote repository. * @purpose Pulls changes from the remote repository.

View File

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