1
0
forked from wrenn/wrenn
Co-authored-by: Tasnim Kabir Sadik <tksadik92@gmail.com>
Reviewed-on: wrenn/sandbox#8
This commit is contained in:
2026-04-09 19:24:49 +00:00
parent 32e5a5a715
commit d3e4812e46
199 changed files with 24552 additions and 2776 deletions

View File

@ -0,0 +1,38 @@
import { apiFetch, type ApiResult } from '$lib/api/client';
export type AuditLog = {
id: string;
actor_type: 'user' | 'api_key' | 'system';
actor_id?: string;
actor_name?: string;
resource_type: string;
resource_id?: string;
action: string;
scope: 'team' | 'admin';
status: 'success' | 'info' | 'warning' | 'error';
metadata?: Record<string, unknown>;
created_at: string;
};
export type AuditListResponse = {
items: AuditLog[];
next_before?: string;
next_before_id?: string;
};
export async function listAuditLogs(params?: {
before?: string;
before_id?: string;
resource_types?: string[];
actions?: string[];
limit?: number;
}): Promise<ApiResult<AuditListResponse>> {
const q = new URLSearchParams();
if (params?.before) q.set('before', params.before);
if (params?.before_id) q.set('before_id', params.before_id);
params?.resource_types?.forEach((t) => q.append('resource_type', t));
params?.actions?.forEach((a) => q.append('action', a));
if (params?.limit != null) q.set('limit', String(params.limit));
const qs = q.toString();
return apiFetch('GET', `/api/v1/audit-logs${qs ? '?' + qs : ''}`);
}

View File

