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:
@@ -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}
|
||||
|
||||
589
frontend/src/lib/components/git/GitEnvironmentTimeline.svelte
Normal file
589
frontend/src/lib/components/git/GitEnvironmentTimeline.svelte
Normal 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 -->
|
||||
@@ -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}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
@@ -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": "окно недавних (более старые могут быть)"
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user