Initial frontend implementation

- React dashboard with Tailwind CSS v4
- Session-based authentication (Lucia patterns)
- API client with axios
- Project, Task, and Agent views
- Bun.serve() with HMR and API proxy
- Docker support

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hector Ros
2026-01-20 01:20:34 +01:00
commit c9082cba81
26 changed files with 1474 additions and 0 deletions

29
src/App.tsx Normal file
View File

@@ -0,0 +1,29 @@
import React from 'react'
import { Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider, ProtectedRoute } from './lib/auth'
import Layout from './components/Layout'
import Login from './pages/Login'
import Register from './pages/Register'
import Dashboard from './pages/Dashboard'
export default function App() {
return (
<AuthProvider>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Layout>
<Dashboard />
</Layout>
</ProtectedRoute>
}
/>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes>
</AuthProvider>
)
}

29
src/api/client.ts Normal file
View File

@@ -0,0 +1,29 @@
import axios from 'axios'
// Determine API URL based on environment
const API_URL = import.meta.env.PROD
? 'https://api.fuq.tv/api'
: 'http://localhost:3000/api'
// Create axios instance with default config
export const apiClient = axios.create({
baseURL: API_URL,
withCredentials: true, // Important: send cookies with requests
headers: {
'Content-Type': 'application/json',
},
})
// Response interceptor for error handling
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Unauthorized - redirect to login
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export default apiClient

View File

@@ -0,0 +1,108 @@
import React from 'react'
import type { Agent } from '../types'
interface AgentStatusProps {
agents: Agent[]
onRefresh: () => void
}
export default function AgentStatus({ agents, onRefresh }: AgentStatusProps) {
const getStatusColor = (status: string) => {
switch (status) {
case 'idle':
return 'bg-green-100 text-green-800'
case 'busy':
return 'bg-blue-100 text-blue-800'
case 'error':
return 'bg-red-100 text-red-800'
case 'offline':
return 'bg-gray-100 text-gray-800'
default:
return 'bg-gray-100 text-gray-800'
}
}
const formatDate = (dateString: string) => {
const date = new Date(dateString)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
if (diffMins < 1) return 'Just now'
if (diffMins < 60) return `${diffMins}m ago`
const diffHours = Math.floor(diffMins / 60)
if (diffHours < 24) return `${diffHours}h ago`
const diffDays = Math.floor(diffHours / 24)
return `${diffDays}d ago`
}
if (agents.length === 0) {
return (
<div className="text-center py-12">
<p className="text-gray-500">No agents running. Deploy your first agent to get started.</p>
</div>
)
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{agents.map((agent) => (
<div key={agent.id} className="bg-white rounded-lg shadow p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900">{agent.name}</h3>
<p className="mt-1 text-sm text-gray-600 font-mono">{agent.k8sPodName}</p>
</div>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(
agent.status
)}`}
>
{agent.status}
</span>
</div>
<div className="mt-4 space-y-2">
<div className="text-sm">
<span className="text-gray-500">Namespace:</span>{' '}
<span className="font-mono text-gray-700">{agent.k8sNamespace}</span>
</div>
<div className="text-sm">
<span className="text-gray-500">Tasks completed:</span>{' '}
<span className="text-gray-700">{agent.totalTasksCompleted}</span>
</div>
{agent.averageTaskTimeMinutes && (
<div className="text-sm">
<span className="text-gray-500">Avg. time:</span>{' '}
<span className="text-gray-700">{agent.averageTaskTimeMinutes}m</span>
</div>
)}
<div className="text-sm">
<span className="text-gray-500">Last heartbeat:</span>{' '}
<span className="text-gray-700">{formatDate(agent.lastHeartbeat)}</span>
</div>
{agent.errorCount > 0 && (
<div className="text-sm">
<span className="text-gray-500">Errors:</span>{' '}
<span className="text-red-600">{agent.errorCount}</span>
</div>
)}
</div>
{agent.capabilities && agent.capabilities.length > 0 && (
<div className="mt-4">
<div className="flex flex-wrap gap-2">
{agent.capabilities.map((capability) => (
<span
key={capability}
className="inline-flex items-center px-2 py-1 rounded-md bg-gray-100 text-xs font-medium text-gray-700"
>
{capability}
</span>
))}
</div>
</div>
)}
</div>
))}
</div>
)
}

45
src/components/Layout.tsx Normal file
View File

@@ -0,0 +1,45 @@
import React, { ReactNode } from 'react'
import { useAuth } from '../lib/auth'
import { useNavigate } from 'react-router-dom'
interface LayoutProps {
children: ReactNode
}
export default function Layout({ children }: LayoutProps) {
const { user, logout } = useAuth()
const navigate = useNavigate()
const handleLogout = async () => {
await logout()
navigate('/login')
}
if (!user) {
return <>{children}</>
}
return (
<div className="min-h-screen bg-gray-50">
<nav className="bg-white shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex items-center">
<h1 className="text-xl font-bold text-gray-900">AiWorker</h1>
</div>
<div className="flex items-center space-x-4">
<span className="text-sm text-gray-700">{user.email}</span>
<button
onClick={handleLogout}
className="text-sm text-gray-700 hover:text-gray-900"
>
Logout
</button>
</div>
</div>
</div>
</nav>
<main>{children}</main>
</div>
)
}

View File

@@ -0,0 +1,77 @@
import React from 'react'
import type { Project } from '../types'
interface ProjectListProps {
projects: Project[]
onRefresh: () => void
}
export default function ProjectList({ projects, onRefresh }: ProjectListProps) {
const getStatusColor = (status: string) => {
switch (status) {
case 'active':
return 'bg-green-100 text-green-800'
case 'paused':
return 'bg-yellow-100 text-yellow-800'
case 'archived':
return 'bg-gray-100 text-gray-800'
default:
return 'bg-gray-100 text-gray-800'
}
}
if (projects.length === 0) {
return (
<div className="text-center py-12">
<p className="text-gray-500">No projects yet. Create your first project to get started.</p>
</div>
)
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{projects.map((project) => (
<div key={project.id} className="bg-white rounded-lg shadow p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900">{project.name}</h3>
<p className="mt-2 text-sm text-gray-600">{project.description}</p>
</div>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(
project.status
)}`}
>
{project.status}
</span>
</div>
<div className="mt-4 space-y-2">
{project.giteaRepoUrl && (
<div className="text-sm">
<span className="text-gray-500">Repo:</span>{' '}
<a
href={project.giteaRepoUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
{project.giteaOwner}/{project.giteaRepoName}
</a>
</div>
)}
<div className="text-sm">
<span className="text-gray-500">Namespace:</span>{' '}
<span className="font-mono text-gray-700">{project.k8sNamespace}</span>
</div>
<div className="text-sm">
<span className="text-gray-500">Resources:</span>{' '}
<span className="text-gray-700">
{project.replicas} replica(s), {project.cpuLimit} CPU, {project.memoryLimit} RAM
</span>
</div>
</div>
</div>
))}
</div>
)
}

