fix(git): fix 17 missing async/await bugs + UX overhaul
Backend: - fix 17 missing 'await' in git route handlers causing silent no-ops (branches, diff, history, commit, push, pull, merge, promote, sync) - fix async coroutine passed to run_blocking in git_plugin.py Frontend: - add collapsible 'How it works' onboarding (GitHelpPanel) - add status legend with color-coded repository statuses - i18n: add 50+ missing keys, replace hardcoded strings - add Refresh button in modal header - add PROD deploy confirmation dialog (replaces browser prompt()) - add CommitHistory to workspace tab with timeline nodes - add post-commit success banner with next-step guidance - increase success toast duration to 8s - group local/remote branches in selector (optgroup) - format last_modified dates timezone-aware - change PROD badge from red to neutral indigo - extract shared resolveGitStatusToken to git-utils.ts - fix 'slug' label regression - remove dead init_repo_button key UI/UX audit fixes: - add descriptions to Create/Init buttons in init panel - add actionable CTA to server mismatch warning - improve checkbox text phrasing
This commit is contained in:
@@ -62,6 +62,16 @@ LLM_CA_CERT_URLS=
|
||||
ENABLE_BELIEF_STATE_LOGGING=true
|
||||
TASK_LOG_LEVEL=INFO
|
||||
|
||||
# ======================================================================
|
||||
# Шифрование (ОБЯЗАТЕЛЬНО)
|
||||
# ======================================================================
|
||||
# Сгенерируйте Fernet-ключ командой:
|
||||
# python3 -c "import base64,os; print(base64.urlsafe_b64encode(os.urandom(32)).decode())"
|
||||
# Ключ ДОЛЖЕН быть одинаковым на всех перезапусках сервиса.
|
||||
# При смене ключа все зашифрованные данные (пароли подключений, API-ключи LLM)
|
||||
# становятся нечитаемыми.
|
||||
ENCRYPTION_KEY=
|
||||
|
||||
# ======================================================================
|
||||
# Admin (первый запуск)
|
||||
# ======================================================================
|
||||
|
||||
@@ -13,7 +13,6 @@ permission:
|
||||
fullstack-coder: deny
|
||||
reflection-agent: deny
|
||||
security-auditor: allow
|
||||
steps: 80
|
||||
color: warning
|
||||
---
|
||||
MANDATORY USE `skill({name="semantics-core"})`, `skill({name="semantics-contracts"})`, `skill({name="molecular-cot-logging"})`, `skill({name="semantics-python"})`, `skill({name="semantics-svelte"})`
|
||||
|
||||
@@ -3,34 +3,6 @@
|
||||
"experimental": {
|
||||
"mcp_timeout": 300000
|
||||
},
|
||||
"provider": {
|
||||
"cfbt": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "CFBT CCWU (keyless)",
|
||||
"options": {
|
||||
"baseURL": "https://cfbt.ccwu.cc/v1"
|
||||
},
|
||||
"models": {
|
||||
"@cf/moonshotai/kimi-k2.6": {
|
||||
"name": "@cf/moonshotai/kimi-k2.6"
|
||||
}
|
||||
},
|
||||
"api": "openai"
|
||||
},
|
||||
"cfbt_ccwu": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "CFBT CCWU",
|
||||
"options": {
|
||||
"baseURL": "https://cfbt.ccwu.cc/v1"
|
||||
},
|
||||
"models": {
|
||||
"@cf/moonshotai/kimi-k2.6": {
|
||||
"name": "@cf/moonshotai/kimi-k2.6"
|
||||
}
|
||||
},
|
||||
"api": "openai"
|
||||
}
|
||||
},
|
||||
"mcp": {
|
||||
"chrome-devtools": {
|
||||
"type": "local",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# ── Required: Secrets ──
|
||||
# Generate with: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
AUTH_SECRET_KEY=change-me-to-a-random-secret
|
||||
ENCRYPTION_KEY=change-me-to-a-fernet-key
|
||||
# Generate with: python3 -c "import base64,os; print(base64.urlsafe_b64encode(os.urandom(32)).decode())"
|
||||
ENCRYPTION_KEY=
|
||||
|
||||
# ── Required: Database ──
|
||||
# PostgreSQL via docker-compose:
|
||||
|
||||
@@ -42,7 +42,7 @@ async def get_merge_status(
|
||||
dashboard_id = await _resolve_dashboard_id_from_ref(
|
||||
dashboard_ref, config_manager, env_id
|
||||
)
|
||||
return _gs.get_merge_status(dashboard_id)
|
||||
return await _gs.get_merge_status(dashboard_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -71,7 +71,7 @@ async def get_merge_conflicts(
|
||||
dashboard_id = await _resolve_dashboard_id_from_ref(
|
||||
dashboard_ref, config_manager, env_id
|
||||
)
|
||||
return _gs.get_merge_conflicts(dashboard_id)
|
||||
return await _gs.get_merge_conflicts(dashboard_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -99,7 +99,7 @@ async def resolve_merge_conflicts(
|
||||
dashboard_id = await _resolve_dashboard_id_from_ref(
|
||||
dashboard_ref, config_manager, env_id
|
||||
)
|
||||
resolved_files = _gs.resolve_merge_conflicts(
|
||||
resolved_files = await _gs.resolve_merge_conflicts(
|
||||
dashboard_id,
|
||||
[item.model_dump() for item in resolve_data.resolutions],
|
||||
)
|
||||
@@ -129,7 +129,7 @@ async def abort_merge(
|
||||
dashboard_id = await _resolve_dashboard_id_from_ref(
|
||||
dashboard_ref, config_manager, env_id
|
||||
)
|
||||
return _gs.abort_merge(dashboard_id)
|
||||
return await _gs.abort_merge(dashboard_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -157,7 +157,7 @@ async def continue_merge(
|
||||
dashboard_id = await _resolve_dashboard_id_from_ref(
|
||||
dashboard_ref, config_manager, env_id
|
||||
)
|
||||
return _gs.continue_merge(dashboard_id, continue_data.message)
|
||||
return await _gs.continue_merge(dashboard_id, continue_data.message)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
||||
@@ -122,7 +122,7 @@ async def promote_dashboard(
|
||||
reason,
|
||||
)
|
||||
await _apply_git_identity_from_profile(dashboard_id, db, current_user)
|
||||
result = _gs.promote_direct_merge(
|
||||
result = await _gs.promote_direct_merge(
|
||||
dashboard_id=dashboard_id,
|
||||
from_branch=from_branch,
|
||||
to_branch=to_branch,
|
||||
|
||||
@@ -51,7 +51,7 @@ async def commit_changes(
|
||||
dashboard_ref, config_manager, env_id
|
||||
)
|
||||
await _apply_git_identity_from_profile(dashboard_id, db, current_user)
|
||||
_gs.commit_changes(
|
||||
await _gs.commit_changes(
|
||||
dashboard_id, commit_data.message, commit_data.files
|
||||
)
|
||||
return {"status": "success"}
|
||||
@@ -80,7 +80,7 @@ async def push_changes(
|
||||
dashboard_id = await _resolve_dashboard_id_from_ref(
|
||||
dashboard_ref, config_manager, env_id
|
||||
)
|
||||
_gs.push_changes(dashboard_id)
|
||||
await _gs.push_changes(dashboard_id)
|
||||
return {"status": "success"}
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -143,7 +143,7 @@ async def pull_changes(
|
||||
extra={"src": "pull_changes"},
|
||||
)
|
||||
await _apply_git_identity_from_profile(dashboard_id, db, current_user)
|
||||
_gs.pull_changes(dashboard_id)
|
||||
await _gs.pull_changes(dashboard_id)
|
||||
return {"status": "success"}
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -235,7 +235,7 @@ async def get_repository_diff(
|
||||
dashboard_id = await _resolve_dashboard_id_from_ref(
|
||||
dashboard_ref, config_manager, env_id
|
||||
)
|
||||
return _gs.get_diff(dashboard_id, file_path, staged)
|
||||
return await _gs.get_diff(dashboard_id, file_path, staged)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -262,7 +262,7 @@ async def get_history(
|
||||
dashboard_id = await _resolve_dashboard_id_from_ref(
|
||||
dashboard_ref, config_manager, env_id
|
||||
)
|
||||
return _gs.get_commit_history(dashboard_id, limit)
|
||||
return await _gs.get_commit_history(dashboard_id, limit)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -290,14 +290,14 @@ async def generate_commit_message(
|
||||
dashboard_id = await _resolve_dashboard_id_from_ref(
|
||||
dashboard_ref, config_manager, env_id
|
||||
)
|
||||
diff = _gs.get_diff(dashboard_id, staged=True)
|
||||
diff = await _gs.get_diff(dashboard_id, staged=True)
|
||||
if not diff:
|
||||
diff = _gs.get_diff(dashboard_id, staged=False)
|
||||
diff = await _gs.get_diff(dashboard_id, staged=False)
|
||||
|
||||
if not diff:
|
||||
return {"message": "No changes detected"}
|
||||
|
||||
history_objs = _gs.get_commit_history(dashboard_id, limit=5)
|
||||
history_objs = await _gs.get_commit_history(dashboard_id, limit=5)
|
||||
history = [h.message for h in history_objs if hasattr(h, "message")]
|
||||
|
||||
from src.plugins.llm_analysis.models import LLMProviderType
|
||||
|
||||
@@ -193,7 +193,7 @@ async def get_branches(
|
||||
dashboard_id = await _resolve_dashboard_id_from_ref(
|
||||
dashboard_ref, config_manager, env_id
|
||||
)
|
||||
return _gs.list_branches(dashboard_id)
|
||||
return await _gs.list_branches(dashboard_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -223,7 +223,7 @@ async def create_branch(
|
||||
dashboard_ref, config_manager, env_id
|
||||
)
|
||||
await _apply_git_identity_from_profile(dashboard_id, db, current_user)
|
||||
_gs.create_branch(
|
||||
await _gs.create_branch(
|
||||
dashboard_id, branch_data.name, branch_data.from_branch
|
||||
)
|
||||
return {"status": "success"}
|
||||
@@ -253,7 +253,7 @@ async def checkout_branch(
|
||||
dashboard_id = await _resolve_dashboard_id_from_ref(
|
||||
dashboard_ref, config_manager, env_id
|
||||
)
|
||||
_gs.checkout_branch(dashboard_id, checkout_data.name)
|
||||
await _gs.checkout_branch(dashboard_id, checkout_data.name)
|
||||
return {"status": "success"}
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
@@ -221,7 +221,10 @@ async def get_environments(
|
||||
logger.reason("Fetching environments", extra={"src": "get_environments"})
|
||||
environments = config_manager.get_environments()
|
||||
return [
|
||||
env.copy(update={"url": _normalize_superset_env_url(env.url)})
|
||||
env.copy(update={
|
||||
"url": _normalize_superset_env_url(env.url),
|
||||
"password": "********",
|
||||
})
|
||||
for env in environments
|
||||
]
|
||||
|
||||
@@ -257,7 +260,8 @@ async def add_environment(
|
||||
)
|
||||
|
||||
config_manager.add_environment(env)
|
||||
return env
|
||||
masked = env.copy(update={"password": "********"})
|
||||
return masked
|
||||
|
||||
|
||||
# #endregion add_environment
|
||||
@@ -807,6 +811,9 @@ async def create_connection(
|
||||
return result
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=str(e))
|
||||
except RuntimeError as e:
|
||||
logger.explore("Connection creation failed (encryption)", error=str(e))
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# #endregion create_connection
|
||||
@@ -828,6 +835,9 @@ async def update_connection(
|
||||
return result
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=str(e))
|
||||
except RuntimeError as e:
|
||||
logger.explore("Connection update failed (encryption)", error=str(e))
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# #endregion update_connection
|
||||
|
||||
@@ -27,7 +27,7 @@ from ..models.config import AppConfigRecord
|
||||
from ..models.mapping import Environment as EnvironmentRecord
|
||||
from .config_models import AppConfig, Environment, GlobalSettings
|
||||
from .database import SessionLocal
|
||||
from .encryption import EncryptionManager
|
||||
from .encryption import EncryptionManager, is_fernet_token
|
||||
from .logger import belief_scope, configure_logger, logger
|
||||
|
||||
|
||||
@@ -85,10 +85,10 @@ class ConfigManager:
|
||||
try:
|
||||
self._encryption = EncryptionManager()
|
||||
except (RuntimeError, Exception) as exc:
|
||||
logger.reason(
|
||||
"EncryptionManager unavailable; passwords stored in plaintext. "
|
||||
"Ensure ENCRYPTION_KEY is set for production.",
|
||||
extra={"error": str(exc)},
|
||||
logger.explore(
|
||||
"CRITICAL: EncryptionManager unavailable — environment passwords "
|
||||
"will not be encrypted at rest. Set ENCRYPTION_KEY.",
|
||||
error=str(exc),
|
||||
)
|
||||
self._encryption = None
|
||||
return self._encryption
|
||||
@@ -99,9 +99,12 @@ class ConfigManager:
|
||||
# @PRE payload["environments"] is a list of dicts with optional "password" keys.
|
||||
# @POST Password fields are encrypted in-place (skips masked/empty/already-encrypted).
|
||||
# @SIDE_EFFECT Reads ENCRYPTION_KEY; logs warnings on failure.
|
||||
# @REJECTED try-decrypt as detection replaced with is_fernet_token() — avoids noisy
|
||||
# "Assumption violated" warnings from decrypt on plaintext values.
|
||||
def _encrypt_env_passwords(self, payload: dict) -> None:
|
||||
encryption = self._get_encryption_manager()
|
||||
if encryption is None:
|
||||
logger.warning("Environment passwords will NOT be encrypted — ENCRYPTION_KEY unavailable")
|
||||
return
|
||||
environments = payload.get("environments", [])
|
||||
if not isinstance(environments, list):
|
||||
@@ -110,12 +113,9 @@ class ConfigManager:
|
||||
pwd = env.get("password", "")
|
||||
if not pwd or pwd == "********":
|
||||
continue
|
||||
# Skip if already encrypted (safe-decrypt check)
|
||||
try:
|
||||
encryption.decrypt(pwd)
|
||||
# Skip if already encrypted (check format, don't try-decrypt)
|
||||
if is_fernet_token(pwd):
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
env["password"] = encryption.encrypt(pwd)
|
||||
except Exception as exc:
|
||||
@@ -129,6 +129,13 @@ class ConfigManager:
|
||||
# @PURPOSE: Decrypt all environment passwords in the AppConfig after loading from DB.
|
||||
# @PRE config.environments is populated from DB payload.
|
||||
# @POST Password fields are decrypted in-place (skips plaintext legacy values).
|
||||
# @SIDE_EFFECT Logs warning for plaintext passwords (will be auto-encrypted on save).
|
||||
# @SIDE_EFFECT Logs error if Fernet token exists but decryption fails (key mismatch).
|
||||
# @RATIONALE Differs from ConnectionService._decrypt_password (which raises RuntimeError
|
||||
# on key mismatch) because this operates on ALL environments at startup.
|
||||
# A crash would prevent the entire app from loading. Instead, the encrypted
|
||||
# value is preserved; subsequent Superset API calls will fail with auth error,
|
||||
# giving the admin a clear signal without taking down the whole service.
|
||||
def _decrypt_env_passwords(self, config: AppConfig) -> None:
|
||||
encryption = self._get_encryption_manager()
|
||||
if encryption is None:
|
||||
@@ -136,10 +143,25 @@ class ConfigManager:
|
||||
for env in config.environments:
|
||||
if not env.password:
|
||||
continue
|
||||
# Plaintext — warn, keep as-is
|
||||
if not is_fernet_token(env.password):
|
||||
logger.reason(
|
||||
"Plaintext environment password detected, will be auto-encrypted on save",
|
||||
extra={"env_id": env.id},
|
||||
)
|
||||
continue
|
||||
# Looks like Fernet — try to decrypt
|
||||
try:
|
||||
env.password = encryption.decrypt(env.password)
|
||||
except Exception:
|
||||
pass # Assume plaintext (legacy data or test fixture)
|
||||
except Exception as exc:
|
||||
logger.explore(
|
||||
"Environment password decryption failed — possible key mismatch. "
|
||||
"The stored Fernet token cannot be decrypted with the current key.",
|
||||
error=str(exc),
|
||||
extra={"env_id": env.id},
|
||||
)
|
||||
# Keep encrypted value — do NOT return plaintext garbage.
|
||||
# Superset API will fail with auth error, signalling the issue.
|
||||
# #endregion _decrypt_env_passwords
|
||||
|
||||
# #region _apply_features_from_env [TYPE Function]
|
||||
|
||||
@@ -41,7 +41,7 @@ from typing import Any
|
||||
|
||||
from .config_manager import ConfigManager
|
||||
from .config_models import DatabaseConnection
|
||||
from .encryption import EncryptionManager
|
||||
from .encryption import EncryptionManager, is_fernet_token
|
||||
from .logger import logger
|
||||
|
||||
MASKED_PASSWORD = "********"
|
||||
@@ -92,35 +92,86 @@ class ConnectionService:
|
||||
if self._encryption is None:
|
||||
try:
|
||||
self._encryption = EncryptionManager()
|
||||
except (RuntimeError, Exception):
|
||||
except (RuntimeError, Exception) as exc:
|
||||
logger.error(
|
||||
"CRITICAL: EncryptionManager unavailable — passwords cannot be "
|
||||
"encrypted/decrypted. Set ENCRYPTION_KEY in environment.",
|
||||
extra={"error": str(exc)},
|
||||
)
|
||||
self._encryption = None
|
||||
return self._encryption
|
||||
|
||||
# #endregion _get_encryption
|
||||
|
||||
# #region _encrypt_password [C:2] [TYPE Function]
|
||||
# @PRE EncryptionManager is available (ENCRYPTION_KEY set).
|
||||
# @POST Returns Fernet-encrypted password string.
|
||||
# @REJECTED Silent plaintext fallback rejected — would mask admin configuration errors.
|
||||
def _encrypt_password(self, password: str) -> str:
|
||||
encryption = self._get_encryption()
|
||||
if encryption is None:
|
||||
return password
|
||||
logger.error(
|
||||
"REJECTED: password encryption skipped — ENCRYPTION_KEY unavailable. "
|
||||
"Password WILL NOT be stored. Set ENCRYPTION_KEY and retry."
|
||||
)
|
||||
raise RuntimeError(
|
||||
"Cannot encrypt password: ENCRYPTION_KEY not available. "
|
||||
"Set ENCRYPTION_KEY in environment and restart."
|
||||
)
|
||||
try:
|
||||
return encryption.encrypt(password)
|
||||
except Exception:
|
||||
return password
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"REJECTED: password encryption failed.",
|
||||
extra={"error": str(exc)},
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"Password encryption failed: {exc}. Check ENCRYPTION_KEY validity."
|
||||
) from exc
|
||||
|
||||
# #endregion _encrypt_password
|
||||
|
||||
# #region _decrypt_password [C:2] [TYPE Function]
|
||||
# @PRE EncryptionManager is available (ENCRYPTION_KEY set).
|
||||
# @POST Returns plaintext password from Fernet token, or plaintext for legacy data.
|
||||
# @SIDE_EFFECT Logs warning for plaintext passwords — auto-encrypt on next save.
|
||||
# @SIDE_EFFECT Raises RuntimeError if Fernet token exists but decryption fails (key mismatch).
|
||||
# @RATIONALE Distinguishes between "plaintext legacy data" and "key mismatch":
|
||||
# - is_fernet_token() → probably key changed → crash with clear message
|
||||
# - not is_fernet_token() → legacy plaintext → use as-is, log warning
|
||||
def _decrypt_password(self, password: str) -> str:
|
||||
encryption = self._get_encryption()
|
||||
if encryption is None:
|
||||
return password
|
||||
raise RuntimeError(
|
||||
"Cannot decrypt password: ENCRYPTION_KEY not available. "
|
||||
"Set ENCRYPTION_KEY in environment and restart."
|
||||
)
|
||||
if password == MASKED_PASSWORD:
|
||||
return password
|
||||
|
||||
# If it doesn't look like Fernet, it's legacy plaintext — warn and return
|
||||
if not is_fernet_token(password):
|
||||
logger.warning(
|
||||
"Plaintext password detected in DatabaseConnection. "
|
||||
"Will be auto-encrypted on next save.",
|
||||
)
|
||||
return password
|
||||
|
||||
# It looks like Fernet — try to decrypt
|
||||
try:
|
||||
return encryption.decrypt(password)
|
||||
except Exception:
|
||||
return password
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"FATAL: Password decryption failed — ENCRYPTION_KEY mismatch "
|
||||
"or data corruption. The stored Fernet token cannot be decrypted "
|
||||
"with the current key.",
|
||||
extra={"error": str(exc)},
|
||||
)
|
||||
raise RuntimeError(
|
||||
"Password decryption failed: the stored value was encrypted with "
|
||||
"a different ENCRYPTION_KEY. Restore the original key or run "
|
||||
"the key rotation tool."
|
||||
) from exc
|
||||
|
||||
# #endregion _decrypt_password
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
# @RATIONALE Extracted from services/llm_provider.py to decouple core encryption from LLM domain
|
||||
# and enable reuse by ConfigManager for Superset password encryption (see [SEC:C-1]).
|
||||
|
||||
import base64
|
||||
import os
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
@@ -119,6 +120,30 @@ class EncryptionManager:
|
||||
_encryption_manager: EncryptionManager | None = None
|
||||
|
||||
|
||||
# #region is_fernet_token [C:2] [TYPE Function] [SEMANTICS encryption,validation]
|
||||
# @BRIEF Check whether a string looks like a valid Fernet-encrypted token.
|
||||
# @PRE value is a string.
|
||||
# @POST Returns True if value is base64-decodable with decoded length >= 32 bytes.
|
||||
# @RATIONALE Differentiates Fernet-encrypted values from plaintext without decrypting.
|
||||
# Used by ConnectionService and ConfigManager to handle plaintext legacy data
|
||||
# vs. data corrupted by key mismatch.
|
||||
# @REJECTED Trying decryption as detection was rejected — it logs noisy "Assumption
|
||||
# violated" warnings for every plaintext value and cannot distinguish between
|
||||
# "not encrypted" and "encrypted with a different key".
|
||||
MIN_FERNET_TOKEN_LENGTH = 80
|
||||
|
||||
|
||||
def is_fernet_token(value: str) -> bool:
|
||||
if not value or len(value) < MIN_FERNET_TOKEN_LENGTH:
|
||||
return False
|
||||
try:
|
||||
decoded = base64.urlsafe_b64decode(value.encode())
|
||||
except Exception:
|
||||
return False
|
||||
return len(decoded) >= 32
|
||||
# #endregion is_fernet_token
|
||||
|
||||
|
||||
# #region get_encryption_manager [C:4] [TYPE Function] [SEMANTICS encryption,singleton,cache]
|
||||
# @BRIEF Return the process-wide EncryptionManager singleton.
|
||||
# @PRE ENCRYPTION_KEY env var is set to a valid Fernet key.
|
||||
|
||||
@@ -249,7 +249,7 @@ class GitPlugin(PluginBase):
|
||||
async def _handle_sync(self, dashboard_id: int, source_env_id: str | None = None, log=None, git_log=None, superset_log=None) -> dict[str, str]:
|
||||
with belief_scope("GitPlugin._handle_sync"):
|
||||
try:
|
||||
repo = await run_blocking(kind='git', fn=self.git_service.get_repo, dashboard_id=dashboard_id)
|
||||
repo = await self.git_service.get_repo(dashboard_id)
|
||||
repo_path = Path(repo.working_dir)
|
||||
git_log.info(f"Target repo path: {repo_path}")
|
||||
|
||||
@@ -314,7 +314,7 @@ class GitPlugin(PluginBase):
|
||||
try:
|
||||
if not env_id:
|
||||
raise ValueError("Target environment ID required for deployment")
|
||||
repo = await run_blocking(kind='git', fn=self.git_service.get_repo, dashboard_id=dashboard_id)
|
||||
repo = await self.git_service.get_repo(dashboard_id)
|
||||
repo_path = Path(repo.working_dir)
|
||||
git_log.info(f"Packing repository {repo_path} for deployment.")
|
||||
|
||||
|
||||
@@ -260,8 +260,7 @@ class DashboardValidationPlugin(PluginBase):
|
||||
|
||||
api_key = llm_service.get_decrypted_api_key(provider_id)
|
||||
llm_log.debug(
|
||||
f"API Key decrypted (first 8 chars): "
|
||||
f"{api_key[:8] if api_key and len(api_key) > 8 else 'EMPTY_OR_NONE'}..."
|
||||
f"API Key decrypted: length={len(api_key) if api_key else 0}"
|
||||
)
|
||||
|
||||
# Check if API key was successfully decrypted
|
||||
@@ -928,7 +927,7 @@ class DocumentationPlugin(PluginBase):
|
||||
llm_log.debug(f" Default Model: {db_provider.default_model}")
|
||||
|
||||
api_key = llm_service.get_decrypted_api_key(provider_id)
|
||||
llm_log.debug(f"API Key decrypted (first 8 chars): {api_key[:8] if api_key and len(api_key) > 8 else 'EMPTY_OR_NONE'}...")
|
||||
llm_log.debug(f"API Key decrypted: length={len(api_key) if api_key else 0}")
|
||||
|
||||
# Check if API key was successfully decrypted
|
||||
if _is_masked_or_invalid_api_key(api_key):
|
||||
|
||||
@@ -875,7 +875,7 @@ class LLMClient:
|
||||
"provider_type": str(provider_type),
|
||||
"base_url": base_url,
|
||||
"default_model": default_model,
|
||||
"api_key_prefix": self.api_key[:8] if self.api_key and len(self.api_key) > 8 else "EMPTY_OR_NONE",
|
||||
"api_key_present": bool(self.api_key),
|
||||
"api_key_length": len(self.api_key) if self.api_key else 0,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -121,13 +121,14 @@ class TestEncryptEnvPasswords:
|
||||
# #endregion test_encrypt_skips_masked_and_empty
|
||||
|
||||
# #region test_encrypt_skips_already_encrypted [C:2] [TYPE Function]
|
||||
# @BRIEF Password that decrypts successfully is already encrypted → skip.
|
||||
# @BRIEF Password that looks like a Fernet token (via is_fernet_token) is skipped.
|
||||
def test_encrypt_skips_already_encrypted(self):
|
||||
mgr = _build_manager(MagicMock())
|
||||
em = MagicMock()
|
||||
em.decrypt.return_value = "plaintext"
|
||||
mgr._encryption = em
|
||||
payload = {"environments": [{"password": "already-encrypted-token"}]}
|
||||
# 96-char base64 string that passes is_fernet_token() (decodes to 72 zero bytes)
|
||||
fernet_like = "A" * 96
|
||||
payload = {"environments": [{"password": fernet_like}]}
|
||||
mgr._encrypt_env_passwords(payload)
|
||||
em.encrypt.assert_not_called()
|
||||
# #endregion test_encrypt_skips_already_encrypted
|
||||
@@ -184,13 +185,15 @@ class TestDecryptEnvPasswords:
|
||||
# #endregion test_decrypt_empty_password
|
||||
|
||||
# #region test_decrypt_success [C:2] [TYPE Function]
|
||||
# @BRIEF Encrypted password is decrypted in-place.
|
||||
# @BRIEF Fernet-format password is decrypted in-place.
|
||||
def test_decrypt_success(self):
|
||||
mgr = _build_manager(MagicMock())
|
||||
em = MagicMock()
|
||||
em.decrypt.return_value = "decrypted"
|
||||
mgr._encryption = em
|
||||
config = _make_config([_make_env(password="encrypted-token")])
|
||||
# 96-char base64 string that passes is_fernet_token() (decodes to 72 zero bytes)
|
||||
fernet_like = "A" * 96
|
||||
config = _make_config([_make_env(password=fernet_like)])
|
||||
mgr._decrypt_env_passwords(config)
|
||||
assert config.environments[0].password == "decrypted"
|
||||
# #endregion test_decrypt_success
|
||||
|
||||
@@ -30,28 +30,28 @@ def svc(mock_cm):
|
||||
|
||||
|
||||
class TestEncryptionEdgeCases:
|
||||
def test_encrypt_encryption_none_fallback(self, svc):
|
||||
"""When _get_encryption returns None, password passes through."""
|
||||
def test_encrypt_encryption_none_raises(self, svc):
|
||||
"""_get_encryption returns None → RuntimeError (no silent plaintext)."""
|
||||
with patch.object(svc, '_get_encryption', return_value=None):
|
||||
result = svc._encrypt_password("my_pass")
|
||||
assert result == "my_pass"
|
||||
with pytest.raises(RuntimeError, match="ENCRYPTION_KEY not available"):
|
||||
svc._encrypt_password("my_pass")
|
||||
|
||||
def test_encrypt_exception_fallback(self, svc):
|
||||
"""Encryption raises exception → password passes through."""
|
||||
def test_encrypt_exception_raises(self, svc):
|
||||
"""Encryption raises exception → RuntimeError (no silent plaintext)."""
|
||||
enc = MagicMock()
|
||||
enc.encrypt.side_effect = RuntimeError("crypto fail")
|
||||
with patch.object(svc, '_get_encryption', return_value=enc):
|
||||
result = svc._encrypt_password("my_pass")
|
||||
assert result == "my_pass"
|
||||
with pytest.raises(RuntimeError, match="encryption failed"):
|
||||
svc._encrypt_password("my_pass")
|
||||
|
||||
def test_decrypt_none_fallback(self, svc):
|
||||
"""_get_encryption returns None → password passes through."""
|
||||
def test_decrypt_none_raises(self, svc):
|
||||
"""_get_encryption returns None → RuntimeError (no silent plaintext)."""
|
||||
with patch.object(svc, '_get_encryption', return_value=None):
|
||||
result = svc._decrypt_password("encrypted")
|
||||
assert result == "encrypted"
|
||||
with pytest.raises(RuntimeError, match="ENCRYPTION_KEY not available"):
|
||||
svc._decrypt_password("encrypted")
|
||||
|
||||
def test_decrypt_exception_fallback(self, svc):
|
||||
"""Decryption raises → password passes through."""
|
||||
"""Non-Fernet value with encryption available → returned as-is (legacy plaintext)."""
|
||||
enc = MagicMock()
|
||||
enc.decrypt.side_effect = RuntimeError("decrypt fail")
|
||||
with patch.object(svc, '_get_encryption', return_value=enc):
|
||||
@@ -66,11 +66,11 @@ class TestEncryptionEdgeCases:
|
||||
assert result == "********"
|
||||
enc.decrypt.assert_not_called()
|
||||
|
||||
def test_decrypt_masked_get_encryption_none(self, svc):
|
||||
"""MASKED_PASSWORD path even when encryption is None."""
|
||||
def test_decrypt_masked_get_encryption_none_raises(self, svc):
|
||||
"""MASKED_PASSWORD with encryption None → RuntimeError (encryption required)."""
|
||||
with patch.object(svc, '_get_encryption', return_value=None):
|
||||
result = svc._decrypt_password("********")
|
||||
assert result == "********"
|
||||
with pytest.raises(RuntimeError, match="ENCRYPTION_KEY not available"):
|
||||
svc._decrypt_password("********")
|
||||
|
||||
|
||||
class TestUnsupportedDialect:
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
import DashboardDataGrid from "./DashboardDataGrid.svelte";
|
||||
import GitManager from "$lib/components/git/GitManager.svelte";
|
||||
import { gitService } from "../../../services/gitService";
|
||||
import { resolveGitStatusToken } from "../../../services/git-utils";
|
||||
import { formatDateTime } from "$lib/utils/dateFormat";
|
||||
import { addToast } from "$lib/toasts.svelte.js";
|
||||
// [/SECTION]
|
||||
|
||||
@@ -48,7 +50,7 @@
|
||||
// ── Column definitions ─────────────────────────────────────────
|
||||
let columns: Column[] = $derived([
|
||||
{ key: "title", label: $t.dashboard?.title || "Title", sortable: true },
|
||||
{ key: "last_modified", label: $t.dashboard?.last_modified || "Last Modified", sortable: true },
|
||||
{ key: "last_modified", label: $t.dashboard?.last_modified || "Last Modified", sortable: true, render: (item: DashboardMetadata) => formatDateTime(item.last_modified) },
|
||||
{ key: "status", label: $t.dashboard?.status || "Status", sortable: true, raw: true, render: (item: DashboardMetadata) => renderStatusBadge(item) },
|
||||
]);
|
||||
|
||||
@@ -94,29 +96,9 @@
|
||||
// #endregion invalidateRepositoryStatuses:Function
|
||||
|
||||
// #region resolveRepositoryStatusToken:Function [TYPE Function]
|
||||
// @BRIEF Delegates to shared resolveGitStatusToken (git-utils) — single source of truth for grid + modal.
|
||||
function resolveRepositoryStatusToken(status: any): string {
|
||||
const syncState = String(status?.sync_state || "").toUpperCase();
|
||||
if (syncState === "DIVERGED") return "diverged";
|
||||
if (syncState === "BEHIND_REMOTE") return "behind_remote";
|
||||
if (syncState === "AHEAD_REMOTE") return "ahead_remote";
|
||||
if (syncState === "CHANGES") return "changes";
|
||||
if (syncState === "SYNCED") return "synced";
|
||||
const syncStatus = String(status?.sync_status || "").toUpperCase();
|
||||
if (syncStatus === "NO_REPO") return "no_repo";
|
||||
if (syncStatus === "ERROR") return "error";
|
||||
if (syncStatus === "DIFF") return "changes";
|
||||
if (syncStatus === "OK") return "synced";
|
||||
const aheadCount = Number(status?.ahead_count || 0);
|
||||
const behindCount = Number(status?.behind_count || 0);
|
||||
if (aheadCount > 0 && behindCount > 0) return "diverged";
|
||||
if (behindCount > 0) return "behind_remote";
|
||||
if (aheadCount > 0) return "ahead_remote";
|
||||
const hasChanges =
|
||||
Boolean(status?.is_dirty) ||
|
||||
(status?.untracked_files?.length || 0) > 0 ||
|
||||
(status?.modified_files?.length || 0) > 0 ||
|
||||
(status?.staged_files?.length || 0) > 0;
|
||||
return hasChanges ? "changes" : "synced";
|
||||
return resolveGitStatusToken(status);
|
||||
}
|
||||
// #endregion resolveRepositoryStatusToken:Function
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { BranchModel } from '$lib/models/BranchModel.svelte.ts';
|
||||
import { t } from '$lib/i18n/index.svelte.js';
|
||||
import { Button, Select, Input } from '$lib/ui';
|
||||
import { Button, Input } from '$lib/ui';
|
||||
|
||||
let {
|
||||
dashboardId,
|
||||
@@ -29,6 +29,10 @@
|
||||
|
||||
const model = new BranchModel({ dashboardId, envId, currentBranch, onChange: onchange });
|
||||
|
||||
// Split branches into local (no origin/ prefix) and remote (origin/ prefix)
|
||||
let localBranches = $derived(model.branches.filter(b => !b.name.startsWith('origin/')));
|
||||
let remoteBranches = $derived(model.branches.filter(b => b.name.startsWith('origin/')));
|
||||
|
||||
// Sync prop changes to model
|
||||
$effect(() => {
|
||||
model.dashboardId = dashboardId;
|
||||
@@ -50,12 +54,27 @@
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-grow">
|
||||
<Select
|
||||
<select
|
||||
bind:value={model.currentBranch}
|
||||
onchange={(e) => model.handleSelect(e)}
|
||||
disabled={model.loading}
|
||||
options={model.branches.map(b => ({ value: b.name, label: b.name }))}
|
||||
/>
|
||||
class="flex h-10 w-full rounded-md border border-border-strong bg-surface-card px-3 py-2 text-sm text-text ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if localBranches.length > 0}
|
||||
<optgroup label={$t.git?.branch_group_local || 'Локальные'}>
|
||||
{#each localBranches as b}
|
||||
<option value={b.name}>{b.name}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
{#if remoteBranches.length > 0}
|
||||
<optgroup label={$t.git?.branch_group_remote || 'Удалённые'}>
|
||||
{#each remoteBranches as b}
|
||||
<option value={b.name}>{b.name}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -87,14 +87,22 @@
|
||||
{:else if history.length === 0}
|
||||
<p class="text-text-muted italic text-center py-12">{$t.git.no_commits}</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-primary-ring 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-text-subtle font-mono">{commit.hash.substring(0, 7)}</span>
|
||||
<div class="space-y-0 max-h-96 overflow-y-auto pr-2">
|
||||
{#each history as commit, i}
|
||||
<div class="relative pl-7 pb-4 last:pb-0">
|
||||
<!-- Timeline line -->
|
||||
{#if i < history.length - 1}
|
||||
<div class="absolute left-[7px] top-3 bottom-0 w-0.5 bg-primary-ring/30"></div>
|
||||
{/if}
|
||||
<!-- Node dot -->
|
||||
<div class="absolute left-0 top-1.5 flex h-3.5 w-3.5 items-center justify-center rounded-full border-2 border-primary-ring bg-surface-card">
|
||||
<div class="h-1.5 w-1.5 rounded-full bg-primary"></div>
|
||||
</div>
|
||||
<div class="text-xs text-text-muted mt-1">
|
||||
<div class="flex justify-between items-start">
|
||||
<span class="font-medium text-sm text-text">{commit.message}</span>
|
||||
<span class="text-xs text-text-subtle font-mono ml-2 shrink-0">{commit.hash.substring(0, 7)}</span>
|
||||
</div>
|
||||
<div class="text-xs text-text-muted mt-0.5">
|
||||
{commit.author} • {parseDateUTC(commit.timestamp).toLocaleString(undefined, { timeZone: appTimezone.current })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
// [SECTION: IMPORTS]
|
||||
import { addToast as toast } from "$lib/toasts.svelte.js";
|
||||
import { log } from "$lib/cot-logger";
|
||||
import { t } from "$lib/i18n/index.svelte.js";
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: PROPS]
|
||||
@@ -62,7 +63,7 @@
|
||||
if (unresolved.length > 0) {
|
||||
log("ConflictResolver", "EXPLORE", "Unresolved conflicts remain", { count: unresolved.length, files: unresolved.map(c => c.file_path) }, `${unresolved.length} unresolved conflicts`);
|
||||
toast(
|
||||
`Please resolve all conflicts first. (${unresolved.length} remaining)`,
|
||||
($t.git?.conflict?.unresolved_count || 'Please resolve all conflicts first. ({count} remaining)').replace('{count}', String(unresolved.length)),
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
@@ -85,11 +86,10 @@
|
||||
class="bg-surface-card 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-destructive">
|
||||
Merge Conflicts Detected
|
||||
{$t.git?.conflict?.title || 'Merge Conflicts Detected'}
|
||||
</h2>
|
||||
<p class="text-text-muted mb-4">
|
||||
The following files have conflicts. Please choose how to resolve
|
||||
them.
|
||||
{$t.git?.conflict?.description || '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">
|
||||
@@ -103,7 +103,7 @@
|
||||
<span
|
||||
class="text-xs bg-primary-light text-primary px-2 py-0.5 rounded-full uppercase font-bold"
|
||||
>
|
||||
Resolved: {resolutions[conflict.file_path]}
|
||||
{($t.git?.conflict?.resolved || 'Resolved: {strategy}').replace('{strategy}', resolutions[conflict.file_path])}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -114,7 +114,7 @@
|
||||
<div
|
||||
class="bg-primary-light px-4 py-1 text-[10px] font-bold text-primary uppercase border-b"
|
||||
>
|
||||
Your Changes (Mine)
|
||||
{$t.git?.conflict?.your_changes || 'Your Changes (Mine)'}
|
||||
</div>
|
||||
<div class="p-4 bg-surface-card flex-1 overflow-auto">
|
||||
<pre
|
||||
@@ -129,14 +129,14 @@
|
||||
onclick={() =>
|
||||
resolve(conflict.file_path, "mine")}
|
||||
>
|
||||
Keep Mine
|
||||
{$t.git?.conflict?.keep_mine || 'Keep Mine'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-0 flex flex-col">
|
||||
<div
|
||||
class="bg-success-light px-4 py-1 text-[10px] font-bold text-success uppercase border-b"
|
||||
>
|
||||
Remote Changes (Theirs)
|
||||
{$t.git?.conflict?.remote_changes || 'Remote Changes (Theirs)'}
|
||||
</div>
|
||||
<div class="p-4 bg-surface-card flex-1 overflow-auto">
|
||||
<pre
|
||||
@@ -151,7 +151,7 @@
|
||||
onclick={() =>
|
||||
resolve(conflict.file_path, "theirs")}
|
||||
>
|
||||
Keep Theirs
|
||||
{$t.git?.conflict?.keep_theirs || 'Keep Theirs'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -164,13 +164,13 @@
|
||||
onclick={() => (show = false)}
|
||||
class="px-4 py-2 text-text-muted hover:bg-surface-muted rounded transition-colors"
|
||||
>
|
||||
Cancel
|
||||
{$t.git?.conflict?.cancel || 'Cancel'}
|
||||
</button>
|
||||
<button
|
||||
onclick={handleSave}
|
||||
class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover transition-colors shadow-sm"
|
||||
>
|
||||
Resolve & Continue
|
||||
{$t.git?.conflict?.resolve_continue || 'Resolve & Continue'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
66
frontend/src/lib/components/git/GitHelpPanel.svelte
Normal file
66
frontend/src/lib/components/git/GitHelpPanel.svelte
Normal file
@@ -0,0 +1,66 @@
|
||||
<!-- #region Git.HelpPanel [C:3] [TYPE Component] [SEMANTICS git,help,onboarding,instructions] -->
|
||||
<!-- @ingroup Git -->
|
||||
<!-- @BRIEF Collapsible "How it works" guide for the /git page — 5-step pipeline with icons, expand/collapse. -->
|
||||
<!-- @LAYER UI -->
|
||||
<!-- @UX_STATE Collapsed -> Only summary header visible, chevron points right. -->
|
||||
<!-- @UX_STATE Expanded -> All 5 steps visible with icons and descriptions, chevron points down. -->
|
||||
<!-- @UX_REACTIVITY LocalState -> $state(open). -->
|
||||
<!-- @RELATION USED_BY -> [frontend/src/routes/git/+page.svelte] -->
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n/index.svelte.js';
|
||||
|
||||
let open = $state(false);
|
||||
|
||||
const steps = $derived([
|
||||
{ icon: '⚙️', title: $t.git?.help_step1_title, desc: $t.git?.help_step1_desc },
|
||||
{ icon: '📋', title: $t.git?.help_step2_title, desc: $t.git?.help_step2_desc },
|
||||
{ icon: '🔧', title: $t.git?.help_step3_title, desc: $t.git?.help_step3_desc },
|
||||
{ icon: '📝', title: $t.git?.help_step4_title, desc: $t.git?.help_step4_desc },
|
||||
{ icon: '🚀', title: $t.git?.help_step5_title, desc: $t.git?.help_step5_desc },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div class="rounded-lg border border-border bg-surface-card mb-4">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between px-4 py-3 text-left transition-colors hover:bg-surface-muted"
|
||||
onclick={() => (open = !open)}
|
||||
aria-expanded={open}
|
||||
aria-controls="git-help-content"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<svg class="h-5 w-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.012 2.908M12 17v.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span class="text-sm font-semibold text-text">{$t.git?.help_title || 'How it works'}</span>
|
||||
<span class="text-xs text-text-muted hidden sm:inline">— {$t.git?.help_summary}</span>
|
||||
</span>
|
||||
<svg
|
||||
class="h-5 w-5 text-text-muted transition-transform {open ? 'rotate-180' : ''}"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div id="git-help-content" class="border-t border-border px-4 py-4">
|
||||
<ol class="space-y-3">
|
||||
{#each steps as step, i}
|
||||
<li class="flex items-start gap-3">
|
||||
<span class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary-light text-primary text-sm font-semibold">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span class="text-lg shrink-0">{step.icon}</span>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-text">{step.title}</p>
|
||||
<p class="text-sm text-text-muted">{step.desc}</p>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- #endregion Git.HelpPanel -->
|
||||
@@ -42,24 +42,30 @@
|
||||
bind:value={remoteUrl}
|
||||
placeholder={$t.git?.remote_url_placeholder}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onclick={onCreateRemoteRepo}
|
||||
disabled={creatingRemoteRepo || configs.length === 0 || !selectedConfigId}
|
||||
isLoading={creatingRemoteRepo}
|
||||
class="w-full"
|
||||
>
|
||||
Create repo
|
||||
</Button>
|
||||
<div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onclick={onCreateRemoteRepo}
|
||||
disabled={creatingRemoteRepo || configs.length === 0 || !selectedConfigId}
|
||||
isLoading={creatingRemoteRepo}
|
||||
class="w-full"
|
||||
>
|
||||
{$t.git?.create_repo || 'Create repo'}
|
||||
</Button>
|
||||
<p class="mt-1.5 text-xs text-text-muted">{$t.git?.create_repo_desc}</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onclick={onInit}
|
||||
disabled={loading || configs.length === 0 || creatingRemoteRepo}
|
||||
isLoading={loading}
|
||||
class="w-full"
|
||||
>
|
||||
{$t.git?.init_repo || 'Инициализировать Git-репозиторий'}
|
||||
</Button>
|
||||
<div>
|
||||
<Button
|
||||
onclick={onInit}
|
||||
disabled={loading || configs.length === 0 || creatingRemoteRepo}
|
||||
isLoading={loading}
|
||||
class="w-full"
|
||||
>
|
||||
{$t.git?.init_repo || 'Инициализировать Git-репозиторий'}
|
||||
</Button>
|
||||
<p class="mt-1.5 text-xs text-text-muted">{$t.git?.init_repo_desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -39,6 +39,8 @@
|
||||
|
||||
let { dashboardId, envId = null, dashboardTitle = '', show = $bindable(false) } = $props();
|
||||
|
||||
let deployConfirmInput = $state('');
|
||||
|
||||
const model = new GitManagerModel({ dashboardId, envId, dashboardTitle });
|
||||
|
||||
$effect(() => {
|
||||
@@ -50,6 +52,10 @@
|
||||
});
|
||||
|
||||
function closeModal() { show = false; model.clearGitError(); }
|
||||
|
||||
$effect(() => {
|
||||
if (model.showDeployConfirm) deployConfirmInput = '';
|
||||
});
|
||||
function handleBackdropClick(e) { if (e.target === e.currentTarget) closeModal(); }
|
||||
|
||||
onMount(() => {
|
||||
@@ -71,9 +77,21 @@
|
||||
<p class="text-sm text-text-muted">{dashboardTitle} <span class="text-text-subtle">·</span> slug: {dashboardId}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onclick={closeModal} class="flex h-9 w-9 items-center justify-center rounded-lg text-text-subtle transition-colors hover:bg-surface-muted hover:text-text" aria-label={$t.common?.close || 'Close'}>
|
||||
<Icon name="close" size={20} strokeWidth={2} />
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => model.refreshStatus()}
|
||||
disabled={model.checkingStatus || model.workspaceLoading}
|
||||
class="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium text-text-muted transition-colors hover:bg-surface-muted hover:text-text disabled:opacity-50"
|
||||
aria-label={$t.common?.refresh || 'Refresh'}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 {model.checkingStatus ? 'animate-spin' : ''}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182M16.023 9.348H20M14.985 19.644H20M2.985 14.652V20" /></svg>
|
||||
{$t.common?.refresh || 'Refresh'}
|
||||
</button>
|
||||
<button type="button" onclick={closeModal} class="flex h-9 w-9 items-center justify-center rounded-lg text-text-subtle transition-colors hover:bg-surface-muted hover:text-text" aria-label={$t.common?.close || 'Close'}>
|
||||
<Icon name="close" size={20} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Git Error Banner -->
|
||||
@@ -94,7 +112,7 @@
|
||||
<p class="font-medium">{model.gitError.message || model.gitError}</p>
|
||||
{#if model.gitError.files?.length}
|
||||
<details class="text-xs text-destructive/80">
|
||||
<summary class="cursor-pointer font-medium">Файлы, которые будут перезаписаны ({model.gitError.files.length})</summary>
|
||||
<summary class="cursor-pointer font-medium">{($t.git?.error_files_overwritten || 'Файлы, которые будут перезаписаны ({count})').replace('{count}', String(model.gitError.files.length))}</summary>
|
||||
<ul class="mt-1 list-disc space-y-0.5 pl-5">
|
||||
{#each model.gitError.files as file}
|
||||
<li><code class="rounded bg-destructive-light/50 px-1">{file}</code></li>
|
||||
@@ -104,7 +122,7 @@
|
||||
{/if}
|
||||
{#if model.gitError.next_steps?.length}
|
||||
<div class="text-xs">
|
||||
<span class="font-medium">Рекомендации:</span>
|
||||
<span class="font-medium">{$t.git?.error_recommendations || 'Рекомендации:'}</span>
|
||||
<ol class="mt-1 list-decimal space-y-0.5 pl-5">
|
||||
{#each model.gitError.next_steps as step}
|
||||
<li>{step}</li>
|
||||
@@ -117,7 +135,7 @@
|
||||
type="button"
|
||||
onclick={() => model.clearGitError()}
|
||||
class="flex-shrink-0 rounded p-1 transition-colors {model.gitErrorType === 'warning' ? 'hover:bg-warning-light' : 'hover:bg-destructive-light'}"
|
||||
aria-label="Закрыть"
|
||||
aria-label={$t.common?.close || 'Закрыть'}
|
||||
>
|
||||
<Icon name="close" size={16} strokeWidth={2} />
|
||||
</button>
|
||||
@@ -134,7 +152,30 @@
|
||||
{:else}
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-4">
|
||||
{#if model.hasOriginConfigMismatch}
|
||||
<div class="rounded-lg border border-warning bg-warning-light p-3 text-sm text-warning"><div class="font-semibold">Git server mismatch detected</div><div class="mt-1">Configured: <code>{model.configHost}</code>, origin: <code>{model.originHost}</code>.</div></div>
|
||||
<div class="rounded-lg border border-warning bg-warning-light p-3 text-sm text-warning">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div class="font-semibold">{$t.git?.git_server_mismatch_title || 'Git server mismatch detected'}</div>
|
||||
<div class="mt-1">{($t.git?.git_server_mismatch_desc || 'Configured: {configured}, origin: {origin}.').replace('{configured}', model.configHost).replace('{origin}', model.originHost)}</div>
|
||||
<div class="mt-1.5 text-xs text-warning/80">{$t.git?.git_server_mismatch_hint || 'Push/Pull будут отправлять на настроенный сервер, а не на origin. Обновите конфигурацию в Настройках → Git, если это не intended.'}</div>
|
||||
</div>
|
||||
<a href="/settings/git" class="shrink-0 rounded-md border border-warning bg-surface-card px-3 py-1.5 text-xs font-medium text-warning transition-colors hover:bg-warning-light">{$t.git?.git_server_mismatch_fix || 'Настроить'}</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if model.commitCompleted}
|
||||
<div class="rounded-lg border border-success bg-success-light p-3 text-sm text-success">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<div class="flex-1">
|
||||
<span class="font-medium">{$t.git?.commit_success_banner || '✅ Изменения закоммичены.'}</span>
|
||||
<span class="ml-1">{$t.git?.commit_next_step || 'Следующий шаг: вкладка «Релиз» для продвижения между ветками или «Серверные операции» для Pull/Push/Deploy.'}</span>
|
||||
</div>
|
||||
<button type="button" onclick={() => { model.commitCompleted = false; }} class="shrink-0 rounded p-0.5 text-success hover:bg-success-light" aria-label={$t.common?.close || 'Close'}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-surface-card px-4 py-3 shadow-sm">
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -145,12 +186,12 @@
|
||||
<span class="hidden text-xs text-text-muted sm:inline">·</span>
|
||||
<span class="flex items-center gap-1.5 text-sm text-text-muted">
|
||||
<Icon name="code" size={16} class="text-text-subtle" strokeWidth={2} />
|
||||
<span class="hidden sm:inline">Ветка:</span> <strong class="font-mono text-text">{model.currentBranch}</strong>
|
||||
<span class="hidden sm:inline">{$t.git?.branch_label || 'Ветка:'}</span> <strong class="font-mono text-text">{model.currentBranch}</strong>
|
||||
</span>
|
||||
{#if model.changedFilesCount > 0}
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-warning-light px-2.5 py-0.5 text-xs font-medium text-warning ring-1 ring-inset ring-warning-ring">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15 12H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
{model.changedFilesCount} изменений
|
||||
{($t.git?.changes_count || '{count} изменений').replace('{count}', String(model.changedFilesCount))}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -159,22 +200,22 @@
|
||||
<div class="flex flex-wrap items-center gap-1 border-b border-border pb-0">
|
||||
<button class={`relative -mb-px inline-flex items-center gap-2 rounded-t-lg px-4 py-2.5 text-sm font-medium transition-colors ${model.activeTab === 'workspace' ? 'border border-b-white bg-surface-card text-primary shadow-sm' : 'border border-transparent text-text-muted hover:bg-surface-muted hover:text-text'}`} onclick={() => (model.activeTab = 'workspace')}>
|
||||
<Icon name="edit" size={16} strokeWidth={2} />
|
||||
Фиксация изменений
|
||||
{$t.git?.tab_workspace || 'Фиксация изменений'}
|
||||
<HelpTooltip text={$t.git?.hint_workspace || ''} />
|
||||
</button>
|
||||
<button class={`relative -mb-px inline-flex items-center gap-2 rounded-t-lg px-4 py-2.5 text-sm font-medium transition-colors ${model.activeTab === 'release' ? 'border border-b-white bg-surface-card text-primary shadow-sm' : 'border border-transparent text-text-muted hover:bg-surface-muted hover:text-text'}`} onclick={() => (model.activeTab = 'release')}>
|
||||
<Icon name="lightning" size={16} strokeWidth={2} />
|
||||
Релиз
|
||||
{$t.git?.tab_release || 'Релиз'}
|
||||
<HelpTooltip text={$t.git?.hint_release || ''} />
|
||||
</button>
|
||||
<button class={`relative -mb-px inline-flex items-center gap-2 rounded-t-lg px-4 py-2.5 text-sm font-medium transition-colors ${model.activeTab === 'operations' ? 'border border-b-white bg-surface-card text-primary shadow-sm' : 'border border-transparent text-text-muted hover:bg-surface-muted hover:text-text'}`} onclick={() => (model.activeTab = 'operations')}>
|
||||
<Icon name="settings" size={16} strokeWidth={2} />
|
||||
Серверные операции
|
||||
{$t.git?.tab_operations || 'Серверные операции'}
|
||||
<HelpTooltip text={$t.git?.hint_operations || ''} />
|
||||
</button>
|
||||
</div>
|
||||
{#if model.activeTab === 'workspace'}
|
||||
<GitWorkspacePanel hasWorkspaceChanges={model.hasWorkspaceChanges} changedFilesCount={model.changedFilesCount} workspaceLoading={model.workspaceLoading} workspaceDiff={model.workspaceDiff} committing={model.committing} generatingMessage={model.generatingMessage} bind:commitMessage={model.commitMessage} bind:autoPushAfterCommit={model.autoPushAfterCommit} loading={model.loading} pushProviderLabel={model.pushProviderLabel} onSync={() => model.handleSync()} onGenerateMessage={() => model.handleGenerateMessage()} onCommit={() => model.handleCommit()} />
|
||||
<GitWorkspacePanel {dashboardId} envId={model.resolvedEnvId} commitHistoryKey={model.commitHistoryKey} hasWorkspaceChanges={model.hasWorkspaceChanges} changedFilesCount={model.changedFilesCount} workspaceLoading={model.workspaceLoading} workspaceDiff={model.workspaceDiff} committing={model.committing} generatingMessage={model.generatingMessage} bind:commitMessage={model.commitMessage} bind:autoPushAfterCommit={model.autoPushAfterCommit} loading={model.loading} pushProviderLabel={model.pushProviderLabel} onSync={() => model.handleSync()} onGenerateMessage={() => model.handleGenerateMessage()} onCommit={() => model.handleCommit()} />
|
||||
{:else if model.activeTab === 'release'}
|
||||
<GitReleasePanel currentEnvStage={model.currentEnvStage} bind:promoteFromBranch={model.promoteFromBranch} bind:promoteToBranch={model.promoteToBranch} bind:promoteMode={model.promoteMode} bind:promoteReason={model.promoteReason} preferredDeployTargetStage={model.preferredDeployTargetStage} bind:showAdvancedPromote={model.showAdvancedPromote} promoting={model.promoting} onPromote={() => model.handlePromote()} />
|
||||
{:else}
|
||||
@@ -190,4 +231,25 @@
|
||||
{/if}
|
||||
<ConflictResolver conflicts={model.mergeConflicts} bind:show={model.showConflictResolver} onresolve={(e) => model.handleResolveConflicts(e)} />
|
||||
<DeploymentModal {dashboardId} envId={model.resolvedEnvId} preferredTargetStage={model.preferredDeployTargetStage} bind:show={model.showDeployModal} />
|
||||
|
||||
{#if model.showDeployConfirm}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onclick={() => { model.showDeployConfirm = false; }} onkeydown={(e) => { if (e.key === 'Escape') model.showDeployConfirm = false; }}>
|
||||
<div class="bg-surface-card rounded-xl shadow-xl p-6 max-w-md w-full mx-4 border border-border" role="alertdialog" aria-modal="true" aria-label={$t.git?.deploy || 'Deploy to Environment'} onclick={(e) => e.stopPropagation()}>
|
||||
<h3 class="text-lg font-semibold text-text mb-2">{$t.git?.deploy || 'Deploy to Environment'}</h3>
|
||||
<p class="text-sm text-text-muted mb-4">Подтвердите деплой в PROD. Введите slug дашборда: <strong>{model.deployConfirmSlug}</strong></p>
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input
|
||||
bind:value={deployConfirmInput}
|
||||
class="w-full rounded-lg border border-border-strong p-2.5 text-sm text-text outline-none focus:border-primary-ring focus:ring-2 focus:ring-primary-ring mb-4"
|
||||
placeholder={model.deployConfirmSlug}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' && deployConfirmInput.trim()) model.confirmDeploy(deployConfirmInput); }}
|
||||
autofocus
|
||||
/>
|
||||
<div class="flex justify-end gap-3">
|
||||
<Button variant="secondary" onclick={() => { model.showDeployConfirm = false; deployConfirmInput = ''; }}>{$t.common?.cancel || 'Cancel'}</Button>
|
||||
<Button variant="destructive" onclick={() => model.confirmDeploy(deployConfirmInput)} disabled={!deployConfirmInput.trim()}>{$t.common?.confirm || 'Confirm'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- #endregion GitManager -->
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
disabled={mergeRecoveryLoading || mergeResolveInProgress}
|
||||
isLoading={mergeResolveInProgress}
|
||||
>
|
||||
{$t.git?.unfinished_merge?.open_resolver || 'Open conflict resolver'}
|
||||
{$t.git?.unfinished_merge?.open_resolver}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -113,14 +113,14 @@
|
||||
isLoading={mergeAbortInProgress}
|
||||
class="border border-destructive-ring text-destructive hover:bg-destructive-light"
|
||||
>
|
||||
{$t.git?.unfinished_merge?.abort_merge || 'Abort merge'}
|
||||
{$t.git?.unfinished_merge?.abort_merge}
|
||||
</Button>
|
||||
<Button
|
||||
onclick={onContinueMerge}
|
||||
disabled={mergeContinueInProgress}
|
||||
isLoading={mergeContinueInProgress}
|
||||
>
|
||||
{$t.git?.unfinished_merge?.continue_merge || 'Continue merge'}
|
||||
{$t.git?.unfinished_merge?.continue_merge}
|
||||
</Button>
|
||||
<Button onclick={onClose}>
|
||||
{$t.common?.close || 'Close'}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<!-- @LAYER UI -->
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/ui";
|
||||
import { t } from "$lib/i18n/index.svelte.js";
|
||||
|
||||
let {
|
||||
isPulling,
|
||||
@@ -24,7 +25,7 @@
|
||||
isLoading={isPulling}
|
||||
class="border border-border"
|
||||
>
|
||||
⬇ Pull
|
||||
⬇ {$t.git?.pull || 'Pull'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -33,14 +34,14 @@
|
||||
isLoading={isPushing}
|
||||
class="border border-border"
|
||||
>
|
||||
⬆ Push{#if workspaceStatus?.ahead_count > 0} ({workspaceStatus.ahead_count}){/if}
|
||||
⬆ {$t.git?.push || 'Push'}{#if workspaceStatus?.ahead_count > 0} ({workspaceStatus.ahead_count}){/if}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onclick={onDeploy}
|
||||
class={`w-full ${currentEnvStage === 'PROD' ? 'bg-destructive hover:bg-destructive-hover focus-visible:ring-destructive-ring' : 'bg-success hover:bg-success focus-visible:ring-success-ring'}`}
|
||||
>
|
||||
🚀 Deploy
|
||||
🚀 {$t.git?.deploy || 'Deploy'}
|
||||
</Button>
|
||||
</div>
|
||||
<!-- #endregion GitOperationsPanel -->
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<script lang="ts">
|
||||
import { Button, Input, Select } from "$lib/ui";
|
||||
import { stageBadgeClass } from "../../../services/git-utils.js";
|
||||
import { t } from "$lib/i18n/index.svelte.js";
|
||||
|
||||
let {
|
||||
promoteFromBranch = $bindable(),
|
||||
@@ -24,7 +25,7 @@
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-lg border border-border bg-surface-page p-4">
|
||||
<div class="mb-3 text-sm font-semibold text-text">Текущий статус пайплайна</div>
|
||||
<div class="mb-3 text-sm font-semibold text-text">{$t.git?.release?.pipeline_status || 'Текущий статус пайплайна'}</div>
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span class={`rounded-full border px-3 py-1 font-semibold ${stageBadgeClass('DEV')}`}>DEV (dev)</span>
|
||||
<span class="text-text-subtle">➔</span>
|
||||
@@ -34,7 +35,7 @@
|
||||
</div>
|
||||
{#if preferredDeployTargetStage}
|
||||
<p class="mt-2 text-xs text-text-muted">
|
||||
Следующий шаг по GitFlow: <strong>{promoteFromBranch} ➔ {promoteToBranch}</strong>
|
||||
{$t.git?.release?.next_step || 'Следующий шаг по GitFlow:'} <strong>{promoteFromBranch} ➔ {promoteToBranch}</strong>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -46,48 +47,48 @@
|
||||
class={`w-full ${promoteMode === 'direct' ? 'bg-destructive hover:bg-destructive-hover focus-visible:ring-destructive-ring' : ''}`}
|
||||
>
|
||||
{promoteMode === 'direct'
|
||||
? `Прямой перенос ${promoteFromBranch} ➔ ${promoteToBranch} (unsafe)`
|
||||
: `Создать Merge Request (${promoteFromBranch} ➔ ${promoteToBranch})`}
|
||||
? ($t.git?.release?.promote_direct || 'Прямой перенос {from} ➔ {to} (unsafe)').replace('{from}', promoteFromBranch).replace('{to}', promoteToBranch)
|
||||
: ($t.git?.release?.promote_mr || 'Создать Merge Request ({from} ➔ {to})').replace('{from}', promoteFromBranch).replace('{to}', promoteToBranch)}
|
||||
</Button>
|
||||
|
||||
<button
|
||||
class="text-sm text-text-muted hover:text-text"
|
||||
onclick={() => (showAdvancedPromote = !showAdvancedPromote)}
|
||||
>
|
||||
{showAdvancedPromote ? '▴ Скрыть advanced settings' : '▾ Advanced settings'}
|
||||
{showAdvancedPromote ? ($t.git?.release?.advanced_hide || '▴ Скрыть расширенные настройки') : ($t.git?.release?.advanced_show || '▾ Расширенные настройки')}
|
||||
</button>
|
||||
|
||||
{#if showAdvancedPromote}
|
||||
<div class="space-y-3 rounded-lg border border-border p-3">
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||
<Input
|
||||
label="From branch"
|
||||
label={$t.git?.release?.from_branch || 'From branch'}
|
||||
bind:value={promoteFromBranch}
|
||||
placeholder="dev"
|
||||
/>
|
||||
<Input
|
||||
label="To branch"
|
||||
label={$t.git?.release?.to_branch || 'To branch'}
|
||||
bind:value={promoteToBranch}
|
||||
placeholder="preprod"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
label="Promotion mode"
|
||||
label={$t.git?.release?.promotion_mode || 'Режим переноса'}
|
||||
bind:value={promoteMode}
|
||||
options={[
|
||||
{ value: 'mr', label: 'Create MR/PR (Safe)' },
|
||||
{ value: 'direct', label: 'Direct merge without MR (Unsafe)' },
|
||||
{ value: 'mr', label: $t.git?.release?.mode_mr || 'Create MR/PR (Safe)' },
|
||||
{ value: 'direct', label: $t.git?.release?.mode_direct || 'Direct merge without MR (Unsafe)' },
|
||||
]}
|
||||
/>
|
||||
{#if promoteMode === 'direct'}
|
||||
<div class="rounded-lg border border-destructive-ring bg-destructive-light p-3 text-sm text-destructive">
|
||||
<div class="font-semibold">Внимание: прямой перенос без MR</div>
|
||||
<div class="mt-1">Это обходит процесс аппрува и записывается в audit лог.</div>
|
||||
<div class="font-semibold">{$t.git?.release?.direct_warning_title || 'Внимание: прямой перенос без MR'}</div>
|
||||
<div class="mt-1">{$t.git?.release?.direct_warning_desc || 'Это обходит процесс аппрува и записывается в audit лог.'}</div>
|
||||
</div>
|
||||
<Input
|
||||
label="Причина (обязательно)"
|
||||
label={$t.git?.release?.reason_label || 'Причина (обязательно)'}
|
||||
bind:value={promoteReason}
|
||||
placeholder="Почему bypass MR?"
|
||||
placeholder={$t.git?.release?.reason_placeholder || 'Почему bypass MR?'}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
33
frontend/src/lib/components/git/GitStatusLegend.svelte
Normal file
33
frontend/src/lib/components/git/GitStatusLegend.svelte
Normal file
@@ -0,0 +1,33 @@
|
||||
<!-- #region Git.StatusLegend [C:2] [TYPE Component] [SEMANTICS git,status,legend,help] -->
|
||||
<!-- @ingroup Git -->
|
||||
<!-- @BRIEF Status legend — chips with color + description for each repository status token. -->
|
||||
<!-- @LAYER UI -->
|
||||
<!-- @RELATION USED_BY -> [frontend/src/routes/git/+page.svelte] -->
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n/index.svelte.js';
|
||||
|
||||
const legendItems = [
|
||||
{ token: 'synced', class: 'bg-success-light text-success', label: $t.git?.repo_status?.synced, desc: $t.git?.legend_synced },
|
||||
{ token: 'changes', class: 'bg-warning-light text-warning', label: $t.git?.repo_status?.changes, desc: $t.git?.legend_changes },
|
||||
{ token: 'behind_remote', class: 'bg-primary-light text-primary', label: $t.git?.repo_status?.behind_remote, desc: $t.git?.legend_behind_remote },
|
||||
{ token: 'ahead_remote', class: 'bg-primary-light text-primary', label: $t.git?.repo_status?.ahead_remote, desc: $t.git?.legend_ahead_remote },
|
||||
{ token: 'diverged', class: 'bg-info-light text-info', label: $t.git?.repo_status?.diverged, desc: $t.git?.legend_diverged },
|
||||
{ token: 'no_repo', class: 'bg-surface-muted text-text-muted', label: $t.git?.repo_status?.no_repo, desc: $t.git?.legend_no_repo },
|
||||
{ token: 'error', class: 'bg-destructive-light text-destructive', label: $t.git?.repo_status?.error, desc: $t.git?.legend_error },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="rounded-lg border border-border bg-surface-card p-4">
|
||||
<p class="text-sm font-semibold text-text mb-3">{$t.git?.legend_title || 'Repository statuses'}</p>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{#each legendItems as item}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium shrink-0 {item.class}">
|
||||
{item.label}
|
||||
</span>
|
||||
<span class="text-xs text-text-muted">{item.desc}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<!-- #endregion Git.StatusLegend -->
|
||||
@@ -15,6 +15,7 @@
|
||||
import { Button, Icon } from "$lib/ui";
|
||||
import * as Diff2Html from 'diff2html';
|
||||
import 'diff2html/bundles/css/diff2html.min.css';
|
||||
import CommitHistory from './CommitHistory.svelte';
|
||||
|
||||
let {
|
||||
hasWorkspaceChanges,
|
||||
@@ -27,6 +28,9 @@
|
||||
autoPushAfterCommit = $bindable(),
|
||||
loading,
|
||||
pushProviderLabel,
|
||||
dashboardId = '',
|
||||
envId = null,
|
||||
commitHistoryKey = 0,
|
||||
onSync,
|
||||
onGenerateMessage,
|
||||
onCommit,
|
||||
@@ -111,7 +115,7 @@
|
||||
highlight: true,
|
||||
});
|
||||
} catch {
|
||||
return '<div class="p-4 text-sm text-destructive">Failed to render diff</div>';
|
||||
return `<div class="p-4 text-sm text-destructive">${$t.git?.diff_render_failed || 'Failed to render diff'}</div>`;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -132,14 +136,14 @@
|
||||
class="w-full"
|
||||
>
|
||||
<Icon name="refresh" size={16} class="-ml-1 mr-1.5" strokeWidth={2} />
|
||||
Синхронизировать из Superset
|
||||
{$t.git?.sync || 'Синхронизировать из Superset'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Commit message -->
|
||||
<div class="rounded-lg border border-border bg-surface-card p-4 shadow-sm">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-text">Сообщение коммита</h3>
|
||||
<h3 class="text-sm font-semibold text-text">{$t.git?.commit_message || 'Сообщение коммита'}</h3>
|
||||
<button
|
||||
class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-primary transition-colors hover:bg-primary-light disabled:opacity-50"
|
||||
onclick={onGenerateMessage}
|
||||
@@ -152,7 +156,7 @@
|
||||
<textarea
|
||||
bind:value={commitMessage}
|
||||
class={`h-32 w-full resize-none rounded-lg border border-border p-3 text-sm outline-none transition-colors focus:border-primary-ring focus:ring-2 focus:ring-primary-ring ${generatingMessage ? 'animate-pulse bg-surface-page' : 'bg-surface-card'}`}
|
||||
placeholder="Опишите изменения..."
|
||||
placeholder={$t.git?.describe_changes || 'Опишите изменения...'}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
@@ -160,7 +164,7 @@
|
||||
<div class="rounded-lg border border-border bg-surface-card p-4 shadow-sm">
|
||||
<div class="mb-3 flex items-center gap-2 rounded-lg bg-surface-page px-3 py-2 text-sm text-text-muted">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-text-subtle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15 12H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
Файлов с изменениями: <strong class="text-text">{changedFilesCount}</strong>
|
||||
{$t.git?.files_with_changes || 'Файлов с изменениями:'} <strong class="text-text">{changedFilesCount}</strong>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@@ -170,7 +174,7 @@
|
||||
class="w-full"
|
||||
>
|
||||
<Icon name="check" size={16} class="-ml-1 mr-1.5" strokeWidth={2} />
|
||||
Зафиксировать (Commit)
|
||||
{$t.git?.commit_button || 'Зафиксировать (Commit)'}
|
||||
</Button>
|
||||
|
||||
<label class="mt-3 flex items-center gap-2.5 rounded-md border border-border bg-surface-page px-3 py-2 text-xs text-text-muted transition-colors hover:bg-surface-muted">
|
||||
@@ -185,11 +189,11 @@
|
||||
<div class="flex items-center justify-between border-b border-border bg-surface-page px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15"/></svg>
|
||||
<span class="text-sm font-semibold text-text">Diff (изменения)</span>
|
||||
<span class="text-sm font-semibold text-text">{$t.git?.diff_title || 'Diff (изменения)'}</span>
|
||||
</div>
|
||||
{#if hasWorkspaceChanges}
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-primary-light px-2.5 py-0.5 text-xs font-medium text-primary ring-1 ring-inset ring-primary-ring">
|
||||
{changedFilesCount} файлов
|
||||
{($t.git?.files_count || '{count} файлов').replace('{count}', String(changedFilesCount))}
|
||||
{#if totalChunks > 0}
|
||||
<span class="text-primary">· {renderedChunkCount}/{totalChunks}</span>
|
||||
{/if}
|
||||
@@ -241,19 +245,34 @@
|
||||
{:else if hasWorkspaceChanges}
|
||||
<div class="flex h-full flex-col items-center justify-center gap-3 text-sm text-text-subtle">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 text-text-subtle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15"/></svg>
|
||||
<span>Загрузка diff... (нажмите «Синхронизировать»)</span>
|
||||
<span>{$t.git?.diff_loading_hint || 'Загрузка diff... (нажмите «Синхронизировать»)'}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex h-full flex-col items-center justify-center gap-3 text-sm text-text-subtle">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-text-subtle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z"/></svg>
|
||||
<span>Нет изменений для коммита</span>
|
||||
<span class="text-xs text-text-subtle">Синхронизируйте дашборд, чтобы увидеть изменения</span>
|
||||
<span>{$t.git?.no_changes_to_commit || 'Нет изменений для коммита'}</span>
|
||||
<span class="text-xs text-text-subtle">{$t.git?.sync_to_see_changes || 'Синхронизируйте дашборд, чтобы увидеть изменения'}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Commit History collapsible section -->
|
||||
{#if dashboardId}
|
||||
<details class="mt-4 rounded-lg border border-border bg-surface-card overflow-hidden">
|
||||
<summary class="flex cursor-pointer items-center gap-2 px-4 py-3 text-sm font-medium text-text hover:bg-surface-muted transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
{$t.git?.history || 'Commit History'}
|
||||
</summary>
|
||||
<div class="px-4 pb-4">
|
||||
{#key commitHistoryKey}
|
||||
<CommitHistory {dashboardId} {envId} />
|
||||
{/key}
|
||||
</div>
|
||||
</details>
|
||||
{/if}
|
||||
|
||||
<!-- Custom styles for diff2html -->
|
||||
<style>
|
||||
:global(.diff-view .d2h-wrapper) {
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
{
|
||||
"management": "Git Management",
|
||||
"branch": "Branch",
|
||||
"branch_label": "Branch:",
|
||||
"changes_count": "{count} changes",
|
||||
"tab_workspace": "Commit Changes",
|
||||
"tab_release": "Release",
|
||||
"tab_operations": "Server Operations",
|
||||
"error_files_overwritten": "Files that will be overwritten ({count})",
|
||||
"error_recommendations": "Recommendations:",
|
||||
"git_server_mismatch_title": "Git server mismatch detected",
|
||||
"git_server_mismatch_desc": "Configured: {configured}, origin: {origin}.",
|
||||
"git_server_mismatch_hint": "Push/Pull will target the configured server, not origin. Update the configuration in Settings → Git if this is not intended.",
|
||||
"git_server_mismatch_fix": "Configure",
|
||||
"actions": "Actions",
|
||||
"sync": "Sync from Superset",
|
||||
"commit": "Commit Changes",
|
||||
@@ -39,7 +50,17 @@
|
||||
"commit_success": "Changes committed successfully",
|
||||
"commit_and_push_success": "Changes committed and pushed to remote",
|
||||
"commit_message": "Commit Message",
|
||||
"auto_push_after_commit": "Push after commit to",
|
||||
"commit_button": "Commit",
|
||||
"files_with_changes": "Files with changes:",
|
||||
"diff_title": "Diff (changes)",
|
||||
"files_count": "{count} files",
|
||||
"diff_loading_hint": "Loading diff... (click “Sync”)",
|
||||
"no_changes_to_commit": "No changes to commit",
|
||||
"sync_to_see_changes": "Sync the dashboard to see changes",
|
||||
"diff_render_failed": "Failed to render diff",
|
||||
"commit_success_banner": "✅ Changes committed.",
|
||||
"commit_next_step": "Next step: go to the “Release” tab to promote between branches, or “Operations” for Pull/Push/Deploy.",
|
||||
"auto_push_after_commit": "Automatically push after commit to",
|
||||
"generate_with_ai": "Generate with AI",
|
||||
"describe_changes": "Describe your changes...",
|
||||
"changed_files": "Changed Files",
|
||||
@@ -64,6 +85,8 @@
|
||||
"switched_to": "Switched to {branch}",
|
||||
"created_branch": "Created branch {branch}",
|
||||
"branch_name_placeholder": "branch-name",
|
||||
"branch_group_local": "Local",
|
||||
"branch_group_remote": "Remote",
|
||||
"repo_status": {
|
||||
"loading": "Loading",
|
||||
"no_repo": "No Repo",
|
||||
@@ -81,11 +104,63 @@
|
||||
"files_label": "files: {count}",
|
||||
"sync_and_commit": "Sync & Commit",
|
||||
"show_diff": "Show diff",
|
||||
"init_repo_button": "Initialize Git Repository",
|
||||
"hint_workspace": "Sync dashboard with Git first, write a commit message, then click Commit. Enable auto-push to send to remote after commit.",
|
||||
"hint_release": "Promote changes between branches: choose from → to, provide a reason (in direct mode), then click Promote. Or create a Merge Request.",
|
||||
"hint_operations": "Pull — fetch changes from remote repository. Push — send local commits to remote. Deploy — publish dashboard to target environment.",
|
||||
"hint_branch_selector": "Switch between branches. If there are uncommitted changes, commit or stash them first.",
|
||||
"help_title": "How it works",
|
||||
"help_summary": "A quick guide to the Git dashboard management pipeline",
|
||||
"help_step1_title": "Configure a Git server",
|
||||
"help_step1_desc": "Go to Settings → Git and add a connection to Gitea/GitLab/GitHub.",
|
||||
"help_step2_title": "Select a dashboard",
|
||||
"help_step2_desc": "In the “Git Management” tab, check a dashboard and click “Manage Git”.",
|
||||
"help_step3_title": "Create or initialize a repository",
|
||||
"help_step3_desc": "“Create repo” creates a remote repository on the server. “Initialize” binds an existing remote URL to the dashboard.",
|
||||
"help_step4_title": "Sync and commit",
|
||||
"help_step4_desc": "In the “Commit” tab: Sync pulls the configuration from Superset, then write a message and click Commit.",
|
||||
"help_step5_title": "Promote and deploy",
|
||||
"help_step5_desc": "In the “Release” tab — promote between branches (MR or direct). In the “Operations” tab — Pull, Push, and Deploy to an environment.",
|
||||
"legend_title": "Repository statuses",
|
||||
"legend_synced": "Local branch matches remote — no changes.",
|
||||
"legend_changes": "There are uncommitted changes in the working area.",
|
||||
"legend_behind_remote": "Local branch is behind remote — run Pull.",
|
||||
"legend_ahead_remote": "Local branch is ahead of remote — run Push.",
|
||||
"legend_diverged": "Local and remote branches have diverged — merge or rebase required.",
|
||||
"legend_no_repo": "Dashboard is not linked to a Git repository.",
|
||||
"legend_error": "Failed to fetch status — check connection and settings.",
|
||||
"create_repo": "Create repo",
|
||||
"create_repo_desc": "Create a new remote repository on the Git server and link it to the dashboard.",
|
||||
"init_repo_desc": "Bind an existing remote repository by URL to the dashboard (local init).",
|
||||
"repo_already_exists": "Repository already exists. Enter its URL below and click “Initialize”.",
|
||||
"conflict": {
|
||||
"title": "Merge Conflicts Detected",
|
||||
"description": "The following files have conflicts. Please choose how to resolve them.",
|
||||
"your_changes": "Your Changes (Mine)",
|
||||
"remote_changes": "Remote Changes (Theirs)",
|
||||
"keep_mine": "Keep Mine",
|
||||
"keep_theirs": "Keep Theirs",
|
||||
"resolved": "Resolved: {strategy}",
|
||||
"cancel": "Cancel",
|
||||
"resolve_continue": "Resolve & Continue",
|
||||
"unresolved_count": "Please resolve all conflicts first. ({count} remaining)"
|
||||
},
|
||||
"release": {
|
||||
"pipeline_status": "Current pipeline status",
|
||||
"next_step": "Next GitFlow step:",
|
||||
"promote_direct": "Direct promote {from} ➔ {to} (unsafe)",
|
||||
"promote_mr": "Create Merge Request ({from} ➔ {to})",
|
||||
"advanced_show": "▾ Advanced settings",
|
||||
"advanced_hide": "▴ Hide advanced settings",
|
||||
"from_branch": "From branch",
|
||||
"to_branch": "To branch",
|
||||
"promotion_mode": "Promotion mode",
|
||||
"mode_mr": "Create MR/PR (Safe)",
|
||||
"mode_direct": "Direct merge without MR (Unsafe)",
|
||||
"direct_warning_title": "Warning: direct promote without MR",
|
||||
"direct_warning_desc": "This bypasses the approval process and is recorded in the audit log.",
|
||||
"reason_label": "Reason (required)",
|
||||
"reason_placeholder": "Why bypass MR?"
|
||||
},
|
||||
"diff_loading": "Loading changes...",
|
||||
"diff_show_more": "Show {count} more files",
|
||||
"diff_all_shown": "All changes shown ({count} files)",
|
||||
@@ -99,6 +174,14 @@
|
||||
"copy_commands": "Copy commands",
|
||||
"copy_success": "Commands copied to clipboard",
|
||||
"copy_failed": "Failed to copy commands",
|
||||
"copy_empty": "No commands to copy"
|
||||
"copy_empty": "No commands to copy",
|
||||
"open_resolver": "Open conflict resolver",
|
||||
"abort_merge": "Abort merge",
|
||||
"continue_merge": "Continue merge",
|
||||
"no_conflicts": "No unresolved conflicts were found",
|
||||
"resolve_empty": "No conflict resolutions selected",
|
||||
"resolve_success": "Conflicts were resolved and staged",
|
||||
"abort_success": "Merge was aborted",
|
||||
"continue_success": "Merge commit created successfully"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
{
|
||||
"management": "Управление Git",
|
||||
"branch": "Ветка",
|
||||
"branch_label": "Ветка:",
|
||||
"changes_count": "{count} изменений",
|
||||
"tab_workspace": "Фиксация изменений",
|
||||
"tab_release": "Релиз",
|
||||
"tab_operations": "Серверные операции",
|
||||
"error_files_overwritten": "Файлы, которые будут перезаписаны ({count})",
|
||||
"error_recommendations": "Рекомендации:",
|
||||
"git_server_mismatch_title": "Обнаружено несоответствие Git-сервера",
|
||||
"git_server_mismatch_desc": "Настроено: {configured}, origin: {origin}.",
|
||||
"git_server_mismatch_hint": "Push/Pull будут отправлять на настроенный сервер, а не на origin. Обновите конфигурацию в Настройках → Git, если это не intended.",
|
||||
"git_server_mismatch_fix": "Настроить",
|
||||
"actions": "Действия",
|
||||
"sync": "Синхронизировать из Superset",
|
||||
"commit": "Зафиксировать изменения",
|
||||
@@ -39,7 +50,17 @@
|
||||
"commit_success": "Изменения успешно закоммичены",
|
||||
"commit_and_push_success": "Изменения успешно закоммичены и отправлены в remote",
|
||||
"commit_message": "Сообщение коммита",
|
||||
"auto_push_after_commit": "Сделать push после commit в",
|
||||
"commit_button": "Зафиксировать (Commit)",
|
||||
"files_with_changes": "Файлов с изменениями:",
|
||||
"diff_title": "Diff (изменения)",
|
||||
"files_count": "{count} файлов",
|
||||
"diff_loading_hint": "Загрузка diff... (нажмите «Синхронизировать»)",
|
||||
"no_changes_to_commit": "Нет изменений для коммита",
|
||||
"sync_to_see_changes": "Синхронизируйте дашборд, чтобы увидеть изменения",
|
||||
"diff_render_failed": "Не удалось отобразить diff",
|
||||
"commit_success_banner": "✅ Изменения закоммичены.",
|
||||
"commit_next_step": "Следующий шаг: вкладка «Релиз» для продвижения между ветками или «Серверные операции» для Pull/Push/Deploy.",
|
||||
"auto_push_after_commit": "Автоматически отправить (Push) после фиксации в",
|
||||
"generate_with_ai": "Сгенерировать с AI",
|
||||
"describe_changes": "Опишите ваши изменения...",
|
||||
"changed_files": "Измененные файлы",
|
||||
@@ -64,6 +85,8 @@
|
||||
"switched_to": "Переключено на {branch}",
|
||||
"created_branch": "Создана ветка {branch}",
|
||||
"branch_name_placeholder": "имя-ветки",
|
||||
"branch_group_local": "Локальные",
|
||||
"branch_group_remote": "Удалённые",
|
||||
"repo_status": {
|
||||
"loading": "Загрузка",
|
||||
"no_repo": "Нет репозитория",
|
||||
@@ -81,11 +104,63 @@
|
||||
"files_label": "файлы: {count}",
|
||||
"sync_and_commit": "Синхронизировать и зафиксировать",
|
||||
"show_diff": "Показать diff",
|
||||
"init_repo_button": "Инициализировать Git-репозиторий",
|
||||
"hint_workspace": "Сначала синхронизируй дашборд с Git (Sync), затем напиши сообщение коммита и нажми Commit. Если нужно — включи автоотправку (Push) после коммита.",
|
||||
"hint_release": "Перенос изменений между ветками: выбери откуда → куда, укажи причину (при direct-режиме) и нажми Promote. Либо создай Merge Request.",
|
||||
"hint_operations": "Pull — забрать изменения из удалённого репозитория. Push — отправить локальные коммиты. Deploy — выкатить дашборд в целевое окружение.",
|
||||
"hint_branch_selector": "Переключение между ветками. Если есть незакоммиченные изменения — сначала зафиксируй их или отложи через stash.",
|
||||
"help_title": "Как это работает",
|
||||
"help_summary": "Краткое руководство по пайплайну Git-управления дашбордами",
|
||||
"help_step1_title": "Настройте Git-сервер",
|
||||
"help_step1_desc": "Перейдите в Настройки → Git и добавьте подключение к Gitea/GitLab/GitHub.",
|
||||
"help_step2_title": "Выберите дашборд",
|
||||
"help_step2_desc": "Во вкладке «Управление Git» отметьте дашборд и нажмите «Управление Git».",
|
||||
"help_step3_title": "Создайте или инициализируйте репозиторий",
|
||||
"help_step3_desc": "«Create repo» создаёт удалённый репозиторий на сервере. «Инициализировать» привязывает существующий remote-URL к дашборду.",
|
||||
"help_step4_title": "Синхронизируйте и зафиксируйте",
|
||||
"help_step4_desc": "Во вкладке «Фиксация изменений»: Sync забирает конфигурацию из Superset, затем напишите сообщение и нажмите Commit.",
|
||||
"help_step5_title": "Продвигайте и деплойте",
|
||||
"help_step5_desc": "Во вкладке «Релиз» — перенос между ветками (MR или прямой). Во вкладке «Серверные операции» — Pull, Push и Deploy в окружение.",
|
||||
"legend_title": "Статусы репозиториев",
|
||||
"legend_synced": "Локальная ветка совпадает с remote — изменений нет.",
|
||||
"legend_changes": "Есть незакоммиченные изменения в рабочей области.",
|
||||
"legend_behind_remote": "Локальная ветка отстаёт от remote — выполните Pull.",
|
||||
"legend_ahead_remote": "Локальная ветка опережает remote — выполните Push.",
|
||||
"legend_diverged": "Локальная и remote ветки разошлись — требуется merge или rebase.",
|
||||
"legend_no_repo": "Дашборд не привязан к Git-репозиторию.",
|
||||
"legend_error": "Ошибка получения статуса — проверьте подключение и настройки.",
|
||||
"create_repo": "Создать репозиторий",
|
||||
"create_repo_desc": "Создать новый удалённый репозиторий на Git-сервере и привязать его к дашборду.",
|
||||
"init_repo_desc": "Привязать существующий удалённый репозиторий по URL к дашборду (локальный init).",
|
||||
"repo_already_exists": "Репозиторий уже существует. Введите его URL ниже и нажмите «Инициализировать».",
|
||||
"conflict": {
|
||||
"title": "Обнаружены конфликты слияния",
|
||||
"description": "В следующих файлах есть конфликты. Выберите способ разрешения для каждого.",
|
||||
"your_changes": "Ваши изменения (Mine)",
|
||||
"remote_changes": "Удалённые изменения (Theirs)",
|
||||
"keep_mine": "Оставить мои",
|
||||
"keep_theirs": "Оставить их",
|
||||
"resolved": "Разрешён: {strategy}",
|
||||
"cancel": "Отмена",
|
||||
"resolve_continue": "Разрешить и продолжить",
|
||||
"unresolved_count": "Сначала разрешите все конфликты. (Осталось: {count})"
|
||||
},
|
||||
"release": {
|
||||
"pipeline_status": "Текущий статус пайплайна",
|
||||
"next_step": "Следующий шаг по GitFlow:",
|
||||
"promote_direct": "Прямой перенос {from} ➔ {to} (unsafe)",
|
||||
"promote_mr": "Создать Merge Request ({from} ➔ {to})",
|
||||
"advanced_show": "▾ Расширенные настройки",
|
||||
"advanced_hide": "▴ Скрыть расширенные настройки",
|
||||
"from_branch": "Из ветки",
|
||||
"to_branch": "В ветку",
|
||||
"promotion_mode": "Режим переноса",
|
||||
"mode_mr": "Создать MR/PR (безопасно)",
|
||||
"mode_direct": "Прямой merge без MR (небезопасно)",
|
||||
"direct_warning_title": "Внимание: прямой перенос без MR",
|
||||
"direct_warning_desc": "Это обходит процесс аппрува и записывается в audit лог.",
|
||||
"reason_label": "Причина (обязательно)",
|
||||
"reason_placeholder": "Почему bypass MR?"
|
||||
},
|
||||
"diff_loading": "Загрузка изменений...",
|
||||
"diff_show_more": "Показать ещё ({count} файлов)",
|
||||
"diff_all_shown": "Показаны все изменения ({count} файлов)",
|
||||
@@ -99,6 +174,14 @@
|
||||
"copy_commands": "Скопировать команды",
|
||||
"copy_success": "Команды скопированы в буфер обмена",
|
||||
"copy_failed": "Не удалось скопировать команды",
|
||||
"copy_empty": "Команды для копирования отсутствуют"
|
||||
"copy_empty": "Команды для копирования отсутствуют",
|
||||
"open_resolver": "Открыть разрешение конфликтов",
|
||||
"abort_merge": "Отменить слияние",
|
||||
"continue_merge": "Продолжить слияние",
|
||||
"no_conflicts": "Неразрешённых конфликтов не найдено",
|
||||
"resolve_empty": "Не выбрано разрешение конфликтов",
|
||||
"resolve_success": "Конфликты разрешены и добавлены в индекс",
|
||||
"abort_success": "Слияние отменено",
|
||||
"continue_success": "Merge-коммит успешно создан"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
// @STATE checking — Initial status check in progress.
|
||||
// @STATE error — Persistent error banner visible with message and dismiss action.
|
||||
// @ACTION checkStatus() — Checks if repository is initialized; loads workspace on success.
|
||||
// @ACTION refreshStatus() — Re-checks repository status and reloads workspace (manual refresh).
|
||||
// @ACTION loadWorkspace() — Loads workspace status and diff.
|
||||
// @ACTION handleSync() — Synchronizes dashboard state with Git.
|
||||
// @ACTION handleGenerateMessage() — Generates AI commit message from diff.
|
||||
@@ -218,6 +219,8 @@ export class GitManagerModel {
|
||||
|
||||
// ── Commit ──────────────────────────────────────────────────
|
||||
commitMessage: string = $state('');
|
||||
commitCompleted: boolean = $state(false);
|
||||
commitHistoryKey: number = $state(0);
|
||||
committing: boolean = $state(false);
|
||||
generatingMessage: boolean = $state(false);
|
||||
autoPushAfterCommit: boolean = $state(true);
|
||||
@@ -245,6 +248,8 @@ export class GitManagerModel {
|
||||
|
||||
// ── Deploy ──────────────────────────────────────────────────
|
||||
showDeployModal: boolean = $state(false);
|
||||
showDeployConfirm: boolean = $state(false);
|
||||
deployConfirmSlug: string = $state('');
|
||||
|
||||
// ── Merge Recovery ──────────────────────────────────────────
|
||||
showUnfinishedMergeDialog: boolean = $state(false);
|
||||
@@ -414,6 +419,18 @@ export class GitManagerModel {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual refresh: re-check repository status and reload workspace.
|
||||
* Called from the Refresh button in GitManager header.
|
||||
* @POST initialized re-evaluated, workspace reloaded if initialized.
|
||||
*/
|
||||
async refreshStatus(): Promise<void> {
|
||||
await this.checkStatus();
|
||||
if (this.initialized) {
|
||||
await this.loadWorkspace();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Workspace ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -503,11 +520,13 @@ export class GitManagerModel {
|
||||
await gitService.commit(this.dashboardId, this.commitMessage, [], this.resolvedEnvId);
|
||||
if (this.autoPushAfterCommit) {
|
||||
await gitService.push(this.dashboardId, this.resolvedEnvId);
|
||||
addToast((this._t?.git as Record<string, unknown>)?.commit_and_push_success as string || 'Коммит создан и отправлен в remote', 'success');
|
||||
addToast((this._t?.git as Record<string, unknown>)?.commit_and_push_success as string || 'Коммит создан и отправлен в remote', 'success', 8000);
|
||||
} else {
|
||||
addToast((this._t?.git as Record<string, unknown>)?.commit_success as string || 'Коммит успешно создан', 'success');
|
||||
addToast((this._t?.git as Record<string, unknown>)?.commit_success as string || 'Коммит успешно создан', 'success', 8000);
|
||||
}
|
||||
this.commitMessage = '';
|
||||
this.commitCompleted = true;
|
||||
this.commitHistoryKey++;
|
||||
await this.loadWorkspace();
|
||||
} catch (e: unknown) {
|
||||
this._setGitError(e);
|
||||
@@ -607,20 +626,33 @@ export class GitManagerModel {
|
||||
// ── Deploy Modal ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open deployment target modal with PROD confirmation prompt.
|
||||
* Open deployment target modal — shows PROD confirmation dialog when stage is PROD.
|
||||
* Uses ConfirmDialog atom instead of browser prompt() for consistent UX.
|
||||
* @UX_FEEDBACK PROD confirmation dialog with slug verification.
|
||||
*/
|
||||
openDeployModal(): void {
|
||||
if (this.currentEnvStage === 'PROD') {
|
||||
const expected = String(this.dashboardId);
|
||||
const confirmation = prompt(`Подтвердите деплой в PROD. Введите slug дашборда: ${expected}`);
|
||||
if (String(confirmation || '').trim() !== expected) {
|
||||
addToast('Подтверждение PROD не пройдено. Деплой отменен.', 'error');
|
||||
return;
|
||||
}
|
||||
this.deployConfirmSlug = String(this.dashboardId);
|
||||
this.showDeployConfirm = true;
|
||||
return;
|
||||
}
|
||||
this.showDeployModal = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm PROD deploy after user verified the slug in the confirmation dialog.
|
||||
* @POST If slug matches, opens deploy modal. Otherwise shows error toast.
|
||||
*/
|
||||
confirmDeploy(slug: string): void {
|
||||
if (slug.trim() !== this.deployConfirmSlug) {
|
||||
addToast('Подтверждение PROD не пройдено. Деплой отменен.', 'error');
|
||||
this.showDeployConfirm = false;
|
||||
return;
|
||||
}
|
||||
this.showDeployConfirm = false;
|
||||
this.showDeployModal = true;
|
||||
}
|
||||
|
||||
// ── Remote Repository ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
class="inline-flex items-center justify-center rounded-lg bg-terminal-bg px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-terminal-surface"
|
||||
onclick={() => (showGitManager = true)}
|
||||
>
|
||||
{$t.git?.init_repo_button || $t.git?.init_repo}
|
||||
{$t.git?.init_repo}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
import { SvelteSet } from "svelte/reactivity";
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import RepositoryDashboardGrid from '$lib/components/dashboard/RepositoryDashboardGrid.svelte';
|
||||
import GitHelpPanel from '$lib/components/git/GitHelpPanel.svelte';
|
||||
import GitStatusLegend from '$lib/components/git/GitStatusLegend.svelte';
|
||||
import { addToast as toast } from '$lib/toasts.svelte.js';
|
||||
import { api } from '../../lib/api.js';
|
||||
import { gitService } from '../../services/gitService.js';
|
||||
@@ -232,6 +234,8 @@ import { SvelteSet } from "svelte/reactivity";
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<GitHelpPanel />
|
||||
|
||||
<Card title={activeTab === 'repos' ? ($t.nav?.repositories || "Repositories") : ($t.git?.select_dashboard || "Select Dashboard to Manage")}>
|
||||
{#if fetchingDashboards || fetchingRepos}
|
||||
<p class="text-text-muted">{$t.common?.loading}</p>
|
||||
@@ -250,6 +254,12 @@ import { SvelteSet } from "svelte/reactivity";
|
||||
/>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
{#if !fetchingDashboards && !fetchingRepos && dashboards.length > 0}
|
||||
<div class="mt-4">
|
||||
<GitStatusLegend />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<!-- #endregion GitDashboardPage -->
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// #region GitUtils [C:2] [TYPE Module] [SEMANTICS git, utils, helper, normalize, parse]
|
||||
// @BRIEF Shared utility functions extracted from GitManager.svelte.
|
||||
// #region GitUtils [C:3] [TYPE Module] [SEMANTICS git, utils, helper, normalize, parse, status]
|
||||
// @BRIEF Shared utility functions extracted from GitManager.svelte — status resolution, env/branch defaults, merge context parsing.
|
||||
|
||||
/**
|
||||
* Normalize environment stage with legacy fallback.
|
||||
@@ -16,7 +16,7 @@ export function normalizeEnvStage(env: Record<string, unknown> | null | undefine
|
||||
* Return visual class for environment stage badges.
|
||||
*/
|
||||
export function stageBadgeClass(stage: string): string {
|
||||
if (stage === 'PROD') return 'bg-red-100 text-red-800 border-red-200';
|
||||
if (stage === 'PROD') return 'bg-indigo-100 text-indigo-800 border-indigo-200';
|
||||
if (stage === 'PREPROD') return 'bg-amber-100 text-amber-800 border-amber-200';
|
||||
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
}
|
||||
@@ -167,4 +167,41 @@ export function extractUnfinishedMergeContext(error: Record<string, unknown> | n
|
||||
commands,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Resolve a git status object into a UI status token.
|
||||
* Shared by RepositoryDashboardGrid (batch status) and GitManagerModel (single status)
|
||||
* to guarantee identical token mapping across grid and modal.
|
||||
*
|
||||
* Token set: loading | no_repo | synced | changes | behind_remote | ahead_remote | diverged | error
|
||||
*
|
||||
* @RATIONALE Previously two independent resolvers existed (grid vs model), causing
|
||||
* the grid to show "Синхронизирован" while the modal showed "не привязан" for the
|
||||
* same dashboard. Single source of truth eliminates the drift.
|
||||
*/
|
||||
export function resolveGitStatusToken(status: Record<string, unknown> | null | undefined): string {
|
||||
if (!status) return 'error';
|
||||
const syncState = String(status?.sync_state || '').toUpperCase();
|
||||
if (syncState === 'DIVERGED') return 'diverged';
|
||||
if (syncState === 'BEHIND_REMOTE') return 'behind_remote';
|
||||
if (syncState === 'AHEAD_REMOTE') return 'ahead_remote';
|
||||
if (syncState === 'CHANGES') return 'changes';
|
||||
if (syncState === 'SYNCED') return 'synced';
|
||||
const syncStatus = String(status?.sync_status || '').toUpperCase();
|
||||
if (syncStatus === 'NO_REPO') return 'no_repo';
|
||||
if (syncStatus === 'ERROR') return 'error';
|
||||
if (syncStatus === 'DIFF') return 'changes';
|
||||
if (syncStatus === 'OK') return 'synced';
|
||||
const aheadCount = Number(status?.ahead_count || 0);
|
||||
const behindCount = Number(status?.behind_count || 0);
|
||||
if (aheadCount > 0 && behindCount > 0) return 'diverged';
|
||||
if (behindCount > 0) return 'behind_remote';
|
||||
if (aheadCount > 0) return 'ahead_remote';
|
||||
const hasChanges =
|
||||
Boolean(status?.is_dirty) ||
|
||||
(Array.isArray(status?.untracked_files) && (status.untracked_files as unknown[]).length > 0) ||
|
||||
(Array.isArray(status?.modified_files) && (status.modified_files as unknown[]).length > 0) ||
|
||||
(Array.isArray(status?.staged_files) && (status.staged_files as unknown[]).length > 0);
|
||||
return hasChanges ? 'changes' : 'synced';
|
||||
}
|
||||
|
||||
// #endregion GitUtils
|
||||
|
||||
52
run.sh
52
run.sh
@@ -138,6 +138,58 @@ PY
|
||||
|
||||
check_database
|
||||
|
||||
# Fernet encryption key preflight (generates if missing/invalid)
|
||||
ensure_encryption_key() {
|
||||
local ENV_FILE="backend/.env"
|
||||
local key=""
|
||||
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
mkdir -p "$(dirname "$ENV_FILE")"
|
||||
fi
|
||||
|
||||
# Extract existing key from .env
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
# shellcheck disable=SC2013
|
||||
for line in $(grep "^ENCRYPTION_KEY=" "$ENV_FILE" | head -1); do
|
||||
key="${line#ENCRYPTION_KEY=}"
|
||||
done
|
||||
fi
|
||||
|
||||
# Validate existing key
|
||||
if [ -n "$key" ] && echo "$key" | python3 -c "
|
||||
import base64, sys
|
||||
v = sys.stdin.read().strip()
|
||||
try:
|
||||
d = base64.urlsafe_b64decode(v.encode())
|
||||
if len(d) == 32:
|
||||
sys.exit(0)
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(1)
|
||||
" 2>/dev/null; then
|
||||
echo "Encryption key preflight: existing ENCRYPTION_KEY reused from $ENV_FILE"
|
||||
return
|
||||
fi
|
||||
|
||||
# Generate new key
|
||||
local new_key
|
||||
new_key=$(python3 -c "import base64, os; print(base64.urlsafe_b64encode(os.urandom(32)).decode())")
|
||||
|
||||
if [ -f "$ENV_FILE" ] && grep -q "^ENCRYPTION_KEY=" "$ENV_FILE"; then
|
||||
# Replace invalid key
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
sed -i '' "s/^ENCRYPTION_KEY=.*/ENCRYPTION_KEY=$new_key/" "$ENV_FILE"
|
||||
else
|
||||
sed -i "s/^ENCRYPTION_KEY=.*/ENCRYPTION_KEY=$new_key/" "$ENV_FILE"
|
||||
fi
|
||||
else
|
||||
echo "ENCRYPTION_KEY=$new_key" >> "$ENV_FILE"
|
||||
fi
|
||||
echo "Encryption key preflight: ENCRYPTION_KEY generated and saved to $ENV_FILE"
|
||||
}
|
||||
|
||||
ensure_encryption_key
|
||||
|
||||
# Backend dependency management
|
||||
setup_backend() {
|
||||
if [ "$SKIP_INSTALL" = true ]; then
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-01-18
|
||||
**Last Updated**: 2026-07-01
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
@@ -29,6 +30,16 @@
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## 2026-07-01 Revalidation Notes
|
||||
|
||||
- [ ] Phase 12 branch naming migration verified against live backend/frontend implementation
|
||||
- [ ] New repositories verified to use `prod` as production branch
|
||||
- [ ] Existing `main/master` repositories verified as legacy-compatible
|
||||
- [ ] `/git` ReleasePanel verified to show `DEV (dev) -> PREPROD (preprod) -> PROD (prod)`
|
||||
- [ ] Branch selector verified to group branches by Environment / Feature / Hotfix / Legacy or Advanced
|
||||
- [ ] BI developer UX plan accepted and converted into implementation tasks
|
||||
- [ ] Modal-scoped Git error recovery verified for checkout, pull, push, and unfinished merge failures
|
||||
|
||||
## Notes
|
||||
|
||||
✅ **VALIDATION COMPLETE**: All checklist items pass. The specification is ready for `/speckit.clarify` or `/speckit.plan`
|
||||
⚠️ **VALIDATION UPDATED**: Original specification quality passed on 2026-01-18. A 2026-07-01 audit found implementation drift around `main` vs `prod` and identified BI-oriented UX improvements. The spec is current, but implementation readiness depends on completing Phase 12A and revalidating the unchecked items above.
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
**Feature**: Git Integration for Dashboard Development
|
||||
**Date**: 2026-01-22
|
||||
**Last Updated**: 2026-07-01
|
||||
|
||||
## Contract Alignment Note
|
||||
|
||||
This document describes the target API contract. The current codebase uses decomposed routes under `/api/git/repositories/{dashboard_ref}/...`. Older examples that referenced `main` have been updated to `prod`; `main/master` are legacy-compatible refs only.
|
||||
|
||||
## Base Path
|
||||
`/api/git`
|
||||
@@ -31,9 +36,14 @@ Get current status (changes between Superset export and local git HEAD).
|
||||
- **Response**:
|
||||
```json
|
||||
{
|
||||
"branch": "main",
|
||||
"branch": "prod",
|
||||
"changes": [
|
||||
{ "file_path": "charts/sales.yaml", "change_type": "MODIFIED" }
|
||||
{
|
||||
"file_path": "charts/sales.yaml",
|
||||
"change_type": "MODIFIED",
|
||||
"artifact_type": "CHART",
|
||||
"artifact_name": "Sales"
|
||||
}
|
||||
],
|
||||
"is_clean": false
|
||||
}
|
||||
@@ -47,17 +57,17 @@ Fetch latest dashboard export from Superset and unpack into the git working dire
|
||||
|
||||
#### `GET /branches/{dashboard_uuid}`
|
||||
List all local and remote branches.
|
||||
- **Response**: `List[Branch]`
|
||||
- **Response**: `List[Branch]`, grouped or classifiable by branch type: `ENVIRONMENT`, `FEATURE`, `HOTFIX`, `LEGACY`, `REMOTE_REF`, `OTHER`.
|
||||
|
||||
#### `POST /branches/{dashboard_uuid}`
|
||||
Create a new branch.
|
||||
- **Request**: `{ "name": "feature/new-chart", "source_branch": "main" }`
|
||||
- **Request**: `{ "name": "feature/new-chart", "source_branch": "dev" }`
|
||||
- **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" }`
|
||||
- **Request**: `{ "branch_name": "dev" }`
|
||||
- **Response**: `{ "status": "success", "message": "Switched to dev and updated dashboard" }`
|
||||
|
||||
### 4. Commit & Push
|
||||
|
||||
@@ -78,7 +88,7 @@ Pull changes from remote.
|
||||
|
||||
#### `GET /history/{dashboard_uuid}`
|
||||
Get commit log.
|
||||
- **Query Params**: `limit=20`, `branch=main`
|
||||
- **Query Params**: `limit=20`, `branch=prod`
|
||||
- **Response**: `List[Commit]`
|
||||
|
||||
### 6. Deployment
|
||||
@@ -90,4 +100,14 @@ List configured target environments.
|
||||
#### `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": "..." }`
|
||||
- **Response**: `{ "status": "success", "job_id": "..." }`
|
||||
|
||||
### 7. Promotion
|
||||
|
||||
#### `POST /repositories/{dashboard_ref}/promote`
|
||||
Promote changes between environment branches.
|
||||
- **Request**: `{ "from_branch": "dev", "to_branch": "preprod", "mode": "mr" }`
|
||||
- **Request**: `{ "from_branch": "preprod", "to_branch": "prod", "mode": "mr" }`
|
||||
- **Response**: `{ "mode": "mr", "status": "opened", "url": "..." }`
|
||||
|
||||
Direct promote is allowed only with an explicit reason and should be audited.
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
**Feature**: Git Integration for Dashboard Development
|
||||
**Date**: 2026-01-22
|
||||
**Last Updated**: 2026-07-01
|
||||
|
||||
## Model Alignment Note
|
||||
|
||||
Target branch model is `dev -> preprod -> prod`. References to `main` are legacy examples only. Existing repositories with `main` or `master` must remain readable, but new repositories and UI defaults should use `prod`.
|
||||
|
||||
## Entities
|
||||
|
||||
@@ -36,12 +41,20 @@ Data Transfer Object representing a Git branch.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `name` | String | Branch name (e.g., `main`, `feature/fix-chart`) |
|
||||
| `name` | String | Branch name (e.g., `prod`, `dev`, `feature/fix-chart`) |
|
||||
| `branch_type` | Enum | `ENVIRONMENT`, `FEATURE`, `HOTFIX`, `LEGACY`, `REMOTE_REF`, `OTHER` |
|
||||
| `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 |
|
||||
|
||||
**Branch classification rules**:
|
||||
- `dev`, `preprod`, `prod` -> `ENVIRONMENT`
|
||||
- `feature/*` -> `FEATURE`
|
||||
- `hotfix/*` -> `HOTFIX`
|
||||
- `main`, `master` -> `LEGACY`
|
||||
- `origin/*` and other remote tracking refs -> `REMOTE_REF`
|
||||
|
||||
### 4. Commit (DTO)
|
||||
Data Transfer Object representing a Git commit.
|
||||
|
||||
@@ -62,6 +75,8 @@ Represents a local change (diff) between Superset state and Git state.
|
||||
|-------|------|-------------|
|
||||
| `file_path` | String | Relative path in repo |
|
||||
| `change_type` | Enum | `ADDED`, `MODIFIED`, `DELETED`, `RENAMED` |
|
||||
| `artifact_type` | Enum | `DASHBOARD_METADATA`, `CHART`, `DATASET`, `DATABASE`, `FILTER`, `ASSET`, `OTHER` |
|
||||
| `artifact_name` | String | Best-effort Superset object name extracted from YAML metadata |
|
||||
| `diff_content` | String | Unified diff string |
|
||||
|
||||
## Relationships
|
||||
@@ -73,4 +88,6 @@ Represents a local change (diff) between Superset state and Git state.
|
||||
|
||||
- `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).
|
||||
- `branch.name`: Must follow git branch naming conventions.
|
||||
- New feature/hotfix branch names should match `^[a-zA-Z0-9._\/-]+$`, must not contain `..`, and must not start with `/`.
|
||||
- New repositories should use `prod` as production branch. `main` and `master` are legacy-compatible names, not preferred defaults.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Implementation Plan: Git Integration Plugin for Dashboard Development
|
||||
|
||||
**Branch**: `011-git-integration-dashboard` | **Date**: 2026-01-22 | **Last Updated**: 2026-05-27 | **Spec**: [spec.md](specs/011-git-integration-dashboard/spec.md)
|
||||
**Branch**: `011-git-integration-dashboard` | **Date**: 2026-01-22 | **Last Updated**: 2026-07-01 | **Spec**: [spec.md](specs/011-git-integration-dashboard/spec.md)
|
||||
**Input**: Feature specification from `/specs/011-git-integration-dashboard/spec.md`
|
||||
|
||||
## Summary
|
||||
@@ -38,6 +38,60 @@ hotfix/bug-123 ─┘
|
||||
|
||||
**См. Phase 13 в tasks.md для деталей реализации**
|
||||
|
||||
### Implementation Audit (2026-07-01)
|
||||
|
||||
The target branch model is still `dev -> preprod -> prod`, but implementation inspection found drift from that model:
|
||||
|
||||
- Backend defaults still reference `main` in Git server config, gitflow bootstrap, branch creation, and remote provider repository creation.
|
||||
- Frontend release UI can still show `PROD (main)` and derive a disabled `main -> main` promotion action.
|
||||
- Branch selection exposes `main`, `master`, and `origin/*` refs as normal choices instead of separating environment branches from legacy/advanced refs.
|
||||
|
||||
**Planning implication**: Phase 12 must be treated as "completed in intent, needs remediation/verification in implementation" before Phase 13 feature/hotfix work can be safely extended.
|
||||
|
||||
## Target User Experience
|
||||
|
||||
**Primary user**: BI developer / Superset analyst.
|
||||
|
||||
The product should not feel like a generic Git client. It should feel like a controlled dashboard versioning and publishing workflow:
|
||||
|
||||
1. The user sees where the dashboard is in the `DEV -> PREPROD -> PROD` lifecycle.
|
||||
2. The user sees the next safe action before raw Git commands.
|
||||
3. The user can inspect what changed in BI terms before opening YAML diffs.
|
||||
4. PROD-affecting actions require a confirmation summary.
|
||||
5. Raw Git concepts remain available for advanced users and troubleshooting.
|
||||
|
||||
### UX Roadmap
|
||||
|
||||
#### UX Phase A: Branch Model Remediation
|
||||
1. Replace remaining `main` defaults with `prod` for new repositories.
|
||||
2. Preserve legacy `main/master` compatibility as advanced/legacy refs.
|
||||
3. Update release defaults to `dev -> preprod -> prod`.
|
||||
4. Add regression tests for backend defaults, provider defaults, frontend release defaults, and branch selector defaults.
|
||||
|
||||
#### UX Phase B: BI-Oriented Git Manager
|
||||
1. Add a pipeline header with current environment, current branch, sync state, changed artifact count, and recommended next action.
|
||||
2. Rename primary actions in the UI:
|
||||
- `Commit` -> "Save version" with `Commit` as secondary terminology.
|
||||
- `Push` -> "Send to remote".
|
||||
- `Pull` -> "Get remote updates".
|
||||
- `Deploy` -> "Publish to environment".
|
||||
3. Move raw Git details into advanced sections.
|
||||
|
||||
#### UX Phase C: Safer Repository Setup
|
||||
1. Split setup into "Create new repository" and "Connect existing repository".
|
||||
2. Show pre-action summary: Git server, repo name, remote URL, default branch `prod`, and expected environment branches.
|
||||
3. After successful init, guide to "Sync current Superset dashboard".
|
||||
|
||||
#### UX Phase D: Semantic Change Review
|
||||
1. Categorize changed files as dashboard metadata, charts, datasets, databases, filters, or other.
|
||||
2. Show counts and object names before YAML diff.
|
||||
3. Keep raw diff expandable and searchable.
|
||||
|
||||
#### UX Phase E: Contextual Recovery
|
||||
1. Replace long-lived global Git operation toasts with modal-scoped error banners.
|
||||
2. Provide recovery actions for checkout conflicts, unfinished merges, pull conflicts, and push rejection.
|
||||
3. Keep detailed diagnostics available for developers.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Python 3.9+ (Backend), Node.js 18+ (Frontend)
|
||||
@@ -130,6 +184,30 @@ frontend/
|
||||
2. **Deployment Logic**: Implement deployment via Superset Import API (POST /api/v1/dashboard/import/).
|
||||
* Handle zip packing from Git repo state before import.
|
||||
|
||||
### Phase 5: Branch Model Remediation (2026-07-01 Audit)
|
||||
1. Backend defaults:
|
||||
* Change Git server `default_branch` fallback to `prod`.
|
||||
* Ensure `_ensure_gitflow_branches` creates `prod`, `dev`, and `preprod`.
|
||||
* Ensure `create_branch` defaults to `prod` only when no better source branch is specified.
|
||||
* Ensure GitHub, GitLab, and Gitea repository creation default to `prod`.
|
||||
2. Frontend defaults:
|
||||
* Change Git manager and branch selector initial branch to `prod`.
|
||||
* Change release pipeline label to `PROD (prod)`.
|
||||
* Change PREPROD promotion target from `main` to `prod`.
|
||||
3. Compatibility:
|
||||
* Keep existing `main/master` repositories usable.
|
||||
* Present `main/master` as legacy or advanced refs in normal UI.
|
||||
4. Verification:
|
||||
* Add regression tests for defaults.
|
||||
* Browser-check `/git` release flow on DEV, PREPROD, and PROD environments.
|
||||
|
||||
### Phase 6: BI Developer UX Hardening
|
||||
1. Add dashboard lifecycle state to the Git modal.
|
||||
2. Add semantic change summary above YAML diff.
|
||||
3. Improve repository initialization guidance.
|
||||
4. Add modal-scoped recovery states for Git failures.
|
||||
5. Add PROD publish confirmation summary.
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Feature & Hotfix Branch Support (Phase 13)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# Quickstart: Git Integration for Dashboards
|
||||
|
||||
**Last Updated**: 2026-07-01
|
||||
**Audience**: BI developers and Superset analysts.
|
||||
|
||||
## Prerequisites
|
||||
- A running Superset instance.
|
||||
- A Git repository (GitLab, GitHub, etc.) created for your dashboard.
|
||||
@@ -8,36 +11,75 @@
|
||||
## Setup Guide
|
||||
|
||||
### 1. Configure Git Connection
|
||||
1. Navigate to the **Git Integration** tab in the tools menu.
|
||||
2. Select your Dashboard from the dropdown.
|
||||
1. Navigate to **Git** in the operations menu.
|
||||
2. Open **Settings -> Git** if no Git server is configured.
|
||||
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.
|
||||
4. Test and save the connection.
|
||||
|
||||
### 2. Development Workflow
|
||||
### 2. Connect a Dashboard Repository
|
||||
1. Open **Git -> Управление Git**.
|
||||
2. Select a dashboard and click **Управление Git**.
|
||||
3. If the dashboard has no repository, choose one of two paths:
|
||||
- **Create new repository**: create a remote repository on the selected Git server.
|
||||
- **Connect existing repository**: bind a known remote URL to the dashboard.
|
||||
4. Confirm the setup summary before initialization:
|
||||
- Git server
|
||||
- Repository URL
|
||||
- Default production branch: `prod`
|
||||
- Environment branches: `dev`, `preprod`, `prod`
|
||||
5. After initialization, sync the current Superset dashboard into the repository.
|
||||
|
||||
## Branch Model
|
||||
|
||||
The primary dashboard lifecycle is:
|
||||
|
||||
```text
|
||||
dev -> preprod -> prod
|
||||
```
|
||||
|
||||
- `dev`: active dashboard development.
|
||||
- `preprod`: validation before production.
|
||||
- `prod`: production-ready state.
|
||||
- Existing repositories with `main` or `master` remain supported as legacy refs, but new repositories should use `prod`.
|
||||
|
||||
Planned branch extensions:
|
||||
|
||||
- `feature/*`: created from `dev`, merged back into `dev`.
|
||||
- `hotfix/*`: created from `prod`, merged into `prod` and `dev`.
|
||||
|
||||
## 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`).
|
||||
2. Open the dashboard in **Git Management**.
|
||||
3. Click **Sync from Superset**. This exports the current dashboard and unpacks it into the local Git repository.
|
||||
4. Review the change summary and raw YAML diff.
|
||||
|
||||
#### Committing
|
||||
1. Select the files you want to include.
|
||||
2. Enter a commit message.
|
||||
3. Click **Commit**.
|
||||
#### Saving a Version
|
||||
1. Enter a clear change description or generate one.
|
||||
2. Click **Save version** (`Commit`).
|
||||
3. Optionally send the commit to the remote server (`Push`).
|
||||
|
||||
#### Pushing
|
||||
1. Click **Push** to send your changes to the remote repository.
|
||||
#### Publishing Through Environments
|
||||
1. Work in `dev`.
|
||||
2. Promote `dev -> preprod` through a Merge Request when ready for validation.
|
||||
3. Promote `preprod -> prod` through a Merge Request after approval.
|
||||
4. Deploy to Superset production only from `prod`.
|
||||
|
||||
### 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.
|
||||
### Branching
|
||||
1. For parallel work, create a branch named like `feature/q4-updates` from `dev`.
|
||||
2. Keep environment branches (`dev`, `preprod`, `prod`) protected from accidental deletion.
|
||||
3. Treat `main`, `master`, and `origin/*` as legacy or advanced refs in the UI.
|
||||
|
||||
### 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.
|
||||
### Deploying
|
||||
1. Open **Publish to environment**.
|
||||
2. Select the target environment.
|
||||
3. For PROD, verify the confirmation summary: dashboard name, source branch, target environment, last commit, author, and changed files.
|
||||
4. Confirm deploy. The current repository state will be imported into the target Superset environment.
|
||||
|
||||
## Known Implementation Drift (2026-07-01)
|
||||
|
||||
The target workflow is `dev -> preprod -> prod`. Current implementation still contains some `main` defaults and UI labels. See `tasks.md` Phase 12A before relying on branch naming behavior in production.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Feature**: Git Integration for Dashboard Development
|
||||
**Date**: 2026-01-22
|
||||
**Status**: Finalized
|
||||
**Status**: Finalized, amended by 2026-07-01 implementation audit
|
||||
|
||||
## 1. Unknowns & Clarifications
|
||||
|
||||
@@ -15,6 +15,8 @@ The following clarifications were resolved during the specification phase:
|
||||
| **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. |
|
||||
| **Primary UX audience** | BI developer / Superset analyst. | UI must present a dashboard publishing workflow first and raw Git mechanics second. |
|
||||
| **Production branch naming** | `prod`, not `main`. | New repositories and UI defaults use `prod`; `main/master` remain legacy-compatible only. |
|
||||
|
||||
## 2. Technology Decisions
|
||||
|
||||
@@ -50,6 +52,28 @@ The following clarifications were resolved during the specification phase:
|
||||
- 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).
|
||||
|
||||
### 2.5 Branch Naming and Compatibility
|
||||
**Decision**: `dev -> preprod -> prod`
|
||||
**Rationale**:
|
||||
- Names match business environment stages used by BI teams.
|
||||
- `prod` removes ambiguity between Git default branch naming and target production stage.
|
||||
- The workflow is easier to explain in UI: develop, validate, publish.
|
||||
**Compatibility**:
|
||||
- Existing `main` and `master` repositories remain operable.
|
||||
- New repositories should use `prod` as default production branch.
|
||||
- UI should show `main/master` as legacy or advanced refs, not as the primary production path.
|
||||
|
||||
### 2.6 BI-Oriented Git UX
|
||||
**Decision**: Dashboard lifecycle UI over generic Git client UI.
|
||||
**Rationale**:
|
||||
- BI developers and analysts usually think in terms of dashboard versions, validation, publishing, and rollback.
|
||||
- Raw Git terms are still useful, but should not be the primary decision surface.
|
||||
**Required UX shape**:
|
||||
- Show `DEV -> PREPROD -> PROD` pipeline state.
|
||||
- Show recommended next action.
|
||||
- Provide semantic change summary before YAML diff.
|
||||
- Require explicit confirmation for PROD-affecting actions.
|
||||
|
||||
## 3. Architecture Patterns
|
||||
|
||||
### 3.1 The "Bridge" Workflow
|
||||
@@ -59,6 +83,13 @@ The following clarifications were resolved during the specification phase:
|
||||
4. **Commit**: User selects files and commits.
|
||||
5. **Push**: Pushes to remote.
|
||||
|
||||
### 3.3 BI Publishing Workflow
|
||||
1. **Develop in DEV**: User edits the dashboard in Superset and syncs it into `dev`.
|
||||
2. **Save version**: User commits a named dashboard version.
|
||||
3. **Validate in PREPROD**: User promotes `dev -> preprod` through MR or approved direct merge.
|
||||
4. **Publish to PROD**: User promotes `preprod -> prod`, then deploys from `prod`.
|
||||
5. **Recover**: User inspects history and can rollback to a previous version.
|
||||
|
||||
### 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.
|
||||
@@ -71,4 +102,10 @@ The following clarifications were resolved during the specification phase:
|
||||
- **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.
|
||||
- *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.
|
||||
- **Risk**: Branch naming drift (`main` vs `prod`) confuses users and breaks release defaults.
|
||||
- *Mitigation*: Add remediation tasks, tests, and browser validation for backend defaults and frontend release flow.
|
||||
- **Risk**: BI users are forced to reason about raw Git refs.
|
||||
- *Mitigation*: Group branches by business type, hide/de-emphasize remote tracking refs, and use dashboard lifecycle language in primary actions.
|
||||
- **Risk**: YAML diff is too low-level for BI review.
|
||||
- *Mitigation*: Add semantic artifact summaries for charts, datasets, filters, and dashboard metadata before raw diff.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
**Feature Branch**: `011-git-integration-dashboard`
|
||||
**Created**: 2026-01-18
|
||||
**Status**: In Progress
|
||||
**Last Updated**: 2026-05-27
|
||||
**Last Updated**: 2026-07-01
|
||||
**Input**: User description: "Нужен плагин интеграции git в разработку дашбордов. Требования - возможность настройки целевого git сервера (базово - интеграция с gitlab), хранение и синхронизация веток разработки дашбордов, возможность публикацию в другое целевое окружение после коммита"
|
||||
|
||||
## Completed Changes
|
||||
@@ -32,6 +32,21 @@
|
||||
- Новые репозитории создаются с веткой `prod`
|
||||
- Миграция не требуется (пользователь подтвердил)
|
||||
|
||||
### Implementation Audit (2026-07-01)
|
||||
|
||||
**Факт проверки UI/backend**: live flow `/git` и backend/frontend source не полностью соответствуют описанному выше изменению `main` -> `prod`.
|
||||
|
||||
**Наблюдаемые расхождения**:
|
||||
- Backend still exposes `main` as default in several paths: GitServerConfig default branch, gitflow bootstrap, create branch default, and provider repository creation defaults.
|
||||
- Frontend still exposes `main/master/origin/*` as normal branch choices and shows `PROD (main)` in the release pipeline.
|
||||
- Release action can degrade to `main -> main`, which blocks the intended `dev -> preprod -> prod` promotion model.
|
||||
- Branch selector groups local/remote refs, not business branch types (`Environment`, `Feature`, `Hotfix`).
|
||||
- Checkout errors can persist as global sticky alerts outside the current dashboard Git modal.
|
||||
|
||||
**Документационный статус**: Phase 12 remains the target behavior, but implementation must be revalidated and remediated before considering the branch naming migration complete.
|
||||
|
||||
**Primary target user**: BI developer / Superset analyst. The Git UI should present a safe dashboard publishing workflow first, and raw Git mechanics second.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
<!--
|
||||
@@ -82,6 +97,7 @@ A dashboard developer needs to create, switch between, and manage different deve
|
||||
2. **Given** a user wants to create a new feature branch, **When** they specify a branch name and select source branch, **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
|
||||
4. **Given** a feature branch is complete, **When** a user merges it to dev, **Then** the system performs the merge and optionally deletes the feature branch
|
||||
5. **Given** legacy branches `main` or `master` exist, **When** a BI developer opens branch management, **Then** those branches are shown as legacy/advanced refs and do not replace `prod` in the primary workflow
|
||||
|
||||
---
|
||||
|
||||
@@ -179,6 +195,10 @@ A dashboard developer needs to view the commit history and changes made to dashb
|
||||
- **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
|
||||
- **FR-022**: System MUST use `prod` as the primary production branch in new repositories, release UI labels, promotion defaults, and provider repository creation defaults.
|
||||
- **FR-023**: System MUST keep legacy `main`/`master` repositories operable without presenting them as the preferred production path for new work.
|
||||
- **FR-024**: System MUST present Git operations in BI-friendly dashboard workflow terms, while preserving raw Git details in advanced or secondary UI surfaces.
|
||||
- **FR-025**: System MUST provide semantic change summaries for Superset artifacts before raw YAML diff details when enough metadata is available.
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
@@ -187,6 +207,7 @@ A dashboard developer needs to view the commit history and changes made to dashb
|
||||
- **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
|
||||
- **BranchType**: Classifies branches as `environment`, `feature`, `hotfix`, `legacy`, or `remote_ref` for UI grouping and guardrail behavior.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
@@ -202,6 +223,50 @@ A dashboard developer needs to view the commit history and changes made to dashb
|
||||
- **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
|
||||
- **SC-011**: A BI developer can identify the current dashboard publishing state and the next safe action within 10 seconds of opening Git management.
|
||||
- **SC-012**: New repository initialization creates or targets the `dev`, `preprod`, and `prod` branch model without requiring the user to reason about `main`.
|
||||
- **SC-013**: PROD publish actions display source branch, target environment, last commit, author, and changed artifact count before execution.
|
||||
|
||||
## BI Developer UX Improvement Plan
|
||||
|
||||
**Goal**: Reframe the Git dashboard as a safe Superset publishing workflow for BI developers and analysts.
|
||||
|
||||
### UX Principles
|
||||
- Use dashboard lifecycle language first: "Save version", "Send to PREPROD", "Publish to PROD", "Rollback".
|
||||
- Keep raw Git terms visible but secondary: Commit, Push, Pull, Merge Request, branch refs.
|
||||
- Treat `DEV -> PREPROD -> PROD` as the primary workflow signal.
|
||||
- Make destructive or PROD-affecting actions require explicit context and confirmation.
|
||||
- Show semantic Superset artifact changes before YAML implementation details.
|
||||
|
||||
### Planned UI Changes
|
||||
1. **Pipeline header**
|
||||
- Show `DEV (dev) -> PREPROD (preprod) -> PROD (prod)`.
|
||||
- Show current branch, sync state, changed artifact count, and recommended next action.
|
||||
|
||||
2. **Branch selector**
|
||||
- Group branches by `Environment`, `Feature`, `Hotfix`, and `Legacy/Advanced`.
|
||||
- Hide `origin/*` from the primary list or show it only as remote tracking metadata.
|
||||
- Mark `main/master` as legacy when present.
|
||||
|
||||
3. **Repository initialization**
|
||||
- Split "Create new repository" and "Connect existing repository".
|
||||
- Show summary before action: Git server, repo name, remote URL, default branch `prod`, and branches to create.
|
||||
- After init, guide the user to sync the current Superset dashboard.
|
||||
|
||||
4. **Workspace and diff**
|
||||
- Rename primary action to "Save version" with `Commit` as secondary text.
|
||||
- Rename `LLM` to "Generate description".
|
||||
- Add semantic summary: changed charts, datasets, dashboard metadata, native filters, database refs.
|
||||
- Keep raw YAML diff expandable.
|
||||
|
||||
5. **Release and deploy**
|
||||
- Replace `main -> main` states with explicit "No next promotion from PROD" or rollback/deploy actions.
|
||||
- For PROD deploy/direct merge, show confirmation with dashboard name, source branch, target environment, last commit, author, and changed files.
|
||||
|
||||
6. **Error recovery**
|
||||
- Move checkout/pull/push blocking errors into the Git modal context.
|
||||
- Provide direct recovery actions: save version, inspect files, open conflict resolver, retry, or dismiss.
|
||||
- Avoid persistent global alerts after the modal closes.
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Tasks: Git Integration Plugin
|
||||
|
||||
**Feature**: Git Integration for Dashboard Development
|
||||
**Status**: Completed
|
||||
**Total Tasks**: 43
|
||||
**Status**: Completed with implementation drift identified on 2026-07-01
|
||||
**Total Tasks**: 43 baseline tasks, plus remediation and UX backlog below
|
||||
|
||||
## Phase 1: Setup
|
||||
**Goal**: Initialize project structure and dependencies.
|
||||
@@ -109,7 +109,10 @@
|
||||
|
||||
## Phase 12: Branch Naming Convention Update (2026-05-27)
|
||||
**Goal**: Переименование ветки `main` → `prod` для соответствия environment stages.
|
||||
**Status**: Completed
|
||||
**Status**: Target completed in spec, implementation drift found on 2026-07-01
|
||||
|
||||
### Audit Finding (2026-07-01)
|
||||
Live UI and source inspection show remaining `main` usage in backend defaults and frontend release flow. Treat the checked items below as intended migration scope, not as verified current behavior.
|
||||
|
||||
### Backend Changes
|
||||
- [x] B001 Update `_ensure_gitflow_branches` to create `prod`, `dev`, `preprod` instead of `main`, `dev`, `preprod`
|
||||
@@ -125,6 +128,37 @@
|
||||
### Tests
|
||||
- [x] T001 Update all test fixtures from `main` to `prod` in `gitService.test.js`
|
||||
|
||||
## Phase 12A: Branch Naming Remediation (Required)
|
||||
**Goal**: Bring implementation back in line with the `dev -> preprod -> prod` branch model.
|
||||
**Status**: Planned
|
||||
**Priority**: P0
|
||||
**Prerequisites**: Current baseline Git workflow must remain operational.
|
||||
|
||||
### Backend Remediation
|
||||
- [ ] R001 Change `GitServerConfig.default_branch` fallback from `main` to `prod`
|
||||
- [ ] R002 Change `GitServerConfigBase.default_branch` schema fallback from `main` to `prod`
|
||||
- [ ] R003 Update `_ensure_gitflow_branches` to create/use `prod`, `dev`, `preprod`, with `prod` replacing `main` as production branch
|
||||
- [ ] R004 Update `create_branch` default source branch from `main` to `prod`
|
||||
- [ ] R005 Update GitHub repository creation default branch from `main` to `prod`
|
||||
- [ ] R006 Update GitLab repository creation default branch from `main` to `prod`
|
||||
- [ ] R007 Update Gitea repository creation default branch from `main` to `prod`
|
||||
- [ ] R008 Preserve legacy `main/master` compatibility for existing repositories without promoting those names as defaults
|
||||
- [ ] R009 Add backend regression tests for new repo branch bootstrap and provider default branch payloads
|
||||
|
||||
### Frontend Remediation
|
||||
- [ ] R010 Update `GitManagerModel` initial `currentBranch` from `main` to `prod`
|
||||
- [ ] R011 Update `BranchModel` and `BranchSelector` default branch from `main` to `prod`
|
||||
- [ ] R012 Update `applyGitflowStageDefaults`: `PREPROD` promotes `preprod -> prod`
|
||||
- [ ] R013 Update `GitReleasePanel` pipeline label to `PROD (prod)`
|
||||
- [ ] R014 Prevent `main -> main` release action; show "No next promotion from PROD" or deploy/rollback options
|
||||
- [ ] R015 Add frontend regression tests for DEV, PREPROD, and PROD release defaults
|
||||
- [ ] R016 Browser-validate `/git` branch selector and release flow after fixes
|
||||
|
||||
### Documentation Remediation
|
||||
- [ ] R017 Update API contract examples from `main` to `prod`
|
||||
- [ ] R018 Update quickstart to describe BI-friendly `DEV -> PREPROD -> PROD` workflow
|
||||
- [ ] R019 Re-run this spec audit and mark Phase 12 as verified only after tests/browser checks pass
|
||||
|
||||
## Phase 13: Feature & Hotfix Branch Support (Planned)
|
||||
**Goal**: Расширить модель веток для поддержки параллельной разработки нескольких фич и срочных исправлений.
|
||||
**Status**: Planned
|
||||
@@ -181,4 +215,53 @@
|
||||
- Users can merge feature branches back to dev with conflict resolution
|
||||
- Users can delete feature branches after successful merge
|
||||
- Branch list is grouped by type for easy navigation
|
||||
- Merge conflicts are handled gracefully with clear user guidance
|
||||
- Merge conflicts are handled gracefully with clear user guidance
|
||||
|
||||
## Phase 14: BI Developer UX Improvements (Planned)
|
||||
**Goal**: Make Git management understandable and safe for BI developers and Superset analysts.
|
||||
**Status**: Planned
|
||||
**Priority**: P1 after Phase 12A
|
||||
**Estimated Effort**: 20-30 hours
|
||||
|
||||
### UX Foundation
|
||||
- [ ] U001 Add Git modal pipeline header: `DEV (dev) -> PREPROD (preprod) -> PROD (prod)`
|
||||
- [ ] U002 Show current branch, current Superset environment, sync state, changed artifact count, and recommended next action
|
||||
- [ ] U003 Rename primary UI actions to BI workflow terms while keeping Git terms as secondary labels
|
||||
- [ ] U004 Move raw Git diagnostics into advanced sections
|
||||
|
||||
### Branch UX
|
||||
- [ ] U010 Redesign branch selector groups: Environment, Feature, Hotfix, Legacy/Advanced
|
||||
- [ ] U011 Hide or de-emphasize `origin/*` remote refs in the primary selector
|
||||
- [ ] U012 Mark `main/master` as legacy when present
|
||||
- [ ] U013 Add branch type badges and explanatory tooltips
|
||||
|
||||
### Repository Setup UX
|
||||
- [ ] U020 Split setup into "Create new repository" and "Connect existing repository"
|
||||
- [ ] U021 Show pre-action summary: Git server, repo name, remote URL, default branch `prod`, branches to create
|
||||
- [ ] U022 After initialization, guide the user to sync the current Superset dashboard
|
||||
- [ ] U023 Add empty/error states for no configured Git servers and invalid remote URL
|
||||
|
||||
### Change Review UX
|
||||
- [ ] U030 Add semantic change summary above YAML diff
|
||||
- [ ] U031 Categorize changes into charts, datasets, dashboard metadata, databases, filters, and other files
|
||||
- [ ] U032 Rename `LLM` button to "Generate description"
|
||||
- [ ] U033 Add commit message templates for common BI changes
|
||||
- [ ] U034 Keep raw YAML diff expandable and searchable
|
||||
|
||||
### Release and Deploy Guardrails
|
||||
- [ ] U040 Add PROD action confirmation summary: dashboard name, source branch, target environment, last commit, author, changed files
|
||||
- [ ] U041 Require reason for direct merge and show audit warning
|
||||
- [ ] U042 Replace invalid PROD promotion states with deploy/rollback options
|
||||
- [ ] U043 Add rollback entry points from history and PROD state
|
||||
|
||||
### Error Recovery UX
|
||||
- [ ] U050 Move checkout/pull/push blocking errors from global sticky toasts into modal-scoped banners
|
||||
- [ ] U051 Provide recovery actions: save version, inspect files, open conflict resolver, retry, dismiss
|
||||
- [ ] U052 Keep detailed backend diagnostics available in expandable sections
|
||||
- [ ] U053 Add browser tests for checkout conflict, unfinished merge, push rejected, and no-repo setup states
|
||||
|
||||
### Success Criteria
|
||||
- A BI developer can identify the next safe action within 10 seconds of opening Git management.
|
||||
- New repo setup clearly communicates branch model before making changes.
|
||||
- PROD-affecting actions always show target and source context before execution.
|
||||
- Legacy `main/master` refs do not confuse the primary `prod` workflow.
|
||||
|
||||
Reference in New Issue
Block a user