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}`);
|
||||
}
|
||||
|
||||
@ -3,14 +3,17 @@
|
||||
import { auth } from '$lib/auth.svelte';
|
||||
import {
|
||||
IconServer,
|
||||
IconTemplate,
|
||||
IconBox,
|
||||
IconMonitor,
|
||||
IconSettings,
|
||||
IconLogout,
|
||||
IconSidebar,
|
||||
IconBell,
|
||||
IconDocs,
|
||||
IconChevron,
|
||||
IconShield
|
||||
IconShield,
|
||||
IconMembers,
|
||||
IconUser
|
||||
} from './icons';
|
||||
|
||||
let { collapsed = $bindable(false) }: { collapsed: boolean } = $props();
|
||||
@ -22,8 +25,14 @@
|
||||
};
|
||||
|
||||
const managementItems: NavItem[] = [
|
||||
{ label: 'Hosts', icon: IconServer, href: '/admin/hosts' },
|
||||
{ label: 'Templates', icon: IconTemplate, href: '/admin/templates' }
|
||||
{ label: 'Users', icon: IconUser, href: '/admin/users' },
|
||||
{ label: 'Teams', icon: IconMembers, href: '/admin/teams' }
|
||||
];
|
||||
|
||||
const platformItems: NavItem[] = [
|
||||
{ label: 'Templates', icon: IconBox, href: '/admin/templates' },
|
||||
{ label: 'Capsules', icon: IconMonitor, href: '/admin/capsules' },
|
||||
{ label: 'Hosts', icon: IconServer, href: '/admin/hosts' }
|
||||
];
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
@ -96,43 +105,8 @@
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 overflow-y-auto px-3 {collapsed ? 'px-1.5' : ''}">
|
||||
<div class="mb-3">
|
||||
{#if !collapsed}
|
||||
<div class="mb-1 px-2.5 py-1.5 text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">
|
||||
Management
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mx-1 my-2 h-px bg-[var(--color-border)]"></div>
|
||||
{/if}
|
||||
{#each managementItems as item}
|
||||
{#if isActive(item.href)}
|
||||
<a
|
||||
href={item.href}
|
||||
class="group relative flex items-center rounded-[var(--radius-input)] bg-[var(--color-accent-glow-mid)] px-2.5 py-2.5 transition-colors duration-150 {collapsed ? 'justify-center px-2' : 'gap-3'}"
|
||||
title={collapsed ? item.label : undefined}
|
||||
>
|
||||
{#if !collapsed}
|
||||
<div class="absolute left-0 top-1/2 h-5 w-[3px] -translate-y-1/2 rounded-r-full bg-[var(--color-accent)]"></div>
|
||||
{/if}
|
||||
<item.icon size={16} class="shrink-0 text-[var(--color-accent-bright)]" />
|
||||
{#if !collapsed}
|
||||
<span class="text-ui font-medium text-[var(--color-accent-bright)]">{item.label}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{:else}
|
||||
<a
|
||||
href={item.href}
|
||||
class="group flex items-center rounded-[var(--radius-input)] px-2.5 py-2.5 transition-colors duration-150 hover:bg-[var(--color-bg-3)] {collapsed ? 'justify-center px-2' : 'gap-3'}"
|
||||
title={collapsed ? item.label : undefined}
|
||||
>
|
||||
<item.icon size={16} class="shrink-0 opacity-50 transition-opacity duration-150 group-hover:opacity-100" />
|
||||
{#if !collapsed}
|
||||
<span class="text-ui text-[var(--color-text-primary)] transition-colors duration-150 group-hover:text-[var(--color-text-bright)]">{item.label}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{@render navSection('Platform', platformItems)}
|
||||
{@render navSection('Management', managementItems)}
|
||||
</nav>
|
||||
|
||||
<!-- Bottom links -->
|
||||
@ -184,3 +158,43 @@
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{#snippet navSection(title: string, items: NavItem[])}
|
||||
<div class="mb-3">
|
||||
{#if !collapsed}
|
||||
<div class="mb-1 px-2.5 py-1.5 text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">
|
||||
{title}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mx-1 my-2 h-px bg-[var(--color-border)]"></div>
|
||||
{/if}
|
||||
{#each items as item}
|
||||
{#if isActive(item.href)}
|
||||
<a
|
||||
href={item.href}
|
||||
class="group relative flex items-center rounded-[var(--radius-input)] bg-[var(--color-accent-glow-mid)] px-2.5 py-2.5 transition-colors duration-150 {collapsed ? 'justify-center px-2' : 'gap-3'}"
|
||||
title={collapsed ? item.label : undefined}
|
||||
>
|
||||
{#if !collapsed}
|
||||
<div class="absolute left-0 top-1/2 h-5 w-[3px] -translate-y-1/2 rounded-r-full bg-[var(--color-accent)]"></div>
|
||||
{/if}
|
||||
<item.icon size={16} class="shrink-0 text-[var(--color-accent-bright)]" />
|
||||
{#if !collapsed}
|
||||
<span class="text-ui font-medium text-[var(--color-accent-bright)]">{item.label}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{:else}
|
||||
<a
|
||||
href={item.href}
|
||||
class="group flex items-center rounded-[var(--radius-input)] px-2.5 py-2.5 transition-colors duration-150 hover:bg-[var(--color-bg-3)] {collapsed ? 'justify-center px-2' : 'gap-3'}"
|
||||
title={collapsed ? item.label : undefined}
|
||||
>
|
||||
<item.icon size={16} class="shrink-0 opacity-50 transition-opacity duration-150 group-hover:opacity-100" />
|
||||
{#if !collapsed}
|
||||
<span class="text-ui text-[var(--color-text-primary)] transition-colors duration-150 group-hover:text-[var(--color-text-bright)]">{item.label}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
@ -1,210 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Dialog } from 'bits-ui';
|
||||
import {
|
||||
IconGithub,
|
||||
IconMail,
|
||||
IconLock,
|
||||
IconUser,
|
||||
IconX,
|
||||
IconEye,
|
||||
IconEyeOff
|
||||
} from './icons';
|
||||
|
||||
let {
|
||||
mode = $bindable('signin'),
|
||||
open = $bindable(false),
|
||||
onSwitchMode
|
||||
}: {
|
||||
mode: 'signin' | 'signup';
|
||||
open: boolean;
|
||||
onSwitchMode: () => void;
|
||||
} = $props();
|
||||
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let name = $state('');
|
||||
let showPassword = $state(false);
|
||||
|
||||
const title = $derived(mode === 'signin' ? 'Welcome back' : 'Create account');
|
||||
const subtitle = $derived(
|
||||
mode === 'signin' ? 'Sign in to your Wrenn account' : 'Get started with Wrenn'
|
||||
);
|
||||
const submitLabel = $derived(mode === 'signin' ? 'Sign in' : 'Create account');
|
||||
const switchText = $derived(
|
||||
mode === 'signin' ? "Don't have an account?" : 'Already have an account?'
|
||||
);
|
||||
const switchAction = $derived(mode === 'signin' ? 'Sign up' : 'Sign in');
|
||||
|
||||
function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay
|
||||
class="fixed inset-0 z-50 bg-black/70 backdrop-blur-[3px]"
|
||||
style="animation: overlayFadeIn 200ms ease"
|
||||
/>
|
||||
<Dialog.Content
|
||||
class="fixed left-1/2 top-1/2 z-50 w-[calc(100%-2rem)] max-w-[400px] -translate-x-1/2 -translate-y-1/2 rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-0"
|
||||
style="animation: contentSlideIn 250ms cubic-bezier(0.16, 1, 0.3, 1)"
|
||||
>
|
||||
<!-- Close button -->
|
||||
<Dialog.Close
|
||||
class="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-[var(--radius-button)] border border-transparent text-[var(--color-text-tertiary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:bg-[var(--color-bg-4)] hover:text-[var(--color-text-secondary)]"
|
||||
>
|
||||
<IconX size={14} />
|
||||
</Dialog.Close>
|
||||
|
||||
<div class="px-7 pb-7 pt-8">
|
||||
<!-- Header -->
|
||||
<div class="mb-7">
|
||||
<Dialog.Title
|
||||
class="font-serif text-page tracking-[-0.02em] text-[var(--color-text-bright)]"
|
||||
>
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description
|
||||
class="mt-1 text-ui text-[var(--color-text-secondary)]"
|
||||
>
|
||||
{subtitle}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
|
||||
<!-- GitHub OAuth -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-center gap-2.5 rounded-[var(--radius-button)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] px-4 py-2.5 text-ui font-medium text-[var(--color-text-bright)] transition-all duration-150 hover:border-[var(--color-accent)] hover:text-[var(--color-text-bright)]"
|
||||
>
|
||||
<IconGithub size={16} />
|
||||
Continue with GitHub
|
||||
</button>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="my-5 flex items-center gap-3">
|
||||
<div class="h-px flex-1 bg-[var(--color-border)]"></div>
|
||||
<span
|
||||
class="font-mono text-badge uppercase tracking-[0.1em] text-[var(--color-text-muted)]"
|
||||
>or</span
|
||||
>
|
||||
<div class="h-px flex-1 bg-[var(--color-border)]"></div>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<form onsubmit={handleSubmit} class="space-y-3">
|
||||
{#if mode === 'signup'}
|
||||
<div class="group relative">
|
||||
<div
|
||||
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)] transition-colors duration-150 group-focus-within:text-[var(--color-accent)]"
|
||||
>
|
||||
<IconUser size={14} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={name}
|
||||
placeholder="Full name"
|
||||
autocomplete="name"
|
||||
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-2)] py-2.5 pl-9 pr-3 text-ui text-[var(--color-text-bright)] outline-none transition-all duration-150 placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)]"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="group relative">
|
||||
<div
|
||||
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)] transition-colors duration-150 group-focus-within:text-[var(--color-accent)]"
|
||||
>
|
||||
<IconMail size={14} />
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
bind:value={email}
|
||||
placeholder="Email address"
|
||||
autocomplete="email"
|
||||
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-2)] py-2.5 pl-9 pr-3 text-ui text-[var(--color-text-bright)] outline-none transition-all duration-150 placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="group relative">
|
||||
<div
|
||||
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)] transition-colors duration-150 group-focus-within:text-[var(--color-accent)]"
|
||||
>
|
||||
<IconLock size={14} />
|
||||
</div>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
bind:value={password}
|
||||
placeholder="Password"
|
||||
autocomplete={mode === 'signin' ? 'current-password' : 'new-password'}
|
||||
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-2)] py-2.5 pl-9 pr-10 text-ui text-[var(--color-text-bright)] outline-none transition-all duration-150 placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showPassword = !showPassword)}
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)] transition-colors duration-150 hover:text-[var(--color-text-secondary)]"
|
||||
tabindex={-1}
|
||||
>
|
||||
{#if showPassword}
|
||||
<IconEyeOff size={14} />
|
||||
{:else}
|
||||
<IconEye size={14} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if mode === 'signin'}
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="text-meta text-[var(--color-text-secondary)] transition-colors duration-150 hover:text-[var(--color-accent-mid)]"
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="!mt-5 w-full rounded-[var(--radius-button)] bg-[var(--color-accent)] px-4 py-2.5 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0"
|
||||
>
|
||||
{submitLabel}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Switch mode -->
|
||||
<p class="mt-5 text-center text-meta text-[var(--color-text-secondary)]">
|
||||
{switchText}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onSwitchMode}
|
||||
class="font-medium text-[var(--color-text-primary)] transition-colors duration-150 hover:text-[var(--color-text-bright)]"
|
||||
>
|
||||
{switchAction}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
|
||||
<style>
|
||||
@keyframes overlayFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes contentSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -48%) scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
112
frontend/src/lib/components/CopyButton.svelte
Normal file
112
frontend/src/lib/components/CopyButton.svelte
Normal file
@ -0,0 +1,112 @@
|
||||
<script lang="ts">
|
||||
let { value }: { value: string } = $props();
|
||||
|
||||
let copied = $state(false);
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
async function copy(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
copied = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => (copied = false), 1800);
|
||||
} catch {
|
||||
// Clipboard API unavailable
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
onclick={copy}
|
||||
class="copy-btn"
|
||||
class:copied
|
||||
aria-label="Copy to clipboard"
|
||||
>
|
||||
<span class="copy-btn-inner">
|
||||
{#if copied}
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="check-icon">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="clipboard-icon">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.copy-btn {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 22px;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-muted);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-bg-4);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.copy-btn:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
/* ── Copied state ── */
|
||||
.copy-btn.copied {
|
||||
opacity: 1;
|
||||
color: var(--color-accent-bright);
|
||||
background: rgba(94, 140, 88, 0.1);
|
||||
border-color: rgba(94, 140, 88, 0.25);
|
||||
}
|
||||
|
||||
.copy-btn-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* ── Clipboard icon — subtle nudge on hover ── */
|
||||
.clipboard-icon {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.copy-btn:hover .clipboard-icon {
|
||||
transform: translate(-0.5px, -0.5px);
|
||||
}
|
||||
|
||||
/* ── Check icon draw animation ── */
|
||||
.check-icon {
|
||||
animation: checkDraw 0.3s cubic-bezier(0.25, 1, 0.5, 1) both;
|
||||
}
|
||||
.check-icon polyline {
|
||||
stroke-dasharray: 24;
|
||||
stroke-dashoffset: 24;
|
||||
animation: drawCheck 0.3s cubic-bezier(0.25, 1, 0.5, 1) 0.05s forwards;
|
||||
}
|
||||
@keyframes drawCheck {
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
@keyframes checkDraw {
|
||||
0% { transform: scale(0.6); opacity: 0; }
|
||||
50% { opacity: 1; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
</style>
|
||||
@ -1,23 +1,126 @@
|
||||
<script lang="ts">
|
||||
import { createCapsule, type Capsule, type CreateCapsuleParams } from '$lib/api/capsules';
|
||||
import { createCapsule, listSnapshots, type Capsule, type CreateCapsuleParams, type Snapshot } from '$lib/api/capsules';
|
||||
import { createAdminCapsule, listPlatformTemplates } from '$lib/api/admin-capsules';
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onclose: () => void;
|
||||
oncreated?: (capsule: Capsule) => void;
|
||||
/** 'team' = user-scoped templates (default), 'platform' = admin platform templates only */
|
||||
templateSource?: 'team' | 'platform';
|
||||
};
|
||||
let { open, onclose, oncreated }: Props = $props();
|
||||
let { open, onclose, oncreated, templateSource = 'team' }: Props = $props();
|
||||
|
||||
let createForm = $state<CreateCapsuleParams>({ template: 'minimal', vcpus: 1, memory_mb: 512, timeout_sec: 0 });
|
||||
let creating = $state(false);
|
||||
let createError = $state<string | null>(null);
|
||||
|
||||
// Template combobox state
|
||||
let templates = $state<Snapshot[]>([]);
|
||||
let templatesLoading = $state(false);
|
||||
let templateQuery = $state('');
|
||||
let comboOpen = $state(false);
|
||||
let highlightIdx = $state(-1);
|
||||
let inputEl = $state<HTMLInputElement | undefined>(undefined);
|
||||
let listEl = $state<HTMLUListElement | undefined>(undefined);
|
||||
|
||||
// Resolve selected template for type indicator + snapshot locking
|
||||
let selectedTemplate = $derived(
|
||||
templates.find((t) => t.name === createForm.template)
|
||||
);
|
||||
let selectedIsSnapshot = $derived(selectedTemplate?.type === 'snapshot');
|
||||
|
||||
let filtered = $derived.by(() => {
|
||||
const q = templateQuery.toLowerCase();
|
||||
if (!q) return templates;
|
||||
return templates.filter((t) => t.name.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
// Fetch templates when dialog opens
|
||||
$effect(() => {
|
||||
if (open && templates.length === 0 && !templatesLoading) {
|
||||
templatesLoading = true;
|
||||
const fetcher = templateSource === 'platform' ? listPlatformTemplates : listSnapshots;
|
||||
fetcher().then((result) => {
|
||||
if (result.ok) templates = result.data;
|
||||
templatesLoading = false;
|
||||
});
|
||||
}
|
||||
if (open) {
|
||||
templateQuery = createForm.template ?? '';
|
||||
}
|
||||
});
|
||||
|
||||
function selectTemplate(t: Snapshot) {
|
||||
createForm.template = t.name;
|
||||
templateQuery = t.name;
|
||||
// Pre-fill specs from the template if available
|
||||
if (t.vcpus) createForm.vcpus = t.vcpus;
|
||||
if (t.memory_mb) createForm.memory_mb = t.memory_mb;
|
||||
comboOpen = false;
|
||||
highlightIdx = -1;
|
||||
}
|
||||
|
||||
function handleInputKeydown(e: KeyboardEvent) {
|
||||
if (!comboOpen && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
|
||||
comboOpen = true;
|
||||
highlightIdx = 0;
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (!comboOpen) return;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
highlightIdx = Math.min(highlightIdx + 1, filtered.length - 1);
|
||||
scrollToHighlighted();
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
highlightIdx = Math.max(highlightIdx - 1, 0);
|
||||
scrollToHighlighted();
|
||||
} else if (e.key === 'Enter' && highlightIdx >= 0 && highlightIdx < filtered.length) {
|
||||
e.preventDefault();
|
||||
selectTemplate(filtered[highlightIdx]);
|
||||
} else if (e.key === 'Escape') {
|
||||
comboOpen = false;
|
||||
highlightIdx = -1;
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToHighlighted() {
|
||||
if (!listEl) return;
|
||||
const item = listEl.children[highlightIdx] as HTMLElement | undefined;
|
||||
item?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
function handleInputFocus() {
|
||||
comboOpen = true;
|
||||
highlightIdx = -1;
|
||||
}
|
||||
|
||||
function handleInputBlur() {
|
||||
// Delay to allow click on dropdown item to fire first
|
||||
setTimeout(() => {
|
||||
comboOpen = false;
|
||||
// If the typed query matches an existing template, apply it
|
||||
const match = templates.find((t) => t.name === templateQuery);
|
||||
if (match) {
|
||||
createForm.template = match.name;
|
||||
} else {
|
||||
// Allow free-form entry (user might know a template name not in the list)
|
||||
createForm.template = templateQuery;
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
creating = true;
|
||||
createError = null;
|
||||
const result = await createCapsule(createForm);
|
||||
const creator = templateSource === 'platform' ? createAdminCapsule : createCapsule;
|
||||
const result = await creator(createForm);
|
||||
if (result.ok) {
|
||||
createForm = { template: 'minimal', vcpus: 1, memory_mb: 512, timeout_sec: 0 };
|
||||
templateQuery = 'minimal';
|
||||
oncreated?.(result.data);
|
||||
onclose();
|
||||
} else {
|
||||
@ -36,8 +139,9 @@
|
||||
onkeydown={(e) => { if (e.key === 'Escape' && !creating) onclose(); }}
|
||||
></div>
|
||||
|
||||
<div class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6" style="animation: fadeUp 0.2s ease both; box-shadow: var(--shadow-dialog)">
|
||||
<h2 class="font-serif text-heading tracking-[-0.02em] text-[var(--color-text-bright)]">Launch Capsule</h2>
|
||||
<div class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)]" style="animation: fadeUp 0.2s ease both; box-shadow: var(--shadow-dialog)">
|
||||
<div class="p-6">
|
||||
<h2 class="font-serif text-heading text-[var(--color-text-bright)]">Launch Capsule</h2>
|
||||
<p class="mt-1 text-ui text-[var(--color-text-tertiary)]">Configure resources and launch. The VM will be ready in under a second.</p>
|
||||
|
||||
{#if createError}
|
||||
@ -47,16 +151,101 @@
|
||||
{/if}
|
||||
|
||||
<div class="mt-5 space-y-4">
|
||||
<div>
|
||||
<!-- Template combobox -->
|
||||
<div class="relative">
|
||||
<label class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]" for="create-template">Template</label>
|
||||
<input
|
||||
id="create-template"
|
||||
type="text"
|
||||
bind:value={createForm.template}
|
||||
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-colors duration-150 focus:border-[var(--color-accent)]"
|
||||
placeholder="minimal"
|
||||
/>
|
||||
<p class="mt-1.5 text-meta text-[var(--color-text-muted)]">Name of a snapshot or base image to boot from.</p>
|
||||
<div class="relative">
|
||||
{#if selectedTemplate}
|
||||
<span class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 h-1.5 w-1.5 rounded-full {selectedTemplate.type === 'snapshot' ? 'bg-[var(--color-accent)]' : 'bg-[var(--color-blue)]'}"></span>
|
||||
{/if}
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
id="create-template"
|
||||
type="text"
|
||||
role="combobox"
|
||||
aria-expanded={comboOpen}
|
||||
aria-autocomplete="list"
|
||||
aria-controls="template-listbox"
|
||||
autocomplete="off"
|
||||
bind:value={templateQuery}
|
||||
onfocus={handleInputFocus}
|
||||
onblur={handleInputBlur}
|
||||
onkeydown={handleInputKeydown}
|
||||
disabled={creating}
|
||||
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] py-2 pr-8 font-mono text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-colors duration-150 focus:border-[var(--color-accent)] disabled:opacity-60 {selectedTemplate ? 'pl-7' : 'pl-3'}"
|
||||
placeholder="Search templates..."
|
||||
/>
|
||||
<!-- Chevron -->
|
||||
<svg
|
||||
class="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)] transition-transform duration-150 {comboOpen ? 'rotate-180' : ''}"
|
||||
width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Dropdown -->
|
||||
{#if comboOpen}
|
||||
<ul
|
||||
bind:this={listEl}
|
||||
id="template-listbox"
|
||||
role="listbox"
|
||||
class="absolute z-10 mt-1 max-h-[200px] w-full overflow-y-auto rounded-[var(--radius-input)] border border-[var(--color-border-mid)] bg-[var(--color-bg-3)] py-1 shadow-lg"
|
||||
style="animation: fadeUp 0.12s ease both"
|
||||
>
|
||||
{#if templatesLoading}
|
||||
<li class="flex items-center gap-2 px-3 py-2.5 text-meta text-[var(--color-text-muted)]">
|
||||
<svg class="animate-spin" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
|
||||
Loading templates...
|
||||
</li>
|
||||
{:else if filtered.length === 0}
|
||||
<li class="px-3 py-2.5 text-meta text-[var(--color-text-muted)]">
|
||||
{templateQuery ? 'No matching templates' : 'No templates available'}
|
||||
</li>
|
||||
{:else}
|
||||
{#each filtered as t, i (t.name)}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<li
|
||||
role="option"
|
||||
aria-selected={i === highlightIdx}
|
||||
class="flex cursor-pointer items-center gap-2.5 px-3 py-2 transition-colors duration-75
|
||||
{i === highlightIdx
|
||||
? 'bg-[var(--color-bg-5)] text-[var(--color-text-bright)]'
|
||||
: 'text-[var(--color-text-primary)] hover:bg-[var(--color-bg-4)]'}
|
||||
{createForm.template === t.name ? 'font-medium' : ''}"
|
||||
onmousedown={(e) => { e.preventDefault(); selectTemplate(t); }}
|
||||
onmouseenter={() => { highlightIdx = i; }}
|
||||
>
|
||||
<!-- Type badge -->
|
||||
{#if t.type === 'snapshot'}
|
||||
<span class="inline-flex shrink-0 items-center rounded-full border border-[var(--color-accent)]/25 bg-[var(--color-accent)]/8 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.04em] text-[var(--color-accent-bright)]">
|
||||
snap
|
||||
</span>
|
||||
{:else}
|
||||
<span class="inline-flex shrink-0 items-center rounded-full border border-[var(--color-blue)]/25 bg-[var(--color-blue)]/8 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.04em] text-[var(--color-blue)]">
|
||||
base
|
||||
</span>
|
||||
{/if}
|
||||
<span class="truncate font-mono text-meta">{t.name}</span>
|
||||
<!-- Specs hint -->
|
||||
{#if t.vcpus && t.memory_mb}
|
||||
<span class="ml-auto shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
||||
{t.vcpus}v · {t.memory_mb}MB
|
||||
</span>
|
||||
{/if}
|
||||
<!-- Selected check -->
|
||||
{#if createForm.template === t.name}
|
||||
<svg class="ml-auto shrink-0 text-[var(--color-accent)]" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
{/if}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<p class="mt-1.5 text-meta text-[var(--color-text-muted)]">Snapshot or base image to boot from.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
@ -68,7 +257,8 @@
|
||||
min="1"
|
||||
max="8"
|
||||
bind:value={createForm.vcpus}
|
||||
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-ui text-[var(--color-text-bright)] outline-none transition-colors duration-150 focus:border-[var(--color-accent)]"
|
||||
disabled={creating || selectedIsSnapshot}
|
||||
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-ui text-[var(--color-text-bright)] outline-none transition-colors duration-150 focus:border-[var(--color-accent)] disabled:opacity-60"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@ -80,7 +270,8 @@
|
||||
max="8192"
|
||||
step="128"
|
||||
bind:value={createForm.memory_mb}
|
||||
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-ui text-[var(--color-text-bright)] outline-none transition-colors duration-150 focus:border-[var(--color-accent)]"
|
||||
disabled={creating || selectedIsSnapshot}
|
||||
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-ui text-[var(--color-text-bright)] outline-none transition-colors duration-150 focus:border-[var(--color-accent)] disabled:opacity-60"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -92,7 +283,8 @@
|
||||
type="number"
|
||||
min="0"
|
||||
bind:value={createForm.timeout_sec}
|
||||
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-ui text-[var(--color-text-bright)] outline-none transition-colors duration-150 focus:border-[var(--color-accent)]"
|
||||
disabled={creating}
|
||||
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-ui text-[var(--color-text-bright)] outline-none transition-colors duration-150 focus:border-[var(--color-accent)] disabled:opacity-60"
|
||||
placeholder="0"
|
||||
/>
|
||||
<p class="mt-1.5 text-meta text-[var(--color-text-muted)]">Seconds of inactivity before the capsule pauses. Set to 0 to keep it running indefinitely.</p>
|
||||
@ -109,7 +301,7 @@
|
||||
</button>
|
||||
<button
|
||||
onclick={handleCreate}
|
||||
disabled={creating}
|
||||
disabled={creating || !templateQuery.trim()}
|
||||
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
|
||||
>
|
||||
{#if creating}
|
||||
@ -122,6 +314,7 @@
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
87
frontend/src/lib/components/DestroyDialog.svelte
Normal file
87
frontend/src/lib/components/DestroyDialog.svelte
Normal file
@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
import { destroyCapsule } from '$lib/api/capsules';
|
||||
import type { ApiResult } from '$lib/api/client';
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
capsuleId: string;
|
||||
onclose: () => void;
|
||||
ondestroyed?: () => void;
|
||||
destroyFn?: (id: string) => Promise<ApiResult<void>>;
|
||||
};
|
||||
let { open, capsuleId, onclose, ondestroyed, destroyFn }: Props = $props();
|
||||
|
||||
let destroying = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
async function handleDestroy() {
|
||||
destroying = true;
|
||||
error = null;
|
||||
const destroy = destroyFn ?? destroyCapsule;
|
||||
const result = await destroy(capsuleId);
|
||||
if (result.ok) {
|
||||
error = null;
|
||||
ondestroyed?.();
|
||||
onclose();
|
||||
} else {
|
||||
error = result.error;
|
||||
}
|
||||
destroying = false;
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
if (!destroying) {
|
||||
error = null;
|
||||
onclose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black/60"
|
||||
onclick={handleClose}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') handleClose(); }}
|
||||
></div>
|
||||
<div class="relative w-full max-w-[380px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)]" style="animation: fadeUp 0.2s ease both; box-shadow: var(--shadow-dialog)">
|
||||
<div class="p-6">
|
||||
<h2 class="font-serif text-heading text-[var(--color-text-bright)]">Destroy Capsule</h2>
|
||||
<p class="mt-2 text-ui text-[var(--color-text-tertiary)]">
|
||||
Terminate <span class="font-mono text-[var(--color-text-secondary)]">{capsuleId}</span> and destroy all data inside it. This cannot be undone.
|
||||
</p>
|
||||
|
||||
{#if error}
|
||||
<div class="mt-4 rounded-[var(--radius-input)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-3 py-2 text-meta text-[var(--color-red)]">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
onclick={handleClose}
|
||||
disabled={destroying}
|
||||
class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-4 py-2 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={handleDestroy}
|
||||
disabled={destroying}
|
||||
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-red)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-110 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
|
||||
>
|
||||
{#if destroying}
|
||||
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</svg>
|
||||
Destroying...
|
||||
{:else}
|
||||
Destroy
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
856
frontend/src/lib/components/FilesTab.svelte
Normal file
856
frontend/src/lib/components/FilesTab.svelte
Normal file
@ -0,0 +1,856 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import {
|
||||
listDir,
|
||||
readFile,
|
||||
downloadFile,
|
||||
isBinaryFile,
|
||||
isFileTooLarge,
|
||||
formatFileSize,
|
||||
type FileEntry,
|
||||
} from '$lib/api/files';
|
||||
import { tokenize, type ThemedToken } from '$lib/highlight';
|
||||
|
||||
type Props = {
|
||||
capsuleId: string;
|
||||
isRunning: boolean;
|
||||
apiBasePath?: string;
|
||||
/** Hide the file preview pane when no file is selected */
|
||||
compact?: boolean;
|
||||
/** Show only the file tree, completely removing the preview panel */
|
||||
treeOnly?: boolean;
|
||||
};
|
||||
|
||||
let { capsuleId, isRunning, apiBasePath = '/api/v1/capsules', compact = false, treeOnly = false }: Props = $props();
|
||||
|
||||
// Directory navigation state
|
||||
let currentPath = $state('~');
|
||||
let entries = $state<FileEntry[]>([]);
|
||||
let dirLoading = $state(false);
|
||||
let dirError = $state<string | null>(null);
|
||||
|
||||
// File preview state
|
||||
let selectedFile = $state<FileEntry | null>(null);
|
||||
let fileContent = $state<string | null>(null);
|
||||
let fileLoading = $state(false);
|
||||
let fileError = $state<string | null>(null);
|
||||
let downloading = $state(false);
|
||||
|
||||
// Syntax highlighting (lazy — loaded on first use)
|
||||
let highlightedTokens = $state<ThemedToken[][] | null>(null);
|
||||
|
||||
// Request generation counters — discard stale responses from rapid clicks
|
||||
let dirGeneration = 0;
|
||||
let fileGeneration = 0;
|
||||
|
||||
// AbortController for in-flight file reads — aborted when the user
|
||||
// selects a different file or the component is torn down.
|
||||
let fileAbort: AbortController | null = null;
|
||||
|
||||
onDestroy(() => {
|
||||
fileAbort?.abort();
|
||||
});
|
||||
|
||||
const MAX_PREVIEW_LINES = 5000;
|
||||
const MAX_HIGHLIGHT_LINES = 2000; // Don't tokenize huge files — diminishing returns
|
||||
|
||||
// Path input
|
||||
let pathInput = $state('~');
|
||||
let pathInputFocused = $state(false);
|
||||
let pathInputEl = $state<HTMLInputElement | undefined>(undefined);
|
||||
|
||||
// Pre-computed preview lines — avoids re-splitting on every render
|
||||
const previewLines = $derived.by(() => {
|
||||
if (!fileContent) return { lines: [] as string[], truncated: false, totalLines: 0 };
|
||||
const allLines = fileContent.split('\n');
|
||||
const truncated = allLines.length > MAX_PREVIEW_LINES;
|
||||
return {
|
||||
lines: truncated ? allLines.slice(0, MAX_PREVIEW_LINES) : allLines,
|
||||
truncated,
|
||||
totalLines: allLines.length,
|
||||
};
|
||||
});
|
||||
|
||||
// Sorted entries: directories first, then files, alphabetical within each group
|
||||
const sortedEntries = $derived(
|
||||
[...entries].sort((a, b) => {
|
||||
if (a.type === 'directory' && b.type !== 'directory') return -1;
|
||||
if (a.type !== 'directory' && b.type === 'directory') return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
);
|
||||
|
||||
// Breadcrumb segments from currentPath
|
||||
const breadcrumbs = $derived.by(() => {
|
||||
const parts = currentPath.split('/').filter(Boolean);
|
||||
const crumbs: { name: string; path: string }[] = [{ name: '/', path: '/' }];
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
crumbs.push({ name: parts[i], path: '/' + parts.slice(0, i + 1).join('/') });
|
||||
}
|
||||
return crumbs;
|
||||
});
|
||||
|
||||
// Count of dirs vs files for the footer
|
||||
const dirCount = $derived(entries.filter((e) => e.type === 'directory').length);
|
||||
const fileCount = $derived(entries.filter((e) => e.type !== 'directory').length);
|
||||
|
||||
const canGoUp = $derived(currentPath !== '/' && currentPath.startsWith('/'));
|
||||
|
||||
// Only regular files can be downloaded — symlinks and other non-regular types
|
||||
// may point to devices, sockets, or directories that can't be read as a file.
|
||||
const isDownloadable = $derived(selectedFile?.type === 'file');
|
||||
|
||||
// Device files, pipes, sockets, etc. — can't be read or downloaded.
|
||||
const isSpecialFile = $derived(selectedFile?.type === 'unknown');
|
||||
|
||||
async function navigateTo(path: string) {
|
||||
// Abort any in-flight file read and invalidate stale generation so the
|
||||
// abort error isn't surfaced in the UI.
|
||||
fileAbort?.abort();
|
||||
++fileGeneration;
|
||||
currentPath = normalizePath(path);
|
||||
pathInput = currentPath;
|
||||
selectedFile = null;
|
||||
fileContent = null;
|
||||
fileError = null;
|
||||
highlightedTokens = null;
|
||||
await loadDir();
|
||||
}
|
||||
|
||||
function normalizePath(p: string): string {
|
||||
// Let envd handle ~ expansion — pass through as-is
|
||||
if (p === '~' || p.startsWith('~/')) {
|
||||
return p;
|
||||
}
|
||||
|
||||
if (!p.startsWith('/')) {
|
||||
// Relative path — resolve against current directory
|
||||
p = currentPath.replace(/\/$/, '') + '/' + p;
|
||||
}
|
||||
// Collapse .. and .
|
||||
const parts = p.split('/').filter(Boolean);
|
||||
const resolved: string[] = [];
|
||||
for (const part of parts) {
|
||||
if (part === '..') resolved.pop();
|
||||
else if (part !== '.') resolved.push(part);
|
||||
}
|
||||
return '/' + resolved.join('/');
|
||||
}
|
||||
|
||||
/** Derive the parent directory from an entry's absolute path. */
|
||||
function parentFromEntry(entryPath: string): string {
|
||||
const lastSlash = entryPath.lastIndexOf('/');
|
||||
if (lastSlash <= 0) return '/';
|
||||
return entryPath.slice(0, lastSlash);
|
||||
}
|
||||
|
||||
async function loadDir() {
|
||||
if (!isRunning) return;
|
||||
dirLoading = true;
|
||||
dirError = null;
|
||||
const gen = ++dirGeneration;
|
||||
const result = await listDir(capsuleId, currentPath, 1, apiBasePath);
|
||||
if (gen !== dirGeneration) return; // stale response
|
||||
if (result.ok) {
|
||||
entries = result.data.entries ?? [];
|
||||
// Resolve actual path when envd expanded ~ or a relative path
|
||||
if (!currentPath.startsWith('/') && entries.length > 0) {
|
||||
currentPath = parentFromEntry(entries[0].path);
|
||||
pathInput = currentPath;
|
||||
}
|
||||
} else {
|
||||
dirError = result.error;
|
||||
entries = [];
|
||||
}
|
||||
dirLoading = false;
|
||||
}
|
||||
|
||||
async function selectFile(entry: FileEntry) {
|
||||
if (entry.type === 'directory') {
|
||||
await navigateTo(entry.path);
|
||||
return;
|
||||
}
|
||||
|
||||
// Abort any in-flight file read before starting a new one.
|
||||
fileAbort?.abort();
|
||||
|
||||
selectedFile = entry;
|
||||
fileContent = null;
|
||||
fileError = null;
|
||||
highlightedTokens = null;
|
||||
|
||||
// Non-regular files (devices, pipes, sockets) — nothing to read
|
||||
if (entry.type === 'unknown') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we should preview or prompt download
|
||||
if (isBinaryFile(entry.name) || isFileTooLarge(entry.size)) {
|
||||
// Don't load content — the preview pane will show download prompt
|
||||
return;
|
||||
}
|
||||
|
||||
fileLoading = true;
|
||||
const gen = ++fileGeneration;
|
||||
const controller = new AbortController();
|
||||
fileAbort = controller;
|
||||
try {
|
||||
const result = await readFile(capsuleId, entry.path, controller.signal, apiBasePath);
|
||||
if (gen !== fileGeneration) return; // stale response — user clicked another file
|
||||
if (result.ok) {
|
||||
if (looksLikeBinary(result.data)) {
|
||||
fileContent = null;
|
||||
} else {
|
||||
fileContent = result.data;
|
||||
// Kick off highlighting in the background — preview shows plain text immediately.
|
||||
// Only tokenize up to MAX_HIGHLIGHT_LINES to avoid freezing on large files.
|
||||
const linesToHighlight = result.data.split('\n').length > MAX_HIGHLIGHT_LINES
|
||||
? result.data.split('\n').slice(0, MAX_HIGHLIGHT_LINES).join('\n')
|
||||
: result.data;
|
||||
tokenize(linesToHighlight, entry.name).then((tokens) => {
|
||||
if (gen === fileGeneration) highlightedTokens = tokens;
|
||||
});
|
||||
}
|
||||
} else if (result.error !== 'Request aborted') {
|
||||
fileError = result.error;
|
||||
}
|
||||
} finally {
|
||||
if (gen === fileGeneration) fileLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function looksLikeBinary(text: string): boolean {
|
||||
// Sample first 8KB for null bytes or high ratio of non-printable chars
|
||||
const sample = text.slice(0, 8192);
|
||||
let nonPrintable = 0;
|
||||
for (let i = 0; i < sample.length; i++) {
|
||||
const code = sample.charCodeAt(i);
|
||||
if (code === 0) return true;
|
||||
if (code < 32 && code !== 9 && code !== 10 && code !== 13) nonPrintable++;
|
||||
}
|
||||
return sample.length > 0 && nonPrintable / sample.length > 0.1;
|
||||
}
|
||||
|
||||
async function handleDownload() {
|
||||
if (!selectedFile || downloading || selectedFile.type !== 'file') return;
|
||||
downloading = true;
|
||||
try {
|
||||
await downloadFile(capsuleId, selectedFile.path, selectedFile.name, undefined, apiBasePath);
|
||||
} catch {
|
||||
fileError = 'Download failed';
|
||||
}
|
||||
downloading = false;
|
||||
}
|
||||
|
||||
function handlePathSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
const target = pathInput.trim();
|
||||
if (!target) return;
|
||||
const resolved = normalizePath(target);
|
||||
navigateOrOpenFile(resolved);
|
||||
}
|
||||
|
||||
async function navigateOrOpenFile(path: string) {
|
||||
// First try as directory
|
||||
const dirResult = await listDir(capsuleId, path, 1, apiBasePath);
|
||||
if (dirResult.ok) {
|
||||
// Resolve actual path from entries (handles ~ expansion by envd)
|
||||
const resolvedEntries = dirResult.data.entries ?? [];
|
||||
let resolvedPath = path;
|
||||
if (resolvedEntries.length > 0) {
|
||||
// Derive parent dir from first entry's absolute path
|
||||
const firstPath = resolvedEntries[0].path;
|
||||
const lastSlash = firstPath.lastIndexOf('/');
|
||||
if (lastSlash >= 0) {
|
||||
resolvedPath = lastSlash === 0 ? '/' : firstPath.slice(0, lastSlash);
|
||||
}
|
||||
}
|
||||
currentPath = resolvedPath;
|
||||
pathInput = resolvedPath;
|
||||
entries = resolvedEntries;
|
||||
selectedFile = null;
|
||||
fileContent = null;
|
||||
fileError = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// If directory listing failed, try reading as a file
|
||||
// We need the parent dir to get the file entry info
|
||||
const lastSlash = path.lastIndexOf('/');
|
||||
const parentPath = lastSlash <= 0 ? '/' : path.slice(0, lastSlash);
|
||||
const fileName = path.slice(lastSlash + 1);
|
||||
|
||||
// Navigate to parent directory
|
||||
currentPath = parentPath;
|
||||
pathInput = parentPath;
|
||||
const parentResult = await listDir(capsuleId, parentPath, 1, apiBasePath);
|
||||
if (parentResult.ok) {
|
||||
entries = parentResult.data.entries ?? [];
|
||||
// Find the file in parent listing
|
||||
const found = entries.find((e) => e.name === fileName);
|
||||
if (found && found.type !== 'directory') {
|
||||
await selectFile(found);
|
||||
} else {
|
||||
dirError = `Not found: ${path}`;
|
||||
}
|
||||
} else {
|
||||
dirError = parentResult.error;
|
||||
entries = [];
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
(e.target as HTMLInputElement)?.blur();
|
||||
}
|
||||
}
|
||||
|
||||
function fileIcon(entry: FileEntry): string {
|
||||
if (entry.type === 'directory') return 'dir';
|
||||
if (entry.type === 'symlink') return 'link';
|
||||
if (entry.type === 'unknown') return 'special';
|
||||
return 'file';
|
||||
}
|
||||
|
||||
// File extension for subtle coloring
|
||||
function fileExt(name: string): string {
|
||||
const dot = name.lastIndexOf('.');
|
||||
return dot > 0 ? name.slice(dot + 1).toLowerCase() : '';
|
||||
}
|
||||
|
||||
// Extension → color mapping for file icons and badges
|
||||
function extColor(name: string): string {
|
||||
const ext = fileExt(name);
|
||||
switch (ext) {
|
||||
case 'go': case 'mod': case 'sum':
|
||||
return '#5a9fd4'; // blue — Go
|
||||
case 'py': case 'pyi': case 'pyx':
|
||||
return '#d4a73c'; // amber — Python
|
||||
case 'js': case 'mjs': case 'cjs':
|
||||
return '#d4a73c'; // amber — JavaScript
|
||||
case 'ts': case 'mts': case 'cts': case 'tsx': case 'jsx':
|
||||
return '#5a9fd4'; // blue — TypeScript/React
|
||||
case 'rs':
|
||||
return '#cf8172'; // red — Rust
|
||||
case 'sh': case 'bash': case 'zsh': case 'fish':
|
||||
return '#5e8c58'; // accent — shell
|
||||
case 'json': case 'yaml': case 'yml': case 'toml': case 'ini': case 'env':
|
||||
return '#8b7ec8'; // purple — config
|
||||
case 'md': case 'mdx': case 'txt': case 'rst':
|
||||
return 'var(--color-text-secondary)'; // neutral — docs
|
||||
case 'sql':
|
||||
return '#5a9fd4'; // blue — SQL
|
||||
case 'proto':
|
||||
return '#5e8c58'; // accent — protobuf
|
||||
case 'svelte': case 'vue':
|
||||
return '#cf8172'; // red — Svelte/Vue
|
||||
case 'css': case 'scss': case 'less':
|
||||
return '#5a9fd4'; // blue — styles
|
||||
case 'html': case 'htm':
|
||||
return '#cf8172'; // red — HTML
|
||||
case 'dockerfile': case 'makefile':
|
||||
return '#5e8c58'; // accent — build
|
||||
default:
|
||||
return 'var(--color-text-muted)';
|
||||
}
|
||||
}
|
||||
|
||||
// Descriptive label for file type badge in preview header
|
||||
function extLabel(name: string): string {
|
||||
const ext = fileExt(name);
|
||||
const lower = name.toLowerCase();
|
||||
if (lower === 'makefile') return 'Make';
|
||||
if (lower === 'dockerfile') return 'Docker';
|
||||
switch (ext) {
|
||||
case 'go': return 'Go';
|
||||
case 'py': return 'Python';
|
||||
case 'js': case 'mjs': case 'cjs': return 'JS';
|
||||
case 'ts': case 'mts': case 'cts': return 'TS';
|
||||
case 'tsx': return 'TSX';
|
||||
case 'jsx': return 'JSX';
|
||||
case 'rs': return 'Rust';
|
||||
case 'sh': case 'bash': return 'Shell';
|
||||
case 'json': return 'JSON';
|
||||
case 'yaml': case 'yml': return 'YAML';
|
||||
case 'toml': return 'TOML';
|
||||
case 'sql': return 'SQL';
|
||||
case 'proto': return 'Proto';
|
||||
case 'svelte': return 'Svelte';
|
||||
case 'css': return 'CSS';
|
||||
case 'html': case 'htm': return 'HTML';
|
||||
case 'md': case 'mdx': return 'Markdown';
|
||||
default: return ext ? ext.toUpperCase() : '';
|
||||
}
|
||||
}
|
||||
|
||||
// Load initial directory on mount, falling back to / if home can't be resolved
|
||||
let hasInitiallyLoaded = false;
|
||||
$effect(() => {
|
||||
if (isRunning && !hasInitiallyLoaded) {
|
||||
hasInitiallyLoaded = true;
|
||||
loadDir().then(() => {
|
||||
if (!currentPath.startsWith('/')) {
|
||||
currentPath = '/';
|
||||
pathInput = '/';
|
||||
if (dirError) loadDir();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.file-row {
|
||||
transition: background-color 0.1s ease;
|
||||
}
|
||||
.file-row:hover {
|
||||
background-color: var(--color-bg-3);
|
||||
}
|
||||
.file-row.active {
|
||||
background-color: var(--color-accent-glow);
|
||||
border-left: 3px solid var(--color-accent);
|
||||
box-shadow: inset 0 0 20px rgba(94, 140, 88, 0.06);
|
||||
}
|
||||
.file-row:not(.active) {
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.preview-code {
|
||||
tab-size: 4;
|
||||
-moz-tab-size: 4;
|
||||
}
|
||||
|
||||
/* Let the browser skip rendering off-screen lines in long files */
|
||||
.code-line {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 1.65rem;
|
||||
}
|
||||
|
||||
/* Staggered row entrance */
|
||||
@keyframes rowSlideIn {
|
||||
from { opacity: 0; transform: translateX(-4px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
.row-enter {
|
||||
animation: rowSlideIn 0.15s ease both;
|
||||
}
|
||||
|
||||
/* Line highlight on hover */
|
||||
.code-line:hover .line-content {
|
||||
background-color: var(--color-bg-3);
|
||||
}
|
||||
.code-line:hover .line-num {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
</style>
|
||||
|
||||
{#if !isRunning}
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<div class="flex flex-col items-center gap-4 text-center">
|
||||
<div class="flex h-14 w-14 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)]" style="animation: iconFloat 3s ease-in-out infinite">
|
||||
<svg class="text-[var(--color-text-muted)]" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-ui font-medium text-[var(--color-text-secondary)]">Capsule not running</span>
|
||||
<span class="text-meta text-[var(--color-text-muted)]">Start or resume the capsule to browse files</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-1 min-h-0">
|
||||
|
||||
<!-- Left panel: File tree -->
|
||||
<div class="flex shrink-0 flex-col bg-[var(--color-bg-2)] {(treeOnly || (compact && !selectedFile)) ? 'flex-1' : compact ? 'w-[28.57%] border-r border-[var(--color-border)]' : 'w-[380px] border-r border-[var(--color-border)]'}"
|
||||
>
|
||||
|
||||
<!-- Path input -->
|
||||
<form onsubmit={handlePathSubmit} class="border-b border-[var(--color-border)] px-4 py-3">
|
||||
<div class="flex items-center gap-2 rounded-[var(--radius-input)] border px-3 py-1.5 transition-colors duration-150
|
||||
{pathInputFocused
|
||||
? 'border-[var(--color-accent)]/50 bg-[var(--color-bg-0)]'
|
||||
: 'border-[var(--color-border)] bg-[var(--color-bg-1)]'}">
|
||||
<!-- Terminal prompt icon -->
|
||||
<span class="shrink-0 font-mono text-badge text-[var(--color-text-muted)] select-none" aria-hidden="true">
|
||||
$
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:this={pathInputEl}
|
||||
bind:value={pathInput}
|
||||
onfocus={() => (pathInputFocused = true)}
|
||||
onblur={() => (pathInputFocused = false)}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="/home/user or ~/file.txt"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
class="flex-1 bg-transparent font-mono text-meta text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-muted)]"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="shrink-0 flex items-center gap-1 rounded-[var(--radius-button)] px-2 py-0.5 text-badge font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-accent-glow-mid)] hover:text-[var(--color-accent-mid)]"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
<polyline points="12 5 19 12 12 19" />
|
||||
</svg>
|
||||
Go
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Breadcrumbs -->
|
||||
<div class="flex items-center gap-0.5 border-b border-[var(--color-border)] px-2 py-2 overflow-x-auto">
|
||||
<!-- Up button -->
|
||||
<button
|
||||
onclick={() => navigateTo(currentPath + '/..')}
|
||||
disabled={!canGoUp}
|
||||
title="Go to parent directory"
|
||||
class="shrink-0 flex items-center justify-center rounded-[3px] w-6 h-6 transition-colors
|
||||
{canGoUp
|
||||
? 'text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-4)] hover:text-[var(--color-text-primary)]'
|
||||
: 'text-[var(--color-text-muted)] opacity-30 cursor-not-allowed'}"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<span class="w-px h-4 bg-[var(--color-border)] shrink-0 mx-1"></span>
|
||||
{#each breadcrumbs as crumb, i}
|
||||
{#if i > 0}
|
||||
<svg class="shrink-0 text-[var(--color-text-muted)]" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => navigateTo(crumb.path)}
|
||||
class="shrink-0 rounded-[3px] px-1.5 py-0.5 font-mono text-label transition-colors hover:bg-[var(--color-bg-4)] hover:text-[var(--color-text-primary)]
|
||||
{i === breadcrumbs.length - 1
|
||||
? 'text-[var(--color-text-primary)]'
|
||||
: 'text-[var(--color-text-tertiary)]'}"
|
||||
>
|
||||
{#if i === 0}
|
||||
<!-- Root icon -->
|
||||
<svg class="inline -mt-px" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
</svg>
|
||||
{:else}
|
||||
{crumb.name}
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- File list -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#if dirLoading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div class="flex items-center gap-2 text-meta text-[var(--color-text-secondary)]">
|
||||
<svg class="animate-spin" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</svg>
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
{:else if dirError}
|
||||
<div class="px-4 py-4">
|
||||
<div class="flex items-start gap-2.5 rounded-[var(--radius-card)] border border-[var(--color-red)]/25 bg-[var(--color-red)]/6 px-3.5 py-3">
|
||||
<svg class="mt-0.5 shrink-0 text-[var(--color-red)]" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
<span class="text-meta text-[var(--color-red)]">{dirError}</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else if entries.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-16 gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-3)]" style="animation: iconFloat 3s ease-in-out infinite">
|
||||
<svg class="text-[var(--color-text-muted)]" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-meta text-[var(--color-text-muted)]">Empty directory</span>
|
||||
</div>
|
||||
{:else}
|
||||
{#each sortedEntries as entry, idx (entry.path)}
|
||||
<button
|
||||
onclick={() => selectFile(entry)}
|
||||
class="file-row flex w-full items-center gap-3 px-4 py-[7px] text-left
|
||||
{selectedFile?.path === entry.path ? 'active' : ''}
|
||||
{idx < 30 ? 'row-enter' : ''}"
|
||||
style={idx < 30 ? `animation-delay: ${idx * 12}ms` : undefined}
|
||||
>
|
||||
<!-- Icon -->
|
||||
{#if fileIcon(entry) === 'dir'}
|
||||
<svg class="shrink-0 text-[var(--color-accent-mid)]" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
{:else if fileIcon(entry) === 'link'}
|
||||
<svg class="shrink-0 text-[var(--color-blue)]" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</svg>
|
||||
{:else if fileIcon(entry) === 'special'}
|
||||
<svg class="shrink-0 text-[var(--color-text-muted)]" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="shrink-0" style="color: {extColor(entry.name)}" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
{/if}
|
||||
|
||||
<!-- Name + metadata -->
|
||||
<div class="flex flex-1 items-center gap-2 overflow-hidden">
|
||||
<span class="truncate font-mono text-meta
|
||||
{entry.type === 'directory'
|
||||
? 'text-[var(--color-text-primary)] font-medium'
|
||||
: 'text-[var(--color-text-secondary)]'}">
|
||||
{entry.name}
|
||||
</span>
|
||||
{#if entry.type === 'symlink' && entry.symlink_target}
|
||||
<span class="truncate font-mono text-badge text-[var(--color-text-muted)]">
|
||||
→ {entry.symlink_target}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Size + extension hint (files only) -->
|
||||
{#if entry.type === 'file'}
|
||||
{#if fileExt(entry.name)}
|
||||
<span class="shrink-0 font-mono text-[9px] uppercase tracking-[0.05em]" style="color: {extColor(entry.name)}; opacity: 0.7">
|
||||
{fileExt(entry.name)}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="shrink-0 font-mono text-badge text-[var(--color-text-muted)]">
|
||||
{formatFileSize(entry.size)}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Permissions -->
|
||||
<span class="hidden shrink-0 font-mono text-badge text-[var(--color-text-muted)] xl:inline">
|
||||
{entry.permissions}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer: entry count -->
|
||||
{#if !dirLoading && !dirError && entries.length > 0}
|
||||
<div class="border-t border-[var(--color-border)] px-4 py-2 flex items-center gap-3">
|
||||
{#if dirCount > 0}
|
||||
<span class="font-mono text-badge text-[var(--color-text-muted)]">
|
||||
{dirCount} dir{dirCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{/if}
|
||||
{#if fileCount > 0}
|
||||
<span class="font-mono text-badge text-[var(--color-text-muted)]">
|
||||
{fileCount} file{fileCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Right panel: File preview -->
|
||||
{#if !treeOnly && (!compact || selectedFile)}
|
||||
<div class="flex flex-1 flex-col min-w-0 bg-[var(--color-bg-1)]">
|
||||
{#if !selectedFile}
|
||||
<!-- Empty state -->
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<div class="flex flex-col items-center gap-3 text-center">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)]" style="animation: iconFloat 3s ease-in-out infinite">
|
||||
<svg class="text-[var(--color-text-muted)]" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-ui text-[var(--color-text-secondary)]">Select a file to preview</span>
|
||||
<span class="text-meta text-[var(--color-text-muted)]">Click a file in the tree or type a path above</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- File header -->
|
||||
<div class="flex items-center justify-between border-b border-[var(--color-border)] bg-[var(--color-bg-2)] px-5 py-2.5">
|
||||
<div class="flex items-center gap-2.5 overflow-hidden">
|
||||
{#if isBinaryFile(selectedFile.name) || isFileTooLarge(selectedFile.size)}
|
||||
<svg class="shrink-0 text-[var(--color-amber)]" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="shrink-0" style="color: {extColor(selectedFile.name)}" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
{/if}
|
||||
<span class="truncate font-mono text-meta text-[var(--color-text-primary)]">{selectedFile.path}</span>
|
||||
{#if extLabel(selectedFile.name)}
|
||||
<span
|
||||
class="shrink-0 rounded-[3px] border px-1.5 py-0.5 font-mono text-badge font-semibold uppercase tracking-[0.03em]"
|
||||
style="color: {extColor(selectedFile.name)}; border-color: color-mix(in srgb, {extColor(selectedFile.name)} 25%, transparent); background: color-mix(in srgb, {extColor(selectedFile.name)} 8%, transparent)"
|
||||
>
|
||||
{extLabel(selectedFile.name)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 shrink-0 ml-4">
|
||||
<span class="font-mono text-badge text-[var(--color-text-muted)]">{formatFileSize(selectedFile.size)}</span>
|
||||
<button
|
||||
onclick={handleDownload}
|
||||
disabled={downloading || !isDownloadable}
|
||||
title={isDownloadable ? 'Download file' : 'Not a regular file — download unavailable'}
|
||||
class="flex items-center gap-1.5 rounded-[var(--radius-button)] border border-[var(--color-border)] bg-[var(--color-bg-3)] px-2.5 py-1 text-badge font-semibold uppercase tracking-[0.05em] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-bg-4)] hover:text-[var(--color-text-primary)] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{#if downloading}
|
||||
<svg class="animate-spin" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
|
||||
{:else}
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
{/if}
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File content -->
|
||||
<div class="flex-1 overflow-auto">
|
||||
{#if fileLoading}
|
||||
<div class="flex items-center justify-center py-16">
|
||||
<div class="flex items-center gap-2 text-meta text-[var(--color-text-secondary)]">
|
||||
<svg class="animate-spin" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</svg>
|
||||
Reading file...
|
||||
</div>
|
||||
</div>
|
||||
{:else if fileError}
|
||||
<div class="px-5 py-5">
|
||||
<div class="flex items-start gap-2.5 rounded-[var(--radius-card)] border border-[var(--color-red)]/25 bg-[var(--color-red)]/6 px-3.5 py-3">
|
||||
<svg class="mt-0.5 shrink-0 text-[var(--color-red)]" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
<span class="text-meta text-[var(--color-red)]">{fileError}</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else if isSpecialFile}
|
||||
<!-- Device file, pipe, socket, etc. — can't read or download -->
|
||||
<div class="flex flex-1 items-center justify-center py-20">
|
||||
<div class="flex flex-col items-center gap-5 text-center" style="animation: fadeUp 0.25s ease both">
|
||||
<div class="flex h-14 w-14 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-3)]">
|
||||
<svg class="text-[var(--color-text-muted)]" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="text-ui font-medium text-[var(--color-text-primary)]">Not a regular file</span>
|
||||
<span class="text-meta text-[var(--color-text-tertiary)]">
|
||||
<code class="rounded bg-[var(--color-bg-4)] px-1.5 py-0.5 font-mono text-[var(--color-text-secondary)]">{selectedFile.name}</code>
|
||||
is a device, socket, or pipe
|
||||
</span>
|
||||
<span class="mt-1 text-meta text-[var(--color-text-muted)]">
|
||||
These file types can't be read or downloaded.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if !isDownloadable}
|
||||
<!-- Symlink — no preview or download -->
|
||||
<div class="flex flex-1 items-center justify-center py-20">
|
||||
<div class="flex flex-col items-center gap-5 text-center" style="animation: fadeUp 0.25s ease both">
|
||||
<div class="flex h-14 w-14 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-3)]">
|
||||
<svg class="text-[var(--color-blue)]" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="text-ui font-medium text-[var(--color-text-primary)]">Symlink</span>
|
||||
{#if selectedFile.symlink_target}
|
||||
<span class="text-meta text-[var(--color-text-tertiary)]">
|
||||
Points to <code class="rounded bg-[var(--color-bg-4)] px-1.5 py-0.5 font-mono text-[var(--color-text-secondary)]">{selectedFile.symlink_target}</code>
|
||||
</span>
|
||||
{/if}
|
||||
<span class="mt-1 text-meta text-[var(--color-text-muted)]">
|
||||
Open the target path to view or download its contents.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if isBinaryFile(selectedFile.name) || isFileTooLarge(selectedFile.size) || (selectedFile && fileContent === null && !fileLoading)}
|
||||
<!-- Binary / too large / unreadable — download prompt -->
|
||||
<div class="flex flex-1 items-center justify-center py-20">
|
||||
<div class="flex flex-col items-center gap-5 text-center" style="animation: fadeUp 0.25s ease both">
|
||||
<div class="flex h-14 w-14 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-3)]">
|
||||
{#if isFileTooLarge(selectedFile.size)}
|
||||
<svg class="text-[var(--color-amber)]" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="text-[var(--color-text-muted)]" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="9" y1="3" x2="9" y2="21" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{#if isFileTooLarge(selectedFile.size)}
|
||||
<span class="text-ui font-medium text-[var(--color-text-primary)]">File too large to preview</span>
|
||||
<span class="text-meta text-[var(--color-text-tertiary)]">
|
||||
{formatFileSize(selectedFile.size)} exceeds the 10 MB preview limit
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-ui font-medium text-[var(--color-text-primary)]">Binary file</span>
|
||||
<span class="text-meta text-[var(--color-text-tertiary)]">
|
||||
This file type can't be displayed as text
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
onclick={handleDownload}
|
||||
class="mt-1 flex items-center gap-2 rounded-[var(--radius-button)] border border-[var(--color-accent)]/30 bg-[var(--color-accent-glow-mid)] px-4 py-2 text-meta font-semibold text-[var(--color-accent-bright)] transition-all duration-150 hover:border-[var(--color-accent)]/50 hover:bg-[var(--color-accent)]/15 hover:-translate-y-px active:translate-y-0"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
Download file
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if fileContent !== null}
|
||||
<!-- Text preview with line numbers (capped at MAX_PREVIEW_LINES) -->
|
||||
<div style="animation: fadeUp 0.15s ease both">
|
||||
<pre class="preview-code p-0 m-0"><code class="block">{#each previewLines.lines as line, i}<div class="code-line flex"><span class="line-num sticky left-0 inline-block w-[52px] shrink-0 select-none border-r border-[var(--color-border)] bg-[var(--color-bg-2)] px-3 py-0 text-right font-mono text-badge leading-[1.65rem] text-[var(--color-text-muted)]">{i + 1}</span><span class="line-content flex-1 whitespace-pre-wrap break-all px-4 py-0 font-mono text-meta leading-[1.65rem]">{#if highlightedTokens && highlightedTokens[i]}{#each highlightedTokens[i] as token}<span style="color: {token.color ?? 'var(--color-text-secondary)'}">{token.content}</span>{/each}{:else}<span class="text-[var(--color-text-secondary)]">{line || ' '}</span>{/if}</span></div>{/each}</code></pre>
|
||||
</div>
|
||||
{#if previewLines.truncated}
|
||||
<div class="flex items-center justify-center gap-2 border-t border-[var(--color-border)] bg-[var(--color-bg-2)] px-4 py-3">
|
||||
<span class="text-meta text-[var(--color-text-tertiary)]">
|
||||
Showing {MAX_PREVIEW_LINES.toLocaleString()} of {previewLines.totalLines.toLocaleString()} lines
|
||||
</span>
|
||||
<button
|
||||
onclick={handleDownload}
|
||||
class="font-mono text-meta text-[var(--color-accent-mid)] transition-colors hover:text-[var(--color-accent-bright)]"
|
||||
>Download full file</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
{/if}
|
||||
350
frontend/src/lib/components/MetricsPanel.svelte
Normal file
350
frontend/src/lib/components/MetricsPanel.svelte
Normal file
@ -0,0 +1,350 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy, tick } from 'svelte';
|
||||
import {
|
||||
fetchCapsuleMetrics,
|
||||
METRIC_RANGES,
|
||||
METRIC_POLL_INTERVALS,
|
||||
type MetricRange,
|
||||
type MetricPoint
|
||||
} from '$lib/api/metrics';
|
||||
|
||||
type Props = {
|
||||
capsuleId: string;
|
||||
/** Whether the capsule is in a state that supports metrics */
|
||||
available: boolean;
|
||||
/** Initial range selection */
|
||||
initialRange?: MetricRange;
|
||||
/** API base path for fetching metrics */
|
||||
apiBasePath?: string;
|
||||
/** Layout: 'full' shows padded cards with gap, 'compact' shows borderless stacked charts */
|
||||
layout?: 'full' | 'compact';
|
||||
};
|
||||
|
||||
let { capsuleId, available, initialRange = '10m' as MetricRange, apiBasePath = '/api/v1/capsules', layout = 'full' }: Props = $props();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let range = $state<MetricRange>(initialRange);
|
||||
let points = $state<MetricPoint[]>([]);
|
||||
let metricsLoading = $state(true);
|
||||
let metricsError = $state<string | null>(null);
|
||||
|
||||
let canvasCpu = $state<HTMLCanvasElement | undefined>(undefined);
|
||||
let canvasRam = $state<HTMLCanvasElement | undefined>(undefined);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let chartCpu: any = null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let chartRam: any = null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let ChartJS = $state<any>(null);
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let lastDataKey = '';
|
||||
let visibilityHandler: (() => void) | null = null;
|
||||
|
||||
const latestCpu = $derived<number | null>(
|
||||
points.length > 0 ? points[points.length - 1].cpu_pct : null
|
||||
);
|
||||
const latestRamMB = $derived<number | null>(
|
||||
points.length > 0 ? points[points.length - 1].mem_bytes / 1_048_576 : null
|
||||
);
|
||||
|
||||
async function loadMetrics() {
|
||||
if (!available) return;
|
||||
const result = await fetchCapsuleMetrics(capsuleId, range, apiBasePath);
|
||||
if (result.ok) {
|
||||
points = result.data.points;
|
||||
metricsError = null;
|
||||
} else {
|
||||
metricsError = result.error;
|
||||
}
|
||||
metricsLoading = false;
|
||||
updateCharts();
|
||||
}
|
||||
|
||||
function smooth(data: number[], window: number): number[] {
|
||||
if (window <= 1) return data;
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const start = Math.max(0, i - Math.floor(window / 2));
|
||||
const end = Math.min(data.length, i + Math.ceil(window / 2));
|
||||
let sum = 0;
|
||||
for (let j = start; j < end; j++) sum += data[j];
|
||||
out.push(+(sum / (end - start)).toFixed(2));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function smoothWindow(count: number): number {
|
||||
if (count < 60) return 1;
|
||||
if (count < 200) return 3;
|
||||
if (count < 600) return 5;
|
||||
return 9;
|
||||
}
|
||||
|
||||
function updateCharts() {
|
||||
if (!points.length) return;
|
||||
const key = `${points.length}:${points.at(-1)?.timestamp_unix ?? ''}`;
|
||||
if (key === lastDataKey) return;
|
||||
lastDataKey = key;
|
||||
const labels = points.map((p) => {
|
||||
const d = new Date(p.timestamp_unix * 1000);
|
||||
if (range === '5m' || range === '10m') {
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
});
|
||||
const w = smoothWindow(points.length);
|
||||
if (chartCpu) {
|
||||
chartCpu.data.labels = labels;
|
||||
chartCpu.data.datasets[0].data = smooth(points.map((p) => +p.cpu_pct.toFixed(2)), w);
|
||||
chartCpu.update();
|
||||
}
|
||||
if (chartRam) {
|
||||
chartRam.data.labels = labels;
|
||||
chartRam.data.datasets[0].data = smooth(points.map((p) => +(p.mem_bytes / 1_048_576).toFixed(1)), w);
|
||||
chartRam.update();
|
||||
}
|
||||
}
|
||||
|
||||
function setRange(r: MetricRange) {
|
||||
range = r;
|
||||
lastDataKey = '';
|
||||
metricsLoading = true;
|
||||
restartPolling();
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollInterval) { clearInterval(pollInterval); pollInterval = null; }
|
||||
}
|
||||
|
||||
function restartPolling() {
|
||||
stopPolling();
|
||||
loadMetrics();
|
||||
pollInterval = setInterval(loadMetrics, METRIC_POLL_INTERVALS[range]);
|
||||
}
|
||||
|
||||
const C_BLUE = '#5a9fd4';
|
||||
const C_BLUE_FILL = 'rgba(90,159,212,0.11)';
|
||||
const C_AMBER = '#d4a73c';
|
||||
const C_AMBER_FILL = 'rgba(212,167,60,0.11)';
|
||||
const C_GRID = 'rgba(255,255,255,0.05)';
|
||||
const C_TICK = '#635f5c';
|
||||
const FONT_MONO = "'JetBrains Mono', monospace";
|
||||
|
||||
const BASE_CHART_OPTIONS = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false as const,
|
||||
interaction: { mode: 'index' as const, intersect: false },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: '#111412',
|
||||
borderColor: '#1f2321',
|
||||
borderWidth: 1,
|
||||
titleColor: '#454340',
|
||||
bodyColor: '#d4cfc8',
|
||||
titleFont: { family: FONT_MONO, size: 10 },
|
||||
bodyFont: { family: FONT_MONO, size: 11 },
|
||||
padding: 10,
|
||||
caretSize: 4,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: C_GRID },
|
||||
ticks: { color: C_TICK, font: { family: FONT_MONO, size: 10 }, maxTicksLimit: 8, maxRotation: 0 },
|
||||
border: { color: C_GRID },
|
||||
},
|
||||
y: {
|
||||
grid: { color: C_GRID },
|
||||
ticks: { color: C_TICK, font: { family: FONT_MONO, size: 10 } },
|
||||
border: { color: C_GRID },
|
||||
beginAtZero: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function initCharts() {
|
||||
if (!ChartJS || !canvasCpu || !canvasRam) return;
|
||||
chartCpu?.destroy();
|
||||
chartRam?.destroy();
|
||||
|
||||
chartCpu = new ChartJS(canvasCpu, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
data: [], borderColor: C_BLUE, backgroundColor: C_BLUE_FILL,
|
||||
borderWidth: 2, fill: true, tension: 0, pointRadius: 0,
|
||||
pointHoverRadius: 4, pointHoverBackgroundColor: C_BLUE,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
...BASE_CHART_OPTIONS,
|
||||
plugins: { ...BASE_CHART_OPTIONS.plugins, tooltip: { ...BASE_CHART_OPTIONS.plugins.tooltip,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
callbacks: { label: (ctx: any) => ` ${ctx.parsed.y.toFixed(1)}%` },
|
||||
}},
|
||||
scales: { ...BASE_CHART_OPTIONS.scales, y: { ...BASE_CHART_OPTIONS.scales.y,
|
||||
ticks: { ...BASE_CHART_OPTIONS.scales.y.ticks, callback: (v: string | number) => `${+v}%` },
|
||||
}},
|
||||
},
|
||||
});
|
||||
|
||||
chartRam = new ChartJS(canvasRam, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
data: [], borderColor: C_AMBER, backgroundColor: C_AMBER_FILL,
|
||||
borderWidth: 2, fill: true, tension: 0, pointRadius: 0,
|
||||
pointHoverRadius: 4, pointHoverBackgroundColor: C_AMBER,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
...BASE_CHART_OPTIONS,
|
||||
plugins: { ...BASE_CHART_OPTIONS.plugins, tooltip: { ...BASE_CHART_OPTIONS.plugins.tooltip,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
callbacks: { label: (ctx: any) => ` ${ctx.parsed.y.toFixed(0)} MB` },
|
||||
}},
|
||||
scales: { ...BASE_CHART_OPTIONS.scales, y: { ...BASE_CHART_OPTIONS.scales.y,
|
||||
ticks: { ...BASE_CHART_OPTIONS.scales.y.ticks, callback: (v: string | number) => `${+v} MB` },
|
||||
}},
|
||||
},
|
||||
});
|
||||
|
||||
updateCharts();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!ChartJS || !available) return;
|
||||
tick().then(() => {
|
||||
if (canvasCpu && canvasRam) {
|
||||
initCharts();
|
||||
restartPolling();
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
stopPolling();
|
||||
chartCpu?.destroy(); chartCpu = null;
|
||||
chartRam?.destroy(); chartRam = null;
|
||||
};
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
if (!available) return;
|
||||
const mod = await import('chart.js/auto');
|
||||
ChartJS = mod.Chart;
|
||||
|
||||
visibilityHandler = () => {
|
||||
if (document.hidden) {
|
||||
stopPolling();
|
||||
} else if (available) {
|
||||
restartPolling();
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', visibilityHandler);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
stopPolling();
|
||||
if (visibilityHandler) document.removeEventListener('visibilitychange', visibilityHandler);
|
||||
chartCpu?.destroy();
|
||||
chartRam?.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.metric-val {
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="flex flex-1 flex-col min-h-0">
|
||||
<!-- Controls row -->
|
||||
<div class="flex shrink-0 items-center justify-between {layout === 'full' ? 'px-0 pb-5' : 'border-b border-[var(--color-border)] bg-[var(--color-bg-1)] px-5 py-2'}">
|
||||
{#if layout === 'full'}
|
||||
{#if !metricsLoading}
|
||||
<span class="flex items-center gap-1.5 rounded-[3px] border border-[var(--color-accent)]/25 bg-[var(--color-accent-glow-mid)] px-2 py-1 text-badge font-semibold uppercase tracking-[0.05em] text-[var(--color-accent-mid)]">
|
||||
<span class="h-[5px] w-[5px] rounded-full bg-[var(--color-accent)]" style="animation: wrenn-glow 2.5s ease-in-out infinite"></span>
|
||||
Live
|
||||
</span>
|
||||
{:else}
|
||||
<div></div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div></div>
|
||||
{/if}
|
||||
|
||||
<div class="flex overflow-hidden rounded-[var(--radius-button)] border border-[var(--color-border)]">
|
||||
{#each METRIC_RANGES as r, i}
|
||||
<button
|
||||
onclick={() => setRange(r)}
|
||||
class="px-3 py-1.5 font-mono text-label transition-colors duration-150
|
||||
{range === r
|
||||
? 'bg-[var(--color-bg-5)] text-[var(--color-text-bright)]'
|
||||
: 'text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-3)] hover:text-[var(--color-text-secondary)]'}
|
||||
{i > 0 ? 'border-l border-[var(--color-border)]' : ''}"
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if metricsError}
|
||||
<div class="flex shrink-0 items-center gap-3 rounded-[var(--radius-card)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/8 px-4 py-3 {layout === 'full' ? 'mb-5' : 'mx-5 my-3'}">
|
||||
<svg class="shrink-0 text-[var(--color-red)]" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
<span class="text-ui text-[var(--color-red)]">Could not load metrics: {metricsError}. Will retry automatically.</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Charts — stacked, each grows to fill half -->
|
||||
<div class="flex flex-1 flex-col min-h-0 {layout === 'full' ? 'gap-5' : 'divide-y divide-[var(--color-border)]'}">
|
||||
|
||||
<!-- CPU Usage -->
|
||||
<div class="flex flex-1 flex-col min-h-0 {layout === 'full' ? 'rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-2)]' : 'bg-[var(--color-bg-1)]'}">
|
||||
<div class="flex shrink-0 items-center justify-between {layout === 'full' ? 'border-b border-[var(--color-border)] px-6 py-4' : 'px-5 py-2'}">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 shrink-0 rounded-full" style="background: {C_BLUE}"></span>
|
||||
<span class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">CPU Usage</span>
|
||||
</div>
|
||||
{#if latestCpu !== null}
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="metric-val font-serif {layout === 'full' ? 'text-[2.571rem]' : 'text-heading'} leading-none tracking-[-0.04em] text-[var(--color-text-bright)]">{latestCpu.toFixed(1)}</span>
|
||||
<span class="font-mono {layout === 'full' ? 'text-label' : 'text-badge'} text-[var(--color-text-muted)]">%</span>
|
||||
</div>
|
||||
{:else if metricsLoading}
|
||||
<span class="font-serif {layout === 'full' ? 'text-[2.571rem]' : 'text-heading'} leading-none text-[var(--color-text-muted)]">—</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="relative flex-1 min-h-0 {layout === 'full' ? 'min-h-[180px] px-5 pb-5 pt-3' : 'px-4 pb-3 pt-1'}">
|
||||
<canvas bind:this={canvasCpu}></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RAM Usage -->
|
||||
<div class="flex flex-1 flex-col min-h-0 {layout === 'full' ? 'rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-2)]' : 'bg-[var(--color-bg-1)]'}">
|
||||
<div class="flex shrink-0 items-center justify-between {layout === 'full' ? 'border-b border-[var(--color-border)] px-6 py-4' : 'px-5 py-2'}">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 shrink-0 rounded-full" style="background: {C_AMBER}"></span>
|
||||
<span class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">RAM Usage</span>
|
||||
</div>
|
||||
{#if latestRamMB !== null}
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="metric-val font-serif {layout === 'full' ? 'text-[2.571rem]' : 'text-heading'} leading-none tracking-[-0.04em] text-[var(--color-text-bright)]">{latestRamMB.toFixed(0)}</span>
|
||||
<span class="font-mono {layout === 'full' ? 'text-label' : 'text-badge'} text-[var(--color-text-muted)]">MB</span>
|
||||
</div>
|
||||
{:else if metricsLoading}
|
||||
<span class="font-serif {layout === 'full' ? 'text-[2.571rem]' : 'text-heading'} leading-none text-[var(--color-text-muted)]">—</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="relative flex-1 min-h-0 {layout === 'full' ? 'min-h-[180px] px-5 pb-5 pt-3' : 'px-4 pb-3 pt-1'}">
|
||||
<canvas bind:this={canvasRam}></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@ -49,7 +49,7 @@
|
||||
|
||||
const platformItems: NavItem[] = [
|
||||
{ label: 'Capsules', icon: IconMonitor, href: '/dashboard/capsules' },
|
||||
{ label: 'Templates', icon: IconBox, href: '/dashboard/snapshots' },
|
||||
{ label: 'Templates', icon: IconBox, href: '/dashboard/templates' },
|
||||
{ label: 'Metrics', icon: IconMetrics, href: '/dashboard/metrics' }
|
||||
];
|
||||
|
||||
@ -280,13 +280,21 @@
|
||||
<IconBell size={16} class="shrink-0" />
|
||||
{#if !collapsed}<span class="text-ui">Notifications</span>{/if}
|
||||
</div>
|
||||
<div
|
||||
class="flex cursor-not-allowed items-center rounded-[var(--radius-input)] px-2.5 py-2.5 opacity-35 {collapsed ? 'justify-center px-2' : 'gap-3'}"
|
||||
title={collapsed ? 'Settings (coming soon)' : 'Coming soon'}
|
||||
<a
|
||||
href="/dashboard/settings"
|
||||
class="group relative flex items-center rounded-[var(--radius-input)] px-2.5 py-2.5 transition-colors duration-150 hover:bg-[var(--color-bg-3)] {collapsed ? 'justify-center px-2' : 'gap-3'} {isActive('/dashboard/settings') ? (collapsed ? 'bg-[var(--color-accent-glow-mid)]' : 'bg-[var(--color-accent)]/[0.12]') : ''}"
|
||||
title={collapsed ? 'Settings' : undefined}
|
||||
>
|
||||
<IconSettings size={16} class="shrink-0" />
|
||||
{#if !collapsed}<span class="text-ui">Settings</span>{/if}
|
||||
</div>
|
||||
{#if isActive('/dashboard/settings') && !collapsed}
|
||||
<div class="absolute left-0 top-1/2 h-6 w-1 -translate-y-1/2 rounded-r-full bg-[var(--color-accent)]"></div>
|
||||
{/if}
|
||||
<IconSettings size={16} class="shrink-0 {isActive('/dashboard/settings') ? 'text-[var(--color-accent-bright)]' : 'opacity-50 transition-opacity duration-150 group-hover:opacity-100'}" />
|
||||
{#if !collapsed}
|
||||
<span class="text-ui transition-colors duration-150 {isActive('/dashboard/settings') ? 'font-semibold text-[var(--color-accent-bright)]' : 'text-[var(--color-text-primary)] group-hover:text-[var(--color-text-bright)]'}">
|
||||
Settings
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- User footer -->
|
||||
@ -402,7 +410,7 @@
|
||||
class="relative w-full max-w-[380px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6"
|
||||
style="animation: fadeUp 0.2s ease both; box-shadow: var(--shadow-dialog)"
|
||||
>
|
||||
<h2 class="font-serif text-heading tracking-[-0.02em] text-[var(--color-text-bright)]">
|
||||
<h2 class="font-serif text-heading text-[var(--color-text-bright)]">
|
||||
Create Team
|
||||
</h2>
|
||||
<p class="mt-1 text-ui text-[var(--color-text-tertiary)]">
|
||||
|
||||
130
frontend/src/lib/components/SnapshotDialog.svelte
Normal file
130
frontend/src/lib/components/SnapshotDialog.svelte
Normal file
@ -0,0 +1,130 @@
|
||||
<script lang="ts">
|
||||
import { createSnapshot } from '$lib/api/capsules';
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
capsuleId: string;
|
||||
pauseFirst?: boolean;
|
||||
onclose: () => void;
|
||||
onsnapshot?: () => void;
|
||||
};
|
||||
let { open, capsuleId, pauseFirst = false, onclose, onsnapshot }: Props = $props();
|
||||
|
||||
let snapshotName = $state('');
|
||||
let snapshotting = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
function reset() {
|
||||
snapshotName = '';
|
||||
error = null;
|
||||
}
|
||||
|
||||
async function handleConfirm() {
|
||||
snapshotting = true;
|
||||
error = null;
|
||||
const result = await createSnapshot(capsuleId, snapshotName.trim() || undefined);
|
||||
if (result.ok) {
|
||||
reset();
|
||||
onsnapshot?.();
|
||||
onclose();
|
||||
} else {
|
||||
error = result.error;
|
||||
}
|
||||
snapshotting = false;
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
if (!snapshotting) {
|
||||
reset();
|
||||
onclose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black/60"
|
||||
onclick={handleClose}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') handleClose(); }}
|
||||
></div>
|
||||
|
||||
<div class="relative w-full max-w-[420px] overflow-hidden rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)]" style="animation: fadeUp 0.2s ease both; box-shadow: var(--shadow-dialog)">
|
||||
<div class="flex items-center gap-4 border-b border-[var(--color-border)] bg-[var(--color-bg-3)] px-6 py-5">
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-[var(--radius-input)] bg-[var(--color-accent)]/15 text-[var(--color-accent)] shadow-[0_0_12px_var(--color-accent-glow)]">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14.5 4h-5L7 7H2v13a2 2 0 002 2h16a2 2 0 002-2V7h-5l-2.5-3z" />
|
||||
<circle cx="12" cy="15" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="font-serif text-heading text-[var(--color-text-bright)]">Capture snapshot</h2>
|
||||
<p class="mt-0.5 text-meta text-[var(--color-text-muted)] font-mono">{capsuleId}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 pt-5 pb-6 space-y-4">
|
||||
{#if pauseFirst}
|
||||
<div class="flex items-start gap-2.5 rounded-[var(--radius-input)] border border-[var(--color-amber)]/25 bg-[var(--color-amber)]/8 px-3 py-2.5">
|
||||
<svg class="mt-px shrink-0 text-[var(--color-amber)]" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
<p class="text-meta text-[var(--color-amber)] leading-relaxed">This capsule will be <strong class="font-semibold">paused first</strong>, then its full state (memory + disk) will be captured.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-ui text-[var(--color-text-tertiary)]">The capsule's current state (memory + disk) will be captured and stored as a reusable snapshot.</p>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="rounded-[var(--radius-input)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-3 py-2 text-meta text-[var(--color-red)]">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<div class="mb-1.5 flex items-baseline justify-between">
|
||||
<label class="text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]" for="snapshot-name">Snapshot name</label>
|
||||
<span class="text-meta text-[var(--color-text-muted)]">optional</span>
|
||||
</div>
|
||||
<input
|
||||
id="snapshot-name"
|
||||
type="text"
|
||||
bind:value={snapshotName}
|
||||
disabled={snapshotting}
|
||||
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-colors duration-150 focus:border-[var(--color-accent)] disabled:opacity-50"
|
||||
placeholder="e.g. after-apt-install, pre-migration"
|
||||
onkeydown={(e) => { if (e.key === 'Enter' && !snapshotting) handleConfirm(); }}
|
||||
/>
|
||||
<p class="mt-1.5 text-meta text-[var(--color-text-muted)]">Leave blank to use an auto-generated name.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-1">
|
||||
<button
|
||||
onclick={handleClose}
|
||||
disabled={snapshotting}
|
||||
class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-4 py-2 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={handleConfirm}
|
||||
disabled={snapshotting}
|
||||
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
|
||||
>
|
||||
{#if snapshotting}
|
||||
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</svg>
|
||||
Capturing...
|
||||
{:else}
|
||||
Capture snapshot
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@ -26,6 +26,8 @@
|
||||
let chartRam: any = null;
|
||||
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let lastDataKey = ''; // cheap fingerprint to skip redundant chart redraws
|
||||
let visibilityHandler: (() => void) | null = null;
|
||||
|
||||
async function load() {
|
||||
const result = await fetchStats(range);
|
||||
@ -43,6 +45,10 @@
|
||||
|
||||
function updateCharts() {
|
||||
if (!stats) return;
|
||||
// Skip redraw if data hasn't changed (same length + same last label).
|
||||
const key = `${stats.series.labels.length}:${stats.series.labels.at(-1) ?? ''}`;
|
||||
if (key === lastDataKey) return;
|
||||
lastDataKey = key;
|
||||
// Use Array.from to pass plain JS arrays to Chart.js — Svelte 5 $state
|
||||
// wraps arrays in reactive proxies which Chart.js can't iterate reliably.
|
||||
const labels = formatLabels(Array.from(stats.series.labels), range);
|
||||
@ -77,14 +83,19 @@
|
||||
});
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollInterval) { clearInterval(pollInterval); pollInterval = null; }
|
||||
}
|
||||
|
||||
function restartPolling() {
|
||||
if (pollInterval) clearInterval(pollInterval);
|
||||
stopPolling();
|
||||
load();
|
||||
pollInterval = setInterval(load, POLL_INTERVALS[range]);
|
||||
}
|
||||
|
||||
function setRange(r: TimeRange) {
|
||||
range = r;
|
||||
lastDataKey = ''; // force chart redraw on range switch
|
||||
goto(`?range=${r}`, { replaceState: true, noScroll: true, keepFocus: true });
|
||||
restartPolling();
|
||||
}
|
||||
@ -185,7 +196,7 @@
|
||||
...BASE_CHART_OPTIONS.scales.y,
|
||||
ticks: {
|
||||
...BASE_CHART_OPTIONS.scales.y.ticks,
|
||||
callback: (v: number) => `${v}`,
|
||||
callback: (v: string | number) => `${v}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -215,7 +226,8 @@
|
||||
tooltip: {
|
||||
...BASE_CHART_OPTIONS.plugins.tooltip,
|
||||
callbacks: {
|
||||
label: (ctx: { parsed: { y: number } }) => ` ${ctx.parsed.y.toFixed(1)} GB`,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
label: (ctx: any) => ` ${ctx.parsed.y.toFixed(1)} GB`,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -225,7 +237,7 @@
|
||||
...BASE_CHART_OPTIONS.scales.y,
|
||||
ticks: {
|
||||
...BASE_CHART_OPTIONS.scales.y.ticks,
|
||||
callback: (v: number) => `${(+v).toFixed(1)} GB`,
|
||||
callback: (v: string | number) => `${(+v).toFixed(1)} GB`,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -236,10 +248,21 @@
|
||||
updateCharts();
|
||||
|
||||
restartPolling();
|
||||
|
||||
// Pause polling when the browser tab is hidden to save bandwidth/CPU.
|
||||
visibilityHandler = () => {
|
||||
if (document.hidden) {
|
||||
stopPolling();
|
||||
} else {
|
||||
restartPolling();
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', visibilityHandler);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (pollInterval) clearInterval(pollInterval);
|
||||
stopPolling();
|
||||
if (visibilityHandler) document.removeEventListener('visibilitychange', visibilityHandler);
|
||||
chartRunning?.destroy();
|
||||
chartCpu?.destroy();
|
||||
chartRam?.destroy();
|
||||
@ -312,7 +335,7 @@
|
||||
</div>
|
||||
<div class="bg-[var(--color-bg-2)] px-6 py-6 transition-colors duration-150 hover:bg-[var(--color-bg-3)]">
|
||||
<div class="text-label text-[var(--color-text-muted)]">Peak · 30d</div>
|
||||
<div class="mt-2 font-serif text-[1.714rem] leading-none tracking-[-0.03em] text-[var(--color-text-secondary)]">
|
||||
<div class="mt-2 font-serif text-[1.714rem] leading-none text-[var(--color-text-secondary)]">
|
||||
{loading ? '—' : (stats?.peaks.running_count ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
@ -334,7 +357,7 @@
|
||||
</div>
|
||||
<div class="bg-[var(--color-bg-2)] px-6 py-6 transition-colors duration-150 hover:bg-[var(--color-bg-3)]">
|
||||
<div class="text-label text-[var(--color-text-muted)]">Peak · 30d</div>
|
||||
<div class="mt-2 font-serif text-[1.714rem] leading-none tracking-[-0.03em] text-[var(--color-text-secondary)]">
|
||||
<div class="mt-2 font-serif text-[1.714rem] leading-none text-[var(--color-text-secondary)]">
|
||||
{loading ? '—' : (stats?.peaks.vcpus ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
@ -356,7 +379,7 @@
|
||||
</div>
|
||||
<div class="bg-[var(--color-bg-2)] px-6 py-6 transition-colors duration-150 hover:bg-[var(--color-bg-3)]">
|
||||
<div class="text-label text-[var(--color-text-muted)]">Peak · 30d</div>
|
||||
<div class="mt-2 font-serif text-[1.714rem] leading-none tracking-[-0.03em] text-[var(--color-text-secondary)]">
|
||||
<div class="mt-2 font-serif text-[1.714rem] leading-none text-[var(--color-text-secondary)]">
|
||||
{loading ? '—' : fmtGB(stats?.peaks.memory_mb ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
602
frontend/src/lib/components/TerminalTab.svelte
Normal file
602
frontend/src/lib/components/TerminalTab.svelte
Normal file
@ -0,0 +1,602 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, tick } from 'svelte';
|
||||
import { auth } from '$lib/auth.svelte';
|
||||
|
||||
type Props = {
|
||||
capsuleId: string;
|
||||
isRunning: boolean;
|
||||
visible?: boolean;
|
||||
apiBasePath?: string;
|
||||
};
|
||||
|
||||
let { capsuleId, isRunning, visible = true, apiBasePath = '/api/v1/capsules' }: Props = $props();
|
||||
|
||||
type ConnectionState = 'idle' | 'connecting' | 'connected' | 'disconnected' | 'error';
|
||||
|
||||
type SessionDisplay = {
|
||||
id: number;
|
||||
state: ConnectionState;
|
||||
errorMessage: string | null;
|
||||
ptyTag: string | null;
|
||||
ptyPid: number | null;
|
||||
};
|
||||
|
||||
type SessionInternal = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
term: any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
fitAddon: any;
|
||||
ws: WebSocket | null;
|
||||
resizeObserver: ResizeObserver | null;
|
||||
fitDebounce: ReturnType<typeof setTimeout> | null;
|
||||
inputFlushTimer: ReturnType<typeof setTimeout> | null;
|
||||
inputBuffer: string;
|
||||
};
|
||||
|
||||
const MAX_SESSIONS = 8;
|
||||
|
||||
let sessions = $state<SessionDisplay[]>([]);
|
||||
const internals = new Map<number, SessionInternal>();
|
||||
let activeSessionId = $state<number | null>(null);
|
||||
let nextId = 0;
|
||||
let cssLoaded = false;
|
||||
let containerRef = $state<HTMLDivElement | undefined>(undefined);
|
||||
let hasAutoCreated = false;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let TerminalClass: any = null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let FitAddonClass: any = null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let WebLinksAddonClass: any = null;
|
||||
|
||||
const activeSession = $derived(sessions.find(s => s.id === activeSessionId) ?? null);
|
||||
|
||||
const TERM_THEME = {
|
||||
background: '#0a0c0b',
|
||||
foreground: '#d0cdc6',
|
||||
cursor: '#5e8c58',
|
||||
cursorAccent: '#0a0c0b',
|
||||
selectionBackground: 'rgba(94, 140, 88, 0.25)',
|
||||
selectionForeground: '#eae7e2',
|
||||
selectionInactiveBackground: 'rgba(94, 140, 88, 0.12)',
|
||||
black: '#1a1e1c',
|
||||
red: '#cf8172',
|
||||
green: '#5e8c58',
|
||||
yellow: '#d4a73c',
|
||||
blue: '#5a9fd4',
|
||||
magenta: '#b07ab8',
|
||||
cyan: '#5aafb0',
|
||||
white: '#d0cdc6',
|
||||
brightBlack: '#454340',
|
||||
brightRed: '#e09585',
|
||||
brightGreen: '#89a785',
|
||||
brightYellow: '#e0c070',
|
||||
brightBlue: '#7ab8e0',
|
||||
brightMagenta: '#c898cf',
|
||||
brightCyan: '#7ac5c6',
|
||||
brightWhite: '#eae7e2',
|
||||
};
|
||||
|
||||
// Binary-safe base64 encode (handles multi-byte UTF-8 from xterm onData)
|
||||
function toBase64(str: string): string {
|
||||
return btoa(
|
||||
Array.from(new TextEncoder().encode(str), (b) => String.fromCharCode(b)).join('')
|
||||
);
|
||||
}
|
||||
|
||||
// Binary-safe base64 decode (handles raw PTY bytes)
|
||||
function fromBase64(b64: string): string {
|
||||
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
function getWsUrl(): string {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return `${proto}//${window.location.host}${apiBasePath}/${capsuleId}/pty`;
|
||||
}
|
||||
|
||||
function wsSend(ws: WebSocket | null, data: string) {
|
||||
try {
|
||||
if (ws?.readyState === WebSocket.OPEN) ws.send(data);
|
||||
} catch {
|
||||
// Connection closing — ignore
|
||||
}
|
||||
}
|
||||
|
||||
function updateSession(id: number, updates: Partial<SessionDisplay>) {
|
||||
const idx = sessions.findIndex(s => s.id === id);
|
||||
if (idx === -1) return;
|
||||
Object.assign(sessions[idx], updates);
|
||||
}
|
||||
|
||||
async function loadModules() {
|
||||
if (TerminalClass) return;
|
||||
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([
|
||||
import('@xterm/xterm'),
|
||||
import('@xterm/addon-fit'),
|
||||
import('@xterm/addon-web-links')
|
||||
]);
|
||||
TerminalClass = Terminal;
|
||||
FitAddonClass = FitAddon;
|
||||
WebLinksAddonClass = WebLinksAddon;
|
||||
if (!cssLoaded) {
|
||||
await import('@xterm/xterm/css/xterm.css');
|
||||
cssLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Create first session when the tab becomes visible for the first time
|
||||
$effect(() => {
|
||||
if (visible && isRunning && !hasAutoCreated && containerRef) {
|
||||
hasAutoCreated = true;
|
||||
createSession();
|
||||
}
|
||||
});
|
||||
|
||||
// Re-fit active terminal when tab becomes visible (after being hidden)
|
||||
$effect(() => {
|
||||
if (visible && activeSessionId !== null) {
|
||||
const int = internals.get(activeSessionId);
|
||||
if (int?.fitAddon && int.term) {
|
||||
requestAnimationFrame(() => {
|
||||
int.fitAddon.fit();
|
||||
int.term.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Close all sessions when capsule stops running
|
||||
$effect(() => {
|
||||
if (!isRunning && sessions.length > 0) {
|
||||
// Copy IDs to avoid mutating during iteration
|
||||
const ids = sessions.map(s => s.id);
|
||||
for (const id of ids) closeSession(id);
|
||||
}
|
||||
});
|
||||
|
||||
async function createSession() {
|
||||
if (!isRunning || !containerRef) return;
|
||||
if (sessions.length >= MAX_SESSIONS) return;
|
||||
await loadModules();
|
||||
|
||||
const id = nextId++;
|
||||
|
||||
sessions = [...sessions, {
|
||||
id,
|
||||
state: 'connecting',
|
||||
errorMessage: null,
|
||||
ptyTag: null,
|
||||
ptyPid: null,
|
||||
}];
|
||||
activeSessionId = id;
|
||||
|
||||
await tick();
|
||||
|
||||
const el = containerRef?.querySelector(`[data-session-id="${id}"]`) as HTMLDivElement | null;
|
||||
if (!el) {
|
||||
// DOM didn't render — clean up the orphaned display entry
|
||||
sessions = sessions.filter(s => s.id !== id);
|
||||
if (activeSessionId === id) activeSessionId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const fitAddon = new FitAddonClass();
|
||||
const term = new TerminalClass({
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'bar',
|
||||
cursorInactiveStyle: 'outline',
|
||||
fontFamily: "'JetBrains Mono Variable', 'JetBrains Mono', monospace",
|
||||
fontSize: 14,
|
||||
lineHeight: 1.35,
|
||||
letterSpacing: 0,
|
||||
theme: TERM_THEME,
|
||||
allowProposedApi: true,
|
||||
scrollback: 5000,
|
||||
convertEol: true,
|
||||
});
|
||||
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(new WebLinksAddonClass());
|
||||
term.open(el);
|
||||
|
||||
const internal: SessionInternal = {
|
||||
term,
|
||||
fitAddon,
|
||||
ws: null,
|
||||
resizeObserver: null,
|
||||
fitDebounce: null,
|
||||
inputFlushTimer: null,
|
||||
inputBuffer: '',
|
||||
};
|
||||
internals.set(id, internal);
|
||||
|
||||
requestAnimationFrame(() => fitAddon.fit());
|
||||
|
||||
internal.resizeObserver = new ResizeObserver(() => {
|
||||
if (internal.fitDebounce) clearTimeout(internal.fitDebounce);
|
||||
internal.fitDebounce = setTimeout(() => {
|
||||
if (internal.fitAddon && internal.term && activeSessionId === id) {
|
||||
internal.fitAddon.fit();
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
internal.resizeObserver.observe(el);
|
||||
|
||||
// Register input/resize handlers ONCE per terminal (not per connection).
|
||||
function flushInput() {
|
||||
const int = internals.get(id);
|
||||
if (!int) return;
|
||||
int.inputFlushTimer = null;
|
||||
if (!int.inputBuffer) return;
|
||||
wsSend(int.ws, JSON.stringify({ type: 'input', data: toBase64(int.inputBuffer) }));
|
||||
int.inputBuffer = '';
|
||||
}
|
||||
|
||||
term.onData((data: string) => {
|
||||
const int = internals.get(id);
|
||||
if (!int) return;
|
||||
int.inputBuffer += data;
|
||||
if (!int.inputFlushTimer) {
|
||||
int.inputFlushTimer = setTimeout(flushInput, 50);
|
||||
}
|
||||
});
|
||||
|
||||
term.onResize(({ cols, rows }: { cols: number; rows: number }) => {
|
||||
const i = internals.get(id);
|
||||
wsSend(i?.ws ?? null, JSON.stringify({ type: 'resize', cols, rows }));
|
||||
});
|
||||
|
||||
connectSession(id);
|
||||
}
|
||||
|
||||
function connectSession(id: number, reconnectTag?: string) {
|
||||
const int = internals.get(id);
|
||||
if (!int) return;
|
||||
|
||||
if (!auth.token) {
|
||||
updateSession(id, { state: 'error', errorMessage: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
const display = sessions.find(s => s.id === id);
|
||||
const tag = reconnectTag ?? display?.ptyTag;
|
||||
|
||||
const ws = new WebSocket(getWsUrl());
|
||||
int.ws = ws;
|
||||
updateSession(id, { state: 'connecting', errorMessage: null });
|
||||
|
||||
ws.onopen = () => {
|
||||
// Send auth as the first message (JWT no longer in URL).
|
||||
wsSend(ws, JSON.stringify({ type: 'auth', token: auth.token }));
|
||||
const { cols, rows } = int.term;
|
||||
const msg: Record<string, unknown> = {
|
||||
type: tag ? 'connect' : 'start',
|
||||
cols,
|
||||
rows,
|
||||
};
|
||||
if (tag) {
|
||||
msg.tag = tag;
|
||||
} else {
|
||||
msg.cmd = '/bin/bash';
|
||||
msg.envs = { TERM: 'xterm-256color' };
|
||||
}
|
||||
wsSend(ws, JSON.stringify(msg));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
switch (msg.type) {
|
||||
case 'started':
|
||||
updateSession(id, {
|
||||
state: 'connected',
|
||||
ptyTag: msg.tag,
|
||||
ptyPid: msg.pid ?? null,
|
||||
});
|
||||
if (activeSessionId === id) int.term.focus();
|
||||
break;
|
||||
case 'output':
|
||||
if (msg.data) int.term.write(fromBase64(msg.data));
|
||||
break;
|
||||
case 'exit':
|
||||
closeSession(id);
|
||||
break;
|
||||
case 'error':
|
||||
if (msg.fatal) {
|
||||
updateSession(id, { state: 'error', errorMessage: msg.data || 'Connection error' });
|
||||
int.term.write(`\r\n\x1b[38;2;207;129;114m${msg.data}\x1b[0m\r\n`);
|
||||
}
|
||||
break;
|
||||
case 'ping':
|
||||
wsSend(ws, JSON.stringify({ type: 'pong' }));
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed messages
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
const s = sessions.find(s => s.id === id);
|
||||
if (!s) return;
|
||||
|
||||
// Abnormal close with a live session — auto-reconnect
|
||||
if (!event.wasClean && s.state === 'connected' && s.ptyTag) {
|
||||
updateSession(id, { state: 'connecting', errorMessage: null });
|
||||
int.term.write('\r\n\x1b[38;2;107;104;98m[reconnecting...]\x1b[0m\r\n');
|
||||
setTimeout(() => connectSession(id, s.ptyTag ?? undefined), 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (s.state === 'connected') {
|
||||
updateSession(id, { state: 'disconnected' });
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
updateSession(id, { state: 'error', errorMessage: 'Connection lost — check that the capsule is running' });
|
||||
};
|
||||
}
|
||||
|
||||
function switchTo(id: number) {
|
||||
activeSessionId = id;
|
||||
requestAnimationFrame(() => {
|
||||
const int = internals.get(id);
|
||||
if (int?.fitAddon && int.term) {
|
||||
int.fitAddon.fit();
|
||||
int.term.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function closeSession(id: number) {
|
||||
const idx = sessions.findIndex(s => s.id === id);
|
||||
if (idx === -1) return;
|
||||
|
||||
const int = internals.get(id);
|
||||
if (int) {
|
||||
if (int.fitDebounce) clearTimeout(int.fitDebounce);
|
||||
if (int.inputFlushTimer) clearTimeout(int.inputFlushTimer);
|
||||
int.resizeObserver?.disconnect();
|
||||
wsSend(int.ws, JSON.stringify({ type: 'kill' }));
|
||||
int.ws?.close();
|
||||
int.term?.dispose();
|
||||
internals.delete(id);
|
||||
}
|
||||
|
||||
sessions = sessions.filter(s => s.id !== id);
|
||||
|
||||
if (activeSessionId === id) {
|
||||
if (sessions.length === 0) {
|
||||
activeSessionId = null;
|
||||
} else {
|
||||
const newIdx = Math.min(idx, sessions.length - 1);
|
||||
switchTo(sessions[newIdx].id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function reconnectSession(id: number) {
|
||||
const int = internals.get(id);
|
||||
const display = sessions.find(s => s.id === id);
|
||||
if (!int || !display) return;
|
||||
int.ws?.close();
|
||||
connectSession(id, display.ptyTag ?? undefined);
|
||||
}
|
||||
|
||||
function statusDot(state: ConnectionState): string {
|
||||
switch (state) {
|
||||
case 'connected': return 'bg-[var(--color-accent)]';
|
||||
case 'connecting': return 'bg-[var(--color-text-tertiary)] animate-pulse';
|
||||
case 'error': return 'bg-[var(--color-red)]';
|
||||
default: return 'bg-[var(--color-text-muted)]';
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
for (const [, int] of internals) {
|
||||
if (int.fitDebounce) clearTimeout(int.fitDebounce);
|
||||
if (int.inputFlushTimer) clearTimeout(int.inputFlushTimer);
|
||||
int.resizeObserver?.disconnect();
|
||||
int.ws?.close();
|
||||
int.term?.dispose();
|
||||
}
|
||||
internals.clear();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.terminal-container :global(.xterm) {
|
||||
padding: 12px 4px 12px 16px;
|
||||
height: 100%;
|
||||
}
|
||||
.terminal-container :global(.xterm-viewport),
|
||||
.terminal-container :global(.xterm-screen) {
|
||||
background-color: #0a0c0b !important;
|
||||
}
|
||||
.terminal-container :global(.xterm-viewport) {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(94, 140, 88, 0.18) transparent;
|
||||
}
|
||||
.terminal-container :global(.xterm-viewport::-webkit-scrollbar) {
|
||||
width: 6px;
|
||||
}
|
||||
.terminal-container :global(.xterm-viewport::-webkit-scrollbar-track) {
|
||||
background: transparent;
|
||||
}
|
||||
.terminal-container :global(.xterm-viewport::-webkit-scrollbar-thumb) {
|
||||
background: rgba(94, 140, 88, 0.18);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.terminal-container :global(.xterm-viewport::-webkit-scrollbar-thumb:hover) {
|
||||
background: rgba(94, 140, 88, 0.32);
|
||||
}
|
||||
.tab-scroll {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.tab-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.term-tab {
|
||||
position: relative;
|
||||
}
|
||||
.term-tab::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 25%;
|
||||
bottom: 25%;
|
||||
width: 1px;
|
||||
background: var(--color-border);
|
||||
}
|
||||
.term-tab:last-child::after {
|
||||
display: none;
|
||||
}
|
||||
.term-tab-active::after {
|
||||
display: none;
|
||||
}
|
||||
.term-tab:has(+ .term-tab-active)::after {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="flex flex-1 flex-col min-h-0">
|
||||
{#if !isRunning}
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<div class="flex flex-col items-center gap-5 text-center">
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)]" style="animation: iconFloat 3s ease-in-out infinite">
|
||||
<svg class="text-[var(--color-text-muted)]" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="4 17 10 11 4 5" /><line x1="12" y1="19" x2="20" y2="19" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="text-body font-medium text-[var(--color-text-secondary)]">Terminal unavailable</span>
|
||||
<span class="text-ui text-[var(--color-text-muted)]">Start the capsule to connect</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Unified session bar (hidden when no sessions) -->
|
||||
<div class="flex items-stretch bg-[var(--color-bg-1)]" style:display={sessions.length === 0 ? 'none' : 'flex'}>
|
||||
<div class="tab-scroll flex items-stretch overflow-x-auto">
|
||||
{#each sessions as session (session.id)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
onclick={() => switchTo(session.id)}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') switchTo(session.id); }}
|
||||
role="tab"
|
||||
tabindex="0"
|
||||
aria-selected={session.id === activeSessionId}
|
||||
class="term-tab group flex shrink-0 cursor-pointer items-center gap-2.5 px-5 py-2.5 text-meta transition-colors
|
||||
{session.id === activeSessionId
|
||||
? 'term-tab-active bg-[var(--color-bg-0)] text-[var(--color-text-primary)]'
|
||||
: 'bg-[var(--color-bg-1)] text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-2)] hover:text-[var(--color-text-secondary)] border-b border-b-[var(--color-border)]'}"
|
||||
>
|
||||
{#if session.state === 'connected'}
|
||||
<span class="relative flex h-[7px] w-[7px] shrink-0">
|
||||
<span class="animate-status-ping absolute inline-flex h-full w-full rounded-full bg-[var(--color-accent)]"></span>
|
||||
<span class="relative inline-flex h-[7px] w-[7px] rounded-full bg-[var(--color-accent)]"></span>
|
||||
</span>
|
||||
{:else if session.state === 'connecting'}
|
||||
<svg class="animate-spin shrink-0 text-[var(--color-text-tertiary)]" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
|
||||
{:else if session.state === 'error'}
|
||||
<span class="h-[7px] w-[7px] shrink-0 rounded-full bg-[var(--color-red)]"></span>
|
||||
{:else}
|
||||
<span class="h-[7px] w-[7px] shrink-0 rounded-full bg-[var(--color-text-muted)]"></span>
|
||||
{/if}
|
||||
|
||||
<span class="font-mono">
|
||||
bash{#if session.ptyPid}<span class="text-[var(--color-text-muted)]">:{session.ptyPid}</span>{/if}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onclick={(e) => { e.stopPropagation(); closeSession(session.id); }}
|
||||
class="ml-0.5 flex h-5 w-5 items-center justify-center rounded-[3px] text-[var(--color-text-muted)] opacity-0 transition-all group-hover:opacity-100 hover:bg-[var(--color-bg-4)] hover:text-[var(--color-text-secondary)]"
|
||||
title="Close session"
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick={createSession}
|
||||
disabled={sessions.length >= MAX_SESSIONS}
|
||||
class="flex shrink-0 items-center justify-center aspect-square self-stretch border-b border-[var(--color-border)] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-bg-2)] hover:text-[var(--color-text-primary)] disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-[var(--color-text-tertiary)]"
|
||||
title={sessions.length >= MAX_SESSIONS ? `Maximum ${MAX_SESSIONS} sessions` : 'New terminal session'}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="flex-1 border-b border-[var(--color-border)] bg-[var(--color-bg-1)]"></div>
|
||||
|
||||
{#if activeSession}
|
||||
<div class="flex items-center gap-3 border-b border-[var(--color-border)] bg-[var(--color-bg-1)] pr-4">
|
||||
{#if activeSession.state === 'error' && activeSession.errorMessage}
|
||||
<span class="text-meta text-[var(--color-red)]/70">{activeSession.errorMessage}</span>
|
||||
{/if}
|
||||
|
||||
{#if (activeSession.state === 'disconnected' || activeSession.state === 'error') && activeSession.ptyTag}
|
||||
<button
|
||||
onclick={() => activeSession && reconnectSession(activeSession.id)}
|
||||
class="flex items-center gap-1.5 rounded-[var(--radius-button)] border border-[var(--color-border)] bg-[var(--color-bg-3)] px-3 py-1 text-meta font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-bg-4)] hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="1 4 1 10 7 10" /><polyline points="23 20 23 14 17 14" />
|
||||
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" />
|
||||
</svg>
|
||||
Reconnect
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if activeSession.ptyTag}
|
||||
<span class="font-mono text-label text-[var(--color-text-muted)]">{activeSession.ptyTag}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Terminal surfaces -->
|
||||
<div class="relative flex-1 min-h-0 bg-[var(--color-bg-0)]" bind:this={containerRef}>
|
||||
{#each sessions as session (session.id)}
|
||||
<div
|
||||
data-session-id={session.id}
|
||||
class="terminal-container absolute inset-0 bg-[var(--color-bg-0)]"
|
||||
style:display={session.id === activeSessionId ? 'block' : 'none'}
|
||||
></div>
|
||||
{/each}
|
||||
|
||||
{#if sessions.length === 0}
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<div class="flex flex-col items-center gap-5 text-center">
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)]" style="animation: iconFloat 3s ease-in-out infinite">
|
||||
<svg class="text-[var(--color-text-muted)]" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="4 17 10 11 4 5" /><line x1="12" y1="19" x2="20" y2="19" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="text-body font-medium text-[var(--color-text-secondary)]">No active sessions</span>
|
||||
<span class="text-ui text-[var(--color-text-muted)]">All terminal sessions have been closed</span>
|
||||
</div>
|
||||
<button
|
||||
onclick={createSession}
|
||||
class="mt-1 flex items-center gap-2 rounded-[var(--radius-button)] border border-[var(--color-accent)]/30 bg-[var(--color-accent-glow-mid)] px-5 py-2.5 text-ui font-semibold text-[var(--color-accent-bright)] transition-all duration-150 hover:border-[var(--color-accent)]/50 hover:bg-[var(--color-accent)]/15 hover:-translate-y-px active:translate-y-0"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
New session
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@ -13,6 +13,7 @@
|
||||
<span class="flex-1 leading-relaxed">{t.message}</span>
|
||||
<button
|
||||
onclick={() => toast.dismiss(t.id)}
|
||||
aria-label="Dismiss"
|
||||
class="mt-0.5 shrink-0 opacity-50 transition-opacity hover:opacity-100"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
|
||||
128
frontend/src/lib/highlight.ts
Normal file
128
frontend/src/lib/highlight.ts
Normal file
@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Lazy syntax highlighting via shiki.
|
||||
*
|
||||
* The highlighter WASM engine + theme are loaded on first use.
|
||||
* Language grammars load on-demand per extension.
|
||||
* All imports are dynamic so nothing touches the main bundle.
|
||||
*/
|
||||
|
||||
import type { HighlighterGeneric, ThemedToken } from 'shiki';
|
||||
export type { ThemedToken };
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let highlighter: HighlighterGeneric<any, any> | null = null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let loadingPromise: Promise<HighlighterGeneric<any, any>> | null = null;
|
||||
|
||||
const THEME = 'vesper';
|
||||
|
||||
// Extensions → shiki language IDs.
|
||||
// Only map what we expect users to encounter in capsules.
|
||||
const EXT_TO_LANG: Record<string, string> = {
|
||||
// Go
|
||||
go: 'go', mod: 'go', sum: 'go',
|
||||
// Python
|
||||
py: 'python', pyi: 'python', pyx: 'python',
|
||||
// JavaScript / TypeScript
|
||||
js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'jsx',
|
||||
ts: 'typescript', mts: 'typescript', cts: 'typescript', tsx: 'tsx',
|
||||
// Rust
|
||||
rs: 'rust',
|
||||
// Shell
|
||||
sh: 'shellscript', bash: 'shellscript', zsh: 'shellscript',
|
||||
// Config
|
||||
json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml', ini: 'ini',
|
||||
env: 'shellscript',
|
||||
// Markup / docs
|
||||
md: 'markdown', mdx: 'mdx', html: 'html', htm: 'html', xml: 'xml',
|
||||
// CSS
|
||||
css: 'css', scss: 'scss', less: 'less',
|
||||
// SQL
|
||||
sql: 'sql',
|
||||
// Svelte / Vue
|
||||
svelte: 'svelte', vue: 'vue',
|
||||
// Docker / Make
|
||||
dockerfile: 'dockerfile',
|
||||
makefile: 'makefile',
|
||||
// Proto
|
||||
proto: 'protobuf',
|
||||
// C / C++
|
||||
c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', cxx: 'cpp', hpp: 'cpp',
|
||||
// Java / Kotlin
|
||||
java: 'java', kt: 'kotlin', kts: 'kotlin',
|
||||
// Ruby
|
||||
rb: 'ruby',
|
||||
// PHP
|
||||
php: 'php',
|
||||
// Lua
|
||||
lua: 'lua',
|
||||
// Misc
|
||||
txt: 'plaintext',
|
||||
};
|
||||
|
||||
// Filenames without extensions
|
||||
const NAME_TO_LANG: Record<string, string> = {
|
||||
Dockerfile: 'dockerfile',
|
||||
Makefile: 'makefile',
|
||||
Containerfile: 'dockerfile',
|
||||
Vagrantfile: 'ruby',
|
||||
};
|
||||
|
||||
/** Resolve a filename to a shiki language ID, or null if unknown. */
|
||||
export function langFromFilename(name: string): string | null {
|
||||
// Check full filename first (Dockerfile, Makefile, etc.)
|
||||
const basename = name.includes('/') ? name.slice(name.lastIndexOf('/') + 1) : name;
|
||||
if (NAME_TO_LANG[basename]) return NAME_TO_LANG[basename];
|
||||
|
||||
const dot = basename.lastIndexOf('.');
|
||||
if (dot <= 0) return null;
|
||||
const ext = basename.slice(dot + 1).toLowerCase();
|
||||
return EXT_TO_LANG[ext] ?? null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async function getHighlighter(): Promise<HighlighterGeneric<any, any>> {
|
||||
if (highlighter) return highlighter;
|
||||
if (loadingPromise) return loadingPromise;
|
||||
|
||||
loadingPromise = (async () => {
|
||||
const { createHighlighter } = await import('shiki');
|
||||
|
||||
const h = await createHighlighter({
|
||||
themes: [THEME],
|
||||
langs: [], // load languages on demand
|
||||
});
|
||||
highlighter = h;
|
||||
return h;
|
||||
})();
|
||||
|
||||
return loadingPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize code for a given language.
|
||||
* Returns an array of lines, each containing themed tokens with `color` and `content`.
|
||||
* Returns null if the language is unknown or highlighting fails.
|
||||
*/
|
||||
export async function tokenize(
|
||||
code: string,
|
||||
filename: string,
|
||||
): Promise<ThemedToken[][] | null> {
|
||||
const lang = langFromFilename(filename);
|
||||
if (!lang || lang === 'plaintext') return null;
|
||||
|
||||
try {
|
||||
const h = await getHighlighter();
|
||||
|
||||
// Load grammar on demand if not yet loaded
|
||||
const loaded = h.getLoadedLanguages();
|
||||
if (!loaded.includes(lang)) {
|
||||
await h.loadLanguage(lang);
|
||||
}
|
||||
|
||||
return h.codeToTokensBase(code, { lang, theme: THEME });
|
||||
} catch {
|
||||
// Grammar not available or other error — fall back to plain text
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user