Files
ss-tools/frontend/src/components/StartupEnvironmentWizard.svelte
2026-05-26 09:30:41 +03:00

218 lines
12 KiB
Svelte

<!-- #region StartupEnvironmentWizard [C:3] [TYPE Component] [SEMANTICS wizard, environment, setup, onboarding, form] -->
<!-- @BRIEF Component component: components/StartupEnvironmentWizard.svelte -->
<!-- @LAYER UI -->
<!-- @RATIONALE Fixed (1) missing connections.json locale — $t.connections?.name/user/pass rendered empty spans causing floating form fields. Created en/ru locales. (2) header bg-slate-50 created visible white strip at top in darkened modal overlay; changed to bg-white, border-b provides sufficient separation. -->
<script>
/**
* @PURPOSE: Blocking startup wizard for creating the first Superset environment from zero-state screens.
* @LAYER UI
* @RELATION CALLS -> api
* @RELATION CALLS -> environmentContext
* @INVARIANT: When open, wizard keeps user on an actionable setup path until the first environment exists.
*
* @UX_STATE: Intro -> Explains why dashboard screen is blocked.
* @UX_STATE: Form -> Collects minimal environment configuration.
* @UX_STATE: Saving -> Disables controls and shows progress label.
* @UX_STATE: Error -> Shows validation or backend error inline.
* @UX_FEEDBACK: Toast on success, inline banner on failure.
* @UX_RECOVERY: User can switch to advanced settings or retry save with corrected data.
*/
import { goto } from "$app/navigation";
import { t } from "$lib/i18n";
import { api } from "$lib/api.js";
import { addToast } from "$lib/toasts.js";
import { refreshEnvironmentContext, setSelectedEnvironment } from "$lib/stores/environmentContext.js";
let { open = false, onCreated = async () => {} } = $props();
let step = $state("intro");
let isSubmitting = $state(false);
let submitError = $state("");
let isIdLocked = $state(false);
let form = $state(createInitialForm());
function createInitialForm() {
return {
id: "", name: "", url: "", username: "", password: "", stage: "DEV", is_default: true,
backup_schedule: { enabled: false, cron_expression: "0 0 * * *" },
};
}
function slugifyEnvironmentId(value) {
return String(value || "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
}
function normalizeSupersetBaseUrl(rawUrl) {
let normalized = String(rawUrl || "").trim().replace(/\/+$/, "");
if (normalized.toLowerCase().endsWith("/api/v1")) {
normalized = normalized.slice(0, -"/api/v1".length).replace(/\/+$/, "");
}
// Auto-prepend https:// if no scheme is present
if (normalized && !/^https?:\/\//i.test(normalized)) {
normalized = `https://${normalized}`;
}
return normalized;
}
function resetWizard() {
step = "intro";
isSubmitting = false;
submitError = "";
isIdLocked = false;
form = createInitialForm();
}
function handleNameInput(event) {
form.name = event.currentTarget.value;
if (!isIdLocked) form.id = slugifyEnvironmentId(form.name);
form = { ...form };
}
function handleIdInput(event) {
isIdLocked = true;
form.id = slugifyEnvironmentId(event.currentTarget.value);
form = { ...form };
}
async function openAdvancedSettings() {
await goto("/settings?tab=environments#environments");
}
async function handleCreateEnvironment() {
submitError = "";
const payload = {
...form,
id: slugifyEnvironmentId(form.id),
url: normalizeSupersetBaseUrl(form.url),
stage: String(form.stage || "DEV").toUpperCase(),
};
payload.is_production = payload.stage === "PROD";
if (!payload.id || !payload.name.trim() || !payload.url || !payload.username.trim() || !payload.password.trim()) {
submitError = $t.dashboard?.setup_required || "Fill in ID, name, URL, username, and password.";
return;
}
isSubmitting = true;
try {
await api.addEnvironment(payload);
await refreshEnvironmentContext(payload.id);
setSelectedEnvironment(payload.id);
addToast($t.dashboard?.setup_created || "Environment created. Loading dashboards.", "success");
await onCreated(payload.id);
resetWizard();
} catch (error) {
submitError = error?.message || $t.dashboard?.setup_failed || "Failed to create environment.";
} finally {
isSubmitting = false;
}
}
$effect(() => {
if (!open) resetWizard();
});
</script>
{#if open}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/45 px-4 py-6 backdrop-blur-sm">
<div class="w-full max-w-3xl overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-2xl">
<div class="border-b border-slate-200 bg-white px-6 py-5">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-sky-600">{$t.nav?.dashboards}</p>
<h2 class="mt-2 text-2xl font-semibold text-slate-950">{$t.dashboard?.setup_title || "Configure your first environment"}</h2>
<p class="mt-2 max-w-2xl text-sm text-slate-600">{$t.dashboard?.setup_intro || "Dashboards need at least one Superset environment. Create it here instead of landing on an empty screen."}</p>
</div>
<button type="button" class="rounded-lg border border-slate-300 px-3 py-2 text-sm font-medium text-slate-600 transition hover:border-slate-400 hover:text-slate-900" onclick={openAdvancedSettings}>
{$t.dashboard?.setup_open_advanced || "Advanced settings"}
</button>
</div>
</div>
{#if step === "intro"}
<div class="grid gap-6 px-6 py-6 lg:grid-cols-[1.2fr_0.8fr]">
<div class="space-y-4">
<div class="rounded-2xl border border-sky-100 bg-sky-50 p-4">
<p class="text-sm font-medium text-sky-900">{$t.dashboard?.setup_card_title || "What happens next"}</p>
<p class="mt-2 text-sm text-sky-800">{$t.dashboard?.setup_card_body || "The wizard saves a Superset endpoint, validates login, and immediately makes the environment available in the global selector."}</p>
</div>
<div class="rounded-2xl border border-slate-200 p-4">
<p class="text-sm font-medium text-slate-900">{$t.dashboard?.setup_checklist_title || "Prepare these values"}</p>
<ul class="mt-3 space-y-2 text-sm text-slate-600">
<li>{$t.dashboard?.setup_checklist_url || "Superset base URL without /api/v1"}</li>
<li>{$t.dashboard?.setup_checklist_user || "Service username with access to dashboards"}</li>
<li>{$t.dashboard?.setup_checklist_pass || "Password for the selected Superset account"}</li>
</ul>
</div>
</div>
<div class="rounded-3xl bg-slate-950 p-5 text-slate-100">
<p class="text-sm font-medium text-white">{$t.dashboard?.setup_step_title || "Starter flow"}</p>
<div class="mt-4 space-y-3 text-sm text-slate-300">
<p>1. {$t.dashboard?.setup_step_one || "Create the first environment"}</p>
<p>2. {$t.dashboard?.setup_step_two || "Select it automatically for the current session"}</p>
<p>3. {$t.dashboard?.setup_step_three || "Load dashboard inventory for that environment"}</p>
</div>
<button type="button" class="mt-6 inline-flex w-full items-center justify-center rounded-xl bg-sky-500 px-4 py-3 text-sm font-semibold text-white transition hover:bg-sky-600" onclick={() => { step = "form"; submitError = ""; }}>
{$t.dashboard?.setup_start || "Start setup"}
</button>
</div>
</div>
{:else}
<div class="px-6 py-6">
<div class="grid gap-4 md:grid-cols-2">
<label class="block text-sm">
<span class="font-medium text-slate-700">{$t.common?.id}</span>
<input type="text" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2.5 text-slate-900 shadow-sm outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-100" value={form.id} oninput={handleIdInput} placeholder="dev-superset" disabled={isSubmitting} />
</label>
<label class="block text-sm">
<span class="font-medium text-slate-700">{$t.connections?.name}</span>
<input type="text" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2.5 text-slate-900 shadow-sm outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-100" value={form.name} oninput={handleNameInput} placeholder="Development" disabled={isSubmitting} />
</label>
<label class="block text-sm md:col-span-2">
<span class="font-medium text-slate-700">{$t.settings?.env_url}</span>
<input type="url" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2.5 text-slate-900 shadow-sm outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-100" bind:value={form.url} placeholder="https://superset.example.com" disabled={isSubmitting} />
</label>
<label class="block text-sm">
<span class="font-medium text-slate-700">{$t.connections?.user}</span>
<input type="text" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2.5 text-slate-900 shadow-sm outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-100" bind:value={form.username} placeholder="admin" disabled={isSubmitting} />
</label>
<label class="block text-sm">
<span class="font-medium text-slate-700">{$t.connections?.pass}</span>
<input type="password" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2.5 text-slate-900 shadow-sm outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-100" bind:value={form.password} placeholder="••••••••" disabled={isSubmitting} />
</label>
<label class="block text-sm">
<span class="font-medium text-slate-700">Stage</span>
<select class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2.5 text-slate-900 shadow-sm outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-100" bind:value={form.stage} disabled={isSubmitting}>
<option value="DEV">DEV</option><option value="PREPROD">PREPROD</option><option value="PROD">PROD</option>
</select>
</label>
<label class="flex items-center gap-3 rounded-2xl border border-slate-200 px-4 py-3 text-sm text-slate-700">
<input type="checkbox" class="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500" bind:checked={form.is_default} disabled={isSubmitting} />
<span>{$t.settings?.env_default}</span>
</label>
</div>
{#if submitError}
<div class="mt-4 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{submitError}</div>
{/if}
<div class="mt-6 flex flex-wrap items-center justify-between gap-3">
<button type="button" class="rounded-xl border border-slate-300 px-4 py-2.5 text-sm font-medium text-slate-700 transition hover:border-slate-400 hover:text-slate-900" onclick={() => { step = "intro"; submitError = ""; }} disabled={isSubmitting}>
{$t.common?.back}
</button>
<div class="flex flex-wrap items-center gap-3">
<button type="button" class="rounded-xl border border-slate-300 px-4 py-2.5 text-sm font-medium text-slate-700 transition hover:border-slate-400 hover:text-slate-900" onclick={openAdvancedSettings} disabled={isSubmitting}>
{$t.dashboard?.setup_open_advanced || "Advanced settings"}
</button>
<button type="button" class="inline-flex min-w-44 items-center justify-center rounded-xl bg-sky-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-sky-700 disabled:cursor-not-allowed disabled:bg-sky-300" onclick={handleCreateEnvironment} disabled={isSubmitting}>
{isSubmitting ? $t.dashboard?.setup_creating || "Creating environment..." : $t.dashboard?.setup_create || "Create environment"}
</button>
</div>
</div>
</div>
{/if}
</div>
</div>
{/if}
<!-- #endregion StartupEnvironmentWizard -->