From 80b28ac3717e78a55cbe6294efbff629eba09e2c Mon Sep 17 00:00:00 2001 From: busya Date: Sun, 1 Mar 2026 13:17:05 +0300 Subject: [PATCH] slug first logic --- backend/git_repos/10 | 1 + backend/src/api/routes/dashboards.py | 100 ++- backend/src/api/routes/git.py | 371 ++++++-- backend/src/api/routes/git_schemas.py | 49 ++ backend/src/core/superset_client.py | 6 + backend/src/services/git_service.py | 277 +++++- frontend/src/components/DashboardGrid.svelte | 4 +- .../components/RepositoryDashboardGrid.svelte | 4 +- .../src/components/git/BranchSelector.svelte | 7 +- .../src/components/git/CommitHistory.svelte | 3 +- .../src/components/git/CommitModal.svelte | 10 +- .../src/components/git/DeploymentModal.svelte | 4 +- frontend/src/components/git/GitManager.svelte | 149 +++- frontend/src/lib/api.js | 10 +- .../lib/components/layout/TopNavbar.svelte | 2 +- frontend/src/routes/dashboards/+page.svelte | 8 +- .../src/routes/dashboards/[id]/+page.svelte | 799 ++++++++++++------ .../src/routes/datasets/[id]/+page.svelte | 8 +- frontend/src/routes/settings/git/+page.svelte | 164 +++- frontend/src/services/gitService.js | 142 +++- 20 files changed, 1739 insertions(+), 379 deletions(-) create mode 160000 backend/git_repos/10 diff --git a/backend/git_repos/10 b/backend/git_repos/10 new file mode 160000 index 0000000..5f04cdd --- /dev/null +++ b/backend/git_repos/10 @@ -0,0 +1 @@ +Subproject commit 5f04cdd3bcf6d6b49c870503361bbc80a04674f7 diff --git a/backend/src/api/routes/dashboards.py b/backend/src/api/routes/dashboards.py index 598ac56..2714dec 100644 --- a/backend/src/api/routes/dashboards.py +++ b/backend/src/api/routes/dashboards.py @@ -155,6 +155,57 @@ class DatabaseMappingsResponse(BaseModel): mappings: List[DatabaseMapping] # [/DEF:DatabaseMappingsResponse:DataClass] + +# [DEF:_find_dashboard_id_by_slug:Function] +# @PURPOSE: Resolve dashboard numeric ID by slug using Superset list endpoint. +# @PRE: `dashboard_slug` is non-empty. +# @POST: Returns dashboard ID when found, otherwise None. +def _find_dashboard_id_by_slug( + client: SupersetClient, + dashboard_slug: str, +) -> Optional[int]: + query_variants = [ + {"filters": [{"col": "slug", "opr": "eq", "value": dashboard_slug}], "page": 0, "page_size": 1}, + {"filters": [{"col": "slug", "op": "eq", "value": dashboard_slug}], "page": 0, "page_size": 1}, + ] + + for query in query_variants: + try: + _count, dashboards = client.get_dashboards_page(query=query) + if dashboards: + resolved_id = dashboards[0].get("id") + if resolved_id is not None: + return int(resolved_id) + except Exception: + continue + + return None +# [/DEF:_find_dashboard_id_by_slug:Function] + + +# [DEF:_resolve_dashboard_id_from_ref:Function] +# @PURPOSE: Resolve dashboard ID from slug-first reference with numeric fallback. +# @PRE: `dashboard_ref` is provided in route path. +# @POST: Returns a valid dashboard ID or raises HTTPException(404). +def _resolve_dashboard_id_from_ref( + dashboard_ref: str, + client: SupersetClient, +) -> int: + normalized_ref = str(dashboard_ref or "").strip() + if not normalized_ref: + raise HTTPException(status_code=404, detail="Dashboard not found") + + # Slug-first: even if ref looks numeric, try slug first. + slug_match_id = _find_dashboard_id_by_slug(client, normalized_ref) + if slug_match_id is not None: + return slug_match_id + + if normalized_ref.isdigit(): + return int(normalized_ref) + + raise HTTPException(status_code=404, detail="Dashboard not found") +# [/DEF:_resolve_dashboard_id_from_ref:Function] + # [DEF:get_dashboards:Function] # @PURPOSE: Fetch list of dashboards from a specific environment with Git status and last task status # @PRE: env_id must be a valid environment ID @@ -331,17 +382,17 @@ async def get_database_mappings( # [DEF:get_dashboard_detail:Function] # @PURPOSE: Fetch detailed dashboard info with related charts and datasets -# @PRE: env_id must be valid and dashboard_id must exist +# @PRE: env_id must be valid and dashboard ref (slug or id) must exist # @POST: Returns dashboard detail payload for overview page # @RELATION: CALLS -> SupersetClient.get_dashboard_detail -@router.get("/{dashboard_id:int}", response_model=DashboardDetailResponse) +@router.get("/{dashboard_ref}", response_model=DashboardDetailResponse) async def get_dashboard_detail( - dashboard_id: int, + dashboard_ref: str, env_id: str, config_manager=Depends(get_config_manager), _ = Depends(has_permission("plugin:migration", "READ")) ): - with belief_scope("get_dashboard_detail", f"dashboard_id={dashboard_id}, env_id={env_id}"): + with belief_scope("get_dashboard_detail", f"dashboard_ref={dashboard_ref}, env_id={env_id}"): environments = config_manager.get_environments() env = next((e for e in environments if e.id == env_id), None) if not env: @@ -350,9 +401,10 @@ async def get_dashboard_detail( try: client = SupersetClient(env) + dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, client) detail = client.get_dashboard_detail(dashboard_id) logger.info( - f"[get_dashboard_detail][Coherence:OK] Dashboard {dashboard_id}: {detail.get('chart_count', 0)} charts, {detail.get('dataset_count', 0)} datasets" + f"[get_dashboard_detail][Coherence:OK] Dashboard ref={dashboard_ref} resolved_id={dashboard_id}: {detail.get('chart_count', 0)} charts, {detail.get('dataset_count', 0)} datasets" ) return DashboardDetailResponse(**detail) except HTTPException: @@ -398,17 +450,38 @@ def _task_matches_dashboard(task: Any, dashboard_id: int, env_id: Optional[str]) # [DEF:get_dashboard_tasks_history:Function] # @PURPOSE: Returns history of backup and LLM validation tasks for a dashboard. -# @PRE: dashboard_id is valid integer. +# @PRE: dashboard ref (slug or id) is valid. # @POST: Response contains sorted task history (newest first). -@router.get("/{dashboard_id:int}/tasks", response_model=DashboardTaskHistoryResponse) +@router.get("/{dashboard_ref}/tasks", response_model=DashboardTaskHistoryResponse) async def get_dashboard_tasks_history( - dashboard_id: int, + dashboard_ref: str, env_id: Optional[str] = None, limit: int = Query(20, ge=1, le=100), + config_manager=Depends(get_config_manager), task_manager=Depends(get_task_manager), _ = Depends(has_permission("tasks", "READ")) ): - with belief_scope("get_dashboard_tasks_history", f"dashboard_id={dashboard_id}, env_id={env_id}, limit={limit}"): + with belief_scope("get_dashboard_tasks_history", f"dashboard_ref={dashboard_ref}, env_id={env_id}, limit={limit}"): + dashboard_id: Optional[int] = None + if dashboard_ref.isdigit(): + dashboard_id = int(dashboard_ref) + elif env_id: + environments = config_manager.get_environments() + env = next((e for e in environments if e.id == env_id), None) + if not env: + logger.error(f"[get_dashboard_tasks_history][Coherence:Failed] Environment not found: {env_id}") + raise HTTPException(status_code=404, detail="Environment not found") + client = SupersetClient(env) + dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, client) + else: + logger.error( + "[get_dashboard_tasks_history][Coherence:Failed] Non-numeric dashboard ref requires env_id" + ) + raise HTTPException( + status_code=400, + detail="env_id is required when dashboard reference is a slug", + ) + matching_tasks = [] for task in task_manager.get_all_tasks(): if _task_matches_dashboard(task, dashboard_id, env_id): @@ -451,7 +524,7 @@ async def get_dashboard_tasks_history( ) ) - logger.info(f"[get_dashboard_tasks_history][Coherence:OK] Found {len(items)} tasks for dashboard {dashboard_id}") + logger.info(f"[get_dashboard_tasks_history][Coherence:OK] Found {len(items)} tasks for dashboard_ref={dashboard_ref}, dashboard_id={dashboard_id}") return DashboardTaskHistoryResponse(dashboard_id=dashboard_id, items=items) # [/DEF:get_dashboard_tasks_history:Function] @@ -460,15 +533,15 @@ async def get_dashboard_tasks_history( # @PURPOSE: Proxies Superset dashboard thumbnail with cache support. # @PRE: env_id must exist. # @POST: Returns image bytes or 202 when thumbnail is being prepared by Superset. -@router.get("/{dashboard_id:int}/thumbnail") +@router.get("/{dashboard_ref}/thumbnail") async def get_dashboard_thumbnail( - dashboard_id: int, + dashboard_ref: str, env_id: str, force: bool = Query(False), config_manager=Depends(get_config_manager), _ = Depends(has_permission("plugin:migration", "READ")) ): - with belief_scope("get_dashboard_thumbnail", f"dashboard_id={dashboard_id}, env_id={env_id}, force={force}"): + with belief_scope("get_dashboard_thumbnail", f"dashboard_ref={dashboard_ref}, env_id={env_id}, force={force}"): environments = config_manager.get_environments() env = next((e for e in environments if e.id == env_id), None) if not env: @@ -477,6 +550,7 @@ async def get_dashboard_thumbnail( try: client = SupersetClient(env) + dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, client) digest = None thumb_endpoint = None diff --git a/backend/src/api/routes/git.py b/backend/src/api/routes/git.py index f8a537d..bed09cd 100644 --- a/backend/src/api/routes/git.py +++ b/backend/src/api/routes/git.py @@ -17,15 +17,18 @@ import typing import os from src.dependencies import get_config_manager, has_permission from src.core.database import get_db -from src.models.git import GitServerConfig, GitRepository +from src.models.git import GitServerConfig, GitRepository, GitProvider from src.api.routes.git_schemas import ( GitServerConfigSchema, GitServerConfigCreate, BranchSchema, BranchCreate, BranchCheckout, CommitSchema, CommitCreate, DeploymentEnvironmentSchema, DeployRequest, RepoInitRequest, RepoStatusBatchRequest, RepoStatusBatchResponse, + GiteaRepoCreateRequest, GiteaRepoSchema, + RemoteRepoCreateRequest, RemoteRepoSchema, ) from src.services.git_service import GitService +from src.core.superset_client import SupersetClient from src.core.logger import logger, belief_scope from ...services.llm_prompt_templates import ( DEFAULT_LLM_PROMPTS, @@ -99,6 +102,78 @@ def _resolve_repository_status(dashboard_id: int) -> dict: raise # [/DEF:_resolve_repository_status:Function] + +# [DEF:_get_git_config_or_404:Function] +# @PURPOSE: Resolve GitServerConfig by id or raise 404. +# @PRE: db session is available. +# @POST: Returns GitServerConfig model. +def _get_git_config_or_404(db: Session, config_id: str) -> GitServerConfig: + config = db.query(GitServerConfig).filter(GitServerConfig.id == config_id).first() + if not config: + raise HTTPException(status_code=404, detail="Git configuration not found") + return config +# [/DEF:_get_git_config_or_404:Function] + + +# [DEF:_find_dashboard_id_by_slug:Function] +# @PURPOSE: Resolve dashboard numeric ID by slug in a specific environment. +# @PRE: dashboard_slug is non-empty. +# @POST: Returns dashboard ID or None when not found. +def _find_dashboard_id_by_slug( + client: SupersetClient, + dashboard_slug: str, +) -> Optional[int]: + query_variants = [ + {"filters": [{"col": "slug", "opr": "eq", "value": dashboard_slug}], "page": 0, "page_size": 1}, + {"filters": [{"col": "slug", "op": "eq", "value": dashboard_slug}], "page": 0, "page_size": 1}, + ] + + for query in query_variants: + try: + _count, dashboards = client.get_dashboards_page(query=query) + if dashboards: + resolved_id = dashboards[0].get("id") + if resolved_id is not None: + return int(resolved_id) + except Exception: + continue + return None +# [/DEF:_find_dashboard_id_by_slug:Function] + + +# [DEF:_resolve_dashboard_id_from_ref:Function] +# @PURPOSE: Resolve dashboard ID from slug-or-id reference for Git routes. +# @PRE: dashboard_ref is provided; env_id is required for slug values. +# @POST: Returns numeric dashboard ID or raises HTTPException. +def _resolve_dashboard_id_from_ref( + dashboard_ref: str, + config_manager, + env_id: Optional[str] = None, +) -> int: + normalized_ref = str(dashboard_ref or "").strip() + if not normalized_ref: + raise HTTPException(status_code=400, detail="dashboard_ref is required") + + if normalized_ref.isdigit(): + return int(normalized_ref) + + if not env_id: + raise HTTPException( + status_code=400, + detail="env_id is required for slug-based Git operations", + ) + + environments = config_manager.get_environments() + env = next((e for e in environments if e.id == env_id), None) + if not env: + raise HTTPException(status_code=404, detail="Environment not found") + + dashboard_id = _find_dashboard_id_by_slug(SupersetClient(env), normalized_ref) + if dashboard_id is None: + raise HTTPException(status_code=404, detail=f"Dashboard slug '{normalized_ref}' not found") + return dashboard_id +# [/DEF:_resolve_dashboard_id_from_ref:Function] + # [DEF:get_git_configs:Function] # @PURPOSE: List all configured Git servers. # @PRE: Database session `db` is available. @@ -172,20 +247,175 @@ async def test_git_config( raise HTTPException(status_code=400, detail="Connection failed") # [/DEF:test_git_config:Function] + +# [DEF:list_gitea_repositories:Function] +# @PURPOSE: List repositories in Gitea for a saved Gitea config. +# @PRE: config_id exists and provider is GITEA. +# @POST: Returns repositories visible to PAT user. +@router.get("/config/{config_id}/gitea/repos", response_model=List[GiteaRepoSchema]) +async def list_gitea_repositories( + config_id: str, + db: Session = Depends(get_db), + _ = Depends(has_permission("admin:settings", "READ")) +): + with belief_scope("list_gitea_repositories"): + config = _get_git_config_or_404(db, config_id) + if config.provider != GitProvider.GITEA: + raise HTTPException(status_code=400, detail="This endpoint supports GITEA provider only") + repos = await git_service.list_gitea_repositories(config.url, config.pat) + return [ + GiteaRepoSchema( + name=repo.get("name", ""), + full_name=repo.get("full_name", ""), + private=bool(repo.get("private", False)), + clone_url=repo.get("clone_url"), + html_url=repo.get("html_url"), + ssh_url=repo.get("ssh_url"), + default_branch=repo.get("default_branch"), + ) + for repo in repos + ] +# [/DEF:list_gitea_repositories:Function] + + +# [DEF:create_gitea_repository:Function] +# @PURPOSE: Create a repository in Gitea for a saved Gitea config. +# @PRE: config_id exists and provider is GITEA. +# @POST: Returns created repository payload. +@router.post("/config/{config_id}/gitea/repos", response_model=GiteaRepoSchema) +async def create_gitea_repository( + config_id: str, + request: GiteaRepoCreateRequest, + db: Session = Depends(get_db), + _ = Depends(has_permission("admin:settings", "WRITE")) +): + with belief_scope("create_gitea_repository"): + config = _get_git_config_or_404(db, config_id) + if config.provider != GitProvider.GITEA: + raise HTTPException(status_code=400, detail="This endpoint supports GITEA provider only") + repo = await git_service.create_gitea_repository( + server_url=config.url, + pat=config.pat, + name=request.name, + private=request.private, + description=request.description, + auto_init=request.auto_init, + default_branch=request.default_branch, + ) + return GiteaRepoSchema( + name=repo.get("name", ""), + full_name=repo.get("full_name", ""), + private=bool(repo.get("private", False)), + clone_url=repo.get("clone_url"), + html_url=repo.get("html_url"), + ssh_url=repo.get("ssh_url"), + default_branch=repo.get("default_branch"), + ) +# [/DEF:create_gitea_repository:Function] + + +# [DEF:create_remote_repository:Function] +# @PURPOSE: Create repository on remote Git server using selected provider config. +# @PRE: config_id exists and PAT has creation permissions. +# @POST: Returns normalized remote repository payload. +@router.post("/config/{config_id}/repositories", response_model=RemoteRepoSchema) +async def create_remote_repository( + config_id: str, + request: RemoteRepoCreateRequest, + db: Session = Depends(get_db), + _ = Depends(has_permission("admin:settings", "WRITE")) +): + with belief_scope("create_remote_repository"): + config = _get_git_config_or_404(db, config_id) + + if config.provider == GitProvider.GITEA: + repo = await git_service.create_gitea_repository( + server_url=config.url, + pat=config.pat, + name=request.name, + private=request.private, + description=request.description, + auto_init=request.auto_init, + default_branch=request.default_branch, + ) + elif config.provider == GitProvider.GITHUB: + repo = await git_service.create_github_repository( + server_url=config.url, + pat=config.pat, + name=request.name, + private=request.private, + description=request.description, + auto_init=request.auto_init, + default_branch=request.default_branch, + ) + elif config.provider == GitProvider.GITLAB: + repo = await git_service.create_gitlab_repository( + server_url=config.url, + pat=config.pat, + name=request.name, + private=request.private, + description=request.description, + auto_init=request.auto_init, + default_branch=request.default_branch, + ) + else: + raise HTTPException(status_code=501, detail=f"Provider {config.provider} is not supported") + + return RemoteRepoSchema( + provider=config.provider, + name=repo.get("name", ""), + full_name=repo.get("full_name", repo.get("name", "")), + private=bool(repo.get("private", False)), + clone_url=repo.get("clone_url"), + html_url=repo.get("html_url"), + ssh_url=repo.get("ssh_url"), + default_branch=repo.get("default_branch"), + ) +# [/DEF:create_remote_repository:Function] + + +# [DEF:delete_gitea_repository:Function] +# @PURPOSE: Delete repository in Gitea for a saved Gitea config. +# @PRE: config_id exists and provider is GITEA. +# @POST: Target repository is deleted on Gitea. +@router.delete("/config/{config_id}/gitea/repos/{owner}/{repo_name}") +async def delete_gitea_repository( + config_id: str, + owner: str, + repo_name: str, + db: Session = Depends(get_db), + _ = Depends(has_permission("admin:settings", "WRITE")) +): + with belief_scope("delete_gitea_repository"): + config = _get_git_config_or_404(db, config_id) + if config.provider != GitProvider.GITEA: + raise HTTPException(status_code=400, detail="This endpoint supports GITEA provider only") + await git_service.delete_gitea_repository( + server_url=config.url, + pat=config.pat, + owner=owner, + repo_name=repo_name, + ) + return {"status": "success", "message": "Repository deleted"} +# [/DEF:delete_gitea_repository:Function] + # [DEF:init_repository:Function] # @PURPOSE: Link a dashboard to a Git repository and perform initial clone/init. -# @PRE: `dashboard_id` exists and `init_data` contains valid config_id and remote_url. +# @PRE: `dashboard_ref` exists and `init_data` contains valid config_id and remote_url. # @POST: Repository is initialized on disk and a GitRepository record is saved in DB. -# @PARAM: dashboard_id (int) +# @PARAM: dashboard_ref (str) # @PARAM: init_data (RepoInitRequest) -@router.post("/repositories/{dashboard_id}/init") +@router.post("/repositories/{dashboard_ref}/init") async def init_repository( - dashboard_id: int, + dashboard_ref: str, init_data: RepoInitRequest, + 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("init_repository"): + dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id) # 1. Get config config = db.query(GitServerConfig).filter(GitServerConfig.id == init_data.config_id).first() if not config: @@ -225,17 +455,20 @@ async def init_repository( # [DEF:get_branches:Function] # @PURPOSE: List all branches for a dashboard's repository. -# @PRE: Repository for `dashboard_id` is initialized. +# @PRE: Repository for `dashboard_ref` is initialized. # @POST: Returns a list of branches from the local repository. -# @PARAM: dashboard_id (int) +# @PARAM: dashboard_ref (str) # @RETURN: List[BranchSchema] -@router.get("/repositories/{dashboard_id}/branches", response_model=List[BranchSchema]) +@router.get("/repositories/{dashboard_ref}/branches", response_model=List[BranchSchema]) async def get_branches( - dashboard_id: int, + dashboard_ref: str, + env_id: Optional[str] = None, + config_manager=Depends(get_config_manager), _ = Depends(has_permission("plugin:git", "EXECUTE")) ): with belief_scope("get_branches"): try: + dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id) return git_service.list_branches(dashboard_id) except HTTPException: raise @@ -245,18 +478,21 @@ async def get_branches( # [DEF:create_branch:Function] # @PURPOSE: Create a new branch in the dashboard's repository. -# @PRE: `dashboard_id` repository exists and `branch_data` has name and from_branch. +# @PRE: `dashboard_ref` repository exists and `branch_data` has name and from_branch. # @POST: A new branch is created in the local repository. -# @PARAM: dashboard_id (int) +# @PARAM: dashboard_ref (str) # @PARAM: branch_data (BranchCreate) -@router.post("/repositories/{dashboard_id}/branches") +@router.post("/repositories/{dashboard_ref}/branches") async def create_branch( - dashboard_id: int, + dashboard_ref: str, branch_data: BranchCreate, + env_id: Optional[str] = None, + config_manager=Depends(get_config_manager), _ = Depends(has_permission("plugin:git", "EXECUTE")) ): with belief_scope("create_branch"): try: + dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id) git_service.create_branch(dashboard_id, branch_data.name, branch_data.from_branch) return {"status": "success"} except HTTPException: @@ -267,18 +503,21 @@ async def create_branch( # [DEF:checkout_branch:Function] # @PURPOSE: Switch the dashboard's repository to a specific branch. -# @PRE: `dashboard_id` repository exists and branch `checkout_data.name` exists. +# @PRE: `dashboard_ref` repository exists and branch `checkout_data.name` exists. # @POST: The local repository HEAD is moved to the specified branch. -# @PARAM: dashboard_id (int) +# @PARAM: dashboard_ref (str) # @PARAM: checkout_data (BranchCheckout) -@router.post("/repositories/{dashboard_id}/checkout") +@router.post("/repositories/{dashboard_ref}/checkout") async def checkout_branch( - dashboard_id: int, + dashboard_ref: str, checkout_data: BranchCheckout, + env_id: Optional[str] = None, + config_manager=Depends(get_config_manager), _ = Depends(has_permission("plugin:git", "EXECUTE")) ): with belief_scope("checkout_branch"): try: + dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id) git_service.checkout_branch(dashboard_id, checkout_data.name) return {"status": "success"} except HTTPException: @@ -289,18 +528,21 @@ async def checkout_branch( # [DEF:commit_changes:Function] # @PURPOSE: Stage and commit changes in the dashboard's repository. -# @PRE: `dashboard_id` repository exists and `commit_data` has message and files. +# @PRE: `dashboard_ref` repository exists and `commit_data` has message and files. # @POST: Specified files are staged and a new commit is created. -# @PARAM: dashboard_id (int) +# @PARAM: dashboard_ref (str) # @PARAM: commit_data (CommitCreate) -@router.post("/repositories/{dashboard_id}/commit") +@router.post("/repositories/{dashboard_ref}/commit") async def commit_changes( - dashboard_id: int, + dashboard_ref: str, commit_data: CommitCreate, + env_id: Optional[str] = None, + config_manager=Depends(get_config_manager), _ = Depends(has_permission("plugin:git", "EXECUTE")) ): with belief_scope("commit_changes"): try: + dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id) git_service.commit_changes(dashboard_id, commit_data.message, commit_data.files) return {"status": "success"} except HTTPException: @@ -311,16 +553,19 @@ async def commit_changes( # [DEF:push_changes:Function] # @PURPOSE: Push local commits to the remote repository. -# @PRE: `dashboard_id` repository exists and has a remote configured. +# @PRE: `dashboard_ref` repository exists and has a remote configured. # @POST: Local commits are pushed to the remote repository. -# @PARAM: dashboard_id (int) -@router.post("/repositories/{dashboard_id}/push") +# @PARAM: dashboard_ref (str) +@router.post("/repositories/{dashboard_ref}/push") async def push_changes( - dashboard_id: int, + dashboard_ref: str, + env_id: Optional[str] = None, + config_manager=Depends(get_config_manager), _ = Depends(has_permission("plugin:git", "EXECUTE")) ): with belief_scope("push_changes"): try: + dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id) git_service.push_changes(dashboard_id) return {"status": "success"} except HTTPException: @@ -331,16 +576,19 @@ async def push_changes( # [DEF:pull_changes:Function] # @PURPOSE: Pull changes from the remote repository. -# @PRE: `dashboard_id` repository exists and has a remote configured. +# @PRE: `dashboard_ref` repository exists and has a remote configured. # @POST: Remote changes are fetched and merged into the local branch. -# @PARAM: dashboard_id (int) -@router.post("/repositories/{dashboard_id}/pull") +# @PARAM: dashboard_ref (str) +@router.post("/repositories/{dashboard_ref}/pull") async def pull_changes( - dashboard_id: int, + dashboard_ref: str, + env_id: Optional[str] = None, + config_manager=Depends(get_config_manager), _ = Depends(has_permission("plugin:git", "EXECUTE")) ): with belief_scope("pull_changes"): try: + dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id) git_service.pull_changes(dashboard_id) return {"status": "success"} except HTTPException: @@ -351,18 +599,21 @@ async def pull_changes( # [DEF:sync_dashboard:Function] # @PURPOSE: Sync dashboard state from Superset to Git using the GitPlugin. -# @PRE: `dashboard_id` is valid; GitPlugin is available. +# @PRE: `dashboard_ref` is valid; GitPlugin is available. # @POST: Dashboard YAMLs are exported from Superset and committed to Git. -# @PARAM: dashboard_id (int) +# @PARAM: dashboard_ref (str) # @PARAM: source_env_id (Optional[str]) -@router.post("/repositories/{dashboard_id}/sync") +@router.post("/repositories/{dashboard_ref}/sync") async def sync_dashboard( - dashboard_id: int, + dashboard_ref: str, + env_id: Optional[str] = None, source_env_id: typing.Optional[str] = None, + config_manager=Depends(get_config_manager), _ = Depends(has_permission("plugin:git", "EXECUTE")) ): with belief_scope("sync_dashboard"): try: + dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id) from src.plugins.git_plugin import GitPlugin plugin = GitPlugin() return await plugin.execute({ @@ -400,18 +651,21 @@ async def get_environments( # [DEF:deploy_dashboard:Function] # @PURPOSE: Deploy dashboard from Git to a target environment. -# @PRE: `dashboard_id` and `deploy_data.environment_id` are valid. +# @PRE: `dashboard_ref` and `deploy_data.environment_id` are valid. # @POST: Dashboard YAMLs are read from Git and imported into the target Superset. -# @PARAM: dashboard_id (int) +# @PARAM: dashboard_ref (str) # @PARAM: deploy_data (DeployRequest) -@router.post("/repositories/{dashboard_id}/deploy") +@router.post("/repositories/{dashboard_ref}/deploy") async def deploy_dashboard( - dashboard_id: int, + dashboard_ref: str, deploy_data: DeployRequest, + env_id: Optional[str] = None, + config_manager=Depends(get_config_manager), _ = Depends(has_permission("plugin:git", "EXECUTE")) ): with belief_scope("deploy_dashboard"): try: + dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id) from src.plugins.git_plugin import GitPlugin plugin = GitPlugin() return await plugin.execute({ @@ -427,19 +681,22 @@ async def deploy_dashboard( # [DEF:get_history:Function] # @PURPOSE: View commit history for a dashboard's repository. -# @PRE: `dashboard_id` repository exists. +# @PRE: `dashboard_ref` repository exists. # @POST: Returns a list of recent commits from the repository. -# @PARAM: dashboard_id (int) +# @PARAM: dashboard_ref (str) # @PARAM: limit (int) # @RETURN: List[CommitSchema] -@router.get("/repositories/{dashboard_id}/history", response_model=List[CommitSchema]) +@router.get("/repositories/{dashboard_ref}/history", response_model=List[CommitSchema]) async def get_history( - dashboard_id: int, + dashboard_ref: str, limit: int = 50, + env_id: Optional[str] = None, + config_manager=Depends(get_config_manager), _ = Depends(has_permission("plugin:git", "EXECUTE")) ): with belief_scope("get_history"): try: + dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id) return git_service.get_commit_history(dashboard_id, limit) except HTTPException: raise @@ -449,17 +706,20 @@ async def get_history( # [DEF:get_repository_status:Function] # @PURPOSE: Get current Git status for a dashboard repository. -# @PRE: `dashboard_id` is a valid integer. +# @PRE: `dashboard_ref` resolves to a valid dashboard. # @POST: Returns repository status; if repo is not initialized, returns `NO_REPO` payload. -# @PARAM: dashboard_id (int) +# @PARAM: dashboard_ref (str) # @RETURN: dict -@router.get("/repositories/{dashboard_id}/status") +@router.get("/repositories/{dashboard_ref}/status") async def get_repository_status( - dashboard_id: int, + dashboard_ref: str, + env_id: Optional[str] = None, + config_manager=Depends(get_config_manager), _ = Depends(has_permission("plugin:git", "EXECUTE")) ): with belief_scope("get_repository_status"): try: + dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id) return _resolve_repository_status(dashboard_id) except HTTPException: raise @@ -513,21 +773,24 @@ async def get_repository_status_batch( # [DEF:get_repository_diff:Function] # @PURPOSE: Get Git diff for a dashboard repository. -# @PRE: `dashboard_id` repository exists. +# @PRE: `dashboard_ref` repository exists. # @POST: Returns the diff text for the specified file or all changes. -# @PARAM: dashboard_id (int) +# @PARAM: dashboard_ref (str) # @PARAM: file_path (Optional[str]) # @PARAM: staged (bool) # @RETURN: str -@router.get("/repositories/{dashboard_id}/diff") +@router.get("/repositories/{dashboard_ref}/diff") async def get_repository_diff( - dashboard_id: int, + dashboard_ref: str, file_path: Optional[str] = None, staged: bool = False, + env_id: Optional[str] = None, + config_manager=Depends(get_config_manager), _ = Depends(has_permission("plugin:git", "EXECUTE")) ): with belief_scope("get_repository_diff"): try: + dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id) diff_text = git_service.get_diff(dashboard_id, file_path, staged) return diff_text except HTTPException: @@ -538,17 +801,19 @@ async def get_repository_diff( # [DEF:generate_commit_message:Function] # @PURPOSE: Generate a suggested commit message using LLM. -# @PRE: Repository for `dashboard_id` is initialized. +# @PRE: Repository for `dashboard_ref` is initialized. # @POST: Returns a suggested commit message string. -@router.post("/repositories/{dashboard_id}/generate-message") +@router.post("/repositories/{dashboard_ref}/generate-message") async def generate_commit_message( - dashboard_id: int, - db: Session = Depends(get_db), + 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("generate_commit_message"): try: + dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id) # 1. Get Diff diff = git_service.get_diff(dashboard_id, staged=True) if not diff: diff --git a/backend/src/api/routes/git_schemas.py b/backend/src/api/routes/git_schemas.py index 759fc79..b150017 100644 --- a/backend/src/api/routes/git_schemas.py +++ b/backend/src/api/routes/git_schemas.py @@ -154,4 +154,53 @@ class RepoStatusBatchResponse(BaseModel): statuses: Dict[str, Dict[str, Any]] # [/DEF:RepoStatusBatchResponse:Class] + +# [DEF:GiteaRepoSchema:Class] +# @PURPOSE: Schema describing a Gitea repository. +class GiteaRepoSchema(BaseModel): + name: str + full_name: str + private: bool = False + clone_url: Optional[str] = None + html_url: Optional[str] = None + ssh_url: Optional[str] = None + default_branch: Optional[str] = None +# [/DEF:GiteaRepoSchema:Class] + + +# [DEF:GiteaRepoCreateRequest:Class] +# @PURPOSE: Request schema for creating a Gitea repository. +class GiteaRepoCreateRequest(BaseModel): + name: str = Field(..., min_length=1, max_length=255) + private: bool = True + description: Optional[str] = None + auto_init: bool = True + default_branch: Optional[str] = "main" +# [/DEF:GiteaRepoCreateRequest:Class] + + +# [DEF:RemoteRepoSchema:Class] +# @PURPOSE: Provider-agnostic remote repository payload. +class RemoteRepoSchema(BaseModel): + provider: GitProvider + name: str + full_name: str + private: bool = False + clone_url: Optional[str] = None + html_url: Optional[str] = None + ssh_url: Optional[str] = None + default_branch: Optional[str] = None +# [/DEF:RemoteRepoSchema:Class] + + +# [DEF:RemoteRepoCreateRequest:Class] +# @PURPOSE: Provider-agnostic repository creation request. +class RemoteRepoCreateRequest(BaseModel): + name: str = Field(..., min_length=1, max_length=255) + private: bool = True + description: Optional[str] = None + auto_init: bool = True + default_branch: Optional[str] = "main" +# [/DEF:RemoteRepoCreateRequest:Class] + # [/DEF:backend.src.api.routes.git_schemas:Module] diff --git a/backend/src/core/superset_client.py b/backend/src/core/superset_client.py index f743c44..ed32ab9 100644 --- a/backend/src/core/superset_client.py +++ b/backend/src/core/superset_client.py @@ -90,6 +90,7 @@ class SupersetClient: validated_query['columns'] = [ "slug", "id", + "url", "changed_on_utc", "dashboard_title", "published", @@ -121,6 +122,7 @@ class SupersetClient: validated_query["columns"] = [ "slug", "id", + "url", "changed_on_utc", "dashboard_title", "published", @@ -167,7 +169,9 @@ class SupersetClient: result.append({ "id": dash.get("id"), + "slug": dash.get("slug"), "title": dash.get("dashboard_title"), + "url": dash.get("url"), "last_modified": dash.get("changed_on_utc"), "status": "published" if dash.get("published") else "draft", "created_by": self._extract_user_display( @@ -225,7 +229,9 @@ class SupersetClient: result.append({ "id": dash.get("id"), + "slug": dash.get("slug"), "title": dash.get("dashboard_title"), + "url": dash.get("url"), "last_modified": dash.get("changed_on_utc"), "status": "published" if dash.get("published") else "draft", "created_by": self._extract_user_display( diff --git a/backend/src/services/git_service.py b/backend/src/services/git_service.py index 3b6a3cf..ac24a16 100644 --- a/backend/src/services/git_service.py +++ b/backend/src/services/git_service.py @@ -13,7 +13,7 @@ import os import httpx from git import Repo from fastapi import HTTPException -from typing import List +from typing import Any, Dict, List, Optional from datetime import datetime from src.core.logger import logger, belief_scope from src.models.git import GitProvider @@ -460,5 +460,280 @@ class GitService: return False # [/DEF:test_connection:Function] + # [DEF:_normalize_git_server_url:Function] + # @PURPOSE: Normalize Git server URL for provider API calls. + # @PRE: raw_url is non-empty. + # @POST: Returns URL without trailing slash. + # @RETURN: str + def _normalize_git_server_url(self, raw_url: str) -> str: + normalized = (raw_url or "").strip() + if not normalized: + raise HTTPException(status_code=400, detail="Git server URL is required") + return normalized.rstrip("/") + # [/DEF:_normalize_git_server_url:Function] + + # [DEF:_gitea_headers:Function] + # @PURPOSE: Build Gitea API authorization headers. + # @PRE: pat is provided. + # @POST: Returns headers with token auth. + # @RETURN: Dict[str, str] + def _gitea_headers(self, pat: str) -> Dict[str, str]: + token = (pat or "").strip() + if not token: + raise HTTPException(status_code=400, detail="Git PAT is required for Gitea operations") + return { + "Authorization": f"token {token}", + "Content-Type": "application/json", + "Accept": "application/json", + } + # [/DEF:_gitea_headers:Function] + + # [DEF:_gitea_request:Function] + # @PURPOSE: Execute HTTP request against Gitea API with stable error mapping. + # @PRE: method and endpoint are valid. + # @POST: Returns decoded JSON payload. + # @RETURN: Any + async def _gitea_request( + self, + method: str, + server_url: str, + pat: str, + endpoint: str, + payload: Optional[Dict[str, Any]] = None, + ) -> Any: + base_url = self._normalize_git_server_url(server_url) + url = f"{base_url}/api/v1{endpoint}" + headers = self._gitea_headers(pat) + try: + async with httpx.AsyncClient(timeout=20.0) as client: + response = await client.request( + method=method, + url=url, + headers=headers, + json=payload, + ) + except Exception as e: + logger.error(f"[gitea_request][Coherence:Failed] Network error: {e}") + raise HTTPException(status_code=503, detail=f"Gitea API is unavailable: {str(e)}") + + if response.status_code >= 400: + detail = response.text + try: + parsed = response.json() + detail = parsed.get("message") or parsed.get("error") or detail + except Exception: + pass + logger.error( + f"[gitea_request][Coherence:Failed] method={method} endpoint={endpoint} status={response.status_code} detail={detail}" + ) + raise HTTPException( + status_code=response.status_code, + detail=f"Gitea API error: {detail}", + ) + + if response.status_code == 204: + return None + return response.json() + # [/DEF:_gitea_request:Function] + + # [DEF:get_gitea_current_user:Function] + # @PURPOSE: Resolve current Gitea user for PAT. + # @PRE: server_url and pat are valid. + # @POST: Returns current username. + # @RETURN: str + async def get_gitea_current_user(self, server_url: str, pat: str) -> str: + payload = await self._gitea_request("GET", server_url, pat, "/user") + username = payload.get("login") or payload.get("username") + if not username: + raise HTTPException(status_code=500, detail="Failed to resolve Gitea username") + return str(username) + # [/DEF:get_gitea_current_user:Function] + + # [DEF:list_gitea_repositories:Function] + # @PURPOSE: List repositories visible to authenticated Gitea user. + # @PRE: server_url and pat are valid. + # @POST: Returns repository list from Gitea. + # @RETURN: List[dict] + async def list_gitea_repositories(self, server_url: str, pat: str) -> List[dict]: + payload = await self._gitea_request( + "GET", + server_url, + pat, + "/user/repos?limit=100&page=1", + ) + if not isinstance(payload, list): + return [] + return payload + # [/DEF:list_gitea_repositories:Function] + + # [DEF:create_gitea_repository:Function] + # @PURPOSE: Create repository in Gitea for authenticated user. + # @PRE: name is non-empty and PAT has repo creation permission. + # @POST: Returns created repository payload. + # @RETURN: dict + async def create_gitea_repository( + self, + server_url: str, + pat: str, + name: str, + private: bool = True, + description: Optional[str] = None, + auto_init: bool = True, + default_branch: Optional[str] = "main", + ) -> Dict[str, Any]: + payload = { + "name": name, + "private": bool(private), + "auto_init": bool(auto_init), + } + if description: + payload["description"] = description + if default_branch: + payload["default_branch"] = default_branch + created = await self._gitea_request( + "POST", + server_url, + pat, + "/user/repos", + payload=payload, + ) + if not isinstance(created, dict): + raise HTTPException(status_code=500, detail="Unexpected Gitea response while creating repository") + return created + # [/DEF:create_gitea_repository:Function] + + # [DEF:delete_gitea_repository:Function] + # @PURPOSE: Delete repository in Gitea. + # @PRE: owner and repo_name are non-empty. + # @POST: Repository deleted on Gitea server. + async def delete_gitea_repository( + self, + server_url: str, + pat: str, + owner: str, + repo_name: str, + ) -> None: + if not owner or not repo_name: + raise HTTPException(status_code=400, detail="owner and repo_name are required") + await self._gitea_request( + "DELETE", + server_url, + pat, + f"/repos/{owner}/{repo_name}", + ) + # [/DEF:delete_gitea_repository:Function] + + # [DEF:create_github_repository:Function] + # @PURPOSE: Create repository in GitHub or GitHub Enterprise. + # @PRE: PAT has repository create permission. + # @POST: Returns created repository payload. + # @RETURN: dict + async def create_github_repository( + self, + server_url: str, + pat: str, + name: str, + private: bool = True, + description: Optional[str] = None, + auto_init: bool = True, + default_branch: Optional[str] = "main", + ) -> Dict[str, Any]: + base_url = self._normalize_git_server_url(server_url) + if "github.com" in base_url: + api_url = "https://api.github.com/user/repos" + else: + api_url = f"{base_url}/api/v3/user/repos" + headers = { + "Authorization": f"token {pat.strip()}", + "Content-Type": "application/json", + "Accept": "application/vnd.github+json", + } + payload: Dict[str, Any] = { + "name": name, + "private": bool(private), + "auto_init": bool(auto_init), + } + if description: + payload["description"] = description + # GitHub API does not reliably support setting default branch on create without template/import. + if default_branch: + payload["default_branch"] = default_branch + try: + async with httpx.AsyncClient(timeout=20.0) as client: + response = await client.post(api_url, headers=headers, json=payload) + except Exception as e: + raise HTTPException(status_code=503, detail=f"GitHub API is unavailable: {str(e)}") + + if response.status_code >= 400: + detail = response.text + try: + parsed = response.json() + detail = parsed.get("message") or detail + except Exception: + pass + raise HTTPException(status_code=response.status_code, detail=f"GitHub API error: {detail}") + return response.json() + # [/DEF:create_github_repository:Function] + + # [DEF:create_gitlab_repository:Function] + # @PURPOSE: Create repository(project) in GitLab. + # @PRE: PAT has api scope. + # @POST: Returns created repository payload. + # @RETURN: dict + async def create_gitlab_repository( + self, + server_url: str, + pat: str, + name: str, + private: bool = True, + description: Optional[str] = None, + auto_init: bool = True, + default_branch: Optional[str] = "main", + ) -> Dict[str, Any]: + base_url = self._normalize_git_server_url(server_url) + api_url = f"{base_url}/api/v4/projects" + headers = { + "PRIVATE-TOKEN": pat.strip(), + "Content-Type": "application/json", + "Accept": "application/json", + } + payload: Dict[str, Any] = { + "name": name, + "visibility": "private" if private else "public", + "initialize_with_readme": bool(auto_init), + } + if description: + payload["description"] = description + if default_branch: + payload["default_branch"] = default_branch + try: + async with httpx.AsyncClient(timeout=20.0) as client: + response = await client.post(api_url, headers=headers, json=payload) + except Exception as e: + raise HTTPException(status_code=503, detail=f"GitLab API is unavailable: {str(e)}") + + if response.status_code >= 400: + detail = response.text + try: + parsed = response.json() + if isinstance(parsed, dict): + detail = parsed.get("message") or detail + except Exception: + pass + raise HTTPException(status_code=response.status_code, detail=f"GitLab API error: {detail}") + + data = response.json() + # Normalize clone URL key to keep route response stable. + if "clone_url" not in data: + data["clone_url"] = data.get("http_url_to_repo") + if "html_url" not in data: + data["html_url"] = data.get("web_url") + if "ssh_url" not in data: + data["ssh_url"] = data.get("ssh_url_to_repo") + if "full_name" not in data: + data["full_name"] = data.get("path_with_namespace") or data.get("name") + return data + # [/DEF:create_gitlab_repository:Function] + # [/DEF:GitService:Class] # [/DEF:backend.src.services.git_service:Module] diff --git a/frontend/src/components/DashboardGrid.svelte b/frontend/src/components/DashboardGrid.svelte index f43c534..443d5d7 100644 --- a/frontend/src/components/DashboardGrid.svelte +++ b/frontend/src/components/DashboardGrid.svelte @@ -35,7 +35,7 @@ // [SECTION: UI STATE] let showGitManager = $state(false); - let gitDashboardId: number | null = $state(null); + let gitDashboardId: string | null = $state(null); let gitDashboardTitle = $state(""); let validatingIds: Set = $state(new Set()); // [/SECTION] @@ -178,7 +178,7 @@ * @purpose Opens the Git management modal for a dashboard. */ function openGit(dashboard: DashboardMetadata) { - gitDashboardId = dashboard.id; + gitDashboardId = String(dashboard.slug || dashboard.id); gitDashboardTitle = dashboard.title; showGitManager = true; } diff --git a/frontend/src/components/RepositoryDashboardGrid.svelte b/frontend/src/components/RepositoryDashboardGrid.svelte index 8d70612..962a69a 100644 --- a/frontend/src/components/RepositoryDashboardGrid.svelte +++ b/frontend/src/components/RepositoryDashboardGrid.svelte @@ -35,7 +35,7 @@ // [SECTION: UI STATE] let showGitManager = $state(false); - let gitDashboardId: number | null = $state(null); + let gitDashboardId: string | null = $state(null); let gitDashboardTitle = $state(""); let repositoryStatusByDashboardId = $state>({}); let repositoryStatusRequestId = $state(0); @@ -373,7 +373,7 @@ (dashboard) => dashboard.id === selectedDashboardId, ); - gitDashboardId = selectedDashboardId; + gitDashboardId = String(selectedDashboard?.slug || selectedDashboardId); gitDashboardTitle = selectedDashboard?.title || ""; showGitManager = true; } diff --git a/frontend/src/components/git/BranchSelector.svelte b/frontend/src/components/git/BranchSelector.svelte index 222ab21..07bda48 100644 --- a/frontend/src/components/git/BranchSelector.svelte +++ b/frontend/src/components/git/BranchSelector.svelte @@ -21,6 +21,7 @@ // [SECTION: PROPS] let { dashboardId, + envId = null, currentBranch = 'main', } = $props(); @@ -56,7 +57,7 @@ console.log(`[BranchSelector][Action] Loading branches for dashboard ${dashboardId}`); loading = true; try { - branches = await gitService.getBranches(dashboardId); + branches = await gitService.getBranches(dashboardId, envId); console.log(`[BranchSelector][Coherence:OK] Loaded ${branches.length} branches`); } catch (e) { console.error(`[BranchSelector][Coherence:Failed] ${e.message}`); @@ -87,7 +88,7 @@ async function handleCheckout(branchName) { console.log(`[BranchSelector][Action] Checking out branch ${branchName}`); try { - await gitService.checkoutBranch(dashboardId, branchName); + await gitService.checkoutBranch(dashboardId, branchName, envId); currentBranch = branchName; dispatch('change', { branch: branchName }); toast($t.git?.switched_to?.replace('{branch}', branchName), 'success'); @@ -109,7 +110,7 @@ if (!newBranchName) return; console.log(`[BranchSelector][Action] Creating branch ${newBranchName} from ${currentBranch}`); try { - await gitService.createBranch(dashboardId, newBranchName, currentBranch); + await gitService.createBranch(dashboardId, newBranchName, currentBranch, envId); toast($t.git?.created_branch?.replace('{branch}', newBranchName), 'success'); showCreate = false; newBranchName = ''; diff --git a/frontend/src/components/git/CommitHistory.svelte b/frontend/src/components/git/CommitHistory.svelte index 9f47d6e..2e6808b 100644 --- a/frontend/src/components/git/CommitHistory.svelte +++ b/frontend/src/components/git/CommitHistory.svelte @@ -18,6 +18,7 @@ // [SECTION: PROPS] let { dashboardId, + envId = null, } = $props(); // [/SECTION] @@ -48,7 +49,7 @@ console.log(`[CommitHistory][Action] Loading history for dashboard ${dashboardId}`); loading = true; try { - history = await gitService.getHistory(dashboardId); + history = await gitService.getHistory(dashboardId, 50, envId); console.log(`[CommitHistory][Coherence:OK] Loaded ${history.length} commits`); } catch (e) { console.error(`[CommitHistory][Coherence:Failed] ${e.message}`); diff --git a/frontend/src/components/git/CommitModal.svelte b/frontend/src/components/git/CommitModal.svelte index 5e5cffc..7136b49 100644 --- a/frontend/src/components/git/CommitModal.svelte +++ b/frontend/src/components/git/CommitModal.svelte @@ -20,7 +20,7 @@ // [/SECTION] // [SECTION: PROPS] - let { dashboardId, show = false } = $props(); + let { dashboardId, envId = null, show = false } = $props(); // [/SECTION] @@ -47,7 +47,7 @@ ); // postApi returns the JSON data directly or throws an error const data = await api.postApi( - `/git/repositories/${dashboardId}/generate-message`, + `/git/repositories/${encodeURIComponent(String(dashboardId))}/generate-message${envId ? `?env_id=${encodeURIComponent(String(envId))}` : ""}`, ); message = data.message; toast($t.git?.commit_message_generated, "success"); @@ -72,17 +72,19 @@ console.log( `[CommitModal][Action] Loading status and diff for ${dashboardId}`, ); - status = await gitService.getStatus(dashboardId); + status = await gitService.getStatus(dashboardId, envId); // Fetch both unstaged and staged diffs to show complete picture const unstagedDiff = await gitService.getDiff( dashboardId, null, false, + envId, ); const stagedDiff = await gitService.getDiff( dashboardId, null, true, + envId, ); diff = ""; @@ -114,7 +116,7 @@ ); committing = true; try { - await gitService.commit(dashboardId, message, []); + await gitService.commit(dashboardId, message, [], envId); toast($t.git?.commit_success, "success"); dispatch("commit"); show = false; diff --git a/frontend/src/components/git/DeploymentModal.svelte b/frontend/src/components/git/DeploymentModal.svelte index 79665f1..b2f9a5f 100644 --- a/frontend/src/components/git/DeploymentModal.svelte +++ b/frontend/src/components/git/DeploymentModal.svelte @@ -18,7 +18,7 @@ // [/SECTION] // [SECTION: PROPS] - let { dashboardId, show = false } = $props(); + let { dashboardId, envId = null, show = false } = $props(); // [/SECTION] @@ -75,7 +75,7 @@ console.log(`[DeploymentModal][Action] Deploying to ${selectedEnv}`); deploying = true; try { - const result = await gitService.deploy(dashboardId, selectedEnv); + const result = await gitService.deploy(dashboardId, selectedEnv, envId); toast( result.message || $t.git?.deploy_success, "success", diff --git a/frontend/src/components/git/GitManager.svelte b/frontend/src/components/git/GitManager.svelte index 8188701..ba26de9 100644 --- a/frontend/src/components/git/GitManager.svelte +++ b/frontend/src/components/git/GitManager.svelte @@ -28,6 +28,7 @@ // [SECTION: PROPS] let { dashboardId, + envId = null, dashboardTitle = "", show = false, } = $props(); @@ -49,8 +50,19 @@ let configs = $state([]); let selectedConfigId = $state(""); let remoteUrl = $state(""); + let creatingRemoteRepo = $state(false); // [/SECTION] + // [DEF:isNumericDashboardRef:Function] + /** + * @purpose Checks whether current dashboard reference is numeric ID. + * @post Returns true when dashboardId is digits-only. + */ + function isNumericDashboardRef() { + return /^\d+$/.test(String(dashboardId || "").trim()); + } + // [/DEF:isNumericDashboardRef:Function] + // [DEF:checkStatus:Function] /** * @purpose Проверяет, инициализирован ли репозиторий для данного дашборда. @@ -58,16 +70,23 @@ * @post initialized state is set; configs loaded if not initialized. */ async function checkStatus() { + if (isNumericDashboardRef()) { + checkingStatus = false; + initialized = false; + toast('GitManager requires dashboard slug. Numeric ID is forbidden.', 'error'); + return; + } checkingStatus = true; try { // If we can get branches, it means repo exists - await gitService.getBranches(dashboardId); + await gitService.getBranches(dashboardId, envId); initialized = true; } catch (e) { initialized = false; // Load configs if not initialized configs = await gitService.getConfigs(); - if (configs.length > 0) selectedConfigId = configs[0].id; + const defaultConfig = resolveDefaultConfig(configs); + if (defaultConfig?.id) selectedConfigId = defaultConfig.id; } finally { checkingStatus = false; } @@ -81,13 +100,17 @@ * @post Repository is created on backend; initialized set to true. */ async function handleInit() { + if (isNumericDashboardRef()) { + toast('GitManager requires dashboard slug. Numeric ID is forbidden.', 'error'); + return; + } if (!selectedConfigId || !remoteUrl) { toast($t.git?.init_validation_error, 'error'); return; } loading = true; try { - await gitService.initRepository(dashboardId, selectedConfigId, remoteUrl); + await gitService.initRepository(dashboardId, selectedConfigId, remoteUrl, envId); toast($t.git?.init_success, 'success'); initialized = true; } catch (e) { @@ -98,6 +121,91 @@ } // [/DEF:handleInit:Function] + // [DEF:getSelectedConfig:Function] + /** + * @purpose Returns currently selected Git server config. + * @post Config object or null. + */ + function getSelectedConfig() { + return configs.find((item) => item.id === selectedConfigId) || null; + } + // [/DEF:getSelectedConfig:Function] + + // [DEF:resolveDefaultConfig:Function] + /** + * @purpose Resolves default Git config for current dashboard session. + * @post Returns config by priority: selected -> is_default -> CONNECTED -> first. + */ + function resolveDefaultConfig(configList) { + if (!Array.isArray(configList) || configList.length === 0) return null; + const selected = configList.find((item) => item.id === selectedConfigId); + if (selected) return selected; + const explicitDefault = configList.find((item) => item?.is_default); + if (explicitDefault) return explicitDefault; + const connected = configList.find((item) => item?.status === 'CONNECTED'); + return connected || configList[0]; + } + // [/DEF:resolveDefaultConfig:Function] + + // [DEF:buildSuggestedRepoName:Function] + /** + * @purpose Builds deterministic repository name from dashboard title/id. + * @post Returns kebab-case name. + */ + function buildSuggestedRepoName() { + const source = (dashboardTitle || `dashboard-${dashboardId}`).toLowerCase(); + const slug = source + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48); + return `${slug || 'dashboard'}-${dashboardId}`; + } + // [/DEF:buildSuggestedRepoName:Function] + + // [DEF:handleCreateRemoteRepo:Function] + /** + * @purpose Creates remote repository on selected Git server and fills remote URL. + * @pre selectedConfigId is selected. + * @post remoteUrl is set from created repository clone URL. + */ + async function handleCreateRemoteRepo() { + const config = getSelectedConfig() || resolveDefaultConfig(configs); + if (!config) { + toast($t.git?.init_validation_error || 'Select Git server first', 'error'); + return; + } + if (!selectedConfigId && config.id) selectedConfigId = config.id; + const suggestedName = buildSuggestedRepoName(); + const inputName = prompt( + `Repository name for ${config.provider}:`, + suggestedName, + ); + const repoName = String(inputName || '').trim(); + if (!repoName) return; + + creatingRemoteRepo = true; + try { + const repo = await gitService.createRemoteRepository(config.id, { + name: repoName, + private: true, + description: `Superset dashboard ${dashboardId}: ${dashboardTitle || repoName}`, + auto_init: true, + default_branch: 'main', + }); + const resolvedRemoteUrl = repo?.clone_url || repo?.html_url || ''; + if (!resolvedRemoteUrl) { + throw new Error('Remote repository created, but URL is empty'); + } + remoteUrl = resolvedRemoteUrl; + toast(`Repository created on ${config.provider}`, 'success'); + } catch (e) { + toast(e.message, 'error'); + } finally { + creatingRemoteRepo = false; + } + } + // [/DEF:handleCreateRemoteRepo:Function] + // [DEF:handleSync:Function] /** * @purpose Синхронизирует состояние Superset с локальным Git-репозиторием. @@ -105,11 +213,15 @@ * @post Dashboard YAMLs are exported to Git and staged. */ async function handleSync() { + if (isNumericDashboardRef()) { + toast('GitManager requires dashboard slug. Numeric ID is forbidden.', 'error'); + return; + } loading = true; try { // Try to get selected environment from localStorage (set by EnvSelector) const sourceEnvId = localStorage.getItem('selected_env_id'); - await gitService.sync(dashboardId, sourceEnvId); + await gitService.sync(dashboardId, sourceEnvId, envId); toast($t.git?.sync_success, 'success'); } catch (e) { toast(e.message, 'error'); @@ -126,9 +238,13 @@ * @post Changes are pushed to origin. */ async function handlePush() { + if (isNumericDashboardRef()) { + toast('GitManager requires dashboard slug. Numeric ID is forbidden.', 'error'); + return; + } loading = true; try { - await gitService.push(dashboardId); + await gitService.push(dashboardId, envId); toast($t.git?.push_success, 'success'); } catch (e) { toast(e.message, 'error'); @@ -145,9 +261,13 @@ * @post Local branch is updated with remote changes. */ async function handlePull() { + if (isNumericDashboardRef()) { + toast('GitManager requires dashboard slug. Numeric ID is forbidden.', 'error'); + return; + } loading = true; try { - await gitService.pull(dashboardId); + await gitService.pull(dashboardId, envId); toast($t.git?.pull_success, 'success'); } catch (e) { toast(e.message, 'error'); @@ -240,10 +360,19 @@ bind:value={remoteUrl} placeholder={$t.git?.remote_url_placeholder} /> + -

