diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AppController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AppController.java index 3b3c778a..bd7d5b5b 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AppController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AppController.java @@ -1,8 +1,10 @@ package com.cameleer.server.app.controller; +import com.cameleer.server.app.web.EnvPath; import com.cameleer.server.core.runtime.App; import com.cameleer.server.core.runtime.AppService; import com.cameleer.server.core.runtime.AppVersion; +import com.cameleer.server.core.runtime.Environment; import com.cameleer.server.core.runtime.RuntimeType; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -27,13 +29,13 @@ import java.util.Map; import java.util.UUID; /** - * App CRUD and JAR upload endpoints. - * All app-scoped endpoints accept the app slug (not UUID) as path variable. - * Protected by {@code ROLE_OPERATOR} or {@code ROLE_ADMIN}. + * App CRUD and JAR upload. All routes env-scoped: the (env, appSlug) pair + * identifies a single app — the same app slug can legitimately exist in + * multiple environments with independent configuration and history. */ @RestController -@RequestMapping("/api/v1/apps") -@Tag(name = "App Management", description = "Application lifecycle and JAR uploads") +@RequestMapping("/api/v1/environments/{envSlug}/apps") +@Tag(name = "App Management", description = "Application lifecycle and JAR uploads (env-scoped)") @PreAuthorize("hasAnyRole('OPERATOR', 'ADMIN')") public class AppController { @@ -44,46 +46,45 @@ public class AppController { } @GetMapping - @Operation(summary = "List apps by environment") + @Operation(summary = "List apps in this environment") @ApiResponse(responseCode = "200", description = "App list returned") - public ResponseEntity> listApps(@RequestParam(required = false) UUID environmentId) { - if (environmentId != null) { - return ResponseEntity.ok(appService.listByEnvironment(environmentId)); - } - return ResponseEntity.ok(appService.listAll()); + public ResponseEntity> listApps(@EnvPath Environment env) { + return ResponseEntity.ok(appService.listByEnvironment(env.id())); } @GetMapping("/{appSlug}") - @Operation(summary = "Get app by slug") + @Operation(summary = "Get app by env + slug") @ApiResponse(responseCode = "200", description = "App found") - @ApiResponse(responseCode = "404", description = "App not found") - public ResponseEntity getApp(@PathVariable String appSlug) { + @ApiResponse(responseCode = "404", description = "App not found in this environment") + public ResponseEntity getApp(@EnvPath Environment env, @PathVariable String appSlug) { try { - return ResponseEntity.ok(appService.getBySlug(appSlug)); + return ResponseEntity.ok(appService.getByEnvironmentAndSlug(env.id(), appSlug)); } catch (IllegalArgumentException e) { return ResponseEntity.notFound().build(); } } @PostMapping - @Operation(summary = "Create a new app") + @Operation(summary = "Create a new app in this environment", + description = "Slug must match ^[a-z0-9][a-z0-9-]{0,63}$ and be unique within the environment. " + + "Slug is immutable after creation.") @ApiResponse(responseCode = "201", description = "App created") - @ApiResponse(responseCode = "400", description = "Slug already exists in environment") - public ResponseEntity createApp(@RequestBody CreateAppRequest request) { + @ApiResponse(responseCode = "400", description = "Invalid slug, or slug already exists in this environment") + public ResponseEntity createApp(@EnvPath Environment env, @RequestBody CreateAppRequest request) { try { - UUID id = appService.createApp(request.environmentId(), request.slug(), request.displayName()); + UUID id = appService.createApp(env.id(), request.slug(), request.displayName()); return ResponseEntity.status(201).body(appService.getById(id)); } catch (IllegalArgumentException e) { - return ResponseEntity.badRequest().build(); + return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); } } @GetMapping("/{appSlug}/versions") - @Operation(summary = "List app versions") + @Operation(summary = "List versions for this app") @ApiResponse(responseCode = "200", description = "Version list returned") - public ResponseEntity> listVersions(@PathVariable String appSlug) { + public ResponseEntity> listVersions(@EnvPath Environment env, @PathVariable String appSlug) { try { - App app = appService.getBySlug(appSlug); + App app = appService.getByEnvironmentAndSlug(env.id(), appSlug); return ResponseEntity.ok(appService.listVersions(app.id())); } catch (IllegalArgumentException e) { return ResponseEntity.notFound().build(); @@ -91,13 +92,14 @@ public class AppController { } @PostMapping(value = "/{appSlug}/versions", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @Operation(summary = "Upload a JAR for a new app version") + @Operation(summary = "Upload a JAR for a new version of this app") @ApiResponse(responseCode = "201", description = "JAR uploaded and version created") - @ApiResponse(responseCode = "404", description = "App not found") - public ResponseEntity uploadJar(@PathVariable String appSlug, + @ApiResponse(responseCode = "404", description = "App not found in this environment") + public ResponseEntity uploadJar(@EnvPath Environment env, + @PathVariable String appSlug, @RequestParam("file") MultipartFile file) throws IOException { try { - App app = appService.getBySlug(appSlug); + App app = appService.getByEnvironmentAndSlug(env.id(), appSlug); AppVersion version = appService.uploadJar(app.id(), file.getOriginalFilename(), file.getInputStream(), file.getSize()); return ResponseEntity.status(201).body(version); } catch (IllegalArgumentException e) { @@ -106,11 +108,11 @@ public class AppController { } @DeleteMapping("/{appSlug}") - @Operation(summary = "Delete an app") + @Operation(summary = "Delete this app") @ApiResponse(responseCode = "204", description = "App deleted") - public ResponseEntity deleteApp(@PathVariable String appSlug) { + public ResponseEntity deleteApp(@EnvPath Environment env, @PathVariable String appSlug) { try { - App app = appService.getBySlug(appSlug); + App app = appService.getByEnvironmentAndSlug(env.id(), appSlug); appService.deleteApp(app.id()); return ResponseEntity.noContent().build(); } catch (IllegalArgumentException e) { @@ -134,24 +136,25 @@ public class AppController { } @PutMapping("/{appSlug}/container-config") - @Operation(summary = "Update container config for an app") + @Operation(summary = "Update container config for this app") @ApiResponse(responseCode = "200", description = "Container config updated") @ApiResponse(responseCode = "400", description = "Invalid configuration") - @ApiResponse(responseCode = "404", description = "App not found") - public ResponseEntity updateContainerConfig(@PathVariable String appSlug, - @RequestBody Map containerConfig) { + @ApiResponse(responseCode = "404", description = "App not found in this environment") + public ResponseEntity updateContainerConfig(@EnvPath Environment env, + @PathVariable String appSlug, + @RequestBody Map containerConfig) { try { validateContainerConfig(containerConfig); - App app = appService.getBySlug(appSlug); + App app = appService.getByEnvironmentAndSlug(env.id(), appSlug); appService.updateContainerConfig(app.id(), containerConfig); return ResponseEntity.ok(appService.getById(app.id())); } catch (IllegalArgumentException e) { if (e.getMessage().contains("not found")) { return ResponseEntity.notFound().build(); } - return ResponseEntity.badRequest().build(); + return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); } } - public record CreateAppRequest(UUID environmentId, String slug, String displayName) {} + public record CreateAppRequest(String slug, String displayName) {} } diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/DeploymentController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/DeploymentController.java index 4f7a49ee..2a74d809 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/DeploymentController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/DeploymentController.java @@ -1,7 +1,14 @@ package com.cameleer.server.app.controller; import com.cameleer.server.app.runtime.DeploymentExecutor; -import com.cameleer.server.core.runtime.*; +import com.cameleer.server.app.web.EnvPath; +import com.cameleer.server.core.runtime.App; +import com.cameleer.server.core.runtime.AppService; +import com.cameleer.server.core.runtime.Deployment; +import com.cameleer.server.core.runtime.DeploymentService; +import com.cameleer.server.core.runtime.Environment; +import com.cameleer.server.core.runtime.EnvironmentService; +import com.cameleer.server.core.runtime.RuntimeOrchestrator; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; @@ -15,17 +22,18 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.List; +import java.util.Map; import java.util.UUID; import java.util.stream.Collectors; /** - * Deployment management: deploy, stop, promote, and view logs. - * All app-scoped endpoints accept the app slug (not UUID) as path variable. - * Protected by {@code ROLE_OPERATOR} or {@code ROLE_ADMIN}. + * Deployment management. Env + app come from the URL. Promote is inherently + * cross-env, so the target environment stays explicit in the request body + * (as a slug). */ @RestController -@RequestMapping("/api/v1/apps/{appSlug}/deployments") -@Tag(name = "Deployment Management", description = "Deploy, stop, restart, promote, and view logs") +@RequestMapping("/api/v1/environments/{envSlug}/apps/{appSlug}/deployments") +@Tag(name = "Deployment Management", description = "Deploy, stop, promote, and view logs") @PreAuthorize("hasAnyRole('OPERATOR', 'ADMIN')") public class DeploymentController { @@ -33,23 +41,26 @@ public class DeploymentController { private final DeploymentExecutor deploymentExecutor; private final RuntimeOrchestrator orchestrator; private final AppService appService; + private final EnvironmentService environmentService; public DeploymentController(DeploymentService deploymentService, DeploymentExecutor deploymentExecutor, RuntimeOrchestrator orchestrator, - AppService appService) { + AppService appService, + EnvironmentService environmentService) { this.deploymentService = deploymentService; this.deploymentExecutor = deploymentExecutor; this.orchestrator = orchestrator; this.appService = appService; + this.environmentService = environmentService; } @GetMapping - @Operation(summary = "List deployments for an app") + @Operation(summary = "List deployments for this app in this environment") @ApiResponse(responseCode = "200", description = "Deployment list returned") - public ResponseEntity> listDeployments(@PathVariable String appSlug) { + public ResponseEntity> listDeployments(@EnvPath Environment env, @PathVariable String appSlug) { try { - App app = appService.getBySlug(appSlug); + App app = appService.getByEnvironmentAndSlug(env.id(), appSlug); return ResponseEntity.ok(deploymentService.listByApp(app.id())); } catch (IllegalArgumentException e) { return ResponseEntity.notFound().build(); @@ -60,7 +71,9 @@ public class DeploymentController { @Operation(summary = "Get deployment by ID") @ApiResponse(responseCode = "200", description = "Deployment found") @ApiResponse(responseCode = "404", description = "Deployment not found") - public ResponseEntity getDeployment(@PathVariable String appSlug, @PathVariable UUID deploymentId) { + public ResponseEntity getDeployment(@EnvPath Environment env, + @PathVariable String appSlug, + @PathVariable UUID deploymentId) { try { return ResponseEntity.ok(deploymentService.getById(deploymentId)); } catch (IllegalArgumentException e) { @@ -69,12 +82,14 @@ public class DeploymentController { } @PostMapping - @Operation(summary = "Create and start a new deployment") + @Operation(summary = "Create and start a new deployment for this app in this environment") @ApiResponse(responseCode = "202", description = "Deployment accepted and starting") - public ResponseEntity deploy(@PathVariable String appSlug, @RequestBody DeployRequest request) { + public ResponseEntity deploy(@EnvPath Environment env, + @PathVariable String appSlug, + @RequestBody DeployRequest request) { try { - App app = appService.getBySlug(appSlug); - Deployment deployment = deploymentService.createDeployment(app.id(), request.appVersionId(), request.environmentId()); + App app = appService.getByEnvironmentAndSlug(env.id(), appSlug); + Deployment deployment = deploymentService.createDeployment(app.id(), request.appVersionId(), env.id()); deploymentExecutor.executeAsync(deployment); return ResponseEntity.accepted().body(deployment); } catch (IllegalArgumentException e) { @@ -86,7 +101,9 @@ public class DeploymentController { @Operation(summary = "Stop a running deployment") @ApiResponse(responseCode = "200", description = "Deployment stopped") @ApiResponse(responseCode = "404", description = "Deployment not found") - public ResponseEntity stop(@PathVariable String appSlug, @PathVariable UUID deploymentId) { + public ResponseEntity stop(@EnvPath Environment env, + @PathVariable String appSlug, + @PathVariable UUID deploymentId) { try { Deployment deployment = deploymentService.getById(deploymentId); deploymentExecutor.stopDeployment(deployment); @@ -97,27 +114,37 @@ public class DeploymentController { } @PostMapping("/{deploymentId}/promote") - @Operation(summary = "Promote deployment to a different environment") + @Operation(summary = "Promote this deployment to a different environment", + description = "Target environment is specified by slug in the request body. " + + "The same app slug must exist in the target environment (or be created separately first).") @ApiResponse(responseCode = "202", description = "Promotion accepted and starting") - @ApiResponse(responseCode = "404", description = "Deployment not found") - public ResponseEntity promote(@PathVariable String appSlug, @PathVariable UUID deploymentId, - @RequestBody PromoteRequest request) { + @ApiResponse(responseCode = "404", description = "Deployment or target environment not found") + public ResponseEntity promote(@EnvPath Environment env, + @PathVariable String appSlug, + @PathVariable UUID deploymentId, + @RequestBody PromoteRequest request) { try { - App app = appService.getBySlug(appSlug); + App sourceApp = appService.getByEnvironmentAndSlug(env.id(), appSlug); Deployment source = deploymentService.getById(deploymentId); - Deployment promoted = deploymentService.promote(app.id(), source.appVersionId(), request.targetEnvironmentId()); + Environment targetEnv = environmentService.getBySlug(request.targetEnvironment()); + // Target must also have the app with the same slug + App targetApp = appService.getByEnvironmentAndSlug(targetEnv.id(), appSlug); + Deployment promoted = deploymentService.promote(targetApp.id(), source.appVersionId(), targetEnv.id()); deploymentExecutor.executeAsync(promoted); return ResponseEntity.accepted().body(promoted); } catch (IllegalArgumentException e) { - return ResponseEntity.notFound().build(); + return ResponseEntity.status(org.springframework.http.HttpStatus.NOT_FOUND) + .body(Map.of("error", e.getMessage())); } } @GetMapping("/{deploymentId}/logs") - @Operation(summary = "Get container logs for a deployment") + @Operation(summary = "Get container logs for this deployment") @ApiResponse(responseCode = "200", description = "Logs returned") @ApiResponse(responseCode = "404", description = "Deployment not found or no container") - public ResponseEntity> getLogs(@PathVariable String appSlug, @PathVariable UUID deploymentId) { + public ResponseEntity> getLogs(@EnvPath Environment env, + @PathVariable String appSlug, + @PathVariable UUID deploymentId) { try { Deployment deployment = deploymentService.getById(deploymentId); if (deployment.containerId() == null) { @@ -130,6 +157,6 @@ public class DeploymentController { } } - public record DeployRequest(UUID appVersionId, UUID environmentId) {} - public record PromoteRequest(UUID targetEnvironmentId) {} + public record DeployRequest(UUID appVersionId) {} + public record PromoteRequest(String targetEnvironment) {} } diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppService.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppService.java index 9c1e861f..9a4c5a9b 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppService.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppService.java @@ -12,10 +12,14 @@ import java.util.HexFormat; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.regex.Pattern; public class AppService { private static final Logger log = LoggerFactory.getLogger(AppService.class); + /** Slug rules mirror {@link EnvironmentService#SLUG_PATTERN}: lowercase letters, digits, hyphens; 1–64 chars; starts with alnum. Immutable after creation. */ + private static final Pattern SLUG_PATTERN = Pattern.compile("^[a-z0-9][a-z0-9-]{0,63}$"); + private final AppRepository appRepo; private final AppVersionRepository versionRepo; private final String jarStoragePath; @@ -30,6 +34,10 @@ public class AppService { public List listByEnvironment(UUID environmentId) { return appRepo.findByEnvironmentId(environmentId); } public App getById(UUID id) { return appRepo.findById(id).orElseThrow(() -> new IllegalArgumentException("App not found: " + id)); } public App getBySlug(String slug) { return appRepo.findBySlug(slug).orElseThrow(() -> new IllegalArgumentException("App not found: " + slug)); } + public App getByEnvironmentAndSlug(UUID environmentId, String slug) { + return appRepo.findByEnvironmentIdAndSlug(environmentId, slug) + .orElseThrow(() -> new IllegalArgumentException("App not found in environment: " + slug)); + } public List listVersions(UUID appId) { return versionRepo.findByAppId(appId); } public AppVersion getVersion(UUID versionId) { @@ -43,6 +51,10 @@ public class AppService { } public UUID createApp(UUID environmentId, String slug, String displayName) { + if (slug == null || !SLUG_PATTERN.matcher(slug).matches()) { + throw new IllegalArgumentException( + "Invalid app slug: must match ^[a-z0-9][a-z0-9-]{0,63}$ (lowercase letters, digits, hyphens)"); + } if (appRepo.findByEnvironmentIdAndSlug(environmentId, slug).isPresent()) { throw new IllegalArgumentException("App with slug '" + slug + "' already exists in this environment"); } diff --git a/ui/src/api/queries/admin/apps.ts b/ui/src/api/queries/admin/apps.ts index c868c784..ede1e8bf 100644 --- a/ui/src/api/queries/admin/apps.ts +++ b/ui/src/api/queries/admin/apps.ts @@ -43,9 +43,14 @@ export interface Deployment { createdAt: string; } -async function appFetch(path: string, options?: RequestInit): Promise { +/** + * 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(path: string, options?: RequestInit): Promise { 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(path: string, options?: RequestInit): Promise { return JSON.parse(text); } -// --- Apps --- - -export function useAllApps() { - return useQuery({ - queryKey: ['apps', 'all'], - queryFn: () => appFetch(''), - }); +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(`?environmentId=${environmentId}`), - enabled: !!environmentId, + queryKey: ['apps', envSlug], + queryFn: () => apiFetch(envBase(envSlug!)), + enabled: !!envSlug, }); } export function useCreateApp() { const qc = useQueryClient(); return useMutation({ - mutationFn: (req: { environmentId: string; slug: string; displayName: string }) => - appFetch('', { method: 'POST', body: JSON.stringify(req) }), + mutationFn: ({ envSlug, slug, displayName }: { envSlug: string; slug: string; displayName: string }) => + apiFetch(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(`/${slug}`, { method: 'DELETE' }), + mutationFn: ({ envSlug, appSlug }: { envSlug: string; appSlug: string }) => + apiFetch(`${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 }) => - appFetch(`/${appId}/container-config`, { method: 'PUT', body: JSON.stringify(config) }), + mutationFn: ({ envSlug, appSlug, config }: { envSlug: string; appSlug: string; config: Record }) => + apiFetch(`${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(`/${appId}/versions`), - enabled: !!appId, + queryKey: ['apps', envSlug, appSlug, 'versions'], + queryFn: () => apiFetch(`${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; }, - 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(`/${appId}/deployments`), - enabled: !!appId, + queryKey: ['apps', envSlug, appSlug, 'deployments'], + queryFn: () => apiFetch(`${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(`/${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( + `${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(`/${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( + `${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( + `${envBase(envSlug)}/${encodeURIComponent(appSlug)}/deployments/${deploymentId}/promote`, + { method: 'POST', body: JSON.stringify({ targetEnvironment }) }, + ), + onSuccess: () => qc.invalidateQueries({ queryKey: ['apps'] }), }); } diff --git a/ui/src/pages/AppsTab/AppsTab.tsx b/ui/src/pages/AppsTab/AppsTab.tsx index 46dd3278..963ed156 100644 --- a/ui/src/pages/AppsTab/AppsTab.tsx +++ b/ui/src/pages/AppsTab/AppsTab.tsx @@ -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 }); }