125
src/components/TaskList.tsx Normal file
View File

@@ -0,0 +1,125 @@
import React from 'react'
import type { Task } from '../types'
interface TaskListProps {
tasks: Task[]
onRefresh: () => void
}
export default function TaskList({ tasks, onRefresh }: TaskListProps) {
const getStateColor = (state: string) => {
switch (state) {
case 'backlog':
return 'bg-gray-100 text-gray-800'
case 'in_progress':
return 'bg-blue-100 text-blue-800'
case 'needs_input':
return 'bg-yellow-100 text-yellow-800'
case 'ready_to_test':
return 'bg-purple-100 text-purple-800'
case 'approved':
return 'bg-green-100 text-green-800'
case 'staging':
return 'bg-indigo-100 text-indigo-800'
case 'production':
return 'bg-emerald-100 text-emerald-800'
default:
return 'bg-gray-100 text-gray-800'
}
}
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'urgent':
return 'text-red-600'
case 'high':
return 'text-orange-600'
case 'medium':
return 'text-yellow-600'
case 'low':
return 'text-gray-600'
default:
return 'text-gray-600'
}
}
if (tasks.length === 0) {
return (
<div className="text-center py-12">
<p className="text-gray-500">No tasks yet. Create your first task to get started.</p>
</div>
)
}
return (
<div className="space-y-4">
{tasks.map((task) => (
<div key={task.id} className="bg-white rounded-lg shadow p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center space-x-3">
<h3 className="text-lg font-semibold text-gray-900">{task.title}</h3>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStateColor(
task.state
)}`}
>
{task.state.replace('_', ' ')}
</span>
<span className={`text-xs font-medium ${getPriorityColor(task.priority)}`}>
{task.priority.toUpperCase()}
</span>
</div>
<p className="mt-2 text-sm text-gray-600">{task.description}</p>
</div>
</div>
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
{task.giteaPrUrl && (
<div>
<span className="text-gray-500">Pull Request:</span>{' '}
<a
href={task.giteaPrUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
#{task.giteaPrNumber}
</a>
</div>
)}
{task.previewUrl && (
<div>
<span className="text-gray-500">Preview:</span>{' '}
<a
href={task.previewUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
View
</a>
</div>
)}
{task.estimatedComplexity && (
<div>
<span className="text-gray-500">Complexity:</span>{' '}
<span className="text-gray-700">{task.estimatedComplexity}</span>
</div>
)}
{task.actualTimeMinutes && (
<div>
<span className="text-gray-500">Time:</span>{' '}
<span className="text-gray-700">{task.actualTimeMinutes}m</span>
</div>
)}
</div>
{task.errorMessage && (
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">
{task.errorMessage}
</div>
)}
</div>
))}
</div>
)
}

1
src/index.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

80
src/lib/auth.tsx Normal file
View File

@@ -0,0 +1,80 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'
import apiClient from '../api/client'
import type { User } from '../types'
interface AuthContextType {
user: User | null
loading: boolean
login: (email: string, password: string) => Promise<void>
register: (email: string, username: string, password: string) => Promise<void>
logout: () => Promise<void>
refreshUser: () => Promise<void>
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
// Check if user is authenticated on mount
useEffect(() => {
refreshUser()
}, [])
const refreshUser = async () => {
try {
const response = await apiClient.get('/auth/me')
setUser(response.data.data)
} catch (error) {
setUser(null)
} finally {
setLoading(false)
}
}
const login = async (email: string, password: string) => {
const response = await apiClient.post('/auth/login', { email, password })
setUser(response.data.data.user)
}
const register = async (email: string, username: string, password: string) => {
const response = await apiClient.post('/auth/register', { email, username, password })
setUser(response.data.data.user)
}
const logout = async () => {
await apiClient.post('/auth/logout')
setUser(null)
}
return (
<AuthContext.Provider value={{ user, loading, login, register, logout, refreshUser }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}
// Protected route wrapper component
export function ProtectedRoute({ children }: { children: ReactNode }) {
const { user, loading } = useAuth()
if (loading) {
return <div className="flex items-center justify-center min-h-screen">Loading...</div>
}
if (!user) {
window.location.href = '/login'
return null
}
return <>{children}</>
}

15
src/main.tsx Normal file
View File

@@ -0,0 +1,15 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'
const root = createRoot(document.getElementById('root')!)
root.render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
)

100
src/pages/Dashboard.tsx Normal file
View File

@@ -0,0 +1,100 @@
import React, { useState, useEffect } from 'react'
import apiClient from '../api/client'
import type { Project, Task, Agent } from '../types'
import ProjectList from '../components/ProjectList'
import TaskList from '../components/TaskList'
import AgentStatus from '../components/AgentStatus'
export default function Dashboard() {
const [projects, setProjects] = useState<Project[]>([])
const [tasks, setTasks] = useState<Task[]>([])
const [agents, setAgents] = useState<Agent[]>([])
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<'projects' | 'tasks' | 'agents'>('projects')
useEffect(() => {
loadData()
// Refresh data every 10 seconds
const interval = setInterval(loadData, 10000)
return () => clearInterval(interval)
}, [])
const loadData = async () => {
try {
const [projectsRes, tasksRes, agentsRes] = await Promise.all([
apiClient.get('/projects'),
apiClient.get('/tasks'),
apiClient.get('/agents'),
])
setProjects(projectsRes.data.data)
setTasks(tasksRes.data.data)
setAgents(agentsRes.data.data)
} catch (error) {
console.error('Failed to load dashboard data:', error)
} finally {
setLoading(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-gray-600">Loading dashboard...</div>
</div>
)
}
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">AiWorker Dashboard</h1>
<p className="mt-2 text-gray-600">Manage your AI agents and development tasks</p>
</div>
{/* Tabs */}
<div className="border-b border-gray-200 mb-6">
<nav className="flex space-x-8">
<button
onClick={() => setActiveTab('projects')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'projects'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Projects ({projects.length})
</button>
<button
onClick={() => setActiveTab('tasks')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'tasks'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Tasks ({tasks.length})
</button>
<button
onClick={() => setActiveTab('agents')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'agents'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Agents ({agents.length})
</button>
</nav>
</div>
{/* Content */}
<div>
{activeTab === 'projects' && <ProjectList projects={projects} onRefresh={loadData} />}
{activeTab === 'tasks' && <TaskList tasks={tasks} onRefresh={loadData} />}
{activeTab === 'agents' && <AgentStatus agents={agents} onRefresh={loadData} />}
</div>
</div>
</div>
)
}

87
src/pages/Login.tsx Normal file
View File

@@ -0,0 +1,87 @@
import React, { useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useAuth } from '../lib/auth'
export default function Login() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { login } = useAuth()
const navigate = useNavigate()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await login(email, password)
navigate('/dashboard')
} catch (err: any) {
setError(err.response?.data?.message || 'Login failed')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-lg shadow">
<div>
<h2 className="text-3xl font-bold text-center">AiWorker</h2>
<p className="mt-2 text-center text-sm text-gray-600">Sign in to your account</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<div className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
<div className="text-center">
<Link to="/register" className="text-sm text-blue-600 hover:text-blue-500">
Don't have an account? Register
</Link>
</div>
</form>
</div>
</div>
)
}

126
src/pages/Register.tsx Normal file
View File

@@ -0,0 +1,126 @@
import React, { useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useAuth } from '../lib/auth'
export default function Register() {
const [email, setEmail] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { register } = useAuth()
const navigate = useNavigate()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (password !== confirmPassword) {
setError('Passwords do not match')
return
}
if (password.length < 8) {
setError('Password must be at least 8 characters')
return
}
setLoading(true)
try {
await register(email, username, password)
navigate('/dashboard')
} catch (err: any) {
setError(err.response?.data?.message || 'Registration failed')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-lg shadow">
<div>
<h2 className="text-3xl font-bold text-center">AiWorker</h2>
<p className="mt-2 text-center text-sm text-gray-600">Create your account</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<div className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
Username
</label>
<input
id="username"
type="text"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">
Confirm Password
</label>
<input
id="confirmPassword"
type="password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
{loading ? 'Creating account...' : 'Create account'}
</button>
<div className="text-center">
<Link to="/login" className="text-sm text-blue-600 hover:text-blue-500">
Already have an account? Sign in
</Link>
</div>
</form>
</div>
</div>
)
}

91
src/types/index.ts Normal file
View File

@@ -0,0 +1,91 @@
// Database entities
export interface Project {
id: string
name: string
description: string | null
giteaRepoId: number | null
giteaRepoUrl: string | null
giteaOwner: string | null
giteaRepoName: string | null
defaultBranch: string
k8sNamespace: string
dockerImage: string | null
envVars: Record<string, string> | null
replicas: number
cpuLimit: string
memoryLimit: string
status: 'active' | 'paused' | 'archived'
createdAt: string
updatedAt: string
}
export interface Task {
id: string
projectId: string
title: string
description: string
state: 'backlog' | 'in_progress' | 'needs_input' | 'ready_to_test' | 'approved' | 'staging' | 'production'
priority: 'low' | 'medium' | 'high' | 'urgent'
assignedAgentId: string | null
giteaBranchName: string | null
giteaPrNumber: number | null
giteaPrUrl: string | null
previewUrl: string | null
estimatedComplexity: 'simple' | 'moderate' | 'complex' | 'epic' | null
actualTimeMinutes: number | null
errorMessage: string | null
createdAt: string
updatedAt: string
startedAt: string | null
completedAt: string | null
project?: Project
agent?: Agent
}
export interface Agent {
id: string
name: string
status: 'idle' | 'busy' | 'error' | 'offline'
currentTaskId: string | null
k8sPodName: string
k8sNamespace: string
capabilities: string[]
lastHeartbeat: string
totalTasksCompleted: number
averageTaskTimeMinutes: number | null
errorCount: number
createdAt: string
updatedAt: string
currentTask?: Task
}
export interface User {
id: string
email: string
username: string
createdAt: string
}
export interface Session {
id: string
userId: string
expiresAt: string
}
// API Response types
export interface ApiResponse<T> {
success: boolean
data: T
count?: number
message?: string
error?: string
}
export interface PaginatedResponse<T> {
success: boolean
data: T[]
count: number
page: number
pageSize: number
totalPages: number
}