fix
This commit is contained in:
@@ -343,6 +343,7 @@
|
||||
{#if showGitManager && gitDashboardId}
|
||||
<GitManager
|
||||
dashboardId={gitDashboardId}
|
||||
envId={environmentId}
|
||||
dashboardTitle={gitDashboardTitle}
|
||||
bind:show={showGitManager}
|
||||
/>
|
||||
|
||||
@@ -1,58 +1,44 @@
|
||||
<!-- #region BranchSelector [C:3] [TYPE Component] [SEMANTICS git, branch, checkout, create, selector] -->
|
||||
<!-- @BRIEF Component component: components/git/BranchSelector.svelte -->
|
||||
<!-- @BRIEF UI for selecting and creating Git branches for a dashboard repository. -->
|
||||
<!-- @LAYER UI -->
|
||||
<!--
|
||||
@SEMANTICS: git, branch, selection, checkout
|
||||
@PURPOSE: UI для выбора и создания веток Git.
|
||||
@LAYER Component
|
||||
@RELATION CALLS -> [gitService.getBranches]
|
||||
@RELATION CALLS -> [GitServiceBranch]
|
||||
@RELATION CALLS -> [gitService.createBranch]
|
||||
@RELATION BINDS_TO -> [onchange]
|
||||
-->
|
||||
<!-- @RELATION CALLS -> [EXT:frontend:gitService.getBranches] -->
|
||||
<!-- @RELATION CALLS -> [EXT:frontend:gitService.checkoutBranch] -->
|
||||
<!-- @RELATION CALLS -> [EXT:frontend:gitService.createBranch] -->
|
||||
<!-- @RELATION BINDS_TO -> [onchange] -->
|
||||
<!-- @PRE dashboardId is a valid dashboard slug; envId required when ref is slug. -->
|
||||
<!-- @UX_STATE Loading -> Select disabled, spinner. -->
|
||||
<!-- @UX_STATE Loaded -> Branch dropdown enabled. -->
|
||||
<!-- @UX_STATE CreateMode -> Inline input for new branch name. -->
|
||||
|
||||
<script>
|
||||
// [SECTION: IMPORTS]
|
||||
import { onMount } from 'svelte';
|
||||
import { gitService } from '../../services/gitService';
|
||||
import { addToast as toast } from '../../lib/toasts.js';
|
||||
import { t } from '../../lib/i18n';
|
||||
import { Button, Select, Input } from '../../lib/ui';
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: PROPS]
|
||||
let {
|
||||
dashboardId,
|
||||
envId = null,
|
||||
currentBranch = $bindable('main'),
|
||||
onchange = () => {},
|
||||
} = $props();
|
||||
// #region props [C:2] [TYPE Block]
|
||||
// @PRE dashboardId is non-empty slug; envId resolved via GitManager fallback chain.
|
||||
let {
|
||||
dashboardId,
|
||||
envId = null,
|
||||
currentBranch = $bindable('main'),
|
||||
onchange = () => {},
|
||||
} = $props();
|
||||
// #endregion props
|
||||
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: STATE]
|
||||
// #region state [C:1] [TYPE Block]
|
||||
let branches = $state([]);
|
||||
let loading = $state(false);
|
||||
let showCreate = $state(false);
|
||||
let newBranchName = $state('');
|
||||
// [/SECTION]
|
||||
// #region onMount:Function [TYPE Function]
|
||||
/**
|
||||
* @purpose Load branches when component is mounted.
|
||||
* @pre Component is initialized.
|
||||
* @post loadBranches is called.
|
||||
*/
|
||||
onMount(async () => {
|
||||
await loadBranches();
|
||||
});
|
||||
// #endregion onMount:Function
|
||||
// #endregion state
|
||||
|
||||
// #region loadBranches:Function [TYPE Function]
|
||||
/**
|
||||
* @purpose Загружает список веток для дашборда.
|
||||
* @pre dashboardId is provided.
|
||||
* @post branches обновлен.
|
||||
*/
|
||||
// #region loadBranches [C:2] [TYPE Function]
|
||||
// @BRIEF Load branch list from backend.
|
||||
// @PRE dashboardId is non-empty; envId required when ref is slug.
|
||||
// @POST branches array populated with BranchSchema objects.
|
||||
// @SIDE_EFFECT GETs /git/repositories/{ref}/branches.
|
||||
async function loadBranches() {
|
||||
console.log(`[EXT:frontend:BranchSelector][Action] Loading branches for dashboard ${dashboardId}`);
|
||||
loading = true;
|
||||
@@ -66,25 +52,22 @@
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
// #endregion loadBranches:Function
|
||||
// #endregion loadBranches
|
||||
|
||||
// #region handleSelect:Function [TYPE Function]
|
||||
/**
|
||||
* @purpose Handles branch selection from dropdown.
|
||||
* @pre event contains branch name.
|
||||
* @post handleCheckout is called with selected branch.
|
||||
*/
|
||||
// #region handleSelect [C:1] [TYPE Function]
|
||||
// @BRIEF Handle branch selection from dropdown.
|
||||
// @PRE event contains target.value with branch name.
|
||||
// @POST handleCheckout called with selected branch name.
|
||||
function handleSelect(event) {
|
||||
handleCheckout(event.target.value);
|
||||
}
|
||||
// #endregion handleSelect:Function
|
||||
// #endregion handleSelect
|
||||
|
||||
// #region handleCheckout:Function [TYPE Function]
|
||||
/**
|
||||
* @purpose Переключает текущую ветку.
|
||||
* @param {string} branchName - Имя ветки.
|
||||
* @post currentBranch обновлен, родительский callback вызван.
|
||||
*/
|
||||
// #region handleCheckout [C:2] [TYPE Function]
|
||||
// @BRIEF Switch repository to selected branch.
|
||||
// @PRE branchName is non-empty; envId required when ref is slug.
|
||||
// @POST currentBranch updated; onchange callback fired.
|
||||
// @SIDE_EFFECT POSTs to /git/repositories/{ref}/checkout.
|
||||
async function handleCheckout(branchName) {
|
||||
console.log(`[EXT:frontend:BranchSelector][Action] Checking out branch ${branchName}`);
|
||||
try {
|
||||
@@ -98,14 +81,13 @@
|
||||
toast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
// #endregion handleCheckout:Function
|
||||
// #endregion handleCheckout
|
||||
|
||||
// #region handleCreate:Function [TYPE Function]
|
||||
/**
|
||||
* @purpose Создает новую ветку.
|
||||
* @pre newBranchName is not empty.
|
||||
* @post Новая ветка создана и загружена; showCreate reset.
|
||||
*/
|
||||
// #region handleCreate [C:2] [TYPE Function]
|
||||
// @BRIEF Create a new branch from current branch.
|
||||
// @PRE newBranchName is non-empty; envId required when ref is slug.
|
||||
// @POST New branch created; branch list reloaded; create mode closed.
|
||||
// @SIDE_EFFECT POSTs to /git/repositories/{ref}/branches.
|
||||
async function handleCreate() {
|
||||
if (!newBranchName) return;
|
||||
console.log(`[EXT:frontend:BranchSelector][Action] Creating branch ${newBranchName} from ${currentBranch}`);
|
||||
@@ -121,7 +103,11 @@
|
||||
toast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
// #endregion handleCreate:Function
|
||||
// #endregion handleCreate
|
||||
|
||||
onMount(async () => {
|
||||
await loadBranches();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- [SECTION: TEMPLATE] -->
|
||||
|
||||
@@ -92,10 +92,13 @@
|
||||
const configHost = $derived(extractHttpHost(repositoryConfigUrl));
|
||||
const hasOriginConfigMismatch = $derived(originHost && configHost && originHost !== configHost);
|
||||
|
||||
// ── Resolve envId with localStorage fallback ──────────────
|
||||
let resolvedEnvId = $derived(resolveCurrentEnvironmentId(envId));
|
||||
|
||||
// ── State proxy for composable ────────────────────────────
|
||||
function getState() {
|
||||
return {
|
||||
dashboardId, envId, dashboardTitle, toast, t,
|
||||
dashboardId, envId: resolvedEnvId, dashboardTitle, toast, t,
|
||||
currentBranch, showDeployModal, loading, initialized, checkingStatus,
|
||||
configs, selectedConfigId, remoteUrl, creatingRemoteRepo,
|
||||
activeTab, showAdvancedPromote, promoting, promoteFromBranch, promoteToBranch, promoteMode, promoteReason,
|
||||
@@ -170,7 +173,7 @@
|
||||
await Promise.all([h.checkStatus(), h.loadCurrentEnvironmentStage()]);
|
||||
if (initialized) {
|
||||
try {
|
||||
const binding = await gitService.getRepositoryBinding(dashboardId, envId);
|
||||
const binding = await gitService.getRepositoryBinding(dashboardId, resolvedEnvId);
|
||||
repositoryProvider = binding?.provider || '';
|
||||
repositoryBindingRemoteUrl = binding?.remote_url || '';
|
||||
if (binding?.config_id) selectedConfigId = String(binding.config_id);
|
||||
@@ -203,7 +206,7 @@
|
||||
<span class={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold ${stageBadgeClass(currentEnvStage)}`}>{currentEnvStage || 'DEV'}</span>
|
||||
<span class="text-xs text-slate-600">Текущая ветка: <strong>{currentBranch}</strong></span>
|
||||
</div>
|
||||
<div class="w-full max-w-sm"><BranchSelector {dashboardId} {envId} bind:currentBranch /></div>
|
||||
<div class="w-full max-w-sm"><BranchSelector {dashboardId} envId={resolvedEnvId} bind:currentBranch /></div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 border-b border-slate-200 pb-2">
|
||||
<button class={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${activeTab === 'workspace' ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'}`} onclick={() => (activeTab = 'workspace')}>📝 Фиксация изменений</button>
|
||||
@@ -211,7 +214,7 @@
|
||||
<button class={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${activeTab === 'operations' ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'}`} onclick={() => (activeTab = 'operations')}>⚙️ Серверные операции</button>
|
||||
</div>
|
||||
{#if activeTab === 'workspace'}
|
||||
<GitWorkspacePanel {hasWorkspaceChanges} {changedFilesCount} {workspaceLoading} {committing} {generatingMessage} bind:commitMessage bind:autoPushAfterCommit {loading} {pushProviderLabel} onSync={h.handleSync} onGenerateMessage={h.handleGenerateMessage} onCommit={h.handleCommit} />
|
||||
<GitWorkspacePanel {hasWorkspaceChanges} {changedFilesCount} {workspaceLoading} {workspaceDiff} {committing} {generatingMessage} bind:commitMessage bind:autoPushAfterCommit {loading} {pushProviderLabel} onSync={h.handleSync} onGenerateMessage={h.handleGenerateMessage} onCommit={h.handleCommit} />
|
||||
{:else if activeTab === 'release'}
|
||||
<GitReleasePanel {currentEnvStage} bind:promoteFromBranch bind:promoteToBranch bind:promoteMode bind:promoteReason {preferredDeployTargetStage} bind:showAdvancedPromote {promoting} onPromote={h.handlePromote} />
|
||||
{:else}
|
||||
@@ -224,5 +227,5 @@
|
||||
</div>
|
||||
{/if}
|
||||
<ConflictResolver conflicts={mergeConflicts} bind:show={showConflictResolver} onresolve={h.handleResolveConflicts} />
|
||||
<DeploymentModal {dashboardId} {envId} preferredTargetStage={preferredDeployTargetStage} bind:show={showDeployModal} />
|
||||
<DeploymentModal {dashboardId} envId={resolvedEnvId} preferredTargetStage={preferredDeployTargetStage} bind:show={showDeployModal} />
|
||||
<!-- #endregion GitManager -->
|
||||
|
||||
@@ -3,17 +3,22 @@
|
||||
<!-- @LAYER UI -->
|
||||
<!-- @RELATION DEPENDS_ON -> [GitUtils] -->
|
||||
<!-- @RELATION CALLS -> [EXT:frontend:gitService] -->
|
||||
<!-- @UX_STATE Idle -> Commit form with diff viewer. -->
|
||||
<!-- @PRE hasWorkspaceChanges and workspaceDiff are propagated from GitManager. -->
|
||||
<!-- @UX_STATE Idle -> "Нет изменений" placeholder when !hasWorkspaceChanges. -->
|
||||
<!-- @UX_STATE Changes -> workspaceDiff rendered when hasWorkspaceChanges && workspaceDiff. -->
|
||||
<!-- @UX_STATE Loading -> GeneratingMessage spinner, workspace loading. -->
|
||||
<!-- @UX_STATE Error -> Toast on failure. -->
|
||||
<script>
|
||||
import { t } from "$lib/i18n";
|
||||
import { Button } from "$lib/ui";
|
||||
import * as Diff2Html from 'diff2html';
|
||||
import 'diff2html/bundles/css/diff2html.min.css';
|
||||
|
||||
let {
|
||||
hasWorkspaceChanges,
|
||||
changedFilesCount,
|
||||
workspaceLoading,
|
||||
workspaceDiff = '',
|
||||
committing,
|
||||
generatingMessage,
|
||||
commitMessage = $bindable(),
|
||||
@@ -24,6 +29,16 @@
|
||||
onGenerateMessage,
|
||||
onCommit,
|
||||
} = $props();
|
||||
|
||||
// ── Render diff as HTML via diff2html ──
|
||||
let diffHtml = $derived(workspaceDiff
|
||||
? Diff2Html.html(workspaceDiff, {
|
||||
outputFormat: 'side-by-side',
|
||||
drawFileList: true,
|
||||
matching: 'lines',
|
||||
highlight: true,
|
||||
})
|
||||
: '');
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
@@ -85,6 +100,10 @@
|
||||
<div class="h-4 animate-pulse rounded bg-slate-200"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if hasWorkspaceChanges && diffHtml}
|
||||
<div class="diff-view">{@html diffHtml}</div>
|
||||
{:else if hasWorkspaceChanges}
|
||||
<pre class="overflow-x-auto whitespace-pre-wrap font-mono text-xs leading-relaxed text-slate-500">Загрузка diff... (повторите синхронизацию)</pre>
|
||||
{:else}
|
||||
<div class="flex h-full items-center justify-center text-sm text-slate-500">
|
||||
Нет изменений для коммита
|
||||
|
||||
@@ -20,8 +20,8 @@ describe('GitManager unfinished merge dialog contract', () => {
|
||||
|
||||
expect(utils).toContain("error_code !== 'GIT_UNFINISHED_MERGE'");
|
||||
expect(handlers).toContain('showUnfinishedMergeDialog: true');
|
||||
expect(handlers).toContain('this.openUnfinishedMergeDialogFromError(e)');
|
||||
expect(handlers).toContain('await this.loadMergeRecoveryState();');
|
||||
expect(handlers).toContain('handlers.openUnfinishedMergeDialogFromError(e)');
|
||||
expect(handlers).toContain('await handlers.loadMergeRecoveryState();');
|
||||
expect(manager).toContain('h.handlePull');
|
||||
expect(manager).toContain('h.loadMergeRecoveryState');
|
||||
});
|
||||
|
||||
@@ -11,7 +11,14 @@ export function createGitHandlers(getState, setState) {
|
||||
const s = () => getState();
|
||||
const u = (patch) => setState(patch);
|
||||
|
||||
return {
|
||||
// ── Use const reference instead of `this` to avoid context loss in callbacks ──
|
||||
const handlers = {
|
||||
|
||||
// #region loadCurrentEnvironmentStage [C:2] [TYPE Function]
|
||||
// @BRIEF Resolve current environment stage (DEV/PREPROD/PROD) and apply GitFlow defaults.
|
||||
// @PRE resolveEnvId() returns a valid environment ID, or handler exits silently.
|
||||
// @POST currentEnvStage and GitFlow defaults (promoteFromBranch, promoteToBranch) are set.
|
||||
// @SIDE_EFFECT Fetches /environments from API.
|
||||
async loadCurrentEnvironmentStage() {
|
||||
const currentEnvId = s().resolveEnvId();
|
||||
if (!currentEnvId) return;
|
||||
@@ -27,7 +34,13 @@ export function createGitHandlers(getState, setState) {
|
||||
console.error(`[GitManager][Coherence:Failed] Failed to resolve environment stage: ${e.message}`);
|
||||
}
|
||||
},
|
||||
// #endregion loadCurrentEnvironmentStage
|
||||
|
||||
// #region checkStatus [C:3] [TYPE Function]
|
||||
// @BRIEF Check if Git repository is initialized for the dashboard.
|
||||
// @PRE dashboardId is a non-numeric slug; envId is required (resolved with localStorage fallback).
|
||||
// @POST initialized=true if repository found and workspace loaded; initialized=false if not found or envId missing.
|
||||
// @SIDE_EFFECT Fetches /git/repositories/{ref}/branches; may fetch /git/repositories/{ref}/status and /diff on success.
|
||||
async checkStatus() {
|
||||
if (isNumericDashboardRef(s().dashboardId)) {
|
||||
u({ checkingStatus: false, initialized: false });
|
||||
@@ -35,18 +48,25 @@ export function createGitHandlers(getState, setState) {
|
||||
return;
|
||||
}
|
||||
u({ checkingStatus: true });
|
||||
if (!s().envId) {
|
||||
u({ initialized: false, checkingStatus: false });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await gitService.getBranches(s().dashboardId, s().envId);
|
||||
u({ initialized: true });
|
||||
await this.loadWorkspace();
|
||||
await handlers.loadWorkspace();
|
||||
} catch (_e) {
|
||||
u({ initialized: false });
|
||||
try { const configs = await gitService.getConfigs(); u({ configs }); } catch (_e2) { u({ configs: [] }); }
|
||||
const defaultConfig = resolveDefaultConfig(s().configs, s().selectedConfigId);
|
||||
if (defaultConfig?.id) u({ selectedConfigId: defaultConfig.id });
|
||||
} finally { u({ checkingStatus: false }); }
|
||||
},
|
||||
// #endregion checkStatus
|
||||
|
||||
// #region loadWorkspace [C:2] [TYPE Function]
|
||||
// @BRIEF Load Git workspace status and diff for initialized repository.
|
||||
// @PRE initialized is true; envId is required when ref is slug.
|
||||
// @POST workspaceStatus and workspaceDiff are updated; currentBranch synced from server.
|
||||
// @SIDE_EFFECT Fetches /status and /diff for the dashboard repository.
|
||||
async loadWorkspace() {
|
||||
if (!s().initialized) return;
|
||||
u({ workspaceLoading: true });
|
||||
@@ -59,18 +79,29 @@ export function createGitHandlers(getState, setState) {
|
||||
s().toast(e.message || 'Не удалось загрузить изменения', 'error');
|
||||
} finally { u({ workspaceLoading: false }); }
|
||||
},
|
||||
// #endregion loadWorkspace
|
||||
|
||||
// #region handleSync [C:2] [TYPE Function]
|
||||
// @BRIEF Synchronize local dashboard state with Git repository.
|
||||
// @PRE dashboardId is a non-numeric slug; envId required when ref is slug.
|
||||
// @POST Dashboard state synced to Git workspace; workspace reloaded.
|
||||
// @SIDE_EFFECT POSTs to /git/repositories/{ref}/sync; reloads workspace on success.
|
||||
async handleSync() {
|
||||
if (isNumericDashboardRef(s().dashboardId)) { s().toast('GitManager requires dashboard slug. Numeric ID is forbidden.', 'error'); return; }
|
||||
u({ loading: true });
|
||||
try {
|
||||
const sourceEnvId = localStorage.getItem('selected_env_id');
|
||||
const sourceEnvId = s().envId || localStorage.getItem('selected_env_id');
|
||||
await gitService.sync(s().dashboardId, sourceEnvId, s().envId);
|
||||
s().toast(s().t?.git?.sync_success || 'Состояние дашборда синхронизировано с Git', 'success');
|
||||
await this.loadWorkspace();
|
||||
await handlers.loadWorkspace();
|
||||
} catch (e) { s().toast(e.message, 'error'); } finally { u({ loading: false }); }
|
||||
},
|
||||
// #endregion handleSync
|
||||
|
||||
// #region handleGenerateMessage [C:2] [TYPE Function]
|
||||
// @BRIEF Generate AI commit message from dashboard diff.
|
||||
// @SIDE_EFFECT POSTs to /git/repositories/{ref}/generate-message.
|
||||
// @POST commitMessage state updated with generated text.
|
||||
async handleGenerateMessage() {
|
||||
u({ generatingMessage: true });
|
||||
try {
|
||||
@@ -79,7 +110,13 @@ export function createGitHandlers(getState, setState) {
|
||||
s().toast(s().t?.git?.commit_message_generated || 'Сообщение для коммита сгенерировано', 'success');
|
||||
} catch (e) { s().toast(e.message || s().t?.git?.commit_message_failed || 'Не удалось сгенерировать сообщение', 'error'); } finally { u({ generatingMessage: false }); }
|
||||
},
|
||||
// #endregion handleGenerateMessage
|
||||
|
||||
// #region handleCommit [C:2] [TYPE Function]
|
||||
// @BRIEF Stage and commit workspace changes, optionally push.
|
||||
// @PRE commitMessage is non-empty; hasWorkspaceChanges is true; envId required when ref is slug.
|
||||
// @POST Changes committed (and pushed if autoPushAfterCommit); workspace reloaded.
|
||||
// @SIDE_EFFECT POSTs to /commit and optionally /push.
|
||||
async handleCommit() {
|
||||
if (!s().commitMessage || !s().hasWorkspaceChanges) return;
|
||||
u({ committing: true });
|
||||
@@ -90,22 +127,33 @@ export function createGitHandlers(getState, setState) {
|
||||
s().toast(s().t?.git?.commit_and_push_success || 'Коммит создан и отправлен в remote', 'success');
|
||||
} else { s().toast(s().t?.git?.commit_success || 'Коммит успешно создан', 'success'); }
|
||||
u({ commitMessage: '' });
|
||||
await this.loadWorkspace();
|
||||
await handlers.loadWorkspace();
|
||||
} catch (e) { s().toast(e.message, 'error'); } finally { u({ committing: false }); }
|
||||
},
|
||||
// #endregion handleCommit
|
||||
|
||||
// #region openUnfinishedMergeDialogFromError [C:2] [TYPE Function]
|
||||
// @BRIEF Parse error for unfinished merge context and open dialog.
|
||||
// @PRE error contains GIT_UNFINISHED_MERGE error_code or related detail.
|
||||
// @POST Returns true and opens dialog if merge context found; false otherwise.
|
||||
openUnfinishedMergeDialogFromError(error) {
|
||||
const context = extractUnfinishedMergeContext(error);
|
||||
if (!context) return false;
|
||||
u({ unfinishedMergeContext: context, showUnfinishedMergeDialog: true });
|
||||
return true;
|
||||
},
|
||||
// #endregion openUnfinishedMergeDialogFromError
|
||||
|
||||
// #region loadMergeRecoveryState [C:2] [TYPE Function]
|
||||
// @BRIEF Load merge recovery state from backend for unfinished merge.
|
||||
// @PRE Repository exists; envId required when ref is slug.
|
||||
// @POST unfinishedMergeContext is populated; dialog closed if no merge in progress.
|
||||
// @SIDE_EFFECT GETs /merge/status from backend.
|
||||
async loadMergeRecoveryState() {
|
||||
u({ mergeRecoveryLoading: true });
|
||||
try {
|
||||
const status = await gitService.getMergeStatus(s().dashboardId, s().envId);
|
||||
if (!status?.has_unfinished_merge) { this.closeUnfinishedMergeDialog(); return; }
|
||||
if (!status?.has_unfinished_merge) { handlers.closeUnfinishedMergeDialog(); return; }
|
||||
u({
|
||||
unfinishedMergeContext: {
|
||||
...(s().unfinishedMergeContext || {}),
|
||||
@@ -122,11 +170,21 @@ export function createGitHandlers(getState, setState) {
|
||||
});
|
||||
} catch (e) { s().toast(e.message || 'Failed to load merge status', 'error'); } finally { u({ mergeRecoveryLoading: false }); }
|
||||
},
|
||||
// #endregion loadMergeRecoveryState
|
||||
|
||||
// #region closeUnfinishedMergeDialog [C:1] [TYPE Function]
|
||||
// @BRIEF Close unfinished merge dialog and reset merge state.
|
||||
// @POST Dialog hidden; mergeConflicts, showConflictResolver reset.
|
||||
closeUnfinishedMergeDialog() {
|
||||
u({ showUnfinishedMergeDialog: false, unfinishedMergeContext: null, mergeConflicts: [], showConflictResolver: false });
|
||||
},
|
||||
// #endregion closeUnfinishedMergeDialog
|
||||
|
||||
// #region handleOpenConflictResolver [C:2] [TYPE Function]
|
||||
// @BRIEF Load merge conflicts and open conflict resolver UI.
|
||||
// @PRE Unfinished merge is in progress; envId required when ref is slug.
|
||||
// @POST mergeConflicts populated; showConflictResolver set to true if conflicts found.
|
||||
// @SIDE_EFFECT GETs /merge/conflicts from backend.
|
||||
async handleOpenConflictResolver() {
|
||||
u({ mergeRecoveryLoading: true });
|
||||
try {
|
||||
@@ -135,7 +193,12 @@ export function createGitHandlers(getState, setState) {
|
||||
u({ mergeConflicts: mc, showConflictResolver: true });
|
||||
} catch (e) { s().toast(e.message || 'Failed to load merge conflicts', 'error'); } finally { u({ mergeRecoveryLoading: false }); }
|
||||
},
|
||||
// #endregion handleOpenConflictResolver
|
||||
|
||||
// #region handleResolveConflicts [C:2] [TYPE Function]
|
||||
// @BRIEF Apply conflict resolutions and stage resolved files.
|
||||
// @PRE event.detail maps file paths to resolution strategies; envId required when ref is slug.
|
||||
// @POST Conflicts resolved and staged; resolver closed; workspace reloaded.
|
||||
async handleResolveConflicts(event) {
|
||||
const detail = event?.detail || {};
|
||||
const resolutions = Object.entries(detail).map(([fp, r]) => ({ file_path: fp, resolution: r }));
|
||||
@@ -145,41 +208,60 @@ export function createGitHandlers(getState, setState) {
|
||||
await gitService.resolveMergeConflicts(s().dashboardId, resolutions, s().envId);
|
||||
s().toast(s().t?.git?.unfinished_merge?.resolve_success || 'Conflicts were resolved and staged', 'success');
|
||||
u({ showConflictResolver: false });
|
||||
await this.loadMergeRecoveryState();
|
||||
await this.loadWorkspace();
|
||||
await handlers.loadMergeRecoveryState();
|
||||
await handlers.loadWorkspace();
|
||||
} catch (e) { s().toast(e.message || 'Failed to resolve conflicts', 'error'); } finally { u({ mergeResolveInProgress: false }); }
|
||||
},
|
||||
// #endregion handleResolveConflicts
|
||||
|
||||
// #region handleAbortUnfinishedMerge [C:2] [TYPE Function]
|
||||
// @BRIEF Abort current unfinished merge.
|
||||
// @PRE envId required when ref is slug.
|
||||
// @POST Merge aborted; dialog closed; workspace reloaded.
|
||||
async handleAbortUnfinishedMerge() {
|
||||
u({ mergeAbortInProgress: true });
|
||||
try {
|
||||
await gitService.abortMerge(s().dashboardId, s().envId);
|
||||
s().toast(s().t?.git?.unfinished_merge?.abort_success || 'Merge was aborted', 'success');
|
||||
this.closeUnfinishedMergeDialog();
|
||||
await this.loadWorkspace();
|
||||
handlers.closeUnfinishedMergeDialog();
|
||||
await handlers.loadWorkspace();
|
||||
} catch (e) { s().toast(e.message || 'Failed to abort merge', 'error'); } finally { u({ mergeAbortInProgress: false }); }
|
||||
},
|
||||
// #endregion handleAbortUnfinishedMerge
|
||||
|
||||
// #region handleContinueUnfinishedMerge [C:2] [TYPE Function]
|
||||
// @BRIEF Finalize unfinished merge by creating merge commit.
|
||||
// @PRE All conflicts resolved; envId required when ref is slug.
|
||||
// @POST Merge commit created; dialog closed; workspace reloaded.
|
||||
async handleContinueUnfinishedMerge() {
|
||||
u({ mergeContinueInProgress: true });
|
||||
try {
|
||||
await gitService.continueMerge(s().dashboardId, '', s().envId);
|
||||
s().toast(s().t?.git?.unfinished_merge?.continue_success || 'Merge commit created successfully', 'success');
|
||||
this.closeUnfinishedMergeDialog();
|
||||
await this.loadWorkspace();
|
||||
handlers.closeUnfinishedMergeDialog();
|
||||
await handlers.loadWorkspace();
|
||||
} catch (e) {
|
||||
s().toast(e.message || 'Failed to continue merge', 'error');
|
||||
await this.loadMergeRecoveryState();
|
||||
await handlers.loadMergeRecoveryState();
|
||||
} finally { u({ mergeContinueInProgress: false }); }
|
||||
},
|
||||
// #endregion handleContinueUnfinishedMerge
|
||||
|
||||
// #region getUnfinishedMergeCommandsText [C:1] [TYPE Function]
|
||||
// @BRIEF Get shell commands for manual merge recovery.
|
||||
// @POST Returns concatenated command string or empty string.
|
||||
getUnfinishedMergeCommandsText() {
|
||||
if (!s().unfinishedMergeContext?.commands?.length) return '';
|
||||
return s().unfinishedMergeContext.commands.join('\n');
|
||||
},
|
||||
// #endregion getUnfinishedMergeCommandsText
|
||||
|
||||
// #region handleCopyUnfinishedMergeCommands [C:1] [TYPE Function]
|
||||
// @BRIEF Copy merge recovery commands to clipboard.
|
||||
// @PRE commands exist in unfinishedMergeContext.
|
||||
// @POST Commands copied to clipboard; toast shown.
|
||||
async handleCopyUnfinishedMergeCommands() {
|
||||
const text = this.getUnfinishedMergeCommandsText();
|
||||
const text = handlers.getUnfinishedMergeCommandsText();
|
||||
if (!text) { s().toast(s().t?.git?.unfinished_merge?.copy_empty || 'Команды для копирования отсутствуют', 'warning'); return; }
|
||||
u({ copyingUnfinishedMergeCommands: true });
|
||||
try {
|
||||
@@ -187,29 +269,46 @@ export function createGitHandlers(getState, setState) {
|
||||
s().toast(s().t?.git?.unfinished_merge?.copy_success || 'Команды скопированы в буфер обмена', 'success');
|
||||
} catch (_e) { s().toast(s().t?.git?.unfinished_merge?.copy_failed || 'Не удалось скопировать команды', 'error'); } finally { u({ copyingUnfinishedMergeCommands: false }); }
|
||||
},
|
||||
// #endregion handleCopyUnfinishedMergeCommands
|
||||
|
||||
// #region handlePull [C:2] [TYPE Function]
|
||||
// @BRIEF Pull changes from remote repository.
|
||||
// @PRE Remote 'origin' configured; envId required when ref is slug.
|
||||
// @POST Local repository updated with remote changes; workspace reloaded.
|
||||
// @SIDE_EFFECT POSTs to /pull; may open merge dialog on GIT_UNFINISHED_MERGE.
|
||||
async handlePull() {
|
||||
u({ isPulling: true });
|
||||
try {
|
||||
await gitService.pull(s().dashboardId, s().envId);
|
||||
s().toast(s().t?.git?.pull_success || 'Изменения получены из Git', 'success');
|
||||
await this.loadWorkspace();
|
||||
await handlers.loadWorkspace();
|
||||
} catch (e) {
|
||||
const handled = this.openUnfinishedMergeDialogFromError(e);
|
||||
if (handled) await this.loadMergeRecoveryState();
|
||||
const handled = handlers.openUnfinishedMergeDialogFromError(e);
|
||||
if (handled) await handlers.loadMergeRecoveryState();
|
||||
else s().toast(e.message, 'error');
|
||||
} finally { u({ isPulling: false }); }
|
||||
},
|
||||
// #endregion handlePull
|
||||
|
||||
// #region handlePush [C:2] [TYPE Function]
|
||||
// @BRIEF Push local commits to remote repository.
|
||||
// @PRE Remote 'origin' configured; envId required when ref is slug.
|
||||
// @POST Remote updated with local commits; workspace reloaded.
|
||||
async handlePush() {
|
||||
u({ isPushing: true });
|
||||
try {
|
||||
await gitService.push(s().dashboardId, s().envId);
|
||||
s().toast(s().t?.git?.push_success || 'Изменения отправлены в Git', 'success');
|
||||
await this.loadWorkspace();
|
||||
await handlers.loadWorkspace();
|
||||
} catch (e) { s().toast(e.message, 'error'); } finally { u({ isPushing: false }); }
|
||||
},
|
||||
// #endregion handlePush
|
||||
|
||||
// #region handlePromote [C:3] [TYPE Function]
|
||||
// @BRIEF Promote changes between branches via MR or direct merge.
|
||||
// @PRE promoteFromBranch and promoteToBranch differ; direct mode requires reason; envId required when ref is slug.
|
||||
// @POST MR created on Git server (MR mode) or direct merge executed (direct mode).
|
||||
// @SIDE_EFFECT Opens MR URL in new tab (MR mode); writes policy violation to logs (direct mode).
|
||||
async handlePromote() {
|
||||
const st = s();
|
||||
if (!st.promoteFromBranch || !st.promoteToBranch || st.promoteFromBranch === st.promoteToBranch) { st.toast('Выберите разные исходную и целевую ветки', 'error'); return; }
|
||||
@@ -226,7 +325,12 @@ export function createGitHandlers(getState, setState) {
|
||||
else { if (response?.url) window.open(response.url, '_blank', 'noopener,noreferrer'); st.toast('Merge Request создан на Git сервере', 'success'); }
|
||||
} catch (e) { st.toast(e.message, 'error'); } finally { u({ promoting: false }); }
|
||||
},
|
||||
// #endregion handlePromote
|
||||
|
||||
// #region openDeployModal [C:2] [TYPE Function]
|
||||
// @BRIEF Open deployment target modal with PROD confirmation.
|
||||
// @PRE dashboardId is a valid slug.
|
||||
// @POST showDeployModal set to true if PROD confirmation passes.
|
||||
openDeployModal() {
|
||||
const st = s();
|
||||
if (st.currentEnvStage === 'PROD') {
|
||||
@@ -236,7 +340,13 @@ export function createGitHandlers(getState, setState) {
|
||||
}
|
||||
u({ showDeployModal: true });
|
||||
},
|
||||
// #endregion openDeployModal
|
||||
|
||||
// #region handleCreateRemoteRepo [C:2] [TYPE Function]
|
||||
// @BRIEF Create a remote repository on the selected Git provider.
|
||||
// @PRE A Git config is selected; user provides a repo name via prompt.
|
||||
// @POST remoteUrl set to created (or existing) repository clone URL.
|
||||
// @SIDE_EFFECT POSTs to /git/config/{id}/repositories; prompts user for repo name.
|
||||
async handleCreateRemoteRepo() {
|
||||
const config = resolveDefaultConfig(s().configs, s().selectedConfigId);
|
||||
if (!config) { s().toast(s().t?.git?.init_validation_error || 'Сначала выберите Git сервер', 'error'); return; }
|
||||
@@ -256,20 +366,37 @@ export function createGitHandlers(getState, setState) {
|
||||
if (!url) throw new Error('Remote repository created, but URL is empty');
|
||||
u({ remoteUrl: url });
|
||||
s().toast(`Repository created on ${config.provider}`, 'success');
|
||||
} catch (e) { s().toast(e.message, 'error'); } finally { u({ creatingRemoteRepo: false }); }
|
||||
} catch (e) {
|
||||
if (e?.status === 409 && /already exists/i.test(String(e?.message || ''))) {
|
||||
s().toast(s().t?.git?.repo_already_exists || 'Repository already exists. Enter its URL below and click Init.', 'warning');
|
||||
} else { s().toast(e.message, 'error'); }
|
||||
} finally { u({ creatingRemoteRepo: false }); }
|
||||
},
|
||||
// #endregion handleCreateRemoteRepo
|
||||
|
||||
// #region handleInit [C:3] [TYPE Function]
|
||||
// @BRIEF Initialize local Git repository for the dashboard: clone remote.
|
||||
// @PRE selectedConfigId is set; remoteUrl is set; envId required when ref is slug.
|
||||
// @POST Repository initialized; initialized=true; workspace loaded.
|
||||
// @SIDE_EFFECT POSTs to /git/repositories/{ref}/init; loads workspace on success.
|
||||
async handleInit() {
|
||||
if (!s().selectedConfigId || !s().remoteUrl) { s().toast(s().t?.git?.init_validation_error || 'Заполните все поля', 'error'); return; }
|
||||
// ── Guard: slug-based init requires envId ──
|
||||
if (!s().envId && !isNumericDashboardRef(s().dashboardId)) {
|
||||
s().toast('Environment must be selected to initialize Git for this dashboard.', 'error');
|
||||
return;
|
||||
}
|
||||
u({ loading: true });
|
||||
try {
|
||||
await gitService.initRepository(s().dashboardId, s().selectedConfigId, s().remoteUrl, s().envId);
|
||||
s().toast(s().t?.git?.init_success || 'Репозиторий инициализирован', 'success');
|
||||
const provider = resolveDefaultConfig(s().configs, s().selectedConfigId)?.provider || '';
|
||||
u({ initialized: true, repositoryProvider: provider });
|
||||
await this.loadWorkspace();
|
||||
await handlers.loadWorkspace();
|
||||
} catch (e) { s().toast(e.message, 'error'); } finally { u({ loading: false }); }
|
||||
},
|
||||
// #endregion handleInit
|
||||
};
|
||||
return handlers;
|
||||
}
|
||||
// #endregion UseGitManager
|
||||
|
||||
@@ -34,7 +34,11 @@ function shouldSuppressApiErrorToast(endpoint, error) {
|
||||
const isUnfinishedMergeError = error?.status === 409 && (String(error?.error_code || '') === 'GIT_UNFINISHED_MERGE' || String(error?.detail?.error_code || '') === 'GIT_UNFINISHED_MERGE');
|
||||
const isDatasetClarificationEndpoint = typeof endpoint === 'string' && /\/dataset-orchestration\/sessions\/[^/]+\/clarification$/.test(endpoint);
|
||||
const isMissingClarificationSession = error?.status === 404 && /Clarification session not found/i.test(String(error?.message || ''));
|
||||
return (isGitStatusEndpoint && isNoRepoError) || (isGitPullEndpoint && isUnfinishedMergeError) || (isDatasetClarificationEndpoint && isMissingClarificationSession);
|
||||
const isGitRepoEndpoint = typeof endpoint === 'string' && endpoint.startsWith('/git/repositories/');
|
||||
const isMissingEnvIdError = error?.status === 400 && /env_id is required/i.test(String(error?.message || ''));
|
||||
const isGitConfigReposEndpoint = typeof endpoint === 'string' && /^\/git\/config\/[^/]+\/repositories$/.test(endpoint);
|
||||
const isRepoAlreadyExistsError = error?.status === 409 && /already exists/i.test(String(error?.message || ''));
|
||||
return (isGitStatusEndpoint && isNoRepoError) || (isGitPullEndpoint && isUnfinishedMergeError) || (isDatasetClarificationEndpoint && isMissingClarificationSession) || (isGitRepoEndpoint && isMissingEnvIdError) || (isGitConfigReposEndpoint && isRepoAlreadyExistsError);
|
||||
}
|
||||
|
||||
export const getWsUrl = (taskId) => {
|
||||
|
||||
214
frontend/src/lib/components/ui/DatabaseSearchCombobox.svelte
Normal file
214
frontend/src/lib/components/ui/DatabaseSearchCombobox.svelte
Normal file
@@ -0,0 +1,214 @@
|
||||
<!-- #region DatabaseSearchCombobox [C:3] [TYPE Component] [SEMANTICS ui, database, search, combobox, single-select] -->
|
||||
<!-- @BRIEF Searchable single-select combobox for Superset databases. Shows name and engine. -->
|
||||
<!-- @UX_STATE default -> Input with dropdown hidden, selected db name shown if selected -->
|
||||
<!-- @UX_STATE dropdown_open -> Dropdown visible with filtered database options -->
|
||||
<!-- @UX_STATE empty -> No databases match the search filter -->
|
||||
<!-- @UX_STATE loading -> Spinner in input while fetching databases -->
|
||||
<!-- @UX_STATE error -> Toast shown on API failure -->
|
||||
<!-- @UX_FEEDBACK Toast on API fetch error -->
|
||||
<!-- @UX_REACTIVITY envId -> resets databases on change, triggers refetch; Props -> $props(); LocalState -> $state(...) -->
|
||||
<!-- @RELATION DEPENDS_ON -> [EXT:frontend:api] -->
|
||||
<!-- @RELATION CALLED_BY -> [MapperTool] -->
|
||||
<script>
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/toasts.js';
|
||||
|
||||
/**
|
||||
* Searchable single-select combobox for Superset databases.
|
||||
* @prop {string} envId - Environment ID for API calls
|
||||
* @prop {string} value - Bindable selected database ID
|
||||
* @prop {string} label - Label text above input
|
||||
* @prop {string} placeholder - Placeholder for search input
|
||||
* @prop {boolean} disabled - Disable the input
|
||||
*/
|
||||
let {
|
||||
envId = '',
|
||||
value = $bindable(''),
|
||||
label = '',
|
||||
placeholder = 'Search databases...',
|
||||
disabled = false,
|
||||
} = $props();
|
||||
|
||||
let searchQuery = $state('');
|
||||
let isOpen = $state(false);
|
||||
let databases = $state([]);
|
||||
let isLoading = $state(false);
|
||||
let containerEl = $state(null);
|
||||
let selectedDb = $state(null);
|
||||
|
||||
// Find the currently selected database
|
||||
$effect(() => {
|
||||
if (value && databases.length > 0) {
|
||||
const found = databases.find(d => String(d.id) === String(value));
|
||||
if (found) {
|
||||
selectedDb = found;
|
||||
searchQuery = found.database_name;
|
||||
}
|
||||
} else if (!value) {
|
||||
selectedDb = null;
|
||||
if (!isOpen) searchQuery = '';
|
||||
}
|
||||
});
|
||||
|
||||
function handleInput(e) {
|
||||
searchQuery = e.target.value;
|
||||
if (!isOpen) isOpen = true;
|
||||
// Filtering is handled by $derived(filteredDatabases)
|
||||
}
|
||||
|
||||
async function fetchDatabases() {
|
||||
if (!envId) return;
|
||||
isLoading = true;
|
||||
try {
|
||||
const result = await api.getEnvironmentDatabases(envId);
|
||||
databases = result || [];
|
||||
if (value) {
|
||||
selectedDb = databases.find(d => String(d.id) === String(value)) || selectedDb;
|
||||
if (selectedDb) searchQuery = selectedDb.database_name;
|
||||
}
|
||||
} catch (e) {
|
||||
databases = [];
|
||||
addToast(e.message || 'Failed to load databases', 'error');
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
if (!disabled) {
|
||||
isOpen = true;
|
||||
if (databases.length === 0 && envId) {
|
||||
fetchDatabases();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function selectDatabase(db) {
|
||||
value = String(db.id);
|
||||
selectedDb = db;
|
||||
searchQuery = db.database_name;
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
value = '';
|
||||
selectedDb = null;
|
||||
searchQuery = '';
|
||||
isOpen = true;
|
||||
if (envId) fetchDatabases();
|
||||
}
|
||||
|
||||
function handleClickOutside(e) {
|
||||
if (containerEl && !containerEl.contains(e.target)) {
|
||||
isOpen = false;
|
||||
if (selectedDb && !isOpen) {
|
||||
searchQuery = selectedDb.database_name;
|
||||
} else if (!selectedDb && !isOpen) {
|
||||
searchQuery = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}
|
||||
});
|
||||
|
||||
// Reset when envId changes — clear cache so handleFocus triggers refetch
|
||||
$effect(() => {
|
||||
if (envId) {
|
||||
databases = [];
|
||||
selectedDb = null;
|
||||
if (!value) searchQuery = '';
|
||||
if (isOpen) fetchDatabases();
|
||||
}
|
||||
});
|
||||
|
||||
let filteredDatabases = $derived(
|
||||
searchQuery
|
||||
? databases.filter(
|
||||
(db) =>
|
||||
db.database_name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(db.engine && db.engine.toLowerCase().includes(searchQuery.toLowerCase())),
|
||||
)
|
||||
: databases,
|
||||
);
|
||||
</script>
|
||||
|
||||
<div bind:this={containerEl} class="relative">
|
||||
{#if label}
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{label}</label>
|
||||
{/if}
|
||||
|
||||
<!-- Search Input -->
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
oninput={handleInput}
|
||||
onfocus={handleFocus}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
class="w-full px-3 py-2 pr-8 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring {disabled ? 'bg-gray-50 cursor-not-allowed' : ''}"
|
||||
role="combobox"
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
/>
|
||||
{#if isLoading}
|
||||
<div class="absolute right-2.5 top-2.5 animate-spin h-4 w-4 border-2 border-blue-500 border-t-transparent rounded-full"></div>
|
||||
{:else if value && !isOpen}
|
||||
<button
|
||||
onclick={clearSelection}
|
||||
class="absolute right-2.5 top-2.5 text-gray-400 hover:text-gray-600"
|
||||
aria-label="Clear selection"
|
||||
type="button"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
{:else if !isOpen}
|
||||
<div class="absolute right-2.5 top-2.5 text-gray-400">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Dropdown -->
|
||||
{#if isOpen}
|
||||
<div class="absolute z-50 mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg" role="listbox">
|
||||
<div class="max-h-60 overflow-y-auto">
|
||||
{#if isLoading && databases.length === 0}
|
||||
<div class="px-3 py-6 text-center text-sm text-gray-400">
|
||||
<div class="inline-block animate-spin h-5 w-5 border-2 border-blue-500 border-t-transparent rounded-full mb-2"></div>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
{:else if filteredDatabases.length === 0}
|
||||
<div class="px-3 py-6 text-center text-sm text-gray-400 italic">
|
||||
{searchQuery ? 'No databases match your search' : 'No databases available'}
|
||||
</div>
|
||||
{:else}
|
||||
{#each filteredDatabases as db (db.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectDatabase(db)}
|
||||
class="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-blue-50 border-b border-gray-100 last:border-b-0 transition-colors {String(db.id) === String(value) ? 'bg-blue-50' : ''}"
|
||||
role="option"
|
||||
aria-selected={String(db.id) === String(value)}
|
||||
>
|
||||
<span class="font-medium text-gray-900">{db.database_name}</span>
|
||||
{#if db.engine}
|
||||
<span class="ml-auto text-xs text-gray-400">{db.engine}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- #endregion DatabaseSearchCombobox -->
|
||||
204
frontend/src/lib/components/ui/DatasetSearchCombobox.svelte
Normal file
204
frontend/src/lib/components/ui/DatasetSearchCombobox.svelte
Normal file
@@ -0,0 +1,204 @@
|
||||
<!-- #region DatasetSearchCombobox [C:3] [TYPE Component] [SEMANTICS ui, dataset, search, combobox, single-select] -->
|
||||
<!-- @BRIEF Searchable single-select combobox for datasets. Shows name, schema, and database. -->
|
||||
<!-- @UX_STATE default -> Input with dropdown hidden, selected dataset name shown if selected -->
|
||||
<!-- @UX_STATE dropdown_open -> Dropdown visible with filtered dataset options -->
|
||||
<!-- @UX_STATE empty -> No datasets match the search filter -->
|
||||
<!-- @UX_STATE loading -> Spinner in input while fetching datasets from API -->
|
||||
<!-- @UX_STATE error -> Toast shown on API failure, dropdown shows error message -->
|
||||
<!-- @UX_FEEDBACK Toast on API fetch error -->
|
||||
<!-- @UX_REACTIVITY envId -> resets datasets on change, triggers refetch; Props -> $props(); LocalState -> $state(...) -->
|
||||
<!-- @RELATION DEPENDS_ON -> [EXT:frontend:api] -->
|
||||
<!-- @RELATION CALLED_BY -> [MapperTool] -->
|
||||
<script>
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/toasts.js';
|
||||
|
||||
/**
|
||||
* Searchable single-select combobox for datasets.
|
||||
* @prop {string} envId - Environment ID for API calls
|
||||
* @prop {string} value - Bindable selected dataset ID
|
||||
* @prop {string} label - Label text above input
|
||||
* @prop {string} placeholder - Placeholder for search input
|
||||
* @prop {boolean} disabled - Disable the input
|
||||
*/
|
||||
let {
|
||||
envId = '',
|
||||
value = $bindable(''),
|
||||
label = '',
|
||||
placeholder = 'Search datasets...',
|
||||
disabled = false,
|
||||
} = $props();
|
||||
|
||||
let searchQuery = $state('');
|
||||
let isOpen = $state(false);
|
||||
let datasets = $state([]);
|
||||
let isLoading = $state(false);
|
||||
let containerEl = $state(null);
|
||||
let selectedDataset = $state(null);
|
||||
|
||||
// Find the currently selected dataset from the loaded list
|
||||
$effect(() => {
|
||||
if (value && datasets.length > 0) {
|
||||
const found = datasets.find(d => String(d.id) === String(value));
|
||||
if (found) {
|
||||
selectedDataset = found;
|
||||
searchQuery = found.table_name;
|
||||
}
|
||||
} else if (!value) {
|
||||
selectedDataset = null;
|
||||
if (!isOpen) searchQuery = '';
|
||||
}
|
||||
});
|
||||
|
||||
async function handleInput(e) {
|
||||
searchQuery = e.target.value;
|
||||
if (!isOpen) isOpen = true;
|
||||
await fetchDatasets(searchQuery);
|
||||
}
|
||||
|
||||
async function fetchDatasets(search) {
|
||||
if (!envId) return;
|
||||
isLoading = true;
|
||||
try {
|
||||
const result = await api.getDatasets(envId, { search: search || undefined, page_size: 50 });
|
||||
datasets = result?.datasets || result || [];
|
||||
// Re-apply selection highlight
|
||||
if (value) {
|
||||
selectedDataset = datasets.find(d => String(d.id) === String(value)) || selectedDataset;
|
||||
}
|
||||
} catch (e) {
|
||||
datasets = [];
|
||||
addToast(e.message || 'Failed to load datasets', 'error');
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
if (!disabled) {
|
||||
isOpen = true;
|
||||
if (datasets.length === 0 && envId) {
|
||||
fetchDatasets(searchQuery);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function selectDataset(dataset) {
|
||||
value = String(dataset.id);
|
||||
selectedDataset = dataset;
|
||||
searchQuery = dataset.table_name;
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
value = '';
|
||||
selectedDataset = null;
|
||||
searchQuery = '';
|
||||
isOpen = true;
|
||||
if (envId) fetchDatasets('');
|
||||
}
|
||||
|
||||
function handleClickOutside(e) {
|
||||
if (containerEl && !containerEl.contains(e.target)) {
|
||||
isOpen = false;
|
||||
// Restore search query to selected dataset name if any
|
||||
if (selectedDataset && !isOpen) {
|
||||
searchQuery = selectedDataset.table_name;
|
||||
} else if (!selectedDataset && !isOpen) {
|
||||
searchQuery = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}
|
||||
});
|
||||
|
||||
// Reset when envId changes — clear cache so handleFocus triggers refetch
|
||||
$effect(() => {
|
||||
if (envId) {
|
||||
datasets = [];
|
||||
selectedDataset = null;
|
||||
if (!value) searchQuery = '';
|
||||
if (isOpen) fetchDatasets(searchQuery);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={containerEl} class="relative">
|
||||
{#if label}
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{label}</label>
|
||||
{/if}
|
||||
|
||||
<!-- Search Input -->
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
oninput={handleInput}
|
||||
onfocus={handleFocus}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
class="w-full px-3 py-2 pr-8 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring {disabled ? 'bg-gray-50 cursor-not-allowed' : ''}"
|
||||
role="combobox"
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
/>
|
||||
{#if isLoading}
|
||||
<div class="absolute right-2.5 top-2.5 animate-spin h-4 w-4 border-2 border-blue-500 border-t-transparent rounded-full"></div>
|
||||
{:else if value && !isOpen}
|
||||
<button
|
||||
onclick={clearSelection}
|
||||
class="absolute right-2.5 top-2.5 text-gray-400 hover:text-gray-600"
|
||||
aria-label="Clear selection"
|
||||
type="button"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
{:else if !isOpen}
|
||||
<div class="absolute right-2.5 top-2.5 text-gray-400">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Dropdown -->
|
||||
{#if isOpen}
|
||||
<div class="absolute z-50 mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg" role="listbox">
|
||||
<div class="max-h-60 overflow-y-auto">
|
||||
{#if isLoading && datasets.length === 0}
|
||||
<div class="px-3 py-6 text-center text-sm text-gray-400">
|
||||
<div class="inline-block animate-spin h-5 w-5 border-2 border-blue-500 border-t-transparent rounded-full mb-2"></div>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
{:else if datasets.length === 0}
|
||||
<div class="px-3 py-6 text-center text-sm text-gray-400 italic">
|
||||
{searchQuery ? 'No datasets match your search' : 'No datasets available'}
|
||||
</div>
|
||||
{:else}
|
||||
{#each datasets as ds (ds.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectDataset(ds)}
|
||||
class="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-blue-50 border-b border-gray-100 last:border-b-0 transition-colors {String(ds.id) === String(value) ? 'bg-blue-50' : ''}"
|
||||
role="option"
|
||||
aria-selected={String(ds.id) === String(value)}
|
||||
>
|
||||
<span class="font-medium text-gray-900">{ds.table_name}</span>
|
||||
<span class="text-gray-400 shrink-0">{ds.schema || ds.schema_name || 'public'}</span>
|
||||
<span class="ml-auto text-xs text-gray-400 shrink-0">{ds.database} · {ds.mapped_fields ? `${ds.mapped_fields.mapped}/${ds.mapped_fields.total}` : 'unknown'}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- #endregion DatasetSearchCombobox -->
|
||||
@@ -1,20 +1,17 @@
|
||||
// #region GitServiceClient [C:3] [TYPE Service] [SEMANTICS git, service, api, repository, branch]
|
||||
// @BRIEF API client for Git operations including repository management, branch operations, commits, merges, and deployments.
|
||||
// @BRIEF API client for Git operations — repository management, branches, commits, merges, deployments.
|
||||
// @LAYER Service
|
||||
// @RELATION DEPENDS_ON -> [ApiModule]
|
||||
|
||||
// #region GitServiceClient:Module [TYPE Function]
|
||||
/**
|
||||
* @SEMANTICS: git, service, api, client
|
||||
* @PURPOSE: API client for Git operations, managing the communication between frontend and backend.
|
||||
* @LAYER Service
|
||||
* @RELATION DEPENDS_ON -> specs/011-git-integration-dashboard/contracts/api.md
|
||||
*/
|
||||
|
||||
import { requestApi } from '../lib/api';
|
||||
|
||||
const API_BASE = '/git';
|
||||
|
||||
// #region buildDashboardRepoEndpoint [C:2] [TYPE Function]
|
||||
// @BRIEF Build /git/repositories/{ref}{suffix} endpoint with optional env_id query param.
|
||||
// @PRE dashboardRef is non-empty string.
|
||||
// @POST Returns absolute API endpoint path with env_id appended when envId is provided.
|
||||
// @DATA_CONTRACT Input(dashboardRef, suffix, envId?) -> Output(string)
|
||||
function buildDashboardRepoEndpoint(dashboardRef, suffix, envId = null) {
|
||||
const encodedRef = encodeURIComponent(String(dashboardRef));
|
||||
const endpoint = `${API_BASE}/repositories/${encodedRef}${suffix}`;
|
||||
@@ -22,140 +19,113 @@ function buildDashboardRepoEndpoint(dashboardRef, suffix, envId = null) {
|
||||
const sep = endpoint.includes('?') ? '&' : '?';
|
||||
return `${endpoint}${sep}env_id=${encodeURIComponent(String(envId))}`;
|
||||
}
|
||||
// #endregion buildDashboardRepoEndpoint
|
||||
|
||||
// #region gitService:Action [TYPE Function]
|
||||
// #region gitService [C:3] [TYPE Object]
|
||||
// @BRIEF Exported Git API client with methods for all repository and config operations.
|
||||
// @RELATION CALLS -> [ApiModule.requestApi]
|
||||
export const gitService = {
|
||||
/**
|
||||
* #region getConfigs:Function [TYPE Function]
|
||||
* @purpose Fetches all Git server configurations.
|
||||
* @pre User must be authenticated.
|
||||
* @post Returns a list of Git server configurations.
|
||||
* @returns {Promise<Array>} List of configs.
|
||||
*/
|
||||
|
||||
// #region getConfigs [C:2] [TYPE Function]
|
||||
// @BRIEF Fetch all Git server configurations.
|
||||
// @PRE User is authenticated (handled by ApiModule auth headers).
|
||||
// @POST Returns list of GitServerConfig objects.
|
||||
// @DATA_CONTRACT None -> Array<GitServerConfig>
|
||||
async getConfigs() {
|
||||
console.log('[getConfigs][Action] Fetching Git configs');
|
||||
return requestApi(`${API_BASE}/config`);
|
||||
},
|
||||
// #endregion getConfigs
|
||||
|
||||
/**
|
||||
* #region createConfig:Function [TYPE Function]
|
||||
* @purpose Creates a new Git server configuration.
|
||||
* @pre Config object must be valid.
|
||||
* @post New config is created and returned.
|
||||
* @param {Object} config - Configuration details.
|
||||
* @returns {Promise<Object>} Created config.
|
||||
*/
|
||||
// #region createConfig [C:2] [TYPE Function]
|
||||
// @BRIEF Create a new Git server configuration.
|
||||
// @PRE config object is valid with required fields.
|
||||
// @POST New config is persisted and returned.
|
||||
// @DATA_CONTRACT Input(GitServerConfig) -> Output(GitServerConfig)
|
||||
async createConfig(config) {
|
||||
console.log('[createConfig][Action] Creating Git config');
|
||||
return requestApi(`${API_BASE}/config`, 'POST', config);
|
||||
},
|
||||
// #endregion createConfig
|
||||
|
||||
/**
|
||||
* #region deleteConfig:Function [TYPE Function]
|
||||
* @purpose Deletes an existing Git server configuration.
|
||||
* @pre configId must exist.
|
||||
* @post Config is deleted from the backend.
|
||||
* @param {string} configId - ID of the config to delete.
|
||||
* @returns {Promise<Object>} Result of deletion.
|
||||
*/
|
||||
// #region deleteConfig [C:2] [TYPE Function]
|
||||
// @BRIEF Delete an existing Git server configuration.
|
||||
// @PRE configId exists in the backend.
|
||||
// @POST Config is removed from the backend.
|
||||
async deleteConfig(configId) {
|
||||
console.log(`[deleteConfig][Action] Deleting Git config ${configId}`);
|
||||
return requestApi(`${API_BASE}/config/${configId}`, 'DELETE');
|
||||
},
|
||||
// #endregion deleteConfig
|
||||
|
||||
/**
|
||||
* #region updateConfig:Function [TYPE Function]
|
||||
* @purpose Updates an existing Git server configuration.
|
||||
* @pre configId must exist and configData must be valid.
|
||||
* @post Config is updated and returned.
|
||||
* @param {string} configId - ID of the config to update.
|
||||
* @param {Object} config - Updated configuration details.
|
||||
* @returns {Promise<Object>} Updated config.
|
||||
*/
|
||||
// #region updateConfig [C:2] [TYPE Function]
|
||||
// @BRIEF Update an existing Git server configuration.
|
||||
// @PRE configId exists and configData contains valid fields.
|
||||
// @POST Config is updated and returned.
|
||||
async updateConfig(configId, config) {
|
||||
console.log(`[updateConfig][Action] Updating Git config ${configId}`);
|
||||
return requestApi(`${API_BASE}/config/${configId}`, 'PUT', config);
|
||||
},
|
||||
// #endregion updateConfig
|
||||
|
||||
/**
|
||||
* #region testConnection:Function [TYPE Function]
|
||||
* @purpose Tests the connection to a Git server with provided credentials.
|
||||
* @pre Config must contain valid URL and PAT.
|
||||
* @post Returns connection status (success/failure).
|
||||
* @param {Object} config - Configuration to test.
|
||||
* @returns {Promise<Object>} Connection test result.
|
||||
*/
|
||||
// #region testConnection [C:2] [TYPE Function]
|
||||
// @BRIEF Test connection to a Git server with provided credentials.
|
||||
// @PRE config contains valid URL and PAT.
|
||||
// @POST Returns connection status (success/failure).
|
||||
// @DATA_CONTRACT Input(GitServerConfig) -> Output({status: string})
|
||||
async testConnection(config) {
|
||||
console.log('[testConnection][Action] Testing Git connection');
|
||||
return requestApi(`${API_BASE}/config/test`, 'POST', config);
|
||||
},
|
||||
// #endregion testConnection
|
||||
|
||||
/**
|
||||
* #region listGiteaRepositories:Function [TYPE Function]
|
||||
* @purpose Lists repositories on Gitea for a saved Git configuration.
|
||||
* @pre configId must reference a GITEA config.
|
||||
* @post Returns repository metadata.
|
||||
* @param {string} configId - Git configuration ID.
|
||||
* @returns {Promise<Array>} List of Gitea repositories.
|
||||
*/
|
||||
// #region listGiteaRepositories [C:2] [TYPE Function]
|
||||
// @BRIEF List repositories on Gitea for a saved Git configuration.
|
||||
// @PRE configId references a GITEA provider config.
|
||||
// @POST Returns array of Gitea repository metadata.
|
||||
async listGiteaRepositories(configId) {
|
||||
console.log(`[listGiteaRepositories][Action] Listing Gitea repositories for config ${configId}`);
|
||||
return requestApi(`${API_BASE}/config/${configId}/gitea/repos`);
|
||||
},
|
||||
// #endregion listGiteaRepositories
|
||||
|
||||
/**
|
||||
* #region createGiteaRepository:Function [TYPE Function]
|
||||
* @purpose Creates a new repository on Gitea for a saved Git configuration.
|
||||
* @pre configId must reference a GITEA config.
|
||||
* @post Repository is created on Gitea.
|
||||
* @param {string} configId - Git configuration ID.
|
||||
* @param {Object} payload - {name, private, description, auto_init, default_branch}
|
||||
* @returns {Promise<Object>} Created repository payload.
|
||||
*/
|
||||
// #region createGiteaRepository [C:2] [TYPE Function]
|
||||
// @BRIEF Create a repository on Gitea for a saved Git configuration.
|
||||
// @PRE configId references a GITEA config; payload has non-empty name.
|
||||
// @POST Repository created on Gitea; returns created repo metadata.
|
||||
async createGiteaRepository(configId, payload) {
|
||||
console.log(`[createGiteaRepository][Action] Creating Gitea repository ${payload?.name} for config ${configId}`);
|
||||
return requestApi(`${API_BASE}/config/${configId}/gitea/repos`, 'POST', payload);
|
||||
},
|
||||
// #endregion createGiteaRepository
|
||||
|
||||
/**
|
||||
* #region createRemoteRepository:Function [TYPE Function]
|
||||
* @purpose Creates repository on remote provider selected by Git config.
|
||||
* @pre configId exists and points to supported provider config.
|
||||
* @post Remote repository created and normalized payload returned.
|
||||
* @param {string} configId - Git configuration ID.
|
||||
* @param {Object} payload - {name, private, description, auto_init, default_branch}
|
||||
* @returns {Promise<Object>} Created remote repository payload.
|
||||
*/
|
||||
// #region createRemoteRepository [C:2] [TYPE Function]
|
||||
// @BRIEF Create repository on remote provider selected by Git config (idempotent: returns existing on 409).
|
||||
// @PRE configId exists and points to supported provider; payload has non-empty name.
|
||||
// @POST Remote repository created (or existing returned); normalized payload returned.
|
||||
// @DATA_CONTRACT Input(configId, RemoteRepoCreatePayload) -> Output(RemoteRepoSchema)
|
||||
async createRemoteRepository(configId, payload) {
|
||||
console.log(`[createRemoteRepository][Action] Creating remote repository ${payload?.name} for config ${configId}`);
|
||||
return requestApi(`${API_BASE}/config/${configId}/repositories`, 'POST', payload);
|
||||
},
|
||||
// #endregion createRemoteRepository
|
||||
|
||||
/**
|
||||
* #region deleteGiteaRepository:Function [TYPE Function]
|
||||
* @purpose Deletes a repository on Gitea for a saved Git configuration.
|
||||
* @pre configId must reference a GITEA config.
|
||||
* @post Repository is deleted on Gitea.
|
||||
* @param {string} configId - Git configuration ID.
|
||||
* @param {string} owner - Repository owner.
|
||||
* @param {string} repoName - Repository name.
|
||||
* @returns {Promise<Object>} Deletion result.
|
||||
*/
|
||||
// #region deleteGiteaRepository [C:2] [TYPE Function]
|
||||
// @BRIEF Delete a repository on Gitea for a saved Git configuration.
|
||||
// @PRE configId references a GITEA config; owner and repoName are non-empty.
|
||||
// @POST Repository deleted on Gitea server.
|
||||
async deleteGiteaRepository(configId, owner, repoName) {
|
||||
console.log(`[deleteGiteaRepository][Action] Deleting Gitea repository ${owner}/${repoName} for config ${configId}`);
|
||||
return requestApi(`${API_BASE}/config/${configId}/gitea/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}`, 'DELETE');
|
||||
},
|
||||
// #endregion deleteGiteaRepository
|
||||
|
||||
/**
|
||||
* #region initRepository:Function [TYPE Function]
|
||||
* @purpose Initializes or clones a Git repository for a dashboard.
|
||||
* @pre Dashboard must exist and config_id must be valid.
|
||||
* @post Repository is initialized on the backend.
|
||||
* @param {string|number} dashboardRef - Dashboard slug or id.
|
||||
* @param {string} configId - ID of the Git config.
|
||||
* @param {string} remoteUrl - URL of the remote repository.
|
||||
* @returns {Promise<Object>} Initialization result.
|
||||
*/
|
||||
// #region initRepository [C:3] [TYPE Function]
|
||||
// @BRIEF Initialize or clone a Git repository for a dashboard.
|
||||
// @PRE dashboardRef is non-empty; configId and remoteUrl are valid; envId required when ref is slug.
|
||||
// @POST Repository initialized on the backend; GitRepository record created.
|
||||
// @DATA_CONTRACT Input(dashboardRef, configId, remoteUrl, envId?) -> Output({status: string, message: string})
|
||||
// @RELATION CALLS -> [ApiModule.requestApi]
|
||||
async initRepository(dashboardRef, configId, remoteUrl, envId = null) {
|
||||
console.log(`[initRepository][Action] Initializing repo for dashboard ${dashboardRef}`);
|
||||
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/init', envId), 'POST', {
|
||||
@@ -163,43 +133,32 @@ export const gitService = {
|
||||
remote_url: remoteUrl
|
||||
});
|
||||
},
|
||||
// #endregion initRepository
|
||||
|
||||
/**
|
||||
* #region getRepositoryBinding:Function [TYPE Function]
|
||||
* @purpose Fetches repository binding metadata (config/provider) for dashboard.
|
||||
* @pre Repository should be initialized for dashboard.
|
||||
* @post Returns provider and config details for current repository.
|
||||
* @param {string|number} dashboardRef - Dashboard slug or id.
|
||||
* @returns {Promise<Object>} Repository binding payload.
|
||||
*/
|
||||
// #region getRepositoryBinding [C:2] [TYPE Function]
|
||||
// @BRIEF Fetch repository binding metadata (config/provider) for a dashboard.
|
||||
// @PRE Repository should be initialized for the dashboard.
|
||||
// @POST Returns provider, config_id, remote_url, local_path.
|
||||
async getRepositoryBinding(dashboardRef, envId = null) {
|
||||
console.log(`[getRepositoryBinding][Action] Fetching repository binding for dashboard ${dashboardRef}`);
|
||||
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '', envId));
|
||||
},
|
||||
// #endregion getRepositoryBinding
|
||||
|
||||
/**
|
||||
* #region getBranches:Function [TYPE Function]
|
||||
* @purpose Retrieves the list of branches for a dashboard's repository.
|
||||
* @pre Repository must be initialized.
|
||||
* @post Returns a list of branches.
|
||||
* @param {string|number} dashboardRef - Dashboard slug or id.
|
||||
* @returns {Promise<Array>} List of branches.
|
||||
*/
|
||||
// #region getBranches [C:2] [TYPE Function]
|
||||
// @BRIEF List all branches for a dashboard's repository.
|
||||
// @PRE Repository must be initialized; envId required when dashboardRef is a slug.
|
||||
// @POST Returns array of BranchSchema objects.
|
||||
async getBranches(dashboardRef, envId = null) {
|
||||
console.log(`[getBranches][Action] Fetching branches for dashboard ${dashboardRef}`);
|
||||
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/branches', envId));
|
||||
},
|
||||
// #endregion getBranches
|
||||
|
||||
/**
|
||||
* #region createBranch:Function [TYPE Function]
|
||||
* @purpose Creates a new branch in the dashboard's repository.
|
||||
* @pre Source branch must exist.
|
||||
* @post New branch is created.
|
||||
* @param {number} dashboardId - ID of the dashboard.
|
||||
* @param {string} name - New branch name.
|
||||
* @param {string} fromBranch - Source branch name.
|
||||
* @returns {Promise<Object>} Creation result.
|
||||
*/
|
||||
// #region createBranch [C:2] [TYPE Function]
|
||||
// @BRIEF Create a new branch in the dashboard's repository.
|
||||
// @PRE Source branch exists; name is non-empty; envId required when ref is slug.
|
||||
// @POST New branch is created on the backend.
|
||||
async createBranch(dashboardRef, name, fromBranch, envId = null) {
|
||||
console.log(`[createBranch][Action] Creating branch ${name} for dashboard ${dashboardRef}`);
|
||||
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/branches', envId), 'POST', {
|
||||
@@ -207,192 +166,145 @@ export const gitService = {
|
||||
from_branch: fromBranch
|
||||
});
|
||||
},
|
||||
// #endregion createBranch
|
||||
|
||||
/**
|
||||
* #region checkoutBranch:Function [TYPE Function]
|
||||
* @purpose Switches the repository to a different branch.
|
||||
* @pre Target branch must exist.
|
||||
* @post Repository head is moved to the target branch.
|
||||
* @param {number} dashboardId - ID of the dashboard.
|
||||
* @param {string} name - Branch name to checkout.
|
||||
* @returns {Promise<Object>} Checkout result.
|
||||
*/
|
||||
// #region checkoutBranch [C:2] [TYPE Function]
|
||||
// @BRIEF Switch the repository to a different branch.
|
||||
// @PRE Target branch exists; envId required when ref is slug.
|
||||
// @POST Repository HEAD is moved to the target branch.
|
||||
async checkoutBranch(dashboardRef, name, envId = null) {
|
||||
console.log(`[checkoutBranch][Action] Checking out branch ${name} for dashboard ${dashboardRef}`);
|
||||
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/checkout', envId), 'POST', { name });
|
||||
},
|
||||
// #endregion checkoutBranch
|
||||
|
||||
/**
|
||||
* #region commit:Function [TYPE Function]
|
||||
* @purpose Stages and commits changes to the repository.
|
||||
* @pre Message must not be empty.
|
||||
* @post Changes are committed to the current branch.
|
||||
* @param {number} dashboardId - ID of the dashboard.
|
||||
* @param {string} message - Commit message.
|
||||
* @param {Array} files - Optional list of files to commit.
|
||||
* @returns {Promise<Object>} Commit result.
|
||||
*/
|
||||
// #region commit [C:2] [TYPE Function]
|
||||
// @BRIEF Stage and commit changes to the repository.
|
||||
// @PRE message is non-empty; envId required when ref is slug.
|
||||
// @POST Changes are committed to the current branch.
|
||||
async commit(dashboardRef, message, files, envId = null) {
|
||||
console.log(`[commit][Action] Committing changes for dashboard ${dashboardRef}`);
|
||||
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/commit', envId), 'POST', { message, files });
|
||||
},
|
||||
// #endregion commit
|
||||
|
||||
/**
|
||||
* #region push:Function [TYPE Function]
|
||||
* @purpose Pushes local commits to the remote repository.
|
||||
* @pre Remote must be configured and accessible.
|
||||
* @post Remote is updated with local commits.
|
||||
* @param {number} dashboardId - ID of the dashboard.
|
||||
* @returns {Promise<Object>} Push result.
|
||||
*/
|
||||
// #region push [C:2] [TYPE Function]
|
||||
// @BRIEF Push local commits to the remote repository.
|
||||
// @PRE Remote 'origin' is configured and accessible; envId required when ref is slug.
|
||||
// @POST Remote is updated with local commits.
|
||||
async push(dashboardRef, envId = null) {
|
||||
console.log(`[push][Action] Pushing changes for dashboard ${dashboardRef}`);
|
||||
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/push', envId), 'POST');
|
||||
},
|
||||
// #endregion push
|
||||
|
||||
/**
|
||||
* #region deleteRepository:Function [TYPE Function]
|
||||
* @purpose Deletes local repository binding and workspace for dashboard.
|
||||
* @pre Dashboard reference must resolve on backend.
|
||||
* @post Repository record and local folder are removed.
|
||||
* @param {string|number} dashboardRef - Dashboard slug or id.
|
||||
* @returns {Promise<Object>} Deletion result.
|
||||
*/
|
||||
// #region deleteRepository [C:2] [TYPE Function]
|
||||
// @BRIEF Delete local repository binding and workspace for a dashboard.
|
||||
// @PRE dashboardRef resolves on backend; envId required when ref is slug.
|
||||
// @POST Repository record and local folder are removed.
|
||||
async deleteRepository(dashboardRef, envId = null) {
|
||||
console.log(`[deleteRepository][Action] Deleting repository for dashboard ${dashboardRef}`);
|
||||
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '', envId), 'DELETE');
|
||||
},
|
||||
// #endregion deleteRepository
|
||||
|
||||
/**
|
||||
* #region pull:Function [TYPE Function]
|
||||
* @purpose Pulls changes from the remote repository.
|
||||
* @pre Remote must be configured and accessible.
|
||||
* @post Local repository is updated with remote changes.
|
||||
* @param {number} dashboardId - ID of the dashboard.
|
||||
* @returns {Promise<Object>} Pull result.
|
||||
*/
|
||||
// #region pull [C:2] [TYPE Function]
|
||||
// @BRIEF Pull changes from the remote repository.
|
||||
// @PRE Remote 'origin' is configured and accessible; envId required when ref is slug.
|
||||
// @POST Local repository is updated with remote changes.
|
||||
async pull(dashboardRef, envId = null) {
|
||||
console.log(`[pull][Action] Pulling changes for dashboard ${dashboardRef}`);
|
||||
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/pull', envId), 'POST');
|
||||
},
|
||||
// #endregion pull
|
||||
|
||||
/**
|
||||
* #region getMergeStatus:Function [TYPE Function]
|
||||
* @purpose Retrieves unfinished-merge status for repository.
|
||||
* @pre Repository must exist.
|
||||
* @post Returns merge status payload.
|
||||
* @param {string|number} dashboardRef - Dashboard slug or id.
|
||||
* @returns {Promise<Object>} Merge status details.
|
||||
*/
|
||||
// #region getMergeStatus [C:2] [TYPE Function]
|
||||
// @BRIEF Fetch unfinished-merge status for a repository.
|
||||
// @PRE Repository must exist; envId required when ref is slug.
|
||||
// @POST Returns merge status payload with has_unfinished_merge flag.
|
||||
async getMergeStatus(dashboardRef, envId = null) {
|
||||
console.log(`[getMergeStatus][Action] Fetching merge status for dashboard ${dashboardRef}`);
|
||||
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/merge/status', envId));
|
||||
},
|
||||
// #endregion getMergeStatus
|
||||
|
||||
/**
|
||||
* #region getMergeConflicts:Function [TYPE Function]
|
||||
* @purpose Retrieves merge conflicts list for repository.
|
||||
* @pre Unfinished merge should be in progress.
|
||||
* @post Returns conflict files with mine/theirs previews.
|
||||
* @param {string|number} dashboardRef - Dashboard slug or id.
|
||||
* @returns {Promise<Array>} List of conflict files.
|
||||
*/
|
||||
// #region getMergeConflicts [C:2] [TYPE Function]
|
||||
// @BRIEF List merge conflicts for a repository with unresolved merge.
|
||||
// @PRE Unfinished merge is in progress; envId required when ref is slug.
|
||||
// @POST Returns array of conflict file entries with mine/theirs previews.
|
||||
async getMergeConflicts(dashboardRef, envId = null) {
|
||||
console.log(`[getMergeConflicts][Action] Fetching merge conflicts for dashboard ${dashboardRef}`);
|
||||
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/merge/conflicts', envId));
|
||||
},
|
||||
// #endregion getMergeConflicts
|
||||
|
||||
/**
|
||||
* #region resolveMergeConflicts:Function [TYPE Function]
|
||||
* @purpose Applies conflict resolution strategies and stages resolved files.
|
||||
* @pre resolutions contains file_path/resolution entries.
|
||||
* @post Conflicts are resolved and staged.
|
||||
* @param {string|number} dashboardRef - Dashboard slug or id.
|
||||
* @param {Array} resolutions - Resolution entries.
|
||||
* @returns {Promise<Object>} Resolve result.
|
||||
*/
|
||||
// #region resolveMergeConflicts [C:2] [TYPE Function]
|
||||
// @BRIEF Apply conflict resolution strategies and stage resolved files.
|
||||
// @PRE resolutions array contains file_path and resolution entries; envId required when ref is slug.
|
||||
// @POST Conflicts are resolved and staged.
|
||||
async resolveMergeConflicts(dashboardRef, resolutions, envId = null) {
|
||||
console.log(`[resolveMergeConflicts][Action] Resolving ${Array.isArray(resolutions) ? resolutions.length : 0} conflicts for dashboard ${dashboardRef}`);
|
||||
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/merge/resolve', envId), 'POST', {
|
||||
resolutions: Array.isArray(resolutions) ? resolutions : []
|
||||
});
|
||||
},
|
||||
// #endregion resolveMergeConflicts
|
||||
|
||||
/**
|
||||
* #region abortMerge:Function [TYPE Function]
|
||||
* @purpose Aborts current unfinished merge.
|
||||
* @pre Repository exists.
|
||||
* @post Merge state is aborted or reported as absent.
|
||||
* @param {string|number} dashboardRef - Dashboard slug or id.
|
||||
* @returns {Promise<Object>} Abort operation result.
|
||||
*/
|
||||
// #region abortMerge [C:2] [TYPE Function]
|
||||
// @BRIEF Abort current unfinished merge.
|
||||
// @PRE Repository exists; envId required when ref is slug.
|
||||
// @POST Merge state is aborted or reported as absent.
|
||||
async abortMerge(dashboardRef, envId = null) {
|
||||
console.log(`[abortMerge][Action] Aborting merge for dashboard ${dashboardRef}`);
|
||||
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/merge/abort', envId), 'POST');
|
||||
},
|
||||
// #endregion abortMerge
|
||||
|
||||
/**
|
||||
* #region continueMerge:Function [TYPE Function]
|
||||
* @purpose Finalizes unfinished merge by creating merge commit.
|
||||
* @pre All conflicts are resolved.
|
||||
* @post Merge commit is created.
|
||||
* @param {string|number} dashboardRef - Dashboard slug or id.
|
||||
* @param {string} message - Optional commit message.
|
||||
* @returns {Promise<Object>} Continue result.
|
||||
*/
|
||||
// #region continueMerge [C:2] [TYPE Function]
|
||||
// @BRIEF Finalize unfinished merge by creating a merge commit.
|
||||
// @PRE All conflicts are resolved; envId required when ref is slug.
|
||||
// @POST Merge commit is created.
|
||||
async continueMerge(dashboardRef, message = '', envId = null) {
|
||||
console.log(`[continueMerge][Action] Continuing merge for dashboard ${dashboardRef}`);
|
||||
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/merge/continue', envId), 'POST', {
|
||||
message: String(message || '').trim() || null
|
||||
});
|
||||
},
|
||||
// #endregion continueMerge
|
||||
|
||||
/**
|
||||
* #region getEnvironments:Function [TYPE Function]
|
||||
* @purpose Retrieves available deployment environments.
|
||||
* @post Returns a list of environments.
|
||||
* @returns {Promise<Array>} List of environments.
|
||||
*/
|
||||
// #region getEnvironments [C:2] [TYPE Function]
|
||||
// @BRIEF Fetch available deployment environments from Git config.
|
||||
// @POST Returns list of environment objects.
|
||||
async getEnvironments() {
|
||||
console.log('[getEnvironments][Action] Fetching environments');
|
||||
return requestApi(`${API_BASE}/environments`);
|
||||
},
|
||||
// #endregion getEnvironments
|
||||
|
||||
/**
|
||||
* #region deploy:Function [TYPE Function]
|
||||
* @purpose Deploys a dashboard to a target environment.
|
||||
* @pre Environment must be active and accessible.
|
||||
* @post Dashboard is imported into the target Superset instance.
|
||||
* @param {number} dashboardId - ID of the dashboard.
|
||||
* @param {string} environmentId - ID of the target environment.
|
||||
* @returns {Promise<Object>} Deployment result.
|
||||
*/
|
||||
// #region deploy [C:2] [TYPE Function]
|
||||
// @BRIEF Deploy a dashboard to a target environment.
|
||||
// @PRE Environment must be active and accessible; envId required when ref is slug.
|
||||
// @POST Dashboard is imported into target Superset instance.
|
||||
async deploy(dashboardRef, environmentId, envId = null) {
|
||||
console.log(`[deploy][Action] Deploying dashboard ${dashboardRef} to environment ${environmentId}`);
|
||||
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/deploy', envId), 'POST', {
|
||||
environment_id: environmentId
|
||||
});
|
||||
},
|
||||
// #endregion deploy
|
||||
|
||||
/**
|
||||
* #region getHistory:Function [TYPE Function]
|
||||
* @purpose Retrieves the commit history for a dashboard.
|
||||
* @param {number} dashboardId - ID of the dashboard.
|
||||
* @param {number} limit - Maximum number of commits to return.
|
||||
* @returns {Promise<Array>} List of commits.
|
||||
*/
|
||||
// #region getHistory [C:2] [TYPE Function]
|
||||
// @BRIEF Fetch commit history for a dashboard repository.
|
||||
// @POST Returns list of commits with author, date, message.
|
||||
async getHistory(dashboardRef, limit = 50, envId = null) {
|
||||
console.log(`[getHistory][Action] Fetching history for dashboard ${dashboardRef}`);
|
||||
return requestApi(buildDashboardRepoEndpoint(dashboardRef, `/history?limit=${limit}`, envId));
|
||||
},
|
||||
// #endregion getHistory
|
||||
|
||||
/**
|
||||
* #region sync:Function [TYPE Function]
|
||||
* @purpose Synchronizes the local dashboard state with the Git repository.
|
||||
* @param {number} dashboardId - ID of the dashboard.
|
||||
* @param {string|null} sourceEnvId - Optional source environment ID.
|
||||
* @returns {Promise<Object>} Sync result.
|
||||
*/
|
||||
// #region sync [C:2] [TYPE Function]
|
||||
// @BRIEF Synchronize local dashboard state with Git repository.
|
||||
// @POST Dashboard state is synced to Git workspace.
|
||||
async sync(dashboardRef, sourceEnvId = null, envId = null) {
|
||||
console.log(`[sync][Action] Syncing dashboard ${dashboardRef}`);
|
||||
const params = new URLSearchParams();
|
||||
@@ -402,45 +314,34 @@ export const gitService = {
|
||||
const endpoint = `${API_BASE}/repositories/${encodeURIComponent(String(dashboardRef))}/sync${query ? `?${query}` : ''}`;
|
||||
return requestApi(endpoint, 'POST');
|
||||
},
|
||||
// #endregion sync
|
||||
|
||||
/**
|
||||
* #region getStatus:Function [TYPE Function]
|
||||
* @purpose Fetches the current Git status for a dashboard repository.
|
||||
* @pre dashboardId must be a valid integer.
|
||||
* @post Returns a status object with dirty files and branch info.
|
||||
* @param {number} dashboardId - The ID of the dashboard.
|
||||
* @returns {Promise<Object>} Status details.
|
||||
*/
|
||||
// #region getStatus [C:2] [TYPE Function]
|
||||
// @BRIEF Fetch current Git status for a dashboard repository (dirty files, branch info).
|
||||
// @PRE Repository path exists on disk; envId required when ref is slug.
|
||||
// @POST Returns status payload with is_dirty, staged_files, modified_files, current_branch.
|
||||
async getStatus(dashboardRef, envId = null) {
|
||||
console.log(`[getStatus][Action] Fetching status for dashboard ${dashboardRef}`);
|
||||
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/status', envId));
|
||||
},
|
||||
// #endregion getStatus
|
||||
|
||||
/**
|
||||
* #region getStatusesBatch:Function [TYPE Function]
|
||||
* @purpose Fetches Git statuses for multiple dashboards in a single request.
|
||||
* @pre dashboardIds must be an array of dashboard IDs.
|
||||
* @post Returns a map of dashboard_id -> status payload.
|
||||
* @param {Array<number>} dashboardIds - Dashboard IDs.
|
||||
* @returns {Promise<Object>} Batch status response.
|
||||
*/
|
||||
// #region getStatusesBatch [C:2] [TYPE Function]
|
||||
// @BRIEF Fetch Git statuses for multiple dashboards in one batch request.
|
||||
// @PRE dashboardIds is a non-empty array of numeric IDs.
|
||||
// @POST Returns a map of dashboard_id -> status payload (with NO_REPO for uninitialized).
|
||||
async getStatusesBatch(dashboardIds) {
|
||||
console.log(`[getStatusesBatch][Action] Fetching statuses for ${dashboardIds.length} dashboards`);
|
||||
return requestApi(`${API_BASE}/repositories/status/batch`, 'POST', {
|
||||
dashboard_ids: dashboardIds
|
||||
});
|
||||
},
|
||||
// #endregion getStatusesBatch
|
||||
|
||||
/**
|
||||
* #region getDiff:Function [TYPE Function]
|
||||
* @purpose Retrieves the diff for specific files or the whole repository.
|
||||
* @pre dashboardId must be a valid integer.
|
||||
* @post Returns the Git diff string.
|
||||
* @param {number} dashboardId - The ID of the dashboard.
|
||||
* @param {string|null} filePath - Optional specific file path.
|
||||
* @param {boolean} staged - Whether to show staged changes.
|
||||
* @returns {Promise<string>} The diff content.
|
||||
*/
|
||||
// #region getDiff [C:2] [TYPE Function]
|
||||
// @BRIEF Fetch Git diff for a dashboard repository (staged or unstaged).
|
||||
// @PRE Repository path exists; envId required when ref is slug.
|
||||
// @POST Returns unified diff string.
|
||||
async getDiff(dashboardRef, filePath = null, staged = false, envId = null) {
|
||||
console.log(`[getDiff][Action] Fetching diff for dashboard ${dashboardRef} (file: ${filePath}, staged: ${staged})`);
|
||||
let endpoint = `${API_BASE}/repositories/${encodeURIComponent(String(dashboardRef))}/diff`;
|
||||
@@ -451,23 +352,17 @@ export const gitService = {
|
||||
if (params.toString()) endpoint += `?${params.toString()}`;
|
||||
return requestApi(endpoint);
|
||||
},
|
||||
// #endregion getDiff
|
||||
|
||||
/**
|
||||
* #region promote:Function [TYPE 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.
|
||||
*/
|
||||
// #region promote [C:2] [TYPE Function]
|
||||
// @BRIEF Promote changes between branches via MR or direct merge.
|
||||
// @PRE Dashboard repository initialized; from_branch and to_branch differ; envId required when ref is slug.
|
||||
// @POST Returns MR URL (MR mode) or merge status (direct mode).
|
||||
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);
|
||||
}
|
||||
// #endregion promote
|
||||
};
|
||||
// #endregion gitService:Action
|
||||
|
||||
// #endregion GitServiceClient:Module
|
||||
// #endregion gitService
|
||||
// #endregion GitServiceClient
|
||||
|
||||
Reference in New Issue
Block a user