feat: implement docker infrastructure (US-001)
- Add docker-compose.yml with postgres, redis, api, and web services - Add multi-stage Dockerfile for API (Python 3.11) - Add multi-stage Dockerfile for web (Node.js 20 + nginx) - Add Makefile with common development commands - Add .env.example with all required environment variables - Add placeholder pyproject.toml and package.json for builds - Configure health checks for all services - Setup persistent volumes for postgres, redis, and repos - Run services as non-root users
This commit is contained in:
@@ -1,111 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { getPKCE, clearPKCE, storeToken, getState, clearState } from '../auth/oidc'
|
||||
import { api } from '../api/client'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const CLIENT_ID = import.meta.env.VITE_OIDC_CLIENT_ID
|
||||
const REDIRECT_URI = import.meta.env.VITE_OIDC_REDIRECT_URI
|
||||
|
||||
export default function CallbackPage() {
|
||||
const navigate = useNavigate()
|
||||
const { setUser, setError } = useAuthStore()
|
||||
const [status, setStatus] = useState('Processing authentication...')
|
||||
const isMounted = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMounted.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
try {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const code = urlParams.get('code')
|
||||
const error = urlParams.get('error')
|
||||
const state = urlParams.get('state')
|
||||
const storedState = getState()
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Authentication error: ${error}`)
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
throw new Error('No authorization code received')
|
||||
}
|
||||
|
||||
if (!state || state !== storedState) {
|
||||
throw new Error('Invalid or missing state parameter')
|
||||
}
|
||||
|
||||
const verifier = getPKCE()
|
||||
if (!verifier) {
|
||||
throw new Error('PKCE verifier not found')
|
||||
}
|
||||
|
||||
if (isMounted.current) {
|
||||
setStatus('Exchanging code for token...')
|
||||
}
|
||||
|
||||
const tokenEndpoint = 'https://auth.commumedia.org/application/o/token/'
|
||||
const tokenResponse = await fetch(tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: CLIENT_ID,
|
||||
code,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code_verifier: verifier,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorData = await tokenResponse.json().catch(() => ({}))
|
||||
throw new Error(errorData.error_description || errorData.error || 'Token exchange failed')
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json()
|
||||
storeToken(tokenData.access_token)
|
||||
clearPKCE()
|
||||
clearState()
|
||||
|
||||
if (isMounted.current) {
|
||||
setStatus('Fetching user information...')
|
||||
}
|
||||
|
||||
const user = await api.getCurrentUser()
|
||||
|
||||
if (isMounted.current) {
|
||||
setUser(user)
|
||||
window.location.href = '/'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Callback error:', error)
|
||||
clearPKCE()
|
||||
clearState()
|
||||
|
||||
if (isMounted.current) {
|
||||
setError(error instanceof Error ? error : new Error('Authentication failed'))
|
||||
setStatus('Authentication failed')
|
||||
setTimeout(() => navigate('/login'), 3000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleCallback()
|
||||
}, [navigate, setUser, setError])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold mb-4">{status}</h1>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accent mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { api } from '../api/client'
|
||||
import type { Config } from '../types/api'
|
||||
|
||||
export default function ConfigListPage() {
|
||||
const { id: projectId } = useParams<{ id: string }>()
|
||||
const [configs, setConfigs] = useState<Config[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingConfig, setEditingConfig] = useState<Config | null>(null)
|
||||
const [formData, setFormData] = useState({
|
||||
key: '',
|
||||
value: '',
|
||||
scope_type: 'project',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return
|
||||
loadConfigs()
|
||||
}, [projectId])
|
||||
|
||||
const loadConfigs = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await api.getConfigs(projectId!)
|
||||
setConfigs(data)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load configs')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!projectId) return
|
||||
|
||||
try {
|
||||
const value = JSON.parse(formData.value)
|
||||
if (editingConfig) {
|
||||
await api.updateConfig(editingConfig.id, { value })
|
||||
} else {
|
||||
await api.createConfig({
|
||||
key: formData.key,
|
||||
value,
|
||||
scope_type: formData.scope_type,
|
||||
scope_id: projectId,
|
||||
})
|
||||
}
|
||||
setShowForm(false)
|
||||
setEditingConfig(null)
|
||||
setFormData({ key: '', value: '', scope_type: 'project' })
|
||||
loadConfigs()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save config')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (configId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this config?')) return
|
||||
try {
|
||||
await api.deleteConfig(configId)
|
||||
loadConfigs()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete config')
|
||||
}
|
||||
}
|
||||
|
||||
const startEdit = (config: Config) => {
|
||||
setEditingConfig(config)
|
||||
setFormData({
|
||||
key: config.key,
|
||||
value: JSON.stringify(config.value, null, 2),
|
||||
scope_type: config.scope_type,
|
||||
})
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
if (loading) return <div className="p-4">Loading configs...</div>
|
||||
if (error) return <div className="p-4 text-red-600">Error: {error}</div>
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h1 className="text-2xl font-bold">Configuration</h1>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowForm(!showForm)
|
||||
setEditingConfig(null)
|
||||
setFormData({ key: '', value: '', scope_type: 'project' })
|
||||
}}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
{showForm ? 'Cancel' : 'Add Config'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={handleSubmit} className="mb-6 p-4 border rounded bg-gray-50">
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium mb-1">Key</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.key}
|
||||
onChange={(e) => setFormData({ ...formData, key: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
required
|
||||
disabled={!!editingConfig}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium mb-1">Value (JSON)</label>
|
||||
<textarea
|
||||
value={formData.value}
|
||||
onChange={(e) => setFormData({ ...formData, value: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded font-mono text-sm"
|
||||
rows={6}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium mb-1">Scope</label>
|
||||
<select
|
||||
value={formData.scope_type}
|
||||
onChange={(e) => setFormData({ ...formData, scope_type: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
disabled={!!editingConfig}
|
||||
>
|
||||
<option value="global">Global</option>
|
||||
<option value="project">Project</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">
|
||||
{editingConfig ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{configs.length === 0 ? (
|
||||
<p className="text-gray-500">No configs found.</p>
|
||||
) : (
|
||||
configs.map((config) => (
|
||||
<div key={config.id} className="p-4 border rounded flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold">{config.key}</span>
|
||||
<span className="text-xs px-2 py-1 bg-gray-100 rounded">{config.scope_type}</span>
|
||||
</div>
|
||||
<pre className="text-sm bg-gray-50 p-2 rounded overflow-auto">
|
||||
{JSON.stringify(config.value, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-4">
|
||||
<button
|
||||
onClick={() => startEdit(config)}
|
||||
className="px-3 py-1 text-sm bg-gray-100 rounded hover:bg-gray-200"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(config.id)}
|
||||
className="px-3 py-1 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Link to={`/projects/${projectId}`} className="text-blue-600 hover:underline">
|
||||
← Back to Project
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user } = useAuthStore()
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-3xl font-bold mb-4">Dashboard</h1>
|
||||
<p className="text-lg mb-4">
|
||||
Welcome back, {user?.display_name || user?.email || 'User'}!
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="p-4 rounded-lg border border-border bg-surface">
|
||||
<h2 className="text-xl font-semibold mb-2">Projects</h2>
|
||||
<p className="text-gray-400">Manage your projects and repositories</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg border border-border bg-surface">
|
||||
<h2 className="text-xl font-semibold mb-2">Tools</h2>
|
||||
<p className="text-gray-400">Spawn and manage development tools</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg border border-border bg-surface">
|
||||
<h2 className="text-xl font-semibold mb-2">Settings</h2>
|
||||
<p className="text-gray-400">Configure your account and preferences</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { useEffect } from 'react'
|
||||
import { createPKCE, storePKCE, storeState } from '../auth/oidc'
|
||||
|
||||
const CLIENT_ID = import.meta.env.VITE_OIDC_CLIENT_ID
|
||||
const REDIRECT_URI = import.meta.env.VITE_OIDC_REDIRECT_URI
|
||||
|
||||
function generateState(): string {
|
||||
const array = new Uint8Array(32)
|
||||
crypto.getRandomValues(array)
|
||||
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
useEffect(() => {
|
||||
const initiateLogin = async () => {
|
||||
const { verifier, challenge } = await createPKCE()
|
||||
storePKCE(verifier)
|
||||
|
||||
const state = generateState()
|
||||
storeState(state)
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
response_type: 'code',
|
||||
scope: 'openid profile email',
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
state,
|
||||
})
|
||||
|
||||
const authorizationEndpoint = 'https://auth.commumedia.org/application/o/authorize/'
|
||||
window.location.href = `${authorizationEndpoint}?${params.toString()}`
|
||||
}
|
||||
|
||||
initiateLogin()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold mb-4">Redirecting to login...</h1>
|
||||
<p className="text-gray-400">Please wait while we redirect you to the authentication provider.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '../api/client'
|
||||
|
||||
export default function ProjectDetailPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const { data: project, isLoading } = useQuery({
|
||||
queryKey: ['project', id],
|
||||
queryFn: () => api.getProject(id!),
|
||||
enabled: !!id,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center p-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accent"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold">Project not found</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-3xl font-bold mb-4">{project.name}</h1>
|
||||
<p className="text-gray-400 mb-4">{project.description || 'No description'}</p>
|
||||
<div className="text-sm text-gray-500">
|
||||
<span>Slug: {project.slug}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '../api/client'
|
||||
|
||||
export default function ProjectFormPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const isEditing = !!id
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [slug, setSlug] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
|
||||
const { data: project } = useQuery({
|
||||
queryKey: ['project', id],
|
||||
queryFn: () => api.getProject(id!),
|
||||
enabled: isEditing,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (project) {
|
||||
setName(project.name)
|
||||
setSlug(project.slug)
|
||||
setDescription(project.description || '')
|
||||
}
|
||||
}, [project])
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: api.createProject,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] })
|
||||
navigate('/projects')
|
||||
},
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Parameters<typeof api.updateProject>[1] }) =>
|
||||
api.updateProject(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['project', id] })
|
||||
navigate(`/projects/${id}`)
|
||||
},
|
||||
})
|
||||
|
||||
const validate = () => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
if (!name.trim()) newErrors.name = 'Name is required'
|
||||
if (!slug.trim()) newErrors.slug = 'Slug is required'
|
||||
if (!/^[a-z0-9-]+$/i.test(slug)) newErrors.slug = 'Slug must contain only letters, numbers, and hyphens'
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!validate()) return
|
||||
|
||||
const data = {
|
||||
name: name.trim(),
|
||||
slug: slug.trim(),
|
||||
description: description.trim() || null,
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
updateMutation.mutate({ id: id!, data })
|
||||
} else {
|
||||
createMutation.mutate(data)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-2xl">
|
||||
<h1 className="text-3xl font-bold mb-6">
|
||||
{isEditing ? 'Edit Project' : 'New Project'}
|
||||
</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-border bg-surface"
|
||||
placeholder="My Awesome Project"
|
||||
/>
|
||||
{errors.name && <p className="text-red-400 text-sm mt-1">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Slug</label>
|
||||
<input
|
||||
type="text"
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-border bg-surface"
|
||||
placeholder="my-awesome-project"
|
||||
/>
|
||||
{errors.slug && <p className="text-red-400 text-sm mt-1">{errors.slug}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-border bg-surface"
|
||||
rows={3}
|
||||
placeholder="Optional description..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-accent text-bg rounded-lg hover:bg-accent/80 transition-colors"
|
||||
>
|
||||
{isEditing ? 'Update' : 'Create'} Project
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/projects')}
|
||||
className="px-4 py-2 border border-border rounded-lg hover:bg-surface transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../api/client'
|
||||
import type { Project } from '../types/api'
|
||||
|
||||
export default function ProjectListPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: projects, isLoading } = useQuery({
|
||||
queryKey: ['projects'],
|
||||
queryFn: api.getProjects,
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: api.deleteProject,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] })
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center p-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accent"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-3xl font-bold">Projects</h1>
|
||||
<Link
|
||||
to="/projects/new"
|
||||
className="px-4 py-2 bg-accent text-bg rounded-lg hover:bg-accent/80 transition-colors"
|
||||
>
|
||||
New Project
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{projects && projects.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{projects.map((project: Project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="p-4 rounded-lg border border-border bg-surface hover:border-accent transition-colors"
|
||||
>
|
||||
<Link to={`/projects/${project.id}`}>
|
||||
<h2 className="text-xl font-semibold mb-2">{project.name}</h2>
|
||||
<p className="text-gray-400 mb-2">{project.description || 'No description'}</p>
|
||||
<span className="text-sm text-gray-500">Slug: {project.slug}</span>
|
||||
</Link>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Link
|
||||
to={`/projects/${project.id}/edit`}
|
||||
className="text-sm text-accent hover:underline"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure you want to delete this project?')) {
|
||||
deleteMutation.mutate(project.id)
|
||||
}
|
||||
}}
|
||||
className="text-sm text-red-400 hover:underline"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-xl text-gray-400 mb-4">No projects yet</p>
|
||||
<Link
|
||||
to="/projects/new"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
Create your first project
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export default function RepositoriesPage() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-3xl font-bold mb-4">Repositories</h1>
|
||||
<p className="text-gray-400">Repository management coming soon...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '../api/client.ts'
|
||||
|
||||
export default function RepositoryDetailPage() {
|
||||
const { projectId, repoId } = useParams<{ projectId: string; repoId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const [providerKind, setProviderKind] = useState('github')
|
||||
const [credentialPayload, setCredentialPayload] = useState('')
|
||||
const [showSshKey, setShowSshKey] = useState(false)
|
||||
|
||||
const { data: repository } = useQuery({
|
||||
queryKey: ['repository', projectId, repoId],
|
||||
queryFn: () => api.getRepository(projectId!, repoId!),
|
||||
enabled: !!projectId && !!repoId,
|
||||
})
|
||||
|
||||
const { data: connections } = useQuery({
|
||||
queryKey: ['repository-connections', projectId],
|
||||
queryFn: () => api.getRepositoryConnections(projectId!),
|
||||
enabled: !!projectId,
|
||||
})
|
||||
|
||||
const createConnectionMutation = useMutation({
|
||||
mutationFn: (data: {
|
||||
repository_id: string
|
||||
provider_kind: string
|
||||
credential_kind: string
|
||||
credential_payload: string
|
||||
}) => api.createRepositoryConnection(projectId!, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['repository-connections', projectId] })
|
||||
setCredentialPayload('')
|
||||
},
|
||||
})
|
||||
|
||||
const deleteConnectionMutation = useMutation({
|
||||
mutationFn: (connectionId: string) =>
|
||||
api.deleteRepositoryConnection(projectId!, connectionId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['repository-connections', projectId] })
|
||||
},
|
||||
})
|
||||
|
||||
const generateSshKeyMutation = useMutation({
|
||||
mutationFn: (connectionId: string) =>
|
||||
api.generateSshKey(projectId!, connectionId),
|
||||
onSuccess: () => {
|
||||
setShowSshKey(true)
|
||||
},
|
||||
})
|
||||
|
||||
const handleCreateConnection = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
createConnectionMutation.mutate({
|
||||
repository_id: repoId!,
|
||||
provider_kind: providerKind,
|
||||
credential_kind: 'access_token',
|
||||
credential_payload: credentialPayload,
|
||||
})
|
||||
}
|
||||
|
||||
const repoConnections = connections?.filter(
|
||||
(conn) => conn.repository_id === repoId
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">{repository?.name || 'Repository'}</h1>
|
||||
<button
|
||||
onClick={() => navigate(`/projects/${projectId}/repositories`)}
|
||||
className="text-indigo-600 hover:text-indigo-900"
|
||||
>
|
||||
Back to Repositories
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{repository && (
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Git URL</label>
|
||||
<p className="mt-1 text-sm text-gray-900">{repository.git_url}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Provider</label>
|
||||
<p className="mt-1 text-sm text-gray-900">{repository.provider_type}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Default Branch</label>
|
||||
<p className="mt-1 text-sm text-gray-900">{repository.default_branch}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h2 className="text-lg font-semibold mb-4">Create Connection</h2>
|
||||
<form onSubmit={handleCreateConnection} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Provider</label>
|
||||
<select
|
||||
value={providerKind}
|
||||
onChange={(e) => setProviderKind(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="github">GitHub</option>
|
||||
<option value="gitlab">GitLab</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Access Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={credentialPayload}
|
||||
onChange={(e) => setCredentialPayload(e.target.value)}
|
||||
placeholder="ghp_xxxxxxxxxxxx"
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createConnectionMutation.isPending}
|
||||
className="inline-flex justify-center rounded-md border border-transparent bg-indigo-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50"
|
||||
>
|
||||
{createConnectionMutation.isPending ? 'Creating...' : 'Create Connection'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h2 className="text-lg font-semibold mb-4">Connections</h2>
|
||||
{repoConnections?.length === 0 ? (
|
||||
<p className="text-gray-500">No connections yet.</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{repoConnections?.map((conn) => (
|
||||
<div
|
||||
key={conn.id}
|
||||
className="flex items-center justify-between p-4 border rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{conn.provider_kind}</p>
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
conn.connection_status === 'connected'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: conn.connection_status === 'error'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}
|
||||
>
|
||||
{conn.connection_status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => generateSshKeyMutation.mutate(conn.id)}
|
||||
className="text-indigo-600 hover:text-indigo-900"
|
||||
>
|
||||
Generate SSH Key
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteConnectionMutation.mutate(conn.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showSshKey && generateSshKeyMutation.data && (
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h2 className="text-lg font-semibold mb-4">SSH Public Key</h2>
|
||||
<pre className="bg-gray-100 p-4 rounded text-sm overflow-x-auto">
|
||||
{generateSshKeyMutation.data.public_key}
|
||||
</pre>
|
||||
<p className="text-sm text-gray-600 mt-2">
|
||||
Add this key to your repository's deploy keys.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '../api/client.ts'
|
||||
|
||||
export default function RepositoryListPage() {
|
||||
const { projectId } = useParams<{ projectId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const [name, setName] = useState('')
|
||||
const [gitUrl, setGitUrl] = useState('')
|
||||
const [providerType, setProviderType] = useState('github')
|
||||
|
||||
const { data: repositories, isLoading } = useQuery({
|
||||
queryKey: ['repositories', projectId],
|
||||
queryFn: () => api.getRepositories(projectId!),
|
||||
enabled: !!projectId,
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: { name: string; git_url: string; provider_type: string }) =>
|
||||
api.createRepository(projectId!, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['repositories', projectId] })
|
||||
setName('')
|
||||
setGitUrl('')
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (repoId: string) => api.deleteRepository(projectId!, repoId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['repositories', projectId] })
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
createMutation.mutate({ name, git_url: gitUrl, provider_type: providerType })
|
||||
}
|
||||
|
||||
if (isLoading) return <div>Loading repositories...</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Repositories</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 bg-white p-6 rounded-lg shadow">
|
||||
<h2 className="text-lg font-semibold">Add Repository</h2>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Git URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={gitUrl}
|
||||
onChange={(e) => setGitUrl(e.target.value)}
|
||||
placeholder="https://github.com/user/repo.git"
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Provider</label>
|
||||
<select
|
||||
value={providerType}
|
||||
onChange={(e) => setProviderType(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="github">GitHub</option>
|
||||
<option value="gitlab">GitLab</option>
|
||||
<option value="generic">Generic</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending}
|
||||
className="inline-flex justify-center rounded-md border border-transparent bg-indigo-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50"
|
||||
>
|
||||
{createMutation.isPending ? 'Adding...' : 'Add Repository'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Repository List</h2>
|
||||
{repositories?.length === 0 ? (
|
||||
<p className="text-gray-500">No repositories yet.</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{repositories?.map((repo) => (
|
||||
<div
|
||||
key={repo.id}
|
||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50 cursor-pointer"
|
||||
onClick={() => navigate(`/projects/${projectId}/repositories/${repo.id}`)}
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-medium">{repo.name}</h3>
|
||||
<p className="text-sm text-gray-500">{repo.git_url}</p>
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
{repo.provider_type}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
deleteMutation.mutate(repo.id)
|
||||
}}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { api } from '../api/client'
|
||||
import type { Secret } from '../types/api'
|
||||
|
||||
export default function SecretListPage() {
|
||||
const { id: projectId } = useParams<{ id: string }>()
|
||||
const [secrets, setSecrets] = useState<Secret[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingSecret, setEditingSecret] = useState<Secret | null>(null)
|
||||
const [formData, setFormData] = useState({
|
||||
key: '',
|
||||
value: '',
|
||||
scope_type: 'project',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return
|
||||
loadSecrets()
|
||||
}, [projectId])
|
||||
|
||||
const loadSecrets = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await api.getSecrets(projectId!)
|
||||
setSecrets(data)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load secrets')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!projectId) return
|
||||
|
||||
try {
|
||||
if (editingSecret) {
|
||||
await api.updateSecret(editingSecret.id, { value: formData.value })
|
||||
} else {
|
||||
await api.createSecret({
|
||||
key: formData.key,
|
||||
value: formData.value,
|
||||
scope_type: formData.scope_type,
|
||||
scope_id: projectId,
|
||||
})
|
||||
}
|
||||
setShowForm(false)
|
||||
setEditingSecret(null)
|
||||
setFormData({ key: '', value: '', scope_type: 'project' })
|
||||
loadSecrets()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save secret')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (secretId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this secret?')) return
|
||||
try {
|
||||
await api.deleteSecret(secretId)
|
||||
loadSecrets()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete secret')
|
||||
}
|
||||
}
|
||||
|
||||
const startEdit = (secret: Secret) => {
|
||||
setEditingSecret(secret)
|
||||
setFormData({
|
||||
key: secret.key,
|
||||
value: '',
|
||||
scope_type: secret.scope_type,
|
||||
})
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
if (loading) return <div className="p-4">Loading secrets...</div>
|
||||
if (error) return <div className="p-4 text-red-600">Error: {error}</div>
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h1 className="text-2xl font-bold">Secrets</h1>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowForm(!showForm)
|
||||
setEditingSecret(null)
|
||||
setFormData({ key: '', value: '', scope_type: 'project' })
|
||||
}}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
{showForm ? 'Cancel' : 'Add Secret'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={handleSubmit} className="mb-6 p-4 border rounded bg-gray-50">
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium mb-1">Key</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.key}
|
||||
onChange={(e) => setFormData({ ...formData, key: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
required
|
||||
disabled={!!editingSecret}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium mb-1">Value</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.value}
|
||||
onChange={(e) => setFormData({ ...formData, value: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
required
|
||||
placeholder={editingSecret ? 'Enter new value' : ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium mb-1">Scope</label>
|
||||
<select
|
||||
value={formData.scope_type}
|
||||
onChange={(e) => setFormData({ ...formData, scope_type: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
disabled={!!editingSecret}
|
||||
>
|
||||
<option value="global">Global</option>
|
||||
<option value="project">Project</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">
|
||||
{editingSecret ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{secrets.length === 0 ? (
|
||||
<p className="text-gray-500">No secrets found.</p>
|
||||
) : (
|
||||
secrets.map((secret) => (
|
||||
<div key={secret.id} className="p-4 border rounded flex justify-between items-center">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">{secret.key}</span>
|
||||
<span className="text-xs px-2 py-1 bg-gray-100 rounded">{secret.scope_type}</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">{secret.value}</div>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-4">
|
||||
<button
|
||||
onClick={() => startEdit(secret)}
|
||||
className="px-3 py-1 text-sm bg-gray-100 rounded hover:bg-gray-200"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(secret.id)}
|
||||
className="px-3 py-1 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Link to={`/projects/${projectId}`} className="text-blue-600 hover:underline">
|
||||
← Back to Project
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user } = useAuthStore()
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-3xl font-bold mb-4">Settings</h1>
|
||||
<div className="max-w-2xl">
|
||||
<div className="p-4 rounded-lg border border-border bg-surface mb-4">
|
||||
<h2 className="text-xl font-semibold mb-2">Profile</h2>
|
||||
<dl className="space-y-2">
|
||||
<div>
|
||||
<dt className="text-sm text-gray-400">Email</dt>
|
||||
<dd>{user?.email}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-gray-400">Display Name</dt>
|
||||
<dd>{user?.display_name || 'Not set'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { api } from '../api/client'
|
||||
import type { ToolInstance, ToolDefinition } from '../types/api'
|
||||
|
||||
export default function ToolInstanceDetailPage() {
|
||||
const { projectId, instanceId } = useParams<{ projectId: string; instanceId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [instance, setInstance] = useState<ToolInstance | null>(null)
|
||||
const [toolDef, setToolDef] = useState<ToolDefinition | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [actionLoading, setActionLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId || !instanceId) return
|
||||
loadInstance()
|
||||
}, [projectId, instanceId])
|
||||
|
||||
const loadInstance = async () => {
|
||||
if (!projectId || !instanceId) return
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await api.getToolInstance(projectId, instanceId)
|
||||
setInstance(data)
|
||||
const registry = await api.getToolRegistry()
|
||||
const tool = registry.find((t) => t.id === data.tool_definition_id)
|
||||
setToolDef(tool || null)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load instance')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStop = async () => {
|
||||
if (!projectId || !instanceId) return
|
||||
setActionLoading(true)
|
||||
try {
|
||||
const data = await api.stopToolInstance(projectId, instanceId)
|
||||
setInstance(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to stop instance')
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStart = async () => {
|
||||
if (!projectId || !instanceId) return
|
||||
setActionLoading(true)
|
||||
try {
|
||||
const data = await api.startToolInstance(projectId, instanceId)
|
||||
setInstance(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to start instance')
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!projectId || !instanceId) return
|
||||
if (!confirm('Are you sure you want to delete this instance?')) return
|
||||
setActionLoading(true)
|
||||
try {
|
||||
await api.deleteToolInstance(projectId, instanceId)
|
||||
navigate(`/projects/${projectId}`)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete instance')
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return 'text-green-400'
|
||||
case 'creating':
|
||||
return 'text-yellow-400'
|
||||
case 'stopped':
|
||||
return 'text-gray-400'
|
||||
case 'error':
|
||||
return 'text-red-400'
|
||||
default:
|
||||
return 'text-gray-400'
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="animate-pulse">Loading instance...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !instance) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="text-red-400 mb-4">{error || 'Instance not found'}</div>
|
||||
<Link
|
||||
to={`/projects/${projectId}`}
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
← Back to project
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
to={`/projects/${projectId}`}
|
||||
className="text-accent hover:underline mb-4 inline-block"
|
||||
>
|
||||
← Back to project
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold mt-2">{instance.name}</h1>
|
||||
<div className="flex items-center gap-4 mt-2">
|
||||
<span className={`font-medium ${getStatusColor(instance.status)}`}>
|
||||
{instance.status}
|
||||
</span>
|
||||
{toolDef && (
|
||||
<span className="text-gray-400">{toolDef.name}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 p-4 rounded-lg mb-6">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-surface border border-border rounded-lg p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Instance Details</h2>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<span className="text-gray-400">Status: </span>
|
||||
<span className={getStatusColor(instance.status)}>{instance.status}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-400">Tool: </span>
|
||||
<span>{toolDef?.name || instance.tool_definition_id}</span>
|
||||
</div>
|
||||
{instance.subdomain && (
|
||||
<div>
|
||||
<span className="text-gray-400">Subdomain: </span>
|
||||
<a
|
||||
href={`https://${instance.subdomain}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{instance.subdomain}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{instance.container_id && (
|
||||
<div>
|
||||
<span className="text-gray-400">Container: </span>
|
||||
<span className="font-mono text-sm">{instance.container_id.slice(0, 12)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-gray-400">Created: </span>
|
||||
<span>{new Date(instance.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface border border-border rounded-lg p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Actions</h2>
|
||||
<div className="space-y-3">
|
||||
{instance.status === 'running' && instance.subdomain && (
|
||||
<a
|
||||
href={`https://${instance.subdomain}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full bg-accent text-white text-center py-2 px-4 rounded-lg hover:bg-accent/90 transition-colors"
|
||||
>
|
||||
Open Tool
|
||||
</a>
|
||||
)}
|
||||
|
||||
{instance.status === 'running' ? (
|
||||
<button
|
||||
onClick={handleStop}
|
||||
disabled={actionLoading}
|
||||
className="w-full bg-yellow-600 text-white py-2 px-4 rounded-lg hover:bg-yellow-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{actionLoading ? 'Stopping...' : 'Stop Instance'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleStart}
|
||||
disabled={actionLoading}
|
||||
className="w-full bg-green-600 text-white py-2 px-4 rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{actionLoading ? 'Starting...' : 'Start Instance'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={actionLoading}
|
||||
className="w-full bg-red-600 text-white py-2 px-4 rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{actionLoading ? 'Deleting...' : 'Delete Instance'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { api } from '../api/client'
|
||||
import type { ToolDefinition, Project } from '../types/api'
|
||||
|
||||
export default function ToolSpawnPage() {
|
||||
const { projectId } = useParams<{ projectId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [tools, setTools] = useState<ToolDefinition[]>([])
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [selectedTool, setSelectedTool] = useState('')
|
||||
const [selectedProject, setSelectedProject] = useState(projectId || '')
|
||||
const [instanceName, setInstanceName] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [fetchLoading, setFetchLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setFetchLoading(true)
|
||||
const [toolsData, projectsData] = await Promise.all([
|
||||
api.getToolRegistry(),
|
||||
api.getProjects(),
|
||||
])
|
||||
setTools(toolsData)
|
||||
setProjects(projectsData)
|
||||
if (toolsData.length > 0 && !selectedTool) {
|
||||
setSelectedTool(toolsData[0].id)
|
||||
}
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load data')
|
||||
} finally {
|
||||
setFetchLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!selectedTool || !selectedProject || !instanceName.trim()) {
|
||||
setError('Please fill in all fields')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const instance = await api.createToolInstance(selectedProject, {
|
||||
tool_definition_id: selectedTool,
|
||||
name: instanceName.trim(),
|
||||
})
|
||||
navigate(`/projects/${selectedProject}/instances/${instance.id}`)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create instance')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedToolData = tools.find((t) => t.id === selectedTool)
|
||||
|
||||
if (fetchLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="animate-pulse">Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-2xl">
|
||||
<h1 className="text-3xl font-bold mb-6">Spawn Tool</h1>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 p-4 rounded-lg mb-6">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Project</label>
|
||||
<select
|
||||
value={selectedProject}
|
||||
onChange={(e) => setSelectedProject(e.target.value)}
|
||||
className="w-full bg-surface border border-border rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
required
|
||||
>
|
||||
<option value="">Select a project</option>
|
||||
{projects.map((project) => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Tool</label>
|
||||
<select
|
||||
value={selectedTool}
|
||||
onChange={(e) => setSelectedTool(e.target.value)}
|
||||
className="w-full bg-surface border border-border rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
required
|
||||
>
|
||||
<option value="">Select a tool</option>
|
||||
{tools.map((tool) => (
|
||||
<option key={tool.id} value={tool.id}>
|
||||
{tool.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedToolData && (
|
||||
<div className="bg-surface border border-border rounded-lg p-4">
|
||||
<h3 className="font-medium mb-2">{selectedToolData.name}</h3>
|
||||
<p className="text-gray-400 text-sm">
|
||||
{selectedToolData.description || 'No description available'}
|
||||
</p>
|
||||
<div className="mt-2 text-sm text-gray-400">
|
||||
Image: {selectedToolData.image}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Instance Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={instanceName}
|
||||
onChange={(e) => setInstanceName(e.target.value)}
|
||||
placeholder="e.g., My Development Environment"
|
||||
className="w-full bg-surface border border-border rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="bg-accent text-white py-2 px-6 rounded-lg hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Spawning...' : 'Spawn Tool'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(-1)}
|
||||
className="border border-border py-2 px-6 rounded-lg hover:bg-surface transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export default function ToolsPage() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-3xl font-bold mb-4">Tools</h1>
|
||||
<p className="text-gray-400">Tool management coming soon...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user