1
0
forked from wrenn/wrenn
This commit is contained in:
2026-04-16 19:24:25 +00:00
parent 172413e91e
commit 605ad666a0
239 changed files with 19966 additions and 3454 deletions

View 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 };
}

View 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 });
}

View File

@ -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' };
}

View File

@ -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);
}

View File

@ -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[]>> {

View File

@ -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' };
}
}

View 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);
}

View 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 });

View File

@ -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;

View File

@ -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> = {

View File

@ -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}`);
}