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:
2026-07-01 20:47:25 +03:00
parent 7613ad37ae
commit 64564da988
44 changed files with 1201 additions and 262 deletions

View File

@@ -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 (первый запуск)
# ======================================================================

View File

@@ -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"})`

View File

@@ -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",

View File

@@ -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:

View File

@@ -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:

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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]

View File

@@ -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

View File

@@ -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.

View File

@@ -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.")

View File

@@ -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):

View File

@@ -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,
},
)

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View 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 -->

View File

@@ -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>

View File

@@ -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 -->

View File

@@ -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'}

View File

@@ -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 -->

View File

@@ -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>

View 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 -->

View File

@@ -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) {

View File

@@ -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"
}
}

View File

@@ -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-коммит успешно создан"
}
}

View File

@@ -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 ────────────────────────────────────────
/**

View File

@@ -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}

View File

@@ -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 -->

View File

@@ -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
View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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)

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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.