chore: initial commit for phase05

This commit is contained in:
hibna
2026-02-21 16:59:21 +03:00
parent 218452706c
commit 0941a9ba46
43 changed files with 4431 additions and 17 deletions
+99
View File
@@ -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 };
+30
View File
@@ -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();
}
}
+51
View File
@@ -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';
}
}