feat: first-boot compose discovery and adopt-first sidebar (#1600)

* feat: add compose discovery for setup preflight and sidebar empty state

Expose read-only compose discovery via GET /api/stacks/discovery and setup

diagnostics. Replace the blank sidebar with path-aware discovery and move

adopt into a dedicated dialog with a three-tab Create Stack flow.

* test: assert post-setup handoff via sessionStorage read-back

The Setup preflight test spied on Storage.prototype.setItem to check the
post-setup adopt handoff. When the jsdom storage probe fails and the test
harness swaps in its in-memory storage stub (which does not extend Storage),
that stub's setItem never touches Storage.prototype, so the spy records zero
calls and the assertion fails even though the component wrote the value.

Read the value back with sessionStorage.getItem instead, matching how every
other storage test in the suite asserts. This is robust to both the native
jsdom storage and the in-memory fallback.

* fix(setup): surface compose discovery as a preflight check row

Drop the Setup discovery banner and non-working Review button. Show

counts as a pass row in EnvironmentChecks (Setup only) and keep

Enter Sencho as the handoff that opens adopt when candidates exist.

* test(setup): cover zero-count discovery row omission

* fix(stacks): widen adopt scan to any yaml and rename into place

Homelab layouts often use nginx.yml or plex.yml. Surface those for
adopt (except overrides), rename to compose.yaml on move so stacks
register, and reset the confirm UI when a move fails.
This commit is contained in:
Anso
2026-07-10 08:42:52 -04:00
committed by GitHub
parent 7848ce339a
commit ba2e7bded9
26 changed files with 1490 additions and 130 deletions
+45 -5
View File
@@ -6,6 +6,7 @@ import { NotificationPanel } from './NotificationPanel';
import { TopBar } from './TopBar';
import { ViewRouter } from './EditorLayout/ViewRouter';
import { CreateStackDialog, type CreateMode } from './EditorLayout/CreateStackDialog';
import { AdoptExistingDialog } from './EditorLayout/AdoptExistingDialog';
import { EditorView } from './EditorLayout/EditorView';
import { ShellOverlays } from './EditorLayout/ShellOverlays';
import { classifyFailedGate } from './EditorLayout/failed-gate-recovery';
@@ -71,7 +72,7 @@ const ResourcesView = lazy(() => import('./ResourcesView'));
const GlobalObservabilityView = lazy(() => import('./GlobalObservabilityView').then(m => ({ default: m.GlobalObservabilityView })));
export default function EditorLayout() {
const { isAdmin, can } = useAuth();
const { isAdmin, can, permissions } = useAuth();
const { status: trivy } = useTrivyStatus();
const { runWithLog, panelState, logRows, healthGate } = useDeployFeedback();
@@ -164,14 +165,21 @@ export default function EditorLayout() {
createDialogOpen, setCreateDialogOpen,
} = overlayState;
// Which mode the create dialog opens on. The toolbar Create button opens on
// 'empty'; the zero-stacks empty state opens on 'import'.
// Which mode the create dialog opens on (always empty after import tab removal).
const [createDialogInitialMode, setCreateDialogInitialMode] = useState<CreateMode>('empty');
const openCreateDialog = useCallback((mode: CreateMode) => {
const [adoptDialogOpen, setAdoptDialogOpen] = useState(false);
const adoptOpenedFromSetupRef = useRef(false);
const openCreateDialog = useCallback((mode: CreateMode = 'empty') => {
setCreateDialogInitialMode(mode);
setCreateDialogOpen(true);
}, [setCreateDialogOpen]);
const openAdoptDialog = useCallback(() => {
setCreateDialogOpen(false);
setAdoptDialogOpen(true);
}, [setCreateDialogOpen]);
const [diffPreviewEnabled] = useComposeDiffPreviewEnabled();
const [topNavLabels] = useTopNavLabels();
const [topNavAlign] = useTopNavAlign();
@@ -677,6 +685,23 @@ export default function EditorLayout() {
}
}, [containers, selectedFile]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (permissions === null) return;
if (adoptOpenedFromSetupRef.current) return;
try {
const raw = sessionStorage.getItem('sencho:post-setup');
if (!raw) return;
sessionStorage.removeItem('sencho:post-setup');
const parsed = JSON.parse(raw) as { openAdopt?: boolean };
if (parsed.openAdopt && can('stack:create')) {
adoptOpenedFromSetupRef.current = true;
setAdoptDialogOpen(true);
}
} catch {
// ignore malformed session flag
}
}, [permissions, can]);
const createStackSlot = can('stack:create') ? (
<>
<Button
@@ -709,10 +734,19 @@ export default function EditorLayout() {
);
}}
onStacksChanged={async () => { await refreshStacks(); }}
onOpenAdopt={openAdoptDialog}
/>
</>
) : null;
const adoptDialogEl = (
<AdoptExistingDialog
open={adoptDialogOpen}
onOpenChange={setAdoptDialogOpen}
onStacksChanged={async () => { await refreshStacks(); }}
/>
);
return (
<GlobalCommandPaletteProvider>
{(() => {
@@ -766,7 +800,11 @@ export default function EditorLayout() {
if (node) void stackActions.loadFileOnNode(node, file);
},
filterChip,
onOpenCreate: can('stack:create') ? openCreateDialog : undefined,
onOpenCreate: can('stack:create') ? () => openCreateDialog('empty') : undefined,
onOpenAdopt: can('stack:read') ? openAdoptDialog : undefined,
onScan: handleScanStacks,
canCreate: can('stack:create'),
activeNodeId: activeNode?.id ?? null,
openMuteRulesWithPrefill,
stacksLoadStatus,
stacksLoadError,
@@ -1079,6 +1117,7 @@ export default function EditorLayout() {
onNavigate={navigateMobileAware}
onSettings={openSettingsMobileAware}
/>
{adoptDialogEl}
{shellOverlaysEl}
</div>
);
@@ -1095,6 +1134,7 @@ export default function EditorLayout() {
{/* Main Workspace */}
{workspaceEl}
</div>
{adoptDialogEl}
{shellOverlaysEl}
</div>
);
@@ -0,0 +1,24 @@
import { Modal, ModalHeader } from '../ui/modal';
import { ImportStackPanel } from './ImportStackPanel';
export interface AdoptExistingDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onStacksChanged: () => void | Promise<void>;
}
export function AdoptExistingDialog({ open, onOpenChange, onStacksChanged }: AdoptExistingDialogProps) {
return (
<Modal size="xl" open={open} onOpenChange={onOpenChange}>
<ModalHeader
kicker="STACKS · ADOPT"
title="Adopt existing files"
description="Compose files that are not in their own subfolder yet. Preview services, then move each one into place so Sencho can manage it."
/>
<ImportStackPanel
onClose={() => onOpenChange(false)}
onImported={() => { void onStacksChanged(); }}
/>
</Modal>
);
}
@@ -1,5 +1,5 @@
import { useRef, useState, type FormEvent, type KeyboardEvent } from 'react';
import { Plus, GitBranch, FileCode2, FolderSearch, Loader2, type LucideIcon } from 'lucide-react';
import { Plus, GitBranch, FileCode2, Loader2, type LucideIcon } from 'lucide-react';
import { Modal, ModalHeader, ModalBody, ModalFooter } from '../ui/modal';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
@@ -8,7 +8,6 @@ import { ScrollArea } from '../ui/scroll-area';
import { Checkbox } from '../ui/checkbox';
import { GitSourceFields, type ApplyMode } from '../stack/GitSourceFields';
import type { GitBrowseResult } from '../stack/GitComposeFilePicker';
import { ImportStackPanel } from './ImportStackPanel';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { useNodes } from '@/context/NodeContext';
@@ -26,15 +25,13 @@ export interface CreateStackDialogProps {
meta?: { mode: CreateMode },
) => void | Promise<void>;
onStacksChanged: () => void | Promise<void>;
// Mode the dialog opens on. The empty-state entry opens directly on 'import';
// the toolbar Create button opens on 'empty'.
initialMode?: CreateMode;
onOpenAdopt?: () => void;
}
export type CreateMode = 'import' | 'empty' | 'git' | 'docker-run';
export type CreateMode = 'empty' | 'git' | 'docker-run';
const MODES: ReadonlyArray<{ id: CreateMode; label: string; icon: LucideIcon }> = [
{ id: 'import', label: 'Import', icon: FolderSearch },
{ id: 'empty', label: 'Empty', icon: Plus },
{ id: 'git', label: 'From Git', icon: GitBranch },
{ id: 'docker-run', label: 'From Docker Run', icon: FileCode2 },
@@ -43,7 +40,7 @@ const MODES: ReadonlyArray<{ id: CreateMode; label: string; icon: LucideIcon }>
const tabId = (m: CreateMode) => `create-stack-tab-${m}`;
const panelId = (m: CreateMode) => `create-stack-panel-${m}`;
export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacksChanged, initialMode = 'empty' }: CreateStackDialogProps) {
export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacksChanged, initialMode = 'empty', onOpenAdopt }: CreateStackDialogProps) {
const { activeNode } = useNodes();
const [createMode, setCreateMode] = useState<CreateMode>(initialMode);
// Reset to the requested starting mode each time the dialog opens (empty for
@@ -373,19 +370,10 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks
<ModalHeader
kicker="STACKS · NEW"
title="New stack"
description="Import a compose file you already have, or create one: empty, cloned from a Git repository, or converted from a docker run command."
description="Create a stack from scratch, clone from a Git repository, or convert a docker run command."
/>
<ModeRail mode={createMode} onModeChange={setCreateMode} disabled={busy} />
{createMode === 'import' && (
<div role="tabpanel" id={panelId('import')} aria-labelledby={tabId('import')}>
<ImportStackPanel
onClose={() => onOpenChange(false)}
onImported={() => { void onStacksChanged(); }}
/>
</div>
)}
{createMode === 'empty' && (
<div role="tabpanel" id={panelId('empty')} aria-labelledby={tabId('empty')}>
<form onSubmit={handleEmptyFormSubmit}>
@@ -575,6 +563,20 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks
/>
</div>
)}
{onOpenAdopt ? (
<div className="border-t border-card-border/60 px-4 py-2.5 text-center">
<button
type="button"
onClick={() => {
onOpenChange(false);
onOpenAdopt();
}}
className="font-mono text-[10px] uppercase tracking-[0.14em] text-brand hover:underline"
>
Adopt existing files instead
</button>
</div>
) : null}
</Modal>
);
}
@@ -620,7 +622,7 @@ function ModeRail({
<div
role="tablist"
aria-label="Stack source"
className="grid grid-cols-4 border-b border-card-border/60"
className="grid grid-cols-3 border-b border-card-border/60"
onKeyDown={handleKeyDown}
>
{MODES.map((m, i) => {
@@ -16,6 +16,14 @@ import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { useAuth } from '@/context/AuthContext';
// Mirrors backend IMPORT_COMPOSE_FILENAMES: non-canonical basenames land as compose.yaml.
const CANONICAL_COMPOSE_FILENAMES = new Set([
'compose.yaml',
'compose.yml',
'docker-compose.yaml',
'docker-compose.yml',
]);
// Mirrors backend isValidStackName so the move button stays disabled until the
// name the backend would accept; the backend remains authoritative.
const VALID_STACK_NAME = /^[a-zA-Z0-9_-]+$/;
@@ -187,7 +195,7 @@ export function ImportStackPanel({ onClose, onImported }: ImportStackPanelProps)
canCreate={canCreate}
moving={movingLocation === c.location}
onToggle={() => toggle(c.location)}
onMove={(name) => void move(c.location, name)}
onMove={(name) => move(c.location, name)}
/>
))}
</div>
@@ -230,7 +238,7 @@ function CandidateCard({
canCreate: boolean;
moving: boolean;
onToggle: () => void;
onMove: (name: string) => void;
onMove: (name: string) => Promise<void>;
}) {
const { name, composeFile, location, status, services, warnings, parseError } = candidate;
// Prefill the destination name: a nested stack already has a folder name worth
@@ -240,7 +248,9 @@ function CandidateCard({
const trimmedName = destName.trim();
const nameValid = VALID_STACK_NAME.test(trimmedName);
const displayName = name || '<name>';
const target = joinPath(composeDir, trimmedName || displayName, composeFile);
// Match backend importCandidateIntoStack: non-canonical basenames land as compose.yaml.
const destComposeFile = CANONICAL_COMPOSE_FILENAMES.has(composeFile) ? composeFile : 'compose.yaml';
const target = joinPath(composeDir, trimmedName || displayName, destComposeFile);
return (
<div className="rounded-md border border-card-border border-t-card-border-top bg-card shadow-card-bevel">
@@ -299,13 +309,25 @@ function CandidateCard({
put.
</p>
)}
{destComposeFile !== composeFile && (
<p className="text-[10px] leading-relaxed text-stat-subtitle">
Will be saved as compose.yaml so Sencho recognizes the stack.
</p>
)}
{confirming ? (
<div className="flex items-center gap-2">
<span className="flex-1 text-[11px] text-stat-subtitle">Move it on disk?</span>
<Button size="sm" variant="ghost" onClick={() => setConfirming(false)} disabled={moving}>
Cancel
</Button>
<Button size="sm" onClick={() => onMove(trimmedName)} disabled={moving || !nameValid}>
<Button
size="sm"
onClick={() => {
// Close confirm on success or failure (toast already shows errors).
void onMove(trimmedName).finally(() => setConfirming(false));
}}
disabled={moving || !nameValid}
>
{moving ? (
<>
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" strokeWidth={1.5} />
@@ -0,0 +1,57 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { ComponentProps } from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { CreateStackDialog } from '../CreateStackDialog';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('@/components/ui/toast-store', () => ({ toast: { error: vi.fn(), dismiss: vi.fn() } }));
vi.mock('@/context/NodeContext', () => ({
useNodes: () => ({ activeNode: { id: 1, name: 'local' } }),
}));
describe('CreateStackDialog', () => {
beforeEach(() => {
vi.clearAllMocks();
});
function renderOpen(overrides: Partial<ComponentProps<typeof CreateStackDialog>> = {}) {
return render(
<CreateStackDialog
open
onOpenChange={vi.fn()}
onStackCreated={vi.fn()}
onStacksChanged={vi.fn()}
{...overrides}
/>,
);
}
it('renders exactly three evenly sized source tabs (no Import)', () => {
renderOpen();
const tablist = screen.getByRole('tablist', { name: 'Stack source' });
expect(tablist.className).toContain('grid-cols-3');
const tabs = screen.getAllByRole('tab');
expect(tabs).toHaveLength(3);
expect(tabs.map((t) => t.textContent)).toEqual(
expect.arrayContaining(['Empty', 'From Git', 'From Docker Run']),
);
expect(tabs.some((t) => /import/i.test(t.textContent ?? ''))).toBe(false);
});
it('exposes adopt footer link when onOpenAdopt is provided', () => {
const onOpenAdopt = vi.fn();
const onOpenChange = vi.fn();
renderOpen({ onOpenAdopt, onOpenChange });
fireEvent.click(screen.getByRole('button', { name: /adopt existing files instead/i }));
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(onOpenAdopt).toHaveBeenCalledTimes(1);
});
it('hides adopt footer when onOpenAdopt is omitted', () => {
renderOpen();
expect(screen.queryByRole('button', { name: /adopt existing files instead/i })).toBeNull();
});
});
@@ -0,0 +1,61 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { ImportStackPanel } from '../ImportStackPanel';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('@/components/ui/toast-store', () => ({
toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() },
}));
vi.mock('@/context/AuthContext', () => ({
useAuth: () => ({ can: () => true }),
}));
import { apiFetch } from '@/lib/api';
function jsonRes(body: unknown, ok = true) {
return { ok, json: async () => body } as Response;
}
const CANDIDATE = {
name: '',
composeFile: 'nginx.yml',
location: 'nginx.yml',
status: 'loose-root' as const,
services: [{ name: 'app', ports: [], volumes: [], envFiles: [] }],
warnings: [],
};
describe('ImportStackPanel', () => {
beforeEach(() => {
vi.mocked(apiFetch).mockReset();
});
it('clears the move confirm UI when the move request fails', async () => {
vi.mocked(apiFetch).mockImplementation(async (path, init) => {
if (path === '/stacks/import/scan') {
return jsonRes({ composeDir: '/opt/compose', candidates: [CANDIDATE] });
}
if (path === '/stacks/import/move' && init?.method === 'POST') {
return jsonRes({ error: 'A stack named "nginx" already exists' }, false);
}
return jsonRes({});
});
render(<ImportStackPanel onClose={vi.fn()} onImported={vi.fn()} />);
await waitFor(() => expect(screen.getByText('nginx.yml')).toBeTruthy());
fireEvent.click(screen.getByText('nginx.yml'));
const nameInput = await screen.findByLabelText(/destination stack name/i);
fireEvent.change(nameInput, { target: { value: 'nginx' } });
fireEvent.click(screen.getByRole('button', { name: /move into place/i }));
expect(screen.getByText(/move it on disk\?/i)).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /confirm move/i }));
await waitFor(() => {
expect(screen.queryByText(/move it on disk\?/i)).toBeNull();
});
expect(screen.getByRole('button', { name: /move into place/i })).toBeTruthy();
});
});
+46 -8
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useCallback, useEffect } from 'react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -6,12 +6,16 @@ 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';
import { EnvironmentChecks, type EnvironmentReport } from '@/components/settings/EnvironmentChecks';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
interface SetupProps {
onComplete: () => void;
}
const POST_SETUP_KEY = 'sencho:post-setup';
const INPUT_CLASS =
'h-11 bg-background/60 border-card-border font-sans text-base shadow-[inset_0_2px_4px_0_oklch(0_0_0/0.25)] placeholder:text-stat-subtitle/60 focus-visible:border-brand/60 focus-visible:ring-2 focus-visible:ring-brand/40 focus-visible:ring-offset-0';
@@ -35,10 +39,33 @@ 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 [envReport, setEnvReport] = useState<EnvironmentReport | null>(null);
const [envLoading, setEnvLoading] = useState(false);
const loadEnvironment = useCallback(async () => {
setEnvLoading(true);
try {
const res = await apiFetch('/diagnostics/environment', { localOnly: true });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error((err as { error?: string })?.error || 'Failed to run environment checks.');
setEnvReport(null);
return;
}
setEnvReport((await res.json()) as EnvironmentReport);
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Failed to run environment checks.');
setEnvReport(null);
} finally {
setEnvLoading(false);
}
}, []);
useEffect(() => {
if (step !== 'env') return;
void loadEnvironment();
}, [step, loadEnvironment]);
const strength = gaugePassword(password);
const strengthClass =
@@ -50,6 +77,18 @@ export function Setup({ onComplete, className, ...props }: SetupProps & React.Co
? 'text-destructive'
: '';
const handleEnterSencho = () => {
const adoptCount = envReport?.discovery?.adoptCandidateCount ?? 0;
if (adoptCount > 0) {
try {
sessionStorage.setItem(POST_SETUP_KEY, JSON.stringify({ openAdopt: true }));
} catch {
// sessionStorage unavailable
}
}
onComplete();
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
@@ -96,7 +135,7 @@ export function Setup({ onComplete, className, ...props }: SetupProps & React.Co
<div className="flex flex-col gap-4">
<Button
type="button"
onClick={onComplete}
onClick={handleEnterSencho}
className="h-11 w-full bg-brand text-brand-foreground shadow-btn-glow hover:bg-brand/90"
>
Enter Sencho<ArrowRight strokeWidth={1.5} />
@@ -114,7 +153,7 @@ export function Setup({ onComplete, className, ...props }: SetupProps & React.Co
hero="Preflight"
caption="A quick check that this host can run Docker deploys. Warnings won't stop you; each one carries a fix."
/>
<EnvironmentChecks />
<EnvironmentChecks report={envReport} isLoading={envLoading} onRerun={loadEnvironment} />
</div>
</AuthCanvas>
</div>
@@ -222,4 +261,3 @@ function Field({ id, label, children }: { id: string; label: string; children: R
</div>
);
}
@@ -0,0 +1,84 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { Setup } from '../Setup';
const apiFetchMock = vi.fn();
vi.mock('@/lib/api', () => ({ apiFetch: (...args: unknown[]) => apiFetchMock(...args) }));
vi.mock('@/components/ui/toast-store', () => ({ toast: { error: vi.fn() } }));
const ENV_REPORT = {
checks: [
{ id: 'compose_dir', label: 'Compose dir', status: 'pass', detail: '/opt/compose' },
{ id: 'docker_socket', label: 'Docker', status: 'pass', detail: 'ok' },
],
generatedAt: 1,
discovery: {
composeDir: '/opt/compose',
stackCount: 1,
adoptCandidateCount: 2,
adoptCandidatesTruncated: false,
},
};
function jsonRes(body: unknown, ok = true) {
return { ok, json: async () => body } as Response;
}
describe('Setup preflight', () => {
beforeEach(() => {
apiFetchMock.mockReset();
vi.stubGlobal('fetch', vi.fn());
sessionStorage.clear();
});
it('runs exactly one diagnostics fetch on the environment step', async () => {
vi.mocked(fetch).mockResolvedValue(jsonRes({ success: true }));
apiFetchMock.mockImplementation((path: string) => {
if (path === '/diagnostics/environment') {
return Promise.resolve(jsonRes(ENV_REPORT));
}
return Promise.resolve(jsonRes({}));
});
render(<Setup onComplete={vi.fn()} />);
fireEvent.change(screen.getByLabelText(/username/i), { target: { value: 'admin' } });
fireEvent.change(screen.getByLabelText(/^password$/i), { target: { value: 'password123' } });
fireEvent.change(screen.getByLabelText(/confirm password/i), { target: { value: 'password123' } });
fireEvent.click(screen.getByRole('button', { name: /initialize console/i }));
await waitFor(() => expect(screen.getByText('Preflight')).toBeTruthy());
await waitFor(() => {
const envCalls = apiFetchMock.mock.calls.filter((c) => c[0] === '/diagnostics/environment');
expect(envCalls).toHaveLength(1);
});
expect(apiFetchMock).not.toHaveBeenCalledWith('/stacks/discovery', expect.anything());
expect(screen.getByText('Compose discovery')).toBeTruthy();
expect(screen.getByText(/Found 1 stack and 2 files to adopt in \/opt\/compose/i)).toBeTruthy();
expect(screen.getByText(/Enter Sencho to review and adopt/i)).toBeTruthy();
expect(screen.queryByRole('button', { name: /review discovered files/i })).toBeNull();
expect(screen.getByText('ok')).toBeTruthy();
});
it('sets post-setup adopt handoff when discovery has candidates', async () => {
vi.mocked(fetch).mockResolvedValue(jsonRes({ success: true }));
apiFetchMock.mockResolvedValue(jsonRes(ENV_REPORT));
const onComplete = vi.fn();
render(<Setup onComplete={onComplete} />);
fireEvent.change(screen.getByLabelText(/username/i), { target: { value: 'admin' } });
fireEvent.change(screen.getByLabelText(/^password$/i), { target: { value: 'password123' } });
fireEvent.change(screen.getByLabelText(/confirm password/i), { target: { value: 'password123' } });
fireEvent.click(screen.getByRole('button', { name: /initialize console/i }));
await waitFor(() => expect(screen.getByText(/2 files to adopt/i)).toBeTruthy());
fireEvent.click(screen.getByRole('button', { name: /enter sencho/i }));
expect(onComplete).toHaveBeenCalledTimes(1);
expect(sessionStorage.getItem('sencho:post-setup')).toBe(JSON.stringify({ openAdopt: true }));
});
});
@@ -12,7 +12,15 @@ import { SettingsActions, SettingsSecondaryButton } from './SettingsActions';
// 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' | 'self_stack_location' | 'path_mapping' | 'tls' | 'disk_space';
type CheckId =
| 'docker_socket'
| 'docker_compose'
| 'compose_dir'
| 'self_stack_location'
| 'path_mapping'
| 'tls'
| 'disk_space'
| 'compose_discovery';
interface EnvironmentCheck {
id: CheckId;
@@ -25,8 +33,56 @@ interface EnvironmentCheck {
interface EnvironmentReport {
checks: EnvironmentCheck[];
generatedAt: number;
discovery?: import('@/lib/discovery-types').ComposeDiscovery;
}
export type { EnvironmentReport, EnvironmentCheck };
/** Frontend-only row derived from optional discovery on the environment report. */
function discoveryCheckRow(
discovery: NonNullable<EnvironmentReport['discovery']>,
): EnvironmentCheck | null {
const { stackCount, adoptCandidateCount, adoptCandidatesTruncated, composeDir } = discovery;
if (stackCount + adoptCandidateCount === 0) return null;
const stackPart =
stackCount > 0
? `${stackCount} stack${stackCount === 1 ? '' : 's'}`
: null;
const adoptPart =
adoptCandidateCount > 0
? `${adoptCandidateCount}${adoptCandidatesTruncated ? '+' : ''} file${adoptCandidateCount === 1 && !adoptCandidatesTruncated ? '' : 's'} to adopt`
: null;
const summary = [stackPart, adoptPart].filter(Boolean).join(' and ');
return {
id: 'compose_discovery',
label: 'Compose discovery',
status: 'pass',
detail: `Found ${summary} in ${composeDir}.`,
remediation:
adoptCandidateCount > 0
? 'Enter Sencho to review and adopt them into their own stack folders.'
: undefined,
};
}
type EnvironmentChecksControlledProps = {
report: EnvironmentReport | null;
isLoading: boolean;
onRerun: () => void | Promise<void>;
};
type EnvironmentChecksUncontrolledProps = {
report?: undefined;
isLoading?: undefined;
onRerun?: undefined;
};
export type EnvironmentChecksProps = {
className?: string;
} & (EnvironmentChecksControlledProps | EnvironmentChecksUncontrolledProps);
const STATUS_WORD: Record<CheckStatus, string> = { pass: 'OK', warn: 'Warning', fail: 'Action needed' };
function StatusBadge({ status, children }: { status: CheckStatus; children: ReactNode }) {
@@ -84,33 +140,53 @@ function ChecksSkeleton() {
* 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);
export function EnvironmentChecks(props: EnvironmentChecksProps) {
const { className } = props;
const isControlled = props.onRerun !== undefined;
const [internalReport, setInternalReport] = useState<EnvironmentReport | null>(null);
const [internalLoading, setInternalLoading] = useState(!isControlled);
const load = useCallback(async () => {
setIsLoading(true);
if (isControlled) {
await props.onRerun();
return;
}
setInternalLoading(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);
setInternalReport(null);
return;
}
setReport(await res.json() as EnvironmentReport);
setInternalReport(await res.json() as EnvironmentReport);
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Failed to run environment checks.');
setReport(null);
setInternalReport(null);
} finally {
setIsLoading(false);
setInternalLoading(false);
}
}, []);
}, [isControlled, isControlled ? props.onRerun : undefined]);
useEffect(() => {
if (isControlled) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
void load();
}, [load]);
}, [isControlled, load]);
const report = isControlled ? props.report : internalReport;
const isLoading = isControlled ? props.isLoading : internalLoading;
// Discovery is a Setup-only affordance (post-enter adopt handoff). Recovery
// keeps the host-readiness checklist without a compose-discovery row.
const discoveryRow =
isControlled && report?.discovery ? discoveryCheckRow(report.discovery) : null;
const rows = report
? discoveryRow
? [...report.checks, discoveryRow]
: report.checks
: [];
return (
<div className={cn('flex flex-col gap-3', className)}>
@@ -118,13 +194,13 @@ export function EnvironmentChecks({ className }: { className?: string }) {
<ChecksSkeleton />
) : report ? (
<div className="flex flex-col gap-2">
{report.checks.map(check => <CheckRow key={check.id} check={check} />)}
{rows.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}>
<SettingsSecondaryButton onClick={() => void (isControlled ? props.onRerun() : load())} disabled={isLoading}>
<RefreshCw className={cn('h-4 w-4', isLoading && 'animate-spin')} />
Re-run
</SettingsSecondaryButton>
@@ -0,0 +1,128 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { EnvironmentChecks } from '../EnvironmentChecks';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('@/components/ui/toast-store', () => ({ toast: { error: vi.fn() } }));
import { apiFetch } from '@/lib/api';
const ENV_REPORT = {
checks: [
{ id: 'docker_socket' as const, label: 'Docker', status: 'pass' as const, detail: 'ok' },
],
generatedAt: 1,
};
describe('EnvironmentChecks', () => {
beforeEach(() => {
vi.mocked(apiFetch).mockReset();
vi.mocked(apiFetch).mockResolvedValue({
ok: true,
json: async () => ENV_REPORT,
} as Response);
});
it('self-fetches on mount when uncontrolled', async () => {
render(<EnvironmentChecks />);
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(1));
expect(apiFetch).toHaveBeenCalledWith('/diagnostics/environment', { localOnly: true });
expect(screen.getByText('ok')).toBeTruthy();
});
it('does not fetch on mount when controlled', async () => {
const onRerun = vi.fn();
render(
<EnvironmentChecks
report={ENV_REPORT}
isLoading={false}
onRerun={onRerun}
/>,
);
await waitFor(() => expect(screen.getByText('ok')).toBeTruthy());
expect(apiFetch).not.toHaveBeenCalled();
});
it('calls onRerun when Re-run is clicked in controlled mode', async () => {
const onRerun = vi.fn();
render(
<EnvironmentChecks
report={ENV_REPORT}
isLoading={false}
onRerun={onRerun}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /re-run/i }));
expect(onRerun).toHaveBeenCalledTimes(1);
expect(apiFetch).not.toHaveBeenCalled();
});
it('shows error state when controlled with null report', () => {
render(
<EnvironmentChecks
report={null}
isLoading={false}
onRerun={vi.fn()}
/>,
);
expect(screen.getByText('Checks could not be run. Try again.')).toBeTruthy();
});
it('appends a compose discovery row when discovery has counts', () => {
render(
<EnvironmentChecks
report={{
...ENV_REPORT,
discovery: {
composeDir: '/opt/compose',
stackCount: 0,
adoptCandidateCount: 1,
adoptCandidatesTruncated: false,
},
}}
isLoading={false}
onRerun={vi.fn()}
/>,
);
expect(screen.getByText('Compose discovery')).toBeTruthy();
expect(screen.getByText(/Found 1 file to adopt in \/opt\/compose/i)).toBeTruthy();
expect(screen.getByText(/Enter Sencho to review and adopt/i)).toBeTruthy();
});
it('omits the discovery row when counts are zero', () => {
render(
<EnvironmentChecks
report={{
...ENV_REPORT,
discovery: {
composeDir: '/opt/compose',
stackCount: 0,
adoptCandidateCount: 0,
adoptCandidatesTruncated: false,
},
}}
isLoading={false}
onRerun={vi.fn()}
/>,
);
expect(screen.queryByText('Compose discovery')).toBeNull();
});
it('omits the discovery row when uncontrolled even if the report includes discovery', async () => {
vi.mocked(apiFetch).mockResolvedValue({
ok: true,
json: async () => ({
...ENV_REPORT,
discovery: {
composeDir: '/opt/compose',
stackCount: 0,
adoptCandidateCount: 1,
adoptCandidatesTruncated: false,
},
}),
} as Response);
render(<EnvironmentChecks />);
await waitFor(() => expect(screen.getByText('ok')).toBeTruthy());
expect(screen.queryByText('Compose discovery')).toBeNull();
});
});
@@ -0,0 +1,146 @@
import { useCallback, useEffect, useState } from 'react';
import { FolderSearch, Plus, Layers, RefreshCw, Loader2, AlertCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import type { StacksDiscoveryResponse } from '@/lib/discovery-types';
import { Skeleton } from '@/components/ui/skeleton';
export interface DiscoveryEmptyStateProps {
onOpenAdopt?: () => void;
onOpenCreate?: () => void;
onScan?: () => void;
canCreate?: boolean;
activeNodeId?: number | null;
}
export function DiscoveryEmptyState({
onOpenAdopt,
onOpenCreate,
onScan,
canCreate = false,
activeNodeId,
}: DiscoveryEmptyStateProps) {
const [loading, setLoading] = useState(true);
const [data, setData] = useState<StacksDiscoveryResponse | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await apiFetch('/stacks/discovery');
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as { error?: string })?.error || 'Failed to load compose discovery.');
}
setData((await res.json()) as StacksDiscoveryResponse);
} catch (e: unknown) {
console.error('Failed to load compose discovery:', e);
toast.error((e as Error)?.message || 'Failed to load compose discovery.');
setData(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load, activeNodeId]);
if (loading && !data) {
return (
<div className="px-3 py-8">
<Skeleton className="mx-auto h-6 w-6 rounded-full" />
<Skeleton className="mx-auto mt-3 h-4 w-28" />
<Skeleton className="mx-auto mt-2 h-3 w-40" />
</div>
);
}
if (!data) {
return (
<div className="px-3 py-8 text-center">
<AlertCircle className="mx-auto h-6 w-6 text-stat-icon" strokeWidth={1.5} />
<p className="mt-3 text-sm text-stat-title">Discovery unavailable</p>
<Button size="sm" variant="outline" className="mt-4" onClick={() => void load()} disabled={loading}>
{loading ? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-1.5 h-4 w-4" />}
Retry
</Button>
</div>
);
}
const { composeDir, readable, discovery, error } = data;
if (!readable) {
return (
<div className="px-3 py-8 text-center">
<AlertCircle className="mx-auto h-6 w-6 text-warning" strokeWidth={1.5} />
<p className="mt-3 text-sm text-stat-title">Could not read compose directory</p>
<p className="mx-auto mt-1 max-w-[220px] break-all font-mono text-xs text-stat-value">{composeDir}</p>
{error ? (
<p className="mx-auto mt-2 max-w-[220px] text-xs leading-relaxed text-stat-subtitle">{error}</p>
) : null}
</div>
);
}
const stackCount = discovery?.stackCount ?? 0;
const adoptCount = discovery?.adoptCandidateCount ?? 0;
const truncated = discovery?.adoptCandidatesTruncated ?? false;
const hasAdopt = adoptCount > 0;
if (hasAdopt) {
return (
<div className="px-3 py-8 text-center">
<FolderSearch className="mx-auto h-6 w-6 text-brand" strokeWidth={1.5} />
<p className="mt-3 text-sm text-stat-title">
{adoptCount}
{truncated ? '+' : ''} compose file{adoptCount === 1 && !truncated ? '' : 's'} to adopt
</p>
<p className="mx-auto mt-1 max-w-[220px] break-all font-mono text-xs text-stat-value">{composeDir}</p>
{stackCount > 0 ? (
<p className="mx-auto mt-1 max-w-[220px] text-xs text-stat-subtitle">
{stackCount} stack{stackCount === 1 ? '' : 's'} already in place
</p>
) : null}
{onOpenAdopt ? (
<Button size="sm" className="mt-4 w-full" onClick={onOpenAdopt}>
<FolderSearch className="mr-1.5 h-4 w-4" strokeWidth={1.5} />
Adopt existing files
</Button>
) : null}
{canCreate && onOpenCreate ? (
<Button size="sm" variant="outline" className="mt-2 w-full" onClick={onOpenCreate}>
<Plus className="mr-1.5 h-4 w-4" strokeWidth={1.5} />
New stack
</Button>
) : null}
</div>
);
}
return (
<div className="px-3 py-8 text-center">
<Layers className="mx-auto h-6 w-6 text-stat-icon" strokeWidth={1.5} />
<p className="mt-3 text-sm text-stat-title">No compose projects yet</p>
<p className="mx-auto mt-1 max-w-[220px] break-all font-mono text-xs text-stat-value">{composeDir}</p>
<p className="mx-auto mt-1 max-w-[200px] text-xs leading-relaxed text-stat-subtitle">
Drop compose files here or create a stack from scratch.
</p>
<div className="mt-4 flex flex-col gap-2">
{canCreate && onOpenCreate ? (
<Button size="sm" className="w-full" onClick={onOpenCreate}>
<Plus className="mr-1.5 h-4 w-4" strokeWidth={1.5} />
New stack
</Button>
) : null}
{onScan ? (
<Button size="sm" variant="outline" className="w-full" onClick={onScan}>
<RefreshCw className="mr-1.5 h-4 w-4" strokeWidth={1.5} />
Scan stacks folder
</Button>
) : null}
</div>
</div>
);
}
@@ -1,32 +0,0 @@
import { FolderSearch, Plus, Layers } from 'lucide-react';
import { Button } from '@/components/ui/button';
export interface EmptyStackStateProps {
// Open the create dialog on a given starting mode. Provided only when the
// user has permission to create stacks; otherwise the buttons are hidden.
onOpenCreate?: (mode: 'import' | 'empty') => void;
}
export function EmptyStackState({ onOpenCreate }: EmptyStackStateProps) {
return (
<div className="px-3 py-8 text-center">
<Layers className="mx-auto h-6 w-6 text-stat-icon" strokeWidth={1.5} />
<p className="mt-3 text-sm text-stat-title">No stacks yet</p>
<p className="mx-auto mt-1 max-w-[200px] text-xs leading-relaxed text-stat-subtitle">
Import compose files you already have, or create one from scratch.
</p>
{onOpenCreate && (
<div className="mt-4 flex flex-col gap-2">
<Button size="sm" className="w-full" onClick={() => onOpenCreate('import')}>
<FolderSearch className="mr-1.5 h-4 w-4" strokeWidth={1.5} />
Import existing
</Button>
<Button size="sm" variant="outline" className="w-full" onClick={() => onOpenCreate('empty')}>
<Plus className="mr-1.5 h-4 w-4" strokeWidth={1.5} />
New stack
</Button>
</div>
)}
</div>
);
}
+17 -6
View File
@@ -11,7 +11,7 @@ import type { StackRowStatus } from './stack-status-utils';
import { StackGroup } from './StackGroup';
import { StackContextMenu } from './StackContextMenu';
import { StackKebabMenu } from './StackKebabMenu';
import { EmptyStackState } from './EmptyStackState';
import { DiscoveryEmptyState } from './DiscoveryEmptyState';
import type { StackMenuCtx, FilterChip } from './sidebar-types';
import type { MuteRuleDraft } from '@/lib/muteRules';
import type { StacksLoadStatus } from '@/components/EditorLayout/hooks/useStackListState';
@@ -56,9 +56,12 @@ export interface StackListProps {
// applied ('all'), so a filter that matches nothing is not mistaken for "no
// stacks yet".
filterChip: FilterChip;
// Open the create dialog on a starting mode. Present only when the user can
// create stacks; drives the zero-stacks empty state.
onOpenCreate?: (mode: 'import' | 'empty') => void;
// Open the create dialog. Present only when the user can create stacks.
onOpenCreate?: () => void;
onOpenAdopt?: () => void;
onScan?: () => void;
canCreate?: boolean;
activeNodeId?: number | null;
openMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
stacksLoadStatus?: StacksLoadStatus;
stacksLoadError?: string | null;
@@ -150,7 +153,7 @@ export function StackList(props: StackListProps & StackListBulkProps) {
isBusy, getDisplayName, onSelectFile, buildMenuCtx,
bulkMode, selectedFiles, onToggleSelect,
remoteResults, remoteLoading, remoteFailedNodes, onSelectRemoteFile,
filterChip, onOpenCreate,
filterChip, onOpenCreate, onOpenAdopt, onScan, canCreate, activeNodeId,
openMuteRulesWithPrefill,
stacksLoadStatus,
stacksLoadError,
@@ -197,7 +200,15 @@ export function StackList(props: StackListProps & StackListBulkProps) {
// no active filter chip, so a filter that happens to match nothing does not
// masquerade as an empty fleet.
if (files.length === 0 && !searchQuery.trim() && filterChip === 'all') {
return <EmptyStackState onOpenCreate={onOpenCreate} />;
return (
<DiscoveryEmptyState
onOpenAdopt={onOpenAdopt}
onOpenCreate={onOpenCreate}
onScan={onScan}
canCreate={canCreate}
activeNodeId={activeNodeId}
/>
);
}
return (
@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { DiscoveryEmptyState } from '../DiscoveryEmptyState';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('@/components/ui/toast-store', () => ({ toast: { error: vi.fn() } }));
import { apiFetch } from '@/lib/api';
function discoveryRes(body: unknown) {
return { ok: true, json: async () => body } as Response;
}
describe('DiscoveryEmptyState', () => {
beforeEach(() => {
vi.mocked(apiFetch).mockReset();
});
it('fetches /stacks/discovery only, never diagnostics', async () => {
vi.mocked(apiFetch).mockResolvedValue(discoveryRes({
composeDir: '/opt/compose',
readable: true,
discovery: {
composeDir: '/opt/compose',
stackCount: 0,
adoptCandidateCount: 0,
adoptCandidatesTruncated: false,
},
}));
render(<DiscoveryEmptyState canCreate onOpenCreate={vi.fn()} onScan={vi.fn()} />);
await waitFor(() => expect(screen.getByText(/no compose projects yet/i)).toBeTruthy());
expect(apiFetch).toHaveBeenCalledTimes(1);
expect(apiFetch).toHaveBeenCalledWith('/stacks/discovery');
expect(apiFetch).not.toHaveBeenCalledWith('/diagnostics/environment', expect.anything());
});
it('shows unreadable copy with composeDir and error', async () => {
vi.mocked(apiFetch).mockResolvedValue(discoveryRes({
composeDir: '/missing/compose',
readable: false,
discovery: null,
error: 'Compose directory does not exist.',
}));
render(<DiscoveryEmptyState />);
await waitFor(() => expect(screen.getByText(/could not read compose directory/i)).toBeTruthy());
expect(screen.getByText('/missing/compose')).toBeTruthy();
expect(screen.getByText('Compose directory does not exist.')).toBeTruthy();
});
it('shows adopt CTA when candidates exist', async () => {
const onOpenAdopt = vi.fn();
vi.mocked(apiFetch).mockResolvedValue(discoveryRes({
composeDir: '/opt/compose',
readable: true,
discovery: {
composeDir: '/opt/compose',
stackCount: 2,
adoptCandidateCount: 3,
adoptCandidatesTruncated: false,
},
}));
render(<DiscoveryEmptyState onOpenAdopt={onOpenAdopt} />);
await waitFor(() => expect(screen.getByRole('button', { name: /adopt existing files/i })).toBeTruthy());
expect(screen.queryByText(/no compose projects yet/i)).toBeNull();
});
it('re-fetches when activeNodeId changes', async () => {
vi.mocked(apiFetch).mockResolvedValue(discoveryRes({
composeDir: '/opt/compose',
readable: true,
discovery: {
composeDir: '/opt/compose',
stackCount: 0,
adoptCandidateCount: 0,
adoptCandidatesTruncated: false,
},
}));
const { rerender } = render(<DiscoveryEmptyState activeNodeId={1} />);
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(1));
rerender(<DiscoveryEmptyState activeNodeId={2} />);
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
});
});