dev-preprod-prod logic

This commit is contained in:
2026-03-01 14:39:25 +03:00
parent 80b28ac371
commit da24fb9253
13 changed files with 754 additions and 70 deletions

View File

@@ -11,14 +11,15 @@
<script>
// [SECTION: IMPORTS]
import { onMount, createEventDispatcher } from "svelte";
import { createEventDispatcher } from "svelte";
import { gitService } from "../../services/gitService";
import { api } from "../../lib/api.js";
import { addToast as toast } from "../../lib/toasts.js";
import { t } from "../../lib/i18n";
// [/SECTION]
// [SECTION: PROPS]
let { dashboardId, envId = null, show = false } = $props();
let { dashboardId, envId = null, show = false, preferredTargetStage = "" } = $props();
// [/SECTION]
@@ -30,6 +31,13 @@
// [/SECTION]
const dispatch = createEventDispatcher();
const normalizedPreferredStage = $derived(String(preferredTargetStage || "").toUpperCase());
const deploymentCandidates = $derived.by(() => {
const all = environments.filter((env) => env?.id !== envId);
if (!normalizedPreferredStage) return all;
const stageMatched = all.filter((env) => normalizeEnvStage(env) === normalizedPreferredStage);
return stageMatched.length > 0 ? stageMatched : all;
});
// [DEF:loadStatus:Watcher]
$effect(() => {
@@ -37,6 +45,29 @@
});
// [/DEF:loadStatus:Watcher]
// [DEF:normalizeEnvStage:Function]
/**
* @purpose Normalize environment stage with legacy production fallback.
* @post Returns DEV/PREPROD/PROD.
*/
function normalizeEnvStage(env) {
if (env?.is_production) return "PROD";
const stage = String(env?.stage || "").trim().toUpperCase();
if (stage === "PROD" || stage === "PREPROD") return stage;
return "DEV";
}
// [/DEF:normalizeEnvStage:Function]
// [DEF:resolveEnvUrl:Function]
/**
* @purpose Resolve environment URL from consolidated or git-specific payload shape.
* @post Returns stable URL string.
*/
function resolveEnvUrl(env) {
return String(env?.superset_url || env?.url || "");
}
// [/DEF:resolveEnvUrl:Function]
// [DEF:loadEnvironments:Function]
/**
* @purpose Fetch available environments from API.
@@ -47,9 +78,13 @@
console.log(`[DeploymentModal][Action] Loading environments`);
loading = true;
try {
environments = await gitService.getEnvironments();
if (environments.length > 0) {
selectedEnv = environments[0].id;
environments = await api.getEnvironmentsList();
const candidates = (environments || []).filter((env) => env?.id !== envId);
if (normalizedPreferredStage) {
const stageMatched = candidates.filter((env) => normalizeEnvStage(env) === normalizedPreferredStage);
selectedEnv = (stageMatched[0]?.id) || (candidates[0]?.id) || "";
} else {
selectedEnv = (candidates[0]?.id) || "";
}
console.log(
`[DeploymentModal][Coherence:OK] Loaded ${environments.length} environments`,
@@ -103,7 +138,7 @@
{#if loading}
<p class="text-gray-500">{$t.migration?.loading_envs}</p>
{:else if environments.length === 0}
{:else if deploymentCandidates.length === 0}
<p class="text-red-500 mb-4">
{$t.git?.no_deploy_envs}
</p>
@@ -116,6 +151,11 @@
</button>
</div>
{:else}
{#if normalizedPreferredStage}
<p class="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
GitFlow target stage: {normalizedPreferredStage}
</p>
{/if}
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2"
>{$t.migration?.target_env}</label
@@ -124,9 +164,9 @@
bind:value={selectedEnv}
class="w-full border rounded p-2 focus:ring-2 focus:ring-blue-500 outline-none bg-white"
>
{#each environments as env}
{#each deploymentCandidates as env}
<option value={env.id}
>{env.name} ({env.superset_url})</option
>{env.name} [{normalizeEnvStage(env)}] ({resolveEnvUrl(env)})</option
>
{/each}
</select>

View File

@@ -15,6 +15,7 @@
// [SECTION: IMPORTS]
import { onMount } from 'svelte';
import { gitService } from '../../services/gitService';
import { api } from '../../lib/api.js';
import { addToast as toast } from '../../lib/toasts.js';
import { t } from '../../lib/i18n';
import { Button, Card, PageHeader, Select, Input } from '../../lib/ui';
@@ -51,8 +52,83 @@
let selectedConfigId = $state("");
let remoteUrl = $state("");
let creatingRemoteRepo = $state(false);
let promoting = $state(false);
let promoteFromBranch = $state("dev");
let promoteToBranch = $state("preprod");
let promoteMode = $state("mr");
let promoteReason = $state("");
let currentEnvStage = $state("");
let preferredDeployTargetStage = $state("");
// [/SECTION]
// [DEF:normalizeEnvStage:Function]
/**
* @purpose Normalize environment stage with legacy fallback.
* @post Returns DEV/PREPROD/PROD.
*/
function normalizeEnvStage(env) {
if (env?.is_production) return 'PROD';
const stage = String(env?.stage || '').trim().toUpperCase();
if (stage === 'PROD' || stage === 'PREPROD') return stage;
return 'DEV';
}
// [/DEF:normalizeEnvStage:Function]
// [DEF:resolveCurrentEnvironmentId:Function]
/**
* @purpose Resolve active environment id for current dashboard view.
* @post Returns env id from prop or selected_env_id in localStorage.
*/
function resolveCurrentEnvironmentId() {
if (envId) return String(envId);
if (typeof window === 'undefined') return null;
return localStorage.getItem('selected_env_id');
}
// [/DEF:resolveCurrentEnvironmentId:Function]
// [DEF:applyGitflowStageDefaults:Function]
/**
* @purpose Apply branch promotion/deploy defaults based on environment stage.
* @post Promote fields and deploy target stage are updated.
*/
function applyGitflowStageDefaults(stage) {
const normalizedStage = String(stage || '').toUpperCase();
if (normalizedStage === 'DEV') {
promoteFromBranch = 'dev';
promoteToBranch = 'preprod';
preferredDeployTargetStage = 'PREPROD';
return;
}
if (normalizedStage === 'PREPROD') {
promoteFromBranch = 'preprod';
promoteToBranch = 'main';
preferredDeployTargetStage = 'PROD';
return;
}
preferredDeployTargetStage = '';
}
// [/DEF:applyGitflowStageDefaults:Function]
// [DEF:loadCurrentEnvironmentStage:Function]
/**
* @purpose Detect current environment stage and bind Gitflow defaults.
* @post currentEnvStage and defaults are set when environment is found.
*/
async function loadCurrentEnvironmentStage() {
try {
const currentEnvId = resolveCurrentEnvironmentId();
if (!currentEnvId) return;
const environments = await api.getEnvironmentsList();
const currentEnv = (environments || []).find((item) => item.id === currentEnvId);
if (!currentEnv) return;
currentEnvStage = normalizeEnvStage(currentEnv);
applyGitflowStageDefaults(currentEnvStage);
} catch (e) {
console.error(`[GitManager][Coherence:Failed] Failed to resolve environment stage: ${e.message}`);
}
}
// [/DEF:loadCurrentEnvironmentStage:Function]
// [DEF:isNumericDashboardRef:Function]
/**
* @purpose Checks whether current dashboard reference is numeric ID.
@@ -277,6 +353,61 @@
}
// [/DEF:handlePull:Function]
// [DEF:handlePromote:Function]
/**
* @purpose Promotes changes between branches via MR (default) or direct merge (unsafe).
* @pre Repository is initialized and source/target branches are provided.
* @post Promotion request is sent to backend and user receives result feedback.
*/
async function handlePromote() {
if (isNumericDashboardRef()) {
toast('GitManager requires dashboard slug. Numeric ID is forbidden.', 'error');
return;
}
const fromBranch = String(promoteFromBranch || '').trim();
const toBranch = String(promoteToBranch || '').trim();
if (!fromBranch || !toBranch || fromBranch === toBranch) {
toast('Select different source and target branches', 'error');
return;
}
if (promoteMode === 'direct' && !String(promoteReason || '').trim()) {
toast('Unsafe direct promote requires explicit reason', 'error');
return;
}
promoting = true;
try {
const response = await gitService.promote(
dashboardId,
{
from_branch: fromBranch,
to_branch: toBranch,
mode: promoteMode,
title: `Promote ${fromBranch} -> ${toBranch}: ${dashboardTitle || dashboardId}`,
description: promoteMode === 'direct'
? `Unsafe direct promote requested.\nReason: ${promoteReason}`
: undefined,
reason: promoteMode === 'direct' ? promoteReason : undefined,
},
envId,
);
if (promoteMode === 'direct') {
toast('Unsafe direct promote completed. Policy violation logged.', 'warning');
} else {
if (response?.url) {
window.open(response.url, '_blank', 'noopener,noreferrer');
}
toast('Merge request created on Git server', 'success');
}
} catch (e) {
toast(e.message, 'error');
} finally {
promoting = false;
}
}
// [/DEF:handlePromote:Function]
// [DEF:closeModal:Function]
/**
* @purpose Закрывает модальное окно управления Git.
@@ -300,7 +431,9 @@
}
// [/DEF:handleBackdropClick:Function]
onMount(checkStatus);
onMount(async () => {
await Promise.all([checkStatus(), loadCurrentEnvironmentStage()]);
});
</script>
<!-- [SECTION: TEMPLATE] -->
@@ -427,6 +560,59 @@
</div>
</section>
<section class="space-y-3">
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Promote</h3>
{#if currentEnvStage}
<div class="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
Stage: {currentEnvStage}
{#if preferredDeployTargetStage}
| Next deploy target: {preferredDeployTargetStage}
{/if}
</div>
{/if}
<div class="grid grid-cols-2 gap-2">
<Input
label="From branch"
bind:value={promoteFromBranch}
placeholder="dev"
/>
<Input
label="To branch"
bind:value={promoteToBranch}
placeholder="preprod"
/>
</div>
<Select
label="Promotion mode"
bind:value={promoteMode}
options={[
{ value: 'mr', label: 'Create MR/PR (Safe)' },
{ value: 'direct', label: 'Direct merge without MR (Unsafe)' },
]}
/>
{#if promoteMode === 'direct'}
<div class="rounded-lg border border-red-300 bg-red-50 p-3 text-sm text-red-800">
<div class="font-semibold">Warning: Unsafe direct promote</div>
<div class="mt-1">This bypasses MR approval rules and writes a policy violation to logs.</div>
</div>
<Input
label="Reason (required)"
bind:value={promoteReason}
placeholder="Why MR is bypassed?"
/>
{/if}
<Button
onclick={handlePromote}
disabled={promoting || loading}
isLoading={promoting}
class={`w-full ${promoteMode === 'direct' ? 'bg-red-600 hover:bg-red-700 focus-visible:ring-red-500' : ''}`}
>
{promoteMode === 'direct'
? 'Direct promote (unsafe)'
: 'Create MR/PR for promote'}
</Button>
</section>
<section>
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">{$t.git.deployment}</h3>
<Button
@@ -460,6 +646,7 @@
<DeploymentModal
{dashboardId}
{envId}
preferredTargetStage={preferredDeployTargetStage}
bind:show={showDeployModal}
/>

View File

@@ -75,7 +75,9 @@
$: globalEnvironments = $environmentContextStore?.environments || [];
$: globalSelectedEnvId = $environmentContextStore?.selectedEnvId || "";
$: globalSelectedEnv = $selectedEnvironmentStore;
$: isProdContext = Boolean(globalSelectedEnv?.is_production);
$: isProdContext =
String(globalSelectedEnv?.stage || "").toUpperCase() === "PROD" ||
Boolean(globalSelectedEnv?.is_production);
function toggleUserMenu(event) {
event.stopPropagation();
@@ -431,7 +433,7 @@
>
{#each globalEnvironments as env}
<option value={env.id}>
{env.name}{env.is_production ? " [PROD]" : ""}
{env.name}{(String(env.stage || "").toUpperCase() === "PROD" || env.is_production) ? " [PROD]" : ""}
</option>
{/each}
</select>

View File

@@ -136,6 +136,8 @@ export const selectedEnvironmentStore = derived(
export const isProductionContextStore = derived(
selectedEnvironmentStore,
($selectedEnvironment) => Boolean($selectedEnvironment?.is_production),
($selectedEnvironment) =>
String($selectedEnvironment?.stage || "").toUpperCase() === "PROD" ||
Boolean($selectedEnvironment?.is_production),
);
// [/DEF:environmentContext:Store]

View File

@@ -3,12 +3,30 @@
// @PURPOSE: Manages toast notifications using a Svelte writable store.
// @LAYER: UI-State
import { writable } from 'svelte/store';
import { writable } from 'svelte/store';
// [DEF:toasts:Data]
// @PURPOSE: Writable store containing the list of active toasts.
export const toasts = writable([]);
// [/DEF:toasts:Data]
export const toasts = writable([]);
// [/DEF:toasts:Data]
const TOAST_DEDUP_WINDOW_MS = 1200;
const recentToastByKey = new Map();
function buildToastKey(message, type) {
return `${String(type || 'info')}::${String(message || '')}`;
}
function shouldSkipDuplicateToast(message, type) {
const key = buildToastKey(message, type);
const now = Date.now();
const previousAt = recentToastByKey.get(key);
if (previousAt && now - previousAt < TOAST_DEDUP_WINDOW_MS) {
return true;
}
recentToastByKey.set(key, now);
return false;
}
// [DEF:addToast:Function]
// @PURPOSE: Adds a new toast message.
@@ -17,12 +35,16 @@ export const toasts = writable([]);
// @PARAM: message (string) - The message text.
// @PARAM: type (string) - The type of toast (info, success, error).
// @PARAM: duration (number) - Duration in ms before the toast is removed.
export function addToast(message, type = 'info', duration = 3000) {
const id = Math.random().toString(36).substr(2, 9);
console.log(`[toasts.addToast][Action] Adding toast context={{'id': '${id}', 'type': '${type}', 'message': '${message}'}}`);
toasts.update(all => [...all, { id, message, type }]);
setTimeout(() => removeToast(id), duration);
}
export function addToast(message, type = 'info', duration = 3000) {
if (shouldSkipDuplicateToast(message, type)) {
console.log(`[toasts.addToast][Action] Duplicate skipped context={{'type': '${type}', 'message': '${message}'}}`);
return;
}
const id = Math.random().toString(36).substr(2, 9);
console.log(`[toasts.addToast][Action] Adding toast context={{'id': '${id}', 'type': '${type}', 'message': '${message}'}}`);
toasts.update(all => [...all, { id, message, type }]);
setTimeout(() => removeToast(id), duration);
}
// [/DEF:addToast:Function]
// [DEF:removeToast:Function]
@@ -35,4 +57,4 @@ function removeToast(id) {
toasts.update(all => all.filter(t => t.id !== id));
}
// [/DEF:removeToast:Function]
// [/DEF:toasts_module:Module]
// [/DEF:toasts_module:Module]

View File

@@ -74,8 +74,8 @@
url: "",
username: "",
password: "",
stage: "DEV",
is_default: false,
is_production: false,
backup_schedule: {
enabled: false,
cron_expression: "0 0 * * *",
@@ -356,8 +356,13 @@
if (!newEnv.backup_schedule) {
newEnv.backup_schedule = { enabled: false, cron_expression: "0 0 * * *" };
}
if (typeof newEnv.is_production !== "boolean") {
newEnv.is_production = false;
const rawStage = String(newEnv.stage || "").toUpperCase();
if (rawStage === "DEV" && newEnv.is_production === true) {
newEnv.stage = "PROD";
} else if (!rawStage) {
newEnv.stage = newEnv.is_production ? "PROD" : "DEV";
} else {
newEnv.stage = rawStage;
}
editingEnvId = env.id;
isAddingEnv = false;
@@ -370,8 +375,8 @@
url: "",
username: "",
password: "",
stage: "DEV",
is_default: false,
is_production: false,
backup_schedule: {
enabled: false,
cron_expression: "0 0 * * *",
@@ -392,11 +397,17 @@
return;
}
const payload = {
...newEnv,
stage: String(newEnv.stage || "DEV").toUpperCase(),
is_production: String(newEnv.stage || "DEV").toUpperCase() === "PROD",
};
if (editingEnvId) {
await api.updateEnvironment(editingEnvId, newEnv);
await api.updateEnvironment(editingEnvId, payload);
addToast($t.settings?.env_updated, "success");
} else {
await api.addEnvironment(newEnv);
await api.addEnvironment(payload);
addToast($t.settings?.env_added, "success");
}
@@ -429,6 +440,10 @@
}
}
}
function resolveEnvStage(env) {
return String((env?.is_production ? "PROD" : env?.stage) || "DEV").toUpperCase();
}
</script>
<div class="mx-auto w-full max-w-7xl space-y-6">
@@ -679,19 +694,21 @@
>{$t.settings?.env_default}</label
>
</div>
<div class="flex items-center mt-6">
<input
type="checkbox"
id="env_production"
bind:checked={newEnv.is_production}
class="h-4 w-4 text-red-600 border-gray-300 rounded"
/>
<div>
<label
for="env_production"
class="ml-2 block text-sm font-medium text-red-700"
>{$t.settings?.env_production ||
"Production environment"}</label
for="env_stage"
class="block text-sm font-medium text-gray-700"
>Stage</label
>
<select
id="env_stage"
bind:value={newEnv.stage}
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
>
<option value="DEV">DEV</option>
<option value="PREPROD">PREPROD</option>
<option value="PROD">PROD</option>
</select>
</div>
</div>
@@ -777,7 +794,7 @@
>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>{$t.settings?.env_production || "Production"}</th
>Stage</th
>
<th
class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"
@@ -804,15 +821,10 @@
{/if}
</td>
<td class="px-6 py-4 whitespace-nowrap">
{#if env.is_production}
<span
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800"
>
PROD
</span>
{:else}
<span class="text-gray-500">{$t.common?.no}</span>
{/if}
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full
{resolveEnvStage(env) === 'PROD' ? 'bg-red-100 text-red-800' : resolveEnvStage(env) === 'PREPROD' ? 'bg-amber-100 text-amber-800' : 'bg-blue-100 text-blue-800'}">
{resolveEnvStage(env)}
</span>
</td>
<td
class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"

View File

@@ -335,6 +335,21 @@ export const gitService = {
if (envId) params.append('env_id', String(envId));
if (params.toString()) endpoint += `?${params.toString()}`;
return requestApi(endpoint);
},
/**
* [DEF:promote:Function]
* @purpose Promotes changes between branches via MR or direct merge.
* @pre Dashboard repository must be initialized.
* @post Returns promotion metadata (MR URL or direct merge status).
* @param {string|number} dashboardRef - Dashboard slug or id.
* @param {Object} payload - {from_branch,to_branch,mode,title,description,reason,draft,remove_source_branch}
* @param {string|null} envId - Environment id for slug resolution.
* @returns {Promise<Object>} Promotion result.
*/
async promote(dashboardRef, payload, envId = null) {
console.log(`[promote][Action] Promoting ${payload?.from_branch} -> ${payload?.to_branch} for dashboard ${dashboardRef} mode=${payload?.mode}`);
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/promote', envId), 'POST', payload);
}
};
// [/DEF:gitService:Action]