From 587b28f05cbb1dd009f6724e2d267e695e5c3139 Mon Sep 17 00:00:00 2001 From: MUHAMMAD FARIS EL HAKIM Date: Tue, 28 Apr 2026 14:40:56 +0000 Subject: [PATCH] feat(lab-envs): UI for backend-agnostic ephemeral lab envs Adds a /lab/envs page that lists, launches, extends, and archives ephemeral JupyterLab environments against the backend-agnostic /api/v1/lab/envs API introduced in concave. - types.ts: LabEnv, LabStorage, LabEnvsResponse types. - lib/labEnvs.ts: API helpers + countdown formatting. - views/LabEnvsView.vue: launch drawer, env list with live TTL countdown, per-row extend/archive/open-jupyter actions, admin-only storage and driver configuration panels. - router + AppShell: new Lab Envs nav entry. - test/labEnvs.test.ts: unit tests for countdown/remaining helpers. Role gating matches the server: any viewer can list, developers can launch and extend, operators can archive-now, admins can reconfigure storage tiers and the active driver. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- frontend/src/components/layout/AppShell.vue | 1 + frontend/src/lib/labEnvs.ts | 78 +++++ frontend/src/router/index.ts | 2 + frontend/src/types.ts | 42 +++ frontend/src/views/LabEnvsView.vue | 362 ++++++++++++++++++++ frontend/test/labEnvs.test.ts | 21 ++ 6 files changed, 506 insertions(+) create mode 100644 frontend/src/lib/labEnvs.ts create mode 100644 frontend/src/views/LabEnvsView.vue create mode 100644 frontend/test/labEnvs.test.ts diff --git a/frontend/src/components/layout/AppShell.vue b/frontend/src/components/layout/AppShell.vue index ea14afb..537422b 100644 --- a/frontend/src/components/layout/AppShell.vue +++ b/frontend/src/components/layout/AppShell.vue @@ -32,6 +32,7 @@ const items: NavItem[] = [ { label: 'Workspace', to: '/workspace', minRole: 'viewer', icon: 'workspace' }, { label: 'Check', to: '/check', minRole: 'viewer', icon: 'check' }, { label: 'Gradient Lab', to: '/lab', minRole: 'viewer', icon: 'lab' }, + { label: 'Lab Envs', to: '/lab/envs', minRole: 'viewer', icon: 'lab' }, { label: 'Teams', to: '/teams', minRole: 'admin', icon: 'teams' }, { label: 'Users', to: '/users', minRole: 'admin', icon: 'users' }, { label: 'System', to: '/system', minRole: 'admin', icon: 'system' }, diff --git a/frontend/src/lib/labEnvs.ts b/frontend/src/lib/labEnvs.ts new file mode 100644 index 0000000..375a2ec --- /dev/null +++ b/frontend/src/lib/labEnvs.ts @@ -0,0 +1,78 @@ +import { api } from './api' +import type { LabEnv, LabEnvsResponse, LabStorage } from '../types' + +export async function fetchLabEnvs(): Promise { + return api('/api/v1/lab/envs') +} + +export interface LaunchEnvPayload { + image: string + display_name?: string + driver?: string + gpus?: number + cpu_request?: string + mem_request?: string + ttl_seconds: number +} + +export async function launchLabEnv(payload: LaunchEnvPayload): Promise { + return api('/api/v1/lab/envs', { + method: 'POST', + body: JSON.stringify(payload), + }) +} + +export async function extendLabEnv(id: string, extendSeconds: number): Promise { + return api(`/api/v1/lab/envs/${encodeURIComponent(id)}/extend`, { + method: 'POST', + body: JSON.stringify({ extend_seconds: extendSeconds }), + }) +} + +export async function archiveLabEnv(id: string): Promise { + return api(`/api/v1/lab/envs/${encodeURIComponent(id)}/archive`, { + method: 'POST', + }) +} + +export async function deleteLabEnv(id: string): Promise { + return api(`/api/v1/lab/envs/${encodeURIComponent(id)}`, { method: 'DELETE' }) +} + +export async function updateLabStorage(next: LabStorage): Promise { + return api('/api/v1/lab/storage', { + method: 'PUT', + body: JSON.stringify(next), + }) +} + +export async function setActiveDriver(name: string): Promise<{ active: string }> { + return api<{ active: string }>('/api/v1/lab/drivers', { + method: 'PUT', + body: JSON.stringify({ driver: name }), + }) +} + +export function remainingSeconds(expiresAt: string, now: Date = new Date()): number { + const expiry = Date.parse(expiresAt) + if (Number.isNaN(expiry)) { + return 0 + } + return Math.max(0, Math.floor((expiry - now.getTime()) / 1000)) +} + +export function formatCountdown(seconds: number): string { + if (seconds <= 0) { + return 'expired' + } + const h = Math.floor(seconds / 3600) + const m = Math.floor((seconds % 3600) / 60) + const s = seconds % 60 + if (h > 0) { + return `${h}h ${m.toString().padStart(2, '0')}m` + } + if (m > 0) { + return `${m}m ${s.toString().padStart(2, '0')}s` + } + return `${s}s` +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index ac56a9b..ffb3e67 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -9,6 +9,7 @@ import EnvironmentView from '../views/EnvironmentView.vue' import FleetView from '../views/FleetView.vue' import CheckView from '../views/CheckView.vue' import LabView from '../views/LabView.vue' +import LabEnvsView from '../views/LabEnvsView.vue' import LoginView from '../views/LoginView.vue' import LogsView from '../views/LogsView.vue' import SettingsView from '../views/SettingsView.vue' @@ -40,6 +41,7 @@ export const router = createRouter({ { path: '/check', component: CheckView, meta: { minRole: 'viewer', title: 'Check' } }, { path: '/doctor', redirect: '/check' }, { path: '/lab', component: LabView, meta: { minRole: 'viewer', title: 'Gradient Lab' } }, + { path: '/lab/envs', component: LabEnvsView, meta: { minRole: 'viewer', title: 'Lab Envs' } }, { path: '/teams', component: TeamsView, meta: { minRole: 'admin', title: 'Teams' } }, { path: '/users', component: UsersView, meta: { minRole: 'admin', title: 'Users' } }, { path: '/system', component: SystemView, meta: { minRole: 'admin', title: 'System' } }, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index fcee340..07b5c0c 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,5 +1,47 @@ export type Role = 'viewer' | 'developer' | 'operator' | 'admin' +export type LabEnvStatus = + | 'pending' + | 'running' + | 'expiring' + | 'archiving' + | 'archived' + | 'failed' + +export interface LabEnv { + id: string + driver: string + owner: string + image: string + display_name?: string + status: LabEnvStatus + container_id?: string + jupyter_url?: string + token?: string + gpus: number + cpu_request?: string + mem_request?: string + hot_tier_path: string + cold_tier_path: string + archive_ref?: string + created_at: string + expires_at: string + archived_at?: string + last_error?: string +} + +export interface LabStorage { + hot_tier: string + cold_tier: string +} + +export interface LabEnvsResponse { + envs: LabEnv[] + storage: LabStorage + drivers: string[] + active: string +} + export interface Session { username: string role: Role diff --git a/frontend/src/views/LabEnvsView.vue b/frontend/src/views/LabEnvsView.vue new file mode 100644 index 0000000..48382b6 --- /dev/null +++ b/frontend/src/views/LabEnvsView.vue @@ -0,0 +1,362 @@ + + + + + diff --git a/frontend/test/labEnvs.test.ts b/frontend/test/labEnvs.test.ts new file mode 100644 index 0000000..cbd8eeb --- /dev/null +++ b/frontend/test/labEnvs.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' + +import { formatCountdown, remainingSeconds } from '../src/lib/labEnvs' + +describe('labEnvs', () => { + it('formats countdown in hours/minutes/seconds', () => { + expect(formatCountdown(3 * 3600 + 15 * 60)).toBe('3h 15m') + expect(formatCountdown(15 * 60 + 7)).toBe('15m 07s') + expect(formatCountdown(42)).toBe('42s') + expect(formatCountdown(0)).toBe('expired') + expect(formatCountdown(-5)).toBe('expired') + }) + + it('computes remaining seconds from ISO expiry', () => { + const now = new Date('2026-01-01T00:00:00Z') + expect(remainingSeconds('2026-01-01T01:00:00Z', now)).toBe(3600) + expect(remainingSeconds('2026-01-01T00:00:00Z', now)).toBe(0) + expect(remainingSeconds('2025-12-31T23:59:00Z', now)).toBe(0) + expect(remainingSeconds('not-a-date', now)).toBe(0) + }) +})