feat!: move apps & deployments under /api/v1/environments/{envSlug}/apps/{appSlug}/...
P3B of the taxonomy migration. App and deployment routes are now
env-scoped in the URL itself, making the (env, app_slug) uniqueness
key explicit. Previously /api/v1/apps/{appSlug} was ambiguous: with
the same app deployed to multiple environments (dev/staging/prod),
the handler called AppService.getBySlug(slug) which returns the
first row matching slug regardless of env.
Server:
- AppController: @RequestMapping("/api/v1/environments/{envSlug}/
apps"). Every handler now calls
appService.getByEnvironmentAndSlug(env.id(), appSlug) — 404 if the
app doesn't exist in *this* env. CreateAppRequest body drops
environmentId (it's in the path).
- DeploymentController: @RequestMapping("/api/v1/environments/
{envSlug}/apps/{appSlug}/deployments"). DeployRequest body drops
environmentId. PromoteRequest body switches from
targetEnvironmentId (UUID) to targetEnvironment (slug);
promote handler resolves the target env by slug and looks up the
app with the same slug in the target env (fails with 404 if the
target app doesn't exist yet — apps must exist in both source
and target before promote).
- AppService: added getByEnvironmentAndSlug helper; createApp now
validates slug against ^[a-z0-9][a-z0-9-]{0,63}$ (400 on
invalid).
SPA:
- queries/admin/apps.ts: rewritten. Hooks take envSlug where
env-scoped. Removed useAllApps (no flat endpoint). Renamed path
param naming: appId → appSlug throughout. Added
usePromoteDeployment. Query keys include envSlug so cache is
env-scoped.
- AppsTab.tsx: call sites updated. When no environment is selected,
the managed-app list is empty — cross-env discovery lives in the
Runtime tab (catalog). handleDeploy/handleStop/etc. pass envSlug
to the new hook signatures.
BREAKING CHANGE: /api/v1/apps/** paths removed. Clients must use
/api/v1/environments/{envSlug}/apps/{appSlug}/**. Request bodies
for POST /apps and POST /apps/{slug}/deployments no longer accept
environmentId (use the URL path instead). Promote body uses slug
not UUID.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -43,9 +43,14 @@ export interface Deployment {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
async function appFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
/**
|
||||
* Authenticated fetch. `path` is relative to apiBaseUrl, must include the
|
||||
* leading slash. All app/deployment endpoints now live under
|
||||
* /api/v1/environments/{envSlug}/...
|
||||
*/
|
||||
async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const token = useAuthStore.getState().accessToken;
|
||||
const res = await fetch(`${config.apiBaseUrl}/apps${path}`, {
|
||||
const res = await fetch(`${config.apiBaseUrl}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -65,28 +70,28 @@ async function appFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
// --- Apps ---
|
||||
|
||||
export function useAllApps() {
|
||||
return useQuery({
|
||||
queryKey: ['apps', 'all'],
|
||||
queryFn: () => appFetch<App[]>(''),
|
||||
});
|
||||
function envBase(envSlug: string): string {
|
||||
return `/environments/${encodeURIComponent(envSlug)}/apps`;
|
||||
}
|
||||
|
||||
export function useApps(environmentId: string | undefined) {
|
||||
// --- Apps ---
|
||||
|
||||
export function useApps(envSlug: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['apps', environmentId],
|
||||
queryFn: () => appFetch<App[]>(`?environmentId=${environmentId}`),
|
||||
enabled: !!environmentId,
|
||||
queryKey: ['apps', envSlug],
|
||||
queryFn: () => apiFetch<App[]>(envBase(envSlug!)),
|
||||
enabled: !!envSlug,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateApp() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (req: { environmentId: string; slug: string; displayName: string }) =>
|
||||
appFetch<App>('', { method: 'POST', body: JSON.stringify(req) }),
|
||||
mutationFn: ({ envSlug, slug, displayName }: { envSlug: string; slug: string; displayName: string }) =>
|
||||
apiFetch<App>(envBase(envSlug), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ slug, displayName }),
|
||||
}),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['apps'] }),
|
||||
});
|
||||
}
|
||||
@@ -94,8 +99,8 @@ export function useCreateApp() {
|
||||
export function useDeleteApp() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (slug: string) =>
|
||||
appFetch<void>(`/${slug}`, { method: 'DELETE' }),
|
||||
mutationFn: ({ envSlug, appSlug }: { envSlug: string; appSlug: string }) =>
|
||||
apiFetch<void>(`${envBase(envSlug)}/${encodeURIComponent(appSlug)}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['apps'] });
|
||||
qc.invalidateQueries({ queryKey: ['catalog'] });
|
||||
@@ -106,30 +111,34 @@ export function useDeleteApp() {
|
||||
export function useUpdateContainerConfig() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ appId, config }: { appId: string; config: Record<string, unknown> }) =>
|
||||
appFetch<App>(`/${appId}/container-config`, { method: 'PUT', body: JSON.stringify(config) }),
|
||||
mutationFn: ({ envSlug, appSlug, config }: { envSlug: string; appSlug: string; config: Record<string, unknown> }) =>
|
||||
apiFetch<App>(`${envBase(envSlug)}/${encodeURIComponent(appSlug)}/container-config`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(config),
|
||||
}),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['apps'] }),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Versions ---
|
||||
|
||||
export function useAppVersions(appId: string | undefined) {
|
||||
export function useAppVersions(envSlug: string | undefined, appSlug: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['apps', appId, 'versions'],
|
||||
queryFn: () => appFetch<AppVersion[]>(`/${appId}/versions`),
|
||||
enabled: !!appId,
|
||||
queryKey: ['apps', envSlug, appSlug, 'versions'],
|
||||
queryFn: () => apiFetch<AppVersion[]>(`${envBase(envSlug!)}/${encodeURIComponent(appSlug!)}/versions`),
|
||||
enabled: !!envSlug && !!appSlug,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadJar() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ appId, file }: { appId: string; file: File }) => {
|
||||
mutationFn: async ({ envSlug, appSlug, file }: { envSlug: string; appSlug: string; file: File }) => {
|
||||
const token = useAuthStore.getState().accessToken;
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const res = await fetch(`${config.apiBaseUrl}/apps/${appId}/versions`, {
|
||||
const res = await fetch(
|
||||
`${config.apiBaseUrl}${envBase(envSlug)}/${encodeURIComponent(appSlug)}/versions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
@@ -140,18 +149,18 @@ export function useUploadJar() {
|
||||
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
|
||||
return res.json() as Promise<AppVersion>;
|
||||
},
|
||||
onSuccess: (_data, { appId }) =>
|
||||
qc.invalidateQueries({ queryKey: ['apps', appId, 'versions'] }),
|
||||
onSuccess: (_data, { envSlug, appSlug }) =>
|
||||
qc.invalidateQueries({ queryKey: ['apps', envSlug, appSlug, 'versions'] }),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Deployments ---
|
||||
|
||||
export function useDeployments(appId: string | undefined) {
|
||||
export function useDeployments(envSlug: string | undefined, appSlug: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['apps', appId, 'deployments'],
|
||||
queryFn: () => appFetch<Deployment[]>(`/${appId}/deployments`),
|
||||
enabled: !!appId,
|
||||
queryKey: ['apps', envSlug, appSlug, 'deployments'],
|
||||
queryFn: () => apiFetch<Deployment[]>(`${envBase(envSlug!)}/${encodeURIComponent(appSlug!)}/deployments`),
|
||||
enabled: !!envSlug && !!appSlug,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
}
|
||||
@@ -159,19 +168,38 @@ export function useDeployments(appId: string | undefined) {
|
||||
export function useCreateDeployment() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ appId, ...req }: { appId: string; appVersionId: string; environmentId: string }) =>
|
||||
appFetch<Deployment>(`/${appId}/deployments`, { method: 'POST', body: JSON.stringify(req) }),
|
||||
onSuccess: (_data, { appId }) =>
|
||||
qc.invalidateQueries({ queryKey: ['apps', appId, 'deployments'] }),
|
||||
mutationFn: ({ envSlug, appSlug, appVersionId }: { envSlug: string; appSlug: string; appVersionId: string }) =>
|
||||
apiFetch<Deployment>(
|
||||
`${envBase(envSlug)}/${encodeURIComponent(appSlug)}/deployments`,
|
||||
{ method: 'POST', body: JSON.stringify({ appVersionId }) },
|
||||
),
|
||||
onSuccess: (_data, { envSlug, appSlug }) =>
|
||||
qc.invalidateQueries({ queryKey: ['apps', envSlug, appSlug, 'deployments'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useStopDeployment() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ appId, deploymentId }: { appId: string; deploymentId: string }) =>
|
||||
appFetch<Deployment>(`/${appId}/deployments/${deploymentId}/stop`, { method: 'POST' }),
|
||||
onSuccess: (_data, { appId }) =>
|
||||
qc.invalidateQueries({ queryKey: ['apps', appId, 'deployments'] }),
|
||||
mutationFn: ({ envSlug, appSlug, deploymentId }: { envSlug: string; appSlug: string; deploymentId: string }) =>
|
||||
apiFetch<Deployment>(
|
||||
`${envBase(envSlug)}/${encodeURIComponent(appSlug)}/deployments/${deploymentId}/stop`,
|
||||
{ method: 'POST' },
|
||||
),
|
||||
onSuccess: (_data, { envSlug, appSlug }) =>
|
||||
qc.invalidateQueries({ queryKey: ['apps', envSlug, appSlug, 'deployments'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePromoteDeployment() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ envSlug, appSlug, deploymentId, targetEnvironment }:
|
||||
{ envSlug: string; appSlug: string; deploymentId: string; targetEnvironment: string }) =>
|
||||
apiFetch<Deployment>(
|
||||
`${envBase(envSlug)}/${encodeURIComponent(appSlug)}/deployments/${deploymentId}/promote`,
|
||||
{ method: 'POST', body: JSON.stringify({ targetEnvironment }) },
|
||||
),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['apps'] }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ import { EnvEditor } from '../../components/EnvEditor';
|
||||
import { useEnvironmentStore } from '../../api/environment-store';
|
||||
import { useEnvironments } from '../../api/queries/admin/environments';
|
||||
import {
|
||||
useAllApps,
|
||||
useApps,
|
||||
useCreateApp,
|
||||
useDeleteApp,
|
||||
@@ -92,13 +91,13 @@ export default function AppsTab() {
|
||||
|
||||
function AppListView({ selectedEnv, environments }: { selectedEnv: string | undefined; environments: Environment[] }) {
|
||||
const navigate = useNavigate();
|
||||
const { data: allApps = [], isLoading: allLoading } = useAllApps();
|
||||
const envId = useMemo(() => environments.find((e) => e.slug === selectedEnv)?.id, [environments, selectedEnv]);
|
||||
const { data: envApps = [], isLoading: envLoading } = useApps(envId);
|
||||
const { data: envApps = [], isLoading: envLoading } = useApps(selectedEnv);
|
||||
const { data: catalog = [] } = useCatalog(selectedEnv);
|
||||
|
||||
const apps = selectedEnv ? envApps : allApps;
|
||||
const isLoading = selectedEnv ? envLoading : allLoading;
|
||||
// Apps are env-scoped; without an env selection there is no managed-app list
|
||||
// to show. The Runtime tab (catalog) is the cross-env discovery surface.
|
||||
const apps = selectedEnv ? envApps : [];
|
||||
const isLoading = selectedEnv ? envLoading : false;
|
||||
|
||||
const envMap = useMemo(() => new Map(environments.map((e) => [e.id, e])), [environments]);
|
||||
|
||||
@@ -259,13 +258,13 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
|
||||
try {
|
||||
// 1. Create app
|
||||
setStep('Creating app...');
|
||||
const app = await createApp.mutateAsync({ environmentId: envId, slug: slug.trim(), displayName: name.trim() });
|
||||
const app = await createApp.mutateAsync({ envSlug: selectedEnv!, slug: slug.trim(), displayName: name.trim() });
|
||||
|
||||
// 2. Upload JAR (if provided)
|
||||
let version: AppVersion | null = null;
|
||||
if (file) {
|
||||
setStep('Uploading JAR...');
|
||||
version = await uploadJar.mutateAsync({ appId: app.slug, file });
|
||||
version = await uploadJar.mutateAsync({ envSlug: selectedEnv!, appSlug: app.slug, file });
|
||||
}
|
||||
|
||||
// 3. Save container config
|
||||
@@ -286,7 +285,7 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
|
||||
customArgs: customArgs || null,
|
||||
extraNetworks: extraNetworks,
|
||||
};
|
||||
await updateContainerConfig.mutateAsync({ appId: app.slug, config: containerConfig });
|
||||
await updateContainerConfig.mutateAsync({ envSlug: selectedEnv!, appSlug: app.slug, config: containerConfig });
|
||||
|
||||
// 4. Save agent config (will be pushed to agent on first connect)
|
||||
setStep('Saving monitoring config...');
|
||||
@@ -307,13 +306,13 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
|
||||
routeRecording: {},
|
||||
sensitiveKeys: sensitiveKeys.length > 0 ? sensitiveKeys : undefined,
|
||||
},
|
||||
environment: selectedEnv,
|
||||
environment: selectedEnv!,
|
||||
});
|
||||
|
||||
// 5. Deploy (if requested and JAR was uploaded)
|
||||
if (deploy && version) {
|
||||
setStep('Starting deployment...');
|
||||
await createDeployment.mutateAsync({ appId: app.slug, appVersionId: version.id, environmentId: envId });
|
||||
await createDeployment.mutateAsync({ envSlug: selectedEnv!, appSlug: app.slug, appVersionId: version.id });
|
||||
}
|
||||
|
||||
toast({ title: deploy ? 'App created and deployed' : 'App created', description: name.trim(), variant: 'success' });
|
||||
@@ -661,12 +660,12 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
|
||||
function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: string; environments: Environment[]; selectedEnv: string | undefined }) {
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
const { data: allApps = [] } = useAllApps();
|
||||
const app = useMemo(() => allApps.find((a) => a.slug === appSlug), [allApps, appSlug]);
|
||||
const { data: envApps = [] } = useApps(selectedEnv);
|
||||
const app = useMemo(() => envApps.find((a) => a.slug === appSlug), [envApps, appSlug]);
|
||||
const { data: catalogApps } = useCatalog(selectedEnv);
|
||||
const catalogEntry = useMemo(() => (catalogApps ?? []).find((c: CatalogApp) => c.slug === appSlug), [catalogApps, appSlug]);
|
||||
const { data: versions = [] } = useAppVersions(appSlug);
|
||||
const { data: deployments = [] } = useDeployments(appSlug);
|
||||
const { data: versions = [] } = useAppVersions(selectedEnv, appSlug);
|
||||
const { data: deployments = [] } = useDeployments(selectedEnv, appSlug);
|
||||
const uploadJar = useUploadJar();
|
||||
const createDeployment = useCreateDeployment();
|
||||
const stopDeployment = useStopDeployment();
|
||||
@@ -699,15 +698,15 @@ function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: s
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const v = await uploadJar.mutateAsync({ appId: appSlug, file });
|
||||
const v = await uploadJar.mutateAsync({ envSlug: selectedEnv!, appSlug, file });
|
||||
toast({ title: `Version ${v.version} uploaded`, description: file.name, variant: 'success' });
|
||||
} catch { toast({ title: 'Failed to upload JAR', variant: 'error', duration: 86_400_000 }); }
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
|
||||
async function handleDeploy(versionId: string, environmentId: string) {
|
||||
async function handleDeploy(versionId: string) {
|
||||
try {
|
||||
await createDeployment.mutateAsync({ appId: appSlug, appVersionId: versionId, environmentId });
|
||||
await createDeployment.mutateAsync({ envSlug: selectedEnv!, appSlug, appVersionId: versionId });
|
||||
toast({ title: 'Deployment started', variant: 'success' });
|
||||
} catch { toast({ title: 'Failed to deploy application', variant: 'error', duration: 86_400_000 }); }
|
||||
}
|
||||
@@ -719,7 +718,7 @@ function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: s
|
||||
async function confirmStop() {
|
||||
if (!stopTarget) return;
|
||||
try {
|
||||
await stopDeployment.mutateAsync({ appId: appSlug, deploymentId: stopTarget.id });
|
||||
await stopDeployment.mutateAsync({ envSlug: selectedEnv!, appSlug, deploymentId: stopTarget.id });
|
||||
toast({ title: 'Deployment stopped', variant: 'warning' });
|
||||
} catch { toast({ title: 'Failed to stop deployment', variant: 'error', duration: 86_400_000 }); }
|
||||
setStopTarget(null);
|
||||
@@ -727,7 +726,7 @@ function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: s
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
await deleteApp.mutateAsync(appSlug);
|
||||
await deleteApp.mutateAsync({ envSlug: selectedEnv!, appSlug });
|
||||
toast({ title: 'App deleted', variant: 'warning' });
|
||||
navigate('/apps');
|
||||
} catch { toast({ title: 'Delete failed', variant: 'error', duration: 86_400_000 }); }
|
||||
@@ -994,7 +993,7 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
|
||||
const [newNetwork, setNewNetwork] = useState('');
|
||||
|
||||
// Versions query for runtime detection hints
|
||||
const { data: versions = [] } = useAppVersions(app.slug);
|
||||
const { data: versions = [] } = useAppVersions(environment?.slug, app.slug);
|
||||
const latestVersion = versions?.[0] ?? null;
|
||||
|
||||
// Sync from server data
|
||||
@@ -1080,7 +1079,7 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
|
||||
extraNetworks: extraNetworks,
|
||||
};
|
||||
try {
|
||||
await updateContainerConfig.mutateAsync({ appId: app.slug, config: containerConfig });
|
||||
await updateContainerConfig.mutateAsync({ envSlug: environment?.slug ?? '', appSlug: app.slug, config: containerConfig });
|
||||
toast({ title: 'Configuration saved', description: 'Redeploy to apply changes to running deployments.', variant: 'success' });
|
||||
setEditing(false);
|
||||
} catch { toast({ title: 'Failed to save container config', variant: 'error', duration: 86_400_000 }); }
|
||||
|
||||
Reference in New Issue
Block a user