DbMigrateLogPanel komponenti
This commit is contained in:
parent
8186364642
commit
b9cc68ff41
6 changed files with 257 additions and 66 deletions
|
|
@ -11622,6 +11622,12 @@
|
||||||
"tr": "Kapat",
|
"tr": "Kapat",
|
||||||
"en": "Close"
|
"en": "Close"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.Platform.Running",
|
||||||
|
"tr": "Çalışıyor...",
|
||||||
|
"en": "Running..."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"resourceName": "Platform",
|
"resourceName": "Platform",
|
||||||
"key": "App.Platform.Intranet.SurveyModal.RequiredField",
|
"key": "App.Platform.Intranet.SurveyModal.RequiredField",
|
||||||
|
|
|
||||||
106
ui/src/components/shared/DbMigrateLogPanel.tsx
Normal file
106
ui/src/components/shared/DbMigrateLogPanel.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { MigrateLogEntry } from '@/proxy/setup/models'
|
||||||
|
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
|
||||||
|
interface DbMigrateLogPanelProps {
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function levelColor(level: string): string {
|
||||||
|
switch (level?.toLowerCase()) {
|
||||||
|
case 'error':
|
||||||
|
return 'text-red-400'
|
||||||
|
case 'warn':
|
||||||
|
case 'warning':
|
||||||
|
return 'text-yellow-400'
|
||||||
|
case 'success':
|
||||||
|
return 'text-green-400'
|
||||||
|
default:
|
||||||
|
return 'text-gray-200'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function DbMigrateLogPanel({ onClose }: DbMigrateLogPanelProps) {
|
||||||
|
const [logs, setLogs] = useState<MigrateLogEntry[]>([])
|
||||||
|
const [done, setDone] = useState(false)
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: CustomEvent<MigrateLogEntry>) => {
|
||||||
|
setLogs((prev) => [...prev, e.detail])
|
||||||
|
}
|
||||||
|
const doneHandler = () => setDone(true)
|
||||||
|
|
||||||
|
window.addEventListener('db-migrate-log', handler as EventListener)
|
||||||
|
window.addEventListener('db-migrate-done', doneHandler)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('db-migrate-log', handler as EventListener)
|
||||||
|
window.removeEventListener('db-migrate-done', doneHandler)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}, [logs])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60">
|
||||||
|
<div className="flex flex-col w-[700px] max-w-[95vw] h-[520px] max-h-[90vh] rounded-xl shadow-2xl bg-gray-900 border border-gray-700">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700">
|
||||||
|
<span className="text-white font-semibold text-sm">DB Migration Logs</span>
|
||||||
|
{done && (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-white text-xs px-3 py-1 rounded border border-gray-600 hover:border-gray-400 transition-colors"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-3 font-mono text-xs leading-relaxed">
|
||||||
|
{logs.map((log, i) => (
|
||||||
|
<div key={i} className={`whitespace-pre-wrap break-words ${levelColor(log.level)}`}>
|
||||||
|
<span className="text-gray-500 mr-2 select-none">[{log.level}]</span>
|
||||||
|
{log.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!done && (
|
||||||
|
<div className="flex items-center gap-2 text-gray-500 mt-1">
|
||||||
|
<span className="inline-block w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
|
||||||
|
Running...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let panelRoot: ReturnType<typeof createRoot> | null = null
|
||||||
|
let panelContainer: HTMLElement | null = null
|
||||||
|
|
||||||
|
export function openDbMigrateLogPanel() {
|
||||||
|
if (panelContainer) return
|
||||||
|
panelContainer = document.createElement('div')
|
||||||
|
document.body.appendChild(panelContainer)
|
||||||
|
panelRoot = createRoot(panelContainer)
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
panelRoot?.unmount()
|
||||||
|
panelContainer?.remove()
|
||||||
|
panelRoot = null
|
||||||
|
panelContainer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
panelRoot.render(<DbMigrateLogPanel onClose={close} />)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dispatchMigrateLog(entry: MigrateLogEntry) {
|
||||||
|
window.dispatchEvent(new CustomEvent('db-migrate-log', { detail: entry }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dispatchMigrateDone() {
|
||||||
|
window.dispatchEvent(new CustomEvent('db-migrate-done'))
|
||||||
|
}
|
||||||
9
ui/src/proxy/setup/models.ts
Normal file
9
ui/src/proxy/setup/models.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export interface MigrateLogEntry {
|
||||||
|
level: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetupStatusDto {
|
||||||
|
dbExists: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,8 @@ import { getLocalization } from '@/services/localization.service'
|
||||||
import { store } from '@/store'
|
import { store } from '@/store'
|
||||||
import { clearRedisCache } from './languageText.service'
|
import { clearRedisCache } from './languageText.service'
|
||||||
import { kickUser } from './identity.service'
|
import { kickUser } from './identity.service'
|
||||||
import { getSetupMigrate } from './setup.service'
|
import { streamSetupMigrate } from './setup.service'
|
||||||
|
import { openDbMigrateLogPanel, dispatchMigrateLog, dispatchMigrateDone } from '@/components/shared/DbMigrateLogPanel'
|
||||||
|
|
||||||
export abstract class UiEvalService {
|
export abstract class UiEvalService {
|
||||||
static Init = () => {
|
static Init = () => {
|
||||||
|
|
@ -54,9 +55,37 @@ export abstract class UiEvalService {
|
||||||
}
|
}
|
||||||
|
|
||||||
static ApiDbMigrate = () => {
|
static ApiDbMigrate = () => {
|
||||||
UiEvalService.runWithToast(async () => {
|
toast.push(
|
||||||
await getSetupMigrate()
|
<Notification type="info" duration={3000}>
|
||||||
}, '::App.DbMigrate.StartMessage')
|
{UiEvalService.translate('::App.DbMigrate.StartMessage')}
|
||||||
|
</Notification>,
|
||||||
|
{ placement: 'top-end' },
|
||||||
|
)
|
||||||
|
openDbMigrateLogPanel()
|
||||||
|
streamSetupMigrate(
|
||||||
|
(entry) => {
|
||||||
|
dispatchMigrateLog(entry)
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
dispatchMigrateDone()
|
||||||
|
toast.push(
|
||||||
|
<Notification type="success" duration={5000}>
|
||||||
|
{UiEvalService.translate('::App.DbMigrate.EndMessage')}
|
||||||
|
</Notification>,
|
||||||
|
{ placement: 'top-end' },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
dispatchMigrateDone()
|
||||||
|
toast.push(
|
||||||
|
<Notification type="danger" duration={6000}>
|
||||||
|
{UiEvalService.translate('::App.DbMigrate.ErrorMessage')}
|
||||||
|
{err instanceof Error ? `: ${err.message}` : ''}
|
||||||
|
</Notification>,
|
||||||
|
{ placement: 'top-end' },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
import apiService from './api.service'
|
import apiService from './api.service'
|
||||||
import { applicationConfigurationUrl } from './abpConfig.service'
|
import { applicationConfigurationUrl } from './abpConfig.service'
|
||||||
|
import { store } from '@/store'
|
||||||
export interface SetupStatusDto {
|
import { MigrateLogEntry, SetupStatusDto } from '@/proxy/setup/models'
|
||||||
dbExists: boolean
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getSetupStatus = () =>
|
export const getSetupStatus = () =>
|
||||||
apiService.fetchData<SetupStatusDto>({
|
apiService.fetchData<SetupStatusDto>({
|
||||||
|
|
@ -12,67 +9,56 @@ export const getSetupStatus = () =>
|
||||||
url: '/api/setup/status',
|
url: '/api/setup/status',
|
||||||
})
|
})
|
||||||
|
|
||||||
export const getSetupMigrate = () =>
|
|
||||||
apiService.fetchData({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/setup/migrate',
|
|
||||||
})
|
|
||||||
|
|
||||||
export const getMigrateUrl = (): string => {
|
export const getMigrateUrl = (): string => {
|
||||||
const base = import.meta.env.VITE_API_URL ?? ''
|
const base = import.meta.env.VITE_API_URL ?? ''
|
||||||
return `${base}/api/setup/migrate`
|
return `${base}/api/setup/migrate`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export const streamSetupMigrate = async (
|
||||||
* Sunucu yeniden başlayana kadar ABP config endpoint'ini poll eder.
|
onLog: (entry: MigrateLogEntry) => void,
|
||||||
* Arka arkaya 2 başarılı yanıt alındığında onReady çağrılır.
|
onDone: () => void,
|
||||||
* @param onReady Sunucu hazır olduğunda çağrılacak callback
|
onError: (err: unknown) => void,
|
||||||
* @param onAttempt Her denemede kaçıncı deneme olduğunu bildiren opsiyonel callback
|
): Promise<void> => {
|
||||||
*/
|
const token = store.getState().auth.session.token
|
||||||
export const pollUntilServerReady = (onReady: () => void, onAttempt?: (attempt: number) => void): (() => void) => {
|
const url = getMigrateUrl()
|
||||||
const REQUIRED_SUCCESS = 2
|
try {
|
||||||
let attempt = 0
|
const response = await fetch(url, {
|
||||||
let successCount = 0
|
method: 'POST',
|
||||||
let timerId: ReturnType<typeof setTimeout> | null = null
|
headers: {
|
||||||
let cancelled = false
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
Accept: 'text/event-stream',
|
||||||
const tick = async () => {
|
},
|
||||||
if (cancelled) return
|
})
|
||||||
attempt++
|
if (!response.ok) {
|
||||||
onAttempt?.(attempt)
|
onError(new Error(`HTTP ${response.status}`))
|
||||||
|
return
|
||||||
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)
|
const reader = response.body?.getReader()
|
||||||
}
|
if (!reader) {
|
||||||
|
onError(new Error('No response body'))
|
||||||
// İlk denemeden önce kısa bekleme (sunucunun kapanma süresi)
|
return
|
||||||
timerId = setTimeout(tick, 3000)
|
}
|
||||||
|
const decoder = new TextDecoder()
|
||||||
// İptal fonksiyonu döner
|
let buffer = ''
|
||||||
return () => {
|
while (true) {
|
||||||
cancelled = true
|
const { done, value } = await reader.read()
|
||||||
if (timerId) clearTimeout(timerId)
|
if (done) break
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
const lines = buffer.split('\n')
|
||||||
|
buffer = lines.pop() ?? ''
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(line.slice(6)) as MigrateLogEntry
|
||||||
|
onLog(data)
|
||||||
|
} catch { /* parse hatası, atla */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onDone()
|
||||||
|
} catch (err) {
|
||||||
|
onError(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { APP_NAME } from '@/constants/app.constant'
|
import { APP_NAME } from '@/constants/app.constant'
|
||||||
import { getMigrateUrl, getSetupStatus, pollUntilServerReady } from '@/services/setup.service'
|
import { getMigrateUrl } from '@/services/setup.service'
|
||||||
|
import { applicationConfigurationUrl } from '@/services/abpConfig.service'
|
||||||
|
|
||||||
interface LogLine {
|
interface LogLine {
|
||||||
level: 'info' | 'warn' | 'error' | 'success' | 'restart' | 'done'
|
level: 'info' | 'warn' | 'error' | 'success' | 'restart' | 'done'
|
||||||
|
|
@ -18,6 +19,60 @@ const levelClass: Record<string, string> = {
|
||||||
done: 'text-blue-400',
|
done: 'text-blue-400',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const DatabaseSetup = () => {
|
const DatabaseSetup = () => {
|
||||||
const [logs, setLogs] = useState<LogLine[]>([])
|
const [logs, setLogs] = useState<LogLine[]>([])
|
||||||
const [status, setStatus] = useState<MigrationStatus>('idle')
|
const [status, setStatus] = useState<MigrationStatus>('idle')
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue