Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions console/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"js-yaml": "^4.1.1",
"lucide-react": "^0.548.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
Expand All @@ -42,6 +43,7 @@
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@types/js-yaml": "^4.0.9",
"@types/node": "^24.9.2",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
Expand Down
190 changes: 190 additions & 0 deletions console/src/components/forms/QuickSetupWizard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { useState } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { buildSteps, executeSetup, type SetupStep, type SetupResult } from "@/lib/setup-executor"
import { PRESETS, type Preset } from "@/lib/setup-presets"
import type { SetupConfig } from "@/types/setup-config"
import { ModeStep, PresetStep, ParamsStep, ReviewStep } from "./quick-setup/setup-steps"
import { YamlStep } from "./quick-setup/YamlStep"
import { ApplyStep, DoneStep } from "./quick-setup/progress-steps"

type WizardStep = "mode" | "preset" | "params" | "yaml" | "review" | "applying" | "done"

const DIALOG_TITLES: Record<WizardStep, string> = {
mode: "Quick Setup",
preset: "Choose a Preset",
params: "Configure Storage",
yaml: "Paste Custom YAML",
review: "Review Actions",
applying: "Applying Configuration",
done: "Setup Complete",
}

interface QuickSetupWizardProps {
open: boolean
onOpenChange: (open: boolean) => void
}

export function QuickSetupWizard({ open, onOpenChange }: QuickSetupWizardProps) {
const queryClient = useQueryClient()

const [step, setStep] = useState<WizardStep>("mode")
const [mode, setMode] = useState<"preset" | "yaml">("preset")
const [selectedPreset, setSelectedPreset] = useState<Preset>(PRESETS[0])
const [params, setParams] = useState<Record<string, string>>({})
const [config, setConfig] = useState<SetupConfig | null>(null)
const [applySteps, setApplySteps] = useState<SetupStep[]>([])
const [result, setResult] = useState<SetupResult | null>(null)
const [isApplying, setIsApplying] = useState(false)

const reset = () => {
setStep("mode")
setMode("preset")
setSelectedPreset(PRESETS[0])
setParams({})
setConfig(null)
setApplySteps([])
setResult(null)
setIsApplying(false)
}

const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen && isApplying) return
if (!nextOpen) reset()
onOpenChange(nextOpen)
}

const handleSelectMode = (m: "preset" | "yaml") => {
setMode(m)
setStep(m === "preset" ? "preset" : "yaml")
}

const loadConfig = (preset: Preset, p: Record<string, string>) => {
const resolved = preset.buildConfig(p)
setConfig(resolved)
setApplySteps(buildSteps(resolved))
setStep("review")
}

const handleNextFromPreset = () => {
if (selectedPreset.params.length > 0) {
setStep("params")
return
}
loadConfig(selectedPreset, {})
}

const handleNextFromParams = () => loadConfig(selectedPreset, params)

const handleNextFromYaml = (parsed: SetupConfig) => {
setConfig(parsed)
setApplySteps(buildSteps(parsed))
setStep("review")
}

const handleApply = async () => {
if (!config) return
setStep("applying")
setIsApplying(true)
try {
const setupResult = await executeSetup(config, setApplySteps)
setResult(setupResult)
queryClient.invalidateQueries()
} catch (e) {
toast.error("Setup failed", { description: e instanceof Error ? e.message : String(e) })
} finally {
setIsApplying(false)
setStep("done")
}
}

return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="flex max-h-[90vh] max-w-2xl flex-col overflow-hidden">
<DialogHeader className="shrink-0">
<DialogTitle>{DIALOG_TITLES[step]}</DialogTitle>
{step === "mode" && (
<DialogDescription>
Bootstrap a Polaris environment using a preset or your own YAML config.
</DialogDescription>
)}
</DialogHeader>

<div className="min-h-0 flex-1 overflow-y-auto">
{step === "mode" && <ModeStep onSelect={handleSelectMode} />}

{step === "preset" && (
<PresetStep
selected={selectedPreset}
onSelect={setSelectedPreset}
onBack={() => setStep("mode")}
onNext={handleNextFromPreset}
/>
)}

{step === "params" && (
<ParamsStep
preset={selectedPreset}
params={params}
onChange={setParams}
onBack={() => setStep("preset")}
onNext={handleNextFromParams}
/>
)}

