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:
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Справка"
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = ""`).
|
||||
|
||||
@@ -245,6 +245,7 @@ import { SvelteSet } from "svelte/reactivity";
|
||||
statusMode="repository"
|
||||
envId={selectedEnvId || null}
|
||||
repositoriesOnly={activeTab === 'repos'}
|
||||
onDashboardsChanged={(next: DashboardMetadata[]) => { allDashboards = next; }}
|
||||
/>
|
||||
{:else}
|
||||
<EmptyState
|
||||
|
||||
@@ -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: {
|
||||
|
||||
384
specs/034-task-status-center/tasks.md
Normal file
384
specs/034-task-status-center/tasks.md
Normal 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
|
||||
240
specs/research-task-execution-architecture.md
Normal file
240
specs/research-task-execution-architecture.md
Normal 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
|
||||
Reference in New Issue
Block a user