mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-08 22:21:45 +00:00
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:
parent
4bf0c40319
commit
e184213330
4 changed files with 226 additions and 300 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
2
lib/crates/fabro-spa/assets/index.html
generated
2
lib/crates/fabro-spa/assets/index.html
generated
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue