218 lines
12 KiB
Svelte
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 -->
|