mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
feat(workspace): real database + template entry points in Add Service
type=database now provisions a managed database via POST /databases (engine + plan pickers) instead of a hollow service row; template source navigates to the existing catalog with the project preselected (/templates?project= now seeds the deploy target). api-client gains createManagedDatabase + DatabaseEntity from generated schemas. Authored By: TDvorak <[email protected]>
This commit is contained in:
1 parent
25703592c6
commit
e6e76c1084
4 files changed
+113
-15
No files matched your search
+5
-3
@@ -214,9 +214,11 @@ pieces exist; this phase closes the gaps that block real usage.
|
|||||||
- Variables discovered from `${VAR}`/`${VAR:-default}` placeholders.
|
- Variables discovered from `${VAR}`/`${VAR:-default}` placeholders.
|
||||||
- Catalog UI: screenshots, badges, import dialogs (GitHub + paste),
|
- Catalog UI: screenshots, badges, import dialogs (GitHub + paste),
|
||||||
install wizard → creates one service per Compose service.
|
install wizard → creates one service per Compose service.
|
||||||
- [ ] **Service creation flows on canvas**: Add Service menu with Git repo /
|
- [x] **Service creation flows on canvas**: Add Service covers Image, Git
|
||||||
Docker image / database / worker / cron / template entry points —
|
repo (provider → repo → branch → connect+webhook), Database (engine +
|
||||||
currently only some paths are reachable.
|
plan → real `POST /databases` provisioning, not a hollow service row),
|
||||||
|
worker/cron types, and a Template entry that jumps to the catalog with
|
||||||
|
the project preselected (`/templates?project=`).
|
||||||
- [ ] **Variables UX**: secret masking, per-environment values, bulk paste
|
- [ ] **Variables UX**: secret masking, per-environment values, bulk paste
|
||||||
(`KEY=VAL` lines), validation errors inline.
|
(`KEY=VAL` lines), validation errors inline.
|
||||||
- [ ] **Deploy lifecycle polish**: build → deploy status propagation to canvas
|
- [ ] **Deploy lifecycle polish**: build → deploy status propagation to canvas
|
||||||
|
|||||||
@@ -377,7 +377,7 @@ export function TemplatesPage() {
|
|||||||
const [categoryFilter, setCategoryFilter] = useState('');
|
const [categoryFilter, setCategoryFilter] = useState('');
|
||||||
const [searchFilter, setSearchFilter] = useState('');
|
const [searchFilter, setSearchFilter] = useState('');
|
||||||
const [selectedTemplateIdState, setSelectedTemplateId] = useState<string | null>(null);
|
const [selectedTemplateIdState, setSelectedTemplateId] = useState<string | null>(null);
|
||||||
const [deployProjectIdState, setDeployProjectId] = useState('');
|
const [deployProjectIdState, setDeployProjectId] = useState(searchParams.get('project') ?? '');
|
||||||
const [deployNameByTemplate, setDeployNameByTemplate] = useState<Record<string, string>>({});
|
const [deployNameByTemplate, setDeployNameByTemplate] = useState<Record<string, string>>({});
|
||||||
const [variableValuesByTemplate, setVariableValuesByTemplate] = useState<
|
const [variableValuesByTemplate, setVariableValuesByTemplate] = useState<
|
||||||
Record<string, Record<string, string>>
|
Record<string, Record<string, string>>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
ApiError,
|
ApiError,
|
||||||
connectGitRepository,
|
connectGitRepository,
|
||||||
createGitWebhook,
|
createGitWebhook,
|
||||||
|
createManagedDatabase,
|
||||||
createService,
|
createService,
|
||||||
getProjectById,
|
getProjectById,
|
||||||
listConnectedGitRepositories,
|
listConnectedGitRepositories,
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
listServiceLogs,
|
listServiceLogs,
|
||||||
listServiceVariables,
|
listServiceVariables,
|
||||||
listServicesByProject,
|
listServicesByProject,
|
||||||
|
type CreateDatabaseInput,
|
||||||
type CreateServiceInput,
|
type CreateServiceInput,
|
||||||
} from '@/lib/api-client';
|
} from '@/lib/api-client';
|
||||||
import { getDemoProjectById, getDemoServicesByProject, getDemoVariablesByProject } from '@/lib/demo-data';
|
import { getDemoProjectById, getDemoServicesByProject, getDemoVariablesByProject } from '@/lib/demo-data';
|
||||||
@@ -57,12 +59,26 @@ const serviceEnvironments = ['production', 'preview', 'development'] as const;
|
|||||||
type CreateServiceSubmit = CreateServiceInput & {
|
type CreateServiceSubmit = CreateServiceInput & {
|
||||||
gitProviderId?: string;
|
gitProviderId?: string;
|
||||||
gitRepoFullName?: string;
|
gitRepoFullName?: string;
|
||||||
|
database?: { engine: CreateDatabaseInput['type']; plan: CreateDatabaseInput['plan'] };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const databaseEngines = [
|
||||||
|
{ value: 'postgresql', label: 'PostgreSQL' },
|
||||||
|
{ value: 'mysql', label: 'MySQL' },
|
||||||
|
{ value: 'mariadb', label: 'MariaDB' },
|
||||||
|
{ value: 'redis', label: 'Redis' },
|
||||||
|
{ value: 'dragonfly', label: 'Dragonfly' },
|
||||||
|
{ value: 'mongodb', label: 'MongoDB' },
|
||||||
|
{ value: 'clickhouse', label: 'ClickHouse' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const databasePlans = ['hobby', 'starter', 'standard', 'business'] as const;
|
||||||
|
|
||||||
function ServiceCreateDialog(props: {
|
function ServiceCreateDialog(props: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: (payload: CreateServiceSubmit) => void;
|
onSubmit: (payload: CreateServiceSubmit) => void;
|
||||||
|
onBrowseTemplates: () => void;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
}) {
|
}) {
|
||||||
@@ -77,6 +93,10 @@ function ServiceCreateDialog(props: {
|
|||||||
const [providerId, setProviderId] = useState('');
|
const [providerId, setProviderId] = useState('');
|
||||||
const [repoFullName, setRepoFullName] = useState('');
|
const [repoFullName, setRepoFullName] = useState('');
|
||||||
const [repoSearch, setRepoSearch] = useState('');
|
const [repoSearch, setRepoSearch] = useState('');
|
||||||
|
const [dbEngine, setDbEngine] = useState<(typeof databaseEngines)[number]['value']>('postgresql');
|
||||||
|
const [dbPlan, setDbPlan] = useState<(typeof databasePlans)[number]>('hobby');
|
||||||
|
|
||||||
|
const isDatabase = form.type === 'database';
|
||||||
|
|
||||||
const providersQuery = useQuery({
|
const providersQuery = useQuery({
|
||||||
queryKey: ['git-providers'],
|
queryKey: ['git-providers'],
|
||||||
@@ -110,7 +130,7 @@ function ServiceCreateDialog(props: {
|
|||||||
|
|
||||||
const canSubmit =
|
const canSubmit =
|
||||||
Boolean(form.name.trim()) &&
|
Boolean(form.name.trim()) &&
|
||||||
(source === 'image' || Boolean(repoFullName));
|
(isDatabase ? Boolean(dbEngine) : source === 'image' || Boolean(repoFullName));
|
||||||
|
|
||||||
if (!props.open) return null;
|
if (!props.open) return null;
|
||||||
|
|
||||||
@@ -172,11 +192,48 @@ function ServiceCreateDialog(props: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isDatabase ? (
|
||||||
|
<div className="space-y-3 p-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]/50">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
|
||||||
|
Engine
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={dbEngine}
|
||||||
|
onChange={(e) => setDbEngine(e.target.value as typeof dbEngine)}
|
||||||
|
className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
|
||||||
|
>
|
||||||
|
{databaseEngines.map((engine) => (
|
||||||
|
<option key={engine.value} value={engine.value}>{engine.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
|
||||||
|
Plan
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={dbPlan}
|
||||||
|
onChange={(e) => setDbPlan(e.target.value as typeof dbPlan)}
|
||||||
|
className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
|
||||||
|
>
|
||||||
|
{databasePlans.map((plan) => (
|
||||||
|
<option key={plan} value={plan}>{plan.charAt(0).toUpperCase() + plan.slice(1)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[var(--text-muted)]">
|
||||||
|
Provisions a managed database container. Connection details appear under Databases once running.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
|
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
|
||||||
Source
|
Source
|
||||||
</label>
|
</label>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSource('image')}
|
onClick={() => setSource('image')}
|
||||||
@@ -201,10 +258,19 @@ function ServiceCreateDialog(props: {
|
|||||||
<GitBranch size={14} />
|
<GitBranch size={14} />
|
||||||
Git Repository
|
Git Repository
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={props.onBrowseTemplates}
|
||||||
|
className="flex items-center justify-center gap-2 h-10 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-tertiary)] hover:border-[var(--border-default)] transition-colors"
|
||||||
|
>
|
||||||
|
<Sparkles size={14} />
|
||||||
|
Template
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{source === 'image' ? (
|
{isDatabase ? null : source === 'image' ? (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
|
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
|
||||||
Image <span className="normal-case text-[var(--text-muted)]">(optional)</span>
|
Image <span className="normal-case text-[var(--text-muted)]">(optional)</span>
|
||||||
@@ -369,19 +435,20 @@ function ServiceCreateDialog(props: {
|
|||||||
props.onSubmit({
|
props.onSubmit({
|
||||||
...form,
|
...form,
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
image: source === 'image' ? form.image?.trim() : '',
|
image: !isDatabase && source === 'image' ? form.image?.trim() : '',
|
||||||
command: form.command?.trim(),
|
command: form.command?.trim(),
|
||||||
git_repo: source === 'git' ? form.git_repo?.trim() : '',
|
git_repo: !isDatabase && source === 'git' ? form.git_repo?.trim() : '',
|
||||||
git_branch: source === 'git' ? form.git_branch?.trim() : '',
|
git_branch: !isDatabase && source === 'git' ? form.git_branch?.trim() : '',
|
||||||
build_path: source === 'git' ? form.build_path?.trim() : '',
|
build_path: !isDatabase && source === 'git' ? form.build_path?.trim() : '',
|
||||||
gitProviderId: source === 'git' && repoFullName ? providerId : undefined,
|
gitProviderId: !isDatabase && source === 'git' && repoFullName ? providerId : undefined,
|
||||||
gitRepoFullName: source === 'git' ? repoFullName || undefined : undefined,
|
gitRepoFullName: !isDatabase && source === 'git' ? repoFullName || undefined : undefined,
|
||||||
|
database: isDatabase ? { engine: dbEngine, plan: dbPlan } : undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="px-5 py-2 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
className="px-5 py-2 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||||
style={{ background: 'var(--accent-primary)' }}
|
style={{ background: 'var(--accent-primary)' }}
|
||||||
>
|
>
|
||||||
{props.loading ? 'Creating...' : 'Create Service'}
|
{props.loading ? 'Creating...' : isDatabase ? 'Create Database' : 'Create Service'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -428,7 +495,16 @@ export function ProjectWorkspacePage() {
|
|||||||
|
|
||||||
const createServiceMutation = useMutation({
|
const createServiceMutation = useMutation({
|
||||||
mutationFn: async (payload: CreateServiceSubmit) => {
|
mutationFn: async (payload: CreateServiceSubmit) => {
|
||||||
const { gitProviderId, gitRepoFullName, ...serviceInput } = payload;
|
const { gitProviderId, gitRepoFullName, database, ...serviceInput } = payload;
|
||||||
|
if (database) {
|
||||||
|
await createManagedDatabase({
|
||||||
|
name: serviceInput.name,
|
||||||
|
type: database.engine,
|
||||||
|
plan: database.plan,
|
||||||
|
region: 'local',
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const service = await createService(projectId, serviceInput);
|
const service = await createService(projectId, serviceInput);
|
||||||
if (gitProviderId && gitRepoFullName) {
|
if (gitProviderId && gitRepoFullName) {
|
||||||
let repoId: string | undefined;
|
let repoId: string | undefined;
|
||||||
@@ -881,6 +957,10 @@ export function ProjectWorkspacePage() {
|
|||||||
loading={createServiceMutation.isPending}
|
loading={createServiceMutation.isPending}
|
||||||
errorMessage={createServiceMutation.error ? (createServiceMutation.error as Error).message : undefined}
|
errorMessage={createServiceMutation.error ? (createServiceMutation.error as Error).message : undefined}
|
||||||
onSubmit={(payload) => createServiceMutation.mutate(payload)}
|
onSubmit={(payload) => createServiceMutation.mutate(payload)}
|
||||||
|
onBrowseTemplates={() => {
|
||||||
|
setCreateOpen(false);
|
||||||
|
navigate(`/templates?project=${projectId}`);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1231,6 +1231,22 @@ export async function listGitBranches(
|
|||||||
return payload.branches ?? [];
|
return payload.branches ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CreateDatabaseInput = components['schemas']['CreateDatabaseRequest'];
|
||||||
|
export type DatabaseEntity = components['schemas']['Database'];
|
||||||
|
|
||||||
|
export async function createManagedDatabase(
|
||||||
|
input: CreateDatabaseInput,
|
||||||
|
): Promise<{ id: string; status: string }> {
|
||||||
|
const payload = await requestJson<{ id?: string; status?: string }>('/databases', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
if (!payload.id) {
|
||||||
|
throw new ApiError('Database response is invalid', 500);
|
||||||
|
}
|
||||||
|
return { id: payload.id, status: payload.status ?? 'building' };
|
||||||
|
}
|
||||||
|
|
||||||
export type ConnectGitRepositoryInput = components['schemas']['ConnectGitRepoRequest'];
|
export type ConnectGitRepositoryInput = components['schemas']['ConnectGitRepoRequest'];
|
||||||
export type CreateGitWebhookInput = components['schemas']['CreateWebhookRequest'];
|
export type CreateGitWebhookInput = components['schemas']['CreateWebhookRequest'];
|
||||||
export type GitWebhookEntity = components['schemas']['GitWebhook'];
|
export type GitWebhookEntity = components['schemas']['GitWebhook'];
|
||||||
|
|||||||
Reference in new issue
Block a user