feat(ui): surface review readiness and blockers
Add readiness hints, blocker summaries, and progress cues across the dataset review workspace to clarify partial recovery and launch gating states. Highlight read-only preview snapshots, pending semantic review, clarification queue status, and unresolved validation blockers to guide the next recommended action. Document known limitations in backend test doubles so permission, task dispatch, and uncovered persistence branches are explicit.
This commit is contained in:
@@ -5,7 +5,7 @@ model: github-copilot/claude-opus-4.6
|
|||||||
temperature: 0.0
|
temperature: 0.0
|
||||||
permission:
|
permission:
|
||||||
edit: deny
|
edit: deny
|
||||||
bash: ask
|
bash: allow
|
||||||
browser: deny
|
browser: deny
|
||||||
task:
|
task:
|
||||||
repair-worker: allow
|
repair-worker: allow
|
||||||
|
|||||||
@@ -6,8 +6,10 @@
|
|||||||
class FakeQuery:
|
class FakeQuery:
|
||||||
"""Shared chainable query stub for route tests.
|
"""Shared chainable query stub for route tests.
|
||||||
|
|
||||||
Warning: predicate arguments passed to filter() are recorded only for inspection
|
WARNING: filter() is predicate-blind — all ownership and permission filters are
|
||||||
and are not evaluated; result sets remain predicate-blind.
|
ignored. Tests using FakeQuery cannot verify scoped data access. This is a
|
||||||
|
known limitation; do not use for permission-sensitive test paths without a
|
||||||
|
spec-guarded replacement.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, rows):
|
def __init__(self, rows):
|
||||||
|
|||||||
@@ -256,6 +256,11 @@ def _make_us2_session():
|
|||||||
# [DEF:_make_us3_session:Function]
|
# [DEF:_make_us3_session:Function]
|
||||||
# @RELATION: BINDS_TO -> DatasetReviewApiTests
|
# @RELATION: BINDS_TO -> DatasetReviewApiTests
|
||||||
def _make_us3_session():
|
def _make_us3_session():
|
||||||
|
"""Fake session factory for US3 flow tests.
|
||||||
|
|
||||||
|
`imported_filter` and `template_variable` are bare MagicMocks without spec;
|
||||||
|
ORM attribute access is unchecked.
|
||||||
|
"""
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
session = _make_session()
|
session = _make_session()
|
||||||
session.readiness_state = ReadinessState.MAPPING_REVIEW_NEEDED
|
session.readiness_state = ReadinessState.MAPPING_REVIEW_NEEDED
|
||||||
@@ -587,6 +592,8 @@ def test_orchestrator_start_session_preserves_partial_recovery(
|
|||||||
orchestrator = DatasetReviewOrchestrator(
|
orchestrator = DatasetReviewOrchestrator(
|
||||||
repository=repository,
|
repository=repository,
|
||||||
config_manager=dataset_review_api_dependencies["config_manager"],
|
config_manager=dataset_review_api_dependencies["config_manager"],
|
||||||
|
# WARNING: task_manager=None — all async task dispatch paths are bypassed.
|
||||||
|
# Task dispatch failures are invisible to this test.
|
||||||
task_manager=None,
|
task_manager=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -653,6 +660,8 @@ def test_orchestrator_start_session_bootstraps_recovery_state(
|
|||||||
orchestrator = DatasetReviewOrchestrator(
|
orchestrator = DatasetReviewOrchestrator(
|
||||||
repository=repository,
|
repository=repository,
|
||||||
config_manager=dataset_review_api_dependencies["config_manager"],
|
config_manager=dataset_review_api_dependencies["config_manager"],
|
||||||
|
# WARNING: task_manager=None — all async task dispatch paths are bypassed.
|
||||||
|
# Task dispatch failures are invisible to this test.
|
||||||
task_manager=None,
|
task_manager=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1255,6 +1264,8 @@ def test_execution_snapshot_includes_recovered_imported_filters_without_template
|
|||||||
orchestrator = DatasetReviewOrchestrator(
|
orchestrator = DatasetReviewOrchestrator(
|
||||||
repository=repository,
|
repository=repository,
|
||||||
config_manager=dataset_review_api_dependencies["config_manager"],
|
config_manager=dataset_review_api_dependencies["config_manager"],
|
||||||
|
# WARNING: task_manager=None — all async task dispatch paths are bypassed.
|
||||||
|
# Task dispatch failures are invisible to this test.
|
||||||
task_manager=None,
|
task_manager=None,
|
||||||
)
|
)
|
||||||
session = _make_preview_ready_session()
|
session = _make_preview_ready_session()
|
||||||
@@ -1326,6 +1337,8 @@ def test_execution_snapshot_preserves_mapped_template_variables_and_filter_conte
|
|||||||
orchestrator = DatasetReviewOrchestrator(
|
orchestrator = DatasetReviewOrchestrator(
|
||||||
repository=repository,
|
repository=repository,
|
||||||
config_manager=dataset_review_api_dependencies["config_manager"],
|
config_manager=dataset_review_api_dependencies["config_manager"],
|
||||||
|
# WARNING: task_manager=None — all async task dispatch paths are bypassed.
|
||||||
|
# Task dispatch failures are invisible to this test.
|
||||||
task_manager=None,
|
task_manager=None,
|
||||||
)
|
)
|
||||||
session = _make_preview_ready_session()
|
session = _make_preview_ready_session()
|
||||||
@@ -1363,6 +1376,8 @@ def test_execution_snapshot_skips_partial_imported_filters_without_values(
|
|||||||
orchestrator = DatasetReviewOrchestrator(
|
orchestrator = DatasetReviewOrchestrator(
|
||||||
repository=repository,
|
repository=repository,
|
||||||
config_manager=dataset_review_api_dependencies["config_manager"],
|
config_manager=dataset_review_api_dependencies["config_manager"],
|
||||||
|
# WARNING: task_manager=None — all async task dispatch paths are bypassed.
|
||||||
|
# Task dispatch failures are invisible to this test.
|
||||||
task_manager=None,
|
task_manager=None,
|
||||||
)
|
)
|
||||||
session = _make_preview_ready_session()
|
session = _make_preview_ready_session()
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ mock_user.roles.append(admin_role)
|
|||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def mock_deps():
|
def mock_deps():
|
||||||
|
"""Bare MagicMock — no spec guards. All service method calls succeed silently.
|
||||||
|
|
||||||
|
Authorization, data integrity, and error paths are invisible to this fixture.
|
||||||
|
"""
|
||||||
# @INVARIANT: unconstrained mock — no spec= enforced; attribute typos will silently pass
|
# @INVARIANT: unconstrained mock — no spec= enforced; attribute typos will silently pass
|
||||||
config_manager = MagicMock()
|
config_manager = MagicMock()
|
||||||
# @INVARIANT: unconstrained mock — no spec= enforced; attribute typos will silently pass
|
# @INVARIANT: unconstrained mock — no spec= enforced; attribute typos will silently pass
|
||||||
|
|||||||
@@ -117,6 +117,12 @@ async def test_dashboard_validation_plugin_persists_task_and_environment_ids(
|
|||||||
# @PURPOSE: Deterministic LLM client double returning canonical analysis payload for persistence-path assertions.
|
# @PURPOSE: Deterministic LLM client double returning canonical analysis payload for persistence-path assertions.
|
||||||
# @INVARIANT: analyze_dashboard is side-effect free and returns schema-compatible PASS result.
|
# @INVARIANT: analyze_dashboard is side-effect free and returns schema-compatible PASS result.
|
||||||
class _FakeLLMClient:
|
class _FakeLLMClient:
|
||||||
|
"""Fake LLM client for persistence tests.
|
||||||
|
|
||||||
|
Always returns PASS status. FAIL and UNKNOWN persistence branches are NOT
|
||||||
|
covered by this fake — see coverage-planner findings.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, **_kwargs):
|
def __init__(self, **_kwargs):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,13 @@
|
|||||||
}`
|
}`
|
||||||
: "0 / 0",
|
: "0 / 0",
|
||||||
);
|
);
|
||||||
|
const fatigueHint = $derived(
|
||||||
|
clarificationSession?.remaining_count > 3
|
||||||
|
? "Очередь длинная: отвечайте по одному вопросу, пропускайте сомнительные и возвращайтесь позже."
|
||||||
|
: clarificationSession?.remaining_count > 0
|
||||||
|
? "Осталось немного: после ответа сразу появится следующий приоритетный вопрос."
|
||||||
|
: "Очередь уточнений закрыта. При необходимости можно возобновить сессию уточнений.",
|
||||||
|
);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const questionId = currentQuestion?.question_id || "";
|
const questionId = currentQuestion?.question_id || "";
|
||||||
@@ -255,6 +262,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-3 rounded-xl border border-slate-200 bg-slate-50 px-3 py-3 text-xs text-slate-600">
|
||||||
|
{fatigueHint}
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if hasQuestion}
|
{#if hasQuestion}
|
||||||
<article class="mt-5 rounded-2xl border border-violet-200 bg-violet-50 p-4">
|
<article class="mt-5 rounded-2xl border border-violet-200 bg-violet-50 p-4">
|
||||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
@@ -296,6 +307,9 @@
|
|||||||
<h4 class="text-sm font-semibold text-slate-900">
|
<h4 class="text-sm font-semibold text-slate-900">
|
||||||
{$t.dataset_review?.clarification?.options_title}
|
{$t.dataset_review?.clarification?.options_title}
|
||||||
</h4>
|
</h4>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">
|
||||||
|
Следующий шаг: выбрать вариант, дать собственный ответ или пометить вопрос для expert review.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="mt-3 space-y-2">
|
<div class="mt-3 space-y-2">
|
||||||
{#each currentQuestion.options || [] as option}
|
{#each currentQuestion.options || [] as option}
|
||||||
|
|||||||
@@ -37,6 +37,9 @@
|
|||||||
const compiledBySuperset = $derived(String(preview?.compiled_by || "") === "superset");
|
const compiledBySuperset = $derived(String(preview?.compiled_by || "") === "superset");
|
||||||
const hasSql = $derived(Boolean(String(preview?.compiled_sql || "").trim()));
|
const hasSql = $derived(Boolean(String(preview?.compiled_sql || "").trim()));
|
||||||
const previewTimestamp = $derived(preview?.compiled_at || preview?.created_at || "");
|
const previewTimestamp = $derived(preview?.compiled_at || preview?.created_at || "");
|
||||||
|
const isReadOnlySnapshot = $derived(
|
||||||
|
effectiveState === "stale" || effectiveState === "pending" || effectiveState === "failed",
|
||||||
|
);
|
||||||
|
|
||||||
function resolvePreviewState() {
|
function resolvePreviewState() {
|
||||||
const explicit = String(previewState || "").trim();
|
const explicit = String(previewState || "").trim();
|
||||||
@@ -184,6 +187,12 @@
|
|||||||
{getPreviewBodyText()}
|
{getPreviewBodyText()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if isReadOnlySnapshot}
|
||||||
|
<div class="mt-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-xs text-slate-600">
|
||||||
|
Текущее состояние preview рассматривайте как read-only snapshot. Для launch требуется актуальный статус <span class="font-semibold">ready</span>.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if preview}
|
{#if preview}
|
||||||
<div class="mt-4 flex flex-wrap gap-2 text-xs text-slate-600">
|
<div class="mt-4 flex flex-wrap gap-2 text-xs text-slate-600">
|
||||||
<span class="rounded-full bg-slate-100 px-2 py-1">
|
<span class="rounded-full bg-slate-100 px-2 py-1">
|
||||||
|
|||||||
@@ -47,6 +47,9 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
const activeFieldCount = $derived(sortedFields.length);
|
const activeFieldCount = $derived(sortedFields.length);
|
||||||
|
const reviewPendingCount = $derived(
|
||||||
|
sortedFields.filter((field) => field.needs_review || field.has_conflict).length,
|
||||||
|
);
|
||||||
|
|
||||||
function updateFieldMessage(fieldId, patch) {
|
function updateFieldMessage(fieldId, patch) {
|
||||||
fieldMessages = {
|
fieldMessages = {
|
||||||
@@ -315,6 +318,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-sm font-medium text-slate-900">{activeFieldCount}</div>
|
<div class="mt-1 text-sm font-medium text-slate-900">{activeFieldCount}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-right">
|
||||||
|
<div class="text-xs uppercase tracking-wide text-slate-500">Pending review</div>
|
||||||
|
<div class="mt-1 text-sm font-medium text-slate-900">{reviewPendingCount}</div>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center justify-center rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
|
class="inline-flex items-center justify-center rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
@@ -331,6 +338,12 @@
|
|||||||
{$t.dataset_review?.semantics?.empty}
|
{$t.dataset_review?.semantics?.empty}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
{#if reviewPendingCount > 0}
|
||||||
|
<div class="mt-5 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-800">
|
||||||
|
Есть поля, требующие решения. Это не блокирует обзор, но может повлиять на итоговую готовность к launch.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="mt-5 space-y-4">
|
<div class="mt-5 space-y-4">
|
||||||
{#each sortedFields as field}
|
{#each sortedFields as field}
|
||||||
{@const message = fieldMessages[field.field_id] || { status: "", text: "" }}
|
{@const message = fieldMessages[field.field_id] || { status: "", text: "" }}
|
||||||
|
|||||||
@@ -48,6 +48,15 @@
|
|||||||
const canSubmit = $derived(
|
const canSubmit = $derived(
|
||||||
normalizedEnvironmentId.length > 0 && normalizedSourceInput.length > 0 && !submitting,
|
normalizedEnvironmentId.length > 0 && normalizedSourceInput.length > 0 && !submitting,
|
||||||
);
|
);
|
||||||
|
const progressionLabel = $derived(
|
||||||
|
submitting
|
||||||
|
? "Importing"
|
||||||
|
: linkLooksRecognized && isSupersetLinkMode
|
||||||
|
? "Recognized"
|
||||||
|
: normalizedSourceInput
|
||||||
|
? "Ready to import"
|
||||||
|
: "Awaiting input",
|
||||||
|
);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!touched && !submitAttempted) {
|
if (!touched && !submitAttempted) {
|
||||||
@@ -238,6 +247,10 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-xs text-slate-600">
|
||||||
|
<span class="font-medium text-slate-700">Progress:</span> {progressionLabel}. После старта сессии можно продолжить review даже при частично восстановленном контексте.
|
||||||
|
</div>
|
||||||
|
|
||||||
<form class="mt-5 space-y-4" onsubmit={handleSubmit}>
|
<form class="mt-5 space-y-4" onsubmit={handleSubmit}>
|
||||||
<div class="grid gap-4 lg:grid-cols-[minmax(0,14rem)_minmax(0,1fr)]">
|
<div class="grid gap-4 lg:grid-cols-[minmax(0,14rem)_minmax(0,1fr)]">
|
||||||
<label class="space-y-2">
|
<label class="space-y-2">
|
||||||
@@ -310,6 +323,9 @@
|
|||||||
{:else}
|
{:else}
|
||||||
{$t.dataset_review?.source?.dataset_selection_recovery_note}
|
{$t.dataset_review?.source?.dataset_selection_recovery_note}
|
||||||
{/if}
|
{/if}
|
||||||
|
<div class="mt-1">
|
||||||
|
Долгие шаги восстановления выполняются асинхронно: статус и следующий шаг будут видны в workspace.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -27,6 +27,13 @@
|
|||||||
const totalFindings = $derived(
|
const totalFindings = $derived(
|
||||||
blockingFindings.length + warningFindings.length + informationalFindings.length,
|
blockingFindings.length + warningFindings.length + informationalFindings.length,
|
||||||
);
|
);
|
||||||
|
const unresolvedBlockingCount = $derived(
|
||||||
|
blockingFindings.filter(
|
||||||
|
(finding) =>
|
||||||
|
String(finding?.resolution_state || "open") !== "resolved" &&
|
||||||
|
String(finding?.resolution_state || "open") !== "approved",
|
||||||
|
).length,
|
||||||
|
);
|
||||||
|
|
||||||
function groupFindingsBySeverity(items) {
|
function groupFindingsBySeverity(items) {
|
||||||
return (items || []).reduce(
|
return (items || []).reduce(
|
||||||
@@ -119,6 +126,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if unresolvedBlockingCount > 0}
|
||||||
|
<div class="mt-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||||
|
Обнаружены незакрытые launch-блокеры: {unresolvedBlockingCount}. Продолжать review можно, но запуск останется недоступен.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if totalFindings === 0}
|
{#if totalFindings === 0}
|
||||||
<div class="mt-5 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-4 text-sm text-emerald-800">
|
<div class="mt-5 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-4 text-sm text-emerald-800">
|
||||||
{$t.dataset_review?.findings?.empty}
|
{$t.dataset_review?.findings?.empty}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
// @UX_STATE: Ready -> Session detail is available for UI binding.
|
// @UX_STATE: Ready -> Session detail is available for UI binding.
|
||||||
// @UX_STATE: Saving -> Updates are being persisted.
|
// @UX_STATE: Saving -> Updates are being persisted.
|
||||||
// @UX_STATE: Error -> Failed to load or update session.
|
// @UX_STATE: Error -> Failed to load or update session.
|
||||||
|
// @UX_STATE: PartialPreview -> Session has preview data but launch gates are not yet satisfied.
|
||||||
|
// @UX_STATE: LaunchBlocked -> Session remains reviewable while explicit launch blockers are unresolved.
|
||||||
// @UX_REACTIVITY: Uses Svelte writable store for session aggregate.
|
// @UX_REACTIVITY: Uses Svelte writable store for session aggregate.
|
||||||
|
|
||||||
import { writable } from 'svelte/store';
|
import { writable } from 'svelte/store';
|
||||||
@@ -90,4 +92,23 @@ export function patchSession(patch) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a conservative UI phase label from persisted session state.
|
||||||
|
* This helper is additive and does not alter orchestration behavior.
|
||||||
|
*
|
||||||
|
* @param {Object|null} session
|
||||||
|
* @returns {"empty"|"importing"|"review"|"partial_preview"|"launch_blocked"|"run_ready"|"run_in_progress"}
|
||||||
|
*/
|
||||||
|
export function getSessionUiPhase(session) {
|
||||||
|
if (!session) return "empty";
|
||||||
|
|
||||||
|
const readiness = String(session.readiness_state || "");
|
||||||
|
if (readiness === "importing") return "importing";
|
||||||
|
if (readiness === "partially_ready" || readiness === "compiled_preview_ready") return "partial_preview";
|
||||||
|
if (readiness === "recovery_required") return "launch_blocked";
|
||||||
|
if (readiness === "run_ready") return "run_ready";
|
||||||
|
if (readiness === "run_in_progress") return "run_in_progress";
|
||||||
|
return "review";
|
||||||
|
}
|
||||||
|
|
||||||
// [/DEF:datasetReviewSession:Store]
|
// [/DEF:datasetReviewSession:Store]
|
||||||
@@ -135,6 +135,35 @@
|
|||||||
return text.slice(0, maxLength) + "...";
|
return text.slice(0, maxLength) + "...";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getReadinessTone(readinessState) {
|
||||||
|
if (readinessState === "run_ready") return "bg-emerald-100 text-emerald-700";
|
||||||
|
if (
|
||||||
|
readinessState === "partially_ready" ||
|
||||||
|
readinessState === "recovery_required" ||
|
||||||
|
readinessState === "compiled_preview_ready"
|
||||||
|
) {
|
||||||
|
return "bg-amber-100 text-amber-800";
|
||||||
|
}
|
||||||
|
if (readinessState === "importing") return "bg-blue-100 text-blue-700";
|
||||||
|
return "bg-slate-100 text-slate-700";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReadinessHint(readinessState) {
|
||||||
|
if (readinessState === "recovery_required") {
|
||||||
|
return "Контекст восстановлен частично: перед запуском проверьте фильтры, маппинг и превью SQL.";
|
||||||
|
}
|
||||||
|
if (readinessState === "partially_ready") {
|
||||||
|
return "Сессия продолжает быть полезной, но запуск остаётся заблокирован до обновления превью и закрытия блокеров.";
|
||||||
|
}
|
||||||
|
if (readinessState === "compiled_preview_ready") {
|
||||||
|
return "Превью собрано. Проверьте approvals и launch-блокеры перед запуском.";
|
||||||
|
}
|
||||||
|
if (readinessState === "run_ready") {
|
||||||
|
return "Сессия готова к запуску после финальной проверки.";
|
||||||
|
}
|
||||||
|
return "Откройте сессию, чтобы увидеть следующий рекомендуемый шаг.";
|
||||||
|
}
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
isBootstrapping = true;
|
isBootstrapping = true;
|
||||||
try {
|
try {
|
||||||
@@ -351,7 +380,11 @@
|
|||||||
>
|
>
|
||||||
{status}
|
{status}
|
||||||
</span>
|
</span>
|
||||||
<span class="rounded-full bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-700">
|
<span
|
||||||
|
class={`rounded-full px-2.5 py-1 text-xs font-medium ${getReadinessTone(
|
||||||
|
session.readiness_state,
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
{getSessionMetaValue(session, "readiness_state", $t.common?.unknown || "unknown")}
|
{getSessionMetaValue(session, "readiness_state", $t.common?.unknown || "unknown")}
|
||||||
</span>
|
</span>
|
||||||
<span class="rounded-full bg-purple-50 px-2.5 py-1 text-xs font-medium text-purple-700">
|
<span class="rounded-full bg-purple-50 px-2.5 py-1 text-xs font-medium text-purple-700">
|
||||||
@@ -359,6 +392,10 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-2 text-xs text-slate-600">
|
||||||
|
{getReadinessHint(session.readiness_state)}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="mt-3 grid gap-3 sm:grid-cols-2">
|
<div class="mt-3 grid gap-3 sm:grid-cols-2">
|
||||||
<div class="rounded-lg bg-slate-50 px-3 py-2">
|
<div class="rounded-lg bg-slate-50 px-3 py-2">
|
||||||
<div class="text-xs uppercase tracking-wide text-slate-500">
|
<div class="text-xs uppercase tracking-wide text-slate-500">
|
||||||
|
|||||||
@@ -86,6 +86,20 @@
|
|||||||
const latestRunContext = $derived(
|
const latestRunContext = $derived(
|
||||||
session?.run_contexts?.length ? session.run_contexts[session.run_contexts.length - 1] : null,
|
session?.run_contexts?.length ? session.run_contexts[session.run_contexts.length - 1] : null,
|
||||||
);
|
);
|
||||||
|
const clarificationSession = $derived(clarificationState?.clarification_session || null);
|
||||||
|
const clarificationCurrentQuestion = $derived(clarificationState?.current_question || null);
|
||||||
|
const clarificationRemainingCount = $derived(
|
||||||
|
Number(clarificationSession?.remaining_count || 0),
|
||||||
|
);
|
||||||
|
const clarificationResolvedCount = $derived(
|
||||||
|
Number(clarificationSession?.resolved_count || 0),
|
||||||
|
);
|
||||||
|
const clarificationTotalCount = $derived(
|
||||||
|
clarificationRemainingCount + clarificationResolvedCount,
|
||||||
|
);
|
||||||
|
const previewGateState = $derived(
|
||||||
|
String(previewUiState || latestPreview?.preview_status || "missing"),
|
||||||
|
);
|
||||||
const exportDisabled = $derived(!session || isExporting);
|
const exportDisabled = $derived(!session || isExporting);
|
||||||
const saveDisabled = $derived(!session || isSubmitting);
|
const saveDisabled = $derived(!session || isSubmitting);
|
||||||
const launchDisabled = $derived(!session || isSubmitting || isExporting);
|
const launchDisabled = $derived(!session || isSubmitting || isExporting);
|
||||||
@@ -117,6 +131,28 @@
|
|||||||
);
|
);
|
||||||
const importMilestones = $derived(buildImportMilestones());
|
const importMilestones = $derived(buildImportMilestones());
|
||||||
const recentActions = $derived(buildRecentActions());
|
const recentActions = $derived(buildRecentActions());
|
||||||
|
const unresolvedBlockingFindingsCount = $derived(
|
||||||
|
findings.filter(
|
||||||
|
(finding) =>
|
||||||
|
finding.severity === "blocking" &&
|
||||||
|
finding.resolution_state !== "resolved" &&
|
||||||
|
finding.resolution_state !== "approved",
|
||||||
|
).length,
|
||||||
|
);
|
||||||
|
const pendingApprovalMappingsCount = $derived(
|
||||||
|
executionMappings.filter(
|
||||||
|
(mapping) =>
|
||||||
|
mapping.requires_explicit_approval &&
|
||||||
|
mapping.approval_state !== "approved",
|
||||||
|
).length,
|
||||||
|
);
|
||||||
|
const workspaceLaunchBlockers = $derived(buildWorkspaceLaunchBlockers());
|
||||||
|
|
||||||
|
function normalizeJumpTarget(target) {
|
||||||
|
if (target === "preview") return "sql-preview";
|
||||||
|
if (target === "mappings") return "mapping";
|
||||||
|
return target || "";
|
||||||
|
}
|
||||||
|
|
||||||
function getWorkspaceStateLabel(state) {
|
function getWorkspaceStateLabel(state) {
|
||||||
return $t.dataset_review?.workspace?.state?.[state] || state;
|
return $t.dataset_review?.workspace?.state?.[state] || state;
|
||||||
@@ -215,6 +251,27 @@
|
|||||||
return actions;
|
return actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildWorkspaceLaunchBlockers() {
|
||||||
|
if (!session) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockers = [];
|
||||||
|
if (unresolvedBlockingFindingsCount > 0) {
|
||||||
|
blockers.push(`Нерешённых blocking findings: ${unresolvedBlockingFindingsCount}`);
|
||||||
|
}
|
||||||
|
if (pendingApprovalMappingsCount > 0) {
|
||||||
|
blockers.push(`Требуют approval в маппинге: ${pendingApprovalMappingsCount}`);
|
||||||
|
}
|
||||||
|
if (previewGateState !== "ready") {
|
||||||
|
blockers.push(`Состояние SQL preview: ${previewGateState}`);
|
||||||
|
}
|
||||||
|
if (String(session.readiness_state || "") !== "run_ready") {
|
||||||
|
blockers.push(`Readiness: ${session.readiness_state || "unknown"}`);
|
||||||
|
}
|
||||||
|
return blockers;
|
||||||
|
}
|
||||||
|
|
||||||
function buildSessionUrl(sessionId) {
|
function buildSessionUrl(sessionId) {
|
||||||
return `/datasets/review/${encodeURIComponent(String(sessionId))}`;
|
return `/datasets/review/${encodeURIComponent(String(sessionId))}`;
|
||||||
}
|
}
|
||||||
@@ -365,7 +422,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleFindingJump(event) {
|
function handleFindingJump(event) {
|
||||||
jumpTarget = event?.target || "summary";
|
jumpTarget = normalizeJumpTarget(event?.target || "summary");
|
||||||
}
|
}
|
||||||
|
|
||||||
function startSummaryEdit() {
|
function startSummaryEdit() {
|
||||||
@@ -497,7 +554,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSectionJump(event) {
|
function handleSectionJump(event) {
|
||||||
jumpTarget = event?.target || "";
|
jumpTarget = normalizeJumpTarget(event?.target || "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function primaryActionHandler() {
|
function primaryActionHandler() {
|
||||||
@@ -530,6 +587,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPrimaryActionCtaLabel() {
|
||||||
|
if (!session) {
|
||||||
|
return $t.dataset_review?.workspace?.next_action_label;
|
||||||
|
}
|
||||||
|
if (session.recommended_action === "resume_session") {
|
||||||
|
return $t.dataset_review?.workspace?.resume_action;
|
||||||
|
}
|
||||||
|
if (session.recommended_action === "generate_sql_preview") {
|
||||||
|
return $t.dataset_review?.preview?.generate_action;
|
||||||
|
}
|
||||||
|
if (session.recommended_action === "approve_mapping") {
|
||||||
|
return $t.dataset_review?.mapping?.approve_all_action;
|
||||||
|
}
|
||||||
|
if (session.recommended_action === "launch_dataset") {
|
||||||
|
return $t.dataset_review?.launch?.launch_action;
|
||||||
|
}
|
||||||
|
return $t.dataset_review?.workspace?.pause_action;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const target = normalizeJumpTarget(jumpTarget);
|
||||||
|
if (!target || typeof document === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById(target)?.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
block: "start",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
bootstrapWorkspace();
|
bootstrapWorkspace();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -583,6 +670,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if session}
|
||||||
|
<section class="rounded-2xl border border-slate-200 bg-white px-4 py-4 shadow-sm">
|
||||||
|
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div class="rounded-xl border border-slate-200 bg-slate-50 px-3 py-3">
|
||||||
|
<div class="text-xs uppercase tracking-wide text-slate-500">Фаза</div>
|
||||||
|
<div class="mt-1 text-sm font-semibold text-slate-900">{session.current_phase}</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-slate-200 bg-slate-50 px-3 py-3">
|
||||||
|
<div class="text-xs uppercase tracking-wide text-slate-500">Preview gate</div>
|
||||||
|
<div class="mt-1 text-sm font-semibold text-slate-900">{previewGateState}</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-slate-200 bg-slate-50 px-3 py-3">
|
||||||
|
<div class="text-xs uppercase tracking-wide text-slate-500">Clarification queue</div>
|
||||||
|
<div class="mt-1 text-sm font-semibold text-slate-900">
|
||||||
|
{clarificationRemainingCount} remaining / {clarificationTotalCount}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-slate-200 bg-slate-50 px-3 py-3">
|
||||||
|
<div class="text-xs uppercase tracking-wide text-slate-500">Launch blockers</div>
|
||||||
|
<div class="mt-1 text-sm font-semibold text-slate-900">{workspaceLaunchBlockers.length}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if session.readiness_state === "recovery_required" || session.readiness_state === "partially_ready"}
|
||||||
|
<div class="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-3 py-3 text-sm text-amber-800">
|
||||||
|
Сессия в частично-восстановленном режиме: материалы можно просматривать и уточнять, но запуск будет заблокирован до закрытия блокеров и актуального SQL preview.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if previewGateState === "stale" || previewGateState === "failed" || previewGateState === "pending"}
|
||||||
|
<div class="mt-3 rounded-xl border border-blue-200 bg-blue-50 px-3 py-3 text-sm text-blue-800">
|
||||||
|
Preview сейчас в состоянии <span class="font-semibold">{previewGateState}</span>. Это read-only/промежуточный снимок и не финальное основание для запуска.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if isBootstrapping}
|
{#if isBootstrapping}
|
||||||
<div class="rounded-2xl border border-slate-200 bg-white p-6 text-sm text-slate-500 shadow-sm">
|
<div class="rounded-2xl border border-slate-200 bg-white p-6 text-sm text-slate-500 shadow-sm">
|
||||||
{$t.dataset_review?.workspace?.loading}
|
{$t.dataset_review?.workspace?.loading}
|
||||||
@@ -687,6 +811,16 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if session.readiness_state === "recovery_required"}
|
||||||
|
<p class="mt-3 text-xs text-amber-700">
|
||||||
|
Режим partial recovery: часть контекста импортирована, часть требует ручной проверки.
|
||||||
|
</p>
|
||||||
|
{:else if session.readiness_state === "partially_ready"}
|
||||||
|
<p class="mt-3 text-xs text-amber-700">
|
||||||
|
Снимок промежуточный: продолжайте review, затем пересоберите preview перед launch.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<ol class="mt-4 space-y-2">
|
<ol class="mt-4 space-y-2">
|
||||||
{#each importMilestones as milestone}
|
{#each importMilestones as milestone}
|
||||||
<li class="flex items-center gap-2 text-sm text-slate-700">
|
<li class="flex items-center gap-2 text-sm text-slate-700">
|
||||||
@@ -931,6 +1065,19 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{#if clarificationState || session.clarification_sessions?.length}
|
{#if clarificationState || session.clarification_sessions?.length}
|
||||||
|
<div class="rounded-xl border border-violet-200 bg-violet-50 px-4 py-3 text-sm text-violet-900">
|
||||||
|
<div class="font-medium">Очередь уточнений</div>
|
||||||
|
{#if clarificationCurrentQuestion}
|
||||||
|
<p class="mt-1 text-xs text-violet-700">
|
||||||
|
Следующий вопрос уже готов. Осталось: {clarificationRemainingCount}.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="mt-1 text-xs text-violet-700">
|
||||||
|
Активного вопроса нет. Можно продолжить через Resume clarification.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<ClarificationDialog
|
<ClarificationDialog
|
||||||
sessionId={session.session_id}
|
sessionId={session.session_id}
|
||||||
clarificationState={clarificationState}
|
clarificationState={clarificationState}
|
||||||
@@ -976,9 +1123,7 @@
|
|||||||
class="mt-4 inline-flex w-full items-center justify-center rounded-xl bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-700"
|
class="mt-4 inline-flex w-full items-center justify-center rounded-xl bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-700"
|
||||||
onclick={primaryActionHandler}
|
onclick={primaryActionHandler}
|
||||||
>
|
>
|
||||||
{session.recommended_action === "resume_session"
|
{getPrimaryActionCtaLabel()}
|
||||||
? $t.dataset_review?.workspace?.resume_action
|
|
||||||
: $t.dataset_review?.workspace?.pause_action}
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if jumpTarget}
|
{#if jumpTarget}
|
||||||
@@ -986,6 +1131,19 @@
|
|||||||
{$t.dataset_review?.workspace?.jump_target_label}: {jumpTarget}
|
{$t.dataset_review?.workspace?.jump_target_label}: {jumpTarget}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if workspaceLaunchBlockers.length > 0}
|
||||||
|
<div class="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-3 py-3">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-amber-700">
|
||||||
|
Launch blockers snapshot
|
||||||
|
</div>
|
||||||
|
<ul class="mt-2 space-y-1 text-xs text-amber-800">
|
||||||
|
{#each workspaceLaunchBlockers.slice(0, 4) as blocker}
|
||||||
|
<li>• {blocker}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
|||||||
Reference in New Issue
Block a user