{step === "yaml" && (
<YamlStep onBack={() => setStep("mode")} onNext={handleNextFromYaml} />
)}

{step === "review" && applySteps.length > 0 && (
<ReviewStep
steps={applySteps}
onBack={() =>
setStep(
mode === "yaml" ? "yaml" : selectedPreset.params.length > 0 ? "params" : "preset"
)
}
onApply={handleApply}
/>
)}

{step === "applying" && <ApplyStep steps={applySteps} isApplying={isApplying} />}

{(step === "done" || (step === "applying" && !isApplying)) && (
<DoneStep
steps={applySteps}
result={result ?? { credentials: [] }}
onDone={() => handleOpenChange(false)}
/>
)}
</div>
</DialogContent>
</Dialog>
)
}
147 changes: 147 additions & 0 deletions console/src/components/forms/quick-setup/YamlStep.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { useState, useEffect, useRef } from "react"
import { load as parseYaml } from "js-yaml"
import { CheckCircle2, AlertCircle, Upload } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { DialogFooter } from "@/components/ui/dialog"
import type { SetupConfig } from "@/types/setup-config"

type ParseResult = { config: SetupConfig; entityCount: number } | { error: string }

interface Props {
onBack: () => void
onNext: (config: SetupConfig) => void
}

function parseConfig(text: string): ParseResult {
try {
const parsed = parseYaml(text)
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return { error: "YAML must be a mapping object (not a list or scalar)" }
}
const cfg = parsed as SetupConfig
const entityCount =
(cfg.principal_roles?.length ?? 0) +
Object.keys(cfg.principals ?? {}).length +
(cfg.catalogs?.length ?? 0)
if (entityCount === 0) {
return { error: "No principal_roles, principals, or catalogs found" }
}
return { config: cfg, entityCount }
} catch (e) {
return { error: e instanceof Error ? e.message : String(e) }
}
}

export function YamlStep({ onBack, onNext }: Props) {
const [text, setText] = useState("")
const fileInputRef = useRef<HTMLInputElement>(null)
const [parseResult, setParseResult] = useState<ParseResult | null>(null)

useEffect(() => {
if (!text.trim()) {
setParseResult(null)
return
}
setParseResult(parseConfig(text))
}, [text])

const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => setText(reader.result as string)
reader.readAsText(file)
e.target.value = ""
}

const valid = parseResult && "config" in parseResult

return (
<>
<div className="space-y-3">
<div className="flex items-start justify-between gap-4">
<p className="text-sm text-muted-foreground">Paste a YAML config.</p>
<Button
variant="outline"
size="sm"
className="shrink-0"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="mr-2 h-3.5 w-3.5" />
Upload File
</Button>
<input
ref={fileInputRef}
type="file"
accept=".yaml,.yml"
className="hidden"
onChange={handleFile}
/>
</div>

<div className="rounded-lg bg-muted/40 p-4 space-y-2">
<Textarea
className="font-mono text-xs min-h-56 resize-y bg-background border-border"
placeholder={
"principal_roles:\n - my_role\nprincipals:\n my_user:\n roles:\n - my_role\ncatalogs:\n - name: my_catalog\n storage_type: file\n ..."
}
value={text}
onChange={(e) => setText(e.target.value)}
/>

{parseResult && (
<div
className={`flex items-center gap-2 text-sm ${valid ? "text-green-600" : "text-destructive"}`}
>
{valid ? (
<>
<CheckCircle2 className="h-4 w-4 shrink-0" />
<span>
Valid — {(parseResult as { entityCount: number }).entityCount} top-level
entities detected
</span>
</>
) : (
<>
<AlertCircle className="h-4 w-4 shrink-0" />
<span className="truncate">{(parseResult as { error: string }).error}</span>
</>
)}
</div>
)}
</div>
</div>
<DialogFooter className="sticky bottom-0 bg-background pt-4 mt-4 border-t">
<Button variant="outline" onClick={onBack}>
Back
</Button>
<Button
disabled={!valid}
onClick={() => onNext((parseResult as { config: SetupConfig }).config)}
>
Preview →
</Button>
</DialogFooter>
</>
)
}
Loading
Loading