diff --git a/artifacts.json b/artifacts.json index 226059dc..fd9e69ba 100644 --- a/artifacts.json +++ b/artifacts.json @@ -1,14 +1,31 @@ -[ - { - "path": "src/main.py", - "category": "core" - }, - { - "path": "src/api/routes/clean_release.py", - "category": "core" - }, - { - "path": "docs/installation.md", - "category": "docs" - } -] +{ + "artifacts": [ + { + "id": "artifact-backend-dist", + "path": "backend/dist/package.tar.gz", + "sha256": "deadbeef", + "size": 1024, + "category": "core", + "source_uri": "https://repo.intra.company.local/releases/backend/dist/package.tar.gz", + "source_host": "repo.intra.company.local" + }, + { + "id": "artifact-clean-release-route", + "path": "backend/src/api/routes/clean_release.py", + "sha256": "feedface", + "size": 8192, + "category": "core", + "source_uri": "https://repo.intra.company.local/releases/backend/src/api/routes/clean_release.py", + "source_host": "repo.intra.company.local" + }, + { + "id": "artifact-installation-docs", + "path": "docs/installation.md", + "sha256": "c0ffee00", + "size": 4096, + "category": "docs", + "source_uri": "https://repo.intra.company.local/releases/docs/installation.md", + "source_host": "repo.intra.company.local" + } + ] +} diff --git a/backend/src/scripts/clean_release_tui.py b/backend/src/scripts/clean_release_tui.py index 68a8ff5b..4565f501 100644 --- a/backend/src/scripts/clean_release_tui.py +++ b/backend/src/scripts/clean_release_tui.py @@ -36,6 +36,7 @@ from src.models.clean_release import ( ReleaseCandidateStatus, ) from src.services.clean_release.approval_service import approve_candidate +from src.services.clean_release.artifact_catalog_loader import load_bootstrap_artifacts from src.services.clean_release.compliance_execution_service import ComplianceExecutionService from src.services.clean_release.enums import CandidateStatus from src.services.clean_release.manifest_service import build_manifest_snapshot @@ -270,6 +271,15 @@ class CleanReleaseTUI: status=ReleaseCandidateStatus.DRAFT, ) repository.save_candidate(candidate) + imported_artifacts = load_bootstrap_artifacts( + os.getenv("CLEAN_TUI_ARTIFACTS_JSON", "").strip(), + candidate.id, + ) + for artifact in imported_artifacts: + repository.save_artifact(artifact) + if imported_artifacts: + candidate.transition_to(CandidateStatus.PREPARED) + repository.save_candidate(candidate) registry_id = payload.get("registry_id", "REG-1") entries = [ diff --git a/backend/src/services/clean_release/artifact_catalog_loader.py b/backend/src/services/clean_release/artifact_catalog_loader.py new file mode 100644 index 00000000..cc3b5b80 --- /dev/null +++ b/backend/src/services/clean_release/artifact_catalog_loader.py @@ -0,0 +1,94 @@ +# [DEF:backend.src.services.clean_release.artifact_catalog_loader:Module] +# @TIER: STANDARD +# @SEMANTICS: clean-release, artifacts, bootstrap, json, tui +# @PURPOSE: Load bootstrap artifact catalogs for clean release real-mode flows. +# @LAYER: Domain +# @RELATION: DEPENDS_ON -> backend.src.models.clean_release.CandidateArtifact +# @INVARIANT: Artifact catalog must produce deterministic CandidateArtifact entries with required identity and checksum fields. + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict, List + +from ...models.clean_release import CandidateArtifact + + +# [DEF:load_bootstrap_artifacts:Function] +# @PURPOSE: Parse artifact catalog JSON into CandidateArtifact models for TUI/bootstrap flows. +# @PRE: path points to readable JSON file; payload is list[artifact] or {"artifacts": list[artifact]}. +# @POST: Returns non-mutated CandidateArtifact models with required fields populated. +def load_bootstrap_artifacts(path: str, candidate_id: str) -> List[CandidateArtifact]: + if not path or not path.strip(): + return [] + if not candidate_id or not candidate_id.strip(): + raise ValueError("candidate_id must be non-empty for artifact catalog import") + + catalog_path = Path(path) + payload = json.loads(catalog_path.read_text(encoding="utf-8")) + raw_artifacts = payload.get("artifacts") if isinstance(payload, dict) else payload + if not isinstance(raw_artifacts, list): + raise ValueError("artifact catalog must be a list or an object with 'artifacts' list") + + artifacts: List[CandidateArtifact] = [] + for index, raw_artifact in enumerate(raw_artifacts, start=1): + if not isinstance(raw_artifact, dict): + raise ValueError(f"artifact #{index} must be an object") + + artifact_id = str(raw_artifact.get("id", "")).strip() + artifact_path = str(raw_artifact.get("path", "")).strip() + artifact_sha256 = str(raw_artifact.get("sha256", "")).strip() + artifact_size = raw_artifact.get("size") + if not artifact_id: + raise ValueError(f"artifact #{index} missing required field 'id'") + if not artifact_path: + raise ValueError(f"artifact #{index} missing required field 'path'") + if not artifact_sha256: + raise ValueError(f"artifact #{index} missing required field 'sha256'") + if not isinstance(artifact_size, int) or artifact_size < 0: + raise ValueError(f"artifact #{index} field 'size' must be non-negative integer") + + category = str(raw_artifact.get("detected_category") or raw_artifact.get("category") or "").strip() or None + source_uri = str(raw_artifact.get("source_uri", "")).strip() or None + source_host = str(raw_artifact.get("source_host", "")).strip() or None + metadata_json = raw_artifact.get("metadata_json") + if metadata_json is None: + metadata_json = { + key: value + for key, value in raw_artifact.items() + if key + not in { + "id", + "path", + "sha256", + "size", + "category", + "detected_category", + "source_uri", + "source_host", + "metadata_json", + } + } + if not isinstance(metadata_json, dict): + raise ValueError(f"artifact #{index} field 'metadata_json' must be object") + + artifacts.append( + CandidateArtifact( + id=artifact_id, + candidate_id=candidate_id, + path=artifact_path, + sha256=artifact_sha256, + size=artifact_size, + detected_category=category, + declared_category=category, + source_uri=source_uri, + source_host=source_host, + metadata_json=metadata_json, + ) + ) + + return artifacts +# [/DEF:load_bootstrap_artifacts:Function] + +# [/DEF:backend.src.services.clean_release.artifact_catalog_loader:Module] diff --git a/backend/tests/scripts/test_clean_release_tui.py b/backend/tests/scripts/test_clean_release_tui.py index 87a87e55..f8ad731d 100644 --- a/backend/tests/scripts/test_clean_release_tui.py +++ b/backend/tests/scripts/test_clean_release_tui.py @@ -9,6 +9,7 @@ import os import sys import curses +import json from unittest import mock from unittest.mock import MagicMock, patch @@ -152,4 +153,71 @@ def test_tui_clear_history_f7(mock_curses_module, mock_stdscr: MagicMock): assert len(app.checks_progress) == 0 +@patch("src.scripts.clean_release_tui.curses") +def test_tui_real_mode_bootstrap_imports_artifacts_catalog( + mock_curses_module, + mock_stdscr: MagicMock, + tmp_path, +): + """ + @TEST_CONTRACT: bootstrap.json + artifacts.json -> candidate PREPARED with imported artifacts + """ + mock_curses_module.KEY_F10 = curses.KEY_F10 + mock_curses_module.color_pair.side_effect = lambda x: x + mock_curses_module.A_BOLD = 0 + + bootstrap_path = tmp_path / "bootstrap.json" + artifacts_path = tmp_path / "artifacts.json" + bootstrap_path.write_text( + json.dumps( + { + "candidate_id": "real-candidate-1", + "version": "1.0.0", + "source_snapshot_ref": "git:release/1", + "created_by": "operator", + "allowed_hosts": ["repo.intra.company.local"], + } + ), + encoding="utf-8", + ) + artifacts_path.write_text( + json.dumps( + { + "artifacts": [ + { + "id": "artifact-1", + "path": "backend/dist/package.tar.gz", + "sha256": "deadbeef", + "size": 1024, + "category": "core", + "source_uri": "https://repo.intra.company.local/releases/package.tar.gz", + "source_host": "repo.intra.company.local", + } + ] + } + ), + encoding="utf-8", + ) + + with mock.patch.dict( + os.environ, + { + "CLEAN_TUI_MODE": "real", + "CLEAN_TUI_BOOTSTRAP_JSON": str(bootstrap_path), + "CLEAN_TUI_ARTIFACTS_JSON": str(artifacts_path), + }, + clear=False, + ): + app = CleanReleaseTUI(mock_stdscr) + + candidate = app.repo.get_candidate("real-candidate-1") + artifacts = app.repo.get_artifacts_by_candidate("real-candidate-1") + + assert candidate is not None + assert candidate.status == "PREPARED" + assert len(artifacts) == 1 + assert artifacts[0].path == "backend/dist/package.tar.gz" + assert artifacts[0].detected_category == "core" + + # [/DEF:backend.tests.scripts.test_clean_release_tui:Module] diff --git a/frontend/src/components/StartupEnvironmentWizard.svelte b/frontend/src/components/StartupEnvironmentWizard.svelte new file mode 100644 index 00000000..24671077 --- /dev/null +++ b/frontend/src/components/StartupEnvironmentWizard.svelte @@ -0,0 +1,210 @@ + + + +{#if open} +
+
+
+
+
+

{$t.nav?.dashboards}

+

{$t.dashboard?.setup_title || "Configure your first environment"}

+

{$t.dashboard?.setup_intro || "Dashboards need at least one Superset environment. Create it here instead of landing on an empty screen."}

+
+ +
+
+ + {#if step === "intro"} +
+
+
+

{$t.dashboard?.setup_card_title || "What happens next"}

+

{$t.dashboard?.setup_card_body || "The wizard saves a Superset endpoint, validates login, and immediately makes the environment available in the global selector."}

+
+
+

{$t.dashboard?.setup_checklist_title || "Prepare these values"}

+
    +
  • {$t.dashboard?.setup_checklist_url || "Superset base URL without /api/v1"}
  • +
  • {$t.dashboard?.setup_checklist_user || "Service username with access to dashboards"}
  • +
  • {$t.dashboard?.setup_checklist_pass || "Password for the selected Superset account"}
  • +
+
+
+ +
+

{$t.dashboard?.setup_step_title || "Starter flow"}

+
+

1. {$t.dashboard?.setup_step_one || "Create the first environment"}

+

2. {$t.dashboard?.setup_step_two || "Select it automatically for the current session"}

+

3. {$t.dashboard?.setup_step_three || "Load dashboard inventory for that environment"}

+
+ +
+
+ {:else} +
+
+ + + + + + + +
+ + {#if submitError} +
{submitError}
+ {/if} + +
+ +
+ + +
+
+
+ {/if} +
+
+{/if} + diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index a937bff4..00890b83 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -430,7 +430,29 @@ "status_synced": "Synced", "status_diff": "Diff", "status_error": "Error", - "empty": "No dashboards found" + "empty": "No dashboards found", + "setup_badge": "Initial setup", + "setup_title": "Configure your first environment", + "setup_intro": "Dashboards need at least one Superset environment. Create it here instead of landing on an empty screen.", + "setup_open_advanced": "Advanced settings", + "setup_card_title": "What happens next", + "setup_card_body": "The wizard saves a Superset endpoint, validates login, and immediately adds the environment to the global selector.", + "setup_checklist_title": "Prepare these values", + "setup_checklist_url": "Superset base URL without /api/v1", + "setup_checklist_user": "Username with dashboard access", + "setup_checklist_pass": "Password for the selected Superset account", + "setup_step_title": "Starter flow", + "setup_step_one": "Create the first environment", + "setup_step_two": "Select it automatically for the current session", + "setup_step_three": "Load dashboards from the new environment", + "setup_start": "Start setup", + "setup_create": "Create environment", + "setup_creating": "Creating environment...", + "setup_required": "Fill in ID, name, URL, username, and password.", + "setup_failed": "Failed to create environment.", + "setup_created": "Environment created. Loading dashboards.", + "setup_empty_title": "No environments configured yet", + "setup_empty_body": "Add the first Superset environment to unlock dashboards, datasets, backups, and migrations." }, "profile": { "title": "Profile", diff --git a/frontend/src/lib/i18n/locales/ru.json b/frontend/src/lib/i18n/locales/ru.json index 66f8244b..3f9268a9 100644 --- a/frontend/src/lib/i18n/locales/ru.json +++ b/frontend/src/lib/i18n/locales/ru.json @@ -428,7 +428,29 @@ "status_synced": "Синхронизировано", "status_diff": "Различия", "status_error": "Ошибка", - "empty": "Дашборды не найдены" + "empty": "Дашборды не найдены", + "setup_badge": "Стартовая настройка", + "setup_title": "Настройте первое окружение", + "setup_intro": "Для работы с дашбордами нужен хотя бы один Superset environment. Создайте его прямо здесь, без пустого экрана.", + "setup_open_advanced": "Расширенные настройки", + "setup_card_title": "Что произойдет дальше", + "setup_card_body": "Wizard сохранит endpoint Superset, проверит авторизацию и сразу добавит окружение в глобальный селектор.", + "setup_checklist_title": "Подготовьте данные", + "setup_checklist_url": "Базовый URL Superset без /api/v1", + "setup_checklist_user": "Логин пользователя с доступом к дашбордам", + "setup_checklist_pass": "Пароль выбранного аккаунта Superset", + "setup_step_title": "Быстрый путь", + "setup_step_one": "Создать первое окружение", + "setup_step_two": "Автоматически выбрать его для текущей сессии", + "setup_step_three": "Загрузить список дашбордов из нового окружения", + "setup_start": "Начать настройку", + "setup_create": "Создать окружение", + "setup_creating": "Создание окружения...", + "setup_required": "Заполните ID, имя, URL, логин и пароль.", + "setup_failed": "Не удалось создать окружение.", + "setup_created": "Окружение создано. Загружаю дашборды.", + "setup_empty_title": "Окружения еще не настроены", + "setup_empty_body": "Добавьте первое окружение Superset, чтобы открыть дашборды, датасеты, бэкапы и миграции." }, "profile": { "title": "Профиль", diff --git a/frontend/src/routes/dashboards/+page.svelte b/frontend/src/routes/dashboards/+page.svelte index d96be6af..0ad1781e 100644 --- a/frontend/src/routes/dashboards/+page.svelte +++ b/frontend/src/routes/dashboards/+page.svelte @@ -44,9 +44,11 @@ import { addToast } from "$lib/toasts.js"; import { gitService } from "../../services/gitService.js"; import MappingTable from "../../components/MappingTable.svelte"; + import StartupEnvironmentWizard from "../../components/StartupEnvironmentWizard.svelte"; import { environmentContextStore, initializeEnvironmentContext, + setSelectedEnvironment, } from "$lib/stores/environmentContext.js"; // State @@ -127,6 +129,15 @@ // Environment options - will be loaded from API let environments = $derived($environmentContextStore?.environments || []); + let shouldShowEnvironmentWizard = $derived( + Boolean($environmentContextStore?.isLoaded) && + !$environmentContextStore?.isLoading && + !$environmentContextStore?.error && + environments.length === 0, + ); + let shouldShowEnvironmentZeroState = $derived( + shouldShowEnvironmentWizard && !isLoading && !error, + ); // Debounced search function const debouncedSearch = debounce((query) => { @@ -1410,6 +1421,30 @@ } }); + $effect(() => { + const context = $environmentContextStore; + if (!context || context.isLoading) return; + + if (context.error && !selectedEnv) { + error = context.error; + isLoading = false; + return; + } + + if (context.isLoaded && context.environments.length === 0) { + isLoading = false; + error = null; + allDashboards = []; + dashboards = []; + filteredDashboards = []; + total = 0; + totalPages = 1; + serverTotal = 0; + serverTotalPages = 1; + lastLoadedEnvId = null; + } + }); + $effect(() => { const envId = selectedEnv; if (!envId || envId === lastLoadedEnvId) return; @@ -1420,6 +1455,14 @@ clearSelectedIds(); void loadDashboards(); }); + + async function handleEnvironmentCreated(envId) { + if (!envId) return; + error = null; + lastLoadedEnvId = null; + selectedEnv = envId; + setSelectedEnvironment(envId); + }
@@ -1517,6 +1560,36 @@
{/each} + {:else if shouldShowEnvironmentZeroState} +
+
+

+ {$t.dashboard?.setup_badge || "Initial setup"} +

+

+ {$t.dashboard?.setup_empty_title || "No environments configured yet"} +

+

+ {$t.dashboard?.setup_empty_body || + "Add the first Superset environment to unlock dashboards, datasets, backups, and migrations."} +

+
+ + +
+
+
{:else if dashboards.length === 0}
@@ -2775,6 +2848,11 @@
{/if} + +