feat(FN-007): add repository connection frontend UI
- Create RepositoryListPage with connection status display - Create RepositoryDetailPage with SSH key management - Add repository API methods to client - Update router with repository routes - Add Repository types to frontend
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import type { Config, ConfigCreate, Project, ProjectCreate, ProjectUpdate, Secret, SecretCreate, ToolDefinition, ToolInstance, User } from '../types/api.ts'
|
||||
import type { Config, ConfigCreate, Project, ProjectCreate, ProjectUpdate, Repository, RepositoryConnection, RepositoryConnectionCreate, RepositoryCreate, Secret, SecretCreate, ToolDefinition, ToolInstance, User } from '../types/api.ts'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
||||
|
||||
@@ -195,4 +195,54 @@ export const api = {
|
||||
method: 'DELETE',
|
||||
})
|
||||
},
|
||||
|
||||
getRepository: async (projectId: string, repoId: string): Promise<Repository> => {
|
||||
const response = await fetchWithAuth(`/projects/${projectId}/repositories/${repoId}`)
|
||||
return response.json()
|
||||
},
|
||||
|
||||
getRepositories: async (projectId: string): Promise<Repository[]> => {
|
||||
const response = await fetchWithAuth(`/projects/${projectId}/repositories`)
|
||||
return response.json()
|
||||
},
|
||||
|
||||
createRepository: async (projectId: string, data: RepositoryCreate): Promise<Repository> => {
|
||||
const response = await fetchWithAuth(`/projects/${projectId}/repositories`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
|
||||
deleteRepository: async (projectId: string, repoId: string): Promise<void> => {
|
||||
await fetchWithAuth(`/projects/${projectId}/repositories/${repoId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
},
|
||||
|
||||
getRepositoryConnections: async (projectId: string): Promise<RepositoryConnection[]> => {
|
||||
const response = await fetchWithAuth(`/projects/${projectId}/repository-connections`)
|
||||
return response.json()
|
||||
},
|
||||
|
||||
createRepositoryConnection: async (projectId: string, data: RepositoryConnectionCreate): Promise<RepositoryConnection> => {
|
||||
const response = await fetchWithAuth(`/projects/${projectId}/repository-connections`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
|
||||
deleteRepositoryConnection: async (projectId: string, connectionId: string): Promise<void> => {
|
||||
await fetchWithAuth(`/projects/${projectId}/repository-connections/${connectionId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
},
|
||||
|
||||
generateSshKey: async (projectId: string, connectionId: string): Promise<{ public_key: string }> => {
|
||||
const response = await fetchWithAuth(`/projects/${projectId}/repository-connections/${connectionId}/ssh-key`, {
|
||||
method: 'POST',
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,8 @@ import ToolsPage from './pages/ToolsPage'
|
||||
import ToolSpawnPage from './pages/ToolSpawnPage'
|
||||
import ToolInstanceDetailPage from './pages/ToolInstanceDetailPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import RepositoriesPage from './pages/RepositoriesPage'
|
||||
import RepositoryListPage from './pages/RepositoryListPage'
|
||||
import RepositoryDetailPage from './pages/RepositoryDetailPage'
|
||||
import ConfigListPage from './pages/ConfigListPage'
|
||||
import SecretListPage from './pages/SecretListPage'
|
||||
|
||||
@@ -37,7 +38,8 @@ export const router = createBrowserRouter([
|
||||
{ path: '/tools/spawn', element: <ToolSpawnPage /> },
|
||||
{ path: '/projects/:projectId/instances/:instanceId', element: <ToolInstanceDetailPage /> },
|
||||
{ path: '/settings', element: <SettingsPage /> },
|
||||
{ path: '/repositories', element: <RepositoriesPage /> },
|
||||
{ path: '/repositories', element: <RepositoryListPage /> },
|
||||
{ path: '/projects/:projectId/repositories/:repoId', element: <RepositoryDetailPage /> },
|
||||
{ path: '/projects/:id/configs', element: <ConfigListPage /> },
|
||||
{ path: '/projects/:id/secrets', element: <SecretListPage /> },
|
||||
],
|
||||
|
||||
@@ -85,3 +85,36 @@ export interface SecretCreate {
|
||||
scope_type: string
|
||||
scope_id: string
|
||||
}
|
||||
|
||||
export interface Repository {
|
||||
id: string
|
||||
name: string
|
||||
git_url: string
|
||||
provider_type: string
|
||||
default_branch: string
|
||||
project_id: string
|
||||
}
|
||||
|
||||
export interface RepositoryCreate {
|
||||
name: string
|
||||
git_url: string
|
||||
provider_type?: string
|
||||
default_branch?: string
|
||||
}
|
||||
|
||||
export interface RepositoryConnection {
|
||||
id: string
|
||||
project_id: string
|
||||
repository_id: string | null
|
||||
provider_kind: string
|
||||
credential_id: string | null
|
||||
connection_status: string
|
||||
default_branch: string | null
|
||||
}
|
||||
|
||||
export interface RepositoryConnectionCreate {
|
||||
repository_id: string
|
||||
provider_kind: string
|
||||
credential_kind: string
|
||||
credential_payload: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user