Skip to content

Commit 867a61d

Browse files
committed
feat(Console): Add quick setup feature
1 parent 9e511d2 commit 867a61d

9 files changed

Lines changed: 1487 additions & 1 deletion

File tree

console/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"clsx": "^2.1.1",
3131
"cmdk": "^1.1.1",
3232
"date-fns": "^4.1.0",
33+
"js-yaml": "^4.1.1",
3334
"lucide-react": "^0.548.0",
3435
"react": "^19.2.0",
3536
"react-dom": "^19.2.0",
@@ -42,6 +43,7 @@
4243
},
4344
"devDependencies": {
4445
"@eslint/js": "^9.36.0",
46+
"@types/js-yaml": "^4.0.9",
4547
"@types/node": "^24.9.2",
4648
"@types/react": "^19.1.16",
4749
"@types/react-dom": "^19.1.9",
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import { useState } from "react"
21+
import { useQueryClient } from "@tanstack/react-query"
22+
import { toast } from "sonner"
23+
import {
24+
Dialog,
25+
DialogContent,
26+
DialogDescription,
27+
DialogHeader,
28+
DialogTitle,
29+
} from "@/components/ui/dialog"
30+
import { buildSteps, executeSetup, type SetupStep, type SetupResult } from "@/lib/setup-executor"
31+
import { PRESETS, type Preset } from "@/lib/setup-presets"
32+
import type { SetupConfig } from "@/types/setup-config"
33+
import { ModeStep, PresetStep, ParamsStep, ReviewStep } from "./quick-setup/setup-steps"
34+
import { YamlStep } from "./quick-setup/YamlStep"
35+
import { ApplyStep, DoneStep } from "./quick-setup/progress-steps"
36+
37+
type WizardStep = "mode" | "preset" | "params" | "yaml" | "review" | "applying" | "done"
38+
39+
const DIALOG_TITLES: Record<WizardStep, string> = {
40+
mode: "Quick Setup",
41+
preset: "Choose a Preset",
42+
params: "Configure Storage",
43+
yaml: "Paste Custom YAML",
44+
review: "Review Actions",
45+
applying: "Applying Configuration",
46+
done: "Setup Complete",
47+
}
48+
49+
interface QuickSetupWizardProps {
50+
open: boolean
51+
onOpenChange: (open: boolean) => void
52+
}
53+
54+
export function QuickSetupWizard({ open, onOpenChange }: QuickSetupWizardProps) {
55+
const queryClient = useQueryClient()
56+
57+
const [step, setStep] = useState<WizardStep>("mode")
58+
const [mode, setMode] = useState<"preset" | "yaml">("preset")
59+
const [selectedPreset, setSelectedPreset] = useState<Preset>(PRESETS[0])
60+
const [params, setParams] = useState<Record<string, string>>({})
61+
const [config, setConfig] = useState<SetupConfig | null>(null)
62+
const [applySteps, setApplySteps] = useState<SetupStep[]>([])
63+
const [result, setResult] = useState<SetupResult | null>(null)
64+
const [isApplying, setIsApplying] = useState(false)
65+
66+
const reset = () => {
67+
setStep("mode")
68+
setMode("preset")
69+
setSelectedPreset(PRESETS[0])
70+
setParams({})
71+
setConfig(null)
72+
setApplySteps([])
73+
setResult(null)
74+
setIsApplying(false)
75+
}
76+
77+
const handleOpenChange = (nextOpen: boolean) => {
78+
if (!nextOpen && isApplying) return
79+
if (!nextOpen) reset()
80+
onOpenChange(nextOpen)
81+
}
82+
83+
const handleSelectMode = (m: "preset" | "yaml") => {
84+
setMode(m)
85+
setStep(m === "preset" ? "preset" : "yaml")
86+
}
87+
88+
const loadConfig = (preset: Preset, p: Record<string, string>) => {
89+
const resolved = preset.buildConfig(p)
90+
setConfig(resolved)
91+
setApplySteps(buildSteps(resolved))
92+
setStep("review")
93+
}
94+
95+
const handleNextFromPreset = () => {
96+
if (selectedPreset.params.length > 0) {
97+
setStep("params")
98+
return
99+
}
100+
loadConfig(selectedPreset, {})
101+
}
102+
103+
const handleNextFromParams = () => loadConfig(selectedPreset, params)
104+
105+
const handleNextFromYaml = (parsed: SetupConfig) => {
106+
setConfig(parsed)
107+
setApplySteps(buildSteps(parsed))
108+
setStep("review")
109+
}
110+
111+
const handleApply = async () => {
112+
if (!config) return
113+
setStep("applying")
114+
setIsApplying(true)
115+
try {
116+
const setupResult = await executeSetup(config, setApplySteps)
117+
setResult(setupResult)
118+
queryClient.invalidateQueries()
119+
} catch (e) {
120+
toast.error("Setup failed", { description: e instanceof Error ? e.message : String(e) })
121+
} finally {
122+
setIsApplying(false)
123+
setStep("done")
124+
}
125+
}
126+
127+
return (
128+
<Dialog open={open} onOpenChange={handleOpenChange}>
129+
<DialogContent className="flex max-h-[90vh] max-w-2xl flex-col overflow-hidden">
130+
<DialogHeader className="shrink-0">
131+
<DialogTitle>{DIALOG_TITLES[step]}</DialogTitle>
132+
{step === "mode" && (
133+
<DialogDescription>
134+
Bootstrap a Polaris environment using a preset or your own YAML config.
135+
</DialogDescription>
136+
)}
137+
</DialogHeader>
138+
139+
<div className="min-h-0 flex-1 overflow-y-auto">
140+
{step === "mode" && <ModeStep onSelect={handleSelectMode} />}
141+
142+
{step === "preset" && (
143+
<PresetStep
144+
selected={selectedPreset}
145+
onSelect={setSelectedPreset}
146+
onBack={() => setStep("mode")}
147+
onNext={handleNextFromPreset}
148+
/>
149+
)}
150+
151+
{step === "params" && (
152+
<ParamsStep
153+
preset={selectedPreset}
154+
params={params}
155+
onChange={setParams}
156+
onBack={() => setStep("preset")}
157+
onNext={handleNextFromParams}
158+
/>
159+
)}
160+
161+
{step === "yaml" && (
162+
<YamlStep onBack={() => setStep("mode")} onNext={handleNextFromYaml} />
163+
)}
164+
165+
{step === "review" && applySteps.length > 0 && (
166+
<ReviewStep
167+
steps={applySteps}
168+
onBack={() =>
169+
setStep(
170+
mode === "yaml" ? "yaml" : selectedPreset.params.length > 0 ? "params" : "preset"
171+
)
172+
}
173+
onApply={handleApply}
174+
/>
175+
)}
176+
177+
{step === "applying" && <ApplyStep steps={applySteps} isApplying={isApplying} />}
178+
179+
{(step === "done" || (step === "applying" && !isApplying)) && (
180+
<DoneStep
181+
steps={applySteps}
182+
result={result ?? { credentials: [] }}
183+
onDone={() => handleOpenChange(false)}
184+
/>
185+
)}
186+
</div>
187+
</DialogContent>
188+
</Dialog>
189+
)
190+
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import { useState, useEffect, useRef } from "react"
21+
import { load as parseYaml } from "js-yaml"
22+
import { CheckCircle2, AlertCircle, Upload } from "lucide-react"
23+
import { Button } from "@/components/ui/button"
24+
import { Textarea } from "@/components/ui/textarea"
25+
import { DialogFooter } from "@/components/ui/dialog"
26+
import type { SetupConfig } from "@/types/setup-config"
27+
28+
type ParseResult = { config: SetupConfig; entityCount: number } | { error: string }
29+
30+
interface Props {
31+
onBack: () => void
32+
onNext: (config: SetupConfig) => void
33+
}
34+
35+
function parseConfig(text: string): ParseResult {
36+
try {
37+
const parsed = parseYaml(text)
38+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
39+
return { error: "YAML must be a mapping object (not a list or scalar)" }
40+
}
41+
const cfg = parsed as SetupConfig
42+
const entityCount =
43+
(cfg.principal_roles?.length ?? 0) +
44+
Object.keys(cfg.principals ?? {}).length +
45+
(cfg.catalogs?.length ?? 0)
46+
if (entityCount === 0) {
47+
return { error: "No principal_roles, principals, or catalogs found" }
48+
}
49+
return { config: cfg, entityCount }
50+
} catch (e) {
51+
return { error: e instanceof Error ? e.message : String(e) }
52+
}
53+
}
54+
55+
export function YamlStep({ onBack, onNext }: Props) {
56+
const [text, setText] = useState("")
57+
const fileInputRef = useRef<HTMLInputElement>(null)
58+
const [parseResult, setParseResult] = useState<ParseResult | null>(null)
59+
60+
useEffect(() => {
61+
if (!text.trim()) {
62+
setParseResult(null)
63+
return
64+
}
65+
setParseResult(parseConfig(text))
66+
}, [text])
67+
68+
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
69+
const file = e.target.files?.[0]
70+
if (!file) return
71+
const reader = new FileReader()
72+
reader.onload = () => setText(reader.result as string)
73+
reader.readAsText(file)
74+
e.target.value = ""
75+
}
76+
77+
const valid = parseResult && "config" in parseResult
78+
79+
return (
80+
<>
81+
<div className="space-y-3">
82+
<div className="flex items-start justify-between gap-4">
83+
<p className="text-sm text-muted-foreground">
84+
Paste a YAML config.
85+
</p>
86+
<Button
87+
variant="outline"
88+
size="sm"
89+
className="shrink-0"
90+
onClick={() => fileInputRef.current?.click()}
91+
>
92+
<Upload className="mr-2 h-3.5 w-3.5" />
93+
Upload File
94+
</Button>
95+
<input
96+
ref={fileInputRef}
97+
type="file"
98+
accept=".yaml,.yml"
99+
className="hidden"
100+
onChange={handleFile}
101+
/>
102+
</div>
103+
104+
<div className="rounded-lg bg-muted/40 p-4 space-y-2">
105+
<Textarea
106+
className="font-mono text-xs min-h-56 resize-y bg-background border-border"
107+
placeholder={
108+
"principal_roles:\n - my_role\nprincipals:\n my_user:\n roles:\n - my_role\ncatalogs:\n - name: my_catalog\n storage_type: file\n ..."
109+
}
110+
value={text}
111+
onChange={(e) => setText(e.target.value)}
112+
/>
113+
114+
{parseResult && (
115+
<div
116+
className={`flex items-center gap-2 text-sm ${valid ? "text-green-600" : "text-destructive"}`}
117+
>
118+
{valid ? (
119+
<>
120+
<CheckCircle2 className="h-4 w-4 shrink-0" />
121+
<span>
122+
Valid — {(parseResult as { entityCount: number }).entityCount} top-level
123+
entities detected
124+
</span>
125+
</>
126+
) : (
127+
<>
128+
<AlertCircle className="h-4 w-4 shrink-0" />
129+
<span className="truncate">{(parseResult as { error: string }).error}</span>
130+
</>
131+
)}
132+
</div>
133+
)}
134+
</div>
135+
</div>
136+
<DialogFooter className="sticky bottom-0 bg-background pt-4 mt-4 border-t">
137+
<Button variant="outline" onClick={onBack}>
138+
Back
139+
</Button>
140+
<Button
141+
disabled={!valid}
142+
onClick={() => onNext((parseResult as { config: SetupConfig }).config)}
143+
>
144+
Preview →
145+
</Button>
146+
</DialogFooter>
147+
</>
148+
)
149+
}

0 commit comments

Comments
 (0)