mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 18:56:53 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user