From 8186364642c3c0885a336c03439a26e8bd7529ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sedat=20=C3=96ZT=C3=9CRK?= <76204082+iamsedatozturk@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:53:58 +0300 Subject: [PATCH] =?UTF-8?q?Uygulama=20a=C3=A7=C4=B1kken=20Db=20Migration?= =?UTF-8?q?=20=C3=A7al=C4=B1=C5=9Ft=C4=B1rma?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Seeds/LanguagesData.json | 24 +++ .../Seeds/ListFormSeeder_Saas.cs | 16 +- .../Controllers/SetupController.cs | 168 ++++++++++++++++++ .../DbStartup/SetupAppRunner.cs | 9 +- .../Controllers/ImportController.cs | 0 .../Controllers/SetupController.cs | 16 -- ui/src/services/UiEvalService.tsx | 130 ++++---------- ui/src/services/setup.service.ts | 65 ++++++- ui/src/views/setup/DatabaseSetup.tsx | 156 +++++++--------- 9 files changed, 366 insertions(+), 218 deletions(-) create mode 100644 api/src/Sozsoft.Platform.HttpApi.Host/Controllers/SetupController.cs delete mode 100644 api/src/Sozsoft.Platform.HttpApi/Controllers/ImportController.cs delete mode 100644 api/src/Sozsoft.Platform.HttpApi/Controllers/SetupController.cs diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json b/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json index 8a086fb..f6f33d6 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json +++ b/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json @@ -3060,6 +3060,24 @@ "en": "Background Workers are being renewed.", "tr": "Arka Plan Çalışanları yenileniyor." }, + { + "resourceName": "Platform", + "key": "App.DbMigrate.StartMessage", + "en": "Database migration is starting.", + "tr": "Veritabanı geçişi başlatılıyor." + }, + { + "resourceName": "Platform", + "key": "App.DbMigrate.EndMessage", + "en": "Database migration has completed.", + "tr": "Veritabanı geçişi tamamlandı." + }, + { + "resourceName": "Platform", + "key": "App.DbMigrate.ErrorMessage", + "en": "An error occurred during database migration.", + "tr": "Veritabanı geçişi sırasında bir hata oluştu." + }, { "resourceName": "Platform", "key": "App.ClearRedisCache.Message", @@ -3240,6 +3258,12 @@ "en": "Clear Redis Cache", "tr": "Redis Önbelleğini Temizle" }, + { + "resourceName": "Platform", + "key": "ListForms.ListForm.DbMigrate", + "en": "DB Migrate", + "tr": "DB Göçü" + }, { "resourceName": "Platform", "key": "ListForms.ListForm.SaveGridState", diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_Saas.cs b/api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_Saas.cs index 1b6c157..22b5c86 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_Saas.cs +++ b/api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_Saas.cs @@ -131,6 +131,14 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency DialogParameters = JsonSerializer.Serialize(new { name = "@Name", id = "@Id" }), IsVisible = true, }, + new() { + ButtonPosition= UiCommandButtonPositionTypeEnum.Toolbar, + Hint = "ListForms.ListForm.DbMigrate", + Text = "ListForms.ListForm.DbMigrate", + AuthName = listFormName, + OnClick = "UiEvalService.ApiDbMigrate();", + IsVisible = true, + }, }), InsertServiceAddress = "list-form-dynamic-api/tenant-insert", UpdateServiceAddress = "list-form-dynamic-api/tenant-update", @@ -143,7 +151,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency new() { FieldName = "Country", FieldDbType = DbType.String, Value = "Türkiye", CustomValueType = FieldCustomValueTypeEnum.Value }, new() { FieldName = "MaxConcurrentUsers", FieldDbType = DbType.Int32, Value = "0", CustomValueType = FieldCustomValueTypeEnum.Value } }), - SubFormsJson =JsonSerializer.Serialize(new List() { + SubFormsJson = JsonSerializer.Serialize(new List() { new { TabType = ListFormTabTypeEnum.List, TabTitle = AppCodes.Branches, @@ -4907,7 +4915,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency #endregion } #endregion - + #region Route listFormName = AppCodes.Menus.Routes; if (!await _listFormRepository.AnyAsync(a => a.ListFormCode == listFormName)) @@ -5500,7 +5508,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency #endregion } #endregion - + #region Custom Endpoint listFormName = AppCodes.DeveloperKits.CustomEndpoints; if (!await _listFormRepository.AnyAsync(a => a.ListFormCode == listFormName)) @@ -5729,7 +5737,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency #endregion } #endregion - + #region Products listFormName = AppCodes.Orders.Products; if (!await _listFormRepository.AnyAsync(a => a.ListFormCode == listFormName)) diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/Controllers/SetupController.cs b/api/src/Sozsoft.Platform.HttpApi.Host/Controllers/SetupController.cs new file mode 100644 index 0000000..3183582 --- /dev/null +++ b/api/src/Sozsoft.Platform.HttpApi.Host/Controllers/SetupController.cs @@ -0,0 +1,168 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; + +namespace Sozsoft.Platform.Controllers; + +/// +/// DB hazır olduğunda bile /setup sayfasından migration çalıştırmaya olanak tanır. +/// +[Route("api/setup")] +[Authorize(Roles = "admin")] +public class SetupController : ControllerBase +{ + private readonly IConfiguration _configuration; + private readonly IHostEnvironment _env; + + public SetupController( + IConfiguration configuration, + IHostEnvironment env) + { + _configuration = configuration; + _env = env; + } + + [HttpGet("status")] + [AllowAnonymous] + public IActionResult Status() + { + return Ok(new { dbExists = SetupAppRunner.DatabaseIsReady(_configuration) }); + } + + [HttpPost("migrate")] + public async Task Migrate(CancellationToken ct) + { + Response.ContentType = "text/event-stream; charset=utf-8"; + Response.Headers["Cache-Control"] = "no-cache, no-store"; + Response.Headers["X-Accel-Buffering"] = "no"; + await Response.Body.FlushAsync(ct); + + async Task Send(string level, string message) + { + try + { + var payload = JsonSerializer.Serialize(new { level, message }); + await Response.WriteAsync($"data: {payload}\n\n", ct); + await Response.Body.FlushAsync(ct); + } + catch { } + } + + var migratorPath = _configuration["Setup:MigratorPath"] + ?? Path.GetFullPath(Path.Combine(_env.ContentRootPath, "..", "Sozsoft.Platform.DbMigrator")); + + await Send("info", "Veritabanı migration ve seed başlatılıyor..."); + await Send("info", $"Migrator yolu: {migratorPath}"); + + var extraArgs = _configuration["Setup:MigratorArgs"] ?? "--Seed=true"; + + string fileName; + string arguments; + string workingDirectory; + + if (migratorPath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) && System.IO.File.Exists(migratorPath)) + { + fileName = "dotnet"; + arguments = $"\"{migratorPath}\" {extraArgs}"; + workingDirectory = Path.GetDirectoryName(migratorPath)!; + } + else if (Directory.Exists(migratorPath)) + { + var dllFiles = Directory.GetFiles(migratorPath, "*.DbMigrator.dll", SearchOption.TopDirectoryOnly); + if (dllFiles.Length == 0) + dllFiles = Directory.GetFiles(migratorPath, "*Migrator*.dll", SearchOption.TopDirectoryOnly); + + if (dllFiles.Length > 0) + { + fileName = "dotnet"; + arguments = $"\"{dllFiles[0]}\" {extraArgs}"; + workingDirectory = migratorPath; + } + else + { + fileName = "dotnet"; + arguments = $"run --project \"{migratorPath}\" -- {extraArgs}"; + workingDirectory = migratorPath; + } + } + else + { + await Send("error", $"Migrator yolu bulunamadı veya geçersiz: {migratorPath}"); + await Send("done", "Hata ile sonlandı."); + return; + } + + await Send("info", $"Çalıştırılıyor: {fileName} {arguments}"); + + Process? process = null; + try + { + process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = workingDirectory, + } + }; + + process.Start(); + + async Task ReadStream(StreamReader reader, string level) + { + try + { + while (!reader.EndOfStream) + { + var line = await reader.ReadLineAsync(ct); + if (line != null) await Send(level, line); + } + } + catch (OperationCanceledException) { } + } + + await Task.WhenAll( + ReadStream(process.StandardOutput, "info"), + ReadStream(process.StandardError, "warn")); + + await process.WaitForExitAsync(ct); + + if (process.ExitCode == 0) + { + await Send("success", "Migration ve seed başarıyla tamamlandı."); + await Send("done", "Tamamlandı."); + } + else + { + await Send("error", $"Migration başarısız. Çıkış kodu: {process.ExitCode}"); + await Send("done", "Hata ile sonlandı."); + } + } + catch (OperationCanceledException) + { + await Send("warn", "Migration isteği iptal edildi."); + } + catch (Exception ex) + { + await Send("error", $"Migration hatası: {ex.Message}"); + await Send("done", "Hata ile sonlandı."); + } + finally + { + process?.Dispose(); + } + } +} diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/DbStartup/SetupAppRunner.cs b/api/src/Sozsoft.Platform.HttpApi.Host/DbStartup/SetupAppRunner.cs index 266174b..ddcc013 100644 --- a/api/src/Sozsoft.Platform.HttpApi.Host/DbStartup/SetupAppRunner.cs +++ b/api/src/Sozsoft.Platform.HttpApi.Host/DbStartup/SetupAppRunner.cs @@ -130,7 +130,7 @@ internal static class SetupAppRunner app.MapGet("/api/setup/status", (IConfiguration cfg) => Results.Ok(new { dbExists = DatabaseIsReady(cfg) })); - app.MapGet("/api/setup/migrate", async (IConfiguration cfg, IHostEnvironment env, + app.MapPost("/api/setup/migrate", async (IConfiguration cfg, IHostEnvironment env, IHostApplicationLifetime lifetime, HttpContext ctx, CancellationToken ct) => { ctx.Response.ContentType = "text/event-stream; charset=utf-8"; @@ -149,13 +149,6 @@ internal static class SetupAppRunner catch { } } - if (DatabaseIsReady(cfg)) - { - await Send("warn", "Veritabanı zaten hazır. Migration atlanıyor."); - await Send("done", "Tamamlandı."); - return; - } - var migratorPath = cfg["Setup:MigratorPath"] ?? Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "Sozsoft.Platform.DbMigrator")); diff --git a/api/src/Sozsoft.Platform.HttpApi/Controllers/ImportController.cs b/api/src/Sozsoft.Platform.HttpApi/Controllers/ImportController.cs deleted file mode 100644 index e69de29..0000000 diff --git a/api/src/Sozsoft.Platform.HttpApi/Controllers/SetupController.cs b/api/src/Sozsoft.Platform.HttpApi/Controllers/SetupController.cs deleted file mode 100644 index 9935dcf..0000000 --- a/api/src/Sozsoft.Platform.HttpApi/Controllers/SetupController.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Volo.Abp.AspNetCore.Mvc; - -namespace Sozsoft.Platform.Controllers; - -/// -/// Tam ABP uygulaması çalışırken setup durumunu döner. -/// SetupAppRunner'ın aynı endpoint'i DB hazır olmadığında dbExists=false döner. -/// -[ApiController] -[Route("api/setup")] -public class SetupController : AbpControllerBase -{ - [HttpGet("status")] - public IActionResult Status() => Ok(new { dbExists = true }); -} diff --git a/ui/src/services/UiEvalService.tsx b/ui/src/services/UiEvalService.tsx index af28661..754589b 100644 --- a/ui/src/services/UiEvalService.tsx +++ b/ui/src/services/UiEvalService.tsx @@ -4,127 +4,59 @@ 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' export abstract class UiEvalService { static Init = () => { //console.log('init') } - static ApiGenerateBackgroundWorkers = () => { - // Get store state directly instead of using hook - const state = store.getState() - const { texts, config } = state.abpConfig - - // Create translate function similar to useLocalization hook - const translate = ( - localizationKey: string, - params?: Record, - defaultResourceName?: string, - ): string => { - if (!texts) { - return localizationKey - } - return getLocalization( - texts, - defaultResourceName ?? config?.localization?.defaultResourceName, - localizationKey, - params, - ) - } - - const asyncCall = async () => { - await generateBackgroundWorkers() - } - - asyncCall() + private static translate = ( + localizationKey: string, + params?: Record, + defaultResourceName?: string, + ): string => { + const { texts, config } = store.getState().abpConfig + if (!texts) return localizationKey + return getLocalization( + texts, + defaultResourceName ?? config?.localization?.defaultResourceName, + localizationKey, + params, + ) + } + private static runWithToast = (asyncFn: () => Promise, toastKey: string) => { + asyncFn() toast.push( - {translate('::App.BackgroundWorkers.Message')} + {UiEvalService.translate(toastKey)} , - { - placement: 'top-end', - }, + { placement: 'top-end' }, ) } + static ApiGenerateBackgroundWorkers = () => { + UiEvalService.runWithToast(() => generateBackgroundWorkers(), '::App.BackgroundWorkers.Message') + } + static ApiClearRedisCache = () => { - // Get store state directly instead of using hook - const state = store.getState() - const { texts, config } = state.abpConfig - - // Create translate function similar to useLocalization hook - const translate = ( - localizationKey: string, - params?: Record, - defaultResourceName?: string, - ): string => { - if (!texts) { - return localizationKey - } - return getLocalization( - texts, - defaultResourceName ?? config?.localization?.defaultResourceName, - localizationKey, - params, - ) - } - - const asyncCall = async () => { - await clearRedisCache() - } - - asyncCall() - - toast.push( - - {translate('::App.ClearRedisCache.Message')} - , - { - placement: 'top-end', - }, - ) + UiEvalService.runWithToast(() => clearRedisCache(), '::App.ClearRedisCache.Message') } static ApiKickUser = (userId: string, gridRef?: any) => { - // Get store state directly instead of using hook - const state = store.getState() - const { texts, config } = state.abpConfig - - // Create translate function similar to useLocalization hook - const translate = ( - localizationKey: string, - params?: Record, - defaultResourceName?: string, - ): string => { - if (!texts) { - return localizationKey - } - return getLocalization( - texts, - defaultResourceName ?? config?.localization?.defaultResourceName, - localizationKey, - params, - ) - } - - const asyncCall = async () => { + UiEvalService.runWithToast(async () => { await kickUser(userId) if (gridRef?.current) { gridRef.current.instance().refresh() } - } + }, '::App.KickUser.Message') + } - asyncCall() - - toast.push( - - {translate('::App.KickUser.Message')} - , - { - placement: 'top-end', - }, - ) + static ApiDbMigrate = () => { + UiEvalService.runWithToast(async () => { + await getSetupMigrate() + }, '::App.DbMigrate.StartMessage') } } diff --git a/ui/src/services/setup.service.ts b/ui/src/services/setup.service.ts index 6ec7081..3577b22 100644 --- a/ui/src/services/setup.service.ts +++ b/ui/src/services/setup.service.ts @@ -1,4 +1,5 @@ import apiService from './api.service' +import { applicationConfigurationUrl } from './abpConfig.service' export interface SetupStatusDto { dbExists: boolean @@ -11,11 +12,67 @@ export const getSetupStatus = () => url: '/api/setup/status', }) -/** - * Returns the SSE URL for migration streaming. - * Usage: new EventSource(getMigrateUrl()) - */ +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 + } + 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) + } +} diff --git a/ui/src/views/setup/DatabaseSetup.tsx b/ui/src/views/setup/DatabaseSetup.tsx index c31d951..e7649c1 100644 --- a/ui/src/views/setup/DatabaseSetup.tsx +++ b/ui/src/views/setup/DatabaseSetup.tsx @@ -1,7 +1,6 @@ import { useEffect, useRef, useState } from 'react' import { APP_NAME } from '@/constants/app.constant' -import { getMigrateUrl } from '@/services/setup.service' -import { applicationConfigurationUrl, getAppConfig } from '@/services/abpConfig.service' +import { getMigrateUrl, getSetupStatus, pollUntilServerReady } from '@/services/setup.service' interface LogLine { level: 'info' | 'warn' | 'error' | 'success' | 'restart' | 'done' @@ -24,7 +23,6 @@ const DatabaseSetup = () => { const [status, setStatus] = useState('idle') const [pollCountdown, setPollCountdown] = useState(0) const logEndRef = useRef(null) - const eventSourceRef = useRef(null) const pollTimerRef = useRef | null>(null) // Auto-scroll to bottom when new logs arrive @@ -35,8 +33,9 @@ const DatabaseSetup = () => { // Cleanup on component unmount useEffect(() => { return () => { - eventSourceRef.current?.close() - if (pollTimerRef.current) clearTimeout(pollTimerRef.current) + const ref = pollTimerRef.current as any + if (ref?.unref) ref.unref() + else if (pollTimerRef.current) clearTimeout(pollTimerRef.current as any) } }, []) @@ -47,56 +46,14 @@ const DatabaseSetup = () => { // ── Poll ABP config endpoint until server is ready ────────────── const pollUntilReady = () => { setStatus('restarting') - - let attempt = 0 - let successCount = 0 - const REQUIRED_SUCCESS = 2 // sunucunun kararlı olduğunu doğrulamak için arka arkaya 2 başarılı yanıt - - const tick = async () => { - attempt++ - setPollCountdown(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() - // ABP config yanıtının geçerli olduğunu doğrula (currentUser alanı her zaman bulunur) - if (json && typeof json.currentUser === 'object') { - successCount++ - if (successCount >= REQUIRED_SUCCESS) { - // Sunucu tamamen hazır — yönlendir - window.location.href = '/' - return - } - // Bir sonraki doğrulama denemesi - pollTimerRef.current = setTimeout(tick, 1000) - return - } - } catch { - // JSON parse hatası — sunucu henüz tam hazır değil - } - } - // Başarısız — sıfırla ve tekrar dene - successCount = 0 - } catch { - // Sunucu henüz yanıt vermiyor — bekleniyor, tekrar dene - successCount = 0 - } - pollTimerRef.current = setTimeout(tick, 2000) - } - - // İlk denemeden önce kısa bir bekleme (sunucunun kapanma süresi) - pollTimerRef.current = setTimeout(tick, 3000) + const cancel = pollUntilServerReady( + () => { window.location.href = '/' }, + (attempt) => setPollCountdown(attempt), + ) + pollTimerRef.current = { unref: cancel } as any } - const startMigration = () => { + const startMigration = async () => { if (status === 'running') return setLogs([]) @@ -104,46 +61,71 @@ const DatabaseSetup = () => { addLog('info', 'Starting migration...') const url = getMigrateUrl() - const es = new EventSource(url) - eventSourceRef.current = es + const abortController = new AbortController() - es.onmessage = (event) => { - try { - const data = JSON.parse(event.data) as { level?: string; message?: string } - const level = (data.level ?? 'info') as LogLine['level'] - const message = data.message ?? event.data + 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') { - es.close() - eventSourceRef.current = null - // If we received a "restart" event, switch to poll mode; otherwise keep success/error state - setStatus((prev) => { - if (prev === 'running') return 'error' // failed — no restart event received - return prev - }) - return + if (level === 'done') { + setStatus((prev) => { + if (prev === 'running') return 'error' + return prev + }) + return + } + + addLog(level, message) + + if (level === 'success') { + setStatus('success') + } else if (level === 'error') { + setStatus('error') + } else if (level === 'restart') { + pollUntilReady() + } + } catch { + addLog('info', raw) } - - addLog(level, message) - - if (level === 'success') { - setStatus('success') - } else if (level === 'error') { - setStatus('error') - } else if (level === 'restart') { - // Backend is stopping the minimal app — start polling - pollUntilReady() - } - } catch { - addLog('info', event.data) } } - es.onerror = () => { - es.close() - eventSourceRef.current = null - setStatus((prev) => (prev === 'running' ? 'error' : prev)) - addLog('error', 'Server connection lost or migration could not be completed.') + 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.') + } } }