Compare commits
3 Commits
49129d3e86
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 07ec2d9797 | |||
| e9d3f3c827 | |||
| 26ba015b75 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -59,10 +59,13 @@ Thumbs.db
|
||||
*.ps1
|
||||
keyring passwords.py
|
||||
*github*
|
||||
*git*
|
||||
|
||||
*tech_spec*
|
||||
dashboards
|
||||
backend/mappings.db
|
||||
|
||||
|
||||
backend/tasks.db
|
||||
|
||||
# Git Integration repositories
|
||||
backend/git_repos/
|
||||
|
||||
@@ -22,6 +22,9 @@ Auto-generated from all feature plans. Last updated: 2025-12-19
|
||||
- SQLite (for job history/results, connection configs), Filesystem (for temporary file uploads) (010-refactor-cli-to-web)
|
||||
- Python 3.9+ + FastAPI, Pydantic, requests, pyyaml (migrated from superset_tool) (012-remove-superset-tool)
|
||||
- SQLite (tasks.db, migrations.db), Filesystem (012-remove-superset-tool)
|
||||
- Filesystem (local git repo), SQLite (for GitServerConfig, Environment) (011-git-integration-dashboard)
|
||||
- Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI, SvelteKit, GitPython (or CLI git), Pydantic, SQLAlchemy, Superset API (011-git-integration-dashboard)
|
||||
- SQLite (for config/history), Filesystem (local Git repositories) (011-git-integration-dashboard)
|
||||
|
||||
- Python 3.9+ (Backend), Node.js 18+ (Frontend Build) (001-plugin-arch-svelte-ui)
|
||||
|
||||
@@ -42,9 +45,9 @@ cd src; pytest; ruff check .
|
||||
Python 3.9+ (Backend), Node.js 18+ (Frontend Build): Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 012-remove-superset-tool: Added Python 3.9+ + FastAPI, Pydantic, requests, pyyaml (migrated from superset_tool)
|
||||
- 010-refactor-cli-to-web: Added Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI, SvelteKit, Tailwind CSS, Pydantic, SQLAlchemy, `superset_tool` (internal lib)
|
||||
- 009-backup-scheduler: Added Python 3.9+, Node.js 18+ + FastAPI, APScheduler, SQLAlchemy, SvelteKit, Tailwind CSS
|
||||
- 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+
|
||||
|
||||
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
|
||||
1
backend/backend/git_repos/12
Submodule
1
backend/backend/git_repos/12
Submodule
Submodule backend/backend/git_repos/12 added at d592fa7ed5
Binary file not shown.
@@ -43,4 +43,5 @@ uvicorn==0.38.0
|
||||
websockets==15.0.1
|
||||
pandas
|
||||
psycopg2-binary
|
||||
openpyxl
|
||||
openpyxl
|
||||
GitPython==3.1.44
|
||||
@@ -1 +1 @@
|
||||
from . import plugins, tasks, settings, connections
|
||||
from . import plugins, tasks, settings, connections, environments, mappings, migration, git
|
||||
|
||||
@@ -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]
|
||||
|
||||
303
backend/src/api/routes/git.py
Normal file
303
backend/src/api/routes/git.py
Normal 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]
|
||||
130
backend/src/api/routes/git_schemas.py
Normal file
130
backend/src/api/routes/git_schemas.py
Normal 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]
|
||||
@@ -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.
|
||||
|
||||
@@ -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
73
backend/src/models/git.py
Normal 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]
|
||||
345
backend/src/plugins/git_plugin.py
Normal file
345
backend/src/plugins/git_plugin.py
Normal 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]
|
||||
380
backend/src/services/git_service.py
Normal file
380
backend/src/services/git_service.py
Normal 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]
|
||||
BIN
backend/tasks.db
BIN
backend/tasks.db
Binary file not shown.
@@ -0,0 +1,55 @@
|
||||
slice_name: "FI-0083 \u0421\u0442\u0430\u0442\u0438\u0441\u0442\u0438\u043A\u0430\
|
||||
\ \u043F\u043E \u0414\u0417/\u041F\u0414\u0417"
|
||||
description: null
|
||||
certified_by: null
|
||||
certification_details: null
|
||||
viz_type: pivot_table_v2
|
||||
params:
|
||||
datasource: 859__table
|
||||
viz_type: pivot_table_v2
|
||||
slice_id: 4019
|
||||
groupbyColumns:
|
||||
- dt
|
||||
groupbyRows:
|
||||
- counterparty_search_name
|
||||
- attribute
|
||||
time_grain_sqla: P1M
|
||||
temporal_columns_lookup:
|
||||
dt: true
|
||||
metrics:
|
||||
- m_debt_amount
|
||||
- m_overdue_amount
|
||||
metricsLayout: COLUMNS
|
||||
adhoc_filters:
|
||||
- clause: WHERE
|
||||
comparator: No filter
|
||||
expressionType: SIMPLE
|
||||
operator: TEMPORAL_RANGE
|
||||
subject: dt
|
||||
row_limit: '90000'
|
||||
order_desc: false
|
||||
aggregateFunction: Sum
|
||||
combineMetric: true
|
||||
valueFormat: SMART_NUMBER
|
||||
date_format: smart_date
|
||||
rowOrder: key_a_to_z
|
||||
colOrder: key_a_to_z
|
||||
value_font_size: 12
|
||||
header_font_size: 12
|
||||
label_align: left
|
||||
column_config:
|
||||
m_debt_amount:
|
||||
d3NumberFormat: ',d'
|
||||
m_overdue_amount:
|
||||
d3NumberFormat: ',d'
|
||||
conditional_formatting: []
|
||||
extra_form_data: {}
|
||||
dashboards:
|
||||
- 184
|
||||
query_context: '{"datasource":{"id":859,"type":"table"},"force":false,"queries":[{"filters":[{"col":"dt","op":"TEMPORAL_RANGE","val":"No
|
||||
filter"}],"extras":{"having":"","where":""},"applied_time_extras":{},"columns":[{"timeGrain":"P1M","columnType":"BASE_AXIS","sqlExpression":"dt","label":"dt","expressionType":"SQL"},"counterparty_search_name","attribute"],"metrics":["m_debt_amount","m_overdue_amount"],"orderby":[["m_debt_amount",true]],"annotation_layers":[],"row_limit":90000,"series_limit":0,"order_desc":false,"url_params":{},"custom_params":{},"custom_form_data":{}}],"form_data":{"datasource":"859__table","viz_type":"pivot_table_v2","slice_id":4019,"groupbyColumns":["dt"],"groupbyRows":["counterparty_search_name","attribute"],"time_grain_sqla":"P1M","temporal_columns_lookup":{"dt":true},"metrics":["m_debt_amount","m_overdue_amount"],"metricsLayout":"COLUMNS","adhoc_filters":[{"clause":"WHERE","comparator":"No
|
||||
filter","expressionType":"SIMPLE","operator":"TEMPORAL_RANGE","subject":"dt"}],"row_limit":"90000","order_desc":false,"aggregateFunction":"Sum","combineMetric":true,"valueFormat":"SMART_NUMBER","date_format":"smart_date","rowOrder":"key_a_to_z","colOrder":"key_a_to_z","value_font_size":12,"header_font_size":12,"label_align":"left","column_config":{"m_debt_amount":{"d3NumberFormat":",d"},"m_overdue_amount":{"d3NumberFormat":",d"}},"conditional_formatting":[],"extra_form_data":{},"dashboards":[184],"force":false,"result_format":"json","result_type":"full"},"result_format":"json","result_type":"full"}'
|
||||
cache_timeout: null
|
||||
uuid: 9c293065-73e2-4d9b-a175-d188ff8ef575
|
||||
version: 1.0.0
|
||||
dataset_uuid: 9e645dc0-da25-4f61-9465-6e649b0bc4b1
|
||||
@@ -0,0 +1,13 @@
|
||||
database_name: Prod Clickhouse
|
||||
sqlalchemy_uri: clickhousedb+connect://viz_superset_click_prod:XXXXXXXXXX@rgm-s-khclk.hq.root.ad:443/dm
|
||||
cache_timeout: null
|
||||
expose_in_sqllab: true
|
||||
allow_run_async: false
|
||||
allow_ctas: false
|
||||
allow_cvas: false
|
||||
allow_dml: true
|
||||
allow_file_upload: false
|
||||
extra:
|
||||
allows_virtual_table_explore: true
|
||||
uuid: 97aced68-326a-4094-b381-27980560efa9
|
||||
version: 1.0.0
|
||||
@@ -0,0 +1,119 @@
|
||||
table_name: "FI-0080-06 \u041A\u0430\u043B\u0435\u043D\u0434\u0430\u0440\u044C (\u041E\
|
||||
\u0431\u0449\u0438\u0439 \u0441\u043F\u0440\u0430\u0432\u043E\u0447\u043D\u0438\u043A\
|
||||
)"
|
||||
main_dttm_col: null
|
||||
description: null
|
||||
default_endpoint: null
|
||||
offset: 0
|
||||
cache_timeout: null
|
||||
schema: dm_view
|
||||
sql: "-- [HEADER]\r\n-- [\u041D\u0410\u0417\u041D\u0410\u0427\u0415\u041D\u0418\u0415\
|
||||
]: \u041F\u043E\u043B\u0443\u0447\u0435\u043D\u0438\u0435 \u0434\u0438\u0430\u043F\
|
||||
\u0430\u0437\u043E\u043D\u0430 \u0434\u0430\u0442 \u0434\u043B\u044F \u043E\u0442\
|
||||
\u0447\u0435\u0442\u0430 \u043E \u0437\u0430\u0434\u043E\u043B\u0436\u0435\u043D\
|
||||
\u043D\u043E\u0441\u0442\u044F\u0445 \u043F\u043E \u043E\u0431\u043E\u0440\u043E\
|
||||
\u0442\u043D\u044B\u043C \u0441\u0440\u0435\u0434\u0441\u0442\u0432\u0430\u043C\r\
|
||||
\n-- [\u041A\u041B\u042E\u0427\u0415\u0412\u042B\u0415 \u041A\u041E\u041B\u041E\u041D\
|
||||
\u041A\u0418]:\r\n-- - from_dt_txt: \u041D\u0430\u0447\u0430\u043B\u044C\u043D\
|
||||
\u0430\u044F \u0434\u0430\u0442\u0430 \u0432 \u0444\u043E\u0440\u043C\u0430\u0442\
|
||||
\u0435 DD.MM.YYYY\r\n-- - to_dt_txt: \u041A\u043E\u043D\u0435\u0447\u043D\u0430\
|
||||
\u044F \u0434\u0430\u0442\u0430 \u0432 \u0444\u043E\u0440\u043C\u0430\u0442\u0435\
|
||||
\ DD.MM.YYYY\r\n-- [JINJA \u041F\u0410\u0420\u0410\u041C\u0415\u0422\u0420\u042B\
|
||||
]:\r\n-- - {{ filter_values(\"yes_no_check\") }}: \u0424\u0438\u043B\u044C\u0442\
|
||||
\u0440 \"\u0414\u0430/\u041D\u0435\u0442\" \u0434\u043B\u044F \u043E\u0433\u0440\
|
||||
\u0430\u043D\u0438\u0447\u0435\u043D\u0438\u044F \u0432\u044B\u0431\u043E\u0440\u043A\
|
||||
\u0438 \u043F\u043E \u0434\u0430\u0442\u0435\r\n-- [\u041B\u041E\u0413\u0418\u041A\
|
||||
\u0410]: \u041E\u043F\u0440\u0435\u0434\u0435\u043B\u044F\u0435\u0442 \u043F\u043E\
|
||||
\u0440\u043E\u0433\u043E\u0432\u0443\u044E \u0434\u0430\u0442\u0443 \u0432 \u0437\
|
||||
\u0430\u0432\u0438\u0441\u0438\u043C\u043E\u0441\u0442\u0438 \u043E\u0442 \u0442\
|
||||
\u0435\u043A\u0443\u0449\u0435\u0433\u043E \u0434\u043D\u044F \u043C\u0435\u0441\
|
||||
\u044F\u0446\u0430 \u0438 \u0444\u0438\u043B\u044C\u0442\u0440\u0443\u0435\u0442\
|
||||
\ \u0434\u0430\u043D\u043D\u044B\u0435\r\n\r\nWITH date_threshold AS (\r\n SELECT\
|
||||
\ \r\n -- \u041E\u043F\u0440\u0435\u0434\u0435\u043B\u044F\u0435\u043C \u043F\
|
||||
\u043E\u0440\u043E\u0433\u043E\u0432\u0443\u044E \u0434\u0430\u0442\u0443 \u0432\
|
||||
\ \u0437\u0430\u0432\u0438\u0441\u0438\u043C\u043E\u0441\u0442\u0438 \u043E\u0442\
|
||||
\ \u0442\u0435\u043A\u0443\u0449\u0435\u0433\u043E \u0434\u043D\u044F \r\n \
|
||||
\ CASE \r\n WHEN toDayOfMonth(now()) <= 10 THEN \r\n \
|
||||
\ toStartOfMonth(dateSub(MONTH, 1, now())) \r\n ELSE \r\n \
|
||||
\ toStartOfMonth(now()) \r\n END AS cutoff_date \r\n),\r\nfiltered_dates\
|
||||
\ AS (\r\n SELECT \r\n dt,\r\n formatDateTime(dt, '%d.%m.%Y') AS\
|
||||
\ from_dt_txt,\r\n formatDateTime(dt, '%d.%m.%Y') AS to_dt_txt\r\n \
|
||||
\ --dt as from_dt_txt,\r\n -- dt as to_dt_txt\r\n FROM dm_view.account_debt_for_working_capital_final\r\
|
||||
\n WHERE 1=1\r\n -- \u0411\u0435\u0437\u043E\u043F\u0430\u0441\u043D\u0430\
|
||||
\u044F \u043F\u0440\u043E\u0432\u0435\u0440\u043A\u0430 \u0444\u0438\u043B\u044C\
|
||||
\u0442\u0440\u0430\r\n {% if filter_values(\"yes_no_check\") | length !=\
|
||||
\ 0 %}\r\n {% if filter_values(\"yes_no_check\")[0] == \"\u0414\u0430\
|
||||
\" %}\r\n AND dt < (SELECT cutoff_date FROM date_threshold)\r\n \
|
||||
\ {% endif %}\r\n {% endif %}\r\n)\r\nSELECT \r\ndt,\r\n from_dt_txt,\r\
|
||||
\n to_dt_txt,\r\n formatDateTime(toLastDayOfMonth(dt), '%d.%m.%Y') as last_day_of_month_dt_txt\r\
|
||||
\nFROM \r\n filtered_dates\r\nGROUP BY \r\n dt, from_dt_txt, to_dt_txt\r\n\
|
||||
ORDER BY \r\n dt DESC"
|
||||
params: null
|
||||
template_params: null
|
||||
filter_select_enabled: true
|
||||
fetch_values_predicate: null
|
||||
extra: null
|
||||
normalize_columns: false
|
||||
uuid: fca62707-6947-4440-a16b-70cb6a5cea5b
|
||||
metrics:
|
||||
- metric_name: max_date
|
||||
verbose_name: max_date
|
||||
metric_type: count
|
||||
expression: max(dt)
|
||||
description: null
|
||||
d3format: null
|
||||
currency: null
|
||||
extra:
|
||||
warning_markdown: ''
|
||||
warning_text: null
|
||||
columns:
|
||||
- column_name: from_dt_txt
|
||||
verbose_name: null
|
||||
is_dttm: true
|
||||
is_active: true
|
||||
type: String
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: '%Y'
|
||||
extra: {}
|
||||
- column_name: dt
|
||||
verbose_name: null
|
||||
is_dttm: true
|
||||
is_active: true
|
||||
type: Date
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
extra: {}
|
||||
- column_name: last_day_of_month_dt_txt
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: String
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
extra: {}
|
||||
- column_name: to_dt_txt
|
||||
verbose_name: null
|
||||
is_dttm: true
|
||||
is_active: true
|
||||
type: String
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
extra: {}
|
||||
version: 1.0.0
|
||||
database_uuid: 97aced68-326a-4094-b381-27980560efa9
|
||||
@@ -0,0 +1,190 @@
|
||||
table_name: "FI-0090 \u0421\u0442\u0430\u0442\u0438\u0441\u0442\u0438\u043A\u0430\
|
||||
\ \u043F\u043E \u0414\u0417/\u041F\u0414\u0417"
|
||||
main_dttm_col: dt
|
||||
description: null
|
||||
default_endpoint: null
|
||||
offset: 0
|
||||
cache_timeout: null
|
||||
schema: dm_view
|
||||
sql: "-- [JINJA_BLOCK] \u0426\u0435\u043D\u0442\u0440\u0430\u043B\u0438\u0437\u043E\
|
||||
\u0432\u0430\u043D\u043D\u043E\u0435 \u043E\u043F\u0440\u0435\u0434\u0435\u043B\u0435\
|
||||
\u043D\u0438\u0435 \u0432\u0441\u0435\u0445 Jinja \u043F\u0435\u0440\u0435\u043C\
|
||||
\u0435\u043D\u043D\u044B\u0445\r\n{% set raw_to = filter_values('last_day_of_month_dt_txt')[0]\
|
||||
\ \r\n if filter_values('last_day_of_month_dt_txt') else '01.05.2025'\
|
||||
\ %}\r\n\r\n{# \u0440\u0430\u0437\u0431\u0438\u0432\u0430\u0435\u043C \xABDD.MM.YYYY\xBB\
|
||||
\ \u043D\u0430 \u0447\u0430\u0441\u0442\u0438 #}\r\n{% set to_parts = raw_to.split('.')\
|
||||
\ %}\r\n\r\n{# \u0441\u043E\u0431\u0438\u0440\u0430\u0435\u043C ISO\u2011\u0441\u0442\
|
||||
\u0440\u043E\u043A\u0443 \xABYYYY-MM-DD\xBB #}\r\n{% set to_dt = to_parts[2] \
|
||||
\ ~ '-' ~ to_parts[1] ~ '-' ~ to_parts[0] %}\r\n\r\nwith \r\ncp_relations_type\
|
||||
\ AS (\r\n select * from ( SELECT \r\n ctd.counterparty_code AS counterparty_code,\r\
|
||||
\n min(dt_from) as dt_from,\r\n max(dt_to) as dt_to,\r\n crt.relation_type_code\
|
||||
\ || ' ' || crt.relation_type_name AS relation_type_code_name\r\n FROM\r\n \
|
||||
\ dm_view.counterparty_td ctd\r\n JOIN dm_view.counterparty_relation_type_texts\
|
||||
\ crt \r\n ON ctd.relation_type_code = crt.relation_type_code\r\n GROUP\
|
||||
\ BY\r\n ctd.counterparty_code, ctd.counterparty_full_name,\r\n crt.relation_type_code,crt.relation_type_name)\r\
|
||||
\n WHERE \r\n dt_from <= toDate('{{to_dt }}') AND \r\n \
|
||||
\ dt_to >= toDate('{{to_dt }}')\r\n ),\r\nt_debt as \r\n(SELECT dt, \r\n\
|
||||
counterparty_search_name,\r\ncp_relations_type.relation_type_code_name as relation_type_code_name,\r\
|
||||
\nunit_balance_code || ' ' || unit_balance_name as unit_balance_code_name,\r\n'1.\
|
||||
\ \u0421\u0443\u043C\u043C\u0430' as attribute,\r\nsum(debt_balance_subposition_no_revaluation_usd_amount)\
|
||||
\ as debt_amount,\r\nsumIf(debt_balance_subposition_no_revaluation_usd_amount,dt_overdue\
|
||||
\ < dt) as overdue_amount\r\nfrom dm_view.account_debt_for_working_capital t_debt\r\
|
||||
\njoin cp_relations_type ON\r\ncp_relations_type.counterparty_code = t_debt.counterparty_code\r\
|
||||
\nwhere dt = toLastDayOfMonth(dt)\r\nand match(general_ledger_account_code,'((62)|(60)|(76))')\r\
|
||||
\nand debit_or_credit = 'S'\r\nand account_type = 'D'\r\nand dt between addMonths(toDate('{{to_dt\
|
||||
\ }}'),-12) and toDate('{{to_dt }}')\r\ngroup by dt, counterparty_search_name,unit_balance_code_name,relation_type_code_name\r\
|
||||
\n),\r\n\r\nt_transaction_count_base as \r\n(\r\nselect *,\r\ncp_relations_type.relation_type_code_name\
|
||||
\ as relation_type_code_name,\r\nunit_balance_code || ' ' || unit_balance_name as\
|
||||
\ unit_balance_code_name,\r\n case when dt_overdue<dt_clearing then\r\n \
|
||||
\ dateDiff(day, dt_overdue, dt_clearing) \r\n else 0\r\n end\
|
||||
\ as overdue_days\r\nfrom dm_view.accounting_documents_leading_to_debt t_docs\r\n\
|
||||
join cp_relations_type ON\r\ncp_relations_type.counterparty_code = t_docs.counterparty_code\r\
|
||||
\nwhere 1=1\r\n\r\nand match(general_ledger_account_code,'((62)|(60)|(76))')\r\n\
|
||||
and debit_or_credit = 'S'\r\nand account_type = 'D'\r\n)\r\n\r\nselect * from t_debt\r\
|
||||
\n\r\nunion all \r\n\r\nselect toLastDayOfMonth(dt_debt) as dt, \r\ncounterparty_search_name,\r\
|
||||
\nrelation_type_code_name,\r\nunit_balance_code_name,\r\n'2. \u043A\u043E\u043B\u0438\
|
||||
\u0447\u0435\u0441\u0442\u0432\u043E \u0442\u0440\u0430\u043D\u0437\u0430\u043A\u0446\
|
||||
\u0438\u0439 \u0432 \u043C\u0435\u0441\u044F\u0446' as attribute,\r\ncount(1) as\
|
||||
\ debt_amount,\r\nnull as overdue_amount\r\nfrom t_transaction_count_base\r\nwhere\
|
||||
\ dt_debt between addMonths(toDate('{{to_dt }}'),-12) and toDate('{{to_dt }}')\r\
|
||||
\ngroup by toLastDayOfMonth(dt_debt), \r\ncounterparty_search_name,\r\nrelation_type_code_name,\r\
|
||||
\nunit_balance_code_name,attribute\r\n\r\nunion all \r\n\r\nselect toLastDayOfMonth(dt_clearing)\
|
||||
\ as dt, \r\ncounterparty_search_name,\r\nrelation_type_code_name,\r\nunit_balance_code_name,\r\
|
||||
\n'2. \u043A\u043E\u043B\u0438\u0447\u0435\u0441\u0442\u0432\u043E \u0442\u0440\u0430\
|
||||
\u043D\u0437\u0430\u043A\u0446\u0438\u0439 \u0432 \u043C\u0435\u0441\u044F\u0446\
|
||||
' as attribute,\r\nnull as debt_amount,\r\ncount(1) as overdue_amount\r\nfrom t_transaction_count_base\r\
|
||||
\nwhere dt_clearing between addMonths(toDate('{{to_dt }}'),-12) and toDate('{{to_dt\
|
||||
\ }}')\r\nand overdue_days > 0\r\ngroup by toLastDayOfMonth(dt_clearing), \r\ncounterparty_search_name,\r\
|
||||
\nrelation_type_code_name,\r\nunit_balance_code_name,attribute\r\n\r\nunion all\
|
||||
\ \r\n\r\nselect toLastDayOfMonth(dt_clearing) as dt, \r\ncounterparty_search_name,\r\
|
||||
\nrelation_type_code_name,\r\nunit_balance_code_name,\r\nmultiIf(\r\noverdue_days\
|
||||
\ < 30,'3. \u0434\u043E 30',\r\noverdue_days between 30 and 60, '4. \u043E\u0442\
|
||||
\ 30 \u0434\u043E 60',\r\noverdue_days between 61 and 90, '5. \u043E\u0442 61 \u0434\
|
||||
\u043E 90',\r\noverdue_days>90,'6. \u0431\u043E\u043B\u0435\u0435 90 \u0434\u043D\
|
||||
',\r\nnull\r\n)\r\n as attribute,\r\nnull as debt_amount,\r\ncount(1) as overdue_amount\r\
|
||||
\nfrom t_transaction_count_base\r\nwhere dt_clearing between addMonths(toDate('{{to_dt\
|
||||
\ }}'),-12) and toDate('{{to_dt }}')\r\nand overdue_days > 0\r\ngroup by toLastDayOfMonth(dt_clearing),\
|
||||
\ \r\ncounterparty_search_name,\r\nrelation_type_code_name,\r\nattribute,unit_balance_code_name,attribute\r\
|
||||
\n"
|
||||
params: null
|
||||
template_params: null
|
||||
filter_select_enabled: true
|
||||
fetch_values_predicate: null
|
||||
extra: null
|
||||
normalize_columns: false
|
||||
uuid: 9e645dc0-da25-4f61-9465-6e649b0bc4b1
|
||||
metrics:
|
||||
- metric_name: m_debt_amount
|
||||
verbose_name: "\u0414\u0417, $"
|
||||
metric_type: count
|
||||
expression: sum(debt_amount)
|
||||
description: null
|
||||
d3format: null
|
||||
currency: null
|
||||
extra:
|
||||
warning_markdown: ''
|
||||
warning_text: null
|
||||
- metric_name: m_overdue_amount
|
||||
verbose_name: "\u041F\u0414\u0417, $"
|
||||
metric_type: null
|
||||
expression: sum(overdue_amount)
|
||||
description: null
|
||||
d3format: null
|
||||
currency: null
|
||||
extra:
|
||||
warning_markdown: ''
|
||||
warning_text: null
|
||||
columns:
|
||||
- column_name: debt_amount
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: Nullable(Decimal(38, 2))
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
extra:
|
||||
warning_markdown: null
|
||||
- column_name: overdue_amount
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: Nullable(Decimal(38, 2))
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
extra:
|
||||
warning_markdown: null
|
||||
- column_name: dt
|
||||
verbose_name: null
|
||||
is_dttm: true
|
||||
is_active: true
|
||||
type: Nullable(Date)
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
extra:
|
||||
warning_markdown: null
|
||||
- column_name: unit_balance_code_name
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: Nullable(String)
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
extra:
|
||||
warning_markdown: null
|
||||
- column_name: relation_type_code_name
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: Nullable(String)
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
extra:
|
||||
warning_markdown: null
|
||||
- column_name: counterparty_search_name
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: Nullable(String)
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
extra:
|
||||
warning_markdown: null
|
||||
- column_name: attribute
|
||||
verbose_name: null
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: Nullable(String)
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
extra:
|
||||
warning_markdown: null
|
||||
version: 1.0.0
|
||||
database_uuid: 97aced68-326a-4094-b381-27980560efa9
|
||||
@@ -0,0 +1,3 @@
|
||||
version: 1.0.0
|
||||
type: Dashboard
|
||||
timestamp: '2026-01-14T11:21:08.078620+00:00'
|
||||
7
frontend/.eslintignore
Normal file
7
frontend/.eslintignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
.svelte-kit/
|
||||
.vite/
|
||||
coverage/
|
||||
*.min.js
|
||||
9
frontend/.prettierignore
Normal file
9
frontend/.prettierignore
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
.svelte-kit/
|
||||
.vite/
|
||||
coverage/
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
170
frontend/src/components/git/BranchSelector.svelte
Normal file
170
frontend/src/components/git/BranchSelector.svelte
Normal 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] -->
|
||||
90
frontend/src/components/git/CommitHistory.svelte
Normal file
90
frontend/src/components/git/CommitHistory.svelte
Normal 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] -->
|
||||
175
frontend/src/components/git/CommitModal.svelte
Normal file
175
frontend/src/components/git/CommitModal.svelte
Normal 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] -->
|
||||
142
frontend/src/components/git/ConflictResolver.svelte
Normal file
142
frontend/src/components/git/ConflictResolver.svelte
Normal 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] -->
|
||||
147
frontend/src/components/git/DeploymentModal.svelte
Normal file
147
frontend/src/components/git/DeploymentModal.svelte
Normal 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] -->
|
||||
284
frontend/src/components/git/GitManager.svelte
Normal file
284
frontend/src/components/git/GitManager.svelte
Normal 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] -->
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
86
frontend/src/routes/git/+page.svelte
Normal file
86
frontend/src/routes/git/+page.svelte
Normal 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] -->
|
||||
40
frontend/src/routes/settings/environments/+page.svelte
Normal file
40
frontend/src/routes/settings/environments/+page.svelte
Normal 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>
|
||||
136
frontend/src/routes/settings/git/+page.svelte
Normal file
136
frontend/src/routes/settings/git/+page.svelte
Normal 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>
|
||||
325
frontend/src/services/gitService.js
Normal file
325
frontend/src/services/gitService.js
Normal 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]
|
||||
@@ -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}")
|
||||
@@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: Git Integration Plugin for Dashboard Development
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-01-18
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
✅ **VALIDATION COMPLETE**: All checklist items pass. The specification is ready for `/speckit.clarify` or `/speckit.plan`
|
||||
93
specs/011-git-integration-dashboard/contracts/api.md
Normal file
93
specs/011-git-integration-dashboard/contracts/api.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# API Contracts: Git Integration Plugin
|
||||
|
||||
**Feature**: Git Integration for Dashboard Development
|
||||
**Date**: 2026-01-22
|
||||
|
||||
## Base Path
|
||||
`/api/git`
|
||||
|
||||
## Endpoints
|
||||
|
||||
### 1. Configuration
|
||||
|
||||
#### `GET /config/{dashboard_uuid}`
|
||||
Retrieve Git configuration for a specific dashboard.
|
||||
- **Response**: `GitServerConfig` (excluding full token)
|
||||
|
||||
#### `POST /config`
|
||||
Save or update Git configuration.
|
||||
- **Request**: `GitServerConfig`
|
||||
- **Response**: `GitServerConfig`
|
||||
|
||||
### 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.
|
||||
- **Request**: `{ "name": "feature/new-chart", "source_branch": "main" }`
|
||||
- **Response**: `Branch`
|
||||
|
||||
#### `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" }`
|
||||
|
||||
### 4. Commit & Push
|
||||
|
||||
#### `POST /commit/{dashboard_uuid}`
|
||||
Commit staged changes.
|
||||
- **Request**: `{ "message": "Updated sales chart", "files": ["charts/sales.yaml"] }`
|
||||
- **Response**: `Commit`
|
||||
|
||||
#### `POST /push/{dashboard_uuid}`
|
||||
Push commits to remote.
|
||||
- **Response**: `{ "status": "success" }`
|
||||
|
||||
#### `POST /pull/{dashboard_uuid}`
|
||||
Pull changes from remote.
|
||||
- **Response**: `{ "status": "success", "updates": [...] }`
|
||||
|
||||
### 5. History
|
||||
|
||||
#### `GET /history/{dashboard_uuid}`
|
||||
Get commit log.
|
||||
- **Query Params**: `limit=20`, `branch=main`
|
||||
- **Response**: `List[Commit]`
|
||||
|
||||
### 6. Deployment
|
||||
|
||||
#### `GET /environments`
|
||||
List configured target environments.
|
||||
- **Response**: `List[Environment]`
|
||||
|
||||
#### `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": "..." }`
|
||||
76
specs/011-git-integration-dashboard/data-model.md
Normal file
76
specs/011-git-integration-dashboard/data-model.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Data Model: Git Integration Plugin
|
||||
|
||||
**Feature**: Git Integration for Dashboard Development
|
||||
**Date**: 2026-01-22
|
||||
|
||||
## Entities
|
||||
|
||||
### 1. GitServerConfig
|
||||
Configuration for connecting a dashboard to a Git repository.
|
||||
|
||||
| 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 |
|
||||
|
||||
### 2. Environment
|
||||
Target environments for deployment.
|
||||
|
||||
| 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 |
|
||||
|
||||
### 3. Branch (DTO)
|
||||
Data Transfer Object representing a Git branch.
|
||||
|
||||
| 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
|
||||
|
||||
- **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).
|
||||
100
specs/011-git-integration-dashboard/plan.md
Normal file
100
specs/011-git-integration-dashboard/plan.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# Implementation Plan: Git Integration Plugin for Dashboard Development
|
||||
|
||||
**Branch**: `011-git-integration-dashboard` | **Date**: 2026-01-22 | **Spec**: [spec.md](specs/011-git-integration-dashboard/spec.md)
|
||||
**Input**: Feature specification from `/specs/011-git-integration-dashboard/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Implement a Git integration plugin that allows dashboard developers to version control their Superset dashboards. The plugin will support GitLab, GitHub, and Gitea, enabling branch management, committing/pushing changes (as unpacked Superset exports), and deploying to target environments via the Superset API.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Python 3.9+ (Backend), Node.js 18+ (Frontend)
|
||||
**Primary Dependencies**: FastAPI, SvelteKit, GitPython (or CLI git), Pydantic, SQLAlchemy, Superset API
|
||||
**Storage**: SQLite (for config/history), Filesystem (local Git repositories)
|
||||
**Testing**: pytest (Backend), SvelteKit testing utilities (Frontend)
|
||||
**Target Platform**: Linux server
|
||||
**Project Type**: Web application (Frontend + Backend)
|
||||
**Performance Goals**: Branch switching < 5s, Search/Filter history < 2s
|
||||
**Constraints**: Offline mode support, Superset API dependency for deployment, PAT-based authentication
|
||||
**Scale/Scope**: 1 repository per dashboard, support for up to 1000 commits per repo
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
1. **Semantic Protocol Compliance**: All new modules must include headers, `[DEF]` anchors, and `@RELATION` tags as per `semantic_protocol.md`.
|
||||
2. **Causal Validity**: API contracts and data models must be defined in `specs/` before implementation.
|
||||
3. **Everything is a Plugin**: The Git integration must be implemented as a `PluginBase` subclass in `backend/src/plugins/`.
|
||||
4. **Design by Contract**: Use Pydantic models for request/response validation and internal state transitions.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/[###-feature]/
|
||||
├── plan.md # This file (/speckit.plan command output)
|
||||
├── research.md # Phase 0 output (/speckit.plan command)
|
||||
├── data-model.md # Phase 1 output (/speckit.plan command)
|
||||
├── quickstart.md # Phase 1 output (/speckit.plan command)
|
||||
├── contracts/ # Phase 1 output (/speckit.plan command)
|
||||
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
backend/
|
||||
├── src/
|
||||
│ ├── 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 (+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.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> **Fill ONLY if Constitution Check has violations that must be justified**
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
|
||||
[e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 2: Backend Implementation (Plugin & Service)
|
||||
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.
|
||||
* 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.
|
||||
4. **API Routes**: Implement `/api/git/*` endpoints for config, sync, and history.
|
||||
|
||||
### Phase 3: Frontend Implementation
|
||||
1. **Configuration UI**: Settings page for `GitServerConfig` (Provider selection, PAT validation).
|
||||
2. **Dashboard Integration**: Add Git controls to the Dashboard view.
|
||||
* 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 `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.
|
||||
43
specs/011-git-integration-dashboard/quickstart.md
Normal file
43
specs/011-git-integration-dashboard/quickstart.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Quickstart: Git Integration for Dashboards
|
||||
|
||||
## Prerequisites
|
||||
- A running Superset instance.
|
||||
- A Git repository (GitLab, GitHub, etc.) created for your dashboard.
|
||||
- A Personal Access Token (PAT) with `repo` scope.
|
||||
|
||||
## Setup Guide
|
||||
|
||||
### 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.
|
||||
|
||||
### 2. Development Workflow
|
||||
|
||||
#### 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`).
|
||||
|
||||
#### Committing
|
||||
1. Select the files you want to include.
|
||||
2. Enter a commit message.
|
||||
3. Click **Commit**.
|
||||
|
||||
#### Pushing
|
||||
1. Click **Push** to send your changes to the remote repository.
|
||||
|
||||
### 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.
|
||||
74
specs/011-git-integration-dashboard/research.md
Normal file
74
specs/011-git-integration-dashboard/research.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Research & Decisions: Git Integration Plugin
|
||||
|
||||
**Feature**: Git Integration for Dashboard Development
|
||||
**Date**: 2026-01-22
|
||||
**Status**: Finalized
|
||||
|
||||
## 1. Unknowns & Clarifications
|
||||
|
||||
The following clarifications were resolved during the specification phase:
|
||||
|
||||
| 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. |
|
||||
|
||||
## 2. Technology Decisions
|
||||
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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).
|
||||
|
||||
### 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.
|
||||
171
specs/011-git-integration-dashboard/spec.md
Normal file
171
specs/011-git-integration-dashboard/spec.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# Feature Specification: Git Integration Plugin for Dashboard Development
|
||||
|
||||
**Feature Branch**: `011-git-integration-dashboard`
|
||||
**Created**: 2026-01-18
|
||||
**Status**: In Progress
|
||||
**Input**: User description: "Нужен плагин интеграции git в разработку дашбордов. Требования - возможность настройки целевого git сервера (базово - интеграция с gitlab), хранение и синхронизация веток разработки дашбордов, возможность публикацию в другое целевое окружение после коммита"
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
<!--
|
||||
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
|
||||
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
|
||||
you should still have a viable MVP (Minimum Viable Product) that delivers value.
|
||||
|
||||
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
|
||||
Think of each story as a standalone slice of functionality that can be:
|
||||
- Developed independently
|
||||
- Tested independently
|
||||
- Deployed independently
|
||||
- Demonstrated to users independently
|
||||
-->
|
||||
|
||||
### User Story 1 - Configure Git Server Connection (Priority: P1)
|
||||
|
||||
A dashboard developer needs to connect the system to their Git server (GitLab, GitHub, or Gitea) to enable version control for dashboard development. They want to configure the Git server provider, URL, authentication credentials, and repository details through a simple form interface.
|
||||
|
||||
**Why this priority**: This is the foundational requirement - without Git server configuration, no other Git functionality can work. It's the entry point for all Git operations.
|
||||
|
||||
**Independent Test**: Can be fully tested by configuring a Git server connection (e.g., GitHub) and verifying the connection test succeeds, delivering the ability to establish Git server connectivity.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a user is on the Git integration settings page, **When** they select a provider (GitLab/GitHub/Gitea) and enter valid server URL and PAT, **Then** the system successfully validates the connection
|
||||
2. **Given** a user enters invalid Git credentials, **When** they test the connection, **Then** the system displays a clear error message indicating authentication failure
|
||||
3. **Given** a user has configured a Git server, **When** they save the settings, **Then** the configuration is persisted and can be retrieved later
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Dashboard Branch Management (Priority: P1)
|
||||
|
||||
A dashboard developer needs to create, switch between, and manage different development branches for their dashboards. They want to see available branches, create new feature branches, and switch between branches to work on different dashboard versions.
|
||||
|
||||
**Why this priority**: This is core to the Git workflow - developers need to manage branches to work on different features or versions of dashboards simultaneously.
|
||||
|
||||
**Independent Test**: Can be fully tested by creating a new branch, switching to it, and verifying the branch operations work correctly, delivering basic Git branch workflow capabilities.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a Git server is configured, **When** a user views the branch list, **Then** they see all available branches from the remote repository
|
||||
2. **Given** a user wants to create a new feature branch, **When** they specify a branch name, **Then** the system creates the branch both locally and pushes it to the remote repository
|
||||
3. **Given** multiple branches exist, **When** a user switches to a different branch, **Then** the dashboard content updates to reflect the selected branch's state
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Dashboard Synchronization with Git (Priority: P1)
|
||||
|
||||
A dashboard developer needs to synchronize their local dashboard changes with the Git repository. They want to commit changes, push to remote branches, and pull updates from other developers working on the same dashboard.
|
||||
|
||||
**Why this priority**: This enables collaborative development and ensures changes are properly tracked and shared between team members.
|
||||
|
||||
**Independent Test**: Can be fully tested by making dashboard changes, committing them, and pushing to the remote repository, delivering complete Git workflow integration.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a user has made changes to a dashboard, **When** they commit the changes with a message, **Then** the changes are committed to the current branch
|
||||
2. **Given** local changes exist, **When** a user pushes to the remote repository, **Then** the changes are successfully pushed and visible to other team members
|
||||
3. **Given** remote changes exist, **When** a user pulls from the remote repository, **Then** local dashboard content is updated with the latest changes
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Environment Deployment (Priority: P2)
|
||||
|
||||
A dashboard developer needs to deploy their dashboard changes to different target environments (e.g., staging, production) after committing changes. They want to select a target environment and trigger the deployment process.
|
||||
|
||||
**Why this priority**: This enables the complete development-to-production workflow, allowing teams to promote dashboard changes through different environments.
|
||||
|
||||
**Independent Test**: Can be fully tested by selecting a target environment and triggering deployment, delivering the ability to promote dashboard changes to different environments.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** dashboard changes are committed, **When** a user selects a target environment for deployment, **Then** the system validates the deployment configuration
|
||||
2. **Given** deployment is initiated, **When** the process completes, **Then** the user receives clear feedback on deployment success or failure
|
||||
3. **Given** multiple environments are configured, **When** a user deploys to one environment, **Then** other environments remain unaffected
|
||||
|
||||
---
|
||||
|
||||
### User Story 5 - Git History and Change Tracking (Priority: P3)
|
||||
|
||||
A dashboard developer needs to view the commit history and changes made to dashboards over time. They want to see who made changes, when they were made, and what specific changes were included in each commit.
|
||||
|
||||
**Why this priority**: This provides visibility into the development process and helps with debugging, auditing, and understanding the evolution of dashboards.
|
||||
|
||||
**Independent Test**: Can be fully tested by viewing commit history and examining specific commits, delivering transparency into dashboard development history.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** multiple commits exist, **When** a user views the commit history, **Then** they see a chronological list of all commits with relevant details
|
||||
2. **Given** a specific commit is selected, **When** the user views commit details, **Then** they see the changes included in that commit
|
||||
3. **Given** commit history is available, **When** a user searches for specific changes, **Then** they can filter and find relevant commits
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when Git server is temporarily unavailable during synchronization?
|
||||
- How does system handle merge conflicts when multiple developers modify the same dashboard?
|
||||
- What happens when a user tries to deploy to an environment that doesn't exist or is misconfigured?
|
||||
- How does system handle large dashboard files that exceed Git repository size limits?
|
||||
- What happens when Git authentication tokens expire during operations?
|
||||
- How does system handle network interruptions during long-running operations like large pushes?
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
## Clarifications
|
||||
|
||||
|
||||
### Session 2026-01-22
|
||||
- Q: Что именно должно входить в состав данных дашборда в Git-репозитории? → A: Распакованный архив экспорта Superset (YAML файлы: метаданные, чарты, датасеты, базы данных).
|
||||
- Q: Какова структура хранения дашбордов в Git-репозитории? → A: Один репозиторий соответствует одному дашборду.
|
||||
- Q: Как должен происходить деплой на целевое окружение после коммита? → A: Импорт через API (Superset Import API).
|
||||
- Q: Как система должна обрабатывать конфликты слияния (merge conflicts)? → A: UI для выбора версии (Keep Mine / Keep Theirs) на уровне файлов.
|
||||
- Q: Как должен происходить выбор дашборда для работы с Git? → A: Выбор конкретного дашборда из списка в UI.
|
||||
- Q: What triggers a deployment to a target environment? → A: Manual trigger by user from UI
|
||||
- Q: How should the system handle authentication for the different providers (GitHub, GitLab, Gitea)? → A: Option [A] - Personal Access Token (PAT) for all providers
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST allow users to configure Git server connection settings including provider type (GitHub, GitLab, Gitea), server URL, authentication via Personal Access Token (PAT), and repository details
|
||||
- **FR-002**: System MUST support GitLab, GitHub, and Gitea as supported Git server providers
|
||||
- **FR-003**: System MUST validate Git server connection using the provided PAT for the selected provider before saving configuration
|
||||
- **FR-004**: Users MUST be able to view all available branches from the configured Git repository
|
||||
- **FR-005**: Users MUST be able to create new branches both locally and remotely with proper naming validation
|
||||
- **FR-006**: Users MUST be able to switch between existing branches and have dashboard content update accordingly
|
||||
- **FR-007**: System MUST allow users to commit dashboard changes with commit messages and optional file selection
|
||||
- **FR-008**: System MUST support pushing local commits to remote repository branches
|
||||
- **FR-009**: System MUST support pulling changes from remote repository to local working directory
|
||||
- **FR-010**: System MUST handle merge conflicts with a built-in UI providing "Keep Mine", "Keep Theirs", and "Manual Edit" (via a text area editor for YAML content) resolution options
|
||||
- **FR-011**: Users MUST be able to configure multiple target environments for dashboard deployment
|
||||
- **FR-012**: System MUST allow users to manually trigger deployment to a selected target environment from the UI
|
||||
- **FR-013**: System MUST validate deployment configuration before initiating deployment process
|
||||
- **FR-014**: System MUST provide clear feedback on deployment success or failure with detailed logs
|
||||
- **FR-015**: Users MUST be able to view commit history with details including author, timestamp, and commit message
|
||||
- **FR-016**: System MUST display detailed changes included in each commit for audit purposes
|
||||
- **FR-017**: System MUST handle PAT expiration gracefully with re-authentication prompts
|
||||
- **FR-018**: System MUST provide offline mode functionality when Git server is unavailable
|
||||
- **FR-019**: System MUST store and validate dashboard data as a standard unpacked Superset export (YAML files for dashboard metadata, charts, datasets, and database connections) within their respective directories.
|
||||
- **FR-020**: Users MUST be able to search and filter commit history by date, author, or message content
|
||||
- **FR-021**: System MUST support rollback functionality to previous dashboard versions via Git operations
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **GitServerConfig**: Represents Git server connection configuration including server URL, Personal Access Token (PAT), repository path, and connection status
|
||||
- **Branch**: Represents a Git branch with properties like name, commit hash, last updated timestamp, and remote tracking status
|
||||
- **Commit**: Represents a Git commit with properties like commit hash, author, timestamp, commit message, and list of changed files
|
||||
- **Environment**: Represents a deployment target environment with properties like name, URL, authentication details, and deployment status
|
||||
- **DashboardChange**: Represents changes made to dashboard directories (YAML metadata + assets) including file paths, change type (add/modify/delete), and content differences
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Users can successfully configure a supported Git server connection (GitLab, GitHub, or Gitea) and validate it within 2 minutes
|
||||
- **SC-002**: Dashboard branch switching operations complete within 5 seconds for repositories with up to 100 commits
|
||||
- **SC-003**: Commit and push operations succeed in 95% of attempts under normal network conditions
|
||||
- **SC-004**: Deployment to target environments completes successfully within 30 seconds for standard dashboard configurations
|
||||
- **SC-005**: Users can view and navigate through commit history for dashboards with up to 1000 commits without performance degradation
|
||||
- **SC-006**: Merge conflict resolution guidance is provided in 100% of conflict scenarios with clear resolution steps
|
||||
- **SC-007**: Authentication token refresh process completes within 10 seconds when tokens expire
|
||||
- **SC-008**: System maintains dashboard state consistency across branch switches in 99% of operations
|
||||
- **SC-009**: Deployment rollback operations complete within 1 minute for standard dashboard configurations
|
||||
- **SC-010**: Users can search and filter commit history with results displayed within 2 seconds for repositories with up to 500 commits
|
||||
88
specs/011-git-integration-dashboard/tasks.md
Normal file
88
specs/011-git-integration-dashboard/tasks.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Tasks: Git Integration Plugin
|
||||
|
||||
**Feature**: Git Integration for Dashboard Development
|
||||
**Status**: Completed
|
||||
**Total Tasks**: 35
|
||||
|
||||
## Phase 1: Setup
|
||||
**Goal**: Initialize project structure and dependencies.
|
||||
|
||||
- [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**: Implement core data models and service skeletons.
|
||||
**Prerequisites**: Phase 1
|
||||
|
||||
- [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 (P1)
|
||||
**Goal**: Enable users to configure and validate Git server connections.
|
||||
**Prerequisites**: Phase 2
|
||||
|
||||
- [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 - Branch Management (P1)
|
||||
**Goal**: Enable creating and switching branches for dashboards.
|
||||
**Prerequisites**: Phase 3
|
||||
|
||||
- [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 - Synchronization (P1)
|
||||
**Goal**: Enable committing, pushing, and pulling changes.
|
||||
**Prerequisites**: Phase 4
|
||||
|
||||
- [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 - Deployment (P2)
|
||||
**Goal**: Deploy dashboard versions to target environments.
|
||||
**Prerequisites**: Phase 5
|
||||
|
||||
- [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 - History (P3)
|
||||
**Goal**: View commit history and audit changes.
|
||||
**Prerequisites**: Phase 5
|
||||
|
||||
- [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
|
||||
**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 -> US2 -> US3 -> US4
|
||||
- US3 -> US5
|
||||
- US1 is the critical path.
|
||||
|
||||
## Implementation Strategy
|
||||
- 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.
|
||||
Reference in New Issue
Block a user