mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
feat(environments): env CRUD, workspace switcher, CLI + MCP parity (#68)
Environment lanes existed as seeded rows (production/preview/development) with a per-service environment label, but there was no way to list, create, or delete them. Adds the management surface and the switcher: - GET /projects/:id/environments with live service counts (sqlc) - POST /projects/:id/environments — lowercase dns-label names, 1-50 chars, unique per project (409 on dupe) - DELETE /environments/:id — refuses while it holds services (409) - Workspace service list gains env filter chips with counts plus an inline "+ env" creator - CLI: containr environments list|create|delete (--yes on delete); MCP: containr_environments_list/create/delete (delete needs confirm) - OpenAPI: new Environments tag + ProjectEnvironment schema Live-verified: list counts, name validation, dupe 409, empty delete, non-empty 409, CLI list. Generated with [Devin](https://devin.ai) Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
dd7b735b38
commit
60145f7b21
13 files changed
+727
-6
No files matched your search
@@ -8,12 +8,14 @@ import {
|
||||
createManagedDatabase,
|
||||
createService,
|
||||
getProjectById,
|
||||
createProjectEnvironment,
|
||||
importComposeIntoProject,
|
||||
listConnectedGitRepositories,
|
||||
listGitBranches,
|
||||
listGitProviders,
|
||||
listGitRepositories,
|
||||
listProjectActivity,
|
||||
listProjectEnvironments,
|
||||
listServiceLogs,
|
||||
listServiceVariables,
|
||||
listServicesByProject,
|
||||
@@ -589,6 +591,22 @@ export function ProjectWorkspacePage() {
|
||||
() => (isDemoMode ? getDemoServicesByProject(projectId) : servicesQuery.data ?? []),
|
||||
[isDemoMode, projectId, servicesQuery.data],
|
||||
);
|
||||
const environmentsQuery = useQuery({
|
||||
queryKey: ['project-environments', projectId],
|
||||
queryFn: () => listProjectEnvironments(projectId!),
|
||||
enabled: !!projectId && !isDemoMode,
|
||||
});
|
||||
const [envFilter, setEnvFilter] = useState<string | null>(null);
|
||||
const environments = environmentsQuery.data ?? [];
|
||||
const visibleServices = useMemo(
|
||||
() => (envFilter ? services.filter((s) => (s.environment ?? 'production') === envFilter) : services),
|
||||
[services, envFilter],
|
||||
);
|
||||
const createEnvMutation = useMutation({
|
||||
mutationFn: (name: string) => createProjectEnvironment(projectId!, name),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['project-environments', projectId] }),
|
||||
onError: (err) => toast.showToast(err instanceof ApiError ? err.message : 'Failed to create environment', 'error'),
|
||||
});
|
||||
const serviceIdFingerprint = services.map((service) => service.id).sort().join('|');
|
||||
|
||||
const variablesQuery = useQuery({
|
||||
@@ -850,18 +868,50 @@ export function ProjectWorkspacePage() {
|
||||
|
||||
{/* Service List */}
|
||||
<div className="panel p-6">
|
||||
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-3">Services</h3>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-[var(--text-secondary)]">Services</h3>
|
||||
{!isDemoMode && environments.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<button
|
||||
onClick={() => setEnvFilter(null)}
|
||||
className={`px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
|
||||
envFilter === null
|
||||
? 'bg-[var(--accent-primary)] text-white'
|
||||
: 'bg-[var(--surface-muted)] text-[var(--text-secondary)] hover:bg-[var(--surface-card)]'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{environments.map((env) => (
|
||||
<button
|
||||
key={env.id}
|
||||
onClick={() => setEnvFilter(envFilter === env.name ? null : env.name)}
|
||||
className={`px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
|
||||
envFilter === env.name
|
||||
? 'bg-[var(--accent-primary)] text-white'
|
||||
: 'bg-[var(--surface-muted)] text-[var(--text-secondary)] hover:bg-[var(--surface-card)]'
|
||||
}`}
|
||||
>
|
||||
{env.name} ({env.service_count})
|
||||
</button>
|
||||
))}
|
||||
<EnvCreateButton onCreate={(name) => createEnvMutation.mutate(name)} pending={createEnvMutation.isPending} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] overflow-hidden divide-y divide-[var(--border-subtle)]">
|
||||
{services.length === 0 ? (
|
||||
{visibleServices.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-[var(--surface-muted)] flex items-center justify-center">
|
||||
<Layers size={20} className="text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">No services deployed yet</p>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-1">Add services from the Canvas view</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{envFilter ? `No services in ${envFilter}` : 'No services deployed yet'}
|
||||
</p>
|
||||
{!envFilter && <p className="text-xs text-[var(--text-muted)] mt-1">Add services from the Canvas view</p>}
|
||||
</div>
|
||||
) : (
|
||||
services.map((service) => (
|
||||
visibleServices.map((service) => (
|
||||
<button
|
||||
key={service.id}
|
||||
onClick={() => navigate(serviceHref(service.id))}
|
||||
@@ -1124,3 +1174,39 @@ export function ProjectWorkspacePage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// EnvCreateButton expands into an inline name input for a new environment.
|
||||
function EnvCreateButton({ onCreate, pending }: { onCreate: (name: string) => void; pending: boolean }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="px-2.5 py-1 rounded-full text-xs font-medium border border-dashed border-[var(--border-subtle)] text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors"
|
||||
>
|
||||
+ env
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && name.trim()) {
|
||||
onCreate(name.trim());
|
||||
setName('');
|
||||
setOpen(false);
|
||||
}
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
}}
|
||||
placeholder="staging"
|
||||
className="w-24 px-2 py-1 rounded-full text-xs bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-[var(--text-primary)] outline-none"
|
||||
/>
|
||||
{pending && <Loader2 size={12} className="animate-spin text-[var(--text-muted)]" />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -9391,6 +9391,137 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/projects/{id}/environments": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* List project environments
|
||||
* @description Returns each environment lane with a live service count — the data behind the environment switcher.
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Project ID */
|
||||
id: components["parameters"]["ProjectIdParam"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Project environments */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
environments?: components["schemas"]["ProjectEnvironment"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
/**
|
||||
* Create an environment
|
||||
* @description Adds a named environment lane. Names are lowercase alphanumeric with dashes, 1-50 chars, unique per project.
|
||||
*/
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Project ID */
|
||||
id: components["parameters"]["ProjectIdParam"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Created environment */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
environment?: components["schemas"]["ProjectEnvironment"];
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Environment already exists */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/environments/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
/**
|
||||
* Delete an empty environment
|
||||
* @description Removes an environment lane; fails with 409 while it still holds services.
|
||||
*/
|
||||
delete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Environment deleted */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Environment still contains services */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/projects/{id}/preview-environments": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -12488,6 +12619,18 @@ export interface components {
|
||||
restart_policy?: Record<string, never>;
|
||||
health_check?: Record<string, never>;
|
||||
};
|
||||
ProjectEnvironment: {
|
||||
/** Format: uuid */
|
||||
id?: string;
|
||||
name?: string;
|
||||
/** Format: uuid */
|
||||
project_id?: string;
|
||||
service_count?: number;
|
||||
/** Format: date-time */
|
||||
created_at?: string;
|
||||
/** Format: date-time */
|
||||
updated_at?: string;
|
||||
};
|
||||
PreviewEnvironment: {
|
||||
/** Format: uuid */
|
||||
id?: string;
|
||||
|
||||
@@ -1321,6 +1321,34 @@ export async function listServicesByProject(projectId: string): Promise<ServiceE
|
||||
return normalizeServiceArray(rows);
|
||||
}
|
||||
|
||||
export interface ProjectEnvironment {
|
||||
id: string;
|
||||
name: string;
|
||||
project_id: string;
|
||||
service_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export async function listProjectEnvironments(projectId: string): Promise<ProjectEnvironment[]> {
|
||||
const payload = await requestJson<{ environments?: ProjectEnvironment[] }>(
|
||||
`/projects/${projectId}/environments`,
|
||||
);
|
||||
return payload.environments ?? [];
|
||||
}
|
||||
|
||||
export async function createProjectEnvironment(projectId: string, name: string): Promise<ProjectEnvironment> {
|
||||
const payload = await requestJson<{ environment: ProjectEnvironment }>(
|
||||
`/projects/${projectId}/environments`,
|
||||
{ method: 'POST', body: JSON.stringify({ name }) },
|
||||
);
|
||||
return payload.environment;
|
||||
}
|
||||
|
||||
export async function deleteProjectEnvironment(environmentId: string): Promise<void> {
|
||||
await requestJson(`/environments/${encodeURIComponent(environmentId)}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function getServiceById(serviceId: string): Promise<ServiceEntity> {
|
||||
const payload = await requestJson<RawService | { service?: RawService }>(`/services/${serviceId}`);
|
||||
const row = (payload as { service?: RawService }).service ?? (payload as RawService);
|
||||
|
||||
Reference in new issue
Block a user