feat(git): add environment timeline visualization and branch commit APIs

Implement a new Git environment timeline component to visualize dashboard
versions across different deployment stages (Development, Pre-production,
and Production). This includes new backend endpoints and frontend
services to support historical data retrieval and version comparison.

- Add `get_branch_commits` and `get_commit_diff` endpoints to backend
  API and Git service.
- Implement `GitEnvironmentTimeline` Svelte component for visual
  representation of deployment history.
- Update `GitManagerModel` to manage timeline state, including
  environment histories and version selection for comparison.
- Add `getBranchCommits` and `getCommitDiff` methods to `gitService`.
- Improve UX by separating the branch selector from the version map.
- Add comprehensive i18n support for the new visualization features.
This commit is contained in:
2026-07-10 10:55:10 +03:00
parent db07bbb1d5
commit 3404967f76
12 changed files with 947 additions and 12 deletions

View File

@@ -126,7 +126,7 @@
<div class="space-y-3">
<div class="flex items-center gap-3">
<div class="flex-grow">
<div class="min-w-0 flex-grow">
<select
bind:value={model.currentBranch}
onchange={(e) => model.handleSelect(e)}
@@ -183,12 +183,12 @@
size="sm"
onclick={() => model.toggleCreateForm()}
disabled={model.loading}
class="text-primary"
class="shrink-0 whitespace-nowrap text-primary"
>
+ {$t.git.new_branch}
</Button>
{#if badgeTooltipText}
<span class="hidden sm:inline-flex items-center gap-1 text-[10px] text-text-subtle font-mono cursor-help" title={badgeTooltipText}>
<span class="hidden 2xl:inline-flex items-center gap-1 text-[10px] text-text-subtle font-mono cursor-help" title={badgeTooltipText}>
{branchTypeBadges.filter(Boolean).map(b => `${b.label} ${b.count}`).join(' · ')}
</span>
{/if}

View File

@@ -0,0 +1,589 @@
<!-- #region GitEnvironmentTimeline [C:4] [TYPE Component] [SEMANTICS git,viz,dashboard-versions,deployment-stages,bi-audit] -->
<!-- @ingroup Components -->
<!-- @BRIEF BI-focused dashboard version timeline across deployment stages (Development → Pre-production → Production).
Shows which version of the dashboard definition is active in each stage, promotion lag, and allows comparing changes.
Internal data is git commits, but all labels and logic are presented in business terms for analysts.
-->
<!-- @LAYER UI -->
<!-- @RELATION BINDS_TO -> [GitManagerModel] -->
<!-- @UX_STATE Loading -> Spinner visible; graph preserves last resolved state. -->
<!-- @UX_STATE Ready -> Stage lanes with version nodes showing deployment evolution. -->
<!-- @UX_STATE Selected -> Details show business change description, where the version is live, and impact. -->
<!-- @UX_INTERACTION Click node = inspect that dashboard version; Compare = arm second version for diff of dashboard config changes. -->
<!-- @UX_REACTIVITY: Props -> $props(). -->
<!-- @UX_TEST: Ready -> {click: version node, expected: details show change description + live stages + compare action}. -->
<script lang="ts">
import { t } from '$lib/i18n/index.svelte.js';
import { Button, Icon } from '$lib/ui';
import { parseDateUTC } from '$lib/utils/dateFormat.js';
import { appTimezone } from '$lib/stores/timezone.svelte.js';
type Commit = {
hash: string;
message: string;
author?: string;
timestamp?: string | Date;
branch?: string;
files_changed?: string[];
};
type SelectedCommit = Commit & { environment: string };
let {
branches = [] as any[],
environmentHistories = {} as Record<string, Commit[]>,
historiesLoading = false,
dashboardId = '',
envId = null as string | null,
currentBranch = $bindable('prod'),
onBranchChange = (_event: { branch?: string }) => {},
onMerge = (_branchName: string) => {},
onSelectVersion = (_hash: string | null, _secondary = false) => {},
selectedA = null as string | null,
selectedB = null as string | null,
onClearSelection = () => {},
onRequestDiff = () => {},
} = $props();
const ENVIRONMENTS = ['dev', 'preprod', 'prod'] as const;
// BI-friendly stage presentation (internal keys stay git-oriented for data)
const STAGE_LABELS: Record<string, string> = {
dev: 'Development',
preprod: 'Pre-production',
prod: 'Production (live)',
};
function getStageLabel(env: string): string {
// Prefer translated if available
const key = `viz_stage_${env}`;
const translated = ($t.git as any)?.[key];
if (translated) return translated;
return STAGE_LABELS[env] || env.toUpperCase();
}
function getStageShort(env: string): string {
return env === 'prod' ? 'PROD' : env.toUpperCase();
}
const GRAPH_WIDTH = 960;
const GRAPH_HEIGHT = 240;
const ROW_Y: Record<string, number> = { dev: 40, preprod: 120, prod: 200 };
const environmentColors: Record<string, string> = {
dev: 'border-blue-300 bg-blue-50 text-blue-700',
preprod: 'border-amber-300 bg-amber-50 text-amber-800',
prod: 'border-indigo-300 bg-indigo-50 text-indigo-700',
};
const nodeColors: Record<string, { head: string; version: string }> = {
dev: { head: 'border-primary bg-primary text-white', version: 'border-blue-300 text-blue-700 hover:border-primary' },
preprod: { head: 'border-warning bg-warning text-white', version: 'border-amber-300 text-amber-800 hover:border-warning' },
prod: { head: 'border-indigo-500 bg-indigo-500 text-white', version: 'border-indigo-300 text-indigo-700 hover:border-indigo-500' },
};
let featureBranches = $derived(
branches.filter((branch: any) => String(branch.name || '').startsWith('feature/') || String(branch.name || '').startsWith('hotfix/'))
);
let selectedEnvironment = $state<string | null>(null);
let comparisonArmed = $state(false);
let graphVersions = $derived.by(() => {
const versions: Commit[] = [];
for (const environment of ENVIRONMENTS) {
for (const commit of environmentHistories[environment] || []) {
if (!versions.some((version) => version.hash === commit.hash)) versions.push(commit);
}
}
return versions.sort((a, b) => new Date(a.timestamp || 0).getTime() - new Date(b.timestamp || 0).getTime());
});
let selectedCommit = $derived.by((): SelectedCommit | null => {
if (!selectedA) return null;
if (selectedEnvironment) {
const commit = (environmentHistories[selectedEnvironment] || []).find((item) => item.hash === selectedA);
if (commit) return { ...commit, environment: selectedEnvironment };
}
for (const environment of ENVIRONMENTS) {
const commit = (environmentHistories[environment] || []).find((item) => item.hash === selectedA);
if (commit) return { ...commit, environment };
}
return null;
});
let selectedVersionEnvironments = $derived.by(() => selectedA
? ENVIRONMENTS.filter((environment) => (environmentHistories[environment] || []).some((commit) => commit.hash === selectedA))
: []
);
let selectedCommitStatus = $derived.by(() => {
if (!selectedCommit) return null;
const targets = promotedTo(selectedCommit.environment, selectedCommit);
if (targets.length > 0) {
const stages = targets.map(t => getStageLabel(t)).join(', ');
return {
label: ($t.git?.viz_live_downstream || 'This version is already live in: {stages}').replace('{stages}', stages),
className: 'text-success',
};
}
if (isHead(selectedCommit.environment, selectedCommit.hash)) {
return { label: $t.git?.viz_status_current_stage || 'Текущая версия в стадии', className: 'text-primary' };
}
return { label: $t.git?.viz_status_historical || 'Historical version in this stage', className: 'text-text-muted' };
});
let isWindowTruncated = $derived.by(() => {
// Heuristic: if any lane returned a full page of history (now 20), assume truncation for the window note.
return ENVIRONMENTS.some((e) => (environmentHistories[e] || []).length >= 20);
});
// BI summary for quick overview (very important for analysts)
let productionSummary = $derived.by(() => {
const prodHead = environmentHistories.prod?.[0];
const devHead = environmentHistories.dev?.[0];
if (!prodHead) return $t.git?.viz_summary_no_prod || 'No production data yet';
if (!devHead) return $t.git?.viz_summary_no_dev || 'Development data not loaded';
if (prodHead.hash === devHead.hash) {
return $t.git?.viz_summary_prod_current || 'Продакшн на актуальной версии из разработки.';
}
const idx = (environmentHistories.dev || []).findIndex((c) => c.hash === prodHead.hash);
if (idx > 0) {
return ($t.git?.viz_summary_lag || 'Production is behind by {count} dashboard updates from Development.').replace('{count}', String(idx));
}
return $t.git?.viz_summary_old_prod || 'Production is on an older stable version.';
});
// BI lag metric for visual emphasis
let prodLag = $derived.by(() => {
const prodHead = environmentHistories.prod?.[0];
const devHistory = environmentHistories.dev || [];
if (!prodHead || devHistory.length === 0) return 0;
const idx = devHistory.findIndex((c) => c.hash === prodHead.hash);
return idx > 0 ? idx : 0;
});
// BI recommendation derived from the data (actionable for analysts)
let biRecommendation = $derived.by(() => {
if (prodLag === 0 && environmentHistories.prod?.[0]) {
return $t.git?.viz_rec_up_to_date || 'Продакшн актуален. Можно валидировать.';
}
if (prodLag > 0) {
return ($t.git?.viz_rec_lag || 'Production lags by {count} updates. Review changes and promote after validation.').replace('{count}', String(prodLag));
}
if (!environmentHistories.prod?.length) {
return $t.git?.viz_rec_no_prod || 'No production data. Initialize/sync the repo to track live versions.';
}
return $t.git?.viz_rec_default || 'Review the timeline and compare versions before promoting.';
});
function shortHash(hash: string) { return (hash || '').slice(0, 7); }
function formatTime(timestamp: Commit['timestamp']) {
if (!timestamp) return '';
try {
return parseDateUTC(timestamp).toLocaleString(undefined, {
timeZone: appTimezone.current,
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
});
} catch { return ''; }
}
function isSelected(hash: string) { return selectedA === hash || selectedB === hash; }
function isHead(environment: string, hash: string) { return environmentHistories[environment]?.[0]?.hash === hash; }
function environmentStatus(environment: string) {
const head = environmentHistories[environment]?.[0];
if (!head) return { label: $t.git?.viz_no_versions || 'No data', className: 'text-text-subtle' };
// BI language: focus on "what users / validators see" and lag from development
if (environment === 'dev') {
return { label: $t.git?.viz_status_dev || 'Latest changes (in development)', className: 'text-primary' };
}
const devHistory = environmentHistories.dev || [];
const devHead = devHistory[0];
if (!devHead) {
return { label: $t.git?.viz_status_unknown || 'Status unknown', className: 'text-text-muted' };
}
if (head.hash === devHead.hash) {
return { label: $t.git?.viz_status_up_to_date || 'Up to date with Development', className: 'text-success' };
}
const devIndex = devHistory.findIndex((c) => c.hash === head.hash);
if (devIndex > 0) {
const count = devIndex;
return {
label: ($t.git?.viz_status_lag || '{count} updates behind Development').replace('{count}', String(count)),
className: 'text-warning',
};
}
// Older than loaded window — common and normal for stable prod
return {
label: $t.git?.viz_status_stable_old || 'Stable older version (updates exist in Development)',
className: 'text-text-muted',
};
}
function xFor(hash: string) {
const index = graphVersions.findIndex((version) => version.hash === hash);
const padding = 44;
const availableWidth = GRAPH_WIDTH - padding * 2;
if (index < 0 || graphVersions.length < 2) {
return graphVersions.length < 2 ? GRAPH_WIDTH / 2 : padding;
}
return padding + (availableWidth * index) / (graphVersions.length - 1);
}
function leftFor(commit: Commit) { return `${((xFor(commit.hash) / GRAPH_WIDTH) * 100).toFixed(2)}%`; }
function commitTitle(commit: Commit) {
const when = formatTime(commit.timestamp);
const who = commit.author ? ` · ${commit.author}` : '';
return `${commit.message || shortHash(commit.hash)}\n${when}${who}\n(technical: ${shortHash(commit.hash)})`;
}
function selectCommit(commit: Commit, environment: string, event: MouseEvent) {
if (comparisonArmed && selectedA && commit.hash !== selectedA) {
onSelectVersion(commit.hash, true);
comparisonArmed = false;
return;
}
if (!event.shiftKey) selectedEnvironment = environment;
onSelectVersion(commit.hash, event.shiftKey);
}
function clearSelection() {
selectedEnvironment = null;
comparisonArmed = false;
onClearSelection();
}
function armComparison() {
comparisonArmed = true;
selectedEnvironment = null;
}
function linePoints(environment: string) {
let points = (environmentHistories[environment] || []);
// For preprod/prod, only connect the key promotion points (not full dev history) to avoid visual duplication
if (environment !== 'dev') {
points = points.filter(c => {
const h = isHead(environment, c.hash);
const isProm = promotedTo('dev', c).includes(environment) || promotedTo('preprod', c).includes(environment);
return h || isProm;
});
}
return points
.slice()
.sort((a, b) => xFor(a.hash) - xFor(b.hash))
.map((commit) => `${xFor(commit.hash)},${ROW_Y[environment]}`)
.join(' ');
}
function promotedTo(environment: string, commit: Commit) {
const currentIndex = ENVIRONMENTS.indexOf(environment as typeof ENVIRONMENTS[number]);
return ENVIRONMENTS.slice(currentIndex + 1).filter((target) =>
(environmentHistories[target] || []).some((item) => item.hash === commit.hash)
);
}
// BI distance between two selected versions (number of updates apart in the sequence)
function getVersionDistance(aHash: string | null, bHash: string | null): number {
if (!aHash || !bHash) return 0;
const aIdx = graphVersions.findIndex(v => v.hash === aHash);
const bIdx = graphVersions.findIndex(v => v.hash === bHash);
if (aIdx < 0 || bIdx < 0) return 0;
return Math.abs(aIdx - bIdx);
}
</script>
<div class="rounded-lg border border-border bg-surface-card">
<div class="flex flex-wrap items-start justify-between gap-3 border-b border-border px-4 py-3">
<div>
<div class="text-sm font-semibold text-text">{$t.git?.viz_title || 'Версии дашборда по стадиям'}</div>
<div class="text-[11px] text-text-muted">{$t.git?.viz_hint || 'Какая версия дашборда сейчас активна в Разработке, Предпроде и Продакшне. Видно отставание и можно сравнить, что именно изменилось.'}</div>
<div class="text-[10px] text-text-subtle mt-0.5">{$t.git?.viz_bi_purpose || 'Помогает аналитикам понять состояние развёртывания перед валидацией или промоушеном.'}</div>
{#if graphVersions.length > 0}
<div class="mt-0.5 text-[10px] text-text-subtle">
{graphVersions.length} {$t.git?.viz_unique_versions || 'уникальных версий'} показано
{#if isWindowTruncated} · {$t.git?.viz_recent_window || 'recent window (older may exist)'}{/if}
</div>
{/if}
</div>
<div class="flex items-center gap-2 text-xs">
{#if historiesLoading}
<span class="flex items-center text-text-muted" aria-busy="true" title={$t.common?.loading || 'Loading...'}><Icon name="refresh" size={14} class="animate-spin" /></span>
{/if}
{#if selectedA || selectedB}
<Button variant="ghost" size="sm" onclick={clearSelection}>{$t.git?.viz_clear || 'Clear selection'}</Button>
{/if}
<!-- Most common BI action: one click to diff latest development vs what users see in prod -->
{#if environmentHistories.dev?.[0] && environmentHistories.prod?.[0] && environmentHistories.dev[0].hash !== environmentHistories.prod[0].hash && !selectedA}
<Button variant="secondary" size="sm" onclick={() => {
// One-click BI action: select prod baseline + dev, then load the actual dashboard config diff
onSelectVersion(environmentHistories.prod[0].hash);
queueMicrotask(() => {
onSelectVersion(environmentHistories.dev[0].hash, true);
// Trigger diff load (parent will switch to workspace with the diff)
queueMicrotask(() => onRequestDiff());
});
}}>
{$t.git?.viz_quick_dev_vs_prod || 'Сравнить с текущей в Разработке'}
</Button>
{/if}
</div>
</div>
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 border-b border-border bg-surface-page px-4 py-2 text-[10px] text-text-muted">
<span class="flex items-center gap-1.5"><span class="h-2.5 w-2.5 rounded-full bg-primary"></span>{$t.git?.viz_legend_current || 'Текущая в стадии'}</span>
<span class="flex items-center gap-1.5"><span class="h-3 w-0 border-l border-dashed border-primary"></span>{$t.git?.viz_legend_promoted || 'Бейдж PRE = продвинута (линия только для выбранной)'}</span>
{#if comparisonArmed}<span class="font-medium text-primary">{$t.git?.viz_choose_comparison || 'Выберите версию для сравнения'}</span>{/if}
<span class="ml-auto flex items-center gap-1 text-[9px] text-text-subtle cursor-help" title={$t.git?.viz_how_to_read || 'Точки = версии. Бейдж PRE = продвинута (без дублей). Вертикаль только для выбранной.'}>
<Icon name="help" size={12} /> как читать
</span>
</div>
<!-- Compact status chips (explicit lag, no redundant blocks) -->
{#if graphVersions.length > 0}
<div class="px-4 py-1 flex items-center gap-2 text-[10px] bg-surface-page border-b border-border">
<span class="px-1.5 py-0.5 rounded bg-blue-100 text-blue-800">DEV активна</span>
<span class="px-1.5 py-0.5 rounded bg-amber-100 text-amber-800">PRE {environmentStatus('preprod').label}</span>
<span class="px-1.5 py-0.5 rounded bg-indigo-100 text-indigo-800">PROD {environmentStatus('prod').label}</span>
{#if prodLag > 0}
<span class="ml-auto px-1.5 py-0.5 rounded bg-warning/20 text-warning font-medium">Отставание: {prodLag} версий</span>
{/if}
</div>
{/if}
<div class="flex min-w-0 flex-row">
<div class="min-w-0 flex-1 overflow-x-auto border-r border-border">
<div class="flex min-w-[760px] gap-3 p-4">
<div class="flex h-60 w-28 shrink-0 flex-col">
{#each ENVIRONMENTS as environment (environment)}
<div class="flex h-20 shrink-0 items-center gap-2">
<span class="h-2 w-2 rounded-full {environmentColors[environment]}"></span>
<div>
<div class="text-[11px] font-bold text-text">{getStageLabel(environment)}</div>
<div class="font-mono text-[9px] text-text-muted">{getStageShort(environment)}</div>
<div class="text-[9px] font-medium {environmentStatus(environment).className}">{environmentStatus(environment).label}</div>
{#if environment === 'prod' && prodLag > 0}
<div class="text-[8px] mt-0.5 px-1 py-px rounded bg-warning/20 text-warning font-mono" title={$t.git?.viz_lag_tooltip || 'Количество новых версий в Разработке, которых нет в Продакшне'}>
отстаёт на {prodLag}
</div>
{/if}
</div>
</div>
{/each}
</div>
<div class="relative flex h-60 min-w-0 flex-1 flex-col">
<svg class="absolute inset-0 h-full w-full overflow-visible" viewBox={`0 0 ${GRAPH_WIDTH} ${GRAPH_HEIGHT}`} preserveAspectRatio="none" aria-hidden="true">
{#each ENVIRONMENTS as environment (environment)}
<line x1="0" y1={ROW_Y[environment]} x2={GRAPH_WIDTH} y2={ROW_Y[environment]} stroke="#cbd5e1" stroke-width="1.5" />
{#if (environmentHistories[environment] || []).length > 1}
<polyline points={linePoints(environment)} fill="none" stroke="#94a3b8" stroke-width="1.5" />
{/if}
{/each}
<!-- Promotion connections: only show vertical projection for the SELECTED version to avoid noise -->
{#if selectedA}
{@const selCommit = graphVersions.find(v => v.hash === selectedA)}
{#if selCommit}
{@const selX = xFor(selectedA)}
{#each promotedTo('dev', selCommit) as target (target)}
<line x1={selX} y1={ROW_Y['dev']} x2={selX} y2={ROW_Y[target]} stroke="#60a5fa" stroke-width="1.5" stroke-dasharray="3 2" />
{/each}
{#each promotedTo('preprod', selCommit) as target (target)}
<line x1={selX} y1={ROW_Y['preprod']} x2={selX} y2={ROW_Y[target]} stroke="#60a5fa" stroke-width="1.5" stroke-dasharray="3 2" />
{/each}
{/if}
{/if}
<!-- BI-friendly axis labels inside graph -->
<text x="50" y="20" font-size="10" fill="#64748b" class="font-sans">← Более старые версии дашборда</text>
<text x="{GRAPH_WIDTH - 50}" y="20" font-size="10" fill="#64748b" text-anchor="end" class="font-sans">Новые версии (последние изменения) →</text>
</svg>
{#each ENVIRONMENTS as environment (environment)}
<div class="relative z-10 h-20 shrink-0 border-b border-transparent last:border-b-0">
{#if (environmentHistories[environment] || []).length === 0}
<!-- Explicit empty state on the lane for BI clarity (esp. PROD) -->
<div class="absolute inset-0 flex items-center justify-center text-[10px] text-text-subtle italic border border-dashed border-border rounded bg-surface-page/50">
Нет версии в {getStageLabel(environment).toLowerCase()}
{#if environment === 'prod'}
<button class="ml-2 text-primary text-[9px] underline" onclick={() => onMerge('prod')}>Инициализировать</button>
{/if}
</div>
{:else}
{#each environmentHistories[environment] || [] as commit (commit.hash)}
{@const selected = isSelected(commit.hash)}
{@const head = isHead(environment, commit.hash)}
{@const isA = selectedA === commit.hash}
{@const isB = selectedB === commit.hash}
{@const isKeyPromotion = promotedTo('dev', commit).includes(environment) || (environment === 'prod' && promotedTo('preprod', commit).includes(environment)) }
{#if environment === 'dev' || head || isKeyPromotion}
<button
type="button"
class="absolute top-1/2 grid h-9 w-9 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border-2 shadow-sm transition hover:scale-110 focus:outline-none focus:ring-2 focus:ring-primary-ring {head ? nodeColors[environment].head : `bg-surface-card ${nodeColors[environment].version}`} {selected ? (isB ? 'ring-2 ring-amber-500' : 'ring-2 ring-primary-ring') : ''} {isA ? 'scale-110' : ''} {isB ? 'scale-105 border-amber-500' : ''}"
style:left={leftFor(commit)}
onclick={(event) => selectCommit(commit, environment, event)}
aria-pressed={selected}
aria-label={`${getStageLabel(environment)}: ${commit.message || shortHash(commit.hash)}`}
title={commitTitle(commit)}
>
<span class="h-2.5 w-2.5 rounded-full bg-current"></span>
</button>
{#if head}
<span class="pointer-events-none absolute top-12 z-10 -translate-x-1/2 whitespace-nowrap text-[9px] font-medium text-text-muted" style:left={leftFor(commit)}>
{getStageLabel(environment)} текущая · {formatTime(commit.timestamp) || ''}
</span>
{:else if environment === 'dev' && promotedTo(environment, commit).length > 0}
<!-- Compact badge on dev point -->
<span class="pointer-events-none absolute top-11 z-10 -translate-x-1/2 text-[7px] font-mono text-blue-700 bg-blue-100 px-0.5 rounded" style:left={leftFor(commit)} title="Продвинута в: {promotedTo(environment, commit).map(t => getStageLabel(t)).join(', ')}">
{promotedTo(environment, commit).map(t => getStageShort(t)).join('')}
</span>
{/if}
{/if}
{/each}
{/if}
</div>
{/each}
{#if graphVersions.length === 0 && !historiesLoading}
<div class="absolute inset-0 grid place-items-center text-xs text-center text-text-muted px-2">
{$t.git?.viz_no_versions || 'Нет истории версий'}<br>
<span class="text-[10px]">{$t.git?.viz_no_versions_hint || 'Инициализируйте репозиторий или синхронизируйте из Superset.'}</span>
</div>
{/if}
</div>
</div>
{#if graphVersions.length > 0}
<div class="flex min-w-[760px] items-center justify-between border-t border-border px-4 py-2 text-[10px] text-text-muted">
<span><Icon name="clock" size={12} class="mr-1 inline-block align-text-bottom" />{$t.git?.viz_older || 'Самая старая показанная'}: {formatTime(graphVersions[0].timestamp)}</span>
<span class="text-right">{$t.git?.viz_newer || 'Самая новая'}: {formatTime(graphVersions[graphVersions.length - 1].timestamp)} <span class="text-text-subtle">({graphVersions.length} обновлений дашборда)</span></span>
</div>
{/if}
</div>
<aside class="w-72 shrink-0 p-4" aria-live="polite">
<div class="flex items-center gap-2 text-xs font-semibold text-text"><Icon name="layers" size={14} class="text-text-muted" /> {$t.git?.viz_details || 'Информация о версии'}</div>
{#if selectedCommit}
<div class="mt-3 space-y-3 text-xs">
<!-- BI primary view: the human change description first -->
<div>
<div class="text-[10px] uppercase tracking-wide text-text-muted">{$t.git?.viz_change_description || 'Change description'}</div>
<div class="mt-0.5 leading-snug text-text font-medium">{selectedCommit.message || '—'}</div>
<div class="mt-1 text-[10px] text-text-subtle">
{formatTime(selectedCommit.timestamp)} · {selectedCommit.author || 'unknown'}
</div>
{#if selectedCommitStatus}
<div class="mt-1 text-[10px] font-medium {selectedCommitStatus.className}">{selectedCommitStatus.label}</div>
{/if}
<!-- Quick lifecycle matrix for the selected version -->
<div class="mt-1 text-[9px] grid grid-cols-3 gap-0.5">
<div class="px-1 py-0.5 bg-blue-50 rounded text-center">DEV {selectedVersionEnvironments.includes('dev') ? '✓' : '—'}</div>
<div class="px-1 py-0.5 bg-amber-50 rounded text-center">PRE {selectedVersionEnvironments.includes('preprod') ? '✓' : '—'}</div>
<div class="px-1 py-0.5 bg-indigo-50 rounded text-center">PROD {selectedVersionEnvironments.includes('prod') ? '✓' : '—'}</div>
</div>
</div>
<dl class="space-y-1 text-text-muted">
<div class="flex justify-between gap-3">
<dt>{$t.git?.viz_live_in || 'Активна в'}</dt>
<dd class="text-right">
{#if selectedVersionEnvironments.length > 0}
{selectedVersionEnvironments.map(e => getStageLabel(e)).join(' / ')}
{:else}
{/if}
</dd>
</div>
<div class="flex justify-between gap-3">
<dt>{$t.git?.viz_impact || 'Dashboard config impact'}</dt>
<dd class="font-mono">{(selectedCommit.files_changed?.length ?? 0)} files</dd>
</div>
<div class="text-[9px] text-text-subtle">Это изменения в файлах определения дашборда — они определяют, какие чарты, фильтры и данные увидят пользователи при продвижении версии.</div>
</dl>
{#if selectedCommit.files_changed && selectedCommit.files_changed.length > 0}
<div class="mt-1">
<div class="text-[10px] text-text-muted mb-0.5">{$t.git?.viz_files_sample || 'Example updated files'}</div>
<div class="flex flex-wrap gap-1 text-[10px] font-mono text-text">
{#each selectedCommit.files_changed.slice(0, 3) as f (f)}
<span class="rounded bg-surface-page px-1 py-px border border-border/50 truncate max-w-[9rem]" title={f}>{f}</span>
{/each}
{#if selectedCommit.files_changed.length > 3}<span class="text-text-subtle">+{selectedCommit.files_changed.length - 3}</span>{/if}
</div>
</div>
{/if}
<!-- Technical reference de-emphasized for BI users -->
<div class="pt-1 border-t border-border/60 text-[9px] text-text-subtle flex items-center gap-2">
<span>Git commit:</span>
<span class="font-mono text-text-muted">{shortHash(selectedCommit.hash)}</span>
<button class="text-[8px] underline ml-auto" onclick={() => navigator.clipboard?.writeText(selectedCommit.hash)}>копировать</button>
</div>
{#if selectedB}
<div class="border-t border-border pt-2 text-[10px] font-medium text-amber-600">
{($t.git?.viz_comparing_with || 'Comparing with version {hash}').replace('{hash}', shortHash(selectedB))}
{#if (getVersionDistance(selectedA, selectedB) > 0)}
<span class="text-text-subtle"> — {getVersionDistance(selectedA, selectedB)} updates apart</span>
{/if}
</div>
<div class="text-[10px] text-text-muted">
{$t.git?.viz_comparison_hint || 'Diff покажет, что именно изменилось в конфигурации дашборда (чарты, фильтры, настройки) между этими двумя версиями.'}
</div>
{/if}
{#if selectedA && !selectedB}
<div class="text-[10px] text-primary">{$t.git?.viz_primary_selected || 'Select another version to compare (or use button below)'}</div>
{/if}
<div class="flex flex-wrap gap-2 border-t border-border pt-3">
{#if selectedB}
<Button variant="secondary" size="sm" onclick={onRequestDiff}>{$t.git?.viz_view_changes || 'Показать diff изменений'}</Button>
{:else}
<Button variant="secondary" size="sm" onclick={armComparison}>Выбрать как A / B для сравнения</Button>
{#if selectedA && environmentHistories.preprod?.[0] && !selectedVersionEnvironments.includes('preprod')}
<Button variant="ghost" size="sm" onclick={() => onSelectVersion(environmentHistories.preprod[0].hash, true)}>
Сравнить с текущей PREPROD
</Button>
{/if}
{#if selectedA && environmentHistories.dev?.[0] && selectedA !== environmentHistories.dev[0].hash}
<Button variant="ghost" size="sm" onclick={() => onSelectVersion(environmentHistories.dev[0].hash, true)}>
Сравнить с текущей DEV
</Button>
{/if}
{/if}
</div>
</div>
{:else}
<!-- Default summary for BI user when nothing selected: lifecycle matrix + action -->
<div class="text-xs space-y-2">
<div class="font-medium text-text">Статус по стадиям</div>
<div class="grid grid-cols-3 gap-1 text-[10px]">
<div class="p-1 bg-blue-50 rounded text-center">DEV<br><span class="text-primary font-bold">активна</span></div>
<div class="p-1 bg-amber-50 rounded text-center">PRE<br><span class="text-amber-700">{environmentStatus('preprod').label}</span></div>
<div class="p-1 bg-indigo-50 rounded text-center">PROD<br><span class="text-indigo-700">{environmentStatus('prod').label}</span></div>
</div>
{#if prodLag > 0}
<div class="mt-1 p-1 bg-warning-light rounded text-[10px]">
Продакшн отстаёт. Следующий промоушен принесёт {prodLag} обновлений (чарты/фильтры). Сначала провалидируйте.
</div>
{:else if environmentHistories.prod?.length}
<div class="mt-1 p-1 bg-success-light rounded text-[10px] text-success">
PROD на актуальной версии. Можно безопасно тестировать/публиковать.
</div>
{:else}
<div class="mt-1 p-1 bg-surface-page border rounded text-[10px]">
Нет данных по PROD. <a href="#" class="text-primary underline">Инициализировать репозиторий</a>
</div>
{/if}
<div class="text-[10px] text-primary mt-1">Выберите точку на графике для деталей и сравнения.</div>
</div>
{/if}
</aside>
</div>
{#if featureBranches.length > 0}
<div class="border-t border-border px-4 py-3">
<div class="mb-1.5 text-xs font-medium text-text-muted">{$t.git?.viz_unreleased || 'Нереализованные изменения (не продвинуты)'}</div>
<div class="flex flex-wrap gap-1.5">
{#each featureBranches.slice(0, 6) as branch (branch.name)}
<span class="rounded border border-success/30 bg-success-light px-2 py-0.5 text-[10px] text-success">{branch.name.replace('feature/', '').replace('hotfix/', '')}</span>
{/each}
{#if featureBranches.length > 6}<span class="self-center text-xs text-text-subtle">+{featureBranches.length - 6}</span>{/if}
</div>
<div class="text-[9px] text-text-subtle mt-1">{$t.git?.viz_unreleased_hint || 'These are draft changes. They will appear in the main timeline only after merge to Development.'}</div>
</div>
{/if}
</div>
<!-- #endregion GitEnvironmentTimeline -->

View File

@@ -27,10 +27,10 @@
import { onMount } from 'svelte';
import { t } from '$lib/i18n/index.svelte.js';
import { Button, HelpTooltip, Icon } from '$lib/ui';
import { addToast } from '$lib/toasts.svelte.js';
import { resolveDefaultConfig } from '../../../services/git-utils.js';
import { GitManagerModel } from '$lib/models/GitManagerModel.svelte.ts';
import { ROUTES } from '$lib/routes';
import BranchSelector from './BranchSelector.svelte';
import DeploymentModal from './DeploymentModal.svelte';
import ConflictResolver from './ConflictResolver.svelte';
import GitInitPanel from './GitInitPanel.svelte';
@@ -41,6 +41,8 @@
import GitLifecycleHeader from './GitLifecycleHeader.svelte';
import CreateBranchDialog from './CreateBranchDialog.svelte';
import MergeDialog from './MergeDialog.svelte';
import GitEnvironmentTimeline from './GitEnvironmentTimeline.svelte';
import BranchSelector from './BranchSelector.svelte';
let { dashboardId, envId = null, dashboardTitle = '', show = $bindable(false) } = $props();
@@ -249,14 +251,49 @@
preferredDeployTargetStage={model.preferredDeployTargetStage}
onRecommendedAction={handleRecommendedAction}
/>
<div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-surface-page px-4 py-3">
<div class="text-sm font-medium text-text">{$t.git?.branch_label || 'Ветка:'}</div>
<div class="flex w-full items-center gap-1 sm:w-72"><BranchSelector {dashboardId} envId={model.resolvedEnvId} bind:currentBranch={model.currentBranch} onchange={(event) => model.handleBranchChanged(event)} onmerge={handleOpenMergeDialog} /><HelpTooltip text={$t.git?.hint_branch_selector || ''} ariaLabel={$t.git?.help_aria || 'Справка'} /></div>
<!-- Branch selector moved here per UX review: separate from version map to avoid confusion -->
<div class="flex items-center gap-3 mb-2">
<div class="text-xs font-medium text-text-muted uppercase tracking-wide">Рабочая ветка (текущие изменения):</div>
<div class="flex-1 max-w-[280px]">
<BranchSelector
{dashboardId}
envId={model.resolvedEnvId}
bind:currentBranch={model.currentBranch}
onchange={(event) => model.handleBranchChanged(event)}
onmerge={handleOpenMergeDialog}
/>
</div>
</div>
<!-- BI dashboard version timeline: focused on status map. Operations below in tabs. -->
<GitEnvironmentTimeline
branches={model.branches}
environmentHistories={model.environmentHistories}
historiesLoading={model.environmentHistoriesLoading}
{dashboardId}
envId={model.resolvedEnvId}
bind:currentBranch={model.currentBranch}
onBranchChange={(event) => model.handleBranchChanged(event)}
onMerge={handleOpenMergeDialog}
selectedA={model.selectedVersionA}
selectedB={model.selectedVersionB}
onSelectVersion={(hash, secondary) => model.selectVersion(hash, secondary)}
onClearSelection={() => model.clearVersionSelection()}
onRequestDiff={async () => {
const res = await model.getSelectedVersionsDiff();
if (res?.diff) {
model.workspaceDiff = res.diff;
model.activeTab = 'workspace';
addToast($t.git?.viz_diff_ready || 'Dashboard changes loaded for comparison', 'success');
}
}}
/>
<div class="flex flex-wrap items-center gap-1 border-b border-border pb-0" role="tablist" aria-label={$t.git?.management || 'Управление Git'}>
<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')} role="tab" aria-selected={model.activeTab === 'workspace'} aria-controls="git-tab-panel" id="git-tab-workspace" tabindex={model.activeTab === 'workspace' ? 0 : -1}>
<Icon name="edit" size={16} strokeWidth={2} />
{$t.git?.tab_workspace || 'Фиксация изменений'}
{$t.git?.tab_workspace || 'Изменения'}
<HelpTooltip text={$t.git?.hint_workspace || ''} ariaLabel={$t.git?.help_aria || 'Справка'} />
</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')} role="tab" aria-selected={model.activeTab === 'release'} aria-controls="git-tab-panel" id="git-tab-release" tabindex={model.activeTab === 'release' ? 0 : -1}>

View File

@@ -269,7 +269,7 @@
class="w-full"
>
<Icon name="check" size={16} class="-ml-1 mr-1.5" strokeWidth={2} />
{$t.git?.commit_button || 'Сохранить версию (Commit)'}
{$t.git?.commit_button || 'Создать коммит'}
</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">

View File

@@ -355,5 +355,68 @@
"env_required_for_init": "Environment must be selected to initialize Git for this dashboard.",
"init_fields_required": "Please fill in all fields",
"close_aria": "Close",
"help_aria": "Help"
"help_aria": "Help",
"viz_title": "Dashboard versions across stages",
"viz_hint": "See which version of the dashboard is active in Development, Pre-production and Production. Quickly spot lag and compare what changed.",
"viz_working_branch": "Active development version (your current work)",
"viz_stage_dev": "Development",
"viz_stage_preprod": "Pre-production / Validation",
"viz_stage_prod": "Production (live for users)",
"viz_clear": "Clear selection",
"viz_no_versions": "No data",
"viz_unreleased": "Unreleased changes (not promoted)",
"viz_unreleased_hint": "These are draft changes. They will appear in the main timeline only after merge to Development.",
"viz_selected": "Selected",
"viz_current": "current",
"viz_compare": "Comparing two versions",
"viz_compare_action": "Select for comparison (A/B)",
"viz_version_selected": "Version selected",
"viz_details": "Dashboard version info",
"viz_select_version": "Click a version dot on the timeline to inspect",
"viz_live_in": "Active in",
"viz_impact": "Dashboard config impact",
"viz_author": "Author",
"viz_date": "Date",
"viz_legend_current": "Current version in stage",
"viz_legend_promoted": "Badge = promoted (line only for selected)",
"viz_choose_comparison": "Choose a version to compare changes",
"viz_status_dev": "Latest changes (in development)",
"viz_status_up_to_date": "Up to date with Development",
"viz_status_lag": "{count} updates behind Development",
"viz_status_stable_old": "Stable older version (updates exist in Development)",
"viz_status_unknown": "Status unknown",
"viz_live_downstream": "This version is already live in: {stages}",
"viz_status_current_stage": "Current version for this stage",
"viz_status_historical": "Historical version in this stage",
"viz_prod_health": "Production health:",
"viz_summary_no_prod": "No production data yet",
"viz_summary_no_dev": "Development data not loaded",
"viz_summary_prod_current": "Production is using the latest development version.",
"viz_summary_lag": "Production is behind by {count} dashboard updates from Development.",
"viz_summary_old_prod": "Production is on an older stable version.",
"viz_quick_dev_vs_prod": "Compare with current Development",
"viz_technical_id": "Technical ID",
"viz_comparison_hint": "Diff shows what changed in dashboard config between the two versions.",
"viz_no_versions_hint": "Initialize the repository or sync from Superset to see deployment timeline.",
"viz_bi_purpose": "Helps BI analysts understand deployment state before validation or promotion.",
"viz_lag_tooltip": "Number of newer dashboard updates in Development not yet in Production",
"viz_rec_up_to_date": "Production is current. Safe for validation/testing scenarios.",
"viz_rec_lag": "Production lags by {count} updates. Review changes and promote after validation.",
"viz_rec_no_prod": "No production data. Initialize/sync the repo to track live versions.",
"viz_rec_default": "Review the timeline and compare versions before promoting.",
"viz_recommendation": "Recommendation:",
"viz_how_to_read": "Dots = versions. Badge = promoted to stage (no dup lines). Vertical only for selected. Preprod: promotion events only.",
"viz_older": "Oldest shown",
"viz_newer": "Newest",
"viz_change_description": "Change description",
"viz_comparing_with": "Comparing with version {hash}",
"viz_diff_ready": "Dashboard changes loaded for comparison",
"viz_view_changes": "View exact changes",
"viz_compare_to_dev": "Compare to latest Development",
"viz_files": "Files changed",
"viz_files_sample": "Example updated files",
"viz_status_window": "Outside recent window (older)",
"viz_primary_selected": "Select another version to compare (or use button below)",
"viz_unique_versions": "unique versions",
"viz_recent_window": "recent window (older may exist)"
}

View File

@@ -355,5 +355,68 @@
"env_required_for_init": "Для инициализации Git выберите окружение.",
"init_fields_required": "Заполните все поля",
"close_aria": "Закрыть",
"help_aria": "Справка"
"help_aria": "Справка",
"viz_title": "Версии дашборда по стадиям",
"viz_hint": "Какая версия дашборда сейчас активна в Разработке, Предпроде и Продакшне. Видно отставание и можно сравнить, что именно изменилось.",
"viz_working_branch": "Активная версия в разработке (ваша текущая работа)",
"viz_stage_dev": "Разработка",
"viz_stage_preprod": "Предпрод / Валидация",
"viz_stage_prod": "Продакшн (для пользователей)",
"viz_clear": "Очистить выбор",
"viz_no_versions": "Нет истории версий",
"viz_unreleased": "Нереализованные изменения (не продвинуты)",
"viz_unreleased_hint": "Черновики; появятся после мерджа в dev.",
"viz_selected": "Выбрано",
"viz_current": "текущая",
"viz_compare": "Сравнение двух версий",
"viz_compare_action": "Выбрать для сравнения (A/B)",
"viz_version_selected": "Выбрана версия",
"viz_details": "Информация о версии",
"viz_select_version": "Кликните по точке на таймлайне, чтобы посмотреть",
"viz_live_in": "Активна в",
"viz_impact": "Влияние на конфиг дашборда",
"viz_author": "Автор",
"viz_date": "Дата",
"viz_legend_current": "Текущая версия в стадии",
"viz_legend_promoted": "Бейдж PRE = продвинута (линия только для выбранной)",
"viz_choose_comparison": "Выберите версию для сравнения изменений",
"viz_status_dev": "Последние изменения (в разработке)",
"viz_status_up_to_date": "Актуальна относительно Разработки",
"viz_status_lag": "Отстаёт от Разработки на {count} обновлений",
"viz_status_stable_old": "Стабильная старая версия (в Разработке есть обновления)",
"viz_status_unknown": "Статус неизвестен",
"viz_live_downstream": "Эта версия уже активна в: {stages}",
"viz_status_current_stage": "Текущая в стадии",
"viz_status_historical": "Историческая версия в этой стадии",
"viz_prod_health": "Статус стадий:",
"viz_summary_no_prod": "Пока нет данных по продакшну",
"viz_summary_no_dev": "Нет данных разработки",
"viz_summary_prod_current": "Продакшн использует самую свежую версию из разработки.",
"viz_summary_lag": "Продакшн отстаёт на {count} обновлений дашборда от Разработки.",
"viz_summary_old_prod": "Продакшн на стабильной старой версии.",
"viz_quick_dev_vs_prod": "Сравнить с текущей в Разработке",
"viz_technical_id": "Технический ID",
"viz_comparison_hint": "Diff покажет, что изменилось в конфиге дашборда между выбранными версиями.",
"viz_no_versions_hint": "Инициализируйте репозиторий или синхронизируйте из Superset.",
"viz_bi_purpose": "Помогает аналитикам понять состояние развёртывания перед валидацией или промоушеном.",
"viz_lag_tooltip": "Количество более новых обновлений дашборда в Разработке, которых ещё нет в Продакшне",
"viz_rec_up_to_date": "Продакшн актуален. Можно безопасно проводить валидацию и тестовые сценарии.",
"viz_rec_lag": "Продакшн отстаёт на {count} обновлений. Изучите изменения и промоутните после валидации.",
"viz_rec_no_prod": "Нет данных по продакшну. Инициализируйте/синхронизируйте репозиторий.",
"viz_rec_default": "Просмотрите таймлайн и сравните версии перед промоушеном.",
"viz_recommendation": "Рекомендация:",
"viz_how_to_read": "Точки = версии дашборда. Бейдж PRE = продвинута (без дублирования). Вертикаль только для выбранной. Preprod — только события промоушена.",
"viz_older": "Самая старая показанная",
"viz_newer": "Самая новая",
"viz_change_description": "Описание изменения",
"viz_comparing_with": "Сравнение с версией {hash}",
"viz_diff_ready": "Изменения дашборда загружены для сравнения",
"viz_view_changes": "Посмотреть точные изменения",
"viz_compare_to_dev": "Сравнить с последней в Разработке",
"viz_files": "Файлов изменено",
"viz_files_sample": "Примеры обновлённых файлов",
"viz_status_window": "За пределами окна истории (старше)",
"viz_primary_selected": "Выберите точку B на графике",
"viz_unique_versions": "уникальных версий",
"viz_recent_window": "окно недавних (более старые могут быть)"
}

View File

@@ -296,6 +296,15 @@ export class GitManagerModel {
/** Target branch name for merge dialog (default: dev). */
mergeTargetBranch: string = $state('dev');
// ── Environment Graph Visualization State (for GitEnvironmentTimeline) ─
/** Map environment branch -> recent commits used to render the promotion graph. */
environmentHistories: Record<string, any[]> = $state({});
/** Loading state for environment-history fetches (non-blocking). */
environmentHistoriesLoading: boolean = $state(false);
/** Selected version hashes for details / compare (primary + optional secondary). */
selectedVersionA: string | null = $state(null);
selectedVersionB: string | null = $state(null);
// ── Create Remote Repo Dialog ────────────────────────────────
/** True when the create-remote-repo modal is open (replaces native prompt()). */
showCreateRepoDialog: boolean = $state(false);
@@ -431,6 +440,61 @@ export class GitManagerModel {
}
}
/** Load recent commits from each environment branch for the promotion graph.
* Limit chosen for BI analyst utility (deeper promotion audit) without overloading the lane viz. */
async loadEnvironmentHistories(branchesToLoad: string[] = ['dev', 'preprod', 'prod']): Promise<void> {
if (!this.dashboardId) return;
this.environmentHistoriesLoading = true;
try {
const results = await Promise.all(
branchesToLoad.map(async (b) => {
try {
const commits = await gitService.getBranchCommits(this.dashboardId, b, 20, this.resolvedEnvId);
return [b, commits] as const;
} catch {
return [b, []] as const;
}
})
);
const next: Record<string, any[]> = { ...this.environmentHistories };
for (const [b, commits] of results) {
next[b] = commits || [];
}
this.environmentHistories = next;
} finally {
this.environmentHistoriesLoading = false;
}
}
/** Select a version (commit hash) for the details panel. Supports compare via shift. */
selectVersion(hash: string | null, isSecondary = false): void {
if (isSecondary) {
this.selectedVersionB = hash;
} else {
this.selectedVersionA = hash;
// A newly selected primary starts a new comparison.
this.selectedVersionB = null;
}
}
clearVersionSelection(): void {
this.selectedVersionA = null;
this.selectedVersionB = null;
}
/** Convenience: fetch diff between two selected versions (or A vs current head). */
async getSelectedVersionsDiff(): Promise<{ from: string; to?: string; diff: string } | null> {
if (!this.selectedVersionA) return null;
const from = this.selectedVersionA;
const to = this.selectedVersionB || undefined;
try {
return await gitService.getCommitDiff(this.dashboardId, from, to, this.resolvedEnvId);
} catch (e) {
this._setGitError(e);
return null;
}
}
// ── Initialization ───────────────────────────────────────────
/**
@@ -505,6 +569,8 @@ export class GitManagerModel {
this.branches = await gitService.getBranches(this.dashboardId, this.resolvedEnvId);
this.initialized = true;
await this.loadWorkspace();
// Fetch the recent environment histories for the promotion graph.
void this.loadEnvironmentHistories();
} catch {
this.initialized = false;
} finally {
@@ -622,6 +688,7 @@ export class GitManagerModel {
this.commitCompleted = true;
this.commitHistoryKey++;
await this.loadWorkspace();
void this.loadEnvironmentHistories(); // refresh viz heads after new commit
} catch (e: unknown) {
this._setGitError(e);
} finally {
@@ -685,6 +752,7 @@ export class GitManagerModel {
await gitService.pull(this.dashboardId, this.resolvedEnvId);
addToast((this._t?.git as Record<string, unknown>)?.pull_success as string || 'Изменения получены из Git', 'success');
await this.loadWorkspace();
void this.loadEnvironmentHistories();
} catch (e: unknown) {
const handled = this.openUnfinishedMergeDialogFromError(e);
if (handled) {
@@ -710,6 +778,7 @@ export class GitManagerModel {
await gitService.push(this.dashboardId, this.resolvedEnvId);
addToast((this._t?.git as Record<string, unknown>)?.push_success as string || 'Изменения отправлены в Git', 'success');
await this.loadWorkspace();
void this.loadEnvironmentHistories();
} catch (e: unknown) {
this._setGitError(e);
} finally {

View File

@@ -354,6 +354,25 @@ export const gitService = {
},
// #endregion getHistory
// #region getBranchCommits [C:2]
// @BRIEF Fetch commits for a specific branch (for lane viz timelines).
async getBranchCommits<T = unknown>(dashboardRef: string | number, branchName: string, limit = 15, envId: string | number | null = null): Promise<T> {
log("gitService", "REASON", "Fetching branch commits for viz", { dashboardRef, branchName, limit });
const bn = encodeURIComponent(branchName);
return gitRequest<T>(buildDashboardRepoEndpoint(dashboardRef, `/branches/${bn}/commits?limit=${limit}`, envId));
},
// #endregion getBranchCommits
// #region getCommitDiff [C:2]
// @BRIEF Diff between two commit refs (or one vs working for future).
async getCommitDiff<T = unknown>(dashboardRef: string | number, fromRef: string, toRef?: string | null, envId: string | number | null = null): Promise<T> {
const qs = new URLSearchParams({ from_ref: fromRef });
if (toRef) qs.append('to_ref', toRef);
log("gitService", "REASON", "Fetching commit diff", { dashboardRef, fromRef, toRef });
return gitRequest<T>(buildDashboardRepoEndpoint(dashboardRef, `/commits/diff?${qs.toString()}`, envId));
},
// #endregion getCommitDiff
// #region sync [C:2] [TYPE Function]
// @BRIEF Synchronize local dashboard state with Git repository.
// @POST Dashboard state is synced to Git workspace.