diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts new file mode 100644 index 0000000..c7b9514 --- /dev/null +++ b/ui/src/api/client.ts @@ -0,0 +1,46 @@ +import { useAuthStore } from '../auth/auth-store'; + +const API_BASE = '/api'; + +async function apiFetch(path: string, options: RequestInit = {}): Promise { + const token = useAuthStore.getState().accessToken; + const headers: Record = { + ...(options.headers as Record || {}), + }; + 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: (path: string) => apiFetch(path), + post: (path: string, body?: unknown) => + apiFetch(path, { + method: 'POST', + body: body instanceof FormData ? body : JSON.stringify(body), + }), + patch: (path: string, body: unknown) => + apiFetch(path, { method: 'PATCH', body: JSON.stringify(body) }), + put: (path: string, body: FormData) => + apiFetch(path, { method: 'PUT', body }), + delete: (path: string) => apiFetch(path, { method: 'DELETE' }), +}; diff --git a/ui/src/api/hooks.ts b/ui/src/api/hooks.ts new file mode 100644 index 0000000..01ad1a8 --- /dev/null +++ b/ui/src/api/hooks.ts @@ -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(`/tenants/${tenantId}`), + enabled: !!tenantId, + }); +} + +// License +export function useLicense(tenantId: string) { + return useQuery({ + queryKey: ['license', tenantId], + queryFn: () => api.get(`/tenants/${tenantId}/license`), + enabled: !!tenantId, + }); +} + +// Environments +export function useEnvironments(tenantId: string) { + return useQuery({ + queryKey: ['environments', tenantId], + queryFn: () => api.get(`/tenants/${tenantId}/environments`), + enabled: !!tenantId, + }); +} + +export function useCreateEnvironment(tenantId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (data: { slug: string; displayName: string }) => + api.post(`/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(`/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(`/environments/${environmentId}/apps`), + enabled: !!environmentId, + }); +} + +export function useApp(environmentId: string, appId: string) { + return useQuery({ + queryKey: ['app', appId], + queryFn: () => api.get(`/environments/${environmentId}/apps/${appId}`), + enabled: !!appId, + }); +} + +export function useCreateApp(environmentId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (formData: FormData) => + api.post(`/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(`/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(`/apps/${appId}/deploy`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['deployments', appId] }), + }); +} + +export function useDeployments(appId: string) { + return useQuery({ + queryKey: ['deployments', appId], + queryFn: () => api.get(`/apps/${appId}/deployments`), + enabled: !!appId, + }); +} + +export function useDeployment(appId: string, deploymentId: string) { + return useQuery({ + queryKey: ['deployment', deploymentId], + queryFn: () => api.get(`/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(`/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(`/apps/${appId}/restart`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['deployments', appId] }), + }); +} + +// Observability +export function useAgentStatus(appId: string) { + return useQuery({ + queryKey: ['agent-status', appId], + queryFn: () => api.get(`/apps/${appId}/agent-status`), + enabled: !!appId, + refetchInterval: 15_000, + }); +} + +export function useObservabilityStatus(appId: string) { + return useQuery({ + queryKey: ['observability-status', appId], + queryFn: () => api.get(`/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(`/apps/${appId}/logs${query ? `?${query}` : ''}`); + }, + enabled: !!appId, + }); +}