feat: add API client with auth middleware and React Query hooks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-04 21:50:19 +02:00
parent e33818cc74
commit d572926010
2 changed files with 231 additions and 0 deletions

46
ui/src/api/client.ts Normal file
View File

@@ -0,0 +1,46 @@
import { useAuthStore } from '../auth/auth-store';
const API_BASE = '/api';
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = useAuthStore.getState().accessToken;
const headers: Record<string, string> = {
...(options.headers as Record<string, string> || {}),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
if (!headers['Content-Type'] && !(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
}
const response = await fetch(`${API_BASE}${path}`, { ...options, headers });
if (response.status === 401) {
useAuthStore.getState().logout();
window.location.href = '/login';
throw new Error('Unauthorized');
}
if (!response.ok) {
const text = await response.text();
throw new Error(`API error ${response.status}: ${text}`);
}
if (response.status === 204) return undefined as T;
return response.json();
}
export const api = {
get: <T>(path: string) => apiFetch<T>(path),
post: <T>(path: string, body?: unknown) =>
apiFetch<T>(path, {
method: 'POST',
body: body instanceof FormData ? body : JSON.stringify(body),
}),
patch: <T>(path: string, body: unknown) =>
apiFetch<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
put: <T>(path: string, body: FormData) =>
apiFetch<T>(path, { method: 'PUT', body }),
delete: <T>(path: string) => apiFetch<T>(path, { method: 'DELETE' }),
};

185
ui/src/api/hooks.ts Normal file
View File

@@ -0,0 +1,185 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from './client';
import type {
TenantResponse, EnvironmentResponse, AppResponse,
DeploymentResponse, LicenseResponse, AgentStatusResponse,
ObservabilityStatusResponse, LogEntry,
} from '../types/api';
// Tenant
export function useTenant(tenantId: string) {
return useQuery({
queryKey: ['tenant', tenantId],
queryFn: () => api.get<TenantResponse>(`/tenants/${tenantId}`),
enabled: !!tenantId,
});
}
// License
export function useLicense(tenantId: string) {
return useQuery({
queryKey: ['license', tenantId],
queryFn: () => api.get<LicenseResponse>(`/tenants/${tenantId}/license`),
enabled: !!tenantId,
});
}
// Environments
export function useEnvironments(tenantId: string) {
return useQuery({
queryKey: ['environments', tenantId],
queryFn: () => api.get<EnvironmentResponse[]>(`/tenants/${tenantId}/environments`),
enabled: !!tenantId,
});
}
export function useCreateEnvironment(tenantId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { slug: string; displayName: string }) =>
api.post<EnvironmentResponse>(`/tenants/${tenantId}/environments`, data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['environments', tenantId] }),
});
}
export function useUpdateEnvironment(tenantId: string, envId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { displayName: string }) =>
api.patch<EnvironmentResponse>(`/tenants/${tenantId}/environments/${envId}`, data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['environments', tenantId] }),
});
}
export function useDeleteEnvironment(tenantId: string, envId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: () => api.delete(`/tenants/${tenantId}/environments/${envId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['environments', tenantId] }),
});
}
// Apps
export function useApps(environmentId: string) {
return useQuery({
queryKey: ['apps', environmentId],
queryFn: () => api.get<AppResponse[]>(`/environments/${environmentId}/apps`),
enabled: !!environmentId,
});
}
export function useApp(environmentId: string, appId: string) {
return useQuery({
queryKey: ['app', appId],
queryFn: () => api.get<AppResponse>(`/environments/${environmentId}/apps/${appId}`),
enabled: !!appId,
});
}
export function useCreateApp(environmentId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (formData: FormData) =>
api.post<AppResponse>(`/environments/${environmentId}/apps`, formData),
onSuccess: () => qc.invalidateQueries({ queryKey: ['apps', environmentId] }),
});
}
export function useDeleteApp(environmentId: string, appId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: () => api.delete(`/environments/${environmentId}/apps/${appId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['apps', environmentId] }),
});
}
export function useUpdateRouting(environmentId: string, appId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { exposedPort: number | null }) =>
api.patch<AppResponse>(`/environments/${environmentId}/apps/${appId}/routing`, data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['app', appId] }),
});
}
// Deployments
export function useDeploy(appId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: () => api.post<DeploymentResponse>(`/apps/${appId}/deploy`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['deployments', appId] }),
});
}
export function useDeployments(appId: string) {
return useQuery({
queryKey: ['deployments', appId],
queryFn: () => api.get<DeploymentResponse[]>(`/apps/${appId}/deployments`),
enabled: !!appId,
});
}
export function useDeployment(appId: string, deploymentId: string) {
return useQuery({
queryKey: ['deployment', deploymentId],
queryFn: () => api.get<DeploymentResponse>(`/apps/${appId}/deployments/${deploymentId}`),
enabled: !!deploymentId,
refetchInterval: (query) => {
const status = query.state.data?.observedStatus;
return status === 'BUILDING' || status === 'STARTING' ? 3000 : false;
},
});
}
export function useStop(appId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: () => api.post<DeploymentResponse>(`/apps/${appId}/stop`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['deployments', appId] });
qc.invalidateQueries({ queryKey: ['app'] });
},
});
}
export function useRestart(appId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: () => api.post<DeploymentResponse>(`/apps/${appId}/restart`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['deployments', appId] }),
});
}
// Observability
export function useAgentStatus(appId: string) {
return useQuery({
queryKey: ['agent-status', appId],
queryFn: () => api.get<AgentStatusResponse>(`/apps/${appId}/agent-status`),
enabled: !!appId,
refetchInterval: 15_000,
});
}
export function useObservabilityStatus(appId: string) {
return useQuery({
queryKey: ['observability-status', appId],
queryFn: () => api.get<ObservabilityStatusResponse>(`/apps/${appId}/observability-status`),
enabled: !!appId,
refetchInterval: 30_000,
});
}
export function useLogs(appId: string, params?: { since?: string; limit?: number; stream?: string }) {
return useQuery({
queryKey: ['logs', appId, params],
queryFn: () => {
const qs = new URLSearchParams();
if (params?.since) qs.set('since', params.since);
if (params?.limit) qs.set('limit', String(params.limit));
if (params?.stream) qs.set('stream', params.stream);
const query = qs.toString();
return api.get<LogEntry[]>(`/apps/${appId}/logs${query ? `?${query}` : ''}`);
},
enabled: !!appId,
});
}