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:
29
src/App.tsx
Normal file
29
src/App.tsx
Normal 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
29
src/api/client.ts
Normal 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
|
||||
108
src/components/AgentStatus.tsx
Normal file
108
src/components/AgentStatus.tsx
Normal 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
45
src/components/Layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
77
src/components/ProjectList.tsx
Normal file
77
src/components/ProjectList.tsx
Normal 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
125
src/components/TaskList.tsx
Normal 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
1
src/index.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
80
src/lib/auth.tsx
Normal file
80
src/lib/auth.tsx
Normal 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
15
src/main.tsx
Normal 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
100
src/pages/Dashboard.tsx
Normal 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
87
src/pages/Login.tsx
Normal 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
126
src/pages/Register.tsx
Normal 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
91
src/types/index.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user