refactor(install): collapse install UI pickers and dedupe server strings

- Derive install stepper's current step from INSTALL_STEPS instead of a hand-maintained pathname if-chain.
- Replace four near-identical picker components with one generic CardPicker plus per-flow option arrays.
- Extract repeated object-store validation error strings into constants and a small helper.
- Run the S3 artifacts/ and slatedb/ prefix probes concurrently via tokio::try_join!.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-04-22 19:43:49 -04:00
parent 4bf0c40319
commit e184213330
No known key found for this signature in database
4 changed files with 226 additions and 300 deletions

View file

@ -247,14 +247,12 @@ export default function InstallApp() {
};
}, [finishState]);
const currentStep = useMemo<StepId>(() => {
if (location.pathname.startsWith("/install/object-store")) return "object_store";
if (location.pathname.startsWith("/install/llm")) return "llm";
if (location.pathname.startsWith("/install/server")) return "server";
if (location.pathname.startsWith("/install/github")) return "github";
if (location.pathname.startsWith("/install/review")) return "review";
return "welcome";
}, [location.pathname]);
const currentStep = useMemo<StepId>(
() =>
STEPPER_STEPS.find((step) => location.pathname.startsWith(step.href))?.id ??
"welcome",
[location.pathname],
);
const completedSteps = new Set(session?.completed_steps ?? []);
@ -471,8 +469,10 @@ export default function InstallApp() {
}
}}
>
<ObjectStoreProviderPicker
provider={objectStoreForm.provider}
<CardPicker
legend="Object store"
options={OBJECT_STORE_PROVIDER_OPTIONS}
value={objectStoreForm.provider}
onChange={(provider) => {
setObjectStoreForm((current) => ({ ...current, provider }));
if (provider === "s3") {
@ -516,8 +516,10 @@ export default function InstallApp() {
autoCapitalize="off"
/>
</Field>
<ObjectStoreCredentialModePicker
credentialMode={objectStoreForm.credentialMode}
<CardPicker
legend="Credentials"
options={OBJECT_STORE_CREDENTIAL_MODE_OPTIONS}
value={objectStoreForm.credentialMode}
onChange={(credentialMode) => {
setObjectStoreForm((current) => ({ ...current, credentialMode }));
if (credentialMode === "access_key") {
@ -642,7 +644,12 @@ export default function InstallApp() {
}
}}
>
<GithubStrategyPicker strategy={githubStrategy} onChange={setGithubStrategy} />
<CardPicker
legend="Authentication"
options={GITHUB_STRATEGY_OPTIONS}
value={githubStrategy}
onChange={(strategy) => setGithubStrategy(strategy)}
/>
{githubStrategy === "token" ? (
<div className="space-y-5">
<div>
@ -683,9 +690,11 @@ export default function InstallApp() {
</div>
) : (
<div className="space-y-5">
<OwnerPicker
ownerKind={appForm.owner.kind}
setOwnerKind={(kind) =>
<CardPicker
legend="Owner"
options={GITHUB_OWNER_OPTIONS}
value={appForm.owner.kind}
onChange={(kind) =>
setAppForm((current) => ({
...current,
owner:
@ -1247,33 +1256,27 @@ function ProviderFields({
);
}
function GithubStrategyPicker({
strategy,
type CardOption<T extends string> = { id: T; title: string; body: string };
function CardPicker<T extends string>({
legend,
options,
value,
onChange,
}: {
strategy: GithubStrategy;
onChange: (value: GithubStrategy) => void;
legend: string;
options: ReadonlyArray<CardOption<T>>;
value: T;
onChange: (value: T) => void;
}) {
const options: Array<{ id: GithubStrategy; title: string; body: string }> = [
{
id: "token",
title: "Personal access token",
body: "Quickest path. Validates a PAT and stores it in the vault.",
},
{
id: "app",
title: "GitHub App",
body: "Recommended for teams. Enables OAuth.",
},
];
return (
<fieldset>
<legend className="text-sm font-medium text-fg">Authentication</legend>
<legend className="text-sm font-medium text-fg">{legend}</legend>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
{options.map((option) => (
<OptionCard
key={option.id}
selected={strategy === option.id}
selected={value === option.id}
onSelect={() => onChange(option.id)}
title={option.title}
body={option.body}
@ -1284,120 +1287,59 @@ function GithubStrategyPicker({
);
}
function ObjectStoreProviderPicker({
provider,
onChange,
}: {
provider: ObjectStoreProvider;
onChange: (value: ObjectStoreProvider) => void;
}) {
const options: Array<{ id: ObjectStoreProvider; title: string; body: string }> = [
{
id: "local",
title: "Local disk",
body: "Uses the host filesystem for SlateDB and run artifacts.",
},
{
id: "s3",
title: "AWS S3",
body: "Uses one S3 bucket with fixed slatedb/ and artifacts/ prefixes.",
},
];
return (
<fieldset>
<legend className="text-sm font-medium text-fg">Object store</legend>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
{options.map((option) => (
<OptionCard
key={option.id}
selected={provider === option.id}
onSelect={() => onChange(option.id)}
title={option.title}
body={option.body}
/>
))}
</div>
</fieldset>
);
}
const GITHUB_STRATEGY_OPTIONS: ReadonlyArray<CardOption<GithubStrategy>> = [
{
id: "token",
title: "Personal access token",
body: "Quickest path. Validates a PAT and stores it in the vault.",
},
{
id: "app",
title: "GitHub App",
body: "Recommended for teams. Enables OAuth.",
},
];
function ObjectStoreCredentialModePicker({
credentialMode,
onChange,
}: {
credentialMode: ObjectStoreCredentialMode;
onChange: (value: ObjectStoreCredentialMode) => void;
}) {
const options: Array<{
id: ObjectStoreCredentialMode;
title: string;
body: string;
}> = [
{
id: "runtime",
title: "Use AWS runtime credentials",
body: "Use credentials already supplied by the deployment environment.",
},
{
id: "access_key",
title: "Enter AWS access key credentials",
body: "Store an access key pair in server.env for startup and validation.",
},
];
return (
<fieldset>
<legend className="text-sm font-medium text-fg">Credentials</legend>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
{options.map((option) => (
<OptionCard
key={option.id}
selected={credentialMode === option.id}
onSelect={() => onChange(option.id)}
title={option.title}
body={option.body}
/>
))}
</div>
</fieldset>
);
}
const OBJECT_STORE_PROVIDER_OPTIONS: ReadonlyArray<CardOption<ObjectStoreProvider>> = [
{
id: "local",
title: "Local disk",
body: "Uses the host filesystem for SlateDB and run artifacts.",
},
{
id: "s3",
title: "AWS S3",
body: "Uses one S3 bucket with fixed slatedb/ and artifacts/ prefixes.",
},
];
function OwnerPicker({
ownerKind,
setOwnerKind,
}: {
ownerKind: GithubOwnerKind;
setOwnerKind: (value: GithubOwnerKind) => void;
}) {
const options: Array<{ id: GithubOwnerKind; title: string; body: string }> = [
{
id: "personal",
title: "Personal account",
body: "GitHub's personal app creation flow.",
},
{
id: "org",
title: "Organization",
body: "GitHub's org flow — requires the org slug.",
},
];
return (
<fieldset>
<legend className="text-sm font-medium text-fg">Owner</legend>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
{options.map((option) => (
<OptionCard
key={option.id}
selected={ownerKind === option.id}
onSelect={() => setOwnerKind(option.id)}
title={option.title}
body={option.body}
/>
))}
</div>
</fieldset>
);
}
const OBJECT_STORE_CREDENTIAL_MODE_OPTIONS: ReadonlyArray<
CardOption<ObjectStoreCredentialMode>
> = [
{
id: "runtime",
title: "Use AWS runtime credentials",
body: "Use credentials already supplied by the deployment environment.",
},
{
id: "access_key",
title: "Enter AWS access key credentials",
body: "Store an access key pair in server.env for startup and validation.",
},
];
const GITHUB_OWNER_OPTIONS: ReadonlyArray<CardOption<GithubOwnerKind>> = [
{
id: "personal",
title: "Personal account",
body: "GitHub's personal app creation flow.",
},
{
id: "org",
title: "Organization",
body: "GitHub's org flow — requires the org slug.",
},
];
function OptionCard({
selected,

View file

@ -956,10 +956,7 @@ async fn validate_install_object_store_selection(
}
}
Err(_) => {
return Err(
"Timed out while checking S3 access. Verify the bucket, region, and network path, then try again."
.to_string(),
);
return Err(VALIDATION_TIMEOUT_MSG.to_string());
}
Ok(Ok(_)) => {}
}
@ -985,50 +982,53 @@ async fn validate_install_object_store_selection(
)
.map_err(|err| err.to_string())?;
let prefixes = ["artifacts", "slatedb"];
let probe = async {
for (index, prefix) in prefixes.iter().enumerate() {
let path = ObjectStorePath::from(*prefix);
if let Err(err) = object_store.list_with_delimiter(Some(&path)).await {
return Err((index, err));
}
let probe_prefix = |index: usize, prefix: &'static str| {
let object_store = &object_store;
async move {
let path = ObjectStorePath::from(prefix);
object_store
.list_with_delimiter(Some(&path))
.await
.map(|_| ())
.map_err(|err| (index, err))
}
Ok::<(), (usize, object_store::Error)>(())
};
let probe = async {
tokio::try_join!(probe_prefix(0, "artifacts"), probe_prefix(1, "slatedb")).map(|_| ())
};
match timeout(VALIDATION_TIMEOUT, probe).await {
Ok(Ok(())) => Ok(()),
Err(_) => Err(
"Timed out while checking S3 access. Verify the bucket, region, and network path, then try again."
.to_string(),
),
Err(_) => Err(VALIDATION_TIMEOUT_MSG.to_string()),
Ok(Err((index, err))) => Err(classify_object_store_validation_error(
bucket,
region,
index,
&err,
bucket, region, index, &err,
)),
}
}
const PREFIX_ACCESS_ERROR_MSG: &str = "Fabro reached the bucket but could not verify access to slatedb/ and artifacts/. Validation requires bucket list access plus object access under both prefixes.";
const VALIDATION_TIMEOUT_MSG: &str = "Timed out while checking S3 access. Verify the bucket, region, and network path, then try again.";
fn bucket_credentials_error(bucket: &str, region: &str) -> String {
format!("Could not access bucket {bucket} in region {region} with the selected credentials.")
}
fn classify_object_store_validation_error(
bucket: &str,
region: &str,
prefix_index: usize,
err: &object_store::Error,
) -> String {
let credentials_or_prefix_error = || {
if prefix_index == 0 {
bucket_credentials_error(bucket, region)
} else {
PREFIX_ACCESS_ERROR_MSG.to_string()
}
};
match err {
object_store::Error::PermissionDenied { .. }
| object_store::Error::Unauthenticated { .. } => {
if prefix_index == 0 {
format!(
"Could not access bucket {bucket} in region {region} with the selected credentials."
)
} else {
"Fabro reached the bucket but could not verify access to slatedb/ and artifacts/. Validation requires bucket list access plus object access under both prefixes."
.to_string()
}
}
| object_store::Error::Unauthenticated { .. } => credentials_or_prefix_error(),
object_store::Error::NotFound { .. } => format!("Bucket {bucket} was not found."),
object_store::Error::Generic { .. } => {
let rendered = err.to_string();
@ -1038,27 +1038,11 @@ fn classify_object_store_validation_error(
)
} else if rendered.contains("not found") {
format!("Bucket {bucket} was not found.")
} else if prefix_index == 0 {
format!(
"Could not access bucket {bucket} in region {region} with the selected credentials."
)
} else {
"Fabro reached the bucket but could not verify access to slatedb/ and artifacts/. Validation requires bucket list access plus object access under both prefixes."
.to_string()
credentials_or_prefix_error()
}
}
object_store::Error::NotSupported { .. }
| object_store::Error::AlreadyExists { .. }
| object_store::Error::Precondition { .. }
| object_store::Error::NotModified { .. }
| object_store::Error::InvalidPath { .. }
| object_store::Error::NotImplemented { .. }
| object_store::Error::UnknownConfigurationKey { .. } => {
"Fabro reached the bucket but could not verify access to slatedb/ and artifacts/. Validation requires bucket list access plus object access under both prefixes."
.to_string()
}
_ => "Fabro reached the bucket but could not verify access to slatedb/ and artifacts/. Validation requires bucket list access plus object access under both prefixes."
.to_string(),
_ => PREFIX_ACCESS_ERROR_MSG.to_string(),
}
}

View file

@ -58,7 +58,7 @@
<script type="module" src="/assets/chunk-sadshphz.js"></script>
<script type="module" src="/assets/chunk-pmthkscp.js"></script>
<script type="module" src="/assets/chunk-v61ks9f7.js"></script>
<script type="module" src="/assets/entry-k8y1hgqx.js"></script>
<script type="module" src="/assets/entry-v9xzq9ab.js"></script>
<script type="module" src="/assets/chunk-n1k68xa8.js"></script>
<script type="module" src="/assets/chunk-rsph5pvm.js"></script>
<script type="module" src="/assets/chunk-9t57pdty.js"></script>