fix(onboarding): explain blocked Next, humanise errors, de-jargon provisioning
This commit is contained in:
@@ -432,25 +432,35 @@ export function ProvisioningStatus({ requestId, canAct }: Props) {
|
|||||||
<span className="text-xs text-text-muted">{t("phase")}</span>
|
<span className="text-xs text-text-muted">{t("phase")}</span>
|
||||||
<StatusBadge phase={phase} />
|
<StatusBadge phase={phase} />
|
||||||
</div>
|
</div>
|
||||||
{conditions.map((c, i) => (
|
{/* Setup progress. The operator reports readiness as a list of
|
||||||
<div
|
internal K8s conditions (OpenBao policy, LiteLLM key, network
|
||||||
key={i}
|
policy, …) — meaningful to operators, jargon to customers.
|
||||||
className="flex items-center justify-between bg-surface-2 border border-border rounded-lg px-4 py-2"
|
We surface the *shape* of that progress (how many steps are
|
||||||
>
|
done) without leaking the internal names. */}
|
||||||
<span className="text-xs text-text-muted">{c.type}</span>
|
{conditions.length > 0 &&
|
||||||
<span
|
(() => {
|
||||||
className={`text-xs font-mono ${
|
const done = conditions.filter((c) => c.status === "True").length;
|
||||||
c.status === "True"
|
const total = conditions.length;
|
||||||
? "text-emerald-400"
|
const pct = Math.round((done / total) * 100);
|
||||||
: c.status === "False"
|
return (
|
||||||
? "text-red-400"
|
<div className="bg-surface-2 border border-border rounded-lg px-4 py-3">
|
||||||
: "text-text-muted"
|
<div className="flex items-center justify-between mb-2">
|
||||||
}`}
|
<span className="text-xs text-text-muted">
|
||||||
>
|
{t("setupProgress")}
|
||||||
{c.reason || c.status}
|
</span>
|
||||||
</span>
|
<span className="text-xs font-medium text-text-secondary tabular-nums">
|
||||||
</div>
|
{t("setupStepsComplete", { done, total })}
|
||||||
))}
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full rounded-full bg-surface-3 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-accent transition-all duration-500"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -420,18 +420,51 @@ export function OnboardingWizard({
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Validate that all secret-requiring enabled packages have complete credentials
|
// Enabled packages that still need something from the user before the
|
||||||
const packageCredentialsValid = (): boolean => {
|
// configure step can advance — a missing credential field or an
|
||||||
|
// unaccepted disclaimer. Returns the package defs so the UI can name
|
||||||
|
// exactly what's blocking the (otherwise silently disabled) Next
|
||||||
|
// button instead of greying it out with no explanation.
|
||||||
|
const incompletePackages = (): PackageDef[] => {
|
||||||
|
const out: PackageDef[] = [];
|
||||||
for (const pkgId of config.packages) {
|
for (const pkgId of config.packages) {
|
||||||
const def = PACKAGE_CATALOG.find((p) => p.id === pkgId);
|
const def = PACKAGE_CATALOG.find((p) => p.id === pkgId);
|
||||||
if (!def?.requiresSecrets) continue;
|
if (!def) continue;
|
||||||
const secrets = packageSecrets[pkgId] || {};
|
let incomplete = false;
|
||||||
for (const field of def.secrets || []) {
|
if (def.requiresSecrets) {
|
||||||
if (!secrets[field.key]?.trim()) return false;
|
const secrets = packageSecrets[pkgId] || {};
|
||||||
|
for (const field of def.secrets || []) {
|
||||||
|
if (!secrets[field.key]?.trim()) {
|
||||||
|
incomplete = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (def.disclaimerKey && !disclaimerAccepted[pkgId]) return false;
|
if (def.disclaimerKey && !disclaimerAccepted[pkgId]) incomplete = true;
|
||||||
|
if (incomplete) out.push(def);
|
||||||
}
|
}
|
||||||
return true;
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const packageCredentialsValid = (): boolean =>
|
||||||
|
incompletePackages().length === 0;
|
||||||
|
|
||||||
|
// Map zod field paths to human labels for the confirm-step error
|
||||||
|
// summary, so a stray validation failure reads "Postal code" rather
|
||||||
|
// than "billingAddress.postalCode". Unknown paths fall back to the
|
||||||
|
// raw path (this defence-in-depth list should rarely render at all).
|
||||||
|
const fieldLabel = (path: string): string => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
instanceName: t("instanceName"),
|
||||||
|
agentName: t("agentName"),
|
||||||
|
"billingAddress.company": t("billingCompany"),
|
||||||
|
"billingAddress.street": t("billingStreet"),
|
||||||
|
"billingAddress.postalCode": t("billingPostalCode"),
|
||||||
|
"billingAddress.city": t("billingCity"),
|
||||||
|
"billingAddress.country": t("billingCountry"),
|
||||||
|
"billingAddress.vatNumber": t("billingVatNumber"),
|
||||||
|
};
|
||||||
|
return map[path] ?? path;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
@@ -984,20 +1017,33 @@ export function OnboardingWizard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between mt-6">
|
<div className="mt-6">
|
||||||
<button
|
{(() => {
|
||||||
onClick={goBack}
|
const blocking = incompletePackages();
|
||||||
className="py-2 px-4 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
if (blocking.length === 0) return null;
|
||||||
>
|
return (
|
||||||
{t("back")}
|
<p className="text-xs text-amber-400/90 mb-3 text-right">
|
||||||
</button>
|
{t("packagesIncompleteHint", {
|
||||||
<button
|
packages: blocking.map((p) => p.name).join(", "),
|
||||||
onClick={goNext}
|
})}
|
||||||
disabled={!packageCredentialsValid()}
|
</p>
|
||||||
className="py-2 px-6 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
);
|
||||||
>
|
})()}
|
||||||
{t("next")}
|
<div className="flex justify-between">
|
||||||
</button>
|
<button
|
||||||
|
onClick={goBack}
|
||||||
|
className="py-2 px-4 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||||
|
>
|
||||||
|
{t("back")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={goNext}
|
||||||
|
disabled={!packageCredentialsValid()}
|
||||||
|
className="py-2 px-6 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{t("next")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -1380,7 +1426,8 @@ export function OnboardingWizard({
|
|||||||
<ul className="list-disc list-inside space-y-0.5">
|
<ul className="list-disc list-inside space-y-0.5">
|
||||||
{Object.entries(errors).map(([path, msg]) => (
|
{Object.entries(errors).map(([path, msg]) => (
|
||||||
<li key={path}>
|
<li key={path}>
|
||||||
<span className="font-mono">{path}</span>: {msg}
|
<span className="font-medium">{fieldLabel(path)}</span>:{" "}
|
||||||
|
{msg}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
Reference in New Issue
Block a user