Spaces:
Sleeping
Sleeping
File size: 6,152 Bytes
9dfccd9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 | import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiFetch } from '@/lib/http'
import { useUIStore } from '@/stores/uiStore'
import { cn } from '@/lib/utils'
import { LoadingSkeleton } from '@/components/common/LoadingSkeleton'
interface DataSource {
id: string
type: 'jira' | 'confluence' | 'github' | 'slack'
name: string
url: string
enabled: boolean
last_sync: string | null
sync_status: 'idle' | 'syncing' | 'error'
error_msg: string | null
}
async function fetchSources(): Promise<{ sources: DataSource[] }> {
const res = await apiFetch('/api/admin/data-sources')
return res.json()
}
async function toggleSource(id: string, enabled: boolean): Promise<void> {
await apiFetch(`/api/admin/data-sources/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
})
}
const TYPE_ICONS: Record<DataSource['type'], string> = {
jira: '🔷',
confluence: '📘',
github: '🐙',
slack: '💬',
}
const SYNC_STATUS_STYLES: Record<DataSource['sync_status'], string> = {
idle: 'text-stone-400',
syncing: 'text-blue-500',
error: 'text-red-500',
}
export function DataSourceManager() {
const qc = useQueryClient()
const addToast = useUIStore((s) => s.addToast)
const [adding, setAdding] = useState(false)
const { data, isLoading } = useQuery({
queryKey: ['admin-data-sources'],
queryFn: fetchSources,
staleTime: 60_000,
})
const toggle = useMutation({
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => toggleSource(id, enabled),
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin-data-sources'] }),
onError: () => addToast({ type: 'error', message: 'Failed to update data source' }),
})
return (
<div className="flex flex-col gap-5">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-stone-500">Connected Data Sources</p>
<button
onClick={() => setAdding(true)}
className="rounded-lg bg-brand px-3 py-1.5 text-xs font-medium text-white hover:bg-brand/90"
>
+ Add source
</button>
</div>
{isLoading ? (
<div className="rounded-xl border border-surface-subtle p-5">
<LoadingSkeleton rows={4} />
</div>
) : (data?.sources ?? []).length === 0 ? (
<div className="rounded-xl border border-dashed border-surface-subtle py-16 text-center">
<p className="text-sm text-stone-400">No data sources configured.</p>
<button
onClick={() => setAdding(true)}
className="mt-3 text-sm font-medium text-brand hover:underline"
>
Add your first source →
</button>
</div>
) : (
<div className="flex flex-col gap-3">
{(data?.sources ?? []).map((src) => (
<div
key={src.id}
className={cn(
'rounded-xl border p-4 transition-colors',
src.enabled
? 'border-surface-subtle'
: 'border-dashed border-stone-200 opacity-60 dark:border-stone-700',
)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<span className="mt-0.5 text-xl" aria-hidden>{TYPE_ICONS[src.type]}</span>
<div>
<p className="font-medium">{src.name}</p>
<p className="text-xs text-stone-400">{src.url}</p>
{src.sync_status === 'error' && src.error_msg && (
<p className="mt-1 text-xs text-red-500">{src.error_msg}</p>
)}
<p className={cn('mt-1 text-xs capitalize', SYNC_STATUS_STYLES[src.sync_status])}>
{src.sync_status === 'syncing'
? 'Syncing…'
: src.last_sync
? `Last sync: ${new Date(src.last_sync).toLocaleDateString()}`
: 'Never synced'}
</p>
</div>
</div>
{/* Toggle */}
<button
role="switch"
aria-checked={src.enabled}
onClick={() => toggle.mutate({ id: src.id, enabled: !src.enabled })}
className={cn(
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors',
src.enabled ? 'bg-brand' : 'bg-stone-200 dark:bg-stone-700',
)}
>
<span
className={cn(
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow-sm transition-transform',
src.enabled ? 'translate-x-5' : 'translate-x-0',
)}
/>
</button>
</div>
</div>
))}
</div>
)}
{/* Add source placeholder modal hint */}
{adding && (
<div className="rounded-xl border border-brand/30 bg-amber-50 p-5 dark:bg-amber-950/20">
<p className="text-sm font-medium text-stone-700 dark:text-stone-300">
Data source wizard — configure integration credentials in <code className="rounded bg-stone-100 px-1 py-0.5 text-xs dark:bg-stone-800">.env</code> and register via the API.
</p>
<p className="mt-1 text-xs text-stone-500">
See <code className="rounded bg-stone-100 px-1 py-0.5 dark:bg-stone-800">POST /api/admin/data-sources</code> in the API docs.
</p>
<button
onClick={() => setAdding(false)}
className="mt-3 text-xs font-medium text-stone-500 hover:text-stone-700"
>
Dismiss
</button>
</div>
)}
</div>
)
}
|