@ -3,6 +3,7 @@ export type AuthResponse = {
user_id: string;
team_id: string;
email: string;
name: string;
};
export type AuthResult = { ok: true; data: AuthResponse } | { ok: false; error: string };
@ -11,8 +12,8 @@ export async function apiLogin(email: string, password: string): Promise<AuthRes
return authFetch('/api/v1/auth/login', { email, password });
}
export async function apiSignup(email: string, password: string): Promise<AuthResult> {
return authFetch('/api/v1/auth/signup', { email, password });
export async function apiSignup(email: string, password: string, name: string): Promise<AuthResult> {
return authFetch('/api/v1/auth/signup', { email, password, name });
}
async function authFetch(url: string, body: Record<string, string>): Promise<AuthResult> {

View File

@ -0,0 +1,76 @@
import { apiFetch, type ApiResult } from '$lib/api/client';
export type BuildLogEntry = {
step: number;
phase: string; // "pre-build", "recipe", or "post-build"
cmd: string;
stdout: string;
stderr: string;
exit: number;
ok: boolean;
elapsed_ms: number;
};
export type Build = {
id: string;
name: string;
base_template: string;
recipe: string[];
healthcheck?: string;
vcpus: number;
memory_mb: number;
status: string;
current_step: number;
total_steps: number;
logs: BuildLogEntry[];
error?: string;
sandbox_id?: string;
host_id?: string;
created_at: string;
started_at?: string;
completed_at?: string;
};
export type CreateBuildParams = {
name: string;
base_template?: string;
recipe: string[];
healthcheck?: string;
vcpus?: number;
memory_mb?: number;
skip_pre_post?: boolean;
};
export async function createBuild(params: CreateBuildParams): Promise<ApiResult<Build>> {
return apiFetch('POST', '/api/v1/admin/builds', params);
}
export async function listBuilds(): Promise<ApiResult<Build[]>> {
return apiFetch('GET', '/api/v1/admin/builds');
}
export async function getBuild(id: string): Promise<ApiResult<Build>> {
return apiFetch('GET', `/api/v1/admin/builds/${id}`);
}
export type AdminTemplate = {
name: string;
type: string;
vcpus: number;
memory_mb: number;
size_bytes: number;
team_id: string;
created_at: string;
};
export async function listAdminTemplates(): Promise<ApiResult<AdminTemplate[]>> {
return apiFetch('GET', '/api/v1/admin/templates');
}
export async function deleteAdminTemplate(name: string): Promise<ApiResult<void>> {
return apiFetch('DELETE', `/api/v1/admin/templates/${name}`);
}
export async function cancelBuild(id: string): Promise<ApiResult<void>> {
return apiFetch('POST', `/api/v1/admin/builds/${id}/cancel`);
}

View File

@ -20,6 +20,10 @@ export async function listCapsules(): Promise<ApiResult<Capsule[]>> {
return apiFetch('GET', '/api/v1/sandboxes');
}
export async function getCapsule(id: string): Promise<ApiResult<Capsule>> {
return apiFetch('GET', `/api/v1/sandboxes/${id}`);
}
export type CreateCapsuleParams = {
template?: string;
vcpus?: number;
@ -50,6 +54,7 @@ export type Snapshot = {
memory_mb?: number;
size_bytes: number;
created_at: string;
platform: boolean;
};
export async function createSnapshot(sandboxId: string, name?: string): Promise<ApiResult<Snapshot>> {

View File

@ -0,0 +1,72 @@
import { apiFetch, type ApiResult } from '$lib/api/client';
export type Channel = {
id: string;
team_id: string;
name: string;
provider: string;
events: string[];
created_at: string;
updated_at: string;
secret?: string; // only present immediately after creation (webhook provider)
};
export const PROVIDERS = [
{ value: 'discord', label: 'Discord', fields: ['webhook_url'] },
{ value: 'slack', label: 'Slack', fields: ['webhook_url'] },
{ value: 'teams', label: 'Teams', fields: ['webhook_url'] },
{ value: 'googlechat', label: 'Google Chat', fields: ['webhook_url'] },
{ value: 'telegram', label: 'Telegram', fields: ['bot_token', 'chat_id'] },
{ value: 'matrix', label: 'Matrix', fields: ['homeserver_url', 'access_token', 'room_id'] },
{ value: 'webhook', label: 'Webhook', fields: ['url'] }
] as const;
export const EVENT_TYPES = [
{ value: 'capsule.created', group: 'Capsule' },
{ value: 'capsule.running', group: 'Capsule' },
{ value: 'capsule.paused', group: 'Capsule' },
{ value: 'capsule.destroyed', group: 'Capsule' },
{ value: 'template.snapshot.created', group: 'Template' },
{ value: 'template.snapshot.deleted', group: 'Template' },
{ value: 'host.up', group: 'Host' },
{ value: 'host.down', group: 'Host' }
] as const;
export async function listChannels(): Promise<ApiResult<Channel[]>> {
return apiFetch('GET', '/api/v1/channels');
}
export async function createChannel(
name: string,
provider: string,
config: Record<string, string>,
events: string[]
): Promise<ApiResult<Channel>> {
return apiFetch('POST', '/api/v1/channels', { name, provider, config, events });
}
export async function updateChannel(
id: string,
name: string,
events: string[]
): Promise<ApiResult<Channel>> {
return apiFetch('PATCH', `/api/v1/channels/${id}`, { name, events });
}
export async function deleteChannel(id: string): Promise<ApiResult<void>> {
return apiFetch('DELETE', `/api/v1/channels/${id}`);
}
export async function rotateConfig(
id: string,
config: Record<string, string>
): Promise<ApiResult<Channel>> {
return apiFetch('PUT', `/api/v1/channels/${id}/config`, { config });
}
export async function testChannel(
provider: string,
config: Record<string, string>
): Promise<ApiResult<{ status: string }>> {
return apiFetch('POST', '/api/v1/channels/test', { provider, config });
}

View File

@ -0,0 +1,84 @@
import { apiFetch } from './client';
export type Host = {
id: string;
type: 'regular' | 'byoc';
team_id?: string;
team_name?: string;
provider?: string;
availability_zone?: string;
arch?: string;
cpu_cores?: number;
memory_mb?: number;
disk_gb?: number;
address?: string;
status: 'pending' | 'online' | 'offline' | 'unreachable' | 'draining';
last_heartbeat_at?: string;
created_by: string;
created_at: string;
updated_at: string;
};
export type CreateHostParams = {
type: 'regular' | 'byoc';
team_id?: string;
provider?: string;
availability_zone?: string;
};
export type CreateHostResult = {
host: Host;
registration_token: string;
};
export async function listHosts(): Promise<{ ok: true; data: Host[] } | { ok: false; error: string }> {
return apiFetch<Host[]>('GET', '/api/v1/hosts');
}
export async function createHost(
params: CreateHostParams
): Promise<{ ok: true; data: CreateHostResult } | { ok: false; error: string }> {
return apiFetch<CreateHostResult>('POST', '/api/v1/hosts', params);
}
export async function deleteHost(
id: string,
force = false
): Promise<{ ok: true } | { ok: false; error: string; sandbox_ids?: string[] }> {
const url = `/api/v1/hosts/${id}${force ? '?force=true' : ''}`;
const res = await apiFetch<void>('DELETE', url);
if (!res.ok) {
return res as { ok: false; error: string };
}
return { ok: true };
}
export async function getDeletePreview(
id: string
): Promise<{ ok: true; data: { host: Host; sandbox_ids: string[] } } | { ok: false; error: string }> {
return apiFetch<{ host: Host; sandbox_ids: string[] }>('GET', `/api/v1/hosts/${id}/delete-preview`);
}
export function statusColor(status: Host['status']): string {
switch (status) {
case 'online':
return 'var(--color-accent)';
case 'pending':
return 'var(--color-amber)';
case 'offline':
case 'unreachable':
return 'var(--color-red)';
case 'draining':
return 'var(--color-blue)';
default:
return 'var(--color-text-muted)';
}
}
export function formatSpecs(host: Host): string {
const parts: string[] = [];
if (host.cpu_cores) parts.push(`${host.cpu_cores} vCPU`);
if (host.memory_mb) parts.push(`${Math.round(host.memory_mb / 1024)}GB RAM`);
if (host.disk_gb) parts.push(`${host.disk_gb}GB disk`);
return parts.join(' · ') || '—';
}

View File

@ -0,0 +1,25 @@
import { apiFetch, type ApiResult } from '$lib/api/client';
export type MetricRange = '5m' | '10m' | '1h' | '6h' | '24h';
export type MetricPoint = {
timestamp_unix: number;
cpu_pct: number;
mem_bytes: number;
disk_bytes: number;
};
export type MetricsResponse = {
sandbox_id: string;
range: MetricRange;
points: MetricPoint[];
};
export async function fetchSandboxMetrics(id: string, range: MetricRange): Promise<ApiResult<MetricsResponse>> {
return apiFetch('GET', `/api/v1/sandboxes/${id}/metrics?range=${range}`);
}
export const METRIC_RANGES: MetricRange[] = ['5m', '10m', '1h', '6h', '24h'];
// All ranges poll every 10 seconds.
export const METRIC_POLL_INTERVAL = 10_000;

View File

@ -0,0 +1,44 @@
import { apiFetch, type ApiResult } from '$lib/api/client';
export type TimeRange = '5m' | '1h' | '6h' | '24h' | '30d';
export type StatsResponse = {
range: TimeRange;
current: {
running_count: number;
vcpus_reserved: number;
memory_mb_reserved: number;
sampled_at?: string;
};
peaks: {
running_count: number;
vcpus: number;
memory_mb: number;
};
series: {
labels: string[];
running: number[];
vcpus: number[];
memory_mb: number[];
};
};
export async function fetchStats(range: TimeRange): Promise<ApiResult<StatsResponse>> {
return apiFetch('GET', `/api/v1/sandboxes/stats?range=${range}`);
}
export const POLL_INTERVALS: Record<TimeRange, number> = {
'5m': 15_000,
'1h': 30_000,
'6h': 60_000,
'24h': 120_000,
'30d': 300_000,
};
export const RANGE_LABELS: Record<TimeRange, string> = {
'5m': '5m',
'1h': '1h',
'6h': '6h',
'24h': '24h',
'30d': '30d',
};

View File

@ -0,0 +1,85 @@
import { apiFetch, type ApiResult } from '$lib/api/client';
export type TeamMember = {
user_id: string;
name: string;
email: string;
role: 'owner' | 'admin' | 'member';
joined_at: string;
};
export type TeamInfo = {
id: string;
name: string;
slug: string;
created_at: string;
};
export type TeamDetail = {
team: TeamInfo;
members: TeamMember[];
};
export type UserSearchResult = {
user_id: string;
email: string;
};
export type TeamWithRole = {
id: string;
name: string;
slug: string;
is_byoc: boolean;
created_at: string;
role: string;
};
export async function listTeams(): Promise<ApiResult<TeamWithRole[]>> {
return apiFetch('GET', '/api/v1/teams');
}
export async function createTeam(name: string): Promise<ApiResult<TeamWithRole>> {
return apiFetch('POST', '/api/v1/teams', { name });
}
export async function switchTeam(
teamId: string
): Promise<ApiResult<{ token: string; user_id: string; team_id: string; email: string; name: string }>> {
return apiFetch('POST', '/api/v1/auth/switch-team', { team_id: teamId });
}
export async function getTeam(id: string): Promise<ApiResult<TeamDetail>> {
return apiFetch('GET', `/api/v1/teams/${id}`);
}
export async function updateTeam(id: string, name: string): Promise<ApiResult<void>> {
return apiFetch('PATCH', `/api/v1/teams/${id}`, { name });
}
export async function addMember(id: string, email: string): Promise<ApiResult<TeamMember>> {
return apiFetch('POST', `/api/v1/teams/${id}/members`, { email });
}
export async function removeMember(id: string, userId: string): Promise<ApiResult<void>> {
return apiFetch('DELETE', `/api/v1/teams/${id}/members/${userId}`);
}
export async function updateMemberRole(
id: string,
userId: string,
role: 'admin' | 'member'
): Promise<ApiResult<void>> {
return apiFetch('PATCH', `/api/v1/teams/${id}/members/${userId}`, { role });
}
export async function deleteTeam(id: string): Promise<ApiResult<void>> {
return apiFetch('DELETE', `/api/v1/teams/${id}`);
}
export async function leaveTeam(id: string): Promise<ApiResult<void>> {
return apiFetch('POST', `/api/v1/teams/${id}/leave`);
}
export async function searchUsers(email: string): Promise<ApiResult<UserSearchResult[]>> {
return apiFetch('GET', `/api/v1/users/search?email=${encodeURIComponent(email)}`);
}

View File

@ -4,7 +4,8 @@ const STORAGE_KEYS = {
token: 'wrenn_token',
userId: 'wrenn_user_id',
teamId: 'wrenn_team_id',
email: 'wrenn_email'
email: 'wrenn_email',
name: 'wrenn_name'
} as const;
function isTokenExpired(token: string): boolean {
@ -18,11 +19,23 @@ function isTokenExpired(token: string): boolean {
}
}
function decodeJWTPayload(token: string): Record<string, unknown> {
try {
const payload = token.split('.')[1];
return JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
} catch {
return {};
}
}
function createAuth() {
let token = $state<string | null>(null);
let userId = $state<string | null>(null);
let teamId = $state<string | null>(null);
let email = $state<string | null>(null);
let name = $state<string | null>(null);
let isAdmin = $state(false);
let role = $state<string>('member');
let initialized = $state(false);
// Initialize from localStorage synchronously at module load.
@ -33,6 +46,10 @@ function createAuth() {
userId = localStorage.getItem(STORAGE_KEYS.userId);
teamId = localStorage.getItem(STORAGE_KEYS.teamId);
email = localStorage.getItem(STORAGE_KEYS.email);
name = localStorage.getItem(STORAGE_KEYS.name);
const payload = decodeJWTPayload(stored);
isAdmin = Boolean(payload.is_admin);
role = String(payload.role || 'member');
} else if (stored) {
// Expired — clean up.
for (const key of Object.values(STORAGE_KEYS)) {
@ -57,6 +74,15 @@ function createAuth() {
get email() {
return email;
},
get name() {
return name;
},
get isAdmin() {
return isAdmin;
},
get role() {
return role;
},
get isAuthenticated() {
return isAuthenticated;
},
@ -64,16 +90,21 @@ function createAuth() {
return initialized;
},
login(data: { token: string; user_id: string; team_id: string; email: string }) {
login(data: { token: string; user_id: string; team_id: string; email: string; name: string }) {
token = data.token;
userId = data.user_id;
teamId = data.team_id;
email = data.email;
name = data.name;
const payload = decodeJWTPayload(data.token);
isAdmin = Boolean(payload.is_admin);
role = String(payload.role || 'member');
localStorage.setItem(STORAGE_KEYS.token, data.token);
localStorage.setItem(STORAGE_KEYS.userId, data.user_id);
localStorage.setItem(STORAGE_KEYS.teamId, data.team_id);
localStorage.setItem(STORAGE_KEYS.email, data.email);
localStorage.setItem(STORAGE_KEYS.name, data.name);
},
logout() {
@ -81,6 +112,9 @@ function createAuth() {
userId = null;
teamId = null;
email = null;
name = null;
isAdmin = false;
role = 'member';
for (const key of Object.values(STORAGE_KEYS)) {
localStorage.removeItem(key);

View File

@ -0,0 +1,3 @@
// Shared state written by the list page and read by the capsules layout
// for the running count badge in the header.
export const capsuleRunningCount = $state({ value: 0 });

View File

@ -0,0 +1,186 @@
<script lang="ts">
import { page } from '$app/stores';
import { auth } from '$lib/auth.svelte';
import {
IconServer,
IconTemplate,
IconSettings,
IconLogout,
IconSidebar,
IconBell,
IconDocs,
IconChevron,
IconShield
} from './icons';
let { collapsed = $bindable(false) }: { collapsed: boolean } = $props();
type NavItem = {
label: string;
icon: typeof IconServer;
href: string;
};
const managementItems: NavItem[] = [
{ label: 'Hosts', icon: IconServer, href: '/admin/hosts' },
{ label: 'Templates', icon: IconTemplate, href: '/admin/templates' }
];
function isActive(href: string): boolean {
const p = $page.url.pathname;
return p === href || p.startsWith(href + '/');
}
function toggleCollapsed() {
collapsed = !collapsed;
localStorage.setItem('wrenn_sidebar_collapsed', String(collapsed));
}
let userName = $derived(auth.name || auth.email || '');
</script>
<aside
class="relative flex h-screen shrink-0 flex-col overflow-hidden border-r border-[var(--color-border)] bg-[var(--color-bg-1)] transition-[width] duration-250 ease-in-out"
style="width: {collapsed ? '56px' : '230px'}"
>
<!-- Subtle accent top-edge — marks this as an elevated context -->
<div class="absolute inset-x-0 top-0 h-[2px] bg-gradient-to-r from-[var(--color-accent)]/60 via-[var(--color-accent)] to-[var(--color-accent)]/60"></div>
<!-- Brand + collapse toggle -->
<div class="flex shrink-0 items-center px-4 pt-6 pb-4 {collapsed ? 'justify-center' : 'justify-between'}">
{#if !collapsed}
<div class="flex items-center gap-2.5">
<div class="relative">
<img
src="/logo.svg"
alt="Wrenn"
class="h-7 w-7 shrink-0 rounded-[var(--radius-logo)]"
/>
</div>
<div class="flex flex-col gap-0.5 leading-none">
<span class="font-brand text-[1.286rem] text-[var(--color-text-bright)]">Wrenn</span>
<span class="inline-flex w-fit items-center gap-1 rounded-full bg-[var(--color-accent)]/15 px-1.5 py-px text-[10px] font-bold uppercase tracking-[0.1em] text-[var(--color-accent-bright)]">
<IconShield size={8} />
Admin
</span>
</div>
</div>
{:else}
<!-- Collapsed: show shield as admin identity marker -->
<div class="flex h-7 w-7 items-center justify-center rounded-[var(--radius-button)] bg-[var(--color-accent)]/10 text-[var(--color-accent-bright)]">
<IconShield size={14} />
</div>
{/if}
<button
onclick={toggleCollapsed}
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-[var(--radius-button)] text-[var(--color-text-tertiary)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] hover:text-[var(--color-text-secondary)] {collapsed ? 'absolute top-5 right-1.5' : ''}"
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
<IconSidebar size={16} />
</button>
</div>
<!-- Back to dashboard -->
<div class="px-3 pb-3 {collapsed ? 'px-1.5' : ''}">
<a
href="/dashboard"
class="flex items-center rounded-[var(--radius-input)] px-2.5 py-2 text-ui text-[var(--color-text-tertiary)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] hover:text-[var(--color-text-secondary)] {collapsed ? 'justify-center px-2' : 'gap-2'}"
title={collapsed ? 'Back to dashboard' : undefined}
>
<IconChevron size={12} direction="left" class="shrink-0" />
{#if !collapsed}<span>Dashboard</span>{/if}
</a>
</div>
<div class="mx-4 mb-3 h-px bg-[var(--color-border)] {collapsed ? 'mx-3' : ''}"></div>
<!-- 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>
</nav>
<!-- Bottom links -->
<div class="shrink-0 px-3 pb-1 {collapsed ? 'px-1.5' : ''}">
<a
href="/docs"
class="group flex items-center rounded-[var(--radius-input)] px-2.5 py-2.5 text-[var(--color-text-secondary)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] hover:text-[var(--color-text-primary)] {collapsed ? 'justify-center px-2' : 'gap-3'}"
title={collapsed ? 'Docs' : undefined}
>
<IconDocs size={16} class="shrink-0 opacity-50 transition-opacity duration-150 group-hover:opacity-100" />
{#if !collapsed}<span class="text-ui">Docs</span>{/if}
</a>
<a
href="/dashboard/notifications"
class="group flex items-center rounded-[var(--radius-input)] px-2.5 py-2.5 text-[var(--color-text-secondary)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] hover:text-[var(--color-text-primary)] {collapsed ? 'justify-center px-2' : 'gap-3'}"
title={collapsed ? 'Notifications' : undefined}
>
<IconBell size={16} class="shrink-0 opacity-50 transition-opacity duration-150 group-hover:opacity-100" />
{#if !collapsed}<span class="text-ui">Notifications</span>{/if}
</a>
<a
href="/dashboard/settings"
class="group flex items-center rounded-[var(--radius-input)] px-2.5 py-2.5 text-[var(--color-text-secondary)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] hover:text-[var(--color-text-primary)] {collapsed ? 'justify-center px-2' : 'gap-3'}"
title={collapsed ? 'Settings' : undefined}
>
<IconSettings size={16} class="shrink-0 opacity-50 transition-opacity duration-150 group-hover:opacity-100" />
{#if !collapsed}<span class="text-ui">Settings</span>{/if}
</a>
</div>
<!-- User footer -->
<div
class="flex shrink-0 items-center border-t border-[var(--color-border)] px-3 py-2.5 {collapsed ? 'justify-center px-1.5' : 'gap-2.5'}"
>
{#if !collapsed}
<div class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[var(--color-bg-4)] text-badge font-bold uppercase text-[var(--color-text-secondary)]">
{userName[0] ?? ''}
</div>
<span class="flex-1 truncate text-ui text-[var(--color-text-secondary)]">
{userName}
</span>
{/if}
<button
onclick={() => auth.logout()}
class="flex shrink-0 items-center justify-center rounded-[var(--radius-button)] transition-colors duration-150 hover:text-[var(--color-red)] {collapsed ? 'h-7 w-7 text-[var(--color-text-muted)] hover:bg-[var(--color-bg-3)]' : 'h-6 w-6 text-[var(--color-text-tertiary)]'}"
title="Sign out"
>
<IconLogout size={collapsed ? 15 : 14} />
</button>
</div>
</aside>

View File

@ -61,12 +61,12 @@
<!-- Header -->
<div class="mb-7">
<Dialog.Title
class="font-serif text-[24px] tracking-[-0.02em] text-[var(--color-text-bright)]"
class="font-serif text-page tracking-[-0.02em] text-[var(--color-text-bright)]"
>
{title}
</Dialog.Title>
<Dialog.Description
class="mt-1 text-[13px] text-[var(--color-text-secondary)]"
class="mt-1 text-ui text-[var(--color-text-secondary)]"
>
{subtitle}
</Dialog.Description>
@ -75,7 +75,7 @@
<!-- 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-[13px] font-medium text-[var(--color-text-bright)] transition-all duration-150 hover:border-[var(--color-accent)] hover:text-[var(--color-text-bright)]"
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
@ -85,7 +85,7 @@
<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-[10px] uppercase tracking-[0.1em] text-[var(--color-text-muted)]"
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>
@ -105,7 +105,7 @@
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-[13px] text-[var(--color-text-bright)] outline-none transition-all duration-150 placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)]"
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}
@ -121,7 +121,7 @@
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-[13px] text-[var(--color-text-bright)] outline-none transition-all duration-150 placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)]"
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>
@ -136,7 +136,7 @@
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-[13px] text-[var(--color-text-bright)] outline-none transition-all duration-150 placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)]"
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"
@ -156,7 +156,7 @@
<div class="flex justify-end">
<button
type="button"
class="text-[12px] text-[var(--color-text-secondary)] transition-colors duration-150 hover:text-[var(--color-accent-mid)]"
class="text-meta text-[var(--color-text-secondary)] transition-colors duration-150 hover:text-[var(--color-accent-mid)]"
>
Forgot password?
</button>
@ -165,14 +165,14 @@
<button
type="submit"
class="!mt-5 w-full rounded-[var(--radius-button)] bg-[var(--color-accent)] px-4 py-2.5 text-[13px] font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0"
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-[12px] text-[var(--color-text-secondary)]">
<p class="mt-5 text-center text-meta text-[var(--color-text-secondary)]">
{switchText}
<button
type="button"

View File

@ -0,0 +1,127 @@
<script lang="ts">
import { createCapsule, type Capsule, type CreateCapsuleParams } from '$lib/api/capsules';
type Props = {
open: boolean;
onclose: () => void;
oncreated?: (capsule: Capsule) => void;
};
let { open, onclose, oncreated }: 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);
async function handleCreate() {
creating = true;
createError = null;
const result = await createCapsule(createForm);
if (result.ok) {
createForm = { template: 'minimal', vcpus: 1, memory_mb: 512, timeout_sec: 0 };
oncreated?.(result.data);
onclose();
} else {
createError = result.error;
}
creating = false;
}
</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={() => { if (!creating) onclose(); }}
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>
<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}
<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)]">
{createError}
</div>
{/if}
<div class="mt-5 space-y-4">
<div>
<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>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]" for="create-vcpus">vCPUs</label>
<input
id="create-vcpus"
type="number"
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)]"
/>
</div>
<div>
<label class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]" for="create-memory">Memory (MB)</label>
<input
id="create-memory"
type="number"
min="128"
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)]"
/>
</div>
</div>
<div>
<label class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]" for="create-timeout">Idle timeout</label>
<input
id="create-timeout"
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)]"
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>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<button
onclick={onclose}
disabled={creating}
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={handleCreate}
disabled={creating}
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}
<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>
Launching...
{:else}
Launch
{/if}
</button>
</div>
</div>
</div>
{/if}

