feat(onboarding): add first-run environment checker (#1290)

* feat(onboarding): add first-run environment checker

Add a preflight that checks whether the host can run Docker deploys before a
deploy fails for an avoidable reason. It verifies the Docker engine is reachable
and permitted, the Compose plugin is present, the compose directory is writable
and mounted at a matching host path, the dashboard is behind TLS, and the
compose volume has disk headroom. Each result that needs attention carries a
specific fix rather than a generic error, and the checks never block: an
operator who knows their setup can continue.

The checks run as the final step of first-boot setup and can be re-run any time
from the Recovery settings tab. A new admin-only endpoint,
GET /api/diagnostics/environment, backs both surfaces.

* fix(onboarding): distinguish unverified path mapping and support parent binds

Treat a container whose self-inspect fails as an unverified path-mapping warning
instead of a false "not containerized" pass, so an unverifiable mapping never
reads as healthy. Resolve the compose directory through the longest-prefix bind
mount and compare the host path it resolves to, so a parent bind such as
-v /opt:/opt correctly covers COMPOSE_DIR=/opt/compose instead of warning that
the directory is not bind-mounted.

* test(e2e): advance the setup wizard past the environment step in loginAs

The first-run setup helper clicked "Initialize console" and immediately waited
for the dashboard, but setup now shows an environment-preflight step before
landing the console. Click "Enter Sencho" to complete onboarding before
asserting the dashboard, so the first test on a fresh instance passes.
This commit is contained in:
Anso
2026-06-02 21:40:38 -04:00
committed by GitHub
parent a8f0ce9072
commit 5289f01bfd
11 changed files with 989 additions and 2 deletions
+37 -1
View File
@@ -6,6 +6,7 @@ import { ArrowRight, Loader2 } from 'lucide-react';
import { AuthCanvas } from '@/components/auth/AuthCanvas';
import { AuthStepHeader } from '@/components/auth/AuthStepHeader';
import { ErrorRail } from '@/components/auth/ErrorRail';
import { EnvironmentChecks } from '@/components/settings/EnvironmentChecks';
interface SetupProps {
onComplete: () => void;
@@ -34,6 +35,10 @@ export function Setup({ onComplete, className, ...props }: SetupProps & React.Co
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
// The admin account is created in step 1; /api/auth/setup signs the operator
// in (session cookie), so step 2 can run the admin-gated environment checks
// before handing off to the console.
const [step, setStep] = useState<'account' | 'env'>('account');
const strength = gaugePassword(password);
const strengthClass =
@@ -72,7 +77,7 @@ export function Setup({ onComplete, className, ...props }: SetupProps & React.Co
});
const data = await response.json();
if (response.ok && data.success) {
onComplete();
setStep('env');
} else {
setError(data.error || 'Setup failed');
}
@@ -83,6 +88,37 @@ export function Setup({ onComplete, className, ...props }: SetupProps & React.Co
}
};
if (step === 'env') {
return (
<div className={cn('relative', className)} {...props}>
<AuthCanvas
footer={
<div className="flex items-center justify-between">
<span>Console · First boot</span>
<span className="text-stat-subtitle/70">Account ready</span>
</div>
}
>
<div className="flex flex-col gap-7">
<AuthStepHeader
kicker="SENCHO · ENVIRONMENT"
hero="Preflight"
caption="A quick check that this host can run Docker deploys. Warnings won't stop you; each one carries a fix."
/>
<EnvironmentChecks />
<Button
type="button"
onClick={onComplete}
className="h-11 w-full bg-brand text-brand-foreground shadow-btn-glow hover:bg-brand/90"
>
Enter Sencho<ArrowRight strokeWidth={1.5} />
</Button>
</div>
</AuthCanvas>
</div>
);
}
return (
<div className={cn('relative', className)} {...props}>
<AuthCanvas
@@ -0,0 +1,134 @@
import { useState, useEffect, useCallback } from 'react';
import type { ReactNode } from 'react';
import { RefreshCw, Check, AlertTriangle, X } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { cn } from '@/lib/utils';
import { Skeleton } from '@/components/ui/skeleton';
import { SettingsActions, SettingsSecondaryButton } from './SettingsActions';
// Shape mirrors the backend EnvironmentReport (services/EnvironmentCheckService.ts);
// kept local because the frontend cannot import backend types. The panel only
// reads checks, so `remediation` stays optional here even though the backend
// models it as required on every warn / fail row.
type CheckStatus = 'pass' | 'warn' | 'fail';
type CheckId = 'docker_socket' | 'docker_compose' | 'compose_dir' | 'path_mapping' | 'tls' | 'disk_space';
interface EnvironmentCheck {
id: CheckId;
label: string;
status: CheckStatus;
detail: string;
remediation?: string;
}
interface EnvironmentReport {
checks: EnvironmentCheck[];
generatedAt: number;
}
const STATUS_WORD: Record<CheckStatus, string> = { pass: 'OK', warn: 'Warning', fail: 'Action needed' };
function StatusBadge({ status, children }: { status: CheckStatus; children: ReactNode }) {
const Icon = status === 'pass' ? Check : status === 'warn' ? AlertTriangle : X;
return (
<span
className={cn(
'inline-flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-[0.18em]',
status === 'pass' ? 'text-success' : status === 'warn' ? 'text-warning' : 'text-destructive',
)}
>
<Icon className="h-3.5 w-3.5 shrink-0" />
{children}
</span>
);
}
function CheckRow({ check }: { check: EnvironmentCheck }) {
return (
<div
className={cn(
'rounded-md border px-3 py-2.5',
check.status === 'pass'
? 'border-card-border bg-card'
: check.status === 'warn'
? 'border-warning/40 bg-warning/5'
: 'border-destructive/40 bg-destructive/5',
)}
>
<div className="flex items-center justify-between gap-3">
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">{check.label}</span>
<StatusBadge status={check.status}>{STATUS_WORD[check.status]}</StatusBadge>
</div>
<p className="mt-1 text-xs text-stat-value">{check.detail}</p>
{check.remediation ? (
<p className="mt-1.5 text-xs leading-relaxed text-stat-subtitle">{check.remediation}</p>
) : null}
</div>
);
}
function ChecksSkeleton() {
return (
<div className="flex flex-col gap-2" aria-busy="true">
{[0, 1, 2, 3, 4, 5].map(i => <Skeleton key={i} className="h-14 w-full" />)}
</div>
);
}
/**
* Preflight environment checks (Docker engine + Compose, the compose directory
* and its host path mapping, TLS, disk headroom) with inline remediation.
* Layout-neutral so it renders both inside the Recovery settings tab and as the
* final step of the setup wizard. Self-contained: fetches on mount and exposes
* a Re-run control. It never blocks; the caller decides what continue action,
* if any, sits alongside it.
*/
export function EnvironmentChecks({ className }: { className?: string }) {
const [report, setReport] = useState<EnvironmentReport | null>(null);
const [isLoading, setIsLoading] = useState(true);
const load = useCallback(async () => {
setIsLoading(true);
try {
const res = await apiFetch('/diagnostics/environment', { localOnly: true });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || 'Failed to run environment checks.');
setReport(null);
return;
}
setReport(await res.json() as EnvironmentReport);
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Failed to run environment checks.');
setReport(null);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
void load();
}, [load]);
return (
<div className={cn('flex flex-col gap-3', className)}>
{isLoading ? (
<ChecksSkeleton />
) : report ? (
<div className="flex flex-col gap-2">
{report.checks.map(check => <CheckRow key={check.id} check={check} />)}
</div>
) : (
<p className="text-xs text-stat-subtitle">Checks could not be run. Try again.</p>
)}
<SettingsActions hint="environment preflight">
<SettingsSecondaryButton onClick={() => void load()} disabled={isLoading}>
<RefreshCw className={cn('h-4 w-4', isLoading && 'animate-spin')} />
Re-run
</SettingsSecondaryButton>
</SettingsActions>
</div>
);
}
@@ -8,6 +8,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { SettingsSection } from './SettingsSection';
import { SettingsField } from './SettingsField';
import { SettingsActions, SettingsPrimaryButton, SettingsSecondaryButton } from './SettingsActions';
import { EnvironmentChecks } from './EnvironmentChecks';
import { DEPLOY_FEEDBACK_KEY } from '@/hooks/use-deploy-feedback-enabled';
import { COMPOSE_DIFF_PREVIEW_KEY } from '@/hooks/use-compose-diff-preview-enabled';
@@ -220,6 +221,16 @@ export function RecoverySection() {
</SettingsActions>
</SettingsSection>
<SettingsSection
title="Environment"
kicker="preflight"
description="What deploys depend on: the Docker engine, the Compose plugin, the compose directory and its host path mapping, TLS, and disk headroom. Each warning carries a fix."
>
<div className="pt-3">
<EnvironmentChecks />
</div>
</SettingsSection>
<SettingsSection
title="Safe actions"
description="Low-risk recovery steps that touch no secrets and no other operators' accounts."