Files
garage-ui/frontend/src/lib/api.ts
T

411 lines
12 KiB
TypeScript

import axios from 'axios';
import {toast} from 'sonner';
import type {
AccessKey,
Bucket,
BucketDetails,
ClusterHealth,
ClusterStatistics,
ClusterStatus,
GarageMetrics,
MultiNodeResponse,
MultiNodeStatisticsResponse,
ObjectListResponse,
ObjectMetadata,
S3Object,
StorageMetrics,
} from '@/types';
import type { AuthUser } from '@/types/auth';
// Helper function to encode object keys for URLs
// Encodes the entire key including slashes to ensure proper handling of special characters
const encodeObjectKey = (key: string): string => {
return encodeURIComponent(key);
};
const api = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
});
// Separate axios instance for auth endpoints (which are not under /api)
const authApiClient = axios.create({
baseURL: '/auth',
headers: {
'Content-Type': 'application/json',
},
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem('auth-token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
authApiClient.interceptors.request.use((config) => {
const token = localStorage.getItem('auth-token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
api.interceptors.response.use(
(response) => {
// If response has success=false in data, treat it as an error
if (response.data && response.data.success === false && response.data.error) {
const error = response.data.error;
const errorMessage = error.message || 'An error occurred';
const errorCode = error.code || 'UNKNOWN_ERROR';
// Display toast with error details
toast.error(errorMessage, {
description: `Error Code: ${errorCode}`,
});
// Reject the promise so it's treated as an error
return Promise.reject(new Error(errorMessage));
}
return response;
},
(error) => {
// Handle 401 Unauthorized - redirect to login
if (error.response?.status === 401) {
// Clear auth token
localStorage.removeItem('auth-token');
// Only redirect if not already on login page
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
return Promise.reject(error);
}
// Handle axios errors
if (error.response) {
// Server responded with error status
const data = error.response.data;
if (data && data.error) {
const errorMessage = data.error.message || 'An error occurred';
const errorCode = data.error.code || 'UNKNOWN_ERROR';
toast.error(errorMessage, {
description: `Error Code: ${errorCode}`,
});
} else {
// Generic HTTP error
toast.error(`Request failed: ${error.response.status}`, {
description: error.response.statusText || 'Unknown error',
});
}
} else if (error.request) {
// Request made but no response received
toast.error('Network Error', {
description: 'Unable to reach the server. Please check your connection.',
});
} else {
// Something else happened
toast.error('Error', {
description: error.message || 'An unexpected error occurred',
});
}
return Promise.reject(error);
}
);
// Auth API
export const authApi = {
getConfig: async () => {
const response = await authApiClient.get<{
admin: { enabled: boolean };
oidc: { enabled: boolean; provider?: string };
}>('/config');
return response;
},
loginAdmin: async (username: string, password: string) => {
const response = await authApiClient.post<{ success: boolean; token: string; user: AuthUser }>('/login', {
username,
password,
});
return response;
},
me: async () => {
const response = await authApiClient.get<{ success: boolean; user: AuthUser }>('/me');
return response;
},
logoutAdmin: async () => {
// For admin, just clear local storage (no server logout needed)
return Promise.resolve();
},
logoutOIDC: async () => {
const response = await authApiClient.post('/oidc/logout');
return response;
},
loginOIDC: () => {
window.location.href = '/auth/oidc/login';
},
};
// Health API
export const healthApi = {
getVersion: async (): Promise<string> => {
const response = await api.get('/v1/health');
return response.data.data.version as string;
},
};
// Bucket API
export const bucketsApi = {
list: async (): Promise<Bucket[]> => {
const response = await api.get('/v1/buckets');
return response.data.data.buckets || [];
},
get: async (name: string): Promise<BucketDetails> => {
const response = await api.get(`/v1/buckets/${name}`);
return response.data.data;
},
create: async (bucketName: string, bucketRegion?: string): Promise<void> => {
await api.post('/v1/buckets', { name: bucketName, region: bucketRegion });
},
delete: async (name: string): Promise<void> => {
await api.delete(`/v1/buckets/${name}`);
},
grantPermission: async (
bucketName: string,
accessKeyId: string,
permissions: { read: boolean; write: boolean; owner: boolean }
): Promise<void> => {
await api.post(`/v1/buckets/${bucketName}/permissions`, {
accessKeyId,
permissions,
});
},
updateSettings: async (name: string, settings: Partial<BucketDetails>): Promise<void> => {
await api.patch(`/v1/buckets/${name}/settings`, settings);
},
};
// Objects API
export const objectsApi = {
list: async (bucket: string, prefix?: string, maxKeys?: number, continuationToken?: string): Promise<ObjectListResponse> => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const params: any = {};
if (prefix) params.prefix = prefix;
if (maxKeys) params.max_keys = maxKeys;
if (continuationToken) params.continuation_token = continuationToken;
const response = await api.get(`/v1/buckets/${bucket}/objects`, { params });
const data = response.data.data;
// Combine objects and prefixes (folders)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const objects: S3Object[] = data.objects?.map((obj: any) => ({
key: obj.key,
size: obj.size,
lastModified: obj.last_modified,
etag: obj.etag,
contentType: obj.content_type,
storageClass: obj.storage_class,
isFolder: false,
})) || [];
const folders: S3Object[] = data.prefixes?.map((prefix: string) => ({
key: prefix,
size: 0,
lastModified: null,
isFolder: true,
})) || [];
return {
bucket: data.bucket,
objects: [...folders, ...objects],
prefixes: data.prefixes || [],
count: data.count,
isTruncated: data.is_truncated || false,
nextContinuationToken: data.next_continuation_token,
};
},
get: async (bucket: string, key: string): Promise<Blob> => {
const response = await api.get(`/v1/buckets/${bucket}/objects/${encodeObjectKey(key)}`, {
responseType: 'blob'
});
return response.data;
},
getMetadata: async (bucket: string, key: string): Promise<ObjectMetadata> => {
const response = await api.get(`/v1/buckets/${bucket}/objects/${encodeObjectKey(key)}/metadata`);
const data = response.data.data;
return {
key: data.key,
size: data.size,
lastModified: data.last_modified,
contentType: data.content_type,
etag: data.etag,
storageClass: data.storage_class,
metadata: data.metadata,
};
},
upload: async (bucket: string, key: string, file: File, onProgress?: (progress: number) => void): Promise<void> => {
const formData = new FormData();
formData.append('file', file);
formData.append('key', key);
await api.post(`/v1/buckets/${bucket}/objects`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
onProgress(progress);
}
},
});
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
uploadMultiple: async (bucket: string, files: File[]): Promise<any> => {
const formData = new FormData();
files.forEach(file => {
formData.append('files', file);
});
const response = await api.post(`/v1/buckets/${bucket}/objects/upload-multiple`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data.data;
},
delete: async (bucket: string, key: string): Promise<void> => {
await api.delete(`/v1/buckets/${bucket}/objects/${encodeObjectKey(key)}`);
},
deleteMultiple: async (bucket: string, keys: string[], prefix?: string): Promise<void> => {
const payload = { keys, ...(prefix && { prefix }) };
await api.post(`/v1/buckets/${bucket}/objects/delete-multiple`, payload);
},
getPresignedUrl: async (bucket: string, key: string, expiresIn: number = 3600): Promise<string> => {
const response = await api.get(`/v1/buckets/${bucket}/objects/${encodeObjectKey(key)}/presign`, {
params: { expires_in: expiresIn }
});
return response.data.data.url;
},
};
// Access Control API (Users/Keys)
export const accessApi = {
listKeys: async (): Promise<AccessKey[]> => {
const response = await api.get('/v1/users');
return response.data.data.users || [];
},
getKey: async (accessKey: string): Promise<AccessKey> => {
const response = await api.get(`/v1/users/${accessKey}`);
return response.data.data;
},
getSecretKey: async (accessKey: string): Promise<string> => {
const response = await api.get(`/v1/users/${accessKey}/secret`);
return response.data.data.secretKey;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createKey: async (name: string, permissions?: any[]): Promise<AccessKey> => {
const response = await api.post('/v1/users', { name, permissions });
return response.data.data;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateKey: async (accessKey: string, updates: any): Promise<void> => {
await api.patch(`/v1/users/${accessKey}`, updates);
},
deleteKey: async (accessKey: string): Promise<void> => {
await api.delete(`/v1/users/${accessKey}`);
},
};
// Analytics API
export const analyticsApi = {
getMetrics: async (): Promise<StorageMetrics> => {
const response = await api.get('/v1/monitoring/dashboard');
return response.data.data;
},
};
// Garage Cluster & Monitoring API
export const garageApi = {
getClusterHealth: async (): Promise<ClusterHealth> => {
const response = await api.get('/v1/cluster/health');
return response.data.data;
},
getClusterStatus: async (): Promise<ClusterStatus> => {
const response = await api.get('/v1/cluster/status');
return response.data.data;
},
getClusterStatistics: async (): Promise<ClusterStatistics> => {
const response = await api.get('/v1/cluster/statistics');
return response.data.data;
},
getNodeInfo: async (nodeId: string = 'self'): Promise<MultiNodeResponse> => {
const response = await api.get(`/v1/cluster/nodes/${nodeId}`);
return response.data.data;
},
getNodeStatistics: async (nodeId: string): Promise<MultiNodeStatisticsResponse> => {
const response = await api.get(`/v1/cluster/nodes/${nodeId}/statistics`);
return response.data.data;
},
getFullMetrics: async (): Promise<GarageMetrics> => {
// Fetch all cluster-related metrics
const [health, statistics, storageMetrics] = await Promise.all([
garageApi.getClusterHealth(),
garageApi.getClusterStatistics(),
analyticsApi.getMetrics(),
]);
return {
...storageMetrics,
clusterHealth: health,
clusterStatistics: statistics,
};
},
};
// Monitoring API
export const monitoringApi = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getMetrics: async (): Promise<any> => {
const response = await api.get('/v1/monitoring/metrics');
return response.data.data;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
checkAdminHealth: async (): Promise<any> => {
const response = await api.get('/v1/monitoring/admin-health');
return response.data.data;
},
};
export default api;