feat: Git manager UI — панель управления Git + HelpTooltip + ReviewToggle

- GitManager: переработан в GitWorkspacePanel с вкладками
- Добавлен GitLifecycleHeader с быстрыми действиями
- RepositoryDashboardGrid: поддержка ReviewToggle, badges, env filter
- HelpTooltip: универсальный компонент подсказок с тестами
- GitManagerModel: доработаны экшены, добавлен isReady, loadDefaultBranch
- Локализация en/ru для Git UI
- tailwind: добавлен animation-delay-200
- ConfirmDialog: a11y-атрибуты для кнопок
This commit is contained in:
2026-07-04 15:01:45 +03:00
parent 84bf0fc22b
commit 6f53599758
23 changed files with 1041 additions and 151 deletions

View File

@@ -30,6 +30,8 @@
statusMode = "dashboard" as "dashboard" | "repository",
envId = null as string | null,
repositoriesOnly = false,
/** Called when bulk-delete removes dashboards from the list — parent owns the data. */
onDashboardsChanged = (_next: DashboardMetadata[]) => {},
} = $props();
// [/SECTION]
@@ -43,8 +45,14 @@
let gitDashboardTitle = $state("");
let repositoryStatusByDashboardId = $state<Record<number, string>>({});
let repositoryStatusRequestId = $state(0);
let repositoryStatusFetching = $state(false);
let bulkActionRunning = $state(false);
let showBulkDeleteConfirm = $state(false);
// Bulk-commit dialog (replaces native prompt())
let showBulkCommitDialog = $state(false);
let bulkCommitMessage = $state("");
// Bulk-action confirmation dialogs (sync/pull/push)
let bulkConfirmAction = $state<null | "sync" | "pull" | "push">(null);
// [/SECTION]
// ── Column definitions ─────────────────────────────────────────
@@ -108,9 +116,16 @@
repositoryStatusByDashboardId = {};
return;
}
const requestId = ++repositoryStatusRequestId;
// Load statuses for all pre-filtered data (not just current page — DashboardDataGrid owns pagination)
// P2 #7: skip if a fetch is already in progress, or all IDs already have resolved statuses.
if (repositoryStatusFetching) return;
const allIds = dashboards.map(d => d.id);
const allResolved = allIds.every(id => {
const tkn = repositoryStatusByDashboardId[id];
return tkn !== undefined && tkn !== "loading";
});
if (allResolved) return;
const requestId = ++repositoryStatusRequestId;
repositoryStatusFetching = true;
const missingIds = allIds.filter(id => {
const tkn = repositoryStatusByDashboardId[id];
return tkn === undefined || tkn === "loading";
@@ -130,11 +145,12 @@
} catch {
entries = missingIds.map((id) => [id, "error"] as const);
}
if (requestId !== repositoryStatusRequestId) return;
if (requestId !== repositoryStatusRequestId) { repositoryStatusFetching = false; return; }
repositoryStatusByDashboardId = {
...repositoryStatusByDashboardId,
...Object.fromEntries(entries),
};
repositoryStatusFetching = false;
}
// #endregion loadRepositoryStatuses:Function
@@ -166,18 +182,31 @@
// #endregion runBulkGitAction:Function
// #region handleBulkAction:Function [TYPE Function]
async function handleBulkSync() { await runBulkGitAction("sync", (id) => gitService.sync(id, null, envId)); }
async function handleBulkCommit() {
const msg = prompt($t.git?.commit_message);
if (!msg?.trim()) return;
await runBulkGitAction("commit", (id) => gitService.commit(id, msg.trim(), [], envId));
async function handleBulkSync() { bulkConfirmAction = "sync"; }
async function handleBulkPull() { bulkConfirmAction = "pull"; }
async function handleBulkPush() { bulkConfirmAction = "push"; }
async function executeBulkConfirmed() {
const action = bulkConfirmAction;
bulkConfirmAction = null;
if (action === "sync") { await runBulkGitAction("sync", (id) => gitService.sync(id, null, envId)); }
else if (action === "pull") { await runBulkGitAction("pull", (id) => gitService.pull(id, envId)); }
else if (action === "push") { await runBulkGitAction("push", (id) => gitService.push(id, envId)); }
}
function openBulkCommitDialog() {
bulkCommitMessage = "";
showBulkCommitDialog = true;
}
async function confirmBulkCommit() {
const msg = bulkCommitMessage.trim();
if (!msg) return;
showBulkCommitDialog = false;
await runBulkGitAction("commit", (id) => gitService.commit(id, msg, [], envId));
}
async function handleBulkPull() { await runBulkGitAction("pull", (id) => gitService.pull(id, envId)); }
async function handleBulkPush() { await runBulkGitAction("push", (id) => gitService.push(id, envId)); }
async function onConfirmBulkDelete() {
const idsToDelete = [...selectedIds];
await runBulkGitAction("delete", (id) => gitService.deleteRepository(id, envId));
dashboards = dashboards.filter((d) => !idsToDelete.includes(d.id));
// P2 #9: do NOT mutate the prop directly — delegate to parent via callback.
onDashboardsChanged(dashboards.filter((d) => !idsToDelete.includes(d.id)));
selectedIds = [];
}
// #endregion handleBulkAction:Function
@@ -302,7 +331,7 @@
{$t.git?.bulk_sync}
</Button>
{#if !repositoriesOnly}
<Button size="sm" variant="secondary" onclick={handleBulkCommit} disabled={bulkActionRunning}>
<Button size="sm" variant="secondary" onclick={openBulkCommitDialog} disabled={bulkActionRunning}>
{$t.git?.bulk_commit}
</Button>
{/if}
@@ -337,13 +366,47 @@
<ConfirmDialog
bind:show={showBulkDeleteConfirm}
title="Delete repositories?"
title={$t.git?.delete_repo || "Delete repositories?"}
message={$t.git?.confirm_delete_repo || "Delete selected repositories?"}
variant="destructive"
confirmLabel="Delete"
cancelLabel="Cancel"
confirmLabel={$t.git?.delete_repo || "Delete"}
cancelLabel={$t.common?.cancel || "Cancel"}
onConfirm={onConfirmBulkDelete}
onCancel={() => {}}
/>
<!-- Bulk-commit dialog (replaces native prompt()) -->
{#if showBulkCommitDialog}
<div class="fixed inset-0 z-[60] flex items-center justify-center bg-surface-overlay" onclick={(e) => { if (e.target === e.currentTarget) showBulkCommitDialog = false; }} onkeydown={(e) => { if (e.key === 'Escape') showBulkCommitDialog = false; }} role="dialog" aria-modal="true" aria-label={$t.git?.bulk_commit_dialog?.title || 'Bulk commit'}>
<div class="bg-surface-card rounded-xl shadow-xl p-6 max-w-md w-full mx-4 border border-border" role="document">
<h3 class="text-lg font-semibold text-text mb-2">{$t.git?.bulk_commit_dialog?.title || 'Bulk commit'}</h3>
<p class="text-sm text-text-muted mb-4">{$t.git?.bulk_commit_dialog?.message || 'Enter a commit message for the selected dashboards:'}</p>
<textarea
bind:value={bulkCommitMessage}
class="h-28 w-full resize-none rounded-lg border border-border p-3 text-sm text-text outline-none focus:border-primary-ring focus:ring-2 focus:ring-primary-ring mb-4"
placeholder={$t.git?.bulk_commit_dialog?.placeholder || 'Commit message...'}
onkeydown={(e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && bulkCommitMessage.trim()) confirmBulkCommit(); }}
></textarea>
<div class="flex justify-end gap-3">
<Button variant="secondary" onclick={() => { showBulkCommitDialog = false; bulkCommitMessage = ''; }}>{$t.common?.cancel || 'Cancel'}</Button>
<Button onclick={confirmBulkCommit} disabled={!bulkCommitMessage.trim()}>{$t.git?.bulk_commit_dialog?.confirm || 'Commit'}</Button>
</div>
</div>
</div>
{/if}
<!-- Bulk-action confirmation (sync/pull/push) -->
{#if bulkConfirmAction}
<div class="fixed inset-0 z-[60] flex items-center justify-center bg-surface-overlay" onclick={(e) => { if (e.target === e.currentTarget) bulkConfirmAction = null; }} onkeydown={(e) => { if (e.key === 'Escape') bulkConfirmAction = null; }} role="dialog" aria-modal="true" aria-label={$t.git?.bulk_confirm?.[`${bulkConfirmAction}_title`] || bulkConfirmAction}>
<div class="bg-surface-card rounded-xl shadow-xl p-6 max-w-md w-full mx-4 border border-border" role="document">
<h3 class="text-lg font-semibold text-text mb-2">{$t.git?.bulk_confirm?.[`${bulkConfirmAction}_title`] || bulkConfirmAction}</h3>
<p class="text-sm text-text-muted mb-4">{($t.git?.bulk_confirm?.[`${bulkConfirmAction}_message`] || '').replace('{count}', String(selectedIds.length))}</p>
<div class="flex justify-end gap-3">
<Button variant="secondary" onclick={() => { bulkConfirmAction = null; }}>{$t.common?.cancel || 'Cancel'}</Button>
<Button onclick={executeBulkConfirmed}>{$t.git?.bulk_confirm?.confirm || 'Confirm'}</Button>
</div>
</div>
</div>
{/if}
<!-- #endregion Dashboard.RepositoryGrid -->

View File

@@ -234,7 +234,7 @@
</ul>
{/if}
</div>
<button type="button" onclick={() => model.branchError = null} class="flex-shrink-0 rounded p-0.5 hover:bg-destructive-light" aria-label="Закрыть">
<button type="button" onclick={() => model.branchError = null} class="flex-shrink-0 rounded p-0.5 hover:bg-destructive-light" aria-label={$t.git?.close_aria || $t.common?.close || 'Закрыть'}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>

View File

@@ -171,7 +171,7 @@
<!-- Rollback confirmation modal -->
{#if showRollbackConfirm}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onclick={cancelRollback}>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-surface-overlay" onclick={cancelRollback}>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="mx-4 w-full max-w-md rounded-lg border border-border bg-surface-card p-5 shadow-2xl" onclick={(e) => e.stopPropagation()} role="alertdialog" aria-modal="true" aria-label={($t.git?.rollback || 'Rollback') + ' ' + (rollbackConfirmHash?.substring(0, 7) || '')}>
<h3 class="text-lg font-semibold text-text mb-2">

View File

@@ -36,7 +36,7 @@
{#if show}
<div
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
class="fixed inset-0 bg-surface-overlay flex items-center justify-center z-50 p-4"
>
<div
class="bg-surface-card p-6 rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] flex flex-col"

View File

@@ -80,7 +80,7 @@
<!-- [SECTION: TEMPLATE] -->
{#if show}
<div
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
class="fixed inset-0 bg-surface-overlay flex items-center justify-center z-50 p-4"
>
<div
class="bg-surface-card p-6 rounded-lg shadow-xl w-full max-w-5xl max-h-[90vh] flex flex-col"

View File

@@ -113,7 +113,7 @@
{#if show}
<div
class="absolute inset-0 z-30 flex items-center justify-center bg-black bg-opacity-40 p-4"
class="absolute inset-0 z-30 flex items-center justify-center bg-surface-overlay p-4"
role="dialog"
aria-modal="true"
aria-label={dialogT().title || 'Create new branch'}

View File

@@ -43,7 +43,7 @@
</script>
{#if show}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="fixed inset-0 bg-surface-overlay flex items-center justify-center z-50">
<div class="bg-surface-card p-6 rounded-lg shadow-xl w-96">
<h2 class="text-xl font-bold mb-4">{$t.git?.deploy}</h2>

View File

@@ -29,7 +29,7 @@
if (recommendedAction === 'commit') return release.action_commit || 'Save version';
if (recommendedAction === 'promote') {
const label = release.action_promote || 'Promote to {stage}';
return String(label).replace('{stage}', preferredDeployTargetStage || promoteToBranch);
return String(label).replace('{stage}', preferredDeployTargetStage || 'next stage');
}
if (recommendedAction === 'deploy') return release.action_deploy || 'Deploy';
if (recommendedAction === 'review') return release.action_review || 'Review state';

View File

@@ -46,6 +46,27 @@
let deployConfirmInput = $state('');
// Focus-trap: keep Tab cycling inside the modal while it is open.
let modalEl = $state<HTMLElement | null>(null);
let lastFocused: HTMLElement | null = null;
function trapFocus(e: KeyboardEvent): void {
if (e.key !== 'Tab' || !modalEl) return;
const focusable = modalEl.querySelectorAll<HTMLElement>(
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
const model = new GitManagerModel({ dashboardId, envId, dashboardTitle });
$effect(() => {
@@ -90,14 +111,27 @@
onMount(() => {
model.initialize();
lastFocused = document.activeElement as HTMLElement | null;
// Move focus into the modal once it renders.
queueMicrotask(() => {
if (modalEl) {
const first = modalEl.querySelector<HTMLElement>('button:not([disabled]), [href], input:not([disabled]), [tabindex]:not([tabindex="-1"])');
first?.focus();
}
});
});
function handleModalClose(): void {
closeModal();
lastFocused?.focus();
}
</script>
{#if show}
<div class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 pt-6 backdrop-blur-sm" onclick={handleBackdropClick} onkeydown={(e) => { if (e.key === 'Escape') closeModal(); }} role="dialog" tabindex="-1" aria-label={`${$t.git?.management || 'Управление Git'}: ${dashboardTitle}`}>
<div class="relative w-[95vw] max-w-[1600px] overflow-hidden rounded-xl bg-surface-card shadow-2xl" role="document" aria-label={`${$t.git?.management || 'Управление Git'}: ${dashboardTitle}`}>
<div class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-surface-overlay pt-6 backdrop-blur-sm" onclick={handleBackdropClick} onkeydown={(e) => { if (e.key === 'Escape') handleModalClose(); if (e.key === 'Tab') trapFocus(e); }} role="dialog" aria-modal="true" tabindex="-1" aria-label={`${$t.git?.management || 'Управление Git'}: ${dashboardTitle}`}>
<div bind:this={modalEl} class="relative w-[95vw] max-w-[1600px] overflow-hidden rounded-xl bg-surface-card shadow-2xl" role="document" aria-label={`${$t.git?.management || 'Управление Git'}: ${dashboardTitle}`}>
<!-- Header -->
<div class="flex items-center justify-between border-b border-border bg-gradient-to-r from-slate-50 to-white px-6 py-4">
<div class="flex items-center justify-between border-b border-border bg-surface-page px-6 py-4">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-primary text-white shadow-sm">
<Icon name="code" size={20} strokeWidth={2} />
@@ -127,7 +161,7 @@
<!-- Git Error Banner -->
{#if model.gitError}
<div
class="mx-6 mt-4 flex items-start gap-3 rounded-lg border p-4 text-sm shadow-sm {model.gitErrorType === 'warning' ? 'border-warning bg-warning-light text-amber-900' : 'border-destructive-ring bg-destructive-light text-destructive'}"
class="mx-6 mt-4 flex items-start gap-3 rounded-lg border p-4 text-sm shadow-sm {model.gitErrorType === 'warning' ? 'border-warning bg-warning-light text-warning' : 'border-destructive-ring bg-destructive-light text-destructive'}"
role="alert"
aria-live="polite"
>
@@ -165,7 +199,7 @@
type="button"
onclick={() => model.clearGitError()}
class="flex-shrink-0 rounded p-1 transition-colors {model.gitErrorType === 'warning' ? 'hover:bg-warning-light' : 'hover:bg-destructive-light'}"
aria-label={$t.common?.close || 'Закрыть'}
aria-label={$t.git?.close_aria || $t.common?.close || 'Закрыть'}
>
<Icon name="close" size={16} strokeWidth={2} />
</button>
@@ -217,25 +251,26 @@
/>
<div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-surface-page px-4 py-3">
<div class="text-sm font-medium text-text">{$t.git?.branch_label || 'Ветка:'}</div>
<div class="flex w-full items-center gap-1 sm:w-72"><BranchSelector {dashboardId} envId={model.resolvedEnvId} bind:currentBranch={model.currentBranch} onchange={(event) => model.handleBranchChanged(event)} onmerge={handleOpenMergeDialog} /><HelpTooltip text={$t.git?.hint_branch_selector || ''} /></div>
<div class="flex w-full items-center gap-1 sm:w-72"><BranchSelector {dashboardId} envId={model.resolvedEnvId} bind:currentBranch={model.currentBranch} onchange={(event) => model.handleBranchChanged(event)} onmerge={handleOpenMergeDialog} /><HelpTooltip text={$t.git?.hint_branch_selector || ''} ariaLabel={$t.git?.help_aria || 'Справка'} /></div>
</div>
<div class="flex flex-wrap items-center gap-1 border-b border-border pb-0">
<button class={`relative -mb-px inline-flex items-center gap-2 rounded-t-lg px-4 py-2.5 text-sm font-medium transition-colors ${model.activeTab === 'workspace' ? 'border border-b-white bg-surface-card text-primary shadow-sm' : 'border border-transparent text-text-muted hover:bg-surface-muted hover:text-text'}`} onclick={() => (model.activeTab = 'workspace')}>
<div class="flex flex-wrap items-center gap-1 border-b border-border pb-0" role="tablist" aria-label={$t.git?.management || 'Управление Git'}>
<button class={`relative -mb-px inline-flex items-center gap-2 rounded-t-lg px-4 py-2.5 text-sm font-medium transition-colors ${model.activeTab === 'workspace' ? 'border border-b-white bg-surface-card text-primary shadow-sm' : 'border border-transparent text-text-muted hover:bg-surface-muted hover:text-text'}`} onclick={() => (model.activeTab = 'workspace')} role="tab" aria-selected={model.activeTab === 'workspace'} aria-controls="git-tab-panel" id="git-tab-workspace" tabindex={model.activeTab === 'workspace' ? 0 : -1}>
<Icon name="edit" size={16} strokeWidth={2} />
{$t.git?.tab_workspace || 'Фиксация изменений'}
<HelpTooltip text={$t.git?.hint_workspace || ''} />
<HelpTooltip text={$t.git?.hint_workspace || ''} ariaLabel={$t.git?.help_aria || 'Справка'} />
</button>
<button class={`relative -mb-px inline-flex items-center gap-2 rounded-t-lg px-4 py-2.5 text-sm font-medium transition-colors ${model.activeTab === 'release' ? 'border border-b-white bg-surface-card text-primary shadow-sm' : 'border border-transparent text-text-muted hover:bg-surface-muted hover:text-text'}`} onclick={() => (model.activeTab = 'release')}>
<button class={`relative -mb-px inline-flex items-center gap-2 rounded-t-lg px-4 py-2.5 text-sm font-medium transition-colors ${model.activeTab === 'release' ? 'border border-b-white bg-surface-card text-primary shadow-sm' : 'border border-transparent text-text-muted hover:bg-surface-muted hover:text-text'}`} onclick={() => (model.activeTab = 'release')} role="tab" aria-selected={model.activeTab === 'release'} aria-controls="git-tab-panel" id="git-tab-release" tabindex={model.activeTab === 'release' ? 0 : -1}>
<Icon name="lightning" size={16} strokeWidth={2} />
{$t.git?.tab_release || 'Релиз'}
<HelpTooltip text={$t.git?.hint_release || ''} />
<HelpTooltip text={$t.git?.hint_release || ''} ariaLabel={$t.git?.help_aria || 'Справка'} />
</button>
<button class={`relative -mb-px inline-flex items-center gap-2 rounded-t-lg px-4 py-2.5 text-sm font-medium transition-colors ${model.activeTab === 'operations' ? 'border border-b-white bg-surface-card text-primary shadow-sm' : 'border border-transparent text-text-muted hover:bg-surface-muted hover:text-text'}`} onclick={() => (model.activeTab = 'operations')}>
<button class={`relative -mb-px inline-flex items-center gap-2 rounded-t-lg px-4 py-2.5 text-sm font-medium transition-colors ${model.activeTab === 'operations' ? 'border border-b-white bg-surface-card text-primary shadow-sm' : 'border border-transparent text-text-muted hover:bg-surface-muted hover:text-text'}`} onclick={() => (model.activeTab = 'operations')} role="tab" aria-selected={model.activeTab === 'operations'} aria-controls="git-tab-panel" id="git-tab-operations" tabindex={model.activeTab === 'operations' ? 0 : -1}>
<Icon name="settings" size={16} strokeWidth={2} />
{$t.git?.tab_operations || 'Серверные операции'}
<HelpTooltip text={$t.git?.hint_operations || ''} />
<HelpTooltip text={$t.git?.hint_operations || ''} ariaLabel={$t.git?.help_aria || 'Справка'} />
</button>
</div>
<div id="git-tab-panel" role="tabpanel" aria-labelledby="git-tab-workspace" class="flex min-h-0 flex-1 flex-col">
{#if model.activeTab === 'workspace'}
<GitWorkspacePanel {dashboardId} envId={model.resolvedEnvId} commitHistoryKey={model.commitHistoryKey} hasWorkspaceChanges={model.hasWorkspaceChanges} changedFilesCount={model.changedFilesCount} workspaceStatus={model.workspaceStatus} workspaceLoading={model.workspaceLoading} workspaceDiff={model.workspaceDiff} committing={model.committing} generatingMessage={model.generatingMessage} bind:commitMessage={model.commitMessage} bind:autoPushAfterCommit={model.autoPushAfterCommit} loading={model.loading} pushProviderLabel={model.pushProviderLabel} onSync={() => model.handleSync()} onGenerateMessage={() => model.handleGenerateMessage()} onCommit={() => model.handleCommit()} />
{:else if model.activeTab === 'release'}
@@ -244,6 +279,7 @@
<GitOperationsPanel isPulling={model.isPulling} isPushing={model.isPushing} workspaceStatus={model.workspaceStatus} onPull={() => model.handlePull()} onPush={() => model.handlePush()} />
{/if}
</div>
</div>
{/if}
</div>
@@ -255,7 +291,7 @@
<DeploymentModal {dashboardId} envId={model.resolvedEnvId} preferredTargetStage={model.preferredDeployTargetStage} bind:show={model.showDeployModal} />
{#if model.showDeployConfirm}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onclick={() => { model.showDeployConfirm = false; }} onkeydown={(e) => { if (e.key === 'Escape') model.showDeployConfirm = false; }}>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-surface-overlay" onclick={() => { model.showDeployConfirm = false; }} onkeydown={(e) => { if (e.key === 'Escape') model.showDeployConfirm = false; }}>
<div class="bg-surface-card rounded-xl shadow-xl p-6 max-w-md w-full mx-4 border border-border" role="alertdialog" aria-modal="true" aria-label={$t.git?.deploy || 'Deploy to Environment'} onclick={(e) => e.stopPropagation()}>
<h3 class="text-lg font-semibold text-text mb-2">{$t.git?.deploy || 'Deploy to Environment'}</h3>
<p class="text-sm text-text-muted mb-4">{$t.git?.deploy_confirm_intro || 'Подтвердите деплой. Введите slug дашборда:'} <strong>{model.deployConfirmSlug}</strong></p>
@@ -333,4 +369,24 @@
targetBranch={model.mergeTargetBranch || 'dev'}
onmerged={() => model.refreshBranches()}
/>
<!-- Create Remote Repo Dialog (replaces native prompt()) -->
{#if model.showCreateRepoDialog}
<div class="fixed inset-0 z-[60] flex items-center justify-center bg-surface-overlay" onclick={(e) => { if (e.target === e.currentTarget) model.showCreateRepoDialog = false; }} onkeydown={(e) => { if (e.key === 'Escape') model.showCreateRepoDialog = false; }} role="dialog" aria-modal="true" aria-label={$t.git?.create_repo_dialog?.title || 'Create repository'}>
<div class="bg-surface-card rounded-xl shadow-xl p-6 max-w-md w-full mx-4 border border-border" role="document">
<h3 class="text-lg font-semibold text-text mb-2">{$t.git?.create_repo_dialog?.title || 'Create repository'}</h3>
<p class="text-sm text-text-muted mb-4">{($t.git?.create_repo_dialog?.name_prompt || 'Repository name for {provider}:').replace('{provider}', model.createRepoProviderLabel)}</p>
<input
bind:value={model.pendingRepoName}
class="w-full rounded-lg border border-border-strong p-2.5 text-sm text-text outline-none focus:border-primary-ring focus:ring-2 focus:ring-primary-ring mb-4"
placeholder={$t.git?.create_repo_dialog?.name_label || 'Repository name'}
onkeydown={(e) => { if (e.key === 'Enter' && model.pendingRepoName.trim()) model.confirmCreateRemoteRepo(); }}
/>
<div class="flex justify-end gap-3">
<Button variant="secondary" onclick={() => { model.showCreateRepoDialog = false; model.pendingRepoName = ''; }}>{$t.common?.cancel || 'Cancel'}</Button>
<Button onclick={() => model.confirmCreateRemoteRepo()} disabled={!model.pendingRepoName.trim()}>{$t.git?.create_repo_dialog?.confirm || 'Create'}</Button>
</div>
</div>
</div>
{/if}
<!-- #endregion GitManager -->

View File

@@ -29,13 +29,13 @@
{#if show && unfinishedMergeContext}
<div
class="absolute inset-0 z-20 flex items-center justify-center bg-black bg-opacity-40 p-4"
class="absolute inset-0 z-20 flex items-center justify-center bg-surface-overlay p-4"
role="dialog"
aria-modal="true"
aria-label={$t.git?.unfinished_merge?.title || 'Repository has an unfinished merge'}
>
<div class="max-h-[85vh] w-full max-w-2xl overflow-y-auto rounded-lg border border-warning bg-surface-card p-5 shadow-2xl">
<div class="mb-3 text-lg font-semibold text-amber-900">
<div class="mb-3 text-lg font-semibold text-warning">
{$t.git?.unfinished_merge?.title || 'Repository has an unfinished merge'}
</div>
<p class="mb-4 text-sm text-text">

View File

@@ -52,20 +52,6 @@
onCommit,
} = $props();
let commitTemplates = $derived([
{
label: $t.git?.commit_template_layout || 'Layout',
message: $t.git?.commit_template_layout_message || 'Update dashboard layout and visual arrangement',
},
{
label: $t.git?.commit_template_charts || 'Charts',
message: $t.git?.commit_template_charts_message || 'Update dashboard charts and metrics',
},
{
label: $t.git?.commit_template_filters || 'Filters',
message: $t.git?.commit_template_filters_message || 'Update dashboard filters and metadata',
},
]);
const changeCategoryRules = [
{ key: 'charts', test: (path: string) => path.includes('/charts/') || path.startsWith('charts/') },
@@ -167,6 +153,9 @@
return () => observer.disconnect();
});
// Diff view mode: line-by-line is default (lighter DOM, better a11y); user can toggle to side-by-side.
let diffViewMode = $state<'line-by-line' | 'side-by-side'>('line-by-line');
// Render only visible chunks via Diff2Html individually
let renderedHtml = $derived.by(() => {
const count = renderedChunkCount;
@@ -174,7 +163,7 @@
const visible = diffChunks.slice(0, count).join('\n');
try {
return Diff2Html.html(visible, {
outputFormat: 'side-by-side',
outputFormat: diffViewMode,
drawFileList: true,
matching: 'lines',
highlight: true,
@@ -193,6 +182,29 @@
if (!query) return lines.slice(0, 600);
return lines.filter((line) => line.toLowerCase().includes(query)).slice(0, 600);
});
// ── Scroll to raw diff (skip-link replacement for a11y) ────────
let rawDiffDetailsEl = $state<HTMLDetailsElement | null>(null);
let diffContainerEl = $state<HTMLDivElement | null>(null);
function scrollToRawDiff(): void {
if (!rawDiffDetailsEl) return;
rawDiffDetailsEl.open = true;
// After opening, scroll the diff container to the raw diff element.
requestAnimationFrame(() => {
if (diffContainerEl && rawDiffDetailsEl) {
const containerRect = diffContainerEl.getBoundingClientRect();
const targetRect = rawDiffDetailsEl.getBoundingClientRect();
diffContainerEl.scrollBy({
top: targetRect.top - containerRect.top - 16,
behavior: 'smooth',
});
// Focus the search input inside the raw diff.
const input = rawDiffDetailsEl.querySelector<HTMLInputElement>('input');
requestAnimationFrame(() => input?.focus());
}
});
}
</script>
<div class="flex min-h-0 flex-1 flex-col gap-4 lg:flex-row">
@@ -230,20 +242,6 @@
class={`h-32 w-full resize-none rounded-lg border border-border p-3 text-sm outline-none transition-colors focus:border-primary-ring focus:ring-2 focus:ring-primary-ring ${generatingMessage ? 'animate-pulse bg-surface-page' : 'bg-surface-card'}`}
placeholder={$t.git?.describe_changes || 'Опишите изменения...'}
></textarea>
<div class="mt-3 flex flex-wrap items-center gap-2">
<span class="text-xs font-medium text-text-muted">{$t.git?.commit_templates || 'Templates'}:</span>
{#each commitTemplates as template}
<Button
variant="ghost"
size="sm"
onclick={() => (commitMessage = template.message)}
disabled={generatingMessage || workspaceLoading}
class="border border-border bg-surface-page"
>
{template.label}
</Button>
{/each}
</div>
</div>
<!-- Commit action -->
@@ -286,7 +284,37 @@
</span>
{/if}
</div>
<div class="flex-1 overflow-auto bg-surface-card p-4" id="diff-scroll-container">
<div class="flex-1 overflow-auto bg-surface-card p-4" id="diff-scroll-container" bind:this={diffContainerEl}>
{#if hasWorkspaceChanges && (workspaceDiff || changeSummary.length > 0)}
<div class="mb-3 flex items-center justify-between gap-2">
<button
type="button"
class="text-xs font-medium text-primary hover:text-primary-hover cursor-pointer"
onclick={scrollToRawDiff}
>
{$t.git?.skip_to_raw_diff || 'Skip to raw diff'}
</button>
<div class="flex items-center gap-1 rounded-md border border-border bg-surface-page p-0.5 text-xs">
<span class="px-2 text-text-muted">{$t.git?.diff_view_mode || 'Diff mode'}:</span>
<button
type="button"
class={`rounded px-2 py-1 font-medium transition-colors ${diffViewMode === 'line-by-line' ? 'bg-surface-card text-text shadow-sm' : 'text-text-muted hover:text-text'}`}
onclick={() => (diffViewMode = 'line-by-line')}
aria-pressed={diffViewMode === 'line-by-line'}
>
{$t.git?.diff_view_line_by_line || 'Line-by-line'}
</button>
<button
type="button"
class={`rounded px-2 py-1 font-medium transition-colors ${diffViewMode === 'side-by-side' ? 'bg-surface-card text-text shadow-sm' : 'text-text-muted hover:text-text'}`}
onclick={() => (diffViewMode = 'side-by-side')}
aria-pressed={diffViewMode === 'side-by-side'}
>
{$t.git?.diff_view_side_by_side || 'Side-by-side'}
</button>
</div>
</div>
{/if}
{#if hasWorkspaceChanges && changeSummary.length > 0}
<div class="mb-4 rounded-lg border border-border bg-surface-page p-3">
<div class="mb-2 flex items-center gap-2 text-sm font-semibold text-text">
@@ -313,7 +341,7 @@
</div>
{/if}
{#if hasWorkspaceChanges && workspaceDiff}
<details class="mb-4 rounded-lg border border-border bg-surface-page">
<details id="git-raw-diff" bind:this={rawDiffDetailsEl} class="mb-4 rounded-lg border border-border bg-surface-page">
<summary class="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm font-semibold text-text">
<Icon name="code" size={16} class="text-text-muted" strokeWidth={2} />
{$t.git?.advanced_raw_diff_title || $t.git?.raw_diff_title || 'Advanced: raw YAML diff'}
@@ -343,9 +371,16 @@
</div>
</div>
{:else if hasWorkspaceChanges && renderedHtml}
<div class="diff-view">
<div
class="diff-view"
role="region"
aria-label={$t.git?.diff_region_label || 'Changes preview'}
aria-hidden="true"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html renderedHtml}</div>
{@html renderedHtml}
</div>
<p class="sr-only">{$t.git?.diff_rendered_hidden_hint || 'Visual diff is hidden from screen reader — use the raw diff below.'}</p>
<!-- Sentinel for IntersectionObserver — triggers next chunk load -->
{#if hasMoreChunks}
<div
@@ -385,12 +420,10 @@
</div>
{/if}
</div>
</div>
</div>
<!-- Commit History collapsible section -->
{#if dashboardId}
<details class="mt-4 rounded-lg border border-border bg-surface-card overflow-hidden">
<!-- Commit History inside right panel — no z-index overlap with commit button -->
{#if dashboardId}
<details class="mt-4 rounded-lg border border-border bg-surface-card overflow-hidden">
<summary class="flex cursor-pointer items-center gap-2 px-4 py-3 text-sm font-medium text-text hover:bg-surface-muted transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{$t.git?.history || 'Commit History'}
@@ -400,11 +433,24 @@
<CommitHistory {dashboardId} {envId} />
{/key}
</div>
</details>
{/if}
</details>
{/if}
</div>
</div>
<!-- Custom styles for diff2html -->
<style>
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
:global(.diff-view .d2h-wrapper) {
font-size: 12px;
line-height: 1.6;

View File

@@ -98,7 +98,7 @@
{#if show}
<div
class="absolute inset-0 z-30 flex items-center justify-center bg-black bg-opacity-40 p-4"
class="absolute inset-0 z-30 flex items-center justify-center bg-surface-overlay p-4"
role="dialog"
aria-modal="true"
aria-label={(mergeT().title || 'Merge {source} into {target}').replace('{source}', sourceBranch).replace('{target}', targetBranch)}

View File

@@ -317,5 +317,43 @@
"bugfix": "Bugfix",
"legacy": "Legacy",
"other": "Other"
}
},
"diff_view_mode": "Diff mode",
"diff_view_side_by_side": "Side-by-side",
"diff_view_line_by_line": "Line-by-line",
"diff_region_label": "Changes preview",
"skip_to_raw_diff": "Skip to raw diff",
"diff_rendered_hidden_hint": "Visual diff is hidden from screen reader — use the raw diff below.",
"bulk_commit_dialog": {
"title": "Bulk commit",
"message": "Enter a commit message for the selected dashboards:",
"placeholder": "Commit message...",
"confirm": "Commit"
},
"create_repo_dialog": {
"title": "Create repository",
"name_label": "Repository name",
"name_prompt": "Repository name for {provider}:",
"confirm": "Create"
},
"bulk_confirm": {
"sync_title": "Bulk sync",
"sync_message": "Sync {count} dashboards from Superset to Git?",
"pull_title": "Bulk pull",
"pull_message": "Pull changes from remote for {count} dashboards?",
"push_title": "Bulk push",
"push_message": "Push local commits to remote for {count} dashboards?",
"confirm": "Confirm"
},
"numeric_id_forbidden": "GitManager requires dashboard slug. Numeric ID is forbidden.",
"promote_branches_must_differ": "Select different source and target branches",
"direct_reason_required": "Direct merge requires an audit reason",
"direct_promote_done": "Direct promote completed. Policy violation logged.",
"mr_created": "Merge Request created on Git server",
"deploy_confirm_failed": "PROD confirmation failed. Deploy cancelled.",
"repo_url_empty": "Remote repository created, but URL is empty",
"env_required_for_init": "Environment must be selected to initialize Git for this dashboard.",
"init_fields_required": "Please fill in all fields",
"close_aria": "Close",
"help_aria": "Help"
}

View File

@@ -317,5 +317,43 @@
"bugfix": "Bugfix",
"legacy": "Legacy",
"other": "Другое"
}
},
"diff_view_mode": "Режим diff",
"diff_view_side_by_side": "Две колонки",
"diff_view_line_by_line": "Одна колонка",
"diff_region_label": "Просмотр изменений",
"skip_to_raw_diff": "Перейти к текстовому diff",
"diff_rendered_hidden_hint": "Визуальный diff скрыт от скринридера — используйте текстовый diff ниже.",
"bulk_commit_dialog": {
"title": "Массовый commit",
"message": "Введите сообщение коммита для выбранных дашбордов:",
"placeholder": "Сообщение коммита...",
"confirm": "Зафиксировать"
},
"create_repo_dialog": {
"title": "Создать репозиторий",
"name_label": "Имя репозитория",
"name_prompt": "Имя репозитория для {provider}:",
"confirm": "Создать"
},
"bulk_confirm": {
"sync_title": "Массовая синхронизация",
"sync_message": "Синхронизировать {count} дашбордов из Superset в Git?",
"pull_title": "Массовый pull",
"pull_message": "Получить изменения из remote для {count} дашбордов?",
"push_title": "Массовый push",
"push_message": "Отправить локальные коммиты в remote для {count} дашбордов?",
"confirm": "Подтвердить"
},
"numeric_id_forbidden": "GitManager требует slug дашборда. Числовой ID запрещён.",
"promote_branches_must_differ": "Выберите разные исходную и целевую ветки",
"direct_reason_required": "Для небезопасного прямого переноса укажите причину",
"direct_promote_done": "Прямой перенос выполнен. Нарушение политики записано в логи.",
"mr_created": "Merge Request создан на Git сервере",
"deploy_confirm_failed": "Подтверждение PROD не пройдено. Деплой отменён.",
"repo_url_empty": "Удалённый репозиторий создан, но URL пуст",
"env_required_for_init": "Для инициализации Git выберите окружение.",
"init_fields_required": "Заполните все поля",
"close_aria": "Закрыть",
"help_aria": "Справка"
}

View File

@@ -289,6 +289,14 @@ export class GitManagerModel {
/** Target branch name for merge dialog (default: dev). */
mergeTargetBranch: string = $state('dev');
// ── Create Remote Repo Dialog ────────────────────────────────
/** True when the create-remote-repo modal is open (replaces native prompt()). */
showCreateRepoDialog: boolean = $state(false);
/** Pending repo name being typed in the create-repo dialog. */
pendingRepoName: string = $state('');
/** Provider label for the create-repo dialog prompt. */
createRepoProviderLabel: string = $state('');
// ── Error Banner ────────────────────────────────────────────
gitError: GitErrorPayload | null = $state(null);
gitErrorType: string = $state('error');
@@ -477,7 +485,7 @@ export class GitManagerModel {
if (isNumericDashboardRef(this.dashboardId)) {
this.checkingStatus = false;
this.initialized = false;
addToast('GitManager requires dashboard slug. Numeric ID is forbidden.', 'error');
addToast((this._t?.git as Record<string, unknown>)?.numeric_id_forbidden as string || 'GitManager requires dashboard slug. Numeric ID is forbidden.', 'error');
return;
}
this.checkingStatus = true;
@@ -542,7 +550,7 @@ export class GitManagerModel {
*/
async handleSync(): Promise<void> {
if (isNumericDashboardRef(this.dashboardId)) {
addToast('GitManager requires dashboard slug. Numeric ID is forbidden.', 'error');
addToast((this._t?.git as Record<string, unknown>)?.numeric_id_forbidden as string || 'GitManager requires dashboard slug. Numeric ID is forbidden.', 'error');
return;
}
this.clearGitError();
@@ -622,11 +630,11 @@ export class GitManagerModel {
*/
async handlePromote(): Promise<void> {
if (!this.promoteFromBranch || !this.promoteToBranch || this.promoteFromBranch === this.promoteToBranch) {
addToast('Выберите разные исходную и целевую ветки', 'error');
addToast((this._t?.git as Record<string, unknown>)?.promote_branches_must_differ as string || 'Выберите разные исходную и целевую ветки', 'error');
return;
}
if (this.promoteMode === 'direct' && !String(this.promoteReason || '').trim()) {
addToast('Для небезопасного прямого переноса укажите причину', 'error');
addToast((this._t?.git as Record<string, unknown>)?.direct_reason_required as string || 'Для небезопасного прямого переноса укажите причину', 'error');
return;
}
this.clearGitError();
@@ -645,10 +653,10 @@ export class GitManagerModel {
this.resolvedEnvId,
);
if (this.promoteMode === 'direct') {
addToast('Прямой перенос выполнен. Нарушение политики записано в логи.', 'warning');
addToast((this._t?.git as Record<string, unknown>)?.direct_promote_done as string || 'Прямой перенос выполнен. Нарушение политики записано в логи.', 'warning');
} else {
if (response?.url) window.open(response.url, '_blank', 'noopener,noreferrer');
addToast('Merge Request создан на Git сервере', 'success');
addToast((this._t?.git as Record<string, unknown>)?.mr_created as string || 'Merge Request создан на Git сервере', 'success');
}
} catch (e: unknown) {
this._setGitError(e);
@@ -724,7 +732,7 @@ export class GitManagerModel {
*/
confirmDeploy(slug: string): void {
if (slug.trim() !== this.deployConfirmSlug) {
addToast('Подтверждение PROD не пройдено. Деплой отменен.', 'error');
addToast((this._t?.git as Record<string, unknown>)?.deploy_confirm_failed as string || 'Подтверждение PROD не пройдено. Деплой отменен.', 'error');
this.showDeployConfirm = false;
return;
}
@@ -745,10 +753,19 @@ export class GitManagerModel {
return;
}
if (!this.selectedConfigId && config.id) this.selectedConfigId = String(config.id);
const suggestedName = buildSuggestedRepoName(this.dashboardTitle, this.dashboardId);
const inputName = prompt(`Repository name for ${config.provider}:`, suggestedName);
const repoName = String(inputName || '').trim();
// Open modal dialog instead of native prompt() — non-blocking, i18n, a11y.
this.pendingRepoName = buildSuggestedRepoName(this.dashboardTitle, this.dashboardId);
this.createRepoProviderLabel = String(config.provider || '');
this.showCreateRepoDialog = true;
}
/** Confirm create-remote-repo from the modal dialog (replaces prompt() return value). */
async confirmCreateRemoteRepo(): Promise<void> {
const repoName = String(this.pendingRepoName || '').trim();
if (!repoName) return;
const config = resolveDefaultConfig(this.configs, this.selectedConfigId);
if (!config) return;
this.showCreateRepoDialog = false;
this.clearGitError();
this.creatingRemoteRepo = true;
try {
@@ -760,7 +777,7 @@ export class GitManagerModel {
default_branch: 'prod',
} satisfies CreateRepoPayload);
const url = repo?.clone_url || repo?.html_url || '';
if (!url) throw new Error('Remote repository created, but URL is empty');
if (!url) throw new Error((this._t?.git as Record<string, unknown>)?.repo_url_empty as string || 'Remote repository created, but URL is empty');
this.remoteUrl = url;
addToast(`Repository created on ${config.provider}`, 'success');
} catch (e: unknown) {
@@ -782,11 +799,11 @@ export class GitManagerModel {
*/
async handleInit(): Promise<void> {
if (!this.selectedConfigId || !this.remoteUrl) {
addToast((this._t?.git as Record<string, unknown>)?.init_validation_error as string || 'Заполните все поля', 'error');
addToast((this._t?.git as Record<string, unknown>)?.init_fields_required as string || (this._t?.git as Record<string, unknown>)?.init_validation_error as string || 'Заполните все поля', 'error');
return;
}
if (!this.resolvedEnvId && !isNumericDashboardRef(this.dashboardId)) {
addToast('Environment must be selected to initialize Git for this dashboard.', 'error');
addToast((this._t?.git as Record<string, unknown>)?.env_required_for_init as string || 'Environment must be selected to initialize Git for this dashboard.', 'error');
return;
}
this.clearGitError();

View File

@@ -239,31 +239,39 @@ describe("GitManagerModel — L1 invariants (no render)", () => {
});
describe("handleCreateRemoteRepo", () => {
it("returns early when no config", async () => { await model.handleCreateRemoteRepo(); expect(gitService.createRemoteRepository).not.toHaveBeenCalled(); });
it("returns early when no config", async () => {
await model.handleCreateRemoteRepo();
expect(model.showCreateRepoDialog).toBe(false);
expect(gitService.createRemoteRepository).not.toHaveBeenCalled();
});
it("handles 409 already exists error", async () => {
model.configs = [{ id: "cfg-1", provider: "github" }]; model.selectedConfigId = "cfg-1"; model.dashboardTitle = "Test";
const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("my-repo");
await model.handleCreateRemoteRepo();
expect(model.showCreateRepoDialog).toBe(true);
model.pendingRepoName = "my-repo";
const err = new Error("already exists"); (err as any).status = 409;
vi.mocked(gitService.createRemoteRepository).mockRejectedValue(err);
await model.handleCreateRemoteRepo();
await model.confirmCreateRemoteRepo();
expect(addToast).toHaveBeenCalledWith("Already exists", "warning", 0);
promptSpy.mockRestore();
});
it("handles other error via _setGitError", async () => {
model.configs = [{ id: "cfg-1", provider: "github" }]; model.selectedConfigId = "cfg-1";
const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("my-repo");
vi.mocked(gitService.createRemoteRepository).mockRejectedValue(new Error("Server error"));
await model.handleCreateRemoteRepo();
expect(model.showCreateRepoDialog).toBe(true);
model.pendingRepoName = "my-repo";
vi.mocked(gitService.createRemoteRepository).mockRejectedValue(new Error("Server error"));
await model.confirmCreateRemoteRepo();
expect(model.gitError).not.toBeNull();
expect(model.creatingRemoteRepo).toBe(false);
promptSpy.mockRestore();
});
it("returns early when user cancels prompt", async () => {
model.configs = [{ id: "cfg-1", provider: "github" }];
const promptSpy = vi.spyOn(window, "prompt").mockReturnValue(null);
await model.handleCreateRemoteRepo();
expect(model.showCreateRepoDialog).toBe(true);
expect(gitService.createRemoteRepository).not.toHaveBeenCalled();
promptSpy.mockRestore();
// Close dialog without confirming
model.showCreateRepoDialog = false;
model.pendingRepoName = "";
});
});
@@ -544,21 +552,23 @@ describe("GitManagerModel — L1 invariants (no render)", () => {
it("handleCreateRemoteRepo succeeds with clone_url", async () => {
model.configs = [{ id: "cfg-1", provider: "github" }]; model.selectedConfigId = "cfg-1";
model.dashboardTitle = "Test Dash";
const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("my-repo");
vi.mocked(gitService.createRemoteRepository).mockResolvedValue({ clone_url: "https://github.com/org/repo.git" });
await model.handleCreateRemoteRepo();
expect(model.showCreateRepoDialog).toBe(true);
model.pendingRepoName = "my-repo";
vi.mocked(gitService.createRemoteRepository).mockResolvedValue({ clone_url: "https://github.com/org/repo.git" });
await model.confirmCreateRemoteRepo();
expect(model.remoteUrl).toBe("https://github.com/org/repo.git");
promptSpy.mockRestore();
});
it("handleCreateRemoteRepo succeeds with html_url", async () => {
model.configs = [{ id: "cfg-1", provider: "gitlab" }]; model.selectedConfigId = "cfg-1";
model.dashboardTitle = "Test Dash";
const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("my-repo");
vi.mocked(gitService.createRemoteRepository).mockResolvedValue({ html_url: "https://gitlab.com/org/repo" });
await model.handleCreateRemoteRepo();
expect(model.showCreateRepoDialog).toBe(true);
model.pendingRepoName = "my-repo";
vi.mocked(gitService.createRemoteRepository).mockResolvedValue({ html_url: "https://gitlab.com/org/repo" });
await model.confirmCreateRemoteRepo();
expect(model.remoteUrl).toBe("https://gitlab.com/org/repo");
promptSpy.mockRestore();
});
});
});

View File

@@ -41,7 +41,7 @@
{#if show}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
class="fixed inset-0 z-50 flex items-center justify-center bg-surface-overlay"
onclick={handleBackdrop}
onkeydown={handleKeydown}
>

View File

@@ -9,15 +9,17 @@
/**
* Help tooltip component.
* @prop {string} text - Tooltip text content (plain text, supports both EN/RU inline).
* @prop {string} ariaLabel - Accessible label for the help button (default: "Help").
*/
let { text } = $props();
let { text, ariaLabel = 'Help' } = $props();
</script>
<span
class="group relative inline-flex items-center justify-center align-middle"
role="button"
tabindex="0"
aria-label={text}
aria-label={ariaLabel}
title={text}
>
<!-- Help circle icon -->
<svg
@@ -59,14 +61,15 @@
<span
class="
absolute top-full left-1/2 -translate-x-1/2
border-4 border-transparent border-t-gray-200
border-4 border-transparent border-t-border
"
aria-hidden="true"
></span>
<span
class="
absolute top-full left-1/2 -translate-x-1/2 mt-px
border-4 border-transparent border-t-white
border-4 border-transparent border-t-surface-card
"
aria-hidden="true"
></span>

View File

@@ -23,37 +23,30 @@ describe('HelpTooltip', () => {
expect(container.textContent).toContain('This is helpful');
});
it('sets aria-label on the container', () => {
const { container } = render(HelpTooltip, { props: { text: 'Instruction' } });
it('sets aria-label on the container', () => {
const { container } = render(HelpTooltip, { props: { text: 'Instruction', ariaLabel: 'Help' } });
const span = container.querySelector('span[role="button"]');
expect(span!.getAttribute('aria-label')).toBe('Instruction');
expect(span!.getAttribute('aria-label')).toBe('Help');
expect(span!.getAttribute('title')).toBe('Instruction');
});
it('renders tooltip with empty text when text prop is not provided', () => {
it('renders tooltip with custom ariaLabel', () => {
const { container } = render(HelpTooltip, { props: { text: 'Instruction', ariaLabel: 'Custom help' } });
const span = container.querySelector('span[role="button"]');
expect(span!.getAttribute('aria-label')).toBe('Custom help');
});
it('renders tooltip with default ariaLabel when not provided', () => {
const { container } = render(HelpTooltip);
const tooltip = container.querySelector('span[role="button"]');
// When text is not provided, aria-label is omitted (undefined → attribute removed)
expect(tooltip!.getAttribute('aria-label')).toBeNull();
const span = container.querySelector('span[role="button"]');
expect(span!.getAttribute('aria-label')).toBe('Help');
});
it('renders tooltip text with empty default', () => {
const { container } = render(HelpTooltip);
// text is undefined, renders as empty string
const tooltipSpans = container.querySelectorAll('span.absolute');
expect(tooltipSpans.length).toBeGreaterThan(0);
});
it('renders tooltip with explicit empty string text', () => {
const { container } = render(HelpTooltip, { props: { text: '' } });
it('renders tooltip with empty string ariaLabel', () => {
const { container } = render(HelpTooltip, { props: { text: '', ariaLabel: '' } });
const span = container.querySelector('span[role="button"]');
expect(span!.getAttribute('aria-label')).toBe('');
});
it('renders tooltip with explicit whitespace text', () => {
const { container } = render(HelpTooltip, { props: { text: ' ' } });
const span = container.querySelector('span[role="button"]');
expect(span!.getAttribute('aria-label')).toBe(' ');
});
});
// NOTE: The remaining uncovered branch (50% coverage) is a Svelte 5 compiler artifact
// from the compiled $props() default argument handling (`text = ""`).

View File

@@ -245,6 +245,7 @@ import { SvelteSet } from "svelte/reactivity";
statusMode="repository"
envId={selectedEnvId || null}
repositoriesOnly={activeTab === 'repos'}
onDashboardsChanged={(next: DashboardMetadata[]) => { allDashboards = next; }}
/>
{:else}
<EmptyState

View File

@@ -54,6 +54,7 @@ export default {
page: '#f8fafc', // slate-50
card: '#ffffff', // white
muted: '#f1f5f9', // slate-100
overlay: 'rgba(15, 23, 42, 0.5)', // slate-900/50 — modal backdrop
},
// ── Border hierarchy ──────────────────────────────────────
border: {

View File

@@ -0,0 +1,384 @@
# Tasks: Task Status Center
**Input**: Design documents from `/specs/034-task-status-center/`
**Prerequisites**: plan.md ✅, spec.md ✅, ux_reference.md ✅, research.md ✅, data-model.md ✅, contracts/modules.md ✅
**Tests**: All C4 contracts require tests verifying `@PRE`/`@POST`/`@INVARIANT`. Rejected-path regression tests for RBAC filtering.
**Organization**: Tasks grouped by user story — each story independently implementable and testable.
---
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Verify branch, environment, and prerequisites.
- [ ] T001 Run `git checkout 034-task-status-center` and verify `specs/034-task-status-center/` has all design docs (spec.md, plan.md, research.md, data-model.md, contracts/modules.md, quickstart.md, ux_reference.md)
- [ ] T002 Run `cd backend && source .venv/bin/activate && python -m pytest backend/tests/services/reports/ -v` — verify existing reports tests pass before any changes
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Core infrastructure that ALL user stories depend on — types, RBAC filter, API client, type profiles.
**⚠️ CRITICAL**: No user story work until this phase passes verification.
### Data Layer (backend + frontend — parallel)
- [ ] T003 [P] Add `TaskSummary`, `StatusCounts`, `TaskTypeSummary` Pydantic schemas to `backend/src/models/report.py`
Extend existing `ReportModels` region — add schemas from data-model.md §2.1
@POST: `TaskSummary(by_type: list[TaskTypeSummary], total_tasks: int, active_tasks: int)` with all counts ≥0
- [ ] T004 [P] Create TypeScript DTOs in `frontend/src/types/reports.ts`
Interfaces from data-model.md §3: `TaskType`, `ReportStatus`, `ErrorContext`, `TaskReport`, `ReportQuery`, `ReportCollection`, `ReportDetailView`, `StatusCounts`, `TaskTypeSummary`, `TaskSummary`, `TaskStatusEvent`, `TaskCenterFilter`, `ScreenState`
@DATA_CONTRACT: each interface references `backend/src/models/report.py` counterpart via JSDoc `@see`
- [ ] T005 [P] Add `clean_release` profile to `frontend/src/lib/components/reports/reportTypeProfiles.ts`
Add entry: `clean_release: { key: 'clean_release', label: 'Clean Release', variant: 'success', icon: 'shield-check', fallback: false }`
RATIONALE: R8 — sync frontend profiles with backend `TaskType` enum (was missing `clean_release`)
### RBAC & API Client (sequential after T003/T004)
- [ ] T006 Implement `_filter_tasks_by_rbac()` in `backend/src/services/reports/report_service.py`
@PRE: current_user is authenticated User, tasks is list of in-memory Task objects
@POST: returns tasks filtered by role — admin→all, analyst→own+system(user_id=None), viewer→own only
RATIONALE: extracted as pure function for testability (R4), no side effects, no service dependencies
REJECTED: inline filtering in list_reports — mixes authorization with query logic
REJECTED: filtering at TaskGraph level — graph is data structure, not auth boundary
@TEST_EDGE: admin→all_tasks_returned, analyst→own_plus_system, viewer→only_own, no_matching→empty_list
- [ ] T007 [P] Add `getReportsSummary()` and type existing functions in `frontend/src/lib/api/reports.ts`
New: `export async function getReportsSummary(): Promise<TaskSummary>``GET /api/reports/summary` via `api.fetchApi`
Enhance: `getReports()` returns `Promise<ReportCollection>`, `getReportDetail()` returns `Promise<ReportDetailView>` (replace generic `<T>`)
@DATA_CONTRACT: ReportQuery → ReportCollection, (none) → TaskSummary, reportId → ReportDetailView
import types from `$types/reports`
- [ ] T008 [P] Add `"created_at"` to allowed `sort_by` values in `backend/src/models/report.py` `ReportQuery._validate_sort_by` validator
Change allowed set from `{"updated_at", "status", "task_type"}` to `{"updated_at", "created_at", "status", "task_type"}`
- [ ] T008a [P] Add `ReportsSettings` Pydantic schema to `backend/src/models/report.py`
Schema: `class ReportsSettings(BaseModel): disabled_task_types: list[TaskType] = Field(default_factory=list)`
@POST: `ReportsSettings(disabled_task_types: list[TaskType])` — validates task types against enum
- [ ] T008b Implement `GET /api/settings/reports` and `PUT /api/settings/reports` endpoints in `backend/src/api/routes/settings.py` (or `reports.py`)
GET: returns `{ disabled_task_types: [...] }` — any authenticated user with `tasks:READ`
PUT: accepts `{ disabled_task_types: [...] }` — admin only (`settings:WRITE` permission), validates types against `TaskType` enum
@PRE: GET — authenticated user; PUT — admin role with `settings:WRITE`
@POST: GET → `ReportsSettings`; PUT → updated `ReportsSettings`
@TEST_EDGE: get_returns_defaults→empty_list, put_by_admin→saved, put_by_non_admin→403, put_invalid_type→422
RATIONALE: UX group visibility feature — admin can disable task types globally for all users
- [ ] T008c [P] Add `getReportsSettings()` to `frontend/src/lib/api/reports.ts`
New: `export async function getReportsSettings(): Promise<ReportsSettings>``GET /api/settings/reports`
New: `export async function updateReportsSettings(settings: ReportsSettings): Promise<ReportsSettings>``PUT /api/settings/reports`
import `ReportsSettings` from `$types/reports`
### Foundational Verification
- [ ] T009 Verify foundational phase — run `cd backend && python -m ruff check .`, `cd frontend && npm run lint`, `cd backend && source .venv/bin/activate && python -m pytest backend/tests/ -k "reports" -v`
All existing tests must pass. New schemas must not break existing code.
**Checkpoint**: Types, schemas, RBAC filter, and API client ready — user stories can now begin.
---
## Phase 3: User Story 1 — Сводный обзор всех задач (Priority: P1) 🎯 MVP
**Goal**: На странице `/reports` появляется сводная панель над списком отчётов с агрегированными счётчиками по статусам (pending, running, awaiting_input, success, failed) для каждого типа задач, обновляемая в реальном времени через WebSocket.
**Independent Test**: Открыть `/reports` → сводная панель с карточками тип×статус, счётчики корректны, при изменении статуса задачи через API счётчики обновляются без перезагрузки страницы.
### Tests for User Story 1 (write FIRST, expect FAIL)
- [ ] T010 [P] [US1] Write L1 model test for `TaskCenterModel` invariants in `frontend/src/lib/models/__tests__/TaskCenterModel.test.ts`
@TEST_INVARIANT: active tasks sorted before completed, visibleSummary excludes disabledTypes AND not in visibleTypes
@TEST_EDGE: empty_tasks→summary_shows_zeros, ws_disconnect→screenState='disconnected', ws_reconnect→screenState='ready'
@TEST_EDGE: toggleTypeVisibility→localStorage_updated, admin_disabled_type→hidden_from_visibleSummary
Use vitest, no DOM render (~27ms target). Mock `fetchApi` and `WebSocket`.
- [ ] T011 [P] [US1] Write contract test for `Reports.SummaryService.get_summary()` in `backend/tests/services/reports/test_report_service.py`
@TEST_EDGE: admin_role→all_tasks_counted, analyst_role→own_plus_system_counted, viewer_role→only_own_counted
@TEST_EDGE: empty_tasks→TaskSummary(total_tasks=0, active_tasks=0, by_type=[])
@TEST_CONTRACT: `TaskSummary` invariants — all counts ≥0, total == sum of all status counts across all types
### Backend Implementation for User Story 1
- [ ] T012 [US1] Implement `ReportsService.get_summary()` method in `backend/src/services/reports/report_service.py`
@PRE: task_manager initialized, current_user authenticated with tasks:READ
@POST: TaskSummary with counts filtered by user role — aggregate `get_all_tasks()` → normalize → group by (task_type × status) → TaskTypeSummary per type
@SIDE_EFFECT: None (read-only aggregation)
RATIONALE: RBAC filtering in service layer (R4), separate method for independent caching (R1)
REJECTED: combining into list_reports header, WebSocket-only delivery for initial load
Use `_filter_tasks_by_rbac(tasks, current_user)` from T006, then `resolve_task_type(task.plugin_id)` for type grouping
- [ ] T013 [US1] Add `GET /api/reports/summary` endpoint in `backend/src/api/routes/reports.py`
@PRE: current_user authenticated via `Depends(get_current_user)`
@POST: returns `TaskSummary` — summary counts reflect RBAC (admin→all, analyst→own+system, viewer→own)
RATIONALE: separate endpoint from list_reports (R1) — lightweight, independently cacheable
REJECTED: combining summary into list_reports header, WebSocket-only summary delivery
Route: `@router.get("/summary", response_model=TaskSummary)`, inject `task_manager` + `clean_release_repo` dependencies
- [ ] T014 [US1] Enhance `ReportsService.list_reports()` in `backend/src/services/reports/report_service.py` with RBAC row-level filtering
@PRE: current_user passed to service method
@POST: ReportCollection.items filtered by user role (same rules as summary)
Add `current_user` parameter. Call `_filter_tasks_by_rbac()` in `_load_normalized_reports()` before normalization.
RATIONALE: TSC-FR-009 requires RBAC on both summary AND list — not just gate, but row-level visibility
REJECTED: separate admin/user list endpoints — single endpoint with role-based filtering avoids route duplication
### Frontend Model (core — all stories depend on it)
- [ ] T015 [US1] Implement `TaskCenterModel` in `frontend/src/lib/models/TaskCenterModel.svelte.ts`
@STATE: `tasks`, `summary`, `filters`, `screenState`, `wsConnected`, `selectedTaskId`, `page`, `pageSize`, `disabledTypes` (admin-disabled, from backend), `visibleTypes` (operator preference, from localStorage), `lastUpdated`
@ACTION: `loadInitialData(initialFilters?)` — parallel fetch summary + list + settings, then connect WS
@ACTION: `connectWebSocket()` — subscribe to `getTaskEventsWsUrl()`, buffer events, 100ms debounce flush
@ACTION: `disconnectWebSocket()`, `destroy()` — cleanup WS, timers
@ACTION: `toggleTypeVisibility(type)` — toggle type in `visibleTypes`, persist to localStorage
@INVARIANT: active tasks (PENDING, RUNNING, AWAITING_INPUT) sorted before completed tasks in `$derived filteredTasks`
@INVARIANT: `visibleSummary` excludes types in `disabledTypes` (admin) AND not in `visibleTypes` (operator)
@INVARIANT: DECOMPOSITION GATE: 400 lines, 40 methods (ADR-0010)
@SIDE_EFFECT: connects to `/ws/task-events` WebSocket, calls `ReportsApi`, updates browser URL via `history.replaceState()`, reads/writes `localStorage` for `visibleTypes`
@POST: all public state-mutating methods trigger `$derived` recomputation
@POST: WS lifecycle managed: connect on `loadInitialData`, disconnect on `destroy`
RATIONALE: single model (R3) — 3 stories share the same task dataset; ~400 lines, at ADR-0010 gate
REJECTED: submodels from day one, global store (page-scoped state), polling-based refresh
Private: `_eventBuffer`, `_flushTimeout`, `_reconnectAttempt`(0), `_reconnectTimer`, `_scheduleReconnect()` with exponential backoff 1s→2s→4s→8s→max 30s
### Frontend Components (US1)
- [ ] T016 [US1] Implement `SummaryPanel` in `frontend/src/lib/components/reports/SummaryPanel.svelte`
@UX_STATE: `loading`→skeleton grid (4 cards with pulse animation), `ready`→grid of clickable cards (2-6 cols responsive), `empty`→all cards show zero counts, `disconnected`→cards visible with reduced opacity + "disconnected" badge
@UX_FEEDBACK: count change → CSS `transition: all 200ms` on count numbers, card click → calls `onFilterByTypeAndStatus(taskType, status)`
@UX_RECOVERY: disconnected → auto-reconnect indicator, manual reconnect via `onReconnect` prop
Props: `summary: TaskSummary|null`, `screenState: ScreenState`, `onFilterByTypeAndStatus: (TaskType, ReportStatus)=>void`
Uses `<Card>` from `$lib/ui/Card.svelte` (existing). Colors per ux_reference.md: running→token `primary`, pending→`muted`, success→`success`, failed→`destructive`, awaiting_input→`warning`.
@POST: clicking card emits `onFilterByTypeAndStatus(taskType, status)` — will be wired to model in Phase 4 (US2)
- [ ] T017 [US1] Add WebSocket connection indicator to `frontend/src/routes/reports/+page.svelte` (inline in page header)
Green dot `●` when `m.wsConnected===true`, yellow pulsing `◉` when reconnecting, red `○` when disconnected
Uses semantic tokens: `bg-success` / `bg-warning` / `bg-destructive` in a `w-3 h-3 rounded-full` span
Shows tooltip on hover: "Подключено / Переподключение / Соединение потеряно"
### Frontend Page (US1 — thin render layer)
- [ ] T018 [US1] Create `frontend/src/routes/reports/+page.ts` load function
Parse `url.searchParams` into initial `TaskCenterFilter`: `task_types` (comma-sep→array), `statuses` (comma-sep→array), `search`, `time_range`, `sort_by`, `sort_order`
Return `{ initialFilters }` for the page component
RATIONALE: R5 — filter state survives page refresh via URL query params
- [ ] T019 [US1] Rewrite `frontend/src/routes/reports/+page.svelte` as thin render layer
Layout: `<PageHeader title="Центр статусов">` + refresh `<Button variant="ghost">` (calls `m.refreshSummary()`)
Below: `<SummaryPanel>`, existing `<ReportCard>`-based list (placeholder for US2-enhanced `<TaskList>`), `<ConnectionIndicator>`
Imports and instantiates `TaskCenterModel`, calls `m.loadInitialData(data.initialFilters)` in `onMount`, cleans up via `$effect` return
Model instance: `const m = new TaskCenterModel()`
RATIONALE: thin render layer per ADR-0006 model-first pattern — all state/logic in Model
Uses `<PageHeader>` from `$lib/ui/PageHeader.svelte` (existing), `<Button variant="ghost">` from `$lib/ui/Button.svelte` (existing)
Disconnected banner: `<div class="bg-warning-light border border-warning-ring text-warning p-3 rounded-lg mb-4">` with text "Соединение потеряно. Попытка переподключения..." + `<Button variant="secondary">Подключиться сейчас</Button>`
### Verification for User Story 1
- [ ] T020 [US1] Write L2 UX test for `SummaryPanel` in `frontend/src/lib/components/reports/__tests__/SummaryPanel.test.ts`
@UX_TEST: render_with_summary→cards_visible, click_card→calls_onFilterByTypeAndStatus, loading_state→skeleton_visible, zero_counts→cards_show_0, disconnected_state→opacity_reduced
- [ ] T021 [US1] Run US1 verification — `cd backend && source .venv/bin/activate && python -m pytest backend/tests/services/reports/ backend/tests/api/routes/test_reports.py -v`, `cd frontend && npm run test -- TaskCenterModel SummaryPanel`, `cd frontend && npm run lint`
Confirm: summary endpoint returns correct counts, RBAC filtering works per role, model invariants hold, SummaryPanel renders skeletons/empty/ready states
- [ ] T022 [US1] Semantic audit — manually verify against `ux_reference.md` §3 states:
- `loading` → full-page skeleton (pulse animation)
- `empty` → icon + "Нет активных задач" + navigation hints (inbox icon from `<Icon name="inbox">`)
- `disconnected` → data visible with red indicator + reconnect banner
- `error` → error banner + "Повторить загрузку" button
- `idle` → summary cards + list, green connection indicator
**Checkpoint**: Summary dashboard functional — open `/reports`, see aggregated counts, watch them update via WebSocket.
---
## Phase 4: User Story 2 — Детальный список задач с фильтрацией (Priority: P2)
**Goal**: Оператор фильтрует список задач по типу, статусу, тексту и времени; кликает по карточке сводки → список фильтруется по тип×статус; активные задачи всегда сверху.
**Independent Test**: На `/reports` кликнуть по карточке сводки «Миграция / Выполняется (3)» → список показывает только выполняющиеся миграции, остальные скрыты.
### Frontend Implementation for User Story 2
- [ ] T023 [US2] Extend `TaskCenterModel` in `frontend/src/lib/models/TaskCenterModel.svelte.ts` — add filter logic + URL sync
@ACTION: `applyFilter(partial: Partial<TaskCenterFilter>)` — merges partial into `filters`, resets `page` to 1, calls `_syncFiltersToUrl()`
@ACTION: `clearFilters()` — resets `filters` to defaults, calls `_syncFiltersToUrl()`
Private `_syncFiltersToUrl()`: serializes non-default filters to URL query params via `history.replaceState(null, '', url)`, debounced 300ms
`filteredTasks` $derived: apply `task_types[]`, `statuses[]`, `search` (match on summary + task_id + source_ref), `time_range` (1h/24h/7d/30d/all → compute time_from from now)
RATIONALE: R5 — URL query params survive refresh, `replaceState` avoids back-button pollution
@TEST_EDGE: apply_type_filter→only_matching_types, clearFilters→all_tasks_restored, search_empty_string→no_filtering
- [ ] T024 [US2] Implement `FilterBar` in `frontend/src/lib/components/reports/FilterBar.svelte`
@UX_STATE: `default`→all filters empty, showing all tasks; `filtered`→active filter badges visible with "✕" clear button
@UX_FEEDBACK: filter change → immediate visual indicator on active filter badges (badge with count), empty result → "Нет задач, соответствующих фильтрам" + "Сбросить фильтры" button
Contains: task type `<Select multiple>` (from `$lib/ui/Select.svelte` existing), status `<Select multiple>`, text `<Input type="search">` (from `$lib/ui/Input.svelte` existing), time range `<Select>`, sort_by `<Select>`, sort_order toggle button
Props: `filters: TaskCenterFilter`, `onApplyFilter(partial)`, `onClearFilters()`
Uses semantic tokens: `bg-surface-card border border-border text-text` for the bar container
- [ ] T025 [US2] Enhance `TaskList` in `frontend/src/lib/components/reports/TaskList.svelte` (modify existing)
@UX_STATE: `loading`→10 skeleton rows, `empty``<EmptyState>` with "Нет задач" (inbox icon + links "Запустить перевод/миграцию/бекап"), `ready`→task rows with pagination
@UX_STATE: `filtered_empty`→"Нет задач, соответствующих фильтрам" + "Сбросить фильтры" button
@UX_FEEDBACK: status change → animated badge transition (`transition: background-color 300ms`), task completion → brief row highlight (green flash SUCCESS, red flash FAILED), new task → slide-in from top
Each row shows: type icon (via `getReportTypeProfile`), task_id/summary, status `<span>` badge (inline Tailwind: `rounded-full px-2.5 py-0.5 text-xs font-medium` with colors: success→`bg-success-light text-success`, failed→`bg-destructive-light text-destructive`, running→`bg-primary/10 text-primary`, pending→`bg-surface-muted text-text-muted`, awaiting_input→`bg-warning-light text-warning`), relative time ("2 мин назад"), duration, source_ref display
Pagination: prev/next buttons + "Page X of Y" display. Uses `<Button variant="secondary">` from `$lib/ui`
Sort: active tasks (PENDING, RUNNING, AWAITING_INPUT) always sorted before completed
Props: `tasks: TaskReport[]`, `screenState: ScreenState`, `selectedTaskId: string|null`, `page: number`, `onSelectTask(taskId)`, `onPageChange(n)`
Uses `<EmptyState>` from `$lib/ui/EmptyState.svelte` (existing)
- [ ] T026 [US2] Wire `SummaryPanel` click → filter in `+page.svelte`
Update `<SummaryPanel onFilterByTypeAndStatus={...}>` callback to call `m.applyFilter({ task_types: [type], statuses: [status] })`
Integrate `<FilterBar>` and enhanced `<TaskList>` into page layout below `<SummaryPanel>`
Page structure: `PageHeader → SummaryPanel → FilterBar → TaskList`
### Verification for User Story 2
- [ ] T027 [US2] Write L2 UX test for `FilterBar` in `frontend/src/lib/components/reports/__tests__/FilterBar.test.ts`
@UX_TEST: select_type→calls_onApplyFilter_with_task_types, select_status→calls_onApplyFilter_with_statuses, type_search→calls_onApplyFilter_with_search, click_clear→calls_onClearFilters, active_badge→visible_with_count
- [ ] T028 [US2] Run US2 verification — `cd frontend && npm run test -- FilterBar TaskList TaskCenterModel`, `cd frontend && npm run lint`
Confirm: type filter shows only matching tasks, status filter works, search matches summary/task_id, time range filters correctly, active tasks always sorted first, pagination controls work, clear filters restores full list
**Checkpoint**: Full filtering working — summary cards are clickable filters, multi-select dropdowns work, search and time range functional.
---
## Phase 5: User Story 3 — Быстрый переход к логам и результатам задачи (Priority: P3)
**Goal**: Клик по задаче открывает Task Drawer с логами; hover показывает тултип с деталями; задачи AWAITING_INPUT отображаются с индикатором и кнопкой «Ответить».
**Independent Test**: На `/reports` кликнуть по FAILED задаче → открывается Task Drawer с логами, скролл к последней ошибке.
### Implementation for User Story 3
- [ ] T029 [US3] Implement `TaskCenterModel.selectTask()` in `frontend/src/lib/models/TaskCenterModel.svelte.ts` — integrate with existing TaskDrawer
@ACTION: `selectTask(taskId)` — sets `selectedTaskId`, calls `taskDrawerStore.openDrawerForTask(taskId)` from `$lib/stores/taskDrawer.svelte.ts` (existing)
@SIDE_EFFECT: opens Task Drawer (existing component) which connects to `/ws/logs/{taskId}` for live log streaming
Import: `import { openDrawerForTask } from '$lib/stores/taskDrawer.svelte.ts'`
- [ ] T030 [US3] Enhance `TaskList` row template in `frontend/src/lib/components/reports/TaskList.svelte` — add hover tooltip + click handler + awaiting_input indicator
Click: `onclick={() => onSelectTask(task.report_id)}` — calls model's `selectTask` which opens TaskDrawer
Hover tooltip: `<div class="absolute z-10 bg-surface-card border border-border rounded-lg shadow-lg p-3 text-sm">` showing task_type label, started_at, duration, key param (extract from `source_ref` or `details`). Shown on `mouseenter`, hidden on `mouseleave`. Position: above row.
AWAITING_INPUT indicator: task with status `in_progress` + has `error_context.code === 'AWAITING_INPUT'` → show yellow `<span class="bg-warning-light text-warning rounded-full px-2 py-0.5 text-xs">Ожидает ввода</span>` + small `<Button variant="secondary" size="sm">Ответить</Button>` that opens TaskDrawer with input form
FAILED task visual: destructive left border ring via `border-l-4 border-destructive-ring`
Uses `<Button>` from `$lib/ui` (existing), `<Icon>` from `$lib/ui` (existing)
- [ ] T031 [US3] Ensure Task Drawer scrolls to last error for FAILED tasks
In `TaskList` click handler: when task status is `failed`, pass `scrollTo: 'last_error'` hint to `openDrawerForTask`
RATIONALE: spec US3 acceptance criterion #1 — "курсор прокручивается к последней ошибке"
If TaskDrawer doesn't support this yet, add `scrollToHint` param to `openDrawerForTask()` in `taskDrawer.svelte.ts`
### Verification for User Story 3
- [ ] T032 [US3] Write L2 UX test for TaskList drill-down in `frontend/src/lib/components/reports/__tests__/TaskList.test.ts` (extend existing)
@UX_TEST: click_failed_task→opens_task_drawer, hover_row→shows_tooltip, awaiting_input_task→shows_indicator_and_button, failed_task→has_destructive_left_border
- [ ] T033 [US3] Run US3 verification — `cd frontend && npm run test -- TaskList TaskCenterModel`, manual smoke: click task → TaskDrawer opens, hover → tooltip appears
**Checkpoint**: Full drill-down functional — click any task to open logs, hover for details, awaiting_input tasks interactive.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Regression defense, lint/build gates, semantic index health.
### Regression Tests
- [ ] T034 [P] Write rejected-path regression test for RBAC filtering in `backend/tests/services/reports/test_report_service.py`
@TEST_EDGE: viewer_sees_only_own_tasks→other_users_tasks_excluded, analyst_sees_own_and_system→admin_tasks_excluded, no_role_user→empty_list
@TEST_EDGE: task_with_null_user_id→analyst_can_see, task_with_null_user_id→viewer_cannot_see
Verify that REJECTED patterns (inline filtering, graph-level filtering) are NOT implemented — test checks `_filter_tasks_by_rbac()` is used
- [ ] T035 [P] Write WebSocket reconnect regression test in `frontend/src/lib/models/__tests__/TaskCenterModel.test.ts`
@TEST_EDGE: ws_close_abnormal→screenState='reconnecting', ws_reconnect_success→screenState='ready', ws_max_retries→screenState='disconnected', ws_close_normal→screenState='disconnected'
Verify exponential backoff: 1s, 2s, 4s, 8s, max 30s (mock timers)
- [ ] T036 Run full backend tests — `cd backend && source .venv/bin/activate && python -m pytest -v`
All tests must pass. No regressions in non-reports modules.
- [ ] T037 Run full frontend tests — `cd frontend && npm run test`
All tests pass. No regressions in unrelated components.
### Lint & Build Gates
- [ ] T038 Run `cd backend && python -m ruff check .` — zero new violations
- [ ] T039 Run `cd frontend && npm run lint` — zero new violations
- [ ] T040 Run `cd frontend && npm run build` — production build succeeds (static adapter, SPA mode)
### Admin Settings UI
- [ ] T040a Add "Отчёты" section to settings page `frontend/src/routes/settings/+page.svelte` (or sub-route)
Admin-only section with checkboxes for each `TaskType` — controls `disabled_task_types` globally
Uses `getReportsSettings()` + `updateReportsSettings()` from T008c
@UX_STATE: loading→spinner, loaded→checkboxes, saving→button spinner, error→toast
Save button hidden for non-admins (check `settings:WRITE` permission)
RATIONALE: UX group visibility feature — admin can disable task types for all users
### Semantic Health
- [ ] T041 [P] **Attention compliance audit**: verify contracts pass ATTN_1 (first-line density), ATTN_2 (hierarchical IDs `Reports.*`, `TaskCenter*`), ATTN_3 (`@SEMANTICS` keyword consistency — all contracts use `task-status-center`), ATTN_4 (each contract ≤150 lines, each module ≤400 lines)
- [ ] T042 [P] **Semantic index rebuild**: run `axiom_semantic_index rebuild rebuild_mode="full"` — confirm 0 parse warnings from feature files
- [ ] T043 [P] **Orphan audit**: run `axiom_semantic_context workspace_health` — confirm no new orphan contracts introduced
---
## Dependencies & Execution Order
### Phase Dependencies
```
Phase 1 (Setup) ──► Phase 2 (Foundational) ──┬──► Phase 3 (US1) ──► Phase 4 (US2) ──► Phase 5 (US3)
└──► Phase 6 (Polish — after all stories)
```
### Within User Story Dependencies
- **US1**: T010,T011 (tests) → T012,T013,T014 (backend) ∥ T015 (model) → T016,T017 (components) → T018,T019 (page) → T020,T021,T022 (verify)
- **US2**: T023 (model enhance) ∥ T024 (FilterBar) ∥ T025 (TaskList) → T026 (wire) → T027,T028 (verify)
- **US3**: T029 (model) ∥ T030 (TaskList enhance) ∥ T031 (Drawer scroll) → T032,T033 (verify)
### Parallel Opportunities
| Phase | Can run in parallel |
|-------|---------------------|
| Phase 2 | T003 ∥ T004 ∥ T005 (different files, different stacks) |
| US1 tests | T010 ∥ T011 (different stacks) |
| US1 components | T016 ∥ T017 (different files) |
| US2 components | T023 ∥ T024 ∥ T025 (different files) |
| US3 | T029 ∥ T030 ∥ T031 (different concerns) |
| Polish | T034 ∥ T035 (different stacks), T041 ∥ T042 ∥ T043 |
### MVP Delivery
1. Complete Phase 1 + Phase 2
2. Complete Phase 3 (US1)
3. **STOP and VALIDATE**: Summary dashboard works with real-time WebSocket updates
4. Deploy/demo if ready
---
## Implementation Strategy
### Incremental Delivery
| Delivery | Scope | Value |
|----------|-------|-------|
| MVP | US1 | Summary dashboard with real-time counts |
| +1 | +US2 | Full filtering, clickable summary cards, search |
| +2 | +US3 | Task Drawer integration, tooltips, awaiting_input |
### Notes
- `[P]` — parallelizable (different files, no shared state)
- `[USx]` — user story tag for traceability
- C4+ tasks have inlined contract constraints (`@PRE`, `@POST`, `@TEST_EDGE`, `RATIONALE`/`REJECTED`)
- Model-first frontend: types → model → L1 test → components → L2 UX test
- Never use `writable()` — all new state uses `$state` in `.svelte.ts`
- Never use raw Tailwind colors — use semantic tokens (`bg-surface-card`, `text-text`, `bg-destructive-light`, etc.)
- After every file write, verify anchor pairs with `read_outline` per anti-corruption protocol

View File

@@ -0,0 +1,240 @@
# Task Execution Architecture — superset-tools
**Date:** 2026-07-02
**Purpose:** Comprehensive audit of task execution paths — what runs through TaskManager, what bypasses it, and why.
**Context:** User noted translation tasks are missing from `/reports` (Task Status Center), which only shows tasks from the generic TaskManager pipeline.
---
## 1. TaskManager Pipeline (the unified path)
### Core files
| File | Purpose |
|------|---------|
| `src/core/task_manager/manager.py` | Thin facade composing Graph, EventBus, Lifecycle |
| `src/core/task_manager/graph.py` | In-memory Task registry with CRUD, pagination, filters |
| `src/core/task_manager/lifecycle.py` | State machine: PENDING→RUNNING→SUCCESS/FAILED/WAITING |
| `src/core/task_manager/event_bus.py` | Async log buffer, persistence flush, WebSocket fan-out |
| `src/core/task_manager/context.py` | TaskContext container passed to plugin.execute() |
| `src/core/task_manager/models.py` | Task, TaskStatus, LogEntry, LogFilter (Pydantic) |
| `src/core/plugin_loader.py` | Filesystem-based PluginBase discovery and registration |
| `src/core/plugin_base.py` | ABC PluginBase with id, name, execute, get_schema |
| `src/core/scheduler.py` | APScheduler service (backup, validation, translation jobs) |
| `src/core/async_job_runner.py` | Bridge: sync APScheduler ↔ async event loop |
| `src/dependencies.py` | Singleton factory for TaskManager, PluginLoader, SchedulerService |
| `src/api/routes/tasks.py` | REST API: POST/GET /api/tasks, WebSocket status/logs |
### Pipeline flow
```
POST /api/tasks {"plugin_id": "...", "params": {...}}
TaskManager.create_task(plugin_id, params) [manager.py:306]
JobLifecycle.create_task(plugin_id, params) [lifecycle.py:96]
├─ PluginLoader.has_plugin(plugin_id) → raise ValueError if missing
├─ Task(plugin_id=..., params=..., status=PENDING)
├─ TaskGraph.add_task(task) [graph.py:125]
├─ TaskPersistenceService.persist_task(task) [lifecycle.py:111]
└─ returns Task object
asyncio.create_task( lifecycle._run_task(task_id) ) [manager.py:314]
JobLifecycle._run_task(task_id) [lifecycle.py:128]
├─ TaskGraph.get_task(task_id)
├─ PluginLoader.get_plugin(task.plugin_id)
├─ task.status = RUNNING; persisted; broadcast_status
├─ Creates TaskContext(task_id, add_log_fn, params) [context.py:70]
├─ Inspects plugin.execute() signature:
│ └─ If accepts `context`: plugin.execute(params, context=context)
│ If sync: wrapped in asyncio.to_thread()
│ If async: awaited directly
├─ On success: task.result = result; task.status = SUCCESS
├─ On failure: task.status = FAILED
├─ Finally: task.finished_at, flush_task_logs(), persist_task(), broadcast
└─ Additional: broadcasts dataset.updated for "dataset-mapper"/"llm_documentation"
```
### TaskStatus values
- `PENDING`, `RUNNING`, `SUCCESS`, `FAILED`, `AWAITING_MAPPING`, `AWAITING_INPUT`
### Registered plugin_id values (PluginBase subclasses)
All discovered by PluginLoader scanning `backend/src/plugins/`. Classes inheriting `PluginBase` are instantiated and registered by their `id` property:
| plugin_id | PluginBase subclass | Source file |
|-----------|-------------------|-------------|
| `superset-backup` | BackupPlugin | `backup.py` |
| `superset-migration` | MigrationPlugin | `migration.py` |
| `search-datasets` | SearchPlugin | `search.py` |
| `dataset-mapper` | MapperPlugin | `mapper.py` |
| `system-debug` | DebugPlugin | `debug.py` |
| `maintenance_banner_apply` | MaintenanceBannerPlugin | `maintenance_banner.py` |
| `git-integration` | GitPlugin | `git_plugin.py` |
| `llm_dashboard_validation` | DashboardValidationPlugin | `llm_analysis/plugin.py` |
| `llm_documentation` | DocumentationPlugin | `llm_analysis/plugin.py` |
---
## 2. Scheduler Service
SchedulerService (`src/core/scheduler.py`) manages three types of jobs via APScheduler:
| Job Type | Trigger Mechanism | Uses TaskManager? |
|----------|------------------|-------------------|
| Backup (`backup_{env_id}`) | `task_manager.create_task("superset-backup")` via AsyncJobRunner.run() | **Yes** |
| Translation (`translate_{schedule_id}`) | Direct call to execute_scheduled_translation() → TranslationOrchestrator | **No** |
| Validation (`validation_{policy_id}`) | `task_manager.create_task("llm_dashboard_validation")` via AsyncJobRunner.run() | **Yes** |
---
## 3. Translation System (standalone, bypasses TaskManager)
Translation tasks use a **separate, parallel execution pipeline**. They never go through `TaskManager.create_task()` or `PluginBase.execute()`.
### Key files
| File | Purpose |
|------|---------|
| `src/plugins/translate/orchestrator.py` | TranslationOrchestrator — run lifecycle coordination |
| `src/plugins/translate/orchestrator_planner.py` | TranslationPlanner — plan generation |
| `src/plugins/translate/orchestrator_runner.py` | TranslationStageRunner — execution, retry, cancel |
| `src/plugins/translate/orchestrator_sql.py` | SQL INSERT orchestrator |
| `src/plugins/translate/scheduler.py` | TranslationScheduler CRUD + execute_scheduled_translation() |
| `src/api/routes/translate/_run_routes.py` | POST /api/translate/jobs/{job_id}/run |
| `src/api/routes/translate/_schedule_routes.py` | Translation schedule CRUD |
### Translation execution flow
```
POST /api/translate/jobs/{job_id}/run [_run_routes.py:32]
TranslationOrchestrator(db, config_manager, username) [orchestrator.py:46]
TranslationPlanner.plan_run(job_id) [orchestrator_planner.py]
├─ Creates TranslationRun DB row (status=PENDING)
└─ Returns TranslationRun object
asyncio.create_task( _background_execute() ) [_run_routes.py:123]
│ (separate DB session, separate orchestrator)
TranslationOrchestrator.execute_run(bg_run) [orchestrator.py:89]
TranslationStageRunner.execute_run(run) [orchestrator_runner.py:45]
TranslationExecutionEngine.execute_run(run)
├─ Fetches data from source
├─ Creates batches
├─ Calls LLM for translation (per-batch)
├─ Generates SQL INSERT statements
├─ Submits SQL to Superset SQL Lab
└─ Records results in TranslationRun/TranslationBatch/TranslationRecord DB rows
```
### Translation scheduling flow (also bypasses)
```
SchedulerService.load_schedules() [scheduler.py:69]
├─ Queries TranslationSchedule table for is_active=True
└─ scheduler.add_job(
execute_scheduled_translation, [translate/scheduler.py:278]
CronTrigger(...)
)
▼ (on trigger)
execute_scheduled_translation(schedule_id, job_id, ...)
├─ TranslationOrchestrator(db, config_manager, "scheduler")
├─ orch.start_run(job_id=job_id, is_scheduled=True)
├─ orch.execute_run(run) via AsyncJobRunner.run()
└─ TranslationRun.status set in DB
```
### Key differences: TaskManager vs Translation
| Feature | TaskManager (PluginBase) | Translation Runs |
|---------|------------------------|------------------|
| State model | Pydantic `Task` in memory (SQL persistence) | SQLAlchemy `TranslationRun` in DB |
| Logger | `TaskContext.logger` → EventBus → WebSocket push | `TranslationEventLog` → DB rows |
| WebSocket | `/ws/logs/{task_id}` (push) + `/ws/task-events` | `/ws/translate/run/{run_id}` (poll, 1s interval) |
| Execution model | `plugin.execute(params, context)` | `TranslationOrchestrator.execute_run(run)` |
| Pause/Resume | Built-in (AWAITING_INPUT, AWAITING_MAPPING) | Not supported |
| Cancellation | `TaskManager.cancel_task()` | `TranslationStageRunner.cancel_run()` |
| Discovery | PluginLoader filesystem scan | Hardcoded orchestrator class |
| Results in `/reports` | Yes | **No** |
---
## 4. Complete Audit: All Execution Paths Bypassing TaskManager
### 🔴 Critical bypasses (full task execution, NOT in TaskManager)
| # | Path | File:Line | Launcher | Work Done | State Tracking |
|---|------|-----------|----------|-----------|----------------|
| **A1** | Manual translation run | `_run_routes.py:123` | `asyncio.create_task(_background_execute())` | LLM translation, SQL generation, Superset API | `TranslationRun` table (SQLAlchemy) |
| **A2** | Scheduled translation run | `translate/scheduler.py:278` | APScheduler → AsyncJobRunner.run() | LLM translation, SQL generation, Superset API | `TranslationRun` table (SQLAlchemy) |
### 🟡 Semi-bypasses (blocking HTTP, could be TaskManager async)
| # | Path | File:Line | Work Done | Notes |
|---|------|-----------|-----------|-------|
| **D1** | Retry failed batches | `_run_routes.py:140` | LLM calls + SQL gen | Blocks HTTP response, no 202 Accepted |
| **D2** | Retry SQL insert | `_run_routes.py:168` | Superset SQL submit | Blocks HTTP response, no 202 Accepted |
### 🟢 Non-task execution paths (should NOT be in TaskManager)
| # | Path | File:Line | Work Done | Reason for staying out |
|---|------|-----------|-----------|----------------------|
| **A3** | Agent LLM title generation | `agent/app.py:488` | `asyncio.create_task(generate_llm_title(...))` | Best-effort, sub-second, non-critical. No business state beyond title text. |
| **B1** | EventBus async flusher | `event_bus.py:72` | Flush log buffer to DB every 2s | Internal TaskManager infrastructure |
| **B2** | TaskLogger fire-forget log writes | `task_logger.py:100` | Async log delivery to EventBus | Internal TaskManager infrastructure |
| **C1-C5** | WebSocket event consumers | `app.py:664,770,824,866,895` | Relay events to browser clients | Event relay, not task execution |
| **E** | Thread pool executors | `utils/executors.py:50` | 3× ThreadPoolExecutor | Infra for blocking I/O offloading |
---
## 5. Impact Summary
```
TaskManager (unified)
├─ backup ✅
├─ migration ✅
├─ llm_validation ✅
├─ llm_documentation ✅
├─ dataset-mapper ✅
├─ search-datasets ✅
├─ git-integration ✅
├─ maintenance ✅
├─ debug ✅
└─ translation ❌ ← bypasses completely (A1 + A2)
dataset review? need to verify
```
### If translation were unified:
1. Create `TranslatePlugin extends PluginBase` with `id = "translate-run"` or similar
2. Its `execute(params, context)` method would:
- Receive `job_id` and `run_id` from params
- Open its own DB session
- Call `TranslationOrchestrator(db, ...).execute_run(run)`
- Report progress via `context.logger` (→ automatic WebSocket push)
3. The scheduler would call `task_manager.create_task("translate-run", {schedule_id, job_id})` instead of `execute_scheduled_translation()`
4. Manual POST would call `task_manager.create_task()` instead of the orchestrator directly
5. Translation tasks would automatically appear in `/reports` with log streaming, status broadcasts, and cancel support
### Key benefit:
- Single API: `POST /api/tasks` for ALL background work
- Single monitoring: `/reports` shows ALL tasks including translations
- Unified WebSocket: push-based logs instead of poll-based
- Elimination of ~200 lines of duplicated concurrency/DB-session/stale-run cleanup code