- {dashboard?.title || $t.dashboard?.overview} -

-

- {$t.common?.id}: {dashboardId}{#if dashboard?.slug} - • {dashboard.slug}{/if} + +

+

+ {dashboard?.title || $t.dashboard?.overview} +

+ {#if hasGitRepository() && gitDashboardRef} +
+ +
+ {/if} +
+ +

+ {$t.common?.id}: {resolvedDashboardId ?? dashboardRef}{#if dashboard?.slug} • {dashboard.slug}{/if} + | + + + Git: {gitMeta.label} + {#if gitStatus?.ahead_count > 0} + ({gitStatus.ahead_count}) + {/if} +

+
+
{:else if dashboard}
-
+
-

+

{$t.dashboard?.api_thumbnail || "Dashboard thumbnail"}

-
-
-

- {$t.tasks?.recent || "Recent tasks"} + +
+
+

+ {$t.git?.management || "Git Repository"}

- {#if isTaskHistoryLoading} + + {#if isGitStatusLoading}
- {#each Array(4) as _} + {#each Array(3) as _}
{/each}
- {:else if taskHistoryError} -
- {taskHistoryError} + {:else if gitStatusError} +
+ {gitStatusError}
- {:else if taskHistory.length === 0} -
- {$t.tasks?.select_task || "No backup/LLM tasks yet"} + {:else if !hasGitRepository()} +
+ {$t.git?.not_linked || "This dashboard is not yet linked to a Git repository."}
{:else} -
- - - - - - - - - - - - - {#each taskHistory as task} - {@const validation = getValidationStatus(task)} - - - - - - - - - {/each} - -
{$t.common?.type || "Type"}{$t.common?.status || "Status"}{$t.tasks?.result || "Check"}{$t.common?.started || "Started"}{$t.common?.finished || "Finished"}{$t.common?.actions || "Actions"}
{toTaskTypeLabel(task.plugin_id)} - - {task.status} - - - - {#if validation.icon} - - {validation.icon} - - {/if} - {validation.label} - - {formatDate(task.started_at)}{formatDate(task.finished_at)} -
- {#if task.plugin_id === "llm_dashboard_validation"} - - {/if} -
-
+
+

+ {#if gitStatus?.sync_state === "CHANGES"} + Superset configuration changes were detected. + {:else} + Superset configuration matches branch {gitStatus?.current_branch || "main"}. + {/if} +

+ +
+ + charts: {countChangedByAnyPath(["/charts/", "charts/"])} + + + datasets: {countChangedByAnyPath(["/datasets/", "datasets/"])} + + + files: {allChangedFiles().length} + +
+ +
+ + +
+ +
+ + +
+ + {#if gitDiffPreview} +
+
{gitDiffPreview}
+
+ {/if}
{/if}
@@ -560,131 +767,247 @@
- {#if dashboard.description} -
-

+
+

-

{dashboard.description}

+ Linked resources + + +
- {/if} -
-
-

- {$t.dashboard?.charts} -

-
- {#if dashboard.charts && dashboard.charts.length > 0} -
- - - - - - - - - - - {#each dashboard.charts as chart} - - - + + + + + {#each dashboard.charts as chart} + + + + + + + {/each} + +
{$t.settings?.type_chart}{$t.nav?.datasets}{$t.dashboard?.overview}{$t.dashboard?.last_modified}
-
{chart.title}
-
- ID: {chart.id}{#if chart.viz_type} - • {chart.viz_type}{/if} -
-
- {#if chart.dataset_id} - + {$t.dashboard?.overview} + + {$t.dashboard?.last_modified} +
+
{chart.title}
+
+ ID: {chart.id}{#if chart.viz_type} • {chart.viz_type}{/if} +
+
+ {#if chart.dataset_id} + + {:else} + - + {/if} + {chart.overview || "-"}{formatDate(chart.last_modified)}
+
+ {:else} +
+ {$t.dashboard?.no_charts} +
+ {/if} +
-
-
-

{$t.nav?.datasets}

+
+
+

{$t.nav?.datasets}

+
+ {#if dashboard.datasets && dashboard.datasets.length > 0} +
+ {#each dashboard.datasets as dataset} + + {/each} +
+ {:else} +
+ {$t.dashboard?.no_datasets} +
+ {/if} +
+ {:else if activeTab === "git-history"} + {#if hasGitRepository() && gitDashboardRef} + + {:else} +
+ {$t.git?.not_linked || "This dashboard is not yet linked to a Git repository."} +
+ {/if} + {:else} +
+
+

+ {$t.tasks?.recent || "Recent tasks"} +

+ +
+ {#if isTaskHistoryLoading} +
+ {#each Array(4) as _} +
+ {/each} +
+ {:else if taskHistoryError} +
+ {taskHistoryError} +
+ {:else if taskHistory.length === 0} +
+ {$t.tasks?.select_task || "No backup/LLM tasks yet"} +
+ {:else} +
+ + + + + + + + + + + + + {#each taskHistory as task} + {@const validation = getValidationStatus(task)} + + + + + + + + + {/each} + +
{$t.common?.type || "Type"}{$t.common?.status || "Status"}{$t.tasks?.result || "Check"}{$t.common?.started || "Started"}{$t.common?.finished || "Finished"}{$t.common?.actions || "Actions"}
{toTaskTypeLabel(task.plugin_id)} + + {task.status} + + + + {#if validation.icon} + + {validation.icon} + + {/if} + {validation.label} + + {formatDate(task.started_at)}{formatDate(task.finished_at)} +
+ {#if task.plugin_id === "llm_dashboard_validation"} + + {/if} +
+
+
+ {/if} +
+ {/if}
- {#if dashboard.datasets && dashboard.datasets.length > 0} -
- {#each dashboard.datasets as dataset} - - {/each} -
- {:else} -
- {$t.dashboard?.no_datasets} -
- {/if}
{/if}
+{#if gitDashboardRef} + +{/if} + +{#if gitDashboardRef} + +{/if} + diff --git a/frontend/src/routes/datasets/[id]/+page.svelte b/frontend/src/routes/datasets/[id]/+page.svelte index fb67e1a..9a5a06a 100644 --- a/frontend/src/routes/datasets/[id]/+page.svelte +++ b/frontend/src/routes/datasets/[id]/+page.svelte @@ -70,8 +70,10 @@ } // Navigate to linked dashboard - function navigateToDashboard(dashboardId) { - goto(`/dashboards/${dashboardId}?env_id=${envId}`); + function navigateToDashboard(dashboard) { + const dashboardRef = dashboard?.slug || dashboard?.id; + if (!dashboardRef) return; + goto(`/dashboards/${encodeURIComponent(String(dashboardRef))}?env_id=${envId}`); } // Navigate back to dataset list @@ -269,7 +271,7 @@ {#each dataset.linked_dashboards as dashboard}
navigateToDashboard(dashboard.id)} + on:click={() => navigateToDashboard(dashboard)} role="button" tabindex="0" > diff --git a/frontend/src/routes/settings/git/+page.svelte b/frontend/src/routes/settings/git/+page.svelte index e732d36..51c520f 100644 --- a/frontend/src/routes/settings/git/+page.svelte +++ b/frontend/src/routes/settings/git/+page.svelte @@ -28,6 +28,18 @@ default_repository: '' }; let testing = false; + let selectedGiteaConfigId = ''; + let giteaRepos = []; + let giteaLoading = false; + let giteaCreating = false; + let giteaDeleting = false; + let newGiteaRepo = { + name: '', + private: true, + description: '', + auto_init: true, + default_branch: 'main' + }; // [/SECTION: STATE] // [DEF:loadConfigs:Function] @@ -101,11 +113,103 @@ await gitService.deleteConfig(id); configs = configs.filter(c => c.id !== id); toast($t.settings?.git_config_deleted , 'success'); + if (selectedGiteaConfigId === id) { + selectedGiteaConfigId = ''; + giteaRepos = []; + } } catch (e) { toast(e.message, 'error'); } } // [/DEF:handleDelete:Function] + + // [DEF:loadGiteaRepos:Function] + /** + * @purpose Loads repositories from selected Gitea config. + * @pre selectedGiteaConfigId is set. + * @post giteaRepos state updated. + */ + async function loadGiteaRepos() { + if (!selectedGiteaConfigId) { + giteaRepos = []; + return; + } + giteaLoading = true; + try { + giteaRepos = await gitService.listGiteaRepositories(selectedGiteaConfigId); + } catch (e) { + toast(e.message, 'error'); + giteaRepos = []; + } finally { + giteaLoading = false; + } + } + // [/DEF:loadGiteaRepos:Function] + + // [DEF:handleCreateGiteaRepo:Function] + /** + * @purpose Creates new repository on selected Gitea server. + * @pre selectedGiteaConfigId and newGiteaRepo.name are set. + * @post Repository created and repos list reloaded. + */ + async function handleCreateGiteaRepo() { + if (!selectedGiteaConfigId || !newGiteaRepo.name?.trim()) return; + giteaCreating = true; + try { + await gitService.createGiteaRepository(selectedGiteaConfigId, { + ...newGiteaRepo, + name: newGiteaRepo.name.trim() + }); + toast('Gitea repository created', 'success'); + newGiteaRepo = { + name: '', + private: true, + description: '', + auto_init: true, + default_branch: 'main' + }; + await loadGiteaRepos(); + } catch (e) { + toast(e.message, 'error'); + } finally { + giteaCreating = false; + } + } + // [/DEF:handleCreateGiteaRepo:Function] + + // [DEF:handleDeleteGiteaRepo:Function] + /** + * @purpose Deletes repository from selected Gitea server. + * @pre selectedGiteaConfigId is set. + * @post Repository deleted and repos list reloaded. + */ + async function handleDeleteGiteaRepo(repo) { + if (!selectedGiteaConfigId) return; + if (!confirm(`Delete repository ${repo.full_name || repo.name}?`)) return; + const fullName = String(repo.full_name || ''); + const parts = fullName.split('/'); + const owner = parts.length > 1 ? parts[0] : ''; + const repoName = parts.length > 1 ? parts[1] : repo.name; + if (!owner || !repoName) { + toast('Cannot resolve repository owner/name', 'error'); + return; + } + giteaDeleting = true; + try { + await gitService.deleteGiteaRepository(selectedGiteaConfigId, owner, repoName); + toast('Gitea repository deleted', 'success'); + await loadGiteaRepos(); + } catch (e) { + toast(e.message, 'error'); + } finally { + giteaDeleting = false; + } + } + // [/DEF:handleDeleteGiteaRepo:Function] + + $: if (selectedGiteaConfigId) { + loadGiteaRepos(); + } @@ -162,16 +266,72 @@
- -

+ +
+ +
+ + + +
+
+ + +
+ + {#if giteaRepos.length === 0 && !giteaLoading} +

No repositories found for this Gitea user.

+ {:else} +
+ {#each giteaRepos as repo} +
+
+
{repo.full_name || repo.name}
+
{repo.clone_url || repo.html_url}
+
+ +
+ {/each} +
+ {/if} + {/if} +
+ +
diff --git a/frontend/src/services/gitService.js b/frontend/src/services/gitService.js index 17b0cfd..bea3541 100644 --- a/frontend/src/services/gitService.js +++ b/frontend/src/services/gitService.js @@ -11,6 +11,14 @@ import { requestApi } from '../lib/api'; const API_BASE = '/git'; +function buildDashboardRepoEndpoint(dashboardRef, suffix, envId = null) { + const encodedRef = encodeURIComponent(String(dashboardRef)); + const endpoint = `${API_BASE}/repositories/${encodedRef}${suffix}`; + if (!envId) return endpoint; + const sep = endpoint.includes('?') ? '&' : '?'; + return `${endpoint}${sep}env_id=${encodeURIComponent(String(envId))}`; +} + // [DEF:gitService:Action] export const gitService = { /** @@ -64,6 +72,62 @@ export const gitService = { return requestApi(`${API_BASE}/config/test`, 'POST', config); }, + /** + * [DEF:listGiteaRepositories:Function] + * @purpose Lists repositories on Gitea for a saved Git configuration. + * @pre configId must reference a GITEA config. + * @post Returns repository metadata. + * @param {string} configId - Git configuration ID. + * @returns {Promise} List of Gitea repositories. + */ + async listGiteaRepositories(configId) { + console.log(`[listGiteaRepositories][Action] Listing Gitea repositories for config ${configId}`); + return requestApi(`${API_BASE}/config/${configId}/gitea/repos`); + }, + + /** + * [DEF:createGiteaRepository:Function] + * @purpose Creates a new repository on Gitea for a saved Git configuration. + * @pre configId must reference a GITEA config. + * @post Repository is created on Gitea. + * @param {string} configId - Git configuration ID. + * @param {Object} payload - {name, private, description, auto_init, default_branch} + * @returns {Promise} Created repository payload. + */ + async createGiteaRepository(configId, payload) { + console.log(`[createGiteaRepository][Action] Creating Gitea repository ${payload?.name} for config ${configId}`); + return requestApi(`${API_BASE}/config/${configId}/gitea/repos`, 'POST', payload); + }, + + /** + * [DEF:createRemoteRepository:Function] + * @purpose Creates repository on remote provider selected by Git config. + * @pre configId exists and points to supported provider config. + * @post Remote repository created and normalized payload returned. + * @param {string} configId - Git configuration ID. + * @param {Object} payload - {name, private, description, auto_init, default_branch} + * @returns {Promise} Created remote repository payload. + */ + async createRemoteRepository(configId, payload) { + console.log(`[createRemoteRepository][Action] Creating remote repository ${payload?.name} for config ${configId}`); + return requestApi(`${API_BASE}/config/${configId}/repositories`, 'POST', payload); + }, + + /** + * [DEF:deleteGiteaRepository:Function] + * @purpose Deletes a repository on Gitea for a saved Git configuration. + * @pre configId must reference a GITEA config. + * @post Repository is deleted on Gitea. + * @param {string} configId - Git configuration ID. + * @param {string} owner - Repository owner. + * @param {string} repoName - Repository name. + * @returns {Promise} Deletion result. + */ + async deleteGiteaRepository(configId, owner, repoName) { + console.log(`[deleteGiteaRepository][Action] Deleting Gitea repository ${owner}/${repoName} for config ${configId}`); + return requestApi(`${API_BASE}/config/${configId}/gitea/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}`, 'DELETE'); + }, + /** * [DEF:initRepository:Function] * @purpose Initializes or clones a Git repository for a dashboard. @@ -74,9 +138,9 @@ export const gitService = { * @param {string} remoteUrl - URL of the remote repository. * @returns {Promise} Initialization result. */ - async initRepository(dashboardId, configId, remoteUrl) { - console.log(`[initRepository][Action] Initializing repo for dashboard ${dashboardId}`); - return requestApi(`${API_BASE}/repositories/${dashboardId}/init`, 'POST', { + async initRepository(dashboardRef, configId, remoteUrl, envId = null) { + console.log(`[initRepository][Action] Initializing repo for dashboard ${dashboardRef}`); + return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/init', envId), 'POST', { config_id: configId, remote_url: remoteUrl }); @@ -90,9 +154,9 @@ export const gitService = { * @param {number} dashboardId - ID of the dashboard. * @returns {Promise} List of branches. */ - async getBranches(dashboardId) { - console.log(`[getBranches][Action] Fetching branches for dashboard ${dashboardId}`); - return requestApi(`${API_BASE}/repositories/${dashboardId}/branches`); + async getBranches(dashboardRef, envId = null) { + console.log(`[getBranches][Action] Fetching branches for dashboard ${dashboardRef}`); + return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/branches', envId)); }, /** @@ -105,9 +169,9 @@ export const gitService = { * @param {string} fromBranch - Source branch name. * @returns {Promise} Creation result. */ - async createBranch(dashboardId, name, fromBranch) { - console.log(`[createBranch][Action] Creating branch ${name} for dashboard ${dashboardId}`); - return requestApi(`${API_BASE}/repositories/${dashboardId}/branches`, 'POST', { + async createBranch(dashboardRef, name, fromBranch, envId = null) { + console.log(`[createBranch][Action] Creating branch ${name} for dashboard ${dashboardRef}`); + return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/branches', envId), 'POST', { name, from_branch: fromBranch }); @@ -122,9 +186,9 @@ export const gitService = { * @param {string} name - Branch name to checkout. * @returns {Promise} Checkout result. */ - async checkoutBranch(dashboardId, name) { - console.log(`[checkoutBranch][Action] Checking out branch ${name} for dashboard ${dashboardId}`); - return requestApi(`${API_BASE}/repositories/${dashboardId}/checkout`, 'POST', { name }); + async checkoutBranch(dashboardRef, name, envId = null) { + console.log(`[checkoutBranch][Action] Checking out branch ${name} for dashboard ${dashboardRef}`); + return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/checkout', envId), 'POST', { name }); }, /** @@ -137,9 +201,9 @@ export const gitService = { * @param {Array} files - Optional list of files to commit. * @returns {Promise} Commit result. */ - async commit(dashboardId, message, files) { - console.log(`[commit][Action] Committing changes for dashboard ${dashboardId}`); - return requestApi(`${API_BASE}/repositories/${dashboardId}/commit`, 'POST', { message, files }); + async commit(dashboardRef, message, files, envId = null) { + console.log(`[commit][Action] Committing changes for dashboard ${dashboardRef}`); + return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/commit', envId), 'POST', { message, files }); }, /** @@ -150,9 +214,9 @@ export const gitService = { * @param {number} dashboardId - ID of the dashboard. * @returns {Promise} Push result. */ - async push(dashboardId) { - console.log(`[push][Action] Pushing changes for dashboard ${dashboardId}`); - return requestApi(`${API_BASE}/repositories/${dashboardId}/push`, 'POST'); + async push(dashboardRef, envId = null) { + console.log(`[push][Action] Pushing changes for dashboard ${dashboardRef}`); + return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/push', envId), 'POST'); }, /** @@ -163,9 +227,9 @@ export const gitService = { * @param {number} dashboardId - ID of the dashboard. * @returns {Promise} Pull result. */ - async pull(dashboardId) { - console.log(`[pull][Action] Pulling changes for dashboard ${dashboardId}`); - return requestApi(`${API_BASE}/repositories/${dashboardId}/pull`, 'POST'); + async pull(dashboardRef, envId = null) { + console.log(`[pull][Action] Pulling changes for dashboard ${dashboardRef}`); + return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/pull', envId), 'POST'); }, /** @@ -188,9 +252,9 @@ export const gitService = { * @param {string} environmentId - ID of the target environment. * @returns {Promise} Deployment result. */ - async deploy(dashboardId, environmentId) { - console.log(`[deploy][Action] Deploying dashboard ${dashboardId} to environment ${environmentId}`); - return requestApi(`${API_BASE}/repositories/${dashboardId}/deploy`, 'POST', { + async deploy(dashboardRef, environmentId, envId = null) { + console.log(`[deploy][Action] Deploying dashboard ${dashboardRef} to environment ${environmentId}`); + return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/deploy', envId), 'POST', { environment_id: environmentId }); }, @@ -202,9 +266,9 @@ export const gitService = { * @param {number} limit - Maximum number of commits to return. * @returns {Promise} List of commits. */ - async getHistory(dashboardId, limit = 50) { - console.log(`[getHistory][Action] Fetching history for dashboard ${dashboardId}`); - return requestApi(`${API_BASE}/repositories/${dashboardId}/history?limit=${limit}`); + async getHistory(dashboardRef, limit = 50, envId = null) { + console.log(`[getHistory][Action] Fetching history for dashboard ${dashboardRef}`); + return requestApi(buildDashboardRepoEndpoint(dashboardRef, `/history?limit=${limit}`, envId)); }, /** @@ -214,10 +278,13 @@ export const gitService = { * @param {string|null} sourceEnvId - Optional source environment ID. * @returns {Promise} Sync result. */ - async sync(dashboardId, sourceEnvId = null) { - console.log(`[sync][Action] Syncing dashboard ${dashboardId}`); - let endpoint = `${API_BASE}/repositories/${dashboardId}/sync`; - if (sourceEnvId) endpoint += `?source_env_id=${sourceEnvId}`; + async sync(dashboardRef, sourceEnvId = null, envId = null) { + console.log(`[sync][Action] Syncing dashboard ${dashboardRef}`); + const params = new URLSearchParams(); + if (sourceEnvId) params.append('source_env_id', String(sourceEnvId)); + if (envId) params.append('env_id', String(envId)); + const query = params.toString(); + const endpoint = `${API_BASE}/repositories/${encodeURIComponent(String(dashboardRef))}/sync${query ? `?${query}` : ''}`; return requestApi(endpoint, 'POST'); }, @@ -229,9 +296,9 @@ export const gitService = { * @param {number} dashboardId - The ID of the dashboard. * @returns {Promise} Status details. */ - async getStatus(dashboardId) { - console.log(`[getStatus][Action] Fetching status for dashboard ${dashboardId}`); - return requestApi(`${API_BASE}/repositories/${dashboardId}/status`); + async getStatus(dashboardRef, envId = null) { + console.log(`[getStatus][Action] Fetching status for dashboard ${dashboardRef}`); + return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/status', envId)); }, /** @@ -259,12 +326,13 @@ export const gitService = { * @param {boolean} staged - Whether to show staged changes. * @returns {Promise} The diff content. */ - async getDiff(dashboardId, filePath = null, staged = false) { - console.log(`[getDiff][Action] Fetching diff for dashboard ${dashboardId} (file: ${filePath}, staged: ${staged})`); - let endpoint = `${API_BASE}/repositories/${dashboardId}/diff`; + async getDiff(dashboardRef, filePath = null, staged = false, envId = null) { + console.log(`[getDiff][Action] Fetching diff for dashboard ${dashboardRef} (file: ${filePath}, staged: ${staged})`); + let endpoint = `${API_BASE}/repositories/${encodeURIComponent(String(dashboardRef))}/diff`; const params = new URLSearchParams(); if (filePath) params.append('file_path', filePath); if (staged) params.append('staged', 'true'); + if (envId) params.append('env_id', String(envId)); if (params.toString()) endpoint += `?${params.toString()}`; return requestApi(endpoint); }