feat: add Applications admin page with version upload and deployments
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m12s
CI / docker (push) Successful in 1m3s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 41s

- SplitPane layout with environment selector, app list, and detail pane
- Create/delete apps with slug uniqueness validation
- Upload JAR versions with file size display
- Deploy versions and stop running deployments with status badges
- Deployment list auto-refreshes every 5s for live status updates
- Registered at /admin/apps with sidebar entry

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-08 12:24:22 +02:00
parent 448a63adc9
commit e04dca55aa
4 changed files with 559 additions and 1 deletions

View File

@@ -0,0 +1,150 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { config } from '../../../config';
import { useAuthStore } from '../../../auth/auth-store';
export interface App {
id: string;
environmentId: string;
slug: string;
displayName: string;
createdAt: string;
}
export interface AppVersion {
id: string;
appId: string;
version: number;
jarPath: string;
jarChecksum: string;
jarFilename: string;
jarSizeBytes: number;
uploadedAt: string;
}
export interface Deployment {
id: string;
appId: string;
appVersionId: string;
environmentId: string;
status: 'STARTING' | 'RUNNING' | 'FAILED' | 'STOPPED';
containerId: string | null;
containerName: string | null;
errorMessage: string | null;
deployedAt: string | null;
stoppedAt: string | null;
createdAt: string;
}
async function appFetch<T>(path: string, options?: RequestInit): Promise<T> {
const token = useAuthStore.getState().accessToken;
const res = await fetch(`${config.apiBaseUrl}/apps${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
'X-Cameleer-Protocol-Version': '1',
...options?.headers,
},
});
if (res.status === 401 || res.status === 403) {
useAuthStore.getState().logout();
throw new Error('Unauthorized');
}
if (!res.ok) throw new Error(`API error: ${res.status}`);
if (res.status === 204) return undefined as T;
const text = await res.text();
if (!text) return undefined as T;
return JSON.parse(text);
}
// --- Apps ---
export function useApps(environmentId: string | undefined) {
return useQuery({
queryKey: ['apps', environmentId],
queryFn: () => appFetch<App[]>(`?environmentId=${environmentId}`),
enabled: !!environmentId,
});
}
export function useCreateApp() {
const qc = useQueryClient();
return useMutation({
mutationFn: (req: { environmentId: string; slug: string; displayName: string }) =>
appFetch<App>('', { method: 'POST', body: JSON.stringify(req) }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['apps'] }),
});
}
export function useDeleteApp() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
appFetch<void>(`/${id}`, { method: 'DELETE' }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['apps'] }),
});
}
// --- Versions ---
export function useAppVersions(appId: string | undefined) {
return useQuery({
queryKey: ['apps', appId, 'versions'],
queryFn: () => appFetch<AppVersion[]>(`/${appId}/versions`),
enabled: !!appId,
});
}
export function useUploadJar() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ appId, file }: { appId: 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`, {
method: 'POST',
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
'X-Cameleer-Protocol-Version': '1',
},
body: form,
});
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'] }),
});
}
// --- Deployments ---
export function useDeployments(appId: string | undefined) {
return useQuery({
queryKey: ['apps', appId, 'deployments'],
queryFn: () => appFetch<Deployment[]>(`/${appId}/deployments`),
enabled: !!appId,
refetchInterval: 5000,
});
}
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'] }),
});
}
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'] }),
});
}