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 78d2664e2e
commit 87ac90bb8d
44 changed files with 1201 additions and 262 deletions

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