forked from wrenn/wrenn
v0.1.0 (#17)
This commit is contained in:
40
frontend/src/lib/api/admin-capsules.ts
Normal file
40
frontend/src/lib/api/admin-capsules.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { apiFetch, type ApiResult } from '$lib/api/client';
|
||||
import type { Capsule, CreateCapsuleParams, Snapshot } from '$lib/api/capsules';
|
||||
import type { AdminTemplate } from '$lib/api/builds';
|
||||
|
||||
export async function listAdminCapsules(): Promise<ApiResult<Capsule[]>> {
|
||||
return apiFetch('GET', '/api/v1/admin/capsules');
|
||||
}
|
||||
|
||||
export async function getAdminCapsule(id: string): Promise<ApiResult<Capsule>> {
|
||||
return apiFetch('GET', `/api/v1/admin/capsules/${id}`);
|
||||
}
|
||||
|
||||
export async function createAdminCapsule(params: CreateCapsuleParams): Promise<ApiResult<Capsule>> {
|
||||
return apiFetch('POST', '/api/v1/admin/capsules', params);
|
||||
}
|
||||
|
||||
export async function destroyAdminCapsule(id: string): Promise<ApiResult<void>> {
|
||||
return apiFetch('DELETE', `/api/v1/admin/capsules/${id}`);
|
||||
}
|
||||
|
||||
export async function snapshotAdminCapsule(id: string, name?: string): Promise<ApiResult<Snapshot>> {
|
||||
return apiFetch('POST', `/api/v1/admin/capsules/${id}/snapshot`, { name });
|
||||
}
|
||||
|
||||
/** Fetch platform templates for the admin create dialog. */
|
||||
export async function listPlatformTemplates(): Promise<ApiResult<Snapshot[]>> {
|
||||
const result = await apiFetch<AdminTemplate[]>('GET', '/api/v1/admin/templates');
|
||||
if (!result.ok) return result;
|
||||
// Map AdminTemplate → Snapshot shape.
|
||||
const snapshots: Snapshot[] = result.data.map((t) => ({
|
||||
name: t.name,
|
||||
type: t.type,
|
||||
vcpus: t.vcpus || undefined,
|
||||
memory_mb: t.memory_mb || undefined,
|
||||
size_bytes: t.size_bytes,
|
||||
created_at: t.created_at,
|
||||
platform: true,
|
||||
}));
|
||||
return { ok: true, data: snapshots };
|
||||
}
|
||||
28
frontend/src/lib/api/admin-users.ts
Normal file
28
frontend/src/lib/api/admin-users.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { apiFetch, type ApiResult } from '$lib/api/client';
|
||||
|
||||
export type AdminUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
is_admin: boolean;
|
||||
status: string;
|
||||
created_at: string;
|
||||
teams_joined: number;
|
||||
teams_owned: number;
|
||||
};
|
||||
|
||||
export type AdminUsersResponse = {
|
||||
users: AdminUser[];
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
total_pages: number;
|
||||
};
|
||||
|
||||
export async function listAdminUsers(page: number = 1): Promise<ApiResult<AdminUsersResponse>> {
|
||||
return apiFetch('GET', `/api/v1/admin/users?page=${page}`);
|
||||
}
|
||||
|
||||
export async function setUserActive(id: string, active: boolean): Promise<ApiResult<void>> {
|
||||
return apiFetch('PUT', `/api/v1/admin/users/${id}/active`, { active });
|
||||
}
|
||||
@ -6,17 +6,26 @@ export type AuthResponse = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type SignupResponse = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type AuthResult = { ok: true; data: AuthResponse } | { ok: false; error: string };
|
||||
export type SignupResult = { ok: true; data: SignupResponse } | { ok: false; error: string };
|
||||
|
||||
export async function apiLogin(email: string, password: string): Promise<AuthResult> {
|
||||
return authFetch('/api/v1/auth/login', { email, password });
|
||||
}
|
||||
|
||||
export async function apiSignup(email: string, password: string, name: string): Promise<AuthResult> {
|
||||
export async function apiSignup(email: string, password: string, name: string): Promise<SignupResult> {
|
||||
return authFetch('/api/v1/auth/signup', { email, password, name });
|
||||
}
|
||||
|
||||
async function authFetch(url: string, body: Record<string, string>): Promise<AuthResult> {
|
||||
export async function apiActivate(token: string): Promise<AuthResult> {
|
||||
return authFetch('/api/v1/auth/activate', { token });
|
||||
}
|
||||
|
||||
async function authFetch<T = AuthResponse>(url: string, body: Record<string, string>): Promise<{ ok: true; data: T } | { ok: false; error: string }> {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
@ -31,7 +40,7 @@ async function authFetch(url: string, body: Record<string, string>): Promise<Aut
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
|
||||
return { ok: true, data: data as AuthResponse };
|
||||
return { ok: true, data: data as T };
|
||||
} catch {
|
||||
return { ok: false, error: 'Unable to connect to the server' };
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { apiFetch, type ApiResult } from '$lib/api/client';
|
||||
import { apiFetch, apiFetchMultipart, type ApiResult } from '$lib/api/client';
|
||||
|
||||
export type BuildLogEntry = {
|
||||
step: number;
|
||||
@ -26,6 +26,8 @@ export type Build = {
|
||||
error?: string;
|
||||
sandbox_id?: string;
|
||||
host_id?: string;
|
||||
default_user: string;
|
||||
default_env: Record<string, string>;
|
||||
created_at: string;
|
||||
started_at?: string;
|
||||
completed_at?: string;
|
||||
@ -39,9 +41,18 @@ export type CreateBuildParams = {
|
||||
vcpus?: number;
|
||||
memory_mb?: number;
|
||||
skip_pre_post?: boolean;
|
||||
archive?: File;
|
||||
};
|
||||
|
||||
export async function createBuild(params: CreateBuildParams): Promise<ApiResult<Build>> {
|
||||
if (params.archive) {
|
||||
// Use multipart when an archive file is provided.
|
||||
const { archive, ...config } = params;
|
||||
const formData = new FormData();
|
||||
formData.append('config', JSON.stringify(config));
|
||||
formData.append('archive', archive);
|
||||
return apiFetchMultipart('POST', '/api/v1/admin/builds', formData);
|
||||
}
|
||||
return apiFetch('POST', '/api/v1/admin/builds', params);
|
||||
}
|
||||
|
||||
|
||||
@ -17,11 +17,11 @@ export type Capsule = {
|
||||
|
||||
|
||||
export async function listCapsules(): Promise<ApiResult<Capsule[]>> {
|
||||
return apiFetch('GET', '/api/v1/sandboxes');
|
||||
return apiFetch('GET', '/api/v1/capsules');
|
||||
}
|
||||
|
||||
export async function getCapsule(id: string): Promise<ApiResult<Capsule>> {
|
||||
return apiFetch('GET', `/api/v1/sandboxes/${id}`);
|
||||
return apiFetch('GET', `/api/v1/capsules/${id}`);
|
||||
}
|
||||
|
||||
export type CreateCapsuleParams = {
|
||||
@ -32,19 +32,19 @@ export type CreateCapsuleParams = {
|
||||
};
|
||||
|
||||
export async function createCapsule(params: CreateCapsuleParams): Promise<ApiResult<Capsule>> {
|
||||
return apiFetch('POST', '/api/v1/sandboxes', params);
|
||||
return apiFetch('POST', '/api/v1/capsules', params);
|
||||
}
|
||||
|
||||
export async function pauseCapsule(id: string): Promise<ApiResult<Capsule>> {
|
||||
return apiFetch('POST', `/api/v1/sandboxes/${id}/pause`);
|
||||
return apiFetch('POST', `/api/v1/capsules/${id}/pause`);
|
||||
}
|
||||
|
||||
export async function resumeCapsule(id: string): Promise<ApiResult<Capsule>> {
|
||||
return apiFetch('POST', `/api/v1/sandboxes/${id}/resume`);
|
||||
return apiFetch('POST', `/api/v1/capsules/${id}/resume`);
|
||||
}
|
||||
|
||||
export async function destroyCapsule(id: string): Promise<ApiResult<void>> {
|
||||
return apiFetch('DELETE', `/api/v1/sandboxes/${id}`);
|
||||
return apiFetch('DELETE', `/api/v1/capsules/${id}`);
|
||||
}
|
||||
|
||||
export type Snapshot = {
|
||||
@ -57,8 +57,8 @@ export type Snapshot = {
|
||||
platform: boolean;
|
||||
};
|
||||
|
||||
export async function createSnapshot(sandboxId: string, name?: string): Promise<ApiResult<Snapshot>> {
|
||||
return apiFetch('POST', '/api/v1/snapshots', { sandbox_id: sandboxId, name });
|
||||
export async function createSnapshot(capsuleId: string, name?: string): Promise<ApiResult<Snapshot>> {
|
||||
return apiFetch('POST', '/api/v1/snapshots', { sandbox_id: capsuleId, name });
|
||||
}
|
||||
|
||||
export async function listSnapshots(typeFilter?: string): Promise<ApiResult<Snapshot[]>> {
|
||||
|
||||
@ -22,3 +22,24 @@ export async function apiFetch<T>(method: string, path: string, body?: unknown):
|
||||
return { ok: false, error: 'Unable to connect to the server' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetchMultipart<T>(method: string, path: string, formData: FormData): Promise<ApiResult<T>> {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`;
|
||||
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers,
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (res.status === 204) return { ok: true, data: undefined as T };
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) return { ok: false, error: data?.error?.message ?? 'Something went wrong' };
|
||||
return { ok: true, data: data as T };
|
||||
} catch {
|
||||
return { ok: false, error: 'Unable to connect to the server' };
|
||||
}
|
||||
}
|
||||
|
||||
141
frontend/src/lib/api/files.ts
Normal file
141
frontend/src/lib/api/files.ts
Normal file
@ -0,0 +1,141 @@
|
||||
import { auth } from '$lib/auth.svelte';
|
||||
import { type ApiResult } from '$lib/api/client';
|
||||
|
||||
export type FileEntry = {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'directory' | 'symlink' | 'unknown';
|
||||
size: number;
|
||||
mode: number;
|
||||
permissions: string;
|
||||
owner: string;
|
||||
group: string;
|
||||
modified_at: number;
|
||||
symlink_target?: string | null;
|
||||
};
|
||||
|
||||
export type ListDirResponse = {
|
||||
entries: FileEntry[];
|
||||
};
|
||||
|
||||
const MAX_READABLE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
/**
|
||||
* Whether a file can be previewed as text in the browser.
|
||||
* Binary/unreadable extensions and files > 10 MB should be downloaded instead.
|
||||
*/
|
||||
const BINARY_EXTENSIONS = new Set([
|
||||
'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.webp', '.avif', '.svg',
|
||||
'.mp3', '.mp4', '.wav', '.ogg', '.flac', '.avi', '.mkv', '.mov', '.webm',
|
||||
'.zip', '.tar', '.gz', '.bz2', '.xz', '.7z', '.rar', '.zst',
|
||||
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
|
||||
'.exe', '.dll', '.so', '.dylib', '.bin', '.o', '.a', '.class', '.pyc',
|
||||
'.woff', '.woff2', '.ttf', '.otf', '.eot',
|
||||
'.db', '.sqlite', '.sqlite3',
|
||||
'.iso', '.img', '.dmg',
|
||||
]);
|
||||
|
||||
export function isBinaryFile(name: string): boolean {
|
||||
const dot = name.lastIndexOf('.');
|
||||
if (dot === -1) return false;
|
||||
return BINARY_EXTENSIONS.has(name.slice(dot).toLowerCase());
|
||||
}
|
||||
|
||||
export function isFileTooLarge(size: number): boolean {
|
||||
return size > MAX_READABLE_SIZE;
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
const val = bytes / Math.pow(1024, i);
|
||||
return `${val < 10 ? val.toFixed(1) : Math.round(val)} ${units[i]}`;
|
||||
}
|
||||
|
||||
export async function listDir(capsuleId: string, path: string, depth = 1, basePath = '/api/v1/capsules'): Promise<ApiResult<ListDirResponse>> {
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`;
|
||||
|
||||
const res = await fetch(`${basePath}/${capsuleId}/files/list`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ path, depth }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) return { ok: false, error: data?.error?.message ?? 'Failed to list directory' };
|
||||
return { ok: true, data: data as ListDirResponse };
|
||||
} catch {
|
||||
return { ok: false, error: 'Unable to connect to the server' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function readFile(
|
||||
capsuleId: string,
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
basePath = '/api/v1/capsules',
|
||||
): Promise<ApiResult<string>> {
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`;
|
||||
|
||||
const res = await fetch(`${basePath}/${capsuleId}/files/read`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ path }),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
try {
|
||||
const data = await res.json();
|
||||
return { ok: false, error: data?.error?.message ?? 'Failed to read file' };
|
||||
} catch {
|
||||
return { ok: false, error: `HTTP ${res.status}` };
|
||||
}
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
const text = await blob.text();
|
||||
return { ok: true, data: text };
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'AbortError') {
|
||||
return { ok: false, error: 'Request aborted' };
|
||||
}
|
||||
return { ok: false, error: 'Unable to connect to the server' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadFile(
|
||||
capsuleId: string,
|
||||
path: string,
|
||||
filename: string,
|
||||
signal?: AbortSignal,
|
||||
basePath = '/api/v1/capsules',
|
||||
): Promise<void> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`;
|
||||
|
||||
const res = await fetch(`${basePath}/${capsuleId}/files/read`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ path }),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Download failed');
|
||||
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
// Delay revocation so the browser has time to start the download
|
||||
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||
}
|
||||
42
frontend/src/lib/api/me.ts
Normal file
42
frontend/src/lib/api/me.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { apiFetch, type ApiResult } from '$lib/api/client';
|
||||
import type { AuthResponse } from '$lib/api/auth';
|
||||
|
||||
export type MeResponse = {
|
||||
name: string;
|
||||
email: string;
|
||||
has_password: boolean;
|
||||
providers: string[];
|
||||
};
|
||||
|
||||
export type ChangePasswordBody = {
|
||||
current_password?: string;
|
||||
new_password: string;
|
||||
confirm_password?: string;
|
||||
};
|
||||
|
||||
export const getMe = (): Promise<ApiResult<MeResponse>> =>
|
||||
apiFetch('GET', '/api/v1/me');
|
||||
|
||||
export const updateName = (name: string): Promise<ApiResult<AuthResponse>> =>
|
||||
apiFetch('PATCH', '/api/v1/me', { name });
|
||||
|
||||
export const changePassword = (body: ChangePasswordBody): Promise<ApiResult<void>> =>
|
||||
apiFetch('POST', '/api/v1/me/password', body);
|
||||
|
||||
export const requestPasswordReset = (email: string): Promise<ApiResult<void>> =>
|
||||
apiFetch('POST', '/api/v1/me/password/reset', { email });
|
||||
|
||||
export const confirmPasswordReset = (
|
||||
token: string,
|
||||
new_password: string
|
||||
): Promise<ApiResult<void>> =>
|
||||
apiFetch('POST', '/api/v1/me/password/reset/confirm', { token, new_password });
|
||||
|
||||
export const getProviderConnectURL = (provider: string): Promise<ApiResult<{ auth_url: string }>> =>
|
||||
apiFetch('GET', `/api/v1/me/providers/${provider}/connect`);
|
||||
|
||||
export const disconnectProvider = (provider: string): Promise<ApiResult<void>> =>
|
||||
apiFetch('DELETE', `/api/v1/me/providers/${provider}`);
|
||||
|
||||
export const deleteAccount = (confirmation: string): Promise<ApiResult<void>> =>
|
||||
apiFetch('DELETE', '/api/v1/me', { confirmation });
|
||||
@ -15,11 +15,20 @@ export type MetricsResponse = {
|
||||
points: MetricPoint[];
|
||||
};
|
||||
|
||||
export async function fetchSandboxMetrics(id: string, range: MetricRange): Promise<ApiResult<MetricsResponse>> {
|
||||
return apiFetch('GET', `/api/v1/sandboxes/${id}/metrics?range=${range}`);
|
||||
export async function fetchCapsuleMetrics(id: string, range: MetricRange, basePath = '/api/v1/capsules'): Promise<ApiResult<MetricsResponse>> {
|
||||
return apiFetch('GET', `${basePath}/${id}/metrics?range=${range}`);
|
||||
}
|
||||
|
||||
export const METRIC_RANGES: MetricRange[] = ['5m', '10m', '1h', '6h', '24h'];
|
||||
|
||||
// All ranges poll every 10 seconds.
|
||||
// Poll interval varies by range — shorter ranges need fresher data.
|
||||
export const METRIC_POLL_INTERVALS: Record<MetricRange, number> = {
|
||||
'5m': 10_000,
|
||||
'10m': 10_000,
|
||||
'1h': 30_000,
|
||||
'6h': 60_000,
|
||||
'24h': 120_000,
|
||||
};
|
||||
|
||||
/** @deprecated Use METRIC_POLL_INTERVALS instead */
|
||||
export const METRIC_POLL_INTERVAL = 10_000;
|
||||
|
||||
@ -24,7 +24,7 @@ export type StatsResponse = {
|
||||
};
|
||||
|
||||
export async function fetchStats(range: TimeRange): Promise<ApiResult<StatsResponse>> {
|
||||
return apiFetch('GET', `/api/v1/sandboxes/stats?range=${range}`);
|
||||
return apiFetch('GET', `/api/v1/capsules/stats?range=${range}`);
|
||||
}
|
||||
|
||||
export const POLL_INTERVALS: Record<TimeRange, number> = {
|
||||
|
||||
@ -83,3 +83,39 @@ export async function leaveTeam(id: string): Promise<ApiResult<void>> {
|
||||
export async function searchUsers(email: string): Promise<ApiResult<UserSearchResult[]>> {
|
||||
return apiFetch('GET', `/api/v1/users/search?email=${encodeURIComponent(email)}`);
|
||||
}
|
||||
|
||||
// Admin team types and API functions
|
||||
|
||||
export type AdminTeam = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
is_byoc: boolean;
|
||||
created_at: string;
|
||||
deleted_at: string | null;
|
||||
member_count: number;
|
||||
owner_name: string;
|
||||
owner_email: string;
|
||||
active_sandbox_count: number;
|
||||
channel_count: number;
|
||||
};
|
||||
|
||||
export type AdminTeamsResponse = {
|
||||
teams: AdminTeam[];
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
total_pages: number;
|
||||
};
|
||||
|
||||
export async function listAdminTeams(page: number = 1): Promise<ApiResult<AdminTeamsResponse>> {
|
||||
return apiFetch('GET', `/api/v1/admin/teams?page=${page}`);
|
||||
}
|
||||
|
||||
export async function adminSetBYOC(id: string, enabled: boolean): Promise<ApiResult<void>> {
|
||||
return apiFetch('PUT', `/api/v1/admin/teams/${id}/byoc`, { enabled });
|
||||
}
|
||||
|
||||
export async function adminDeleteTeam(id: string): Promise<ApiResult<void>> {
|
||||
return apiFetch('DELETE', `/api/v1/admin/teams/${id}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user