View File

@ -1,7 +1,10 @@
<script lang="ts">
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { Popover } from 'bits-ui';
import { auth } from '$lib/auth.svelte';
import { teams as teamsStore } from '$lib/teams.svelte';
import { createTeam, switchTeam } from '$lib/api/team';
import {
IconMonitor,
IconBox,
@ -16,40 +19,65 @@
IconSidebar,
IconBell,
IconDocs,
IconAudit
IconAudit,
IconServer,
IconShield,
IconMetrics,
IconBroadcast
} from './icons';
let { collapsed = $bindable(false) }: { collapsed: boolean } = $props();
let teamPopoverOpen = $state(false);
const currentTeam = 'default';
const userName = $derived(auth.email ?? '');
let currentTeamName = $derived(teamsStore.list.find((t) => t.id === auth.teamId)?.name ?? '');
let userName = $derived(auth.name || auth.email || '');
// Create team dialog
let showCreateTeam = $state(false);
let newTeamName = $state('');
let creatingTeam = $state(false);
let createTeamError = $state<string | null>(null);
type NavItem = {
label: string;
icon: typeof IconMonitor;
href: string;
disabled?: boolean;
disabledHint?: string;
};
const platformItems: NavItem[] = [
{ label: 'Capsules', icon: IconMonitor, href: '/dashboard/capsules' },
{ label: 'Templates', icon: IconBox, href: '/dashboard/snapshots' }
{ label: 'Templates', icon: IconBox, href: '/dashboard/snapshots' },
{ label: 'Metrics', icon: IconMetrics, href: '/dashboard/metrics' }
];
const managementItems: NavItem[] = [
let currentTeamIsByoc = $derived(
teamsStore.list.find((t) => t.id === auth.teamId)?.is_byoc ?? false
);
let managementItems = $derived<NavItem[]>([
{ label: 'Keys', icon: IconKey, href: '/dashboard/keys' },
{ label: 'Members', icon: IconMembers, href: '/dashboard/members' },
{ label: 'Audit Logs', icon: IconAudit, href: '/dashboard/audit' }
];
{ label: 'Channels', icon: IconBroadcast, href: '/dashboard/channels' },
{ label: 'Team', icon: IconMembers, href: '/dashboard/team' },
{ label: 'Audit Logs', icon: IconAudit, href: '/dashboard/audit' },
...(currentTeamIsByoc
? [{
label: 'Hosts',
icon: IconServer,
href: '/dashboard/hosts',
disabled: auth.role === 'member',
disabledHint: 'Available to team owners and admins only'
}]
: [])
]);
const billingItems: NavItem[] = [
{ label: 'Usage', icon: IconUsage, href: '/dashboard/usage' },
{ label: 'Billing', icon: IconBilling, href: '/dashboard/billing' }
];
const teams = ['default', 'Wrenn Labs', 'Acme Corp'];
function isActive(href: string): boolean {
const p = $page.url.pathname;
return p === href || p.startsWith(href + '/');
@ -59,6 +87,45 @@
collapsed = !collapsed;
localStorage.setItem('wrenn_sidebar_collapsed', String(collapsed));
}
async function fetchTeams() {
await teamsStore.fetch();
}
async function handleSwitchTeam(teamId: string) {
if (teamId === auth.teamId) {
teamPopoverOpen = false;
return;
}
teamPopoverOpen = false;
const result = await switchTeam(teamId);
if (result.ok) {
auth.login(result.data);
window.location.reload();
}
}
async function handleCreateTeam() {
if (!newTeamName.trim()) return;
creatingTeam = true;
createTeamError = null;
const result = await createTeam(newTeamName.trim());
if (result.ok) {
const switchResult = await switchTeam(result.data.id);
if (switchResult.ok) {
auth.login(switchResult.data);
window.location.reload();
} else {
createTeamError = switchResult.error;
creatingTeam = false;
}
} else {
createTeamError = result.error;
creatingTeam = false;
}
}
onMount(fetchTeams);
</script>
<aside
@ -74,7 +141,7 @@
alt="Wrenn"
class="h-7 w-7 shrink-0 rounded-[var(--radius-logo)]"
/>
<span class="font-brand text-[15px] text-[var(--color-text-bright)]">Wrenn</span>
<span class="font-brand text-[1.286rem] text-[var(--color-text-bright)]">Wrenn</span>
</div>
{/if}
<button
@ -95,19 +162,19 @@
: 'gap-2 px-2.5'}"
>
<div
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-[var(--radius-avatar)] bg-[var(--color-bg-4)] text-[10px] font-bold uppercase text-[var(--color-text-secondary)]"
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-[var(--radius-avatar)] bg-[var(--color-bg-4)] text-badge font-bold uppercase text-[var(--color-text-secondary)]"
>
{currentTeam[0]}
{(currentTeamName || '?')[0].toUpperCase()}
</div>
{#if !collapsed}
<div class="min-w-0 flex-1 overflow-hidden whitespace-nowrap">
<div
class="text-[11px] font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]"
class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]"
>
Team
</div>
<div class="truncate text-[13px] text-[var(--color-text-primary)]">
{currentTeam}
<div class="truncate text-ui text-[var(--color-text-primary)]">
{currentTeamName || '…'}
</div>
</div>
<IconChevron
@ -126,38 +193,44 @@
style="animation: popoverSlideIn 150ms ease"
>
<div
class="mb-1 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]"
class="mb-1 px-2.5 py-1 text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]"
>
Teams
</div>
{#each teams as team}
{#each teamsStore.list as team (team.id)}
<button
class="flex w-full items-center gap-2.5 rounded-[var(--radius-input)] px-2.5 py-2 text-[13px] transition-colors duration-150 hover:bg-[var(--color-bg-3)] {team ===
currentTeam
class="flex w-full items-center gap-2.5 rounded-[var(--radius-input)] px-2.5 py-2 text-ui transition-colors duration-150 hover:bg-[var(--color-bg-3)] {team.id ===
auth.teamId
? 'bg-[var(--color-accent-glow)]'
: ''}"
onclick={() => (teamPopoverOpen = false)}
onclick={() => handleSwitchTeam(team.id)}
>
<div
class="flex h-5 w-5 items-center justify-center rounded-[var(--radius-avatar)] text-[9px] font-bold uppercase text-white {team ===
currentTeam
class="flex h-5 w-5 items-center justify-center rounded-[var(--radius-avatar)] text-badge font-bold uppercase text-white {team.id ===
auth.teamId
? 'bg-[var(--color-accent)]'
: 'bg-[var(--color-bg-5)]'}"
>
{team[0]}
{team.name[0].toUpperCase()}
</div>
<span
class={team === currentTeam
class={team.id === auth.teamId
? 'font-medium text-[var(--color-text-bright)]'
: 'text-[var(--color-text-primary)]'}
>
{team}
{team.name}
</span>
</button>
{/each}
<div class="mt-0.5 border-t border-[var(--color-border)] pt-0.5">
<button
class="flex w-full items-center gap-2.5 rounded-[var(--radius-input)] px-2.5 py-2 text-[13px] text-[var(--color-text-secondary)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] hover:text-[var(--color-text-primary)]"
onclick={() => {
teamPopoverOpen = false;
newTeamName = '';
createTeamError = null;
showCreateTeam = true;
}}
class="flex w-full items-center gap-2.5 rounded-[var(--radius-input)] px-2.5 py-2 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] hover:text-[var(--color-text-primary)]"
>
<IconPlus size={14} />
Create team
@ -180,30 +253,40 @@
<!-- Bottom links -->
<div class="shrink-0 px-3 pb-1 {collapsed ? 'px-1.5' : ''}">
{#if auth.isAdmin}
<a
href="/admin"
class="group flex items-center rounded-[var(--radius-input)] px-2.5 py-2.5 text-[var(--color-text-secondary)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] hover:text-[var(--color-text-primary)] {collapsed ? 'justify-center px-2' : 'gap-3'}"
title={collapsed ? 'Admin' : undefined}
>
<IconShield size={16} class="shrink-0 opacity-50 transition-opacity duration-150 group-hover:opacity-100" />
{#if !collapsed}<span class="text-ui">Admin</span>{/if}
</a>
{/if}
<a
href="/docs"
href="https://docs.wrenn.dev"
target="_blank"
rel="noopener noreferrer"
class="group flex items-center rounded-[var(--radius-input)] px-2.5 py-2.5 text-[var(--color-text-secondary)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] hover:text-[var(--color-text-primary)] {collapsed ? 'justify-center px-2' : 'gap-3'}"
title={collapsed ? 'Docs' : undefined}
>
<IconDocs size={16} class="shrink-0 opacity-50 transition-opacity duration-150 group-hover:opacity-100" />
{#if !collapsed}<span class="text-[13px]">Docs</span>{/if}
{#if !collapsed}<span class="text-ui">Docs</span>{/if}
</a>
<a
href="/dashboard/notifications"
class="group flex items-center rounded-[var(--radius-input)] px-2.5 py-2.5 text-[var(--color-text-secondary)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] hover:text-[var(--color-text-primary)] {collapsed ? 'justify-center px-2' : 'gap-3'}"
title={collapsed ? 'Notifications' : undefined}
<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 ? 'Notifications (coming soon)' : 'Coming soon'}
>
<IconBell size={16} class="shrink-0 opacity-50 transition-opacity duration-150 group-hover:opacity-100" />
{#if !collapsed}<span class="text-[13px]">Notifications</span>{/if}
</a>
<a
href="/dashboard/settings"
class="group flex items-center rounded-[var(--radius-input)] px-2.5 py-2.5 text-[var(--color-text-secondary)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] hover:text-[var(--color-text-primary)] {collapsed ? 'justify-center px-2' : 'gap-3'}"
title={collapsed ? 'Settings' : undefined}
<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'}
>
<IconSettings size={16} class="shrink-0 opacity-50 transition-opacity duration-150 group-hover:opacity-100" />
{#if !collapsed}<span class="text-[13px]">Settings</span>{/if}
</a>
<IconSettings size={16} class="shrink-0" />
{#if !collapsed}<span class="text-ui">Settings</span>{/if}
</div>
</div>
<!-- User footer -->
@ -214,11 +297,11 @@
>
{#if !collapsed}
<div
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[var(--color-bg-4)] text-[10px] font-bold uppercase text-[var(--color-text-secondary)]"
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[var(--color-bg-4)] text-badge font-bold uppercase text-[var(--color-text-secondary)]"
>
{userName[0] ?? ''}
</div>
<span class="flex-1 truncate text-[13px] text-[var(--color-text-secondary)]">
<span class="flex-1 truncate text-ui text-[var(--color-text-secondary)]">
{userName}
</span>
{/if}
@ -242,28 +325,40 @@
{/if}
{:else}
<div
class="mb-1 px-2.5 py-1.5 text-[11px] font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]"
class="mb-1 px-2.5 py-1.5 text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]"
>
{label}
</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
{#if item.disabled}
<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 ? item.disabledHint ?? item.label : item.disabledHint}
>
<item.icon size={16} class="shrink-0" />
{#if !collapsed}
<span class="text-ui text-[var(--color-text-primary)]">{item.label}</span>
{/if}
</div>
{:else if isActive(item.href)}
<a
href={item.href}
class="group relative flex items-center rounded-[var(--radius-input)] px-2.5 py-2.5 transition-colors duration-150 {collapsed
? 'justify-center px-2 bg-[var(--color-accent-glow-mid)]'
: 'gap-3 bg-[var(--color-accent)]/[0.12]'}"
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)]"
class="absolute left-0 top-1/2 h-6 w-1 -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-[13px] font-medium text-[var(--color-accent-bright)]">
<span class="text-ui font-semibold text-[var(--color-accent-bright)]">
{item.label}
</span>
{/if}
@ -282,7 +377,7 @@
/>
{#if !collapsed}
<span
class="text-[13px] text-[var(--color-text-primary)] transition-colors duration-150 group-hover:text-[var(--color-text-bright)]"
class="text-ui text-[var(--color-text-primary)] transition-colors duration-150 group-hover:text-[var(--color-text-bright)]"
>
{item.label}
</span>
@ -293,6 +388,79 @@
</div>
{/snippet}
<!-- Create Team Dialog -->
{#if showCreateTeam}
<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={() => { if (!creatingTeam) showCreateTeam = false; }}
onkeydown={(e) => { if (e.key === 'Escape' && !creatingTeam) showCreateTeam = false; }}
></div>
<div
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)]">
Create Team
</h2>
<p class="mt-1 text-ui text-[var(--color-text-tertiary)]">
Choose a name for your new team.
</p>
{#if createTeamError}
<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)]"
>
{createTeamError}
</div>
{/if}
<div class="mt-5">
<label
class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]"
for="new-team-name"
>
Team name
</label>
<input
id="new-team-name"
type="text"
placeholder="e.g. Acme Engineering"
bind:value={newTeamName}
onkeydown={(e) => { if (e.key === 'Enter' && !creatingTeam) handleCreateTeam(); }}
disabled={creatingTeam}
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 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"
/>
</div>
<div class="mt-6 flex justify-end gap-3">
<button
onclick={() => { showCreateTeam = false; }}
disabled={creatingTeam}
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={handleCreateTeam}
disabled={creatingTeam || !newTeamName.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 creatingTeam}
<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>
Creating...
{:else}
Create Team
{/if}
</button>
</div>
</div>
</div>
{/if}
<style>
@keyframes popoverSlideIn {

View File

@ -0,0 +1,427 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { fetchStats, POLL_INTERVALS, type TimeRange, type StatsResponse } from '$lib/api/stats';
const RANGES: TimeRange[] = ['5m', '1h', '6h', '24h', '30d'];
type Props = { onlaunch?: () => void; launchDisabled?: boolean };
let { onlaunch, launchDisabled = false }: Props = $props();
let range = $state<TimeRange>('1h');
let stats = $state<StatsResponse | null>(null);
// loading is only true before the very first successful fetch; subsequent
// polls update data silently to avoid blanking the cards and charts.
let loading = $state(true);
let error = $state<string | null>(null);
let canvasRunning: HTMLCanvasElement;
let canvasCpu: HTMLCanvasElement;
let canvasRam: HTMLCanvasElement;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let chartRunning: any = null;
// 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;
let pollInterval: ReturnType<typeof setInterval> | null = null;
async function load() {
const result = await fetchStats(range);
if (result.ok) {
stats = result.data;
error = null;
} else {
error = result.error;
}
// Set loading=false before updateCharts so cards always render even if
// chart update throws (e.g. Chart.js not yet initialised on first tick).
loading = false;
updateCharts();
}
function updateCharts() {
if (!stats) return;
// 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);
if (chartRunning) {
chartRunning.data.labels = labels;
chartRunning.data.datasets[0].data = Array.from(stats.series.running);
chartRunning.update();
}
if (chartCpu) {
chartCpu.data.labels = labels;
chartCpu.data.datasets[0].data = Array.from(stats.series.vcpus);
chartCpu.update();
}
if (chartRam) {
chartRam.data.labels = labels;
chartRam.data.datasets[0].data = Array.from(stats.series.memory_mb).map((mb) => +(mb / 1024).toFixed(2));
chartRam.update();
}
}
function formatLabels(labels: string[], r: TimeRange): string[] {
return labels.map((iso) => {
const d = new Date(iso);
if (r === '5m' || r === '1h') {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: r === '5m' ? '2-digit' : undefined });
}
if (r === '6h' || r === '24h') {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
// 30d
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
});
}
function restartPolling() {
if (pollInterval) clearInterval(pollInterval);
load();
pollInterval = setInterval(load, POLL_INTERVALS[range]);
}
function setRange(r: TimeRange) {
range = r;
goto(`?range=${r}`, { replaceState: true, noScroll: true, keepFocus: true });
restartPolling();
}
// Chart colors (resolved from CSS vars, must match app.css)
const C_ACCENT = '#5e8c58';
const C_ACCENT_FILL = 'rgba(94,140,88,0.13)';
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: '#141817',
borderColor: '#1f2321',
borderWidth: 1,
titleColor: '#454340',
bodyColor: '#d4cfc8',
titleFont: { family: FONT_MONO, size: 10 },
bodyFont: { family: FONT_MONO, size: 11 },
padding: 10,
},
},
scales: {
x: {
grid: { color: C_GRID },
ticks: { color: C_TICK, font: { family: FONT_MONO, size: 10 }, maxTicksLimit: 6, maxRotation: 0 },
border: { color: C_GRID },
},
y: {
grid: { color: C_GRID },
ticks: { color: C_TICK, font: { family: FONT_MONO, size: 10 }, precision: 0 },
border: { color: C_GRID },
beginAtZero: true,
},
},
};
onMount(async () => {
// Read range from URL query param; fall back to '1h'.
const urlRange = new URLSearchParams(window.location.search).get('range');
if (urlRange && RANGES.includes(urlRange as TimeRange)) {
range = urlRange as TimeRange;
}
const { Chart } = await import('chart.js/auto');
chartRunning = new Chart(canvasRunning, {
type: 'line',
data: {
labels: [],
datasets: [{
data: [],
borderColor: C_ACCENT,
backgroundColor: C_ACCENT_FILL,
borderWidth: 2,
fill: true,
tension: 0,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: C_ACCENT,
}],
},
options: BASE_CHART_OPTIONS,
});
chartCpu = new Chart(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,
scales: {
...BASE_CHART_OPTIONS.scales,
y: {
...BASE_CHART_OPTIONS.scales.y,
ticks: {
...BASE_CHART_OPTIONS.scales.y.ticks,
callback: (v: number) => `${v}`,
},
},
},
},
});
chartRam = new Chart(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,
callbacks: {
label: (ctx: { parsed: { y: number } }) => ` ${ctx.parsed.y.toFixed(1)} GB`,
},
},
},
scales: {
...BASE_CHART_OPTIONS.scales,
y: {
...BASE_CHART_OPTIONS.scales.y,
ticks: {
...BASE_CHART_OPTIONS.scales.y.ticks,
callback: (v: number) => `${(+v).toFixed(1)} GB`,
},
},
},
},
});
// Apply any data already loaded before charts were ready.
updateCharts();
restartPolling();
});
onDestroy(() => {
if (pollInterval) clearInterval(pollInterval);
chartRunning?.destroy();
chartCpu?.destroy();
chartRam?.destroy();
});
function fmtGB(mb: number): string {
return (mb / 1024).toFixed(1) + ' GB';
}
</script>
<div class="flex flex-col gap-8 px-8 pb-10 pt-6" style="min-height: calc(100dvh - 200px); animation: fadeUp 0.35s ease both">
<!-- Controls row -->
<div class="flex items-center justify-between">
{#if !loading}
<span class="flex items-center gap-1 rounded-[3px] border border-[var(--color-accent)]/25 bg-[var(--color-accent-glow-mid)] px-1.5 py-0.5 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}
<div class="flex items-center gap-3">
<!-- Range selector -->
<div class="flex overflow-hidden rounded-[var(--radius-button)] border border-[var(--color-border)]">
{#each 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>
{#if onlaunch}
<button
onclick={onlaunch}
disabled={launchDisabled}
title={launchDisabled ? 'No active team — re-authenticate to create capsules' : undefined}
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-4 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:pointer-events-none disabled:opacity-40"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" 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>
Launch Capsule
</button>
{/if}
</div>
</div>
<!-- Stat cards: 3 paired cards (now / 30d peak) -->
<div class="grid grid-cols-3 overflow-hidden rounded-[var(--radius-card)] border border-[var(--color-border)]">
<!-- Running capsules -->
<div class="border-r border-[var(--color-border)]" style="box-shadow: inset 5px 0 0 var(--color-accent)">
<div class="flex items-center gap-2 border-b border-[var(--color-border)] bg-[var(--color-bg-3)] px-6 py-3">
<span class="h-2 w-2 rounded-full bg-[var(--color-accent)]"></span>
<span class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Running Capsules</span>
</div>
<div class="grid grid-cols-2 divide-x divide-[var(--color-border)]">
<div class="bg-[var(--color-bg-3)] px-6 py-6 transition-colors duration-150 hover:bg-[var(--color-bg-4)]">
<div class="text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]">Now</div>
<div class="mt-2 font-serif text-[2.571rem] leading-none tracking-[-0.04em] {(!loading && (stats?.current.running_count ?? 0) > 0) ? 'text-[var(--color-accent-bright)]' : 'text-[var(--color-text-bright)]'}">
{loading ? '—' : (stats?.current.running_count ?? 0)}
</div>
</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)]">
{loading ? '—' : (stats?.peaks.running_count ?? 0)}
</div>
</div>
</div>
</div>
<!-- Reserved CPU -->
<div class="border-r border-[var(--color-border)]" style="box-shadow: inset 5px 0 0 #5a9fd4">
<div class="flex items-center gap-2 border-b border-[var(--color-border)] bg-[var(--color-bg-3)] px-6 py-3">
<span class="h-2 w-2 rounded-full" style="background: #5a9fd4"></span>
<span class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">CPU · vCPUs</span>
</div>
<div class="grid grid-cols-2 divide-x divide-[var(--color-border)]">
<div class="bg-[var(--color-bg-3)] px-6 py-6 transition-colors duration-150 hover:bg-[var(--color-bg-4)]">
<div class="text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]">Reserved now</div>
<div class="mt-2 font-serif text-[2.571rem] leading-none tracking-[-0.04em] text-[var(--color-text-bright)]">
{loading ? '—' : (stats?.current.vcpus_reserved ?? 0)}
</div>
</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)]">
{loading ? '—' : (stats?.peaks.vcpus ?? 0)}
</div>
</div>
</div>
</div>
<!-- Reserved RAM -->
<div style="box-shadow: inset 5px 0 0 #d4a73c">
<div class="flex items-center gap-2 border-b border-[var(--color-border)] bg-[var(--color-bg-3)] px-6 py-3">
<span class="h-2 w-2 rounded-full" style="background: #d4a73c"></span>
<span class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">RAM</span>
</div>
<div class="grid grid-cols-2 divide-x divide-[var(--color-border)]">
<div class="bg-[var(--color-bg-3)] px-6 py-6 transition-colors duration-150 hover:bg-[var(--color-bg-4)]">
<div class="text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]">Reserved now</div>
<div class="mt-2 font-serif text-[2.571rem] leading-none tracking-[-0.04em] text-[var(--color-text-bright)]">
{loading ? '—' : fmtGB(stats?.current.memory_mb_reserved ?? 0)}
</div>
</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)]">
{loading ? '—' : fmtGB(stats?.peaks.memory_mb ?? 0)}
</div>
</div>
</div>
</div>
</div>
<!-- Error state -->
{#if error}
<div class="flex items-center gap-3 rounded-[var(--radius-card)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/8 px-4 py-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)]">Failed to load stats: {error}</span>
</div>
{/if}
<!-- Charts -->
<div class="flex flex-1 flex-col gap-5">
<!-- Running Capsules -->
<div class="flex flex-col rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-2)]">
<div class="border-b border-[var(--color-border)] px-6 py-4">
<div class="flex items-center gap-2">
<span class="h-2 w-2 rounded-full bg-[var(--color-accent)]"></span>
<div class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Running Capsules</div>
</div>
</div>
<div class="relative flex-1 px-5 pb-5 pt-3" style="min-height: 260px">
<canvas bind:this={canvasRunning}></canvas>
</div>
</div>
<!-- CPU & RAM side by side -->
<div class="grid grid-cols-2 gap-5">
<!-- CPU -->
<div class="flex flex-col rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-2)]">
<div class="border-b border-[var(--color-border)] px-6 py-4">
<div class="flex items-center gap-2">
<span class="h-2 w-2 rounded-full" style="background: #5a9fd4"></span>
<span class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">CPU · vCPUs</span>
</div>
</div>
<div class="relative flex-1 px-5 pb-5 pt-3" style="min-height: 220px">
<canvas bind:this={canvasCpu}></canvas>
</div>
</div>
<!-- RAM -->
<div class="flex flex-col rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-2)]">
<div class="border-b border-[var(--color-border)] px-6 py-4">
<div class="flex items-center gap-2">
<span class="h-2 w-2 rounded-full" style="background: #d4a73c"></span>
<span class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">RAM · GB</span>
</div>
</div>
<div class="relative flex-1 px-5 pb-5 pt-3" style="min-height: 220px">
<canvas bind:this={canvasRam}></canvas>
</div>
</div>
</div>
</div>
</div>

View File

@ -5,7 +5,7 @@
<div class="pointer-events-none fixed bottom-6 right-6 z-[100] flex flex-col-reverse gap-2">
{#each toast.list as t (t.id)}
<div
class="pointer-events-auto flex min-w-[280px] max-w-[400px] items-start gap-3 rounded-[var(--radius-card)] border bg-[var(--color-bg-2)] px-4 py-3 text-[13px] {t.type === 'error'
class="pointer-events-auto flex min-w-[280px] max-w-[400px] items-start gap-3 rounded-[var(--radius-card)] border bg-[var(--color-bg-2)] px-4 py-3 text-ui {t.type === 'error'
? 'border-[var(--color-red)]/30 text-[var(--color-red)]'
: 'border-[var(--color-accent)]/30 text-[var(--color-accent-bright)]'}"
style="animation: fadeUp 0.2s ease both"

View File

@ -0,0 +1,22 @@
<script lang="ts">
let { size = 18, class: className = '' }: { size?: number; class?: string } = $props();
</script>
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
class={className}
aria-hidden="true"
>
<circle cx="12" cy="12" r="2" />
<path d="M16.24 7.76a6 6 0 0 1 0 8.49" />
<path d="M7.76 16.24a6 6 0 0 1 0-8.49" />
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
<path d="M4.93 19.07a10 10 0 0 1 0-14.14" />
</svg>

View File

@ -0,0 +1,19 @@
<script lang="ts">
let { size = 18, class: className = '' }: { size?: number; class?: string } = $props();
</script>
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
class={className}
aria-hidden="true"
>
<path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>

View File

@ -0,0 +1,20 @@
<script lang="ts">
let { size = 18, class: className = '' }: { size?: number; class?: string } = $props();
</script>
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
class={className}
aria-hidden="true"
>
<line x1="18" y1="20" x2="18" y2="10" />
<line x1="12" y1="20" x2="12" y2="4" />
<line x1="6" y1="20" x2="6" y2="14" />
</svg>

View File

@ -0,0 +1,21 @@
<script lang="ts">
let { size = 18, class: className = '' }: { size?: number; class?: string } = $props();
</script>
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
class={className}
aria-hidden="true"
>
<rect x="2" y="2" width="20" height="8" rx="2" ry="2" />
<rect x="2" y="14" width="20" height="8" rx="2" ry="2" />
<line x1="6" y1="6" x2="6.01" y2="6" />
<line x1="6" y1="18" x2="6.01" y2="18" />
</svg>

View File

@ -0,0 +1,18 @@
<script lang="ts">
let { size = 18, class: className = '' }: { size?: number; class?: string } = $props();
</script>
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
class={className}
aria-hidden="true"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>

View File

@ -23,3 +23,8 @@ export { default as IconBell } from './IconBell.svelte';
export { default as IconDocs } from './IconDocs.svelte';
export { default as IconAudit } from './IconAudit.svelte';
export { default as IconBox } from './IconBox.svelte';
export { default as IconServer } from './IconServer.svelte';
export { default as IconGear } from './IconGear.svelte';
export { default as IconShield } from './IconShield.svelte';
export { default as IconMetrics } from './IconMetrics.svelte';
export { default as IconBroadcast } from './IconBroadcast.svelte';

View File

@ -0,0 +1,35 @@
import { listTeams, type TeamWithRole } from '$lib/api/team';
function createTeamsStore() {
let teams = $state<TeamWithRole[]>([]);
let loaded = $state(false);
return {
get list() {
return teams;
},
get loaded() {
return loaded;
},
async fetch() {
if (loaded) return;
const result = await listTeams();
if (result.ok) {
teams = result.data;
loaded = true;
}
},
// Call after mutating teams (create/switch triggers a full reload, but
// adding a team locally avoids a flicker in the popover list).
set(newTeams: TeamWithRole[]) {
teams = newTeams;
loaded = true;
},
reset() {
teams = [];
loaded = false;
}
};
}
export const teams = createTeamsStore();

View File

@ -0,0 +1,25 @@
/**
* Shared date/time formatting utilities.
* All functions accept `string | undefined` and return a safe fallback.
*/
export function formatDate(iso: string | undefined): string {
if (!iso) return '—';
return new Date(iso).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
}
export function timeAgo(iso: string | undefined): string {
if (!iso) return '';
const seconds = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
if (seconds < 60) return `${seconds}s ago`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return `${Math.floor(seconds / 86400)}d ago`;
}