sozsoft-platform/ui/src/views/setup/DatabaseSetup.tsx

282 lines
9.5 KiB
TypeScript
Raw Normal View History

2026-04-23 10:36:51 +00:00
import { useEffect, useRef, useState } from 'react'
import { APP_NAME } from '@/constants/app.constant'
2026-04-28 17:12:14 +00:00
import { getMigrateUrl } from '@/services/setup.service'
import { applicationConfigurationUrl } from '@/services/abpConfig.service'
2026-04-23 10:36:51 +00:00
interface LogLine {
level: 'info' | 'warn' | 'error' | 'success' | 'restart' | 'done'
message: string
}
type MigrationStatus = 'idle' | 'running' | 'success' | 'error' | 'restarting'
const levelClass: Record<string, string> = {
info: 'text-gray-300',
warn: 'text-yellow-400',
error: 'text-red-400',
success: 'text-green-400',
restart: 'text-blue-400',
done: 'text-blue-400',
}
2026-04-28 17:12:14 +00:00
/**
* Sunucu yeniden başlayana kadar ABP config endpoint'ini poll eder.
* Arka arkaya 2 başarılı yanıt alındığında onReady çağrılır.
* @param onReady Sunucu hazır olduğunda çağrılacak callback
* @param onAttempt Her denemede kaçıncı deneme olduğunu bildiren opsiyonel callback
*/
const pollUntilServerReady = (onReady: () => void, onAttempt?: (attempt: number) => void): (() => void) => {
const REQUIRED_SUCCESS = 2
let attempt = 0
let successCount = 0
let timerId: ReturnType<typeof setTimeout> | null = null
let cancelled = false
const tick = async () => {
if (cancelled) return
attempt++
onAttempt?.(attempt)
try {
const res = await fetch(
`${import.meta.env.VITE_API_URL ?? ''}${applicationConfigurationUrl(false)}`,
{ method: 'GET', headers: { Accept: 'application/json' }, cache: 'no-store' },
)
if (res.status === 200) {
try {
const json = await res.json()
if (json && typeof json.currentUser === 'object') {
successCount++
if (successCount >= REQUIRED_SUCCESS) {
onReady()
return
}
timerId = setTimeout(tick, 1000)
return
}
} catch { /* parse hatası */ }
}
successCount = 0
} catch {
successCount = 0
}
timerId = setTimeout(tick, 2000)
}
// İlk denemeden önce kısa bekleme (sunucunun kapanma süresi)
timerId = setTimeout(tick, 3000)
// İptal fonksiyonu döner
return () => {
cancelled = true
if (timerId) clearTimeout(timerId)
}
}
2026-04-23 10:36:51 +00:00
const DatabaseSetup = () => {
const [logs, setLogs] = useState<LogLine[]>([])
const [status, setStatus] = useState<MigrationStatus>('idle')
const [pollCountdown, setPollCountdown] = useState(0)
const logEndRef = useRef<HTMLDivElement>(null)
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Auto-scroll to bottom when new logs arrive
useEffect(() => {
logEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [logs])
// Cleanup on component unmount
useEffect(() => {
return () => {
const ref = pollTimerRef.current as any
if (ref?.unref) ref.unref()
else if (pollTimerRef.current) clearTimeout(pollTimerRef.current as any)
2026-04-23 10:36:51 +00:00
}
}, [])
const addLog = (level: LogLine['level'], message: string) => {
setLogs((prev) => [...prev, { level, message }])
}
// ── Poll ABP config endpoint until server is ready ──────────────
const pollUntilReady = () => {
setStatus('restarting')
const cancel = pollUntilServerReady(
() => { window.location.href = '/' },
(attempt) => setPollCountdown(attempt),
)
pollTimerRef.current = { unref: cancel } as any
2026-04-23 10:36:51 +00:00
}
const startMigration = async () => {
2026-04-23 10:36:51 +00:00
if (status === 'running') return
setLogs([])
setStatus('running')
addLog('info', 'Starting migration...')
const url = getMigrateUrl()
const abortController = new AbortController()
const parseChunk = (chunk: string) => {
// SSE format: "data: {...}\n\n"
const lines = chunk.split('\n')
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed.startsWith('data:')) continue
const raw = trimmed.slice(5).trim()
if (!raw) continue
try {
const data = JSON.parse(raw) as { level?: string; message?: string }
const level = (data.level ?? 'info') as LogLine['level']
const message = data.message ?? raw
if (level === 'done') {
setStatus((prev) => {
if (prev === 'running') return 'error'
return prev
})
return
}
2026-04-23 10:36:51 +00:00
addLog(level, message)
2026-04-23 10:36:51 +00:00
if (level === 'success') {
setStatus('success')
} else if (level === 'error') {
setStatus('error')
} else if (level === 'restart') {
pollUntilReady()
}
} catch {
addLog('info', raw)
2026-04-23 10:36:51 +00:00
}
}
}
try {
const response = await fetch(url, {
method: 'POST',
headers: { Accept: 'text/event-stream' },
signal: abortController.signal,
})
if (!response.ok || !response.body) {
addLog('error', `Server responded with status ${response.status}`)
setStatus('error')
return
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
// eslint-disable-next-line no-constant-condition
while (true) {
const { done, value } = await reader.read()
if (done) break
parseChunk(decoder.decode(value, { stream: true }))
}
} catch (err: any) {
if (err?.name !== 'AbortError') {
setStatus((prev) => (prev === 'running' ? 'error' : prev))
addLog('error', 'Server connection lost or migration could not be completed.')
}
2026-04-23 10:36:51 +00:00
}
}
return (
<div className="min-h-screen bg-gray-900 flex flex-col items-center justify-center p-6 text-white">
{/* Header */}
<div className="mb-8 text-center">
<h1 className="text-3xl font-bold mb-2">{APP_NAME}</h1>
<p className="text-gray-400 text-lg">Initial Setup Creating Database</p>
</div>
{/* Status Card */}
<div className="w-full max-w-4xl bg-gray-800 rounded-xl shadow-2xl overflow-hidden">
{/* Card Header */}
<div className="flex items-center justify-between px-5 py-4 bg-gray-700 border-b border-gray-600">
<div className="flex items-center gap-3">
<StatusIndicator status={status} />
<span className="font-semibold text-sm tracking-wide">
{status === 'idle' && 'Ready to Setup'}
{status === 'running' && 'Migration Running...'}
{status === 'success' && 'Migration Completed ✓'}
{status === 'restarting' && `Server Restarting... (attempt ${pollCountdown})`}
{status === 'error' && 'Migration Failed ✗'}
</span>
</div>
<span className="text-xs text-gray-400">{logs.length} log lines</span>
</div>
{/* Log Terminal */}
<div className="h-96 overflow-y-auto bg-gray-950 px-4 py-3 font-mono text-xs leading-relaxed">
{logs.length === 0 ? (
<p className="text-gray-600 mt-2">Logs will appear here when migration starts...</p>
) : (
logs.map((line, i) => (
<div key={i} className={`${levelClass[line.level] ?? 'text-gray-300'} mb-0.5`}>
<span className="text-gray-600 mr-2 select-none">{String(i + 1).padStart(4, '0')}</span>
{line.message}
</div>
))
)}
<div ref={logEndRef} />
</div>
{/* Action Area */}
<div className="flex items-center justify-between px-5 py-4 bg-gray-700 border-t border-gray-600">
<div className="text-xs text-gray-400">
{status === 'idle' && 'Database not found. Press the button to start migration.'}
{status === 'running' && 'Please wait, migration is in progress...'}
{status === 'success' && 'Migration completed. Server is restarting...'}
{status === 'restarting' &&
'Waiting for application server to be ready, you will be redirected automatically...'}
{status === 'error' &&
'Review the errors in the logs, fix the issue, and try again.'}
</div>
<div className="flex gap-3">
{(status === 'idle' || status === 'error') && (
<button
onClick={startMigration}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition-colors"
>
{status === 'error' ? 'Retry' : 'Start Setup'}
</button>
)}
{status === 'restarting' && (
<div className="flex items-center gap-2 text-blue-400 text-sm">
<span className="inline-block w-3 h-3 rounded-full bg-blue-400 animate-ping" />
Waiting...
</div>
)}
</div>
</div>
</div>
<p className="mt-6 text-xs text-gray-600">
This page is only visible when the database does not exist.
</p>
</div>
)
}
// ─── Status Indicator ────────────────────────────────────────────────────────
const StatusIndicator = ({ status }: { status: MigrationStatus }) => {
const cls: Record<MigrationStatus, string> = {
idle: 'bg-gray-500',
running: 'bg-yellow-400 animate-pulse',
success: 'bg-green-500',
restarting: 'bg-blue-400 animate-ping',
error: 'bg-red-500',
}
return <span className={`inline-block w-3 h-3 rounded-full ${cls[status]}`} />
}
export default DatabaseSetup