chore: initial commit for phase05
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
const API_BASE = '/api';
|
||||
|
||||
interface RequestOptions extends RequestInit {
|
||||
params?: Record<string, string>;
|
||||
}
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public data: unknown,
|
||||
) {
|
||||
super(`API Error ${status}`);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { params, ...fetchOptions } = options;
|
||||
|
||||
let url = `${API_BASE}${path}`;
|
||||
if (params) {
|
||||
const searchParams = new URLSearchParams(params);
|
||||
url += `?${searchParams.toString()}`;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('access_token');
|
||||
const headers: Record<string, string> = {
|
||||
...(fetchOptions.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
if (fetchOptions.body && typeof fetchOptions.body === 'string') {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const res = await fetch(url, { ...fetchOptions, headers });
|
||||
|
||||
if (res.status === 401) {
|
||||
// Try refresh
|
||||
const refreshed = await refreshToken();
|
||||
if (refreshed) {
|
||||
headers['Authorization'] = `Bearer ${localStorage.getItem('access_token')}`;
|
||||
const retry = await fetch(url, { ...fetchOptions, headers });
|
||||
if (!retry.ok) throw new ApiError(retry.status, await retry.json().catch(() => null));
|
||||
if (retry.status === 204) return undefined as T;
|
||||
return retry.json();
|
||||
}
|
||||
localStorage.removeItem('access_token');
|
||||
window.location.href = '/login';
|
||||
throw new ApiError(401, null);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new ApiError(res.status, await res.json().catch(() => null));
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function refreshToken(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const data = await res.json();
|
||||
localStorage.setItem('access_token', data.accessToken);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string, params?: Record<string, string>) =>
|
||||
request<T>(path, { params }),
|
||||
|
||||
post: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, {
|
||||
method: 'POST',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}),
|
||||
|
||||
patch: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, {
|
||||
method: 'PATCH',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}),
|
||||
|
||||
delete: <T>(path: string) =>
|
||||
request<T>(path, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
export { ApiError };
|
||||
@@ -0,0 +1,30 @@
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
|
||||
let socket: Socket | null = null;
|
||||
|
||||
export function getSocket(): Socket {
|
||||
if (!socket) {
|
||||
socket = io('/', {
|
||||
path: '/socket.io',
|
||||
auth: {
|
||||
token: localStorage.getItem('access_token'),
|
||||
},
|
||||
autoConnect: false,
|
||||
});
|
||||
}
|
||||
return socket;
|
||||
}
|
||||
|
||||
export function connectSocket() {
|
||||
const s = getSocket();
|
||||
if (!s.connected) {
|
||||
s.auth = { token: localStorage.getItem('access_token') };
|
||||
s.connect();
|
||||
}
|
||||
}
|
||||
|
||||
export function disconnectSocket() {
|
||||
if (socket?.connected) {
|
||||
socket.disconnect();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
export function formatBytes(bytes: number, decimals = 1): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export function formatUptime(seconds: number): string {
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
if (days > 0) return `${days}d ${hours}h`;
|
||||
if (hours > 0) return `${hours}h ${mins}m`;
|
||||
return `${mins}m`;
|
||||
}
|
||||
|
||||
export function statusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return 'text-green-500';
|
||||
case 'stopped':
|
||||
return 'text-red-500';
|
||||
case 'starting':
|
||||
case 'stopping':
|
||||
case 'installing':
|
||||
return 'text-yellow-500';
|
||||
case 'suspended':
|
||||
return 'text-orange-500';
|
||||
case 'error':
|
||||
return 'text-destructive';
|
||||
default:
|
||||
return 'text-muted-foreground';
|
||||
}
|
||||
}
|
||||
|
||||
export function statusBadgeVariant(
|
||||
status: string,
|
||||
): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return 'default';
|
||||
case 'stopped':
|
||||
return 'secondary';
|
||||
case 'error':
|
||||
case 'suspended':
|
||||
return 'destructive';
|
||||
default:
|
||||
return 'outline';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user