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:
@@ -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<List<App>> listApps(@RequestParam(required = false) UUID environmentId) {
|
||||
if (environmentId != null) {
|
||||
return ResponseEntity.ok(appService.listByEnvironment(environmentId));
|
||||
}
|
||||
return ResponseEntity.ok(appService.listAll());
|
||||
public ResponseEntity<List<App>> 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<App> getApp(@PathVariable String appSlug) {
|
||||
@ApiResponse(responseCode = "404", description = "App not found in this environment")
|
||||
public ResponseEntity<App> 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<App> 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<List<AppVersion>> listVersions(@PathVariable String appSlug) {
|
||||
public ResponseEntity<List<AppVersion>> 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<AppVersion> uploadJar(@PathVariable String appSlug,
|
||||
@ApiResponse(responseCode = "404", description = "App not found in this environment")
|
||||
public ResponseEntity<AppVersion> 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<Void> deleteApp(@PathVariable String appSlug) {
|
||||
public ResponseEntity<Void> 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<App> updateContainerConfig(@PathVariable String appSlug,
|
||||
@RequestBody Map<String, Object> containerConfig) {
|
||||
@ApiResponse(responseCode = "404", description = "App not found in this environment")
|
||||
public ResponseEntity<?> updateContainerConfig(@EnvPath Environment env,
|
||||
@PathVariable String appSlug,
|
||||
@RequestBody Map<String, Object> 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) {}
|
||||
}
|
||||
|
||||
@@ -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<List<Deployment>> listDeployments(@PathVariable String appSlug) {
|
||||
public ResponseEntity<List<Deployment>> 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<Deployment> getDeployment(@PathVariable String appSlug, @PathVariable UUID deploymentId) {
|
||||
public ResponseEntity<Deployment> 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<Deployment> deploy(@PathVariable String appSlug, @RequestBody DeployRequest request) {
|
||||
public ResponseEntity<Deployment> 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<Deployment> stop(@PathVariable String appSlug, @PathVariable UUID deploymentId) {
|
||||
public ResponseEntity<Deployment> 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<Deployment> 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<List<String>> getLogs(@PathVariable String appSlug, @PathVariable UUID deploymentId) {
|
||||
public ResponseEntity<List<String>> 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) {}
|
||||
}
|
||||
|
||||
@@ -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<App> 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<AppVersion> 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");
|
||||
}
|
||||
|
||||
@@ -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