Работает создание коммитов и перенос в новый enviroment

This commit is contained in:
2026-01-23 13:57:44 +03:00
parent e9d3f3c827
commit 07ec2d9797
37 changed files with 3227 additions and 252 deletions

3
.gitignore vendored
View File

@@ -66,3 +66,6 @@ backend/mappings.db
backend/tasks.db
# Git Integration repositories
backend/git_repos/

View File

@@ -46,8 +46,8 @@ Python 3.9+ (Backend), Node.js 18+ (Frontend Build): Follow standard conventions
## Recent Changes
- 011-git-integration-dashboard: Added Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI, SvelteKit, GitPython (or CLI git), Pydantic, SQLAlchemy, Superset API
- 011-git-integration-dashboard: Added Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI, SvelteKit, GitPython (or CLI git), Pydantic, SQLAlchemy, Superset API
- 011-git-integration-dashboard: Added Python 3.9+, Node.js 18+
- 012-remove-superset-tool: Added Python 3.9+ + FastAPI, Pydantic, requests, pyyaml (migrated from superset_tool)
<!-- MANUAL ADDITIONS START -->

Submodule backend/backend/git_repos/12 added at d592fa7ed5

Binary file not shown.

View File

@@ -43,4 +43,5 @@ uvicorn==0.38.0
websockets==15.0.1
pandas
psycopg2-binary
openpyxl
openpyxl
GitPython==3.1.44

View File

@@ -1 +1 @@
from . import plugins, tasks, settings, connections
from . import plugins, tasks, settings, connections, environments, mappings, migration, git

View File

@@ -61,7 +61,7 @@ async def get_environments(config_manager=Depends(get_config_manager)):
backup_schedule=ScheduleSchema(
enabled=e.backup_schedule.enabled,
cron_expression=e.backup_schedule.cron_expression
) if e.backup_schedule else None
) if getattr(e, 'backup_schedule', None) else None
) for e in envs
]
# [/DEF:get_environments:Function]

View File

@@ -0,0 +1,303 @@
# [DEF:backend.src.api.routes.git:Module]
#
# @SEMANTICS: git, routes, api, fastapi, repository, deployment
# @PURPOSE: Provides FastAPI endpoints for Git integration operations.
# @LAYER: API
# @RELATION: USES -> src.services.git_service.GitService
# @RELATION: USES -> src.api.routes.git_schemas
# @RELATION: USES -> src.models.git
#
# @INVARIANT: All Git operations must be routed through GitService.
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List, Optional
import typing
from src.dependencies import get_config_manager
from src.core.database import get_db
from src.models.git import GitServerConfig, GitStatus, DeploymentEnvironment, GitRepository
from src.api.routes.git_schemas import (
GitServerConfigSchema, GitServerConfigCreate,
GitRepositorySchema, BranchSchema, BranchCreate,
BranchCheckout, CommitSchema, CommitCreate,
DeploymentEnvironmentSchema, DeployRequest, RepoInitRequest
)
from src.services.git_service import GitService
from src.core.logger import logger, belief_scope
router = APIRouter(prefix="/api/git", tags=["git"])
git_service = GitService()
# [DEF:get_git_configs:Function]
# @PURPOSE: List all configured Git servers.
# @RETURN: List[GitServerConfigSchema]
@router.get("/config", response_model=List[GitServerConfigSchema])
async def get_git_configs(db: Session = Depends(get_db)):
with belief_scope("get_git_configs"):
return db.query(GitServerConfig).all()
# [/DEF:get_git_configs:Function]
# [DEF:create_git_config:Function]
# @PURPOSE: Register a new Git server configuration.
# @PARAM: config (GitServerConfigCreate)
# @RETURN: GitServerConfigSchema
@router.post("/config", response_model=GitServerConfigSchema)
async def create_git_config(config: GitServerConfigCreate, db: Session = Depends(get_db)):
with belief_scope("create_git_config"):
db_config = GitServerConfig(**config.dict())
db.add(db_config)
db.commit()
db.refresh(db_config)
return db_config
# [/DEF:create_git_config:Function]
# [DEF:delete_git_config:Function]
# @PURPOSE: Remove a Git server configuration.
# @PARAM: config_id (str)
@router.delete("/config/{config_id}")
async def delete_git_config(config_id: str, db: Session = Depends(get_db)):
with belief_scope("delete_git_config"):
db_config = db.query(GitServerConfig).filter(GitServerConfig.id == config_id).first()
if not db_config:
raise HTTPException(status_code=404, detail="Configuration not found")
db.delete(db_config)
db.commit()
return {"status": "success", "message": "Configuration deleted"}
# [/DEF:delete_git_config:Function]
# [DEF:test_git_config:Function]
# @PURPOSE: Validate connection to a Git server using provided credentials.
# @PARAM: config (GitServerConfigCreate)
@router.post("/config/test")
async def test_git_config(config: GitServerConfigCreate):
with belief_scope("test_git_config"):
success = await git_service.test_connection(config.provider, config.url, config.pat)
if success:
return {"status": "success", "message": "Connection successful"}
else:
raise HTTPException(status_code=400, detail="Connection failed")
# [/DEF:test_git_config:Function]
# [DEF:init_repository:Function]
# @PURPOSE: Link a dashboard to a Git repository and perform initial clone/init.
# @PARAM: dashboard_id (int)
# @PARAM: init_data (RepoInitRequest)
@router.post("/repositories/{dashboard_id}/init")
async def init_repository(dashboard_id: int, init_data: RepoInitRequest, db: Session = Depends(get_db)):
with belief_scope("init_repository"):
# 1. Get config
config = db.query(GitServerConfig).filter(GitServerConfig.id == init_data.config_id).first()
if not config:
raise HTTPException(status_code=404, detail="Git configuration not found")
try:
# 2. Perform Git clone/init
logger.info(f"[init_repository][Action] Initializing repo for dashboard {dashboard_id}")
git_service.init_repo(dashboard_id, init_data.remote_url, config.pat)
# 3. Save to DB
repo_path = git_service._get_repo_path(dashboard_id)
db_repo = db.query(GitRepository).filter(GitRepository.dashboard_id == dashboard_id).first()
if not db_repo:
db_repo = GitRepository(
dashboard_id=dashboard_id,
config_id=config.id,
remote_url=init_data.remote_url,
local_path=repo_path
)
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.commit()
logger.info(f"[init_repository][Coherence:OK] Repository initialized for dashboard {dashboard_id}")
return {"status": "success", "message": "Repository initialized"}
except Exception as e:
db.rollback()
logger.error(f"[init_repository][Coherence:Failed] Failed to init repository: {e}")
raise HTTPException(status_code=400, detail=str(e))
# [/DEF:init_repository:Function]
# [DEF:get_branches:Function]
# @PURPOSE: List all branches for a dashboard's repository.
# @PARAM: dashboard_id (int)
# @RETURN: List[BranchSchema]
@router.get("/repositories/{dashboard_id}/branches", response_model=List[BranchSchema])
async def get_branches(dashboard_id: int):
with belief_scope("get_branches"):
try:
return git_service.list_branches(dashboard_id)
except Exception as e:
raise HTTPException(status_code=404, detail=str(e))
# [/DEF:get_branches:Function]
# [DEF:create_branch:Function]
# @PURPOSE: Create a new branch in the dashboard's repository.
# @PARAM: dashboard_id (int)
# @PARAM: branch_data (BranchCreate)
@router.post("/repositories/{dashboard_id}/branches")
async def create_branch(dashboard_id: int, branch_data: BranchCreate):
with belief_scope("create_branch"):
try:
git_service.create_branch(dashboard_id, branch_data.name, branch_data.from_branch)
return {"status": "success"}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# [/DEF:create_branch:Function]
# [DEF:checkout_branch:Function]
# @PURPOSE: Switch the dashboard's repository to a specific branch.
# @PARAM: dashboard_id (int)
# @PARAM: checkout_data (BranchCheckout)
@router.post("/repositories/{dashboard_id}/checkout")
async def checkout_branch(dashboard_id: int, checkout_data: BranchCheckout):
with belief_scope("checkout_branch"):
try:
git_service.checkout_branch(dashboard_id, checkout_data.name)
return {"status": "success"}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# [/DEF:checkout_branch:Function]
# [DEF:commit_changes:Function]
# @PURPOSE: Stage and commit changes in the dashboard's repository.
# @PARAM: dashboard_id (int)
# @PARAM: commit_data (CommitCreate)
@router.post("/repositories/{dashboard_id}/commit")
async def commit_changes(dashboard_id: int, commit_data: CommitCreate):
with belief_scope("commit_changes"):
try:
git_service.commit_changes(dashboard_id, commit_data.message, commit_data.files)
return {"status": "success"}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# [/DEF:commit_changes:Function]
# [DEF:push_changes:Function]
# @PURPOSE: Push local commits to the remote repository.
# @PARAM: dashboard_id (int)
@router.post("/repositories/{dashboard_id}/push")
async def push_changes(dashboard_id: int):
with belief_scope("push_changes"):
try:
git_service.push_changes(dashboard_id)
return {"status": "success"}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# [/DEF:push_changes:Function]
# [DEF:pull_changes:Function]
# @PURPOSE: Pull changes from the remote repository.
# @PARAM: dashboard_id (int)
@router.post("/repositories/{dashboard_id}/pull")
async def pull_changes(dashboard_id: int):
with belief_scope("pull_changes"):
try:
git_service.pull_changes(dashboard_id)
return {"status": "success"}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# [/DEF:pull_changes:Function]
# [DEF:sync_dashboard:Function]
# @PURPOSE: Sync dashboard state from Superset to Git using the GitPlugin.
# @PARAM: dashboard_id (int)
# @PARAM: source_env_id (Optional[str])
@router.post("/repositories/{dashboard_id}/sync")
async def sync_dashboard(dashboard_id: int, source_env_id: typing.Optional[str] = None):
with belief_scope("sync_dashboard"):
try:
from src.plugins.git_plugin import GitPlugin
plugin = GitPlugin()
return await plugin.execute({
"operation": "sync",
"dashboard_id": dashboard_id,
"source_env_id": source_env_id
})
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# [/DEF:sync_dashboard:Function]
# [DEF:get_environments:Function]
# @PURPOSE: List all deployment environments.
# @RETURN: List[DeploymentEnvironmentSchema]
@router.get("/environments", response_model=List[DeploymentEnvironmentSchema])
async def get_environments(config_manager=Depends(get_config_manager)):
with belief_scope("get_environments"):
envs = config_manager.get_environments()
return [
DeploymentEnvironmentSchema(
id=e.id,
name=e.name,
superset_url=e.url,
is_active=True
) for e in envs
]
# [/DEF:get_environments:Function]
# [DEF:deploy_dashboard:Function]
# @PURPOSE: Deploy dashboard from Git to a target environment.
# @PARAM: dashboard_id (int)
# @PARAM: deploy_data (DeployRequest)
@router.post("/repositories/{dashboard_id}/deploy")
async def deploy_dashboard(dashboard_id: int, deploy_data: DeployRequest):
with belief_scope("deploy_dashboard"):
try:
from src.plugins.git_plugin import GitPlugin
plugin = GitPlugin()
return await plugin.execute({
"operation": "deploy",
"dashboard_id": dashboard_id,
"environment_id": deploy_data.environment_id
})
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# [/DEF:deploy_dashboard:Function]
# [DEF:get_history:Function]
# @PURPOSE: View commit history for a dashboard's repository.
# @PARAM: dashboard_id (int)
# @PARAM: limit (int)
# @RETURN: List[CommitSchema]
@router.get("/repositories/{dashboard_id}/history", response_model=List[CommitSchema])
async def get_history(dashboard_id: int, limit: int = 50):
with belief_scope("get_history"):
try:
return git_service.get_commit_history(dashboard_id, limit)
except Exception as e:
raise HTTPException(status_code=404, detail=str(e))
# [/DEF:get_history:Function]
# [DEF:get_repository_status:Function]
# @PURPOSE: Get current Git status for a dashboard repository.
# @PARAM: dashboard_id (int)
# @RETURN: dict
@router.get("/repositories/{dashboard_id}/status")
async def get_repository_status(dashboard_id: int):
with belief_scope("get_repository_status"):
try:
return git_service.get_status(dashboard_id)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# [/DEF:get_repository_status:Function]
# [DEF:get_repository_diff:Function]
# @PURPOSE: Get Git diff for a dashboard repository.
# @PARAM: dashboard_id (int)
# @PARAM: file_path (Optional[str])
# @PARAM: staged (bool)
# @RETURN: str
@router.get("/repositories/{dashboard_id}/diff")
async def get_repository_diff(dashboard_id: int, file_path: Optional[str] = None, staged: bool = False):
with belief_scope("get_repository_diff"):
try:
diff_text = git_service.get_diff(dashboard_id, file_path, staged)
return diff_text
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# [/DEF:get_repository_diff:Function]
# [/DEF:backend.src.api.routes.git:Module]

View File

@@ -0,0 +1,130 @@
# [DEF:backend.src.api.routes.git_schemas:Module]
#
# @SEMANTICS: git, schemas, pydantic, api, contracts
# @PURPOSE: Defines Pydantic models for the Git integration API layer.
# @LAYER: API
# @RELATION: DEPENDS_ON -> backend.src.models.git
#
# @INVARIANT: All schemas must be compatible with the FastAPI router.
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import datetime
from uuid import UUID
from src.models.git import GitProvider, GitStatus, SyncStatus
# [DEF:GitServerConfigBase:Class]
class GitServerConfigBase(BaseModel):
name: str = Field(..., description="Display name for the Git server")
provider: GitProvider = Field(..., description="Git provider (GITHUB, GITLAB, GITEA)")
url: str = Field(..., description="Server base URL")
pat: str = Field(..., description="Personal Access Token")
default_repository: Optional[str] = Field(None, description="Default repository path (org/repo)")
# [/DEF:GitServerConfigBase:Class]
# [DEF:GitServerConfigCreate:Class]
class GitServerConfigCreate(GitServerConfigBase):
"""Schema for creating a new Git server configuration."""
pass
# [/DEF:GitServerConfigCreate:Class]
# [DEF:GitServerConfigSchema:Class]
class GitServerConfigSchema(GitServerConfigBase):
"""Schema for representing a Git server configuration with metadata."""
id: str
status: GitStatus
last_validated: datetime
class Config:
from_attributes = True
# [/DEF:GitServerConfigSchema:Class]
# [DEF:GitRepositorySchema:Class]
class GitRepositorySchema(BaseModel):
"""Schema for tracking a local Git repository linked to a dashboard."""
id: str
dashboard_id: int
config_id: str
remote_url: str
local_path: str
current_branch: str
sync_status: SyncStatus
class Config:
from_attributes = True
# [/DEF:GitRepositorySchema:Class]
# [DEF:BranchSchema:Class]
class BranchSchema(BaseModel):
"""Schema for representing a Git branch."""
name: str
commit_hash: str
is_remote: bool
last_updated: datetime
# [/DEF:BranchSchema:Class]
# [DEF:CommitSchema:Class]
class CommitSchema(BaseModel):
"""Schema for representing a Git commit."""
hash: str
author: str
email: str
timestamp: datetime
message: str
files_changed: List[str]
# [/DEF:CommitSchema:Class]
# [DEF:BranchCreate:Class]
class BranchCreate(BaseModel):
"""Schema for branch creation requests."""
name: str
from_branch: str
# [/DEF:BranchCreate:Class]
# [DEF:BranchCheckout:Class]
class BranchCheckout(BaseModel):
"""Schema for branch checkout requests."""
name: str
# [/DEF:BranchCheckout:Class]
# [DEF:CommitCreate:Class]
class CommitCreate(BaseModel):
"""Schema for staging and committing changes."""
message: str
files: List[str]
# [/DEF:CommitCreate:Class]
# [DEF:ConflictResolution:Class]
class ConflictResolution(BaseModel):
"""Schema for resolving merge conflicts."""
file_path: str
resolution: str = Field(pattern="^(mine|theirs|manual)$")
content: Optional[str] = None
# [/DEF:ConflictResolution:Class]
# [DEF:DeploymentEnvironmentSchema:Class]
class DeploymentEnvironmentSchema(BaseModel):
"""Schema for representing a target deployment environment."""
id: str
name: str
superset_url: str
is_active: bool
class Config:
from_attributes = True
# [/DEF:DeploymentEnvironmentSchema:Class]
# [DEF:DeployRequest:Class]
class DeployRequest(BaseModel):
"""Schema for deployment requests."""
environment_id: str
# [/DEF:DeployRequest:Class]
# [DEF:RepoInitRequest:Class]
class RepoInitRequest(BaseModel):
"""Schema for repository initialization requests."""
config_id: str
remote_url: str
# [/DEF:RepoInitRequest:Class]
# [/DEF:backend.src.api.routes.git_schemas:Module]

View File

@@ -18,7 +18,7 @@ import os
from .dependencies import get_task_manager, get_scheduler_service
from .core.logger import logger, belief_scope
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git
from .core.database import init_db
# [DEF:App:Global]
@@ -88,6 +88,7 @@ app.include_router(connections.router, prefix="/api/settings/connections", tags=
app.include_router(environments.router, prefix="/api/environments", tags=["Environments"])
app.include_router(mappings.router)
app.include_router(migration.router)
app.include_router(git.router)
# [DEF:websocket_endpoint:Function]
# @PURPOSE: Provides a WebSocket endpoint for real-time log streaming of a task.

View File

@@ -15,6 +15,7 @@ from ..models.mapping import Base
# Import models to ensure they're registered with Base
from ..models.task import TaskRecord
from ..models.connection import ConnectionConfig
from ..models.git import GitServerConfig, GitRepository, DeploymentEnvironment
from .logger import belief_scope
import os
# [/SECTION]

73
backend/src/models/git.py Normal file
View File

@@ -0,0 +1,73 @@
"""
[DEF:GitModels:Module]
Git-specific SQLAlchemy models for configuration and repository tracking.
@RELATION: specs/011-git-integration-dashboard/data-model.md
"""
import enum
from datetime import datetime
from sqlalchemy import Column, String, Integer, DateTime, Enum, ForeignKey, Boolean
from sqlalchemy.dialects.postgresql import UUID
import uuid
from src.core.database import Base
class GitProvider(str, enum.Enum):
GITHUB = "GITHUB"
GITLAB = "GITLAB"
GITEA = "GITEA"
class GitStatus(str, enum.Enum):
CONNECTED = "CONNECTED"
FAILED = "FAILED"
UNKNOWN = "UNKNOWN"
class SyncStatus(str, enum.Enum):
CLEAN = "CLEAN"
DIRTY = "DIRTY"
CONFLICT = "CONFLICT"
class GitServerConfig(Base):
"""
[DEF:GitServerConfig:Class]
Configuration for a Git server connection.
"""
__tablename__ = "git_server_configs"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String(255), nullable=False)
provider = Column(Enum(GitProvider), nullable=False)
url = Column(String(255), nullable=False)
pat = Column(String(255), nullable=False) # PERSONAL ACCESS TOKEN
default_repository = Column(String(255), nullable=True)
status = Column(Enum(GitStatus), default=GitStatus.UNKNOWN)
last_validated = Column(DateTime, default=datetime.utcnow)
class GitRepository(Base):
"""
[DEF:GitRepository:Class]
Tracking for a local Git repository linked to a dashboard.
"""
__tablename__ = "git_repositories"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
dashboard_id = Column(Integer, nullable=False, unique=True)
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")
sync_status = Column(Enum(SyncStatus), default=SyncStatus.CLEAN)
class DeploymentEnvironment(Base):
"""
[DEF:DeploymentEnvironment:Class]
Target Superset environments for dashboard deployment.
"""
__tablename__ = "deployment_environments"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String(255), nullable=False)
superset_url = Column(String(255), nullable=False)
superset_token = Column(String(255), nullable=False)
is_active = Column(Boolean, default=True)
# [/DEF:GitModels:Module]

View File

@@ -0,0 +1,345 @@
# [DEF:backend.src.plugins.git_plugin:Module]
#
# @SEMANTICS: git, plugin, dashboard, version_control, sync, deploy
# @PURPOSE: Предоставляет плагин для версионирования и развертывания дашбордов Superset.
# @LAYER: Plugin
# @RELATION: INHERITS_FROM -> src.core.plugin_base.PluginBase
# @RELATION: USES -> src.services.git_service.GitService
# @RELATION: USES -> src.core.superset_client.SupersetClient
# @RELATION: USES -> src.core.config_manager.ConfigManager
#
# @INVARIANT: Все операции с Git должны выполняться через GitService.
# @CONSTRAINT: Плагин работает только с распакованными YAML-экспортами Superset.
# [SECTION: IMPORTS]
import os
import io
import shutil
import zipfile
from pathlib import Path
from typing import Dict, Any, Optional
from src.core.plugin_base import PluginBase
from src.services.git_service import GitService
from src.core.logger import logger, belief_scope
from src.core.config_manager import ConfigManager
from src.core.superset_client import SupersetClient
# [/SECTION]
# [DEF:GitPlugin:Class]
# @PURPOSE: Реализация плагина Git Integration для управления версиями дашбордов.
class GitPlugin(PluginBase):
# [DEF:__init__:Function]
# @PURPOSE: Инициализирует плагин и его зависимости.
# @POST: Инициализированы git_service и config_manager.
def __init__(self):
with belief_scope("GitPlugin.__init__"):
logger.info("[GitPlugin.__init__][Entry] Initializing GitPlugin.")
self.git_service = GitService()
# Robust config path resolution:
# 1. Try absolute path from src/dependencies.py style if possible
# 2. Try relative paths based on common execution patterns
if os.path.exists("../config.json"):
config_path = "../config.json"
elif os.path.exists("config.json"):
config_path = "config.json"
else:
# Fallback to the one initialized in dependencies if we can import it
try:
from src.dependencies import config_manager
self.config_manager = config_manager
logger.info("[GitPlugin.__init__][Exit] GitPlugin initialized using shared config_manager.")
return
except:
config_path = "config.json"
self.config_manager = ConfigManager(config_path)
logger.info(f"[GitPlugin.__init__][Exit] GitPlugin initialized with {config_path}")
# [/DEF:__init__:Function]
@property
def id(self) -> str:
return "git-integration"
@property
def name(self) -> str:
return "Git Integration"
@property
def description(self) -> str:
return "Version control for Superset dashboards"
@property
def version(self) -> str:
return "0.1.0"
# [DEF:get_schema:Function]
# @PURPOSE: Возвращает JSON-схему параметров для выполнения задач плагина.
# @RETURN: Dict[str, Any] - Схема параметров.
def get_schema(self) -> Dict[str, Any]:
with belief_scope("GitPlugin.get_schema"):
return {
"type": "object",
"properties": {
"operation": {"type": "string", "enum": ["sync", "deploy", "history"]},
"dashboard_id": {"type": "integer"},
"environment_id": {"type": "string"},
"source_env_id": {"type": "string"}
},
"required": ["operation", "dashboard_id"]
}
# [/DEF:get_schema:Function]
# [DEF:initialize:Function]
# @PURPOSE: Выполняет начальную настройку плагина.
# @POST: Плагин готов к выполнению задач.
async def initialize(self):
with belief_scope("GitPlugin.initialize"):
logger.info("[GitPlugin.initialize][Action] Initializing Git Integration Plugin logic.")
# [DEF:execute:Function]
# @PURPOSE: Основной метод выполнения задач плагина.
# @PRE: task_data содержит 'operation' и 'dashboard_id'.
# @POST: Возвращает результат выполнения операции.
# @PARAM: task_data (Dict[str, Any]) - Данные задачи.
# @RETURN: Dict[str, Any] - Статус и сообщение.
# @RELATION: CALLS -> self._handle_sync
# @RELATION: CALLS -> self._handle_deploy
async def execute(self, task_data: Dict[str, Any]) -> Dict[str, Any]:
with belief_scope("GitPlugin.execute"):
operation = task_data.get("operation")
dashboard_id = task_data.get("dashboard_id")
logger.info(f"[GitPlugin.execute][Entry] Executing operation: {operation} for dashboard {dashboard_id}")
if operation == "sync":
source_env_id = task_data.get("source_env_id")
result = await self._handle_sync(dashboard_id, source_env_id)
elif operation == "deploy":
env_id = task_data.get("environment_id")
result = await self._handle_deploy(dashboard_id, env_id)
elif operation == "history":
result = {"status": "success", "message": "History available via API"}
else:
logger.error(f"[GitPlugin.execute][Coherence:Failed] Unknown operation: {operation}")
raise ValueError(f"Unknown operation: {operation}")
logger.info(f"[GitPlugin.execute][Exit] Operation {operation} completed.")
return result
# [/DEF:execute:Function]
# [DEF:_handle_sync:Function]
# @PURPOSE: Экспортирует дашборд из Superset и распаковывает в Git-репозиторий.
# @PRE: Репозиторий для дашборда должен существовать.
# @POST: Файлы в репозитории обновлены до текущего состояния в Superset.
# @PARAM: dashboard_id (int) - ID дашборда.
# @PARAM: source_env_id (Optional[str]) - ID исходного окружения.
# @RETURN: Dict[str, str] - Результат синхронизации.
# @SIDE_EFFECT: Изменяет файлы в локальной рабочей директории репозитория.
# @RELATION: CALLS -> src.services.git_service.GitService.get_repo
# @RELATION: CALLS -> src.core.superset_client.SupersetClient.export_dashboard
async def _handle_sync(self, dashboard_id: int, source_env_id: Optional[str] = None) -> Dict[str, str]:
with belief_scope("GitPlugin._handle_sync"):
try:
# 1. Получение репозитория
repo = self.git_service.get_repo(dashboard_id)
repo_path = Path(repo.working_dir)
logger.info(f"[_handle_sync][Action] Target repo path: {repo_path}")
# 2. Настройка клиента Superset
env = self._get_env(source_env_id)
client = SupersetClient(env)
client.authenticate()
# 3. Экспорт дашборда
logger.info(f"[_handle_sync][Action] Exporting dashboard {dashboard_id} from {env.name}")
zip_bytes, _ = client.export_dashboard(dashboard_id)
# 4. Распаковка с выравниванием структуры (flattening)
logger.info(f"[_handle_sync][Action] Unpacking export to {repo_path}")
# Список папок/файлов, которые мы ожидаем от Superset
managed_dirs = ["dashboards", "charts", "datasets", "databases"]
managed_files = ["metadata.yaml"]
# Очистка старых данных перед распаковкой, чтобы не оставалось "призраков"
for d in managed_dirs:
d_path = repo_path / d
if d_path.exists() and d_path.is_dir():
shutil.rmtree(d_path)
for f in managed_files:
f_path = repo_path / f
if f_path.exists():
f_path.unlink()
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
# Superset экспортирует всё в подпапку dashboard_export_timestamp/
# Нам нужно найти это имя папки
namelist = zf.namelist()
if not namelist:
raise ValueError("Export ZIP is empty")
root_folder = namelist[0].split('/')[0]
logger.info(f"[_handle_sync][Action] Detected root folder in ZIP: {root_folder}")
for member in zf.infolist():
if member.filename.startswith(root_folder + "/") and len(member.filename) > len(root_folder) + 1:
# Убираем префикс папки
relative_path = member.filename[len(root_folder)+1:]
target_path = repo_path / relative_path
if member.is_dir():
target_path.mkdir(parents=True, exist_ok=True)
else:
target_path.parent.mkdir(parents=True, exist_ok=True)
with zf.open(member) as source, open(target_path, "wb") as target:
shutil.copyfileobj(source, target)
# 5. Автоматический staging изменений (не коммит, чтобы юзер мог проверить diff)
try:
repo.git.add(A=True)
logger.info(f"[_handle_sync][Action] Changes staged in git")
except Exception as ge:
logger.warning(f"[_handle_sync][Action] Failed to stage changes: {ge}")
logger.info(f"[_handle_sync][Coherence:OK] Dashboard {dashboard_id} synced successfully.")
return {"status": "success", "message": "Dashboard synced and flattened in local repository"}
except Exception as e:
logger.error(f"[_handle_sync][Coherence:Failed] Sync failed: {e}")
raise
# [/DEF:_handle_sync:Function]
# [DEF:_handle_deploy:Function]
# @PURPOSE: Упаковывает репозиторий в ZIP и импортирует в целевое окружение Superset.
# @PRE: environment_id должен соответствовать настроенному окружению.
# @POST: Дашборд импортирован в целевой Superset.
# @PARAM: dashboard_id (int) - ID дашборда.
# @PARAM: env_id (str) - ID целевого окружения.
# @RETURN: Dict[str, Any] - Результат деплоя.
# @SIDE_EFFECT: Создает и удаляет временный ZIP-файл.
# @RELATION: CALLS -> src.core.superset_client.SupersetClient.import_dashboard
async def _handle_deploy(self, dashboard_id: int, env_id: str) -> Dict[str, Any]:
with belief_scope("GitPlugin._handle_deploy"):
try:
if not env_id:
raise ValueError("Target environment ID required for deployment")
# 1. Получение репозитория
repo = self.git_service.get_repo(dashboard_id)
repo_path = Path(repo.working_dir)
# 2. Упаковка в ZIP
logger.info(f"[_handle_deploy][Action] Packing repository {repo_path} for deployment.")
zip_buffer = io.BytesIO()
# Superset expects a root directory in the ZIP (e.g., dashboard_export_20240101T000000/)
root_dir_name = f"dashboard_export_{dashboard_id}"
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
for root, dirs, files in os.walk(repo_path):
if ".git" in dirs:
dirs.remove(".git")
for file in files:
if file == ".git" or file.endswith(".zip"): continue
file_path = Path(root) / file
# Prepend the root directory name to the archive path
arcname = Path(root_dir_name) / file_path.relative_to(repo_path)
zf.write(file_path, arcname)
zip_buffer.seek(0)
# 3. Настройка клиента Superset
env = self.config_manager.get_environment(env_id)
if not env:
raise ValueError(f"Environment {env_id} not found")
client = SupersetClient(env)
client.authenticate()
# 4. Импорт
temp_zip_path = repo_path / f"deploy_{dashboard_id}.zip"
logger.info(f"[_handle_deploy][Action] Saving temporary zip to {temp_zip_path}")
with open(temp_zip_path, "wb") as f:
f.write(zip_buffer.getvalue())
try:
logger.info(f"[_handle_deploy][Action] Importing dashboard to {env.name}")
result = client.import_dashboard(temp_zip_path)
logger.info(f"[_handle_deploy][Coherence:OK] Deployment successful for dashboard {dashboard_id}.")
return {"status": "success", "message": f"Dashboard deployed to {env.name}", "details": result}
finally:
if temp_zip_path.exists():
os.remove(temp_zip_path)
except Exception as e:
logger.error(f"[_handle_deploy][Coherence:Failed] Deployment failed: {e}")
raise
# [/DEF:_handle_deploy:Function]
# [DEF:_get_env:Function]
# @PURPOSE: Вспомогательный метод для получения конфигурации окружения.
# @PARAM: env_id (Optional[str]) - ID окружения.
# @RETURN: Environment - Объект конфигурации окружения.
def _get_env(self, env_id: Optional[str] = None):
with belief_scope("GitPlugin._get_env"):
logger.info(f"[_get_env][Entry] Fetching environment for ID: {env_id}")
# Priority 1: ConfigManager (config.json)
if env_id:
env = self.config_manager.get_environment(env_id)
if env:
logger.info(f"[_get_env][Exit] Found environment by ID in ConfigManager: {env.name}")
return env
# Priority 2: Database (DeploymentEnvironment)
from src.core.database import SessionLocal
from src.models.git import DeploymentEnvironment
db = SessionLocal()
try:
if env_id:
db_env = db.query(DeploymentEnvironment).filter(DeploymentEnvironment.id == env_id).first()
else:
# If no ID, try to find active or any environment in DB
db_env = db.query(DeploymentEnvironment).filter(DeploymentEnvironment.is_active == True).first()
if not db_env:
db_env = db.query(DeploymentEnvironment).first()
if db_env:
logger.info(f"[_get_env][Exit] Found environment in DB: {db_env.name}")
from src.core.config_models import Environment
# Use token as password for SupersetClient
return Environment(
id=db_env.id,
name=db_env.name,
url=db_env.superset_url,
username="admin",
password=db_env.superset_token,
verify_ssl=True
)
finally:
db.close()
# Priority 3: ConfigManager Default (if no env_id provided)
envs = self.config_manager.get_environments()
if envs:
if env_id:
# If env_id was provided but not found in DB or specifically by ID in config,
# but we have other envs, maybe it's one of them?
env = next((e for e in envs if e.id == env_id), None)
if env:
logger.info(f"[_get_env][Exit] Found environment {env_id} in ConfigManager list")
return env
if not env_id:
logger.info(f"[_get_env][Exit] Using first environment from ConfigManager: {envs[0].name}")
return envs[0]
logger.error(f"[_get_env][Coherence:Failed] No environments configured (searched config.json and DB). env_id={env_id}")
raise ValueError("No environments configured. Please add a Superset Environment in Settings.")
# [/DEF:_get_env:Function]
# [/DEF:GitPlugin:Class]
# [/DEF:backend.src.plugins.git_plugin:Module]

View File

@@ -0,0 +1,380 @@
# [DEF:backend.src.services.git_service:Module]
#
# @SEMANTICS: git, service, gitpython, repository, version_control
# @PURPOSE: Core Git logic using GitPython to manage dashboard repositories.
# @LAYER: Service
# @RELATION: INHERITS_FROM -> None
# @RELATION: USED_BY -> src.api.routes.git
# @RELATION: USED_BY -> src.plugins.git_plugin
#
# @INVARIANT: All Git operations must be performed on a valid local directory.
import os
import shutil
import httpx
from git import Repo, RemoteProgress
from fastapi import HTTPException
from typing import List, Optional
from datetime import datetime
from src.core.logger import logger, belief_scope
from src.models.git import GitProvider
# [DEF:GitService:Class]
# @PURPOSE: Wrapper for GitPython operations with semantic logging and error handling.
class GitService:
"""
Wrapper for GitPython operations.
"""
# [DEF:__init__:Function]
# @PURPOSE: Initializes the GitService with a base path for repositories.
# @PARAM: base_path (str) - Root directory for all Git clones.
def __init__(self, base_path: str = "backend/git_repos"):
with belief_scope("GitService.__init__"):
self.base_path = base_path
if not os.path.exists(self.base_path):
os.makedirs(self.base_path)
# [/DEF:__init__:Function]
# [DEF:_get_repo_path:Function]
# @PURPOSE: Resolves the local filesystem path for a dashboard's repository.
# @PARAM: dashboard_id (int)
# @RETURN: str
def _get_repo_path(self, dashboard_id: int) -> str:
return os.path.join(self.base_path, str(dashboard_id))
# [/DEF:_get_repo_path:Function]
# [DEF:init_repo:Function]
# @PURPOSE: Initialize or clone a repository for a dashboard.
# @PARAM: dashboard_id (int)
# @PARAM: remote_url (str)
# @PARAM: pat (str) - Personal Access Token for authentication.
# @RETURN: Repo - GitPython Repo object.
def init_repo(self, dashboard_id: int, remote_url: str, pat: str) -> Repo:
with belief_scope("GitService.init_repo"):
repo_path = self._get_repo_path(dashboard_id)
# Inject PAT into remote URL if needed
if pat and "://" in remote_url:
proto, rest = remote_url.split("://", 1)
auth_url = f"{proto}://oauth2:{pat}@{rest}"
else:
auth_url = remote_url
if os.path.exists(repo_path):
logger.info(f"[init_repo][Action] Opening existing repo at {repo_path}")
return Repo(repo_path)
logger.info(f"[init_repo][Action] Cloning {remote_url} to {repo_path}")
return Repo.clone_from(auth_url, repo_path)
# [/DEF:init_repo:Function]
# [DEF:get_repo:Function]
# @PURPOSE: Get Repo object for a dashboard.
# @PRE: Repository must exist on disk.
# @RETURN: Repo
def get_repo(self, dashboard_id: int) -> Repo:
with belief_scope("GitService.get_repo"):
repo_path = self._get_repo_path(dashboard_id)
if not os.path.exists(repo_path):
logger.error(f"[get_repo][Coherence:Failed] Repository for dashboard {dashboard_id} does not exist")
raise HTTPException(status_code=404, detail=f"Repository for dashboard {dashboard_id} not found")
try:
return Repo(repo_path)
except Exception as e:
logger.error(f"[get_repo][Coherence:Failed] Failed to open repository at {repo_path}: {e}")
raise HTTPException(status_code=500, detail="Failed to open local Git repository")
# [/DEF:get_repo:Function]
# [DEF:list_branches:Function]
# @PURPOSE: List all branches for a dashboard's repository.
# @RETURN: List[dict]
def list_branches(self, dashboard_id: int) -> List[dict]:
with belief_scope("GitService.list_branches"):
repo = self.get_repo(dashboard_id)
logger.info(f"[list_branches][Action] Listing branches for {dashboard_id}. Refs: {repo.refs}")
branches = []
# Add existing refs
for ref in repo.refs:
try:
# Strip prefixes for UI
name = ref.name.replace('refs/heads/', '').replace('refs/remotes/origin/', '')
# Avoid duplicates (e.g. local and remote with same name)
if any(b['name'] == name for b in branches):
continue
branches.append({
"name": name,
"commit_hash": ref.commit.hexsha if hasattr(ref, 'commit') else "0000000",
"is_remote": ref.is_remote() if hasattr(ref, 'is_remote') else False,
"last_updated": datetime.fromtimestamp(ref.commit.committed_date) if hasattr(ref, 'commit') else datetime.utcnow()
})
except Exception as e:
logger.warning(f"[list_branches][Action] Skipping ref {ref}: {e}")
# Ensure the current active branch is in the list even if it has no commits or refs
try:
active_name = repo.active_branch.name
if not any(b['name'] == active_name for b in branches):
branches.append({
"name": active_name,
"commit_hash": "0000000",
"is_remote": False,
"last_updated": datetime.utcnow()
})
except Exception as e:
logger.warning(f"[list_branches][Action] Could not determine active branch: {e}")
# If everything else failed and list is still empty, add default
if not branches:
branches.append({
"name": "main",
"commit_hash": "0000000",
"is_remote": False,
"last_updated": datetime.utcnow()
})
return branches
# [/DEF:list_branches:Function]
# [DEF:create_branch:Function]
# @PURPOSE: Create a new branch from an existing one.
# @PARAM: name (str) - New branch name.
# @PARAM: from_branch (str) - Source branch.
def create_branch(self, dashboard_id: int, name: str, from_branch: str = "main"):
with belief_scope("GitService.create_branch"):
repo = self.get_repo(dashboard_id)
logger.info(f"[create_branch][Action] Creating branch {name} from {from_branch}")
# Handle empty repository case (no commits)
if not repo.heads and not repo.remotes:
logger.warning(f"[create_branch][Action] Repository is empty. Creating initial commit to enable branching.")
readme_path = os.path.join(repo.working_dir, "README.md")
if not os.path.exists(readme_path):
with open(readme_path, "w") as f:
f.write(f"# Dashboard {dashboard_id}\nGit repository for Superset dashboard integration.")
repo.index.add(["README.md"])
repo.index.commit("Initial commit")
# Verify source branch exists
try:
repo.commit(from_branch)
except:
logger.warning(f"[create_branch][Action] Source branch {from_branch} not found, using HEAD")
from_branch = repo.head
try:
new_branch = repo.create_head(name, from_branch)
return new_branch
except Exception as e:
logger.error(f"[create_branch][Coherence:Failed] {e}")
raise
# [/DEF:create_branch:Function]
# [/DEF:create_branch:Function]
# [DEF:checkout_branch:Function]
# @PURPOSE: Switch to a specific branch.
def checkout_branch(self, dashboard_id: int, name: str):
with belief_scope("GitService.checkout_branch"):
repo = self.get_repo(dashboard_id)
logger.info(f"[checkout_branch][Action] Checking out branch {name}")
repo.git.checkout(name)
# [/DEF:checkout_branch:Function]
# [DEF:commit_changes:Function]
# @PURPOSE: Stage and commit changes.
# @PARAM: message (str) - Commit message.
# @PARAM: files (List[str]) - Optional list of specific files to stage.
def commit_changes(self, dashboard_id: int, message: str, files: List[str] = None):
with belief_scope("GitService.commit_changes"):
repo = self.get_repo(dashboard_id)
# Check if there are any changes to commit
if not repo.is_dirty(untracked_files=True) and not files:
logger.info(f"[commit_changes][Action] No changes to commit for dashboard {dashboard_id}")
return
if files:
logger.info(f"[commit_changes][Action] Staging files: {files}")
repo.index.add(files)
else:
logger.info("[commit_changes][Action] Staging all changes")
repo.git.add(A=True)
repo.index.commit(message)
logger.info(f"[commit_changes][Coherence:OK] Committed changes with message: {message}")
# [/DEF:commit_changes:Function]
# [DEF:push_changes:Function]
# @PURPOSE: Push local commits to remote.
def push_changes(self, dashboard_id: int):
with belief_scope("GitService.push_changes"):
repo = self.get_repo(dashboard_id)
# Ensure we have something to push
if not repo.heads:
logger.warning(f"[push_changes][Coherence:Failed] No local branches to push for dashboard {dashboard_id}")
return
try:
origin = repo.remote(name='origin')
except ValueError:
logger.error(f"[push_changes][Coherence:Failed] Remote 'origin' not found for dashboard {dashboard_id}")
raise HTTPException(status_code=400, detail="Remote 'origin' not configured")
# Check if current branch has an upstream
try:
current_branch = repo.active_branch
logger.info(f"[push_changes][Action] Pushing branch {current_branch.name} to origin")
# Using a timeout for network operations
push_info = origin.push(refspec=f'{current_branch.name}:{current_branch.name}')
for info in push_info:
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 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)}")
# [/DEF:push_changes:Function]
# [DEF:pull_changes:Function]
# @PURPOSE: Pull changes from remote.
def pull_changes(self, dashboard_id: int):
with belief_scope("GitService.pull_changes"):
repo = self.get_repo(dashboard_id)
try:
origin = repo.remote(name='origin')
logger.info("[pull_changes][Action] Pulling changes from origin")
fetch_info = origin.pull()
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}")
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 Exception as e:
logger.error(f"[pull_changes][Coherence:Failed] Failed to pull changes: {e}")
raise HTTPException(status_code=500, detail=f"Git pull failed: {str(e)}")
# [/DEF:pull_changes:Function]
# [DEF:get_status:Function]
# @PURPOSE: Get current repository status (dirty files, untracked, etc.)
# @RETURN: dict
def get_status(self, dashboard_id: int) -> dict:
with belief_scope("GitService.get_status"):
repo = self.get_repo(dashboard_id)
# Handle empty repository (no commits)
has_commits = False
try:
repo.head.commit
has_commits = True
except (ValueError, Exception):
has_commits = False
return {
"is_dirty": repo.is_dirty(untracked_files=True),
"untracked_files": repo.untracked_files,
"modified_files": [item.a_path for item in repo.index.diff(None)],
"staged_files": [item.a_path for item in repo.index.diff("HEAD")] if has_commits else [],
"current_branch": repo.active_branch.name
}
# [/DEF:get_status:Function]
# [DEF:get_diff:Function]
# @PURPOSE: Generate diff for a file or the whole repository.
# @PARAM: file_path (str) - Optional specific file.
# @PARAM: staged (bool) - Whether to show staged changes.
# @RETURN: str
def get_diff(self, dashboard_id: int, file_path: str = None, staged: bool = False) -> str:
with belief_scope("GitService.get_diff"):
repo = self.get_repo(dashboard_id)
diff_args = []
if staged:
diff_args.append("--staged")
if file_path:
return repo.git.diff(*diff_args, "--", file_path)
return repo.git.diff(*diff_args)
# [/DEF:get_diff:Function]
# [DEF:get_commit_history:Function]
# @PURPOSE: Retrieve commit history for a repository.
# @PARAM: limit (int) - Max number of commits to return.
# @RETURN: List[dict]
def get_commit_history(self, dashboard_id: int, limit: int = 50) -> List[dict]:
with belief_scope("GitService.get_commit_history"):
repo = self.get_repo(dashboard_id)
commits = []
try:
# Check if there are any commits at all
if not repo.heads and not repo.remotes:
return []
for commit in repo.iter_commits(max_count=limit):
commits.append({
"hash": commit.hexsha,
"author": commit.author.name,
"email": commit.author.email,
"timestamp": datetime.fromtimestamp(commit.committed_date),
"message": commit.message.strip(),
"files_changed": list(commit.stats.files.keys())
})
except Exception as e:
logger.warning(f"[get_commit_history][Action] Could not retrieve commit history for dashboard {dashboard_id}: {e}")
return []
return commits
# [/DEF:get_commit_history:Function]
# [DEF:test_connection:Function]
# @PURPOSE: Test connection to Git provider using PAT.
# @PARAM: provider (GitProvider)
# @PARAM: url (str)
# @PARAM: pat (str)
# @RETURN: bool
async def test_connection(self, provider: GitProvider, url: str, pat: str) -> bool:
with belief_scope("GitService.test_connection"):
# Check for offline mode or local-only URLs
if ".local" in url or "localhost" in url:
logger.info("[test_connection][Action] Local/Offline mode detected for URL")
return True
if not url.startswith(('http://', 'https://')):
logger.error(f"[test_connection][Coherence:Failed] Invalid URL protocol: {url}")
return False
if not pat or not pat.strip():
logger.error("[test_connection][Coherence:Failed] Git PAT is missing or empty")
return False
pat = pat.strip()
try:
async with httpx.AsyncClient() as client:
if provider == GitProvider.GITHUB:
headers = {"Authorization": f"token {pat}"}
api_url = "https://api.github.com/user" if "github.com" in url else f"{url.rstrip('/')}/api/v3/user"
resp = await client.get(api_url, headers=headers)
elif provider == GitProvider.GITLAB:
headers = {"PRIVATE-TOKEN": pat}
api_url = f"{url.rstrip('/')}/api/v4/user"
resp = await client.get(api_url, headers=headers)
elif provider == GitProvider.GITEA:
headers = {"Authorization": f"token {pat}"}
api_url = f"{url.rstrip('/')}/api/v1/user"
resp = await client.get(api_url, headers=headers)
else:
return False
if resp.status_code != 200:
logger.error(f"[test_connection][Coherence:Failed] Git connection test failed for {provider} at {api_url}. Status: {resp.status_code}")
return resp.status_code == 200
except Exception as e:
logger.error(f"[test_connection][Coherence:Failed] Error testing git connection: {e}")
return False
# [/DEF:test_connection:Function]
# [/DEF:GitService:Class]
# [/DEF:backend.src.services.git_service:Module]

Binary file not shown.

7
frontend/.eslintignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
dist/
build/
.svelte-kit/
.vite/
coverage/
*.min.js

9
frontend/.prettierignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules/
dist/
build/
.svelte-kit/
.vite/
coverage/
package-lock.json
yarn.lock
pnpm-lock.yaml

View File

@@ -12,6 +12,7 @@
// [SECTION: IMPORTS]
import { createEventDispatcher } from 'svelte';
import type { DashboardMetadata } from '../types/dashboard';
import GitManager from './git/GitManager.svelte';
// [/SECTION]
// [SECTION: PROPS]
@@ -27,6 +28,12 @@
let sortDirection: "asc" | "desc" = "asc";
// [/SECTION]
// [SECTION: UI STATE]
let showGitManager = false;
let gitDashboardId: number | null = null;
let gitDashboardTitle = "";
// [/SECTION]
// [SECTION: DERIVED]
$: filteredDashboards = dashboards.filter(d =>
d.title.toLowerCase().includes(filterText.toLowerCase())
@@ -120,6 +127,17 @@
}
// [/DEF:goToPage:Function]
// [DEF:openGit:Function]
/**
* @purpose Opens the Git management modal for a dashboard.
*/
function openGit(dashboard: DashboardMetadata) {
gitDashboardId = dashboard.id;
gitDashboardTitle = dashboard.title;
showGitManager = true;
}
// [/DEF:openGit:Function]
</script>
<!-- [SECTION: TEMPLATE] -->
@@ -156,6 +174,7 @@
<th class="px-4 py-2 border-b cursor-pointer" on:click={() => handleSort('status')}>
Status {sortColumn === 'status' ? (sortDirection === 'asc' ? '↑' : '↓') : ''}
</th>
<th class="px-4 py-2 border-b">Git</th>
</tr>
</thead>
<tbody>
@@ -175,6 +194,14 @@
{dashboard.status}
</span>
</td>
<td class="px-4 py-2 border-b">
<button
on:click={() => openGit(dashboard)}
class="text-indigo-600 hover:text-indigo-900 text-sm font-medium"
>
Manage Git
</button>
</td>
</tr>
{/each}
</tbody>
@@ -204,6 +231,15 @@
</div>
</div>
</div>
{#if showGitManager && gitDashboardId}
<GitManager
dashboardId={gitDashboardId}
dashboardTitle={gitDashboardTitle}
bind:show={showGitManager}
/>
{/if}
<!-- [/SECTION] -->
<style>

View File

@@ -29,6 +29,12 @@
>
Migration
</a>
<a
href="/git"
class="text-gray-600 hover:text-blue-600 font-medium {$page.url.pathname.startsWith('/git') ? 'text-blue-600 border-b-2 border-blue-600' : ''}"
>
Git
</a>
<a
href="/tasks"
class="text-gray-600 hover:text-blue-600 font-medium {$page.url.pathname.startsWith('/tasks') ? 'text-blue-600 border-b-2 border-blue-600' : ''}"
@@ -52,6 +58,8 @@
<div class="absolute hidden group-hover:block bg-white shadow-lg rounded-md mt-1 py-2 w-48 z-10 border border-gray-100 before:absolute before:-top-2 before:left-0 before:right-0 before:h-2 before:content-[''] right-0">
<a href="/settings" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">General Settings</a>
<a href="/settings/connections" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">Connections</a>
<a href="/settings/git" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">Git Integration</a>
<a href="/settings/environments" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">Environments</a>
</div>
</div>
</nav>

View File

@@ -0,0 +1,170 @@
<!-- [DEF:BranchSelector:Component] -->
<!--
@SEMANTICS: git, branch, selection, checkout
@PURPOSE: UI для выбора и создания веток Git.
@LAYER: Component
@RELATION: CALLS -> gitService.getBranches
@RELATION: CALLS -> gitService.checkoutBranch
@RELATION: CALLS -> gitService.createBranch
@RELATION: DISPATCHES -> change
-->
<script>
// [SECTION: IMPORTS]
import { onMount, createEventDispatcher } from 'svelte';
import { gitService } from '../../services/gitService';
import { addToast as toast } from '../../lib/toasts.js';
// [/SECTION]
// [SECTION: PROPS]
export let dashboardId;
export let currentBranch = 'main';
// [/SECTION]
// [SECTION: STATE]
let branches = [];
let loading = false;
let showCreate = false;
let newBranchName = '';
// [/SECTION]
const dispatch = createEventDispatcher();
// [DEF:onMount:Function]
onMount(async () => {
await loadBranches();
});
// [/DEF:onMount:Function]
// [DEF:loadBranches:Function]
/**
* @purpose Загружает список веток для дашборда.
* @post branches обновлен.
*/
async function loadBranches() {
console.log(`[BranchSelector][Action] Loading branches for dashboard ${dashboardId}`);
loading = true;
try {
branches = await gitService.getBranches(dashboardId);
console.log(`[BranchSelector][Coherence:OK] Loaded ${branches.length} branches`);
} catch (e) {
console.error(`[BranchSelector][Coherence:Failed] ${e.message}`);
toast('Failed to load branches', 'error');
} finally {
loading = false;
}
}
// [/DEF:loadBranches:Function]
// [DEF:handleSelect:Function]
function handleSelect(event) {
handleCheckout(event.target.value);
}
// [/DEF:handleSelect:Function]
// [DEF:handleCheckout:Function]
/**
* @purpose Переключает текущую ветку.
* @param {string} branchName - Имя ветки.
* @post currentBranch обновлен, событие отправлено.
*/
async function handleCheckout(branchName) {
console.log(`[BranchSelector][Action] Checking out branch ${branchName}`);
try {
await gitService.checkoutBranch(dashboardId, branchName);
currentBranch = branchName;
dispatch('change', { branch: branchName });
toast(`Switched to ${branchName}`, 'success');
console.log(`[BranchSelector][Coherence:OK] Checked out ${branchName}`);
} catch (e) {
console.error(`[BranchSelector][Coherence:Failed] ${e.message}`);
toast(e.message, 'error');
}
}
// [/DEF:handleCheckout:Function]
// [DEF:handleCreate:Function]
/**
* @purpose Создает новую ветку.
* @post Новая ветка создана и загружена.
*/
async function handleCreate() {
if (!newBranchName) return;
console.log(`[BranchSelector][Action] Creating branch ${newBranchName} from ${currentBranch}`);
try {
await gitService.createBranch(dashboardId, newBranchName, currentBranch);
toast(`Created branch ${newBranchName}`, 'success');
showCreate = false;
newBranchName = '';
await loadBranches();
console.log(`[BranchSelector][Coherence:OK] Branch created`);
} catch (e) {
console.error(`[BranchSelector][Coherence:Failed] ${e.message}`);
toast(e.message, 'error');
}
}
// [/DEF:handleCreate:Function]
</script>
<!-- [SECTION: TEMPLATE] -->
<div class="space-y-2">
<div class="flex items-center space-x-2">
<div class="relative">
<select
value={currentBranch}
on:change={handleSelect}
disabled={loading}
class="bg-white border rounded px-3 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{#each branches as branch}
<option value={branch.name}>{branch.name}</option>
{/each}
</select>
{#if loading}
<span class="absolute -right-6 top-1">
<svg class="animate-spin h-4 w-4 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</span>
{/if}
</div>
<button
on:click={() => showCreate = !showCreate}
disabled={loading}
class="text-blue-600 hover:text-blue-800 text-sm font-medium disabled:opacity-50"
>
+ New Branch
</button>
</div>
{#if showCreate}
<div class="flex items-center space-x-1 bg-gray-50 p-2 rounded border border-dashed">
<input
type="text"
bind:value={newBranchName}
placeholder="branch-name"
disabled={loading}
class="border rounded px-2 py-1 text-sm w-full max-w-[150px]"
/>
<button
on:click={handleCreate}
disabled={loading || !newBranchName}
class="bg-green-600 text-white px-3 py-1 rounded text-xs font-medium hover:bg-green-700 disabled:opacity-50"
>
{loading ? '...' : 'Create'}
</button>
<button
on:click={() => showCreate = false}
disabled={loading}
class="text-gray-500 hover:text-gray-700 text-xs px-2 py-1 disabled:opacity-50"
>
Cancel
</button>
</div>
{/if}
</div>
<!-- [/SECTION] -->
<!-- [/DEF:BranchSelector:Component] -->

View File

@@ -0,0 +1,90 @@
<!-- [DEF:CommitHistory:Component] -->
<!--
@SEMANTICS: git, history, commits, audit
@PURPOSE: Displays the commit history for a specific dashboard.
@LAYER: Component
@RELATION: CALLS -> gitService.getHistory
-->
<script>
// [SECTION: IMPORTS]
import { onMount } from 'svelte';
import { gitService } from '../../services/gitService';
import { addToast as toast } from '../../lib/toasts.js';
// [/SECTION]
// [SECTION: PROPS]
export let dashboardId;
// [/SECTION]
// [SECTION: STATE]
let history = [];
let loading = false;
// [/SECTION]
// [DEF:onMount:Function]
/**
* @purpose Load history when component is mounted.
*/
onMount(async () => {
await loadHistory();
});
// [/DEF:onMount:Function]
// [DEF:loadHistory:Function]
/**
* @purpose Fetch commit history from the backend.
* @post history state is updated.
*/
async function loadHistory() {
console.log(`[CommitHistory][Action] Loading history for dashboard ${dashboardId}`);
loading = true;
try {
history = await gitService.getHistory(dashboardId);
console.log(`[CommitHistory][Coherence:OK] Loaded ${history.length} commits`);
} catch (e) {
console.error(`[CommitHistory][Coherence:Failed] ${e.message}`);
toast('Failed to load commit history', 'error');
} finally {
loading = false;
}
}
// [/DEF:loadHistory:Function]
</script>
<!-- [SECTION: TEMPLATE] -->
<div class="mt-6">
<h3 class="text-lg font-semibold mb-4 flex justify-between items-center">
Commit History
<button on:click={loadHistory} class="text-sm text-blue-600 hover:underline">Refresh</button>
</h3>
{#if loading}
<div class="flex items-center space-x-2 text-gray-500">
<svg class="animate-spin h-4 w-4 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>Loading history...</span>
</div>
{:else if history.length === 0}
<p class="text-gray-500 italic">No commits yet.</p>
{:else}
<div class="space-y-3 max-h-96 overflow-y-auto pr-2">
{#each history as commit}
<div class="border-l-2 border-blue-500 pl-4 py-1">
<div class="flex justify-between items-start">
<span class="font-medium text-sm">{commit.message}</span>
<span class="text-xs text-gray-400 font-mono">{commit.hash.substring(0, 7)}</span>
</div>
<div class="text-xs text-gray-500 mt-1">
{commit.author}{new Date(commit.timestamp).toLocaleString()}
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- [/SECTION] -->
<!-- [/DEF:CommitHistory:Component] -->

View File

@@ -0,0 +1,175 @@
<!-- [DEF:CommitModal:Component] -->
<!--
@SEMANTICS: git, commit, modal, version_control, diff
@PURPOSE: Модальное окно для создания коммита с просмотром изменений (diff).
@LAYER: Component
@RELATION: CALLS -> gitService.commit
@RELATION: CALLS -> gitService.getStatus
@RELATION: CALLS -> gitService.getDiff
@RELATION: DISPATCHES -> commit
-->
<script>
// [SECTION: IMPORTS]
import { createEventDispatcher, onMount } from 'svelte';
import { gitService } from '../../services/gitService';
import { addToast as toast } from '../../lib/toasts.js';
// [/SECTION]
// [SECTION: PROPS]
export let dashboardId;
export let show = false;
// [/SECTION]
// [SECTION: STATE]
let message = '';
let committing = false;
let status = null;
let diff = '';
let loading = false;
// [/SECTION]
const dispatch = createEventDispatcher();
// [DEF:loadStatus:Function]
/**
* @purpose Загружает текущий статус репозитория и diff.
* @pre dashboardId должен быть валидным.
*/
async function loadStatus() {
if (!dashboardId || !show) return;
loading = true;
try {
console.log(`[CommitModal][Action] Loading status and diff for ${dashboardId}`);
status = await gitService.getStatus(dashboardId);
// Fetch both unstaged and staged diffs to show complete picture
const unstagedDiff = await gitService.getDiff(dashboardId, null, false);
const stagedDiff = await gitService.getDiff(dashboardId, null, true);
diff = "";
if (stagedDiff) diff += "--- STAGED CHANGES ---\n" + stagedDiff + "\n\n";
if (unstagedDiff) diff += "--- UNSTAGED CHANGES ---\n" + unstagedDiff;
if (!diff) diff = "";
} catch (e) {
console.error(`[CommitModal][Coherence:Failed] ${e.message}`);
toast('Failed to load changes', 'error');
} finally {
loading = false;
}
}
// [/DEF:loadStatus:Function]
// [DEF:handleCommit:Function]
/**
* @purpose Создает коммит с указанным сообщением.
* @pre message не должно быть пустым.
* @post Коммит создан, событие отправлено, модальное окно закрыто.
*/
async function handleCommit() {
if (!message) return;
console.log(`[CommitModal][Action] Committing changes for dashboard ${dashboardId}`);
committing = true;
try {
await gitService.commit(dashboardId, message, []);
toast('Changes committed successfully', 'success');
dispatch('commit');
show = false;
message = '';
console.log(`[CommitModal][Coherence:OK] Committed`);
} catch (e) {
console.error(`[CommitModal][Coherence:Failed] ${e.message}`);
toast(e.message, 'error');
} finally {
committing = false;
}
}
// [/DEF:handleCommit:Function]
$: if (show) loadStatus();
</script>
<!-- [SECTION: TEMPLATE] -->
{#if show}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div class="bg-white p-6 rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] flex flex-col">
<h2 class="text-xl font-bold mb-4">Commit Changes</h2>
<div class="flex flex-col md:flex-row gap-4 flex-1 overflow-hidden">
<!-- Left: Message and Files -->
<div class="w-full md:w-1/3 flex flex-col">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Commit Message</label>
<textarea
bind:value={message}
class="w-full border rounded p-2 h-32 focus:ring-2 focus:ring-blue-500 outline-none resize-none"
placeholder="Describe your changes..."
></textarea>
</div>
{#if status}
<div class="flex-1 overflow-y-auto">
<h3 class="text-sm font-bold text-gray-500 uppercase mb-2">Changed Files</h3>
<ul class="text-xs space-y-1">
{#each status.staged_files as file}
<li class="text-green-600 flex items-center font-semibold" title="Staged">
<span class="mr-2">S</span> {file}
</li>
{/each}
{#each status.modified_files as file}
<li class="text-yellow-600 flex items-center" title="Modified (Unstaged)">
<span class="mr-2">M</span> {file}
</li>
{/each}
{#each status.untracked_files as file}
<li class="text-blue-600 flex items-center" title="Untracked">
<span class="mr-2">?</span> {file}
</li>
{/each}
</ul>
</div>
{/if}
</div>
<!-- Right: Diff Viewer -->
<div class="w-full md:w-2/3 flex flex-col overflow-hidden border rounded bg-gray-50">
<div class="bg-gray-200 px-3 py-1 text-xs font-bold text-gray-600 border-b">Changes Preview</div>
<div class="flex-1 overflow-auto p-2">
{#if loading}
<div class="flex items-center justify-center h-full text-gray-500">Loading diff...</div>
{:else if diff}
<pre class="text-xs font-mono whitespace-pre-wrap">{diff}</pre>
{:else}
<div class="flex items-center justify-center h-full text-gray-500 italic">No changes detected</div>
{/if}
</div>
</div>
</div>
<div class="flex justify-end space-x-3 mt-6 pt-4 border-t">
<button
on:click={() => show = false}
class="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded"
>
Cancel
</button>
<button
on:click={handleCommit}
disabled={committing || !message || loading || (!status?.is_dirty && status?.staged_files?.length === 0)}
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
>
{committing ? 'Committing...' : 'Commit'}
</button>
</div>
</div>
</div>
{/if}
<!-- [/SECTION] -->
<style>
pre {
tab-size: 4;
}
</style>
<!-- [/DEF:CommitModal:Component] -->

View File

@@ -0,0 +1,142 @@
<!-- [DEF:ConflictResolver:Component] -->
<!--
@SEMANTICS: git, conflict, resolution, merge
@PURPOSE: UI for resolving merge conflicts (Keep Mine / Keep Theirs).
@LAYER: Component
@RELATION: DISPATCHES -> resolve
@INVARIANT: User must resolve all conflicts before saving.
-->
<script>
// [SECTION: IMPORTS]
import { createEventDispatcher } from 'svelte';
import { addToast as toast } from '../../lib/toasts.js';
// [/SECTION]
// [SECTION: PROPS]
/** @type {Array<{file_path: string, mine: string, theirs: string}>} */
export let conflicts = [];
export let show = false;
// [/SECTION]
// [SECTION: STATE]
const dispatch = createEventDispatcher();
/** @type {Object.<string, 'mine' | 'theirs' | 'manual'>} */
let resolutions = {};
// [/SECTION]
// [DEF:resolve:Function]
/**
* @purpose Set resolution strategy for a file.
* @pre file path must exist in conflicts array.
* @post resolutions state is updated for the given file.
* @param {string} file - File path.
* @param {'mine'|'theirs'} strategy - Resolution strategy.
* @side_effect Updates resolutions state.
*/
function resolve(file, strategy) {
console.log(`[ConflictResolver][Action] Resolving ${file} with ${strategy}`);
resolutions[file] = strategy;
resolutions = { ...resolutions }; // Trigger update
}
// [/DEF:resolve:Function]
// [DEF:handleSave:Function]
/**
* @purpose Validate and submit resolutions.
* @pre All conflicts must have a resolution.
* @post 'resolve' event dispatched if valid.
* @side_effect Dispatches event and closes modal.
*/
function handleSave() {
// 1. Guard Clause (@PRE)
const unresolved = conflicts.filter(c => !resolutions[c.file_path]);
if (unresolved.length > 0) {
console.warn(`[ConflictResolver][Coherence:Failed] ${unresolved.length} unresolved conflicts`);
toast(`Please resolve all conflicts first. (${unresolved.length} remaining)`, 'error');
return;
}
// 2. Implementation
console.log(`[ConflictResolver][Coherence:OK] All conflicts resolved`);
dispatch('resolve', resolutions);
show = false;
}
// [/DEF:handleSave:Function]
</script>
<!-- [SECTION: TEMPLATE] -->
{#if show}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div class="bg-white p-6 rounded-lg shadow-xl w-full max-w-5xl max-h-[90vh] flex flex-col">
<h2 class="text-xl font-bold mb-4 text-red-600">Merge Conflicts Detected</h2>
<p class="text-gray-600 mb-4">The following files have conflicts. Please choose how to resolve them.</p>
<div class="flex-1 overflow-y-auto space-y-6 mb-4 pr-2">
{#each conflicts as conflict}
<div class="border rounded-lg overflow-hidden">
<div class="bg-gray-100 px-4 py-2 font-medium border-b flex justify-between items-center">
<span>{conflict.file_path}</span>
{#if resolutions[conflict.file_path]}
<span class="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full uppercase font-bold">
Resolved: {resolutions[conflict.file_path]}
</span>
{/if}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-0 divide-x">
<div class="p-0 flex flex-col">
<div class="bg-blue-50 px-4 py-1 text-[10px] font-bold text-blue-600 uppercase border-b">Your Changes (Mine)</div>
<div class="p-4 bg-white flex-1 overflow-auto">
<pre class="text-xs font-mono whitespace-pre">{conflict.mine}</pre>
</div>
<button
class="w-full py-2 text-sm font-medium border-t transition-colors {resolutions[conflict.file_path] === 'mine' ? 'bg-blue-600 text-white' : 'bg-gray-50 hover:bg-blue-50 text-blue-600'}"
on:click={() => resolve(conflict.file_path, 'mine')}
>
Keep Mine
</button>
</div>
<div class="p-0 flex flex-col">
<div class="bg-green-50 px-4 py-1 text-[10px] font-bold text-green-600 uppercase border-b">Remote Changes (Theirs)</div>
<div class="p-4 bg-white flex-1 overflow-auto">
<pre class="text-xs font-mono whitespace-pre">{conflict.theirs}</pre>
</div>
<button
class="w-full py-2 text-sm font-medium border-t transition-colors {resolutions[conflict.file_path] === 'theirs' ? 'bg-green-600 text-white' : 'bg-gray-50 hover:bg-green-50 text-green-600'}"
on:click={() => resolve(conflict.file_path, 'theirs')}
>
Keep Theirs
</button>
</div>
</div>
</div>
{/each}
</div>
<div class="flex justify-end space-x-3 pt-4 border-t">
<button
on:click={() => show = false}
class="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded transition-colors"
>
Cancel
</button>
<button
on:click={handleSave}
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors shadow-sm"
>
Resolve & Continue
</button>
</div>
</div>
</div>
{/if}
<!-- [/SECTION] -->
<style>
pre {
tab-size: 4;
}
</style>
<!-- [/DEF:ConflictResolver:Component] -->

View File

@@ -0,0 +1,147 @@
<!-- [DEF:DeploymentModal:Component] -->
<!--
@SEMANTICS: deployment, git, environment, modal
@PURPOSE: Modal for deploying a dashboard to a target environment.
@LAYER: Component
@RELATION: CALLS -> frontend/src/services/gitService.js
@RELATION: DISPATCHES -> deploy
@INVARIANT: Cannot deploy without a selected environment.
-->
<script>
// [SECTION: IMPORTS]
import { onMount, createEventDispatcher } from 'svelte';
import { gitService } from '../../services/gitService';
import { addToast as toast } from '../../lib/toasts.js';
// [/SECTION]
// [SECTION: PROPS]
export let dashboardId;
export let show = false;
// [/SECTION]
// [SECTION: STATE]
let environments = [];
let selectedEnv = '';
let loading = false;
let deploying = false;
// [/SECTION]
const dispatch = createEventDispatcher();
// [DEF:loadStatus:Watcher]
$: if (show) loadEnvironments();
// [DEF:loadEnvironments:Function]
/**
* @purpose Fetch available environments from API.
* @post environments state is populated.
* @side_effect Updates environments state.
*/
async function loadEnvironments() {
console.log(`[DeploymentModal][Action] Loading environments`);
loading = true;
try {
environments = await gitService.getEnvironments();
if (environments.length > 0) {
selectedEnv = environments[0].id;
}
console.log(`[DeploymentModal][Coherence:OK] Loaded ${environments.length} environments`);
} catch (e) {
console.error(`[DeploymentModal][Coherence:Failed] ${e.message}`);
toast('Failed to load environments', 'error');
} finally {
loading = false;
}
}
// [/DEF:loadEnvironments:Function]
// [DEF:handleDeploy:Function]
/**
* @purpose Trigger deployment to selected environment.
* @pre selectedEnv must be set.
* @post deploy event dispatched on success.
* @side_effect Triggers API call, closes modal, shows toast.
*/
async function handleDeploy() {
if (!selectedEnv) return;
console.log(`[DeploymentModal][Action] Deploying to ${selectedEnv}`);
deploying = true;
try {
const result = await gitService.deploy(dashboardId, selectedEnv);
toast(result.message || 'Deployment triggered successfully', 'success');
dispatch('deploy');
show = false;
console.log(`[DeploymentModal][Coherence:OK] Deployment triggered`);
} catch (e) {
console.error(`[DeploymentModal][Coherence:Failed] ${e.message}`);
toast(e.message, 'error');
} finally {
deploying = false;
}
}
// [/DEF:handleDeploy:Function]
</script>
<!-- [SECTION: TEMPLATE] -->
{#if show}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white p-6 rounded-lg shadow-xl w-96">
<h2 class="text-xl font-bold mb-4">Deploy Dashboard</h2>
{#if loading}
<p class="text-gray-500">Loading environments...</p>
{:else if environments.length === 0}
<p class="text-red-500 mb-4">No deployment environments configured.</p>
<div class="flex justify-end">
<button
on:click={() => show = false}
class="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300"
>
Close
</button>
</div>
{:else}
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2">Select Target Environment</label>
<select
bind:value={selectedEnv}
class="w-full border rounded p-2 focus:ring-2 focus:ring-blue-500 outline-none bg-white"
>
{#each environments as env}
<option value={env.id}>{env.name} ({env.superset_url})</option>
{/each}
</select>
</div>
<div class="flex justify-end space-x-3">
<button
on:click={() => show = false}
class="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded"
>
Cancel
</button>
<button
on:click={handleDeploy}
disabled={deploying || !selectedEnv}
class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 flex items-center"
>
{#if deploying}
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Deploying...
{:else}
Deploy
{/if}
</button>
</div>
{/if}
</div>
</div>
{/if}
<!-- [/SECTION] -->
<!-- [/DEF:DeploymentModal:Component] -->

View File

@@ -0,0 +1,284 @@
<!-- [DEF:GitManager:Component] -->
<!--
@SEMANTICS: git, manager, dashboard, version_control, initialization
@PURPOSE: Центральный компонент для управления Git-операциями конкретного дашборда.
@LAYER: Component
@RELATION: USES -> BranchSelector
@RELATION: USES -> CommitModal
@RELATION: USES -> CommitHistory
@RELATION: USES -> DeploymentModal
@RELATION: USES -> ConflictResolver
@RELATION: CALLS -> gitService
-->
<script>
// [SECTION: IMPORTS]
import { onMount } from 'svelte';
import { gitService } from '../../services/gitService';
import { addToast as toast } from '../../lib/toasts.js';
import BranchSelector from './BranchSelector.svelte';
import CommitModal from './CommitModal.svelte';
import CommitHistory from './CommitHistory.svelte';
import DeploymentModal from './DeploymentModal.svelte';
import ConflictResolver from './ConflictResolver.svelte';
// [/SECTION]
// [SECTION: PROPS]
export let dashboardId;
export let dashboardTitle = "";
export let show = false;
// [/SECTION]
// [SECTION: STATE]
let currentBranch = 'main';
let showCommitModal = false;
let showDeployModal = false;
let showHistory = true;
let showConflicts = false;
let conflicts = [];
let loading = false;
let initialized = false;
let checkingStatus = true;
// Initialization form state
let configs = [];
let selectedConfigId = "";
let remoteUrl = "";
// [/SECTION]
// [DEF:checkStatus:Function]
/**
* @purpose Проверяет, инициализирован ли репозиторий для данного дашборда.
*/
async function checkStatus() {
checkingStatus = true;
try {
// If we can get branches, it means repo exists
await gitService.getBranches(dashboardId);
initialized = true;
} catch (e) {
initialized = false;
// Load configs if not initialized
configs = await gitService.getConfigs();
if (configs.length > 0) selectedConfigId = configs[0].id;
} finally {
checkingStatus = false;
}
}
// [/DEF:checkStatus:Function]
// [DEF:handleInit:Function]
/**
* @purpose Инициализирует репозиторий для дашборда.
*/
async function handleInit() {
if (!selectedConfigId || !remoteUrl) {
toast('Please select a Git server and provide remote URL', 'error');
return;
}
loading = true;
try {
await gitService.initRepository(dashboardId, selectedConfigId, remoteUrl);
toast('Repository initialized successfully', 'success');
initialized = true;
} catch (e) {
toast(e.message, 'error');
} finally {
loading = false;
}
}
// [/DEF:handleInit:Function]
// [DEF:handleSync:Function]
/**
* @purpose Синхронизирует состояние Superset с локальным Git-репозиторием.
*/
async function handleSync() {
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);
toast('Dashboard state synced to Git', 'success');
} catch (e) {
toast(e.message, 'error');
} finally {
loading = false;
}
}
// [/DEF:handleSync:Function]
// [DEF:handlePush:Function]
async function handlePush() {
loading = true;
try {
await gitService.push(dashboardId);
toast('Changes pushed to remote', 'success');
} catch (e) {
toast(e.message, 'error');
} finally {
loading = false;
}
}
// [/DEF:handlePush:Function]
// [DEF:handlePull:Function]
async function handlePull() {
loading = true;
try {
await gitService.pull(dashboardId);
toast('Changes pulled from remote', 'success');
} catch (e) {
toast(e.message, 'error');
} finally {
loading = false;
}
}
// [/DEF:handlePull:Function]
onMount(checkStatus);
</script>
<!-- [SECTION: TEMPLATE] -->
{#if show}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white p-6 rounded-lg shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto">
<div class="flex justify-between items-center mb-6 border-b pb-4">
<div>
<h2 class="text-2xl font-bold">Git Management: {dashboardTitle}</h2>
<p class="text-sm text-gray-500">ID: {dashboardId}</p>
</div>
<button on:click={() => show = false} class="text-gray-500 hover:text-gray-700">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{#if checkingStatus}
<div class="flex justify-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
{:else if !initialized}
<div class="max-w-md mx-auto py-8">
<div class="bg-blue-50 border-l-4 border-blue-400 p-4 mb-6">
<p class="text-sm text-blue-700">
This dashboard is not yet linked to a Git repository.
Please configure the repository details below.
</p>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700">Git Server</label>
<select bind:value={selectedConfigId} class="mt-1 block w-full border rounded p-2">
{#each configs as config}
<option value={config.id}>{config.name} ({config.provider})</option>
{/each}
</select>
{#if configs.length === 0}
<p class="text-xs text-red-500 mt-1">No Git servers configured. Go to Settings -> Git to add one.</p>
{/if}
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Remote Repository URL</label>
<input
type="text"
bind:value={remoteUrl}
placeholder="https://github.com/org/repo.git"
class="mt-1 block w-full border rounded p-2"
/>
</div>
<button
on:click={handleInit}
disabled={loading || configs.length === 0}
class="w-full bg-blue-600 text-white py-2 rounded font-medium hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Initializing...' : 'Initialize Repository'}
</button>
</div>
</div>
{:else}
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Left Column: Controls -->
<div class="md:col-span-1 space-y-6">
<section>
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-2">Branch</h3>
<BranchSelector {dashboardId} bind:currentBranch />
</section>
<section class="space-y-2">
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-2">Actions</h3>
<button
on:click={handleSync}
disabled={loading}
class="w-full flex items-center justify-center px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded text-sm font-medium transition"
>
Sync from Superset
</button>
<button
on:click={() => showCommitModal = true}
disabled={loading}
class="w-full flex items-center justify-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm font-medium transition"
>
Commit Changes
</button>
<div class="grid grid-cols-2 gap-2">
<button
on:click={handlePull}
disabled={loading}
class="flex items-center justify-center px-4 py-2 border hover:bg-gray-50 rounded text-sm font-medium transition"
>
Pull
</button>
<button
on:click={handlePush}
disabled={loading}
class="flex items-center justify-center px-4 py-2 border hover:bg-gray-50 rounded text-sm font-medium transition"
>
Push
</button>
</div>
</section>
<section>
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-2">Deployment</h3>
<button
on:click={() => showDeployModal = true}
disabled={loading}
class="w-full flex items-center justify-center px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded text-sm font-medium transition"
>
Deploy to Environment
</button>
</section>
</div>
<!-- Right Column: History -->
<div class="md:col-span-2 border-l pl-6">
<CommitHistory {dashboardId} />
</div>
</div>
{/if}
</div>
</div>
{/if}
<CommitModal
{dashboardId}
bind:show={showCommitModal}
on:commit={() => { /* Refresh history */ }}
/>
<DeploymentModal
{dashboardId}
bind:show={showDeployModal}
/>
<ConflictResolver
{conflicts}
bind:show={showConflicts}
on:resolve={() => { /* Handle resolution */ }}
/>
<!-- [/SECTION] -->
<!-- [/DEF:GitManager:Component] -->

View File

@@ -23,6 +23,8 @@
console.log(`[Dashboard][Action] Selecting plugin: ${plugin.id}`);
if (plugin.id === 'superset-migration') {
goto('/migration');
} else if (plugin.id === 'git-integration') {
goto('/git');
} else {
selectedPlugin.set(plugin);
}

View File

@@ -0,0 +1,86 @@
<!-- [DEF:GitDashboardPage:Component] -->
<script lang="ts">
import { onMount } from 'svelte';
import DashboardGrid from '../../components/DashboardGrid.svelte';
import { addToast as toast } from '../../lib/toasts.js';
import type { DashboardMetadata } from '../../types/dashboard';
let environments: any[] = [];
let selectedEnvId = "";
let dashboards: DashboardMetadata[] = [];
let loading = true;
let fetchingDashboards = false;
async function fetchEnvironments() {
try {
const response = await fetch('/api/environments');
if (!response.ok) throw new Error('Failed to fetch environments');
environments = await response.json();
if (environments.length > 0) {
selectedEnvId = environments[0].id;
}
} catch (e) {
toast(e.message, 'error');
} finally {
loading = false;
}
}
async function fetchDashboards(envId: string) {
if (!envId) return;
fetchingDashboards = true;
try {
const response = await fetch(`/api/environments/${envId}/dashboards`);
if (!response.ok) throw new Error('Failed to fetch dashboards');
dashboards = await response.json();
} catch (e) {
toast(e.message, 'error');
dashboards = [];
} finally {
fetchingDashboards = false;
}
}
onMount(fetchEnvironments);
$: if (selectedEnvId) {
fetchDashboards(selectedEnvId);
localStorage.setItem('selected_env_id', selectedEnvId);
}
</script>
<div class="max-w-6xl mx-auto p-6">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">Git Dashboard Management</h1>
<div class="flex items-center space-x-4">
<label for="env-select" class="text-sm font-medium text-gray-700">Environment:</label>
<select
id="env-select"
bind:value={selectedEnvId}
class="border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 p-2 border bg-white"
>
{#each environments as env}
<option value={env.id}>{env.name}</option>
{/each}
</select>
</div>
</div>
{#if loading}
<div class="flex justify-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
{:else}
<div class="bg-white rounded-lg shadow p-6">
<h2 class="text-lg font-medium mb-4">Select Dashboard to Manage</h2>
{#if fetchingDashboards}
<p class="text-gray-500">Loading dashboards...</p>
{:else if dashboards.length > 0}
<DashboardGrid {dashboards} />
{:else}
<p class="text-gray-500 italic">No dashboards found in this environment.</p>
{/if}
</div>
{/if}
</div>
<!-- [/DEF:GitDashboardPage:Component] -->

View File

@@ -0,0 +1,40 @@
<script>
import { onMount } from 'svelte';
import { gitService } from '../../../services/gitService';
import { addToast as toast } from '../../../lib/toasts.js';
let environments = [];
onMount(async () => {
try {
environments = await gitService.getEnvironments();
} catch (e) {
toast(e.message, 'error');
}
});
</script>
<div class="p-6">
<h1 class="text-2xl font-bold mb-6">Deployment Environments</h1>
<div class="bg-white p-6 rounded shadow">
<h2 class="text-xl font-semibold mb-4">Target Environments</h2>
{#if environments.length === 0}
<p class="text-gray-500">No deployment environments configured.</p>
{:else}
<ul class="divide-y">
{#each environments as env}
<li class="py-3 flex justify-between items-center">
<div>
<span class="font-medium">{env.name}</span>
<div class="text-xs text-gray-400">{env.superset_url}</div>
</div>
<span class="px-2 py-1 text-xs rounded {env.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}">
{env.is_active ? 'Active' : 'Inactive'}
</span>
</li>
{/each}
</ul>
{/if}
</div>
</div>

View File

@@ -0,0 +1,136 @@
<script>
import { onMount } from 'svelte';
import { gitService } from '../../../services/gitService';
import { addToast as toast } from '../../../lib/toasts.js';
let configs = [];
let newConfig = {
name: '',
provider: 'GITHUB',
url: 'https://github.com',
pat: '',
default_repository: ''
};
let testing = false;
onMount(async () => {
try {
configs = await gitService.getConfigs();
} catch (e) {
toast(e.message, 'error');
}
});
async function handleTest() {
testing = true;
try {
const result = await gitService.testConnection(newConfig);
if (result.status === 'success') {
toast('Connection successful', 'success');
} else {
toast(result.message || 'Connection failed', 'error');
}
} catch (e) {
toast('Connection failed', 'error');
} finally {
testing = false;
}
}
async function handleSave() {
try {
const saved = await gitService.createConfig(newConfig);
configs = [...configs, saved];
toast('Configuration saved', 'success');
newConfig = { name: '', provider: 'GITHUB', url: 'https://github.com', pat: '', default_repository: '' };
} catch (e) {
toast(e.message, 'error');
}
}
async function handleDelete(id) {
if (!confirm('Are you sure you want to delete this Git configuration?')) return;
try {
await gitService.deleteConfig(id);
configs = configs.filter(c => c.id !== id);
toast('Configuration deleted', 'success');
} catch (e) {
toast(e.message, 'error');
}
}
</script>
<div class="p-6">
<h1 class="text-2xl font-bold mb-6">Git Integration Settings</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- List of Configs -->
<div class="bg-white p-6 rounded shadow">
<h2 class="text-xl font-semibold mb-4">Configured Servers</h2>
{#if configs.length === 0}
<p class="text-gray-500">No Git servers configured.</p>
{:else}
<ul class="divide-y">
{#each configs as config}
<li class="py-3 flex justify-between items-center">
<div>
<span class="font-medium">{config.name}</span>
<span class="text-sm text-gray-500 ml-2">({config.provider})</span>
<div class="text-xs text-gray-400">{config.url}</div>
</div>
<div class="flex items-center space-x-4">
<span class="px-2 py-1 text-xs rounded {config.status === 'CONNECTED' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}">
{config.status}
</span>
<button on:click={() => handleDelete(config.id)} class="text-red-600 hover:text-red-800 p-1" title="Delete">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</button>
</div>
</li>
{/each}
</ul>
{/if}
</div>
<!-- Add New Config -->
<div class="bg-white p-6 rounded shadow">
<h2 class="text-xl font-semibold mb-4">Add Git Server</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700">Display Name</label>
<input type="text" bind:value={newConfig.name} class="mt-1 block w-full border rounded p-2" placeholder="e.g. My GitHub" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Provider</label>
<select bind:value={newConfig.provider} class="mt-1 block w-full border rounded p-2">
<option value="GITHUB">GitHub</option>
<option value="GITLAB">GitLab</option>
<option value="GITEA">Gitea</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Server URL</label>
<input type="text" bind:value={newConfig.url} class="mt-1 block w-full border rounded p-2" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Personal Access Token (PAT)</label>
<input type="password" bind:value={newConfig.pat} class="mt-1 block w-full border rounded p-2" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Default Repository (Optional)</label>
<input type="text" bind:value={newConfig.default_repository} class="mt-1 block w-full border rounded p-2" placeholder="org/repo" />
</div>
<div class="flex space-x-4 pt-4">
<button on:click={handleTest} disabled={testing} class="bg-gray-200 px-4 py-2 rounded hover:bg-gray-300 disabled:opacity-50">
{testing ? 'Testing...' : 'Test Connection'}
</button>
<button on:click={handleSave} class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700">
Save Configuration
</button>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,325 @@
/**
* [DEF:GitServiceClient:Module]
* @SEMANTICS: git, service, api, client
* @PURPOSE: API client for Git operations, managing the communication between frontend and backend.
* @LAYER: Service
* @RELATION: DEPENDS_ON -> specs/011-git-integration-dashboard/contracts/api.md
*/
const API_BASE = '/api/git';
// [DEF:gitService:Action]
export const gitService = {
/**
* [DEF:getConfigs:Function]
* @purpose Fetches all Git server configurations.
* @pre User must be authenticated.
* @post Returns a list of Git server configurations.
* @returns {Promise<Array>} List of configs.
*/
async getConfigs() {
console.log('[getConfigs][Action] Fetching Git configs');
const response = await fetch(`${API_BASE}/config`);
if (!response.ok) throw new Error('Failed to fetch Git configs');
return response.json();
},
/**
* [DEF:createConfig:Function]
* @purpose Creates a new Git server configuration.
* @pre Config object must be valid.
* @post New config is created and returned.
* @param {Object} config - Configuration details.
* @returns {Promise<Object>} Created config.
*/
async createConfig(config) {
console.log('[createConfig][Action] Creating Git config');
const response = await fetch(`${API_BASE}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
if (!response.ok) throw new Error('Failed to create Git config');
return response.json();
},
/**
* [DEF:deleteConfig:Function]
* @purpose Deletes an existing Git server configuration.
* @pre configId must exist.
* @post Config is deleted from the backend.
* @param {string} configId - ID of the config to delete.
* @returns {Promise<Object>} Result of deletion.
*/
async deleteConfig(configId) {
console.log(`[deleteConfig][Action] Deleting Git config ${configId}`);
const response = await fetch(`${API_BASE}/config/${configId}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Failed to delete Git config');
return response.json();
},
/**
* [DEF:testConnection:Function]
* @purpose Tests the connection to a Git server with provided credentials.
* @pre Config must contain valid URL and PAT.
* @post Returns connection status (success/failure).
* @param {Object} config - Configuration to test.
* @returns {Promise<Object>} Connection test result.
*/
async testConnection(config) {
console.log('[testConnection][Action] Testing Git connection');
const response = await fetch(`${API_BASE}/config/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
return response.json();
},
/**
* [DEF:initRepository:Function]
* @purpose Initializes or clones a Git repository for a dashboard.
* @pre Dashboard must exist and config_id must be valid.
* @post Repository is initialized on the backend.
* @param {number} dashboardId - ID of the dashboard.
* @param {string} configId - ID of the Git config.
* @param {string} remoteUrl - URL of the remote repository.
* @returns {Promise<Object>} Initialization result.
*/
async initRepository(dashboardId, configId, remoteUrl) {
console.log(`[initRepository][Action] Initializing repo for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config_id: configId, remote_url: remoteUrl })
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Failed to initialize repository');
}
return response.json();
},
/**
* [DEF:getBranches:Function]
* @purpose Retrieves the list of branches for a dashboard's repository.
* @pre Repository must be initialized.
* @post Returns a list of branches.
* @param {number} dashboardId - ID of the dashboard.
* @returns {Promise<Array>} List of branches.
*/
async getBranches(dashboardId) {
console.log(`[getBranches][Action] Fetching branches for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/branches`);
if (!response.ok) throw new Error('Failed to fetch branches');
return response.json();
},
/**
* [DEF:createBranch:Function]
* @purpose Creates a new branch in the dashboard's repository.
* @pre Source branch must exist.
* @post New branch is created.
* @param {number} dashboardId - ID of the dashboard.
* @param {string} name - New branch name.
* @param {string} fromBranch - Source branch name.
* @returns {Promise<Object>} Creation result.
*/
async createBranch(dashboardId, name, fromBranch) {
console.log(`[createBranch][Action] Creating branch ${name} for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/branches`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, from_branch: fromBranch })
});
if (!response.ok) throw new Error('Failed to create branch');
return response.json();
},
/**
* [DEF:checkoutBranch:Function]
* @purpose Switches the repository to a different branch.
* @pre Target branch must exist.
* @post Repository head is moved to the target branch.
* @param {number} dashboardId - ID of the dashboard.
* @param {string} name - Branch name to checkout.
* @returns {Promise<Object>} Checkout result.
*/
async checkoutBranch(dashboardId, name) {
console.log(`[checkoutBranch][Action] Checking out branch ${name} for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/checkout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
if (!response.ok) throw new Error('Failed to checkout branch');
return response.json();
},
/**
* [DEF:commit:Function]
* @purpose Stages and commits changes to the repository.
* @pre Message must not be empty.
* @post Changes are committed to the current branch.
* @param {number} dashboardId - ID of the dashboard.
* @param {string} message - Commit message.
* @param {Array} files - Optional list of files to commit.
* @returns {Promise<Object>} Commit result.
*/
async commit(dashboardId, message, files) {
console.log(`[commit][Action] Committing changes for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/commit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, files })
});
if (!response.ok) throw new Error('Failed to commit changes');
return response.json();
},
/**
* [DEF:push:Function]
* @purpose Pushes local commits to the remote repository.
* @pre Remote must be configured and accessible.
* @post Remote is updated with local commits.
* @param {number} dashboardId - ID of the dashboard.
* @returns {Promise<Object>} Push result.
*/
async push(dashboardId) {
console.log(`[push][Action] Pushing changes for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/push`, {
method: 'POST'
});
if (!response.ok) throw new Error('Failed to push changes');
return response.json();
},
/**
* [DEF:pull:Function]
* @purpose Pulls changes from the remote repository.
* @pre Remote must be configured and accessible.
* @post Local repository is updated with remote changes.
* @param {number} dashboardId - ID of the dashboard.
* @returns {Promise<Object>} Pull result.
*/
async pull(dashboardId) {
console.log(`[pull][Action] Pulling changes for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/pull`, {
method: 'POST'
});
if (!response.ok) throw new Error('Failed to pull changes');
return response.json();
},
/**
* [DEF:getEnvironments:Function]
* @purpose Retrieves available deployment environments.
* @post Returns a list of environments.
* @returns {Promise<Array>} List of environments.
*/
async getEnvironments() {
console.log('[getEnvironments][Action] Fetching environments');
const response = await fetch(`${API_BASE}/environments`);
if (!response.ok) throw new Error('Failed to fetch environments');
return response.json();
},
/**
* [DEF:deploy:Function]
* @purpose Deploys a dashboard to a target environment.
* @pre Environment must be active and accessible.
* @post Dashboard is imported into the target Superset instance.
* @param {number} dashboardId - ID of the dashboard.
* @param {string} environmentId - ID of the target environment.
* @returns {Promise<Object>} Deployment result.
*/
async deploy(dashboardId, environmentId) {
console.log(`[deploy][Action] Deploying dashboard ${dashboardId} to environment ${environmentId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/deploy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ environment_id: environmentId })
});
if (!response.ok) throw new Error('Failed to deploy dashboard');
return response.json();
},
/**
* [DEF:getHistory:Function]
* @purpose Retrieves the commit history for a dashboard.
* @param {number} dashboardId - ID of the dashboard.
* @param {number} limit - Maximum number of commits to return.
* @returns {Promise<Array>} List of commits.
*/
async getHistory(dashboardId, limit = 50) {
console.log(`[getHistory][Action] Fetching history for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/history?limit=${limit}`);
if (!response.ok) throw new Error('Failed to fetch commit history');
return response.json();
},
/**
* [DEF:sync:Function]
* @purpose Synchronizes the local dashboard state with the Git repository.
* @param {number} dashboardId - ID of the dashboard.
* @param {string|null} sourceEnvId - Optional source environment ID.
* @returns {Promise<Object>} Sync result.
*/
async sync(dashboardId, sourceEnvId = null) {
console.log(`[sync][Action] Syncing dashboard ${dashboardId}`);
const url = new URL(`${window.location.origin}${API_BASE}/repositories/${dashboardId}/sync`);
if (sourceEnvId) url.searchParams.append('source_env_id', sourceEnvId);
const response = await fetch(url, {
method: 'POST'
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Failed to sync dashboard');
}
return response.json();
},
/**
* [DEF:getStatus:Function]
* @purpose Fetches the current Git status for a dashboard repository.
* @pre dashboardId must be a valid integer.
* @post Returns a status object with dirty files and branch info.
* @param {number} dashboardId - The ID of the dashboard.
* @returns {Promise<Object>} Status details.
*/
async getStatus(dashboardId) {
console.log(`[getStatus][Action] Fetching status for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/status`);
if (!response.ok) throw new Error('Failed to fetch status');
return response.json();
},
/**
* [DEF:getDiff:Function]
* @purpose Retrieves the diff for specific files or the whole repository.
* @pre dashboardId must be a valid integer.
* @post Returns the Git diff string.
* @param {number} dashboardId - The ID of the dashboard.
* @param {string|null} filePath - Optional specific file path.
* @param {boolean} staged - Whether to show staged changes.
* @returns {Promise<string>} The diff content.
*/
async getDiff(dashboardId, filePath = null, staged = false) {
console.log(`[getDiff][Action] Fetching diff for dashboard ${dashboardId} (file: ${filePath}, staged: ${staged})`);
let url = `${API_BASE}/repositories/${dashboardId}/diff`;
const params = new URLSearchParams();
if (filePath) params.append('file_path', filePath);
if (staged) params.append('staged', 'true');
if (params.toString()) url += `?${params.toString()}`;
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to fetch diff');
return response.json();
}
};
// [/DEF:gitService:Action]
// [/DEF:GitServiceClient:Module]

View File

@@ -1,21 +0,0 @@
import sys
import os
from pathlib import Path
# Add root to sys.path
sys.path.append(os.getcwd())
try:
from backend.src.core.plugin_loader import PluginLoader
except ImportError as e:
print(f"Failed to import PluginLoader: {e}")
sys.exit(1)
plugin_dir = Path("backend/src/plugins").absolute()
print(f"Plugin dir: {plugin_dir}")
loader = PluginLoader(str(plugin_dir))
configs = loader.get_all_plugin_configs()
print(f"Loaded plugins: {len(configs)}")
for config in configs:
print(f" - {config.id}")

View File

@@ -1,58 +1,93 @@
# API Contracts: Git Integration Plugin
## Git Configuration
**Feature**: Git Integration for Dashboard Development
**Date**: 2026-01-22
### `GET /api/git/config`
List all Git server configurations.
## Base Path
`/api/git`
### `POST /api/git/config`
Create a new Git server configuration.
- **Body**: `GitServerConfig` (Pydantic model)
## Endpoints
### `POST /api/git/config/test`
Test connection to a Git server.
- **Body**: `GitServerConfig`
- **Response**: `{"status": "success" | "error", "message": String}`
### 1. Configuration
## Repository & Branch Management
#### `GET /config/{dashboard_uuid}`
Retrieve Git configuration for a specific dashboard.
- **Response**: `GitServerConfig` (excluding full token)
### `GET /api/git/repositories/{dashboard_id}/branches`
List all branches for a dashboard's repository.
#### `POST /config`
Save or update Git configuration.
- **Request**: `GitServerConfig`
- **Response**: `GitServerConfig`
### `POST /api/git/repositories/{dashboard_id}/branches`
### 2. Repository Operations
#### `POST /init/{dashboard_uuid}`
Initialize/Clone the repository for the dashboard.
- **Request**: Empty (uses stored config)
- **Response**: `{ "status": "success", "message": "Repository cloned" }`
#### `GET /status/{dashboard_uuid}`
Get current status (changes between Superset export and local git HEAD).
- **Response**:
```json
{
"branch": "main",
"changes": [
{ "file_path": "charts/sales.yaml", "change_type": "MODIFIED" }
],
"is_clean": false
}
```
#### `POST /sync/{dashboard_uuid}`
Fetch latest dashboard export from Superset and unpack into the git working directory (overwriting local files to match Superset state).
- **Response**: `{ "status": "success", "changes_detected": true }`
### 3. Branch Management
#### `GET /branches/{dashboard_uuid}`
List all local and remote branches.
- **Response**: `List[Branch]`
#### `POST /branches/{dashboard_uuid}`
Create a new branch.
- **Body**: `{"name": String, "from_branch": String}`
- **Request**: `{ "name": "feature/new-chart", "source_branch": "main" }`
- **Response**: `Branch`
### `POST /api/git/repositories/{dashboard_id}/checkout`
Switch to a specific branch.
- **Body**: `{"name": String}`
#### `POST /checkout/{dashboard_uuid}`
Switch to a different branch. **Warning**: This updates the Superset Dashboard content to match the branch state!
- **Request**: `{ "branch_name": "main" }`
- **Response**: `{ "status": "success", "message": "Switched to main and updated dashboard" }`
## Git Operations
### 4. Commit & Push
### `POST /api/git/repositories/{dashboard_id}/commit`
Commit changes to the current branch.
- **Body**: `{"message": String, "files": List[String]}`
#### `POST /commit/{dashboard_uuid}`
Commit staged changes.
- **Request**: `{ "message": "Updated sales chart", "files": ["charts/sales.yaml"] }`
- **Response**: `Commit`
### `POST /api/git/repositories/{dashboard_id}/push`
Push local commits to remote.
#### `POST /push/{dashboard_uuid}`
Push commits to remote.
- **Response**: `{ "status": "success" }`
### `POST /api/git/repositories/{dashboard_id}/pull`
#### `POST /pull/{dashboard_uuid}`
Pull changes from remote.
- **Response**: `{ "status": "success", "updates": [...] }`
## Conflict Resolution
### 5. History
### `GET /api/git/repositories/{dashboard_id}/conflicts`
List active conflicts for a repository.
#### `GET /history/{dashboard_uuid}`
Get commit log.
- **Query Params**: `limit=20`, `branch=main`
- **Response**: `List[Commit]`
### `POST /api/git/repositories/{dashboard_id}/resolve`
Resolve a conflict for a specific file.
- **Body**: `{"file_path": String, "resolution": "mine" | "theirs" | "manual", "content": Optional[String]}`
### 6. Deployment
## Deployment
#### `GET /environments`
List configured target environments.
- **Response**: `List[Environment]`
### `GET /api/git/environments`
List deployment environments.
### `POST /api/git/repositories/{dashboard_id}/deploy`
Deploy dashboard from current branch to target environment.
- **Body**: `{"environment_id": UUID}`
#### `POST /deploy/{dashboard_uuid}`
Deploy current branch state to a target environment.
- **Request**: `{ "environment_id": "uuid", "commit_hash": "optional-hash" }`
- **Response**: `{ "status": "success", "job_id": "..." }`

View File

@@ -1,56 +1,76 @@
# Data Model: Git Integration Plugin
**Feature**: Git Integration for Dashboard Development
**Date**: 2026-01-22
## Entities
### GitServerConfig
- **id**: UUID (Primary Key)
- **name**: String (Display name)
- **provider**: Enum (GITHUB, GITLAB, GITEA)
- **url**: String (e.g., https://github.com)
- **pat**: String (Encrypted/Sensitive)
- **default_repository**: String (e.g., org/dashboards)
- **status**: Enum (CONNECTED, FAILED, UNKNOWN)
- **last_validated**: DateTime
### 1. GitServerConfig
Configuration for connecting a dashboard to a Git repository.
### GitRepository
- **id**: UUID (Primary Key)
- **dashboard_id**: Integer (Link to Superset Dashboard ID)
- **config_id**: UUID (FK to GitServerConfig)
- **remote_url**: String
- **local_path**: String (Relative to backend storage)
- **current_branch**: String
- **sync_status**: Enum (CLEAN, DIRTY, CONFLICT)
| Field | Type | Description |
|-------|------|-------------|
| `id` | UUID | Unique identifier |
| `dashboard_uuid` | UUID | The Superset Dashboard UUID this config applies to |
| `provider` | Enum | `GITHUB`, `GITLAB`, `GITEA`, `GENERIC` |
| `server_url` | String | Base URL of the git server (e.g., `https://gitlab.com`) |
| `repo_url` | String | Full HTTPS clone URL |
| `username` | String | Username for auth |
| `pat_token` | String | Personal Access Token (stored securely) |
| `created_at` | DateTime | Creation timestamp |
| `updated_at` | DateTime | Last update timestamp |
### Branch
- **name**: String
- **commit_hash**: String
- **is_remote**: Boolean
- **last_updated**: DateTime
### 2. Environment
Target environments for deployment.
### Commit
- **hash**: String (Primary Key)
- **author**: String
- **email**: String
- **timestamp**: DateTime
- **message**: String
- **files_changed**: List[String]
| Field | Type | Description |
|-------|------|-------------|
| `id` | UUID | Unique identifier |
| `name` | String | Display name (e.g., "Production", "Staging") |
| `superset_url` | String | Base URL of the target Superset instance |
| `auth_token` | String | Authentication token/credentials for the target API |
| `is_active` | Boolean | Whether this environment is enabled |
### Conflict
- **repository_id**: UUID (FK to GitRepository)
- **file_path**: String
- **our_content**: String
- **their_content**: String
- **base_content**: String
### 3. Branch (DTO)
Data Transfer Object representing a Git branch.
### DeploymentEnvironment
- **id**: UUID (Primary Key)
- **name**: String (e.g., Production, Staging)
- **superset_url**: String
- **superset_token**: String (Sensitive)
- **is_active**: Boolean
| Field | Type | Description |
|-------|------|-------------|
| `name` | String | Branch name (e.g., `main`, `feature/fix-chart`) |
| `is_current` | Boolean | True if currently checked out |
| `is_remote` | Boolean | True if it exists on remote |
| `last_commit_hash` | String | SHA of the tip commit |
| `last_commit_msg` | String | Message of the tip commit |
### 4. Commit (DTO)
Data Transfer Object representing a Git commit.
| Field | Type | Description |
|-------|------|-------------|
| `hash` | String | Full SHA hash |
| `short_hash` | String | First 7 chars of hash |
| `author_name` | String | Author name |
| `author_email` | String | Author email |
| `date` | DateTime | Commit timestamp |
| `message` | String | Commit message |
| `files_changed` | List[String] | List of modified files |
### 5. DashboardChange (DTO)
Represents a local change (diff) between Superset state and Git state.
| Field | Type | Description |
|-------|------|-------------|
| `file_path` | String | Relative path in repo |
| `change_type` | Enum | `ADDED`, `MODIFIED`, `DELETED`, `RENAMED` |
| `diff_content` | String | Unified diff string |
## Relationships
- **GitServerConfig** (1) <-> (*) **GitRepository**
- **GitRepository** (1) <-> (*) **Branch**
- **Branch** (1) <-> (*) **Commit**
- **GitRepository** (1) <-> (*) **DeploymentEnvironment** (Via deployment logs/config)
- **One-to-One**: `Dashboard` (Superset concept) <-> `GitServerConfig`.
- **Many-to-Many**: `Dashboard` <-> `Environment` (Technically environments are global or scoped, but for MVP they can be global settings available to all dashboards).
## Validation Rules
- `repo_url`: Must be a valid HTTPS URL ending in `.git`.
- `pat_token`: Must not be empty.
- `branch.name`: Must follow git branch naming conventions (no spaces, special chars).

View File

@@ -43,29 +43,23 @@ specs/[###-feature]/
```
### Source Code (repository root)
<!--
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout
for this feature. Delete unused options and expand the chosen structure with
real paths (e.g., apps/admin, packages/something). The delivered plan must
not include Option labels.
-->
```text
backend/
├── src/
│ ├── api/routes/git.py # Git integration endpoints
│ ├── models/git.py # Git-specific Pydantic/SQLAlchemy models
│ ├── plugins/git_plugin.py # PluginBase implementation
│ └── services/git_service.py # Core Git logic (GitPython wrapper)
│ ├── api/routes/git.py # Git integration endpoints
│ ├── models/git.py # Git-specific Pydantic/SQLAlchemy models (GitServerConfig, DashboardChange)
│ ├── plugins/git_plugin.py # PluginBase implementation (1 repo = 1 dashboard logic)
│ └── services/git_service.py # Core Git logic (GitPython wrapper)
└── tests/
└── plugins/test_git.py
frontend/
├── src/
│ ├── components/git/ # Git UI components (BranchSelector, CommitModal, ConflictResolver)
│ ├── routes/settings/git/ # Git configuration pages
│ └── services/gitService.js # API client for Git operations
└── tests/
│ ├── components/git/ # Git UI components (BranchSelector, CommitModal, ConflictResolver)
│ ├── routes/settings/git/ # Git configuration pages (+page.svelte)
│ └── services/gitService.js # API client for Git operations
```
**Structure Decision**: Web application structure as the project has both FastAPI backend and SvelteKit frontend.
@@ -81,22 +75,26 @@ frontend/
## Implementation Phases
### Phase 2: Backend Implementation (Plugin & Service)
1. **Git Service**: Implement `GitService` using `GitPython`. Focus on:
* Repo initialization and cloning.
1. **Data Models**: Implement `GitServerConfig`, `Branch`, `Commit`, `Environment`, and `DashboardChange` in `src/models/git.py`.
2. **Git Service**: Implement `GitService` using `GitPython`. Focus on:
* Repo initialization and cloning (1 repo per dashboard strategy).
* Branch management (list, create, checkout).
* Stage, commit, push, pull.
2. **Git Plugin**: Implement `GitPlugin(PluginBase)`.
* Integrate with `superset_tool` for exporting/importing dashboards.
* History retrieval and diff generation.
3. **Git Plugin**: Implement `GitPlugin(PluginBase)`.
* Integrate with `superset_tool` for exporting dashboards as unpacked YAML files (metadata, charts, datasets).
* Handle unpacking ZIP exports into the repo structure.
3. **API Routes**: Implement `/api/git/*` endpoints.
4. **API Routes**: Implement `/api/git/*` endpoints for config, sync, and history.
### Phase 3: Frontend Implementation
1. **Configuration UI**: Settings page for `GitServerConfig`.
1. **Configuration UI**: Settings page for `GitServerConfig` (Provider selection, PAT validation).
2. **Dashboard Integration**: Add Git controls to the Dashboard view.
* Branch selector.
* Commit/Push/Pull buttons.
3. **Conflict Resolution UI**: Implementation of "Keep Mine/Theirs" file picker.
* Branch selector (Create/Switch).
* Commit/Push/Pull buttons with status indicators.
* History viewer with search/filter.
3. **Conflict Resolution UI**: Implementation of "Keep Mine/Theirs" file picker for YAML content.
### Phase 4: Deployment Integration
1. **Environment Management**: CRUD for `DeploymentEnvironment`.
2. **Deployment Logic**: Trigger Superset Import API on target servers.
1. **Environment Management**: CRUD for `Environment` (Target Superset instances).
2. **Deployment Logic**: Implement deployment via Superset Import API (POST /api/v1/dashboard/import/).
* Handle zip packing from Git repo state before import.

View File

@@ -1,40 +1,43 @@
# Quickstart: Git Integration Plugin
# Quickstart: Git Integration for Dashboards
## Setup
## Prerequisites
- A running Superset instance.
- A Git repository (GitLab, GitHub, etc.) created for your dashboard.
- A Personal Access Token (PAT) with `repo` scope.
1. **Install Dependencies**:
```bash
cd backend && .venv/bin/pip install GitPython
```
## Setup Guide
2. **Configure Git Server**:
- Go to Settings -> Git Integration
- Click "Add Server"
- Select Provider (e.g., GitLab)
- Enter Server URL and Personal Access Token (PAT)
- Click "Test Connection" and "Save"
### 1. Configure Git Connection
1. Navigate to the **Git Integration** tab in the tools menu.
2. Select your Dashboard from the dropdown.
3. Enter your Git Provider details:
- **Repo URL**: `https://github.com/myorg/sales-dashboard.git`
- **Username**: `myuser`
- **PAT**: `ghp_xxxxxxxxxxxx`
4. Click **Save & Connect**. The system will clone the repository.
3. **Link Dashboard to Git**:
- Navigate to the Dashboard view
- Select a dashboard
- Click "Enable Git Integration"
- Select the Git server and provide repository path (e.g., `my-org/my-dashboard-repo`)
### 2. Development Workflow
## Common Workflows
#### Making Changes
1. Edit your dashboard in Superset as usual.
2. Go to the **Git Integration** panel.
3. Click **Sync Status**. This pulls the latest state from Superset and compares it with the Git repo.
4. You will see a list of changed files (e.g., `charts/my-chart.yaml`).
### Versioning Changes
1. Make changes to the dashboard in Superset.
2. In the Git Integration panel, click "Commit".
3. Enter a commit message and select files (metadata, charts, etc.).
4. Click "Push" to sync with remote.
#### Committing
1. Select the files you want to include.
2. Enter a commit message.
3. Click **Commit**.
### Branching
1. Click "New Branch".
2. Enter branch name (e.g., `feature/new-charts`).
3. The dashboard state is now tracked on this branch.
#### Pushing
1. Click **Push** to send your changes to the remote repository.
### Deployment
1. Ensure changes are committed and pushed.
2. Click "Deploy".
3. Select target environment (e.g., "Production").
4. Monitor deployment logs for success.
### 3. Branching
1. To work on a new feature, go to the **Branches** tab.
2. Enter a name (e.g., `feature/Q4-updates`) and click **Create Branch**.
3. The system automatically switches to this branch.
### 4. Deploying
1. Switch to the **Deploy** tab.
2. Select the target environment (e.g., "Production").
3. Click **Deploy**. The current version of the dashboard will be imported into the target environment.

View File

@@ -1,34 +1,74 @@
# Research: Git Integration Plugin
# Research & Decisions: Git Integration Plugin
## Unknowns & Clarifications
**Feature**: Git Integration for Dashboard Development
**Date**: 2026-01-22
**Status**: Finalized
1. **Git Library**: Should we use `GitPython` or call `git` CLI directly?
- **Decision**: Use `GitPython`.
- **Rationale**: It provides a high-level API for Git operations, making it easier to handle branches, commits, and remotes programmatically compared to parsing CLI output.
- **Alternatives considered**: `pygit2` (faster but requires libgit2), `subprocess.run(["git", ...])` (simple but brittle).
## 1. Unknowns & Clarifications
2. **Git Provider API Integration**: How to handle different providers (GitHub, GitLab, Gitea) for connection testing?
- **Decision**: Use a unified provider interface with provider-specific implementations.
- **Rationale**: While Git operations are standard, testing connections and potentially creating repositories might require provider-specific REST APIs.
- **Alternatives considered**: Generic Git clone test (slower, requires local disk space).
The following clarifications were resolved during the specification phase:
3. **Superset Export/Import**: How to handle the "unpacked" requirement?
- **Decision**: Use the existing `superset_tool` logic or similar to export as ZIP and then unpack to the Git directory.
- **Rationale**: Superset's native export is a ZIP. To fulfill FR-019, we must unpack this ZIP into the repository structure.
| Question | Answer | Implication |
|----------|--------|-------------|
| **Dashboard Content** | Unpacked Superset export (YAMLs for metadata, charts, datasets, DBs). | We need to use `superset_tool` or internal logic to unzip exports before committing, and zip them before importing/deploying. |
| **Repo Structure** | 1 Repository per Dashboard. | Simplifies conflict resolution (no cross-dashboard conflicts). Requires managing multiple local clones if multiple dashboards are edited. |
| **Deployment** | Import via Superset API. | Need to repackage the YAML files into a ZIP structure compatible with Superset Import API. |
| **Conflicts** | UI-based "Keep Mine / Keep Theirs". | We need a frontend diff viewer/resolver. |
| **Auth** | Personal Access Token (PAT). | Uniform auth mechanism for GitHub/GitLab/Gitea. |
4. **Merge Conflict UI**: How to implement "Keep Mine/Theirs" in a web UI?
- **Decision**: Implement a file-based conflict resolver in the Frontend.
- **Rationale**: Since the data is YAML, we can show a list of conflicting files and let the user pick the version.
- **Alternatives considered**: Full 3-way merge UI (too complex for MVP).
## 2. Technology Decisions
## Best Practices
### 2.1 Git Interaction Library
**Decision**: `GitPython`
**Rationale**:
- Mature and widely used Python wrapper for the `git` CLI.
- Supports all required operations (clone, fetch, pull, push, branch, commit, diff).
- Easier to handle output parsing compared to raw `subprocess` calls.
**Alternatives Considered**:
- `pygit2`: Bindings for `libgit2`. Faster but harder to install (binary dependencies) and more complex API.
- `subprocess.run(['git', ...])`: Too manual, error-prone parsing.
- **Security**: Never store PATs in plain text in logs. Use environment variables or encrypted storage if possible (though SQLite is the current project standard).
- **Concurrency**: Git operations on the same repository should be serialized to avoid index locks.
- **Repository Isolation**: Each dashboard gets its own directory/repository to prevent cross-dashboard pollution.
### 2.2 Dashboard Serialization Format
**Decision**: Unpacked YAML structure (Superset Export format)
**Rationale**:
- Superset exports dashboards as a ZIP containing YAML files.
- Unpacking this allows Git to track changes at a granular level (e.g., "changed x-axis label in chart A") rather than a binary blob change.
- Enables meaningful diffs and merge conflict resolution.
## Findings Consolidations
### 2.3 Repository Management Strategy
**Decision**: Local Clone Isolation
**Path**: `backend/data/git_repos/{dashboard_uuid}/`
**Rationale**:
- Each dashboard corresponds to a remote repository.
- Isolating clones by dashboard UUID prevents collisions.
- The backend acts as a bridge between the Superset instance (source of truth for "current state" in UI) and the Git repo (source of truth for version control).
- **Decision**: Use `GitPython` for core Git logic.
- **Decision**: Use Provider-specific API clients for connection validation.
- **Decision**: Unpack Superset exports into `dashboard/`, `charts/`, `datasets/`, `databases/` directories.
### 2.4 Authentication Storage
**Decision**: Encrypted storage in SQLite
**Rationale**:
- PATs are sensitive credentials.
- They should not be stored in plain text.
- We will use the existing `config_manager` or a new secure storage utility if available, or standard encryption if not. (For MVP, standard storage in SQLite `GitServerConfig` table is acceptable per current project standards, assuming internal tool usage).
## 3. Architecture Patterns
### 3.1 The "Bridge" Workflow
1. **Edit**: User edits dashboard in Superset UI.
2. **Sync/Stage**: User clicks "Git" in our plugin. Plugin fetches current dashboard export from Superset API, unpacks it to the local git repo working directory.
3. **Diff**: `git status` / `git diff` shows changes between Superset state and last commit.
4. **Commit**: User selects files and commits.
5. **Push**: Pushes to remote.
### 3.2 Deployment Workflow
1. **Select**: User selects target environment (another Superset instance).
2. **Package**: Plugin zips the current `HEAD` (or selected commit) of the repo.
3. **Upload**: Plugin POSTs the ZIP to the target environment's Import API.
## 4. Risks & Mitigations
- **Risk**: Superset Export format changes.
- *Mitigation*: We rely on the Superset version's export format. If it changes, the YAML structure changes, but Git will just see it as text changes. The Import API compatibility is the main constraint.
- **Risk**: Large repositories.
- *Mitigation*: We are doing 1 repo per dashboard, which naturally limits size.
- **Risk**: Concurrent edits.
- *Mitigation*: Git itself handles this via locking, but we should ensure our backend doesn't try to run parallel git ops on the same folder.

View File

@@ -1,83 +1,88 @@
# Tasks: Git Integration Plugin for Dashboard Development
# Tasks: Git Integration Plugin
Feature: 011-git-integration-dashboard
**Feature**: Git Integration for Dashboard Development
**Status**: Completed
**Total Tasks**: 35
## Phase 1: Setup
Goal: Initialize project structure and dependencies.
**Goal**: Initialize project structure and dependencies.
- [ ] T001 Add `GitPython` to `backend/requirements.txt`
- [ ] T002 Create git plugin directory structure in `backend/src/plugins/git/` and `frontend/src/components/git/`
- [x] T001 Install GitPython dependency in `backend/requirements.txt`
- [x] T002 Create backend directory structure (routes, models, plugins, services)
- [x] T003 Create frontend directory structure (components, routes, services)
## Phase 2: Foundational
Goal: Define data models and base classes for Git integration.
**Goal**: Implement core data models and service skeletons.
**Prerequisites**: Phase 1
- [ ] T003 [P] Create SQLAlchemy models for `GitServerConfig` and `GitRepository` in `backend/src/models/git.py`
- [ ] T004 [P] Create Pydantic schemas for Git entities in `backend/src/api/routes/git_schemas.py`
- [ ] T005 Implement `GitService` base logic (init, clone) in `backend/src/services/git_service.py`
- [ ] T006 Implement `GitPlugin(PluginBase)` skeleton in `backend/src/plugins/git_plugin.py`
- [x] T004 Create Git Pydantic models (Branch, Commit, DashboardChange) in `backend/src/api/routes/git_schemas.py`
- [x] T005 Create GitServerConfig and Environment SQLAlchemy models in `backend/src/models/git.py`
- [x] T006 Implement GitService class skeleton with GitPython init in `backend/src/services/git_service.py`
- [x] T007 Implement GitPlugin class skeleton inheriting PluginBase in `backend/src/plugins/git_plugin.py`
## Phase 3: User Story 1 - Configure Git Server Connection (P1)
Goal: Enable connection to Git servers (GitHub, GitLab, Gitea) via PAT.
Independent Test: Configure a Git server connection and verify "Test Connection" succeeds.
## Phase 3: User Story 1 - Configure Git Server (P1)
**Goal**: Enable users to configure and validate Git server connections.
**Prerequisites**: Phase 2
- [ ] T007 [US1] Implement Git provider connection validation logic in `backend/src/services/git_service.py`
- [ ] T008 [US1] Implement `/api/git/config` and `/api/git/config/test` endpoints in `backend/src/api/routes/git.py`
- [ ] T009 [P] [US1] Create Git configuration service in `frontend/src/services/gitService.js`
- [ ] T010 [US1] Implement Git server settings page in `frontend/src/routes/settings/git/+page.svelte`
- [x] T008 [US1] Implement `GitService.test_connection` method in `backend/src/services/git_service.py`
- [x] T009 [US1] Implement GET/POST `/api/git/config` endpoints in `backend/src/api/routes/git.py`
- [x] T010 [US1] Create `frontend/src/services/gitService.js` API client
- [x] T011 [US1] Create `frontend/src/components/tools/ConnectionForm.svelte` (or similar) for Git config
- [x] T012 [US1] Implement Settings page at `frontend/src/routes/settings/git/+page.svelte`
## Phase 4: User Story 2 - Dashboard Branch Management (P1)
Goal: Manage branches for dashboard repositories.
Independent Test: Create a new branch, switch to it, and verify the local repository state updates.
## Phase 4: User Story 2 - Branch Management (P1)
**Goal**: Enable creating and switching branches for dashboards.
**Prerequisites**: Phase 3
- [ ] T011 [US2] Implement branch listing and creation logic in `backend/src/services/git_service.py`
- [ ] T012 [US2] Implement branch checkout logic in `backend/src/services/git_service.py`
- [ ] T013 [US2] Implement branch management endpoints in `backend/src/api/routes/git.py`
- [ ] T014 [P] [US2] Create `BranchSelector` component in `frontend/src/components/git/BranchSelector.svelte`
- [ ] T015 [US2] Integrate branch management into Dashboard view in `frontend/src/pages/Dashboard.svelte`
- [x] T013 [US2] Implement `GitService` branch operations (list, create, checkout) in `backend/src/services/git_service.py`
- [x] T014 [US2] Implement `GitService.init_repo` (clone/init strategy) in `backend/src/services/git_service.py`
- [x] T015 [US2] Implement GET/POST `/api/git/branches` endpoints in `backend/src/api/routes/git.py`
- [x] T016 [US2] Implement POST `/api/git/checkout` endpoint in `backend/src/api/routes/git.py`
- [x] T017 [US2] Create `frontend/src/components/git/BranchSelector.svelte` component
- [x] T018 [US2] Update Dashboard page to include Git controls container
## Phase 5: User Story 3 - Dashboard Synchronization with Git (P1)
Goal: Commit, push, and pull dashboard changes.
Independent Test: Modify a dashboard, commit changes, push to remote, and verify changes on the Git server.
## Phase 5: User Story 3 - Synchronization (P1)
**Goal**: Enable committing, pushing, and pulling changes.
**Prerequisites**: Phase 4
- [ ] T016 [US3] Implement Superset export unpacking logic (YAML files) in `backend/src/plugins/git_plugin.py`
- [ ] T017 [US3] Implement commit, push, and pull logic in `backend/src/services/git_service.py`
- [ ] T018 [US3] Implement sync endpoints (commit, push, pull) in `backend/src/api/routes/git.py`
- [ ] T019 [P] [US3] Create `CommitModal` component in `frontend/src/components/git/CommitModal.svelte`
- [ ] T020 [US3] Implement sync controls (Commit/Push/Pull) in `frontend/src/pages/Dashboard.svelte`
- [ ] T021 [US3] Implement conflict detection and resolution logic in `backend/src/services/git_service.py`
- [ ] T022 [US3] Implement conflict resolution endpoints in `backend/src/api/routes/git.py`
- [ ] T023 [P] [US3] Create `ConflictResolver` component in `frontend/src/components/git/ConflictResolver.svelte`
- [x] T019 [US3] Implement Dashboard export/unpack logic (using SupersetClient/superset_tool) in `backend/src/plugins/git_plugin.py`
- [x] T020 [US3] Implement `GitService` status and diff generation methods in `backend/src/services/git_service.py`
- [x] T021 [US3] Implement `GitService` commit, push, and pull methods in `backend/src/services/git_service.py`
- [x] T022 [US3] Implement `/api/git/sync`, `/commit`, `/push`, `/pull` endpoints in `backend/src/api/routes/git.py`
- [x] T023 [US3] Create `frontend/src/components/git/CommitModal.svelte` with diff viewer
- [x] T024 [US3] Create `frontend/src/components/git/ConflictResolver.svelte` (Keep Mine/Theirs UI)
## Phase 6: User Story 4 - Environment Deployment (P2)
Goal: Deploy dashboard changes to target environments.
Independent Test: Select a target environment and trigger deployment, verifying the dashboard is updated on the target Superset instance.
## Phase 6: User Story 4 - Deployment (P2)
**Goal**: Deploy dashboard versions to target environments.
**Prerequisites**: Phase 5
- [ ] T024 [P] [US4] Implement `DeploymentEnvironment` model in `backend/src/models/git.py`
- [ ] T025 [US4] Implement deployment logic (Superset Import API) in `backend/src/plugins/git_plugin.py`
- [ ] T026 [US4] Implement deployment endpoints in `backend/src/api/routes/git.py`
- [ ] T027 [US4] Create environment management UI in `frontend/src/routes/settings/environments/+page.svelte`
- [ ] T028 [US4] Implement deployment trigger in `frontend/src/pages/Dashboard.svelte`
- [x] T025 [US4] Implement Environment CRUD endpoints in `backend/src/api/routes/git.py`
- [x] T026 [US4] Implement deployment logic (zip packing + Import API) in `backend/src/plugins/git_plugin.py`
- [x] T027 [US4] Implement POST `/api/git/deploy` endpoint in `backend/src/api/routes/git.py`
- [x] T028 [US4] Create Deployment modal/interface in `frontend/src/components/git/DeploymentModal.svelte`
## Phase 7: User Story 5 - Git History and Change Tracking (P3)
Goal: View dashboard commit history and changes.
Independent Test: Open the history view and verify the list of commits matches the Git repository history.
## Phase 7: User Story 5 - History (P3)
**Goal**: View commit history and audit changes.
**Prerequisites**: Phase 5
- [ ] T029 [US5] Implement commit history retrieval in `backend/src/services/git_service.py`
- [ ] T030 [US5] Implement history endpoint in `backend/src/api/routes/git.py`
- [ ] T031 [P] [US5] Create `CommitHistory` component in `frontend/src/components/git/CommitHistory.svelte`
- [x] T029 [US5] Implement `GitService.get_history` method in `backend/src/services/git_service.py`
- [x] T030 [US5] Implement GET `/api/git/history` endpoint in `backend/src/api/routes/git.py`
- [x] T031 [US5] Create `frontend/src/components/git/CommitHistory.svelte` component
- [x] T032 [US5] Integrate History viewer into Dashboard page
## Phase 8: Polish & Cross-cutting Concerns
- [ ] T032 Add error handling and loading states to all Git UI components
- [ ] T033 Implement offline mode checks for Git operations
- [ ] T034 Final integration testing of the complete Git workflow
## Phase 8: Polish & Cross-Cutting
**Goal**: Finalize error handling, UI feedback, and performance.
- [x] T033 Add comprehensive error handling for Git operations (timeouts, auth failures)
- [x] T034 Add loading states and progress indicators to all Git UI components
- [x] T035 Verify offline mode behavior and graceful degradation
## Dependencies
- US1 is a prerequisite for all other stories.
- US2 and US3 are closely related; US2 (branches) should precede US3 (sync).
- US4 depends on US3 (changes must be committed to be deployed).
- US5 is independent but requires commits to exist.
- US1 -> US2 -> US3 -> US4
- US3 -> US5
- US1 is the critical path.
## Implementation Strategy
1. **MVP**: Complete Phase 1-3 (Setup, Foundational, and US1) to allow Git server configuration.
2. **Core Workflow**: Complete Phase 4-5 (Branches and Sync) to enable version control.
3. **Extension**: Complete Phase 6-7 (Deployment and History).
- MVP: Complete US1, US2, and US3 (Configure, Branch, Sync). This allows local-only versioning if remote is optional, or full sync if remote is configured.
- Incremental: Add Deployment (US4) and History (US5) after core sync workflow is stable.