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:
2026-05-16 13:42:08 +02:00
parent 3728c245d3
commit ed642dcc66
5 changed files with 410 additions and 3 deletions
+51 -1
View File
@@ -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()
},
}
+193
View File
@@ -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>
)
}
+129
View File
@@ -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>
)
}
+4 -2
View File
@@ -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 /> },
],
+33
View File
@@ -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
}