diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json b/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json index f6f33d6..ef11c71 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json +++ b/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json @@ -11622,6 +11622,12 @@ "tr": "Kapat", "en": "Close" }, + { + "resourceName": "Platform", + "key": "App.Platform.Running", + "tr": "Çalışıyor...", + "en": "Running..." + }, { "resourceName": "Platform", "key": "App.Platform.Intranet.SurveyModal.RequiredField", diff --git a/ui/src/components/shared/DbMigrateLogPanel.tsx b/ui/src/components/shared/DbMigrateLogPanel.tsx new file mode 100644 index 0000000..44b68d5 --- /dev/null +++ b/ui/src/components/shared/DbMigrateLogPanel.tsx @@ -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([]) + const [done, setDone] = useState(false) + const bottomRef = useRef(null) + + useEffect(() => { + const handler = (e: CustomEvent) => { + 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 ( +
+
+
+ DB Migration Logs + {done && ( + + )} +
+
+ {logs.map((log, i) => ( +
+ [{log.level}] + {log.message} +
+ ))} + {!done && ( +
+ + Running... +
+ )} +
+
+
+
+ ) +} + +let panelRoot: ReturnType | 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() +} + +export function dispatchMigrateLog(entry: MigrateLogEntry) { + window.dispatchEvent(new CustomEvent('db-migrate-log', { detail: entry })) +} + +export function dispatchMigrateDone() { + window.dispatchEvent(new CustomEvent('db-migrate-done')) +} diff --git a/ui/src/proxy/setup/models.ts b/ui/src/proxy/setup/models.ts new file mode 100644 index 0000000..e99675b --- /dev/null +++ b/ui/src/proxy/setup/models.ts @@ -0,0 +1,9 @@ +export interface MigrateLogEntry { + level: string + message: string +} + +export interface SetupStatusDto { + dbExists: boolean + error?: string +} \ No newline at end of file diff --git a/ui/src/services/UiEvalService.tsx b/ui/src/services/UiEvalService.tsx index 754589b..1d38bf9 100644 --- a/ui/src/services/UiEvalService.tsx +++ b/ui/src/services/UiEvalService.tsx @@ -4,7 +4,8 @@ import { getLocalization } from '@/services/localization.service' import { store } from '@/store' import { clearRedisCache } from './languageText.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 { static Init = () => { @@ -54,9 +55,37 @@ export abstract class UiEvalService { } static ApiDbMigrate = () => { - UiEvalService.runWithToast(async () => { - await getSetupMigrate() - }, '::App.DbMigrate.StartMessage') + toast.push( + + {UiEvalService.translate('::App.DbMigrate.StartMessage')} + , + { placement: 'top-end' }, + ) + openDbMigrateLogPanel() + streamSetupMigrate( + (entry) => { + dispatchMigrateLog(entry) + }, + () => { + dispatchMigrateDone() + toast.push( + + {UiEvalService.translate('::App.DbMigrate.EndMessage')} + , + { placement: 'top-end' }, + ) + }, + (err) => { + dispatchMigrateDone() + toast.push( + + {UiEvalService.translate('::App.DbMigrate.ErrorMessage')} + {err instanceof Error ? `: ${err.message}` : ''} + , + { placement: 'top-end' }, + ) + }, + ) } } diff --git a/ui/src/services/setup.service.ts b/ui/src/services/setup.service.ts index 3577b22..6f1d8eb 100644 --- a/ui/src/services/setup.service.ts +++ b/ui/src/services/setup.service.ts @@ -1,10 +1,7 @@ import apiService from './api.service' import { applicationConfigurationUrl } from './abpConfig.service' - -export interface SetupStatusDto { - dbExists: boolean - error?: string -} +import { store } from '@/store' +import { MigrateLogEntry, SetupStatusDto } from '@/proxy/setup/models' export const getSetupStatus = () => apiService.fetchData({ @@ -12,67 +9,56 @@ export const getSetupStatus = () => url: '/api/setup/status', }) -export const getSetupMigrate = () => - apiService.fetchData({ - method: 'POST', - url: '/api/setup/migrate', - }) - export const getMigrateUrl = (): string => { const base = import.meta.env.VITE_API_URL ?? '' return `${base}/api/setup/migrate` } -/** - * 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 - */ -export const pollUntilServerReady = (onReady: () => void, onAttempt?: (attempt: number) => void): (() => void) => { - const REQUIRED_SUCCESS = 2 - let attempt = 0 - let successCount = 0 - let timerId: ReturnType | 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 +export const streamSetupMigrate = async ( + onLog: (entry: MigrateLogEntry) => void, + onDone: () => void, + onError: (err: unknown) => void, +): Promise => { + const token = store.getState().auth.session.token + const url = getMigrateUrl() + try { + const response = await fetch(url, { + method: 'POST', + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + Accept: 'text/event-stream', + }, + }) + if (!response.ok) { + onError(new Error(`HTTP ${response.status}`)) + return } - 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 reader = response.body?.getReader() + if (!reader) { + onError(new Error('No response body')) + return + } + const decoder = new TextDecoder() + let buffer = '' + while (true) { + const { done, value } = await reader.read() + 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) } } + + diff --git a/ui/src/views/setup/DatabaseSetup.tsx b/ui/src/views/setup/DatabaseSetup.tsx index e7649c1..ee70727 100644 --- a/ui/src/views/setup/DatabaseSetup.tsx +++ b/ui/src/views/setup/DatabaseSetup.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react' 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 { level: 'info' | 'warn' | 'error' | 'success' | 'restart' | 'done' @@ -18,6 +19,60 @@ const levelClass: Record = { 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 | 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 [logs, setLogs] = useState([]) const [status, setStatus] = useState('idle')