Fix git/storage workflows: repos-only page, default dev branch, robust pull/push, and storage path resolution
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user