sozsoft-platform/ui/src/services/setup.service.ts
2026-08-09 11:52:28 +03:00

215 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import apiService from './api.service'
import { store } from '@/store'
import { applicationConfigurationUrl } from './abpConfig.service'
import { MigrateLogEntry, MigrateLogLevel, SetupStatusDto } from '@/proxy/setup/models'
/**
* Sunucunun veritabanı/kurulum durumu.
*
* Hata durumu her çağıran tarafından ayrıca ele alınır (kurulum moduna geçiş kararı,
* durum kartı), bu yüzden global hata diyaloğu bastırılır.
*/
export const getSetupStatus = () =>
apiService.fetchData<SetupStatusDto>({
method: 'GET',
url: '/api/setup/application-status',
silentError: true,
})
const getMigrateUrl = (): string => {
const base = import.meta.env.VITE_API_URL ?? ''
return `${base}/api/setup/migrate`
}
/**
* Sunucu yeniden başlatıldıktan sonra tam ABP stack ile ayağa kalkana kadar
* application-configuration endpoint'ini poll eder.
*
* Arka arkaya iki başarılı yanıt beklenir; tek başarılı yanıt sunucu henüz
* tüm modülleri yüklemeden de dönebilir.
*
* @returns Poll'u iptal eden fonksiyon.
*/
export const pollUntilServerReady = (
onReady: () => void,
onAttempt?: (attempt: number) => void,
): (() => void) => {
const REQUIRED_CONSECUTIVE_SUCCESS = 2
const INITIAL_DELAY_MS = 3000
const SUCCESS_RETRY_MS = 1000
const FAILURE_RETRY_MS = 2000
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) {
const json = await res.json()
if (json && typeof json.currentUser === 'object') {
successCount++
if (successCount >= REQUIRED_CONSECUTIVE_SUCCESS) {
onReady()
return
}
timerId = setTimeout(tick, SUCCESS_RETRY_MS)
return
}
}
} catch {
/* sunucu henüz ayakta değil */
}
successCount = 0
timerId = setTimeout(tick, FAILURE_RETRY_MS)
}
// Sunucunun kapanması için kısa bir bekleme.
timerId = setTimeout(tick, INITIAL_DELAY_MS)
return () => {
cancelled = true
if (timerId) clearTimeout(timerId)
}
}
export interface MigrateStreamOptions {
/**
* Kurulum parolası. Veritabanı yokken (sunucu kurulum modundayken) zorunludur;
* canlı sistemde bunun yerine oturum token'ı ile yetkilendirme yapılır.
*/
setupPassword?: string
signal?: AbortSignal
/** Her log satırı için çağrılır. */
onLog?: (entry: MigrateLogEntry) => void
/** Migration başarıyla tamamlandığında. */
onSuccess?: () => void
/** Sunucu yeniden başlıyor — istemci hazır olana kadar poll etmeli. */
onRestart?: () => void
/** Akış hata ile bitti. */
onError?: (error: Error) => void
/** Başarılı veya başarısız — akış sona erdi. */
onFinished?: () => void
}
/**
* /api/setup/migrate SSE akışının tek istemcisi.
*
* Hem /setup sayfası hem SQL Query Manager hem de Wizard dosya yöneticisi bunu kullanır;
* SSE ayrıştırma mantığı yalnızca burada bulunur.
*/
export const streamSetupMigrate = async (options: MigrateStreamOptions = {}): Promise<void> => {
const { setupPassword, signal, onLog, onSuccess, onRestart, onError, onFinished } = options
// Kurulum parolası kullanılıyorsa oturum token'ı gönderilmez. Sunucu zaten parola
// yolunu seçtiğinde token'a bakmaz; token göndermek yalnızca veritabanına bağımlı
// ara katmanların (oturum doğrulama, dinamik claim'ler) veritabanı düşükken
// devreye girip isteği bloke etmesine yol açar.
const token = setupPassword ? null : store.getState().auth.session.token
let failed = false
const fail = (error: Error) => {
if (failed) return
failed = true
onError?.(error)
}
try {
const response = await fetch(getMigrateUrl(), {
method: 'POST',
headers: {
Accept: 'text/event-stream',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(setupPassword ? { 'X-Setup-Password': setupPassword } : {}),
},
signal,
})
if (!response.ok || !response.body) {
// Sunucu reddetme nedenini gövdede düz metin olarak döner (yanlış parola,
// eksik yapılandırma, eksik izin). Bunu yutmak hatayı teşhis edilemez yapıyordu.
const serverReason = await response.text().catch(() => '')
fail(
new Error(
serverReason.trim() ||
(response.status === 403
? 'Not authorized to run database migration.'
: `Server responded with status ${response.status}`),
),
)
return
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let sawSuccess = false
const handleEntry = (entry: MigrateLogEntry) => {
const level = (entry.level ?? 'info') as MigrateLogLevel
if (level === 'done') {
// Sunucu "done" gönderdi ama öncesinde success/restart gelmediyse akış başarısızdır.
if (!sawSuccess) fail(new Error(entry.message || 'Migration failed.'))
return
}
onLog?.(entry)
if (level === 'success') {
sawSuccess = true
onSuccess?.()
} else if (level === 'restart') {
sawSuccess = true
onRestart?.()
} else if (level === 'error') {
fail(new Error(entry.message || 'Migration failed.'))
}
}
const flushLines = (chunk: string) => {
buffer += chunk
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed.startsWith('data:')) continue
const raw = trimmed.slice(5).trim()
if (!raw) continue
try {
handleEntry(JSON.parse(raw) as MigrateLogEntry)
} catch {
onLog?.({ level: 'info', message: raw })
}
}
}
while (true) {
const { done, value } = await reader.read()
if (done) break
flushLines(decoder.decode(value, { stream: true }))
}
} catch (err) {
if ((err as Error)?.name !== 'AbortError') {
fail(err instanceof Error ? err : new Error(String(err)))
}
} finally {
onFinished?.()
}
}