Claude güncellemesi Setup ve Migration

This commit is contained in:
Sedat ÖZTÜRK 2026-08-09 11:52:28 +03:00
parent 56b74b85a5
commit a01ce1533a
34 changed files with 1670 additions and 743 deletions

View file

@ -1,6 +1,7 @@
using Sozsoft.Platform.Localization;
using Volo.Abp.Authorization.Permissions;
using Volo.Abp.Localization;
using Volo.Abp.MultiTenancy;
namespace Sozsoft.Platform.Permissions;
@ -10,9 +11,23 @@ public class PlatformPermissionDefinitionProvider : PermissionDefinitionProvider
{
PermissionRemoveGroup(context);
//var myGroup = context.AddGroup(PlatformPermissions.GroupName);
//Define your own permissions here. Example:
//var perm = myGroup.AddPermission("hikaye_permission", L("Permission:MyPermission1"));
DefineSetupPermissions(context);
}
/// <summary>
/// Kurulum / veritabanı bakım yetkileri. <see cref="MultiTenancySides.Host"/> ile
/// işaretlendiği için tenant kullanıcılarına hiç görünmez ve verilemez.
/// </summary>
private void DefineSetupPermissions(IPermissionDefinitionContext context)
{
var setupGroup = context.AddGroup(
PlatformPermissions.GroupName,
L("Permission:Setup"));
setupGroup.AddPermission(
PlatformPermissions.Setup.Migrate,
L("Permission:Setup.Migrate"),
MultiTenancySides.Host);
}
private static LocalizableString L(string name)

View file

@ -0,0 +1,16 @@
namespace Sozsoft.Platform.Permissions;
public static class PlatformPermissions
{
public const string GroupName = "App.Setup";
/// <summary>
/// Kurulum / veritabanı bakım işlemleri. Yalnızca host tarafında anlamlıdır;
/// tenant kullanıcıları host veritabanına migration uygulayamaz.
/// </summary>
public static class Setup
{
/// <summary>Veritabanı migration + seed işlemini tetikleme yetkisi.</summary>
public const string Migrate = GroupName + ".Migrate";
}
}

View file

@ -3618,6 +3618,18 @@
"en": "Are you sure you want to start database migration? Please make sure you have a backup before proceeding.",
"tr": "Veritabanı geçişini başlatmak istediğinizden emin misiniz? Lütfen devam etmeden önce bir yedekleme yaptığınızdan emin olun."
},
{
"resourceName": "Platform",
"key": "Permission:Setup",
"en": "Setup & Database Maintenance",
"tr": "Kurulum ve Veritabanı Bakımı"
},
{
"resourceName": "Platform",
"key": "Permission:Setup.Migrate",
"en": "Run database migration",
"tr": "Veritabanı geçişi çalıştırma"
},
{
"resourceName": "Platform",
"key": "App.ClearRedisCache.Message",

View file

@ -14,6 +14,7 @@ using IdentityRole = Volo.Abp.Identity.IdentityRole;
using IdentityUser = Volo.Abp.Identity.IdentityUser;
using Volo.Abp.Domain.Repositories;
using Sozsoft.Platform.Entities;
using Sozsoft.Platform.Permissions;
using Volo.Abp.TenantManagement;
namespace Sozsoft.Platform.Data.Seeds;
@ -198,6 +199,28 @@ public class PlatformIdentityDataSeeder : IdentityDataSeeder
]);
}
/* Kurulum/migration yetkisi yalnızca host admin rolüne verilir.
Yukarıdaki blok sadece ilk kurulumda çalıştığı için burada idempotent olarak eklenir. */
if (tenantId == null)
{
var migrateGrant = await _permissionGrantRepository.FindAsync(
PlatformPermissions.Setup.Migrate,
"R",
PlatformConsts.AbpIdentity.User.AdminRoleName);
if (migrateGrant == null)
{
await _permissionGrantRepository.InsertAsync(
new PermissionGrant(
Guid.NewGuid(),
PlatformPermissions.Setup.Migrate,
"R",
PlatformConsts.AbpIdentity.User.AdminRoleName,
tenantId),
autoSave: true);
}
}
return result;
}
}

View file

@ -1,204 +1,261 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Text.Json;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Sozsoft.Platform.DbStartup;
using Sozsoft.Platform.EntityFrameworkCore;
using Sozsoft.Platform.Permissions;
using Volo.Abp.DistributedLocking;
using Volo.Abp.EntityFrameworkCore;
using Volo.Abp.MultiTenancy;
using Volo.Abp.Uow;
namespace Sozsoft.Platform.Controllers;
/// <summary>
/// DB hazır olduğunda bile /setup sayfasından migration çalıştırmaya olanak tanır.
/// Canlı sistemde (tam ABP stack ayaktayken) veritabanı durumunu raporlar ve
/// yetkili host kullanıcısının migration tetiklemesine izin verir.
///
/// Veritabanı hiç yokken bu controller çalışmaz; o durumda
/// <see cref="DbStartup.SetupAppRunner"/> içindeki minimal uygulama devrededir.
/// İki yol da <see cref="SetupMigrationRunner"/> üzerinden aynı migration akışını kullanır.
/// </summary>
[Route("api/setup")]
[Authorize(Roles = "admin")]
public class SetupController : ControllerBase
{
/// <summary>
/// Yeni izin verilmemiş kurulumlarda kilitlenmemek için tanınan geriye dönük rol.
/// <see cref="PlatformPermissions.Setup.Migrate"/> rollere atandıktan sonra kaldırılabilir.
/// </summary>
private const string LegacyAdminRoleName = "admin";
private const int DistributedLockTimeoutSeconds = 5;
private readonly IConfiguration _configuration;
private readonly IHostEnvironment _env;
private readonly IHostApplicationLifetime _lifetime;
private readonly IAuthorizationService _authorizationService;
private readonly ICurrentTenant _currentTenant;
private readonly IAbpDistributedLock _distributedLock;
private readonly IUnitOfWorkManager _unitOfWorkManager;
private readonly BackgroundWorkerInitializer _backgroundWorkerInitializer;
private readonly AbpBackgroundWorkerRecurringJobInitializer _abpBackgroundWorkerRecurringJobInitializer;
private readonly ILogger<SetupController> _logger;
public SetupController(
IConfiguration configuration,
IHostEnvironment env,
IHostApplicationLifetime lifetime,
IAuthorizationService authorizationService,
ICurrentTenant currentTenant,
IAbpDistributedLock distributedLock,
IUnitOfWorkManager unitOfWorkManager,
BackgroundWorkerInitializer backgroundWorkerInitializer,
AbpBackgroundWorkerRecurringJobInitializer abpBackgroundWorkerRecurringJobInitializer)
AbpBackgroundWorkerRecurringJobInitializer abpBackgroundWorkerRecurringJobInitializer,
ILogger<SetupController> logger)
{
_configuration = configuration;
_env = env;
_lifetime = lifetime;
_authorizationService = authorizationService;
_currentTenant = currentTenant;
_distributedLock = distributedLock;
_unitOfWorkManager = unitOfWorkManager;
_backgroundWorkerInitializer = backgroundWorkerInitializer;
_abpBackgroundWorkerRecurringJobInitializer = abpBackgroundWorkerRecurringJobInitializer;
_logger = logger;
}
/// <summary>
/// Veritabanı durumu. Anonim çağırana yalnızca "hazır mı" bilgisi döner;
/// bekleyen migration sayısı ve hata detayı sadece yetkili host kullanıcısına verilir.
/// </summary>
[HttpGet("application-status")]
[AllowAnonymous]
public IActionResult Status()
public async Task<SetupStatusDto> StatusAsync(CancellationToken cancellationToken)
{
return Ok(new { dbExists = SetupAppRunner.DatabaseIsReady(_configuration) });
var readiness = DatabaseReadinessChecker.GetStatus(_configuration);
var canMigrate = await CanMigrateAsync();
if (canMigrate && readiness.IsReady)
{
readiness = readiness.WithPendingMigrations(
await GetPendingMigrationCountAsync(cancellationToken));
}
return SetupStatusDto.From(readiness, includeDiagnostics: canMigrate);
}
/// <summary>
/// Migration + seed çalıştırır ve logları SSE olarak akıtır.
///
/// Yetki: veritabanı ayaktayken host tarafında <see cref="PlatformPermissions.Setup.Migrate"/>
/// izni (veya geriye dönük olarak admin rolü); veritabanı yokken kurulum parolası.
/// </summary>
[HttpPost("migrate")]
[AllowAnonymous]
public async Task Migrate(CancellationToken ct)
public async Task MigrateAsync()
{
var databaseIsReady = SetupAppRunner.DatabaseIsReady(_configuration);
if (databaseIsReady)
{
if (User?.IsInRole("admin") != true)
{
Response.StatusCode = StatusCodes.Status403Forbidden;
return;
}
}
else if (!SetupAppRunner.IsValidSetupPassword(_configuration, Request.Headers))
var (authorized, denialReason) = await AuthorizeMigrateRequestAsync();
if (!authorized)
{
_logger.LogWarning("Database migration request denied: {Reason}", denialReason);
Response.StatusCode = StatusCodes.Status403Forbidden;
Response.ContentType = "text/plain; charset=utf-8";
await Response.WriteAsync(denialReason);
return;
}
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);
var stream = new SetupEventStream(Response);
await stream.StartAsync();
async Task Send(string level, string message)
// Çok örnekli (multi-instance) dağıtımlarda aynı anda tek migration çalışsın.
await using var lockHandle = await _distributedLock.TryAcquireAsync(
"Sozsoft.Platform.SetupMigration",
TimeSpan.FromSeconds(DistributedLockTimeoutSeconds),
_lifetime.ApplicationStopping);
if (lockHandle is null)
{
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", "Database migration and seeding are being initiated...");
await Send("info", $"Migrator path: {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 path not found or invalid: {migratorPath}");
await Send("done", "Failed.");
await stream.ErrorAsync("Another migration is already in progress. Please try again later.");
await stream.DoneAsync("Failed.");
return;
}
await Send("info", $"Running: {fileName} {arguments}");
_logger.LogWarning(
"Database migration triggered via /api/setup/migrate by user {UserId}.",
User?.Identity?.Name ?? "(setup password)");
Process? process = null;
// ApplicationStopping kullanılıyor: istemci sekmesini kapatsa da migration tamamlanır.
var outcome = await SetupMigrationRunner.ExecuteAsync(
_configuration, _env.ContentRootPath, stream, _lifetime.ApplicationStopping);
if (outcome != MigrationOutcome.Succeeded)
{
_logger.LogWarning("Database migration finished with outcome {Outcome}.", outcome);
return;
}
await stream.InfoAsync("Migration and seed completed successfully.");
// Yeni/değişen tanımlar Hangfire'a yansısın diye worker'lar tazelenir.
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 (await reader.ReadLineAsync(ct) is { } line)
{
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("info", "Migration and seed completed successfully.");
try
{
await Send("info", "Loading background workers into Hangfire...");
await _abpBackgroundWorkerRecurringJobInitializer.RunAsync(ct);
await _backgroundWorkerInitializer.RunAsync();
await Send("success", "Background workers loaded successfully.");
}
catch (Exception ex)
{
await Send("error", $"Background workers could not be loaded: {ex.Message}");
await Send("done", "Failed.");
return;
}
await Send("done", "Completed.");
}
else
{
await Send("error", $"Migration failed. Exit code: {process.ExitCode}");
await Send("done", "Failed.");
}
}
catch (OperationCanceledException)
{
await Send("warn", "Migration request was canceled.");
await stream.InfoAsync("Loading background workers into Hangfire...");
await _abpBackgroundWorkerRecurringJobInitializer.RunAsync(_lifetime.ApplicationStopping);
await _backgroundWorkerInitializer.RunAsync();
await stream.SuccessAsync("Background workers loaded successfully.");
}
catch (Exception ex)
{
await Send("error", $"Migration failed: {ex.Message}");
await Send("done", "Failed.");
_logger.LogError(ex, "Background workers could not be reloaded after migration.");
await stream.ErrorAsync($"Background workers could not be loaded: {ex.Message}");
await stream.DoneAsync("Failed.");
return;
}
finally
await stream.DoneAsync("Completed.");
}
// Yetkilendirme
/// <summary>
/// İki geçerli kimlik yolu vardır:
///
/// 1. <c>X-Setup-Password</c> header'ı — veritabanına hiç ihtiyaç duymaz, bu yüzden
/// veritabanı düşmüşken de çalışır.
/// 2. Oturum açmış host kullanıcısı + <see cref="PlatformPermissions.Setup.Migrate"/> izni —
/// izin kontrolü veritabanı okuması gerektirdiği için yalnızca veritabanı ayaktayken
/// (örn. yalnızca bekleyen migration varken) kullanılabilir.
///
/// Reddetme nedeni çağırana döndürülür; aksi halde "yetkisiz" hatası teşhis edilemez oluyordu.
/// Nedenler yapılandırma/izin adından fazlasını sızdırmaz.
/// </summary>
private async Task<(bool Authorized, string Reason)> AuthorizeMigrateRequestAsync()
{
if (Request.Headers.ContainsKey(SetupPasswordValidator.HeaderName))
{
process?.Dispose();
if (!SetupPasswordValidator.IsConfigured(_configuration))
{
return (false, "Setup password is not configured on the server (Setup:MigrationPassword).");
}
return SetupPasswordValidator.IsValid(_configuration, Request.Headers)
? (true, string.Empty)
: (false, "Invalid setup password.");
}
if (!DatabaseReadinessChecker.GetStatus(_configuration).IsReady)
{
return (false,
"The database is not available, so permissions cannot be verified. " +
$"Send the {SetupPasswordValidator.HeaderName} header to run migration.");
}
if (User?.Identity?.IsAuthenticated != true)
{
return (false, "Authentication is required to run database migration.");
}
if (_currentTenant.Id is not null)
{
return (false, "Database migration can only be run by a host user.");
}
return await CanMigrateAsync()
? (true, string.Empty)
: (false, $"The '{PlatformPermissions.Setup.Migrate}' permission is required.");
}
private async Task<bool> CanMigrateAsync()
{
if (User?.Identity?.IsAuthenticated != true)
{
return false;
}
// Host veritabanına migration uygulanır — tenant kullanıcıları tetikleyemez.
if (_currentTenant.Id is not null)
{
return false;
}
if ((await _authorizationService.AuthorizeAsync(User, PlatformPermissions.Setup.Migrate)).Succeeded)
{
return true;
}
return User.IsInRole(LegacyAdminRoleName);
}
// Bekleyen migration sayısı
private async Task<int?> GetPendingMigrationCountAsync(CancellationToken cancellationToken)
{
try
{
using var uow = _unitOfWorkManager.Begin(requiresNew: true, isTransactional: false);
var dbContext = await uow.ServiceProvider
.GetRequiredService<IDbContextProvider<PlatformDbContext>>()
.GetDbContextAsync();
var pending = await dbContext.Database.GetPendingMigrationsAsync(cancellationToken);
return pending.Count();
}
catch (Exception ex)
{
// Bu bilgi opsiyonel — alınamıyorsa durum raporu yine de dönmeli.
_logger.LogDebug(ex, "Pending migration count could not be determined.");
return null;
}
}
}

View file

@ -0,0 +1,27 @@
namespace Sozsoft.Platform.DbStartup;
/// <summary>
/// Veritabanının o anki hazırlık durumu.
/// </summary>
/// <param name="DatabaseExists">Katalog (database) sunucuda mevcut mu.</param>
/// <param name="SchemaReady">ABP çekirdek şeması (AbpRoles) oluşmuş mu.</param>
/// <param name="PendingMigrationCount">
/// Uygulanmamış EF migration sayısı. Yalnızca tam ABP stack ayaktayken hesaplanabilir;
/// hesaplanamadığında <c>null</c> döner.
/// </param>
/// <param name="Error">Kontrol sırasında oluşan hata mesajı (varsa).</param>
public sealed record DatabaseReadiness(
bool DatabaseExists,
bool SchemaReady,
int? PendingMigrationCount,
string? Error)
{
/// <summary>Uygulamanın tam ABP stack ile açılabilmesi için yeterli mi.</summary>
public bool IsReady => DatabaseExists && SchemaReady;
public static DatabaseReadiness NotReady(string? error)
=> new(false, false, null, error);
public DatabaseReadiness WithPendingMigrations(int? pendingMigrationCount)
=> this with { PendingMigrationCount = pendingMigrationCount };
}

View file

@ -0,0 +1,213 @@
using System;
using System.Threading;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using Npgsql;
using Serilog;
using static Sozsoft.Settings.SettingsConsts;
namespace Sozsoft.Platform.DbStartup;
/// <summary>
/// Veritabanının varlığını ve çekirdek şemanın oluşup oluşmadığını kontrol eder.
///
/// Bu kontrol her çağrıda iki yeni bağlantı açar; endpoint anonim erişime açık olduğu için
/// sonuçlar kısa süreli olarak cache'lenir ve eşzamanlı çağrılar tek probe'a indirgenir
/// (single-flight). Böylece canlı sistemde bağlantı havuzu tüketilemez.
/// </summary>
public static class DatabaseReadinessChecker
{
/// <summary>DB hazır olduğunda sonucun geçerli kalma süresi.</summary>
private static readonly TimeSpan ReadyCacheDuration = TimeSpan.FromSeconds(30);
/// <summary>DB hazır değilken sonucun geçerli kalma süresi (hızlı toparlanma için kısa).</summary>
private static readonly TimeSpan NotReadyCacheDuration = TimeSpan.FromSeconds(5);
private const int ConnectTimeoutSeconds = 8;
private static readonly object ProbeLock = new();
private static DatabaseReadiness? _cached;
private static long _cachedAtTicks;
/// <summary>
/// Cache'lenmiş hazırlık durumunu döner. Cache soğumuşsa yeni bir probe çalıştırır.
/// Aynı anda başka bir probe sürüyorsa ve elde bayat da olsa bir sonuç varsa onu döner.
/// </summary>
public static DatabaseReadiness GetStatus(IConfiguration configuration)
{
var snapshot = _cached;
if (IsFresh(snapshot))
{
return snapshot!;
}
// İlk çağrıda elde sonuç yok — probe bitene kadar bekle.
// Sonraki çağrılarda probe sürüyorsa bayat sonucu servis et, bekleme.
var lockTimeout = snapshot is null ? Timeout.Infinite : 0;
if (!Monitor.TryEnter(ProbeLock, lockTimeout))
{
return snapshot!;
}
try
{
// Kilidi beklerken başka bir thread tazelemiş olabilir.
snapshot = _cached;
if (IsFresh(snapshot))
{
return snapshot!;
}
var result = Probe(configuration);
_cached = result;
_cachedAtTicks = Environment.TickCount64;
return result;
}
finally
{
Monitor.Exit(ProbeLock);
}
}
/// <summary>
/// Cache'i geçersiz kılar. Migration tamamlandıktan sonra çağrılır ki durum
/// bir sonraki istekte anında güncellensin.
/// </summary>
public static void Invalidate()
{
_cached = null;
_cachedAtTicks = 0;
}
/// <summary>
/// Cache'i atlayarak doğrudan veritabanına sorar. Uygulama açılışında
/// (DI kurulmadan önce) kullanılır.
/// </summary>
public static DatabaseReadiness Probe(IConfiguration configuration)
{
var connectionString = configuration.GetConnectionString(DefaultDatabaseProvider);
if (string.IsNullOrWhiteSpace(connectionString))
{
return DatabaseReadiness.NotReady("Connection string is not configured.");
}
try
{
return DefaultDatabaseProvider switch
{
DatabaseProvider.SqlServer => ProbeSqlServer(connectionString),
DatabaseProvider.PostgreSql => ProbePostgreSql(connectionString),
_ => DatabaseReadiness.NotReady(
$"Unsupported database provider: {DefaultDatabaseProvider}"),
};
}
catch (Exception ex)
{
Log.Warning("Veritabanı hazırlık kontrolü başarısız: {Error}", ex.Message);
return DatabaseReadiness.NotReady(ex.Message);
}
}
private static bool IsFresh(DatabaseReadiness? snapshot)
{
if (snapshot is null)
{
return false;
}
var maxAge = snapshot.IsReady ? ReadyCacheDuration : NotReadyCacheDuration;
return Environment.TickCount64 - _cachedAtTicks < maxAge.TotalMilliseconds;
}
private static DatabaseReadiness ProbeSqlServer(string connectionString)
{
var csb = new SqlConnectionStringBuilder(connectionString);
var dbName = csb.InitialCatalog;
if (string.IsNullOrEmpty(dbName))
{
return DatabaseReadiness.NotReady("Initial Catalog is not set in the connection string.");
}
// 1) master'a bağlan — katalog var mı?
var masterCsb = new SqlConnectionStringBuilder(connectionString)
{
InitialCatalog = "master",
ConnectTimeout = ConnectTimeoutSeconds
};
using (var masterConn = new SqlConnection(masterCsb.ConnectionString))
{
masterConn.Open();
using var dbCheck = new SqlCommand(
"SELECT COUNT(1) FROM sys.databases WHERE name = @n", masterConn);
dbCheck.Parameters.AddWithValue("@n", dbName);
if (Convert.ToInt32(dbCheck.ExecuteScalar()) == 0)
{
return DatabaseReadiness.NotReady(null);
}
}
// 2) Hedef DB'ye bağlan — çekirdek şema oluşmuş mu?
csb.ConnectTimeout = ConnectTimeoutSeconds;
using var dbConn = new SqlConnection(csb.ConnectionString);
dbConn.Open();
using var tableCheck = new SqlCommand(
"SELECT COUNT(1) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'AbpRoles'",
dbConn);
var schemaReady = Convert.ToInt32(tableCheck.ExecuteScalar()) > 0;
return new DatabaseReadiness(true, schemaReady, null, null);
}
private static DatabaseReadiness ProbePostgreSql(string connectionString)
{
var csb = new NpgsqlConnectionStringBuilder(connectionString);
var dbName = csb.Database;
if (string.IsNullOrEmpty(dbName))
{
return DatabaseReadiness.NotReady("Database is not set in the connection string.");
}
var maintenanceCsb = new NpgsqlConnectionStringBuilder(connectionString)
{
Database = "postgres",
Timeout = ConnectTimeoutSeconds,
CommandTimeout = ConnectTimeoutSeconds
};
using (var maintenanceConn = new NpgsqlConnection(maintenanceCsb.ConnectionString))
{
maintenanceConn.Open();
using var dbCheck = new NpgsqlCommand(
"SELECT COUNT(1) FROM pg_database WHERE datname = @n", maintenanceConn);
dbCheck.Parameters.AddWithValue("n", dbName);
if (Convert.ToInt32(dbCheck.ExecuteScalar()) == 0)
{
return DatabaseReadiness.NotReady(null);
}
}
csb.Timeout = ConnectTimeoutSeconds;
csb.CommandTimeout = ConnectTimeoutSeconds;
using var dbConn = new NpgsqlConnection(csb.ConnectionString);
dbConn.Open();
using var tableCheck = new NpgsqlCommand(
"""
SELECT COUNT(1)
FROM information_schema.tables
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
AND lower(table_name) = lower('AbpRoles')
""",
dbConn);
var schemaReady = Convert.ToInt32(tableCheck.ExecuteScalar()) > 0;
return new DatabaseReadiness(true, schemaReady, null, null);
}
}

View file

@ -1,182 +1,41 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Text.Json;
using System.Threading;
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Npgsql;
using Serilog;
using static Sozsoft.Settings.SettingsConsts;
namespace Sozsoft.Platform;
namespace Sozsoft.Platform.DbStartup;
/// <summary>
/// Veritabanı henüz hazır değilken çalışan minimal kurulum uygulaması.
/// Tam ABP stack yüklemez; sadece /api/setup/* endpointlerini sunar.
///
/// Migration tamamlandığında uygulama kendini durdurur; process yöneticisi (systemd, IIS,
/// container orchestrator) yeniden başlattığında <see cref="Program"/> artık DB'yi hazır
/// bulur ve tam ABP stack ile açılır.
/// </summary>
internal static class SetupAppRunner
{
private const string SetupPasswordHeaderName = "X-Setup-Password";
// Veritabanı Hazırlık Kontrolü
/// <summary>
/// DB var mı ve AbpRoles tablosu oluşmuş mu diye kontrol eder.
/// Boş DB veya bağlantı hatası durumunda false döner.
/// </summary>
public static bool DatabaseIsReady(IConfiguration configuration)
{
var connectionString = configuration.GetConnectionString(DefaultDatabaseProvider);
if (string.IsNullOrWhiteSpace(connectionString))
return false;
try
{
if (DefaultDatabaseProvider == DatabaseProvider.SqlServer)
return SqlServerIsReady(connectionString);
if (DefaultDatabaseProvider == DatabaseProvider.PostgreSql)
return PostgreSqlIsReady(connectionString);
return false;
}
catch (Exception ex)
{
Log.Warning("Veritabanı hazırlık kontrolü başarısız: {Error}", ex.Message);
return false;
}
}
public static bool IsValidSetupPassword(IConfiguration configuration, IHeaderDictionary headers)
{
var configuredPassword = configuration["Setup:MigrationPassword"];
if (string.IsNullOrWhiteSpace(configuredPassword))
{
return false;
}
return headers.TryGetValue(SetupPasswordHeaderName, out var providedPassword) &&
string.Equals(providedPassword.ToString(), configuredPassword, StringComparison.Ordinal);
}
private static bool SqlServerIsReady(string connectionString)
{
var csb = new SqlConnectionStringBuilder(connectionString);
var dbName = csb.InitialCatalog;
if (string.IsNullOrEmpty(dbName))
return false;
// 1) master'a bağlan — DB varlığını kontrol et
var masterCsb = new SqlConnectionStringBuilder(connectionString)
{
InitialCatalog = "master",
ConnectTimeout = 8
};
using var masterConn = new SqlConnection(masterCsb.ConnectionString);
masterConn.Open();
using var dbCheck = new SqlCommand(
"SELECT COUNT(1) FROM sys.databases WHERE name = @n", masterConn);
dbCheck.Parameters.AddWithValue("@n", dbName);
if ((int)dbCheck.ExecuteScalar() == 0)
return false;
// 2) Hedef DB'ye bağlan — AbpRoles tablosunun varlığını kontrol et
csb.ConnectTimeout = 8;
using var dbConn = new SqlConnection(csb.ConnectionString);
dbConn.Open();
using var tableCheck = new SqlCommand(
"SELECT COUNT(1) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'AbpRoles'",
dbConn);
return (int)tableCheck.ExecuteScalar() > 0;
}
private static bool PostgreSqlIsReady(string connectionString)
{
var csb = new NpgsqlConnectionStringBuilder(connectionString);
var dbName = csb.Database;
if (string.IsNullOrEmpty(dbName))
return false;
var maintenanceCsb = new NpgsqlConnectionStringBuilder(connectionString)
{
Database = "postgres",
Timeout = 8,
CommandTimeout = 8
};
using var maintenanceConn = new NpgsqlConnection(maintenanceCsb.ConnectionString);
maintenanceConn.Open();
using var dbCheck = new NpgsqlCommand(
"SELECT COUNT(1) FROM pg_database WHERE datname = @n",
maintenanceConn);
dbCheck.Parameters.AddWithValue("n", dbName);
if (Convert.ToInt32(dbCheck.ExecuteScalar()) == 0)
return false;
csb.Timeout = 8;
csb.CommandTimeout = 8;
using var dbConn = new NpgsqlConnection(csb.ConnectionString);
dbConn.Open();
using var tableCheck = new NpgsqlCommand(
"""
SELECT COUNT(1)
FROM information_schema.tables
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
AND lower(table_name) = lower('AbpRoles')
""",
dbConn);
return Convert.ToInt32(tableCheck.ExecuteScalar()) > 0;
}
// Minimal Kurulum Uygulaması
public static async Task<int> RunAsync(string[] args, IConfiguration configuration)
{
Log.Warning("Veritabanı hazır değil — kurulum modu başlatılıyor.");
if (!SetupPasswordValidator.IsConfigured(configuration))
{
Log.Warning(
"Setup:MigrationPassword yapılandırılmamış — /api/setup/migrate tüm istekleri reddedecek. " +
"Kurulumu tamamlamak için bu değeri tanımlayın.");
}
var builder = WebApplication.CreateBuilder(args);
var extraOrigins = (configuration["App:CorsOrigins"] ?? "")
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var baseDomain = configuration["App:BaseDomain"]?.Trim();
builder.Services.AddCors(o => o.AddPolicy("Setup", policy =>
policy.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials()
.SetIsOriginAllowed(origin =>
{
if (!Uri.TryCreate(origin, UriKind.Absolute, out var uri)) return false;
var host = uri.Host.ToLowerInvariant();
if (host is "localhost" or "127.0.0.1" or "[::1]")
return true;
if (!string.IsNullOrWhiteSpace(baseDomain))
{
var bd = baseDomain.ToLowerInvariant();
if (host == bd || host.EndsWith("." + bd))
return true;
}
foreach (var o in extraOrigins)
if (Uri.TryCreate(o, UriKind.Absolute, out var eo) &&
eo.Host.Equals(host, StringComparison.OrdinalIgnoreCase))
return true;
return false;
})));
.SetIsOriginAllowed(origin => IsAllowedOrigin(configuration, origin))));
builder.Host.UseSerilog();
@ -184,153 +43,102 @@ internal static class SetupAppRunner
app.UseCors("Setup");
app.MapGet("/api/setup/application-status", (IConfiguration cfg) =>
Results.Ok(new { dbExists = DatabaseIsReady(cfg) }));
Results.Ok(SetupStatusDto.From(
DatabaseReadinessChecker.GetStatus(cfg),
// Uygulama henüz servis vermiyor; bağlantı hatasını kurulumu yapana göstermek
// teşhis için gereklidir.
includeDiagnostics: true,
setupMode: true)));
app.MapPost("/api/setup/migrate", async (IConfiguration cfg, IHostEnvironment env,
IHostApplicationLifetime lifetime, HttpContext ctx, CancellationToken ct) =>
app.MapPost("/api/setup/migrate", async (
IConfiguration cfg,
IHostEnvironment env,
IHostApplicationLifetime lifetime,
HttpContext ctx) =>
{
if (!IsValidSetupPassword(cfg, ctx.Request.Headers))
// Reddetme nedeni ayrıştırılır: "parola yanlış" ile "sunucuda parola hiç
// tanımlı değil" tamamen farklı müdahaleler gerektirir.
if (!SetupPasswordValidator.IsConfigured(cfg))
{
ctx.Response.StatusCode = StatusCodes.Status403Forbidden;
await ctx.Response.WriteAsync("Invalid setup password.", ct);
ctx.Response.ContentType = "text/plain; charset=utf-8";
await ctx.Response.WriteAsync(
"Setup password is not configured on the server (Setup:MigrationPassword).");
return;
}
ctx.Response.ContentType = "text/event-stream; charset=utf-8";
ctx.Response.Headers["Cache-Control"] = "no-cache, no-store";
ctx.Response.Headers["X-Accel-Buffering"] = "no";
await ctx.Response.Body.FlushAsync(ct);
async Task Send(string level, string message)
if (!SetupPasswordValidator.IsValid(cfg, ctx.Request.Headers))
{
try
{
var payload = JsonSerializer.Serialize(new { level, message });
await ctx.Response.WriteAsync($"data: {payload}\n\n", ct);
await ctx.Response.Body.FlushAsync(ct);
}
catch { }
}
var migratorPath = cfg["Setup:MigratorPath"]
?? Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "Sozsoft.Platform.DbMigrator"));
await Send("info", "Database migration and seeding are being initiated...");
await Send("info", $"Migrator path: {migratorPath}");
var extraArgs = cfg["Setup:MigratorArgs"] ?? "--Seed=true";
string fileName;
string arguments;
string workingDirectory;
if (migratorPath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) && File.Exists(migratorPath))
{
// Doğrudan DLL yolu verilmiş — "--" separator YOK, doğrudan argüman
fileName = "dotnet";
arguments = $"\"{migratorPath}\" {extraArgs}";
workingDirectory = Path.GetDirectoryName(migratorPath)!;
}
else if (Directory.Exists(migratorPath))
{
// Klasör verilmiş — içinde publish edilmiş DLL var mı?
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)
{
// Publish çıktısı — SDK gerekmez, "--" separator YOK
fileName = "dotnet";
arguments = $"\"{dllFiles[0]}\" {extraArgs}";
workingDirectory = migratorPath;
}
else
{
// Kaynak proje klasörü — geliştirme ortamı, "--" gerekli
fileName = "dotnet";
arguments = $"run --project \"{migratorPath}\" -- {extraArgs}";
workingDirectory = migratorPath;
}
}
else
{
await Send("error", $"Migrator path not found or invalid: {migratorPath}");
await Send("done", "Failed.");
ctx.Response.StatusCode = StatusCodes.Status403Forbidden;
ctx.Response.ContentType = "text/plain; charset=utf-8";
await ctx.Response.WriteAsync("Invalid setup password.");
return;
}
await Send("info", $"Running: {fileName} {arguments}");
var stream = new SetupEventStream(ctx.Response);
await stream.StartAsync();
Process? process = null;
try
// Süreç ömrü istemci isteğine değil uygulama ömrüne bağlı:
// kullanıcı sekmeyi kapatsa da migration yarıda kesilmez.
var outcome = await SetupMigrationRunner.ExecuteAsync(
cfg, env.ContentRootPath, stream, lifetime.ApplicationStopping);
if (outcome != MigrationOutcome.Succeeded)
{
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 (await reader.ReadLineAsync(ct) is { } line)
{
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 and seed completed successfully.");
await Send("restart", "Application server is restarting...");
await Send("done", "Completed.");
_ = Task.Delay(1500).ContinueWith(_ => lifetime.StopApplication());
}
else
{
await Send("error", $"Migration failed. Exit code: {process.ExitCode}");
await Send("done", "Failed.");
}
}
catch (OperationCanceledException)
{
await Send("warn", "Migration request was canceled.");
}
catch (Exception ex)
{
await Send("error", $"Migration failed: {ex.Message}");
await Send("done", "Failed.");
}
finally
{
process?.Dispose();
return;
}
await stream.SuccessAsync("Migration and seed completed successfully.");
await stream.RestartAsync("Application server is restarting...");
await stream.DoneAsync("Completed.");
// İstemcinin "restart" olayını işleyip poll'a geçmesi için kısa gecikme.
_ = Task.Delay(TimeSpan.FromSeconds(1.5))
.ContinueWith(_ => lifetime.StopApplication(), TaskScheduler.Default);
});
app.MapFallback(() => Results.StatusCode(503));
// Kurulum modunda başka hiçbir endpoint anlamlı değil.
app.MapFallback(() => Results.StatusCode(StatusCodes.Status503ServiceUnavailable));
await app.RunAsync();
return 0;
}
private static bool IsAllowedOrigin(IConfiguration configuration, string origin)
{
if (!Uri.TryCreate(origin, UriKind.Absolute, out var uri))
{
return false;
}
var host = uri.Host.ToLowerInvariant();
if (host is "localhost" or "127.0.0.1" or "[::1]")
{
return true;
}
var baseDomain = configuration["App:BaseDomain"]?.Trim();
if (!string.IsNullOrWhiteSpace(baseDomain))
{
var bd = baseDomain.ToLowerInvariant();
if (host == bd || host.EndsWith("." + bd))
{
return true;
}
}
var extraOrigins = (configuration["App:CorsOrigins"] ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var allowed in extraOrigins)
{
if (Uri.TryCreate(allowed, UriKind.Absolute, out var allowedUri) &&
allowedUri.Host.Equals(host, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
}

View file

@ -0,0 +1,85 @@
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
namespace Sozsoft.Platform.DbStartup;
/// <summary>
/// Migration loglarını SSE (text/event-stream) olarak istemciye akıtır.
///
/// Yazma hataları bilinçli olarak yutulur: istemci sekmeyi kapattığında migration'ın
/// yarıda kesilmemesi gerekir; sadece log akışı sessizce durur.
/// </summary>
public sealed class SetupEventStream
{
private readonly HttpResponse _response;
private bool _headersSent;
public SetupEventStream(HttpResponse response)
{
_response = response;
}
public async Task StartAsync()
{
if (_headersSent)
{
return;
}
_headersSent = true;
_response.ContentType = "text/event-stream; charset=utf-8";
_response.Headers["Cache-Control"] = "no-cache, no-store";
_response.Headers["X-Accel-Buffering"] = "no";
try
{
await _response.Body.FlushAsync();
}
catch
{
// istemci zaten kopmuş
}
}
public async Task SendAsync(string level, string message)
{
try
{
var payload = JsonSerializer.Serialize(new { level, message });
// CancellationToken bilinçli olarak verilmiyor: istemci koptuğunda
// yazma başarısız olsun ama migration akışı bundan etkilenmesin.
await _response.WriteAsync($"data: {payload}\n\n");
await _response.Body.FlushAsync();
}
catch
{
// istemci koptu — migration devam ediyor
}
}
public Task InfoAsync(string message) => SendAsync(SetupEventLevels.Info, message);
public Task ErrorAsync(string message) => SendAsync(SetupEventLevels.Error, message);
public Task SuccessAsync(string message) => SendAsync(SetupEventLevels.Success, message);
public Task RestartAsync(string message) => SendAsync(SetupEventLevels.Restart, message);
public Task DoneAsync(string message) => SendAsync(SetupEventLevels.Done, message);
}
/// <summary>SSE olay seviyeleri — frontend bu değerlere göre davranır.</summary>
public static class SetupEventLevels
{
public const string Info = "info";
public const string Warn = "warn";
public const string Error = "error";
public const string Success = "success";
/// <summary>Sunucu yeniden başlıyor — istemci hazır olana kadar poll etmeli.</summary>
public const string Restart = "restart";
/// <summary>Akış bitti — başarı/başarısızlık bilgisi önceki olaylardan gelir.</summary>
public const string Done = "done";
}

View file

@ -0,0 +1,235 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
namespace Sozsoft.Platform.DbStartup;
public enum MigrationOutcome
{
Succeeded,
Failed,
AlreadyRunning,
MigratorNotFound,
TimedOut
}
/// <summary>
/// DbMigrator sürecini çalıştıran tek merkez.
///
/// Hem veritabanı yokken çalışan minimal kurulum uygulaması (<see cref="SetupAppRunner"/>)
/// hem de canlı sistemdeki <see cref="Controllers.SetupController"/> bu sınıfı kullanır;
/// böylece migration mantığı tek yerde tutulur.
/// </summary>
public static class SetupMigrationRunner
{
/// <summary>
/// Aynı host üzerinde eşzamanlı migration'ı engeller. Bir migration sürerken
/// gelen ikinci istek <see cref="MigrationOutcome.AlreadyRunning"/> alır.
/// </summary>
private static readonly SemaphoreSlim ExecutionGate = new(1, 1);
private const int DefaultTimeoutMinutes = 30;
/// <param name="processCancellationToken">
/// Sürecin yaşam süresini kontrol eden token. Bu <b>istemci isteğinin</b> token'ı DEĞİL,
/// uygulamanın <c>ApplicationStopping</c> token'ı olmalıdır. Aksi halde kullanıcı sekmeyi
/// kapattığında migration yarıda kesilir ve veritabanı tutarsız kalır.
/// </param>
public static async Task<MigrationOutcome> ExecuteAsync(
IConfiguration configuration,
string contentRootPath,
SetupEventStream stream,
CancellationToken processCancellationToken)
{
if (!await ExecutionGate.WaitAsync(0, CancellationToken.None))
{
await stream.ErrorAsync("Another migration is already in progress on this server.");
await stream.DoneAsync("Failed.");
return MigrationOutcome.AlreadyRunning;
}
try
{
return await ExecuteCoreAsync(configuration, contentRootPath, stream, processCancellationToken);
}
finally
{
ExecutionGate.Release();
}
}
private static async Task<MigrationOutcome> ExecuteCoreAsync(
IConfiguration configuration,
string contentRootPath,
SetupEventStream stream,
CancellationToken processCancellationToken)
{
await stream.InfoAsync("Database migration and seeding are being initiated...");
if (!TryResolveCommand(configuration, contentRootPath, out var command, out var resolveError))
{
await stream.ErrorAsync(resolveError!);
await stream.DoneAsync("Failed.");
return MigrationOutcome.MigratorNotFound;
}
await stream.InfoAsync($"Running: {command!.FileName} {command.Arguments}");
var timeout = TimeSpan.FromMinutes(
configuration.GetValue<int?>("Setup:MigratorTimeoutMinutes") ?? DefaultTimeoutMinutes);
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(processCancellationToken);
timeoutCts.CancelAfter(timeout);
var token = timeoutCts.Token;
Process? process = null;
try
{
process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = command.FileName,
Arguments = command.Arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = command.WorkingDirectory,
}
};
process.Start();
async Task PumpAsync(StreamReader reader, string level)
{
try
{
while (await reader.ReadLineAsync(token) is { } line)
{
await stream.SendAsync(level, line);
}
}
catch (OperationCanceledException)
{
// süreç sonlandırılıyor
}
}
await Task.WhenAll(
PumpAsync(process.StandardOutput, SetupEventLevels.Info),
PumpAsync(process.StandardError, SetupEventLevels.Warn));
await process.WaitForExitAsync(token);
// Şema değişmiş olabilir — bir sonraki durum sorgusu taze veri görsün.
DatabaseReadinessChecker.Invalidate();
if (process.ExitCode == 0)
{
return MigrationOutcome.Succeeded;
}
await stream.ErrorAsync($"Migration failed. Exit code: {process.ExitCode}");
await stream.DoneAsync("Failed.");
return MigrationOutcome.Failed;
}
catch (OperationCanceledException)
{
KillQuietly(process);
DatabaseReadinessChecker.Invalidate();
var timedOut = !processCancellationToken.IsCancellationRequested;
await stream.ErrorAsync(timedOut
? $"Migration timed out after {timeout.TotalMinutes:0} minutes and was terminated."
: "Migration was canceled because the application is shutting down.");
await stream.DoneAsync("Failed.");
return timedOut ? MigrationOutcome.TimedOut : MigrationOutcome.Failed;
}
catch (Exception ex)
{
KillQuietly(process);
DatabaseReadinessChecker.Invalidate();
await stream.ErrorAsync($"Migration failed: {ex.Message}");
await stream.DoneAsync("Failed.");
return MigrationOutcome.Failed;
}
finally
{
process?.Dispose();
}
}
private static void KillQuietly(Process? process)
{
try
{
if (process is { HasExited: false })
{
process.Kill(entireProcessTree: true);
}
}
catch
{
// süreç zaten bitmiş olabilir
}
}
/// <summary>
/// DbMigrator'ün nasıl çalıştırılacağını çözer:
/// doğrudan DLL yolu, publish klasörü veya (geliştirme ortamında) kaynak proje klasörü.
/// </summary>
private static bool TryResolveCommand(
IConfiguration configuration,
string contentRootPath,
out MigratorCommand? command,
out string? error)
{
command = null;
error = null;
var migratorPath = configuration["Setup:MigratorPath"];
if (string.IsNullOrWhiteSpace(migratorPath))
{
migratorPath = Path.GetFullPath(
Path.Combine(contentRootPath, "..", "Sozsoft.Platform.DbMigrator"));
}
var extraArgs = configuration["Setup:MigratorArgs"] ?? "--Seed=true";
if (migratorPath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) && File.Exists(migratorPath))
{
// Doğrudan DLL yolu — "--" separator yok, argümanlar doğrudan geçer.
command = new MigratorCommand(
"dotnet",
$"\"{migratorPath}\" {extraArgs}",
Path.GetDirectoryName(migratorPath)!);
return true;
}
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);
}
command = dllFiles.Length > 0
// Publish çıktısı — SDK gerekmez.
? new MigratorCommand("dotnet", $"\"{dllFiles[0]}\" {extraArgs}", migratorPath)
// Kaynak proje klasörü — geliştirme ortamı, "--" separator gerekli.
: new MigratorCommand("dotnet", $"run --project \"{migratorPath}\" -- {extraArgs}", migratorPath);
return true;
}
error = $"Migrator path not found or invalid: {migratorPath}";
return false;
}
}
internal sealed record MigratorCommand(string FileName, string Arguments, string WorkingDirectory);

View file

@ -0,0 +1,48 @@
using System;
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
namespace Sozsoft.Platform.DbStartup;
/// <summary>
/// Veritabanı yokken (henüz kimlik doğrulama altyapısı ayakta değilken) migration'ı koruyan
/// tek mekanizma: <c>X-Setup-Password</c> header'ı ile <c>Setup:MigrationPassword</c> eşleşmesi.
///
/// Parola yapılandırılmamışsa erişim reddedilir — yani üretimde parola tanımlanmadıkça
/// anonim migration mümkün değildir.
/// </summary>
public static class SetupPasswordValidator
{
public const string HeaderName = "X-Setup-Password";
private const string ConfigurationKey = "Setup:MigrationPassword";
public static bool IsConfigured(IConfiguration configuration)
=> !string.IsNullOrWhiteSpace(configuration[ConfigurationKey]);
public static bool IsValid(IConfiguration configuration, IHeaderDictionary headers)
{
var configuredPassword = configuration[ConfigurationKey];
if (string.IsNullOrWhiteSpace(configuredPassword))
{
return false;
}
if (!headers.TryGetValue(HeaderName, out var providedPassword))
{
return false;
}
return FixedTimeEquals(providedPassword.ToString(), configuredPassword);
}
/// <summary>Parola uzunluğunun zamanlama üzerinden sızmaması için sabit süreli karşılaştırma.</summary>
private static bool FixedTimeEquals(string left, string right)
{
var leftHash = SHA256.HashData(Encoding.UTF8.GetBytes(left ?? string.Empty));
var rightHash = SHA256.HashData(Encoding.UTF8.GetBytes(right ?? string.Empty));
return CryptographicOperations.FixedTimeEquals(leftHash, rightHash);
}
}

View file

@ -0,0 +1,46 @@
namespace Sozsoft.Platform.DbStartup;
/// <summary>
/// /api/setup/application-status yanıtı.
/// </summary>
public sealed class SetupStatusDto
{
/// <summary>
/// Uygulamanın tam ABP stack ile çalışabileceği anlamına gelir
/// (katalog mevcut + çekirdek şema oluşmuş). Geriye dönük uyumluluk için bu ad korunmuştur.
/// </summary>
public bool DbExists { get; init; }
/// <summary>
/// Sunucu kurulum modunda mı (minimal uygulama) çalışıyor.
///
/// Frontend yalnızca bu değer <c>true</c> iken /setup sayfasına yönlendirir. Tam ABP stack
/// ayaktayken bu değer her zaman <c>false</c>'tur; böylece canlı sistemde geçici bir
/// veritabanı sorunu anonim kullanıcıyı kurulum ekranına düşürmez.
/// </summary>
public bool SetupMode { get; init; }
/// <summary>
/// Uygulanmamış EF migration sayısı. Yalnızca yetkili kullanıcıya ve tam stack ayaktayken
/// doldurulur; aksi halde <c>null</c>.
/// </summary>
public int? PendingMigrationCount { get; init; }
/// <summary>Kontrol sırasında oluşan hata (yalnızca yetkili çağırana doldurulur).</summary>
public string? Error { get; init; }
public static SetupStatusDto From(
DatabaseReadiness readiness,
bool includeDiagnostics = false,
bool setupMode = false)
{
return new SetupStatusDto
{
DbExists = readiness.IsReady,
SetupMode = setupMode,
PendingMigrationCount = includeDiagnostics ? readiness.PendingMigrationCount : null,
// Hata detayı bağlantı dizesi/sunucu adı sızdırabilir — sadece yetkiliye.
Error = includeDiagnostics ? readiness.Error : null,
};
}
}

View file

@ -9,7 +9,6 @@ using Sozsoft.Platform.Identity;
using Volo.Abp.AspNetCore.Mvc.ApplicationConfigurations;
using Volo.Abp.Data;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Domain.Entities;
using Volo.Abp.Domain.Repositories;
using Volo.Abp.Identity;
using Volo.Abp.Users;
@ -26,13 +25,15 @@ public class PlatformApplicationConfigurationContributor : IApplicationConfigura
var configuration = context.ServiceProvider.GetRequiredService<IConfiguration>();
context.ApplicationConfiguration.SetProperty("version", configuration.GetValue<string>("App:Version"));
// GetListAsync boş liste döner, null dönmez — bu yüzden null kontrolü yok.
// Veritabanı erişilemezse çağrı exception atar; bu durum istek boru hattında,
// buraya gelinmeden 503 ile karşılanır (bkz. PlatformHttpApiHostModule).
var routeRepository = context.ServiceProvider.GetRequiredService<IRepository<Route, Guid>>();
var items = await routeRepository.GetListAsync() ?? throw new EntityNotFoundException("RecordNotFound");
context.ApplicationConfiguration.SetProperty("routes", items);
context.ApplicationConfiguration.SetProperty("routes", await routeRepository.GetListAsync());
var customComponentRepository = context.ServiceProvider.GetRequiredService<IRepository<CustomComponent, Guid>>();
var customComponents = await customComponentRepository.GetListAsync() ?? throw new EntityNotFoundException("RecordNotFound");
context.ApplicationConfiguration.SetProperty("customComponents", customComponents);
context.ApplicationConfiguration.SetProperty(
"customComponents", await customComponentRepository.GetListAsync());
var currentUser = context.ServiceProvider.GetRequiredService<ICurrentUser>();
if (currentUser.Id.HasValue)

View file

@ -3,6 +3,8 @@ using System.Linq;
using System.Threading.Tasks;
using Microsoft.Data.SqlClient;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Sozsoft.Platform.DbStartup;
using Volo.Abp;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Identity;
@ -14,23 +16,29 @@ namespace Sozsoft.Platform.Identity;
public class PlatformSessionValidationMiddleware : IMiddleware, ITransientDependency
{
private const string SetupPathPrefix = "/api/setup";
private const string SetupStatusPath = "/api/setup/application-status";
private readonly IIdentitySessionRepository sessionRepository;
private readonly ICurrentTenant currentTenant;
private readonly IUnitOfWorkManager unitOfWorkManager;
private readonly IConfiguration configuration;
public PlatformSessionValidationMiddleware(
IIdentitySessionRepository sessionRepository,
ICurrentTenant currentTenant,
IUnitOfWorkManager unitOfWorkManager)
IUnitOfWorkManager unitOfWorkManager,
IConfiguration configuration)
{
this.sessionRepository = sessionRepository;
this.currentTenant = currentTenant;
this.unitOfWorkManager = unitOfWorkManager;
this.configuration = configuration;
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
if (context.Request.Path.StartsWithSegments("/api/setup"))
if (ShouldSkipSessionValidation(context))
{
await next(context);
return;
@ -77,6 +85,32 @@ public class PlatformSessionValidationMiddleware : IMiddleware, ITransientDepend
await next(context);
}
/// <summary>
/// Kurulum endpointleri için oturum doğrulaması ne zaman atlanır.
///
/// - Durum sorgusu: anonim erişime açık, oturum bilgisine hiç ihtiyaç duymaz.
/// - Diğer /api/setup istekleri: yalnızca veritabanı erişilemezken atlanır. O durumda
/// oturum zaten doğrulanamaz (kayıtlar veritabanında) ve doğrulamaya çalışmak, tam da
/// veritabanını onaracak endpoint'i 503 ile bloke eder. Güvenlik kaybı yoktur: veritabanı
/// düşükken migration yalnızca kurulum parolasıyla çalışır, oturum yetki vermez
/// (bkz. SetupController.AuthorizeMigrateRequestAsync).
///
/// Veritabanı ayaktayken kurulum endpointleri de normal oturum doğrulamasından geçer;
/// böylece iptal edilmiş bir oturum migration tetikleyemez.
/// </summary>
private bool ShouldSkipSessionValidation(HttpContext context)
{
var path = context.Request.Path;
if (!path.StartsWithSegments(SetupPathPrefix))
{
return false;
}
return path.StartsWithSegments(SetupStatusPath) ||
!DatabaseReadinessChecker.GetStatus(configuration).IsReady;
}
private static Guid? ParseTenantId(string value)
{
return !string.IsNullOrWhiteSpace(value) && Guid.TryParse(value, out var id) ? id : null;

View file

@ -20,6 +20,7 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Http;
using Sozsoft.Platform.DbStartup;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.Extensions.Caching.StackExchangeRedis;
@ -506,20 +507,30 @@ public class PlatformHttpApiHostModule : AbpModule
app.MapAbpStaticAssets();
app.UseRouting();
app.UseCors();
// Not: /api/setup/application-status artık SetupController tarafından karşılanıyor.
// Buradaki kopya middleware kaldırıldı — durum mantığı tek yerde (DatabaseReadinessChecker).
// Veritabanı erişilemezken uygulama konfigürasyonu üretilemez. İsteği burada,
// EF sorguları ve bağlantı zaman aşımları devreye girmeden kesiyoruz: aksi halde
// istemci 10+ saniye bekleyip, contributor'ın içinde patlayan bir bağlantı
// hatasından türeyen belirsiz bir yanıt alıyordu. Kontrol cache'li, maliyeti yok.
app.Use(async (httpContext, next) =>
{
if (httpContext.Request.Path.StartsWithSegments("/api/setup/application-status"))
if (httpContext.Request.Path.StartsWithSegments("/api/abp/application-configuration"))
{
var configuration = httpContext.RequestServices.GetRequiredService<IConfiguration>();
var dbExists = SetupAppRunner.DatabaseIsReady(configuration);
httpContext.Response.ContentType = "application/json; charset=utf-8";
await httpContext.Response.WriteAsJsonAsync(new { dbExists });
return;
if (!DatabaseReadinessChecker.GetStatus(configuration).IsReady)
{
httpContext.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
httpContext.Response.ContentType = "text/plain; charset=utf-8";
await httpContext.Response.WriteAsync("The database is not reachable.");
return;
}
}
await next();
});
app.Use(async (context, next) =>
{
if (context.Request.Path.StartsWithSegments("/messengerhub") &&

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Sozsoft.Platform.Enums;
using Sozsoft.Platform.DbStartup;
using Sozsoft.Platform.DynamicServices;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
@ -35,12 +36,20 @@ public class Program
.WriteTo.Console()
.CreateLogger();
if (!SetupAppRunner.DatabaseIsReady(configuration))
if (!DatabaseReadinessChecker.Probe(configuration).IsReady)
{
var setupResult = await SetupAppRunner.RunAsync(args, configuration);
if (setupResult != 0)
return setupResult;
// Migration başarılı — DB artık hazır, tam ABP başlatmasına geç
// Kurulum uygulaması durdu — DB gerçekten hazır mı, tekrar doğrula.
DatabaseReadinessChecker.Invalidate();
if (!DatabaseReadinessChecker.Probe(configuration).IsReady)
{
Log.Fatal("Kurulum modu sonlandı ancak veritabanı hâlâ hazır değil — uygulama başlatılmıyor.");
return 1;
}
Log.Warning("Migration tamamlandı — tam uygulama başlatılıyor.");
}

View file

@ -15,6 +15,8 @@ import useLocale from '@/utils/hooks/useLocale'
import { useDynamicRoutes } from '@/routes/dynamicRoutesContext'
import { useLocation } from 'react-router-dom'
import { hasSubdomain } from '@/utils/subdomain'
import { ROUTES_ENUM } from '@/routes/route.constant'
import ServiceUnavailable from '@/views/ServiceUnavailable'
export type LayoutType =
| typeof LAYOUT_TYPE_CLASSIC
@ -78,6 +80,7 @@ class LayoutChunkErrorBoundary extends Component<
const Layout = () => {
const location = useLocation()
const layoutType = useStoreState((s) => s.theme.layout.type) as LayoutType
const bootstrapFailed = useStoreState((s) => s.base.common.bootstrapFailed)
const { routes, loading } = useDynamicRoutes()
const { authenticated } = useAuth()
@ -124,6 +127,13 @@ const Layout = () => {
return PublicLayout
}, [isAdminPath, route, layoutType, authenticated, currentPath])
// Uygulama konfigürasyonu hiç yüklenemedi. Layout zinciri config'e bağımlı olduğu için
// burada, layout seçilmeden önce kesiyoruz. /setup dışarıda bırakılır ki yönetici
// müdahale edebilsin.
if (bootstrapFailed && currentPath !== ROUTES_ENUM.setup) {
return <ServiceUnavailable />
}
if (loading) {
return (
<div className="flex flex-auto flex-col h-[100vh]">

View file

@ -0,0 +1,69 @@
import { useState } from 'react'
import { FcAcceptDatabase } from 'react-icons/fc'
import { Button } from '@/components/ui'
import ConfirmDialog from '@/components/shared/ConfirmDialog'
import { UiEvalService } from '@/services/UiEvalService'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { usePermission } from '@/utils/hooks/usePermission'
import { DB_MIGRATE } from '@/constants/permission.constant'
interface DbMigrateButtonProps {
/** Küçük ekranlarda etiketi gizle (yalnızca ikon göster). */
hideLabelOnMobile?: boolean
className?: string
}
/**
* "DB Migrate" aksiyonu — onay diyaloğu ve yetki kontrolü dahil.
*
* SQL Query Manager ve Wizard dosya yöneticisi aynı işi yaptığı için tek bileşende
* toplanmıştır. Yetkisi olmayan kullanıcıya buton hiç render edilmez; asıl kontrol
* yine de sunucuda (`App.Setup.Migrate`) yapılır.
*/
const DbMigrateButton = ({ hideLabelOnMobile = false, className }: DbMigrateButtonProps) => {
const { translate } = useLocalization()
const { checkPermission } = usePermission()
const [isConfirmOpen, setIsConfirmOpen] = useState(false)
if (!checkPermission(DB_MIGRATE)) {
return null
}
const label = translate('::ListForms.ListForm.DbMigrate') || 'DB Migrate'
return (
<>
<Button
size="sm"
variant="default"
icon={<FcAcceptDatabase />}
className={className}
onClick={() => setIsConfirmOpen(true)}
title={translate('::App.DbMigrate.StartMessage') || 'Run DB Migration'}
>
{hideLabelOnMobile ? <span className="hidden sm:inline">{label}</span> : label}
</Button>
<ConfirmDialog
isOpen={isConfirmOpen}
type="info"
title={label}
cancelText={translate('::Cancel')}
confirmText={translate('::App.Platform.Execute') || 'Çalıştır'}
onCancel={() => setIsConfirmOpen(false)}
onClose={() => setIsConfirmOpen(false)}
onConfirm={() => {
setIsConfirmOpen(false)
UiEvalService.ApiDbMigrate()
}}
>
<p className="text-gray-600 dark:text-gray-400">
{translate('::App.DbMigrate.ConfirmMessage') ||
'Are you sure you want to start the database migration process?'}
</p>
</ConfirmDialog>
</>
)
}
export default DbMigrateButton

View file

@ -17,34 +17,46 @@ let didInit = false
const Theme = (props: CommonProps) => {
const { getConfig } = useStoreActions((a) => a.abpConfig)
const { setSetupMode } = useStoreActions((a) => a.base.common)
const { setSetupMode, setBootstrapFailed } = useStoreActions((a) => a.base.common)
const navigate = useNavigate()
const location = useLocation()
useEffect(() => {
if (!didInit) {
didInit = true
if (didInit) return
didInit = true
// Direkt /setup'a gelindiyse — hemen setupMode=true yap (loading gate açılsın)
if (location.pathname === ROUTES_ENUM.setup) {
setSetupMode(true)
return
// Direkt /setup'a gelindiyse — hemen setupMode=true yap (loading gate açılsın)
if (location.pathname === ROUTES_ENUM.setup) {
setSetupMode(true)
return
}
// Doğrudan config yüklenir. Her sayfa açılışında ayrıca veritabanı probe'u
// atılmaz — bu, canlı sistemde gereksiz bir istek ve DB bağlantısıydı.
//
// silentError: açılıştaki config hatası global hata diyaloğunu tetiklemez;
// kurulum modu kararı aşağıda verilir. Aksi halde kullanıcı kurulum sayfasının
// üstünde anlamsız bir "503" penceresi görüyordu.
getConfig({ silentError: true }).catch(async () => {
// Config alınamadı. Sunucu gerçekten kurulum modunda mı, yoksa geçici bir
// arıza mı? Yönlendirme SADECE sunucu kurulum modundayken yapılır; böylece
// çalışan bir sistemde veritabanı sorunu kullanıcıyı kurulum ekranına düşürmez.
try {
const { data } = await getSetupStatus()
if (data.setupMode) {
setSetupMode(true)
navigate(ROUTES_ENUM.setup, { replace: true })
return
}
} catch {
// Sunucuya hiç ulaşılamıyor — aşağıdaki bakım ekranı gösterilir.
}
// Veritabanı var mı kontrol et; yoksa setup sayfasına yönlendir
getSetupStatus()
.then((res) => {
if (!res.data.dbExists) {
setSetupMode(true)
navigate(ROUTES_ENUM.setup, { replace: true })
} else {
getConfig(false)
}
})
.catch(() => {
getConfig(false)
})
}
// Sunucu kurulum modunda değil ama uygulama da açılamıyor.
// Bu bayrak olmadan loading gate hiç kapanmıyor ve ekran sonsuza kadar
// spinner'da kalıyordu.
setBootstrapFailed(true)
})
}, [])
function getThemeStyle() {

View file

@ -1,2 +1,5 @@
export const GLOBAL_SEARCH = 'App.Definitions.GlobalSearch'
export const AI_ASSISTANT = 'App.Definitions.AiBot.Asistant'
/** Veritabanı migration + seed tetikleme yetkisi (host tarafı). */
export const DB_MIGRATE = 'App.Setup.Migrate'

View file

@ -1,9 +1,19 @@
export type MigrateLogLevel = 'info' | 'warn' | 'error' | 'success' | 'restart' | 'done'
export interface MigrateLogEntry {
level: string
level: MigrateLogLevel | string
message: string
}
export interface SetupStatusDto {
/** Uygulamanın tam olarak açılabilmesi için veritabanı hazır mı (katalog + çekirdek şema). */
dbExists: boolean
error?: string
/**
* Sunucu minimal kurulum modunda mı çalışıyor.
* /setup'a otomatik yönlendirme SADECE bu değer true iken yapılır.
*/
setupMode: boolean
/** Uygulanmamış EF migration sayısı — yalnızca yetkili kullanıcıya döner. */
pendingMigrationCount?: number | null
error?: string | null
}

View file

@ -69,9 +69,12 @@ export const DynamicRouter: React.FC = () => {
[routes, components],
)
// /setup path'inde loading bekleme — setup route her zaman erişilebilir olmalı
if (loading && location.pathname !== '/setup') return <div>Loading...</div>
if (error && location.pathname !== '/setup') return <div>Hata: {error}</div>
// /setup path'inde loading bekleme — setup route her zaman erişilebilir olmalı.
// bootstrapFailed durumu Layouts'ta, layout seçilmeden önce ele alınır.
const isSetupPath = location.pathname === ROUTES_ENUM.setup
if (loading && !isSetupPath) return <div>Loading...</div>
if (error && !isSetupPath) return <div>Hata: {error}</div>
return (
<Routes>

View file

@ -20,6 +20,7 @@ export const useDynamicRoutes = () => {
export const DynamicRoutesProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const extraProperties = useStoreState((state) => state.abpConfig?.config?.extraProperties)
const setupMode = useStoreState((state) => state.base.common.setupMode)
const bootstrapFailed = useStoreState((state) => state.base.common.bootstrapFailed)
const [routes, setRoutes] = useState<RouteDto[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@ -49,12 +50,13 @@ export const DynamicRoutesProvider: React.FC<{ children: React.ReactNode }> = ({
useEffect(() => {
if (extraProperties) {
loadRoutesFromConfig()
} else if (setupMode) {
// Veritabanı mevcut değil — setup modunda loading'i kapat
} else if (setupMode || bootstrapFailed) {
// Config hiç gelmeyecek (sunucu kurulum modunda veya erişilemiyor).
// Loading gate'i kapat ki kullanıcı sonsuz spinner yerine ilgili ekranı görsün.
setLoading(false)
setRoutes([])
}
}, [extraProperties, setupMode])
}, [extraProperties, setupMode, bootstrapFailed])
return (
<DynamicRoutesContext.Provider value={{ routes, loading, error, reload: loadRoutesFromConfig }}>

View file

@ -54,6 +54,10 @@ export abstract class UiEvalService {
}, '::App.KickUser.Message')
}
/**
* Canlı sistemde migration tetikler (SQL Query Manager ve Wizard dosya yöneticisi ortak kullanır).
* Yetki sunucu tarafında `App.Setup.Migrate` izniyle doğrulanır.
*/
static ApiDbMigrate = () => {
toast.push(
<Notification type="info" duration={3000}>
@ -61,13 +65,27 @@ export abstract class UiEvalService {
</Notification>,
{ placement: 'bottom-end' },
)
openDbMigrateLogPanel()
streamSetupMigrate(
(entry) => {
dispatchMigrateLog(entry)
let failed = false
streamSetupMigrate({
onLog: dispatchMigrateLog,
onError: (err) => {
failed = true
toast.push(
<Notification type="danger" duration={6000}>
{UiEvalService.translate('::App.DbMigrate.ErrorMessage')}
{`: ${err.message}`}
</Notification>,
{ placement: 'bottom-end' },
)
},
() => {
onFinished: () => {
dispatchMigrateDone()
if (failed) return
toast.push(
<Notification type="success" duration={5000}>
{UiEvalService.translate('::App.DbMigrate.EndMessage')}
@ -75,17 +93,7 @@ export abstract class UiEvalService {
{ placement: 'bottom-end' },
)
},
(err) => {
dispatchMigrateDone()
toast.push(
<Notification type="danger" duration={6000}>
{UiEvalService.translate('::App.DbMigrate.ErrorMessage')}
{err instanceof Error ? `: ${err.message}` : ''}
</Notification>,
{ placement: 'bottom-end' },
)
},
)
})
}
}

View file

@ -8,10 +8,14 @@ import apiService from './api.service'
export const applicationConfigurationUrl = (includeLocalizationResources: boolean) =>
`/api/abp/application-configuration?includeLocalizationResources=${includeLocalizationResources}`
export const getAppConfig = (includeLocalizationResources: boolean) =>
export const getAppConfig = (
includeLocalizationResources: boolean,
options?: { silentError?: boolean },
) =>
apiService.fetchData<ApplicationConfigurationDto>({
method: 'GET',
url: applicationConfigurationUrl(includeLocalizationResources),
silentError: options?.silentError,
})
export const getLocalizations = ({

View file

@ -129,9 +129,11 @@ platformApiService.interceptors.response.use(
platformApiService.interceptors.response.use(
(response) => response,
async (error) => {
console.log('Error interceptor')
console.error(error)
if (!error.silent) {
// error.silent: interceptor'ın kendisi hatayı çözdü (token yenileme, tenant fallback).
// config.silentError: çağıran taraf hatayı kendisi ele alacağını bildirdi.
if (!error.silent && !error.config?.silentError) {
const { messages } = store.getActions().base
messages.addError({
id: crypto.randomUUID(),

View file

@ -1,63 +1,215 @@
import apiService from './api.service'
import { store } from '@/store'
import { MigrateLogEntry, SetupStatusDto } from '@/proxy/setup/models'
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,
})
export const getMigrateUrl = (): string => {
const getMigrateUrl = (): string => {
const base = import.meta.env.VITE_API_URL ?? ''
return `${base}/api/setup/migrate`
}
export const streamSetupMigrate = async (
onLog: (entry: MigrateLogEntry) => void,
onDone: () => void,
onError: (err: unknown) => void,
): Promise<void> => {
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
}
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 */ }
/**
* 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 */
}
onDone()
} catch (err) {
onError(err)
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?.()
}
}

View file

@ -13,10 +13,20 @@ import {
export type Texts = Record<string, Record<string, string>>
export interface GetConfigOptions {
/** Localization kaynaklarını da zorla yeniden yükle. */
reloadTexts?: boolean
/** Hata global hata diyaloğunu açmasın — çağıran taraf kendisi ele alır. */
silentError?: boolean
}
/** Geriye dönük uyumluluk: boolean payload `reloadTexts` anlamına gelir. */
export type GetConfigPayload = boolean | GetConfigOptions
export interface AbpConfigModel {
config?: ApplicationConfigurationDto
setConfig: Action<AbpConfigModel, ApplicationConfigurationDto | undefined>
getConfig: Thunk<AbpConfigModel, boolean, Injections>
getConfig: Thunk<AbpConfigModel, GetConfigPayload, Injections>
texts?: Texts
setTexts: Action<AbpConfigModel, Texts | undefined>
getTexts: Thunk<AbpConfigModel, ApplicationLocalizationRequestDto, Injections>
@ -33,15 +43,18 @@ export const abpConfigModel: AbpConfigModel = {
state.config = payload
}),
getConfig: thunk(async (actions, payload, helpers) => {
const { reloadTexts = false, silentError = false } =
typeof payload === 'boolean' ? { reloadTexts: payload } : (payload ?? {})
const service = helpers.injections.abpConfigService
const result = await service.getAppConfig(payload)
const result = await service.getAppConfig(reloadTexts, { silentError })
// AppConfig'den gelen culture mevcuttan farklı ise localizationları da al
const newCulture = result.data?.localization.currentCulture.cultureName
const currentCulture = helpers.getState().config?.localization.currentCulture.cultureName
const isCultureDifferent = newCulture && currentCulture !== newCulture
const isTextsEmpty = !helpers.getState().texts
if (payload || isCultureDifferent || isTextsEmpty) {
if (reloadTexts || isCultureDifferent || isTextsEmpty) {
await actions.getTexts({
cultureName: newCulture ?? currentCulture ?? appConfig.locale,
onlyDynamics: false,

View file

@ -13,7 +13,12 @@ export interface BaseStoreModel {
common: {
currentRouteKey: string
tabHasFocus: boolean
setupMode: boolean /** Veritabanı mevcut değilse true — setup sayfasına yönlendirme için */
setupMode: boolean /** Sunucu kurulum modunda — setup sayfasına yönlendirme için */
/**
* Açılışta uygulama konfigürasyonu alınamadı ve sunucu kurulum modunda da değil.
* Uygulama çalışamaz durumda; kullanıcıya sonsuz spinner yerine bakım ekranı gösterilir.
*/
bootstrapFailed: boolean
}
messages: {
errors: StoreError[]
@ -26,6 +31,7 @@ export interface BaseStoreActions {
setCurrentRouteKey: Action<BaseStoreModel['common'], string>
setTabHasFocus: Action<BaseStoreModel['common'], boolean>
setSetupMode: Action<BaseStoreModel['common'], boolean>
setBootstrapFailed: Action<BaseStoreModel['common'], boolean>
}
messages: {
addError: Action<BaseStoreModel['messages'], StoreError>
@ -38,7 +44,7 @@ export interface BaseStoreActions {
export type BaseModel = BaseStoreModel & BaseStoreActions
const initialState: BaseStoreModel = {
common: { currentRouteKey: '', tabHasFocus: false, setupMode: false },
common: { currentRouteKey: '', tabHasFocus: false, setupMode: false, bootstrapFailed: false },
messages: {
errors: [],
// success: [],
@ -58,6 +64,9 @@ export const baseModel: BaseModel = {
setSetupMode: action((state, payload) => {
state.setupMode = payload
}),
setBootstrapFailed: action((state, payload) => {
state.bootstrapFailed = payload
}),
},
messages: {
...initialState.messages,

14
ui/src/types/axios.d.ts vendored Normal file
View file

@ -0,0 +1,14 @@
import 'axios'
declare module 'axios' {
interface AxiosRequestConfig {
/**
* true ise bu isteğin hatası global hata diyaloğunu açmaz.
*
* Hatayı çağıran tarafın kendisi ele aldığında kullanılır (örn. uygulama açılışında
* config yüklenemediğinde kurulum moduna geçiş kararı). Hata yine de reject edilir;
* yalnızca kullanıcıya gösterilen genel bildirim bastırılır.
*/
silentError?: boolean
}
}

View file

@ -0,0 +1,135 @@
import { useEffect, useState } from 'react'
import classNames from 'classnames'
import { Helmet } from 'react-helmet'
import { Button } from '@/components/ui'
import { APP_NAME } from '@/constants/app.constant'
import { ROUTES_ENUM } from '@/routes/route.constant'
import { pollUntilServerReady } from '@/services/setup.service'
import { useStoreState } from '@/store'
/**
* Uygulama konfigürasyonu alınamadığında gösterilir (tipik olarak veritabanı erişilemez
* durumdayken).
*
* Kullanıcı bilinçli olarak kurulum sihirbazına yönlendirilmez — çalışan bir sistemde
* geçici bir arıza kimseyi kurulum ekranına düşürmemeli. Bunun yerine durum açıkça
* belirtilir, sunucu arka planda izlenir ve toparlandığı anda sayfa kendini yeniler.
*/
const ServiceUnavailable = () => {
const isDark = useStoreState((state) => state.theme.mode) === 'dark'
const [attempt, setAttempt] = useState(0)
// Sunucu toparlandığında kullanıcının sayfayı elle yenilemesi gerekmesin.
useEffect(() => {
const cancel = pollUntilServerReady(
() => window.location.reload(),
(currentAttempt) => setAttempt(currentAttempt),
)
return cancel
}, [])
return (
<div
className={classNames(
'min-h-screen flex flex-col items-center justify-center p-6 transition-colors',
isDark ? 'bg-gray-900 text-gray-100' : 'bg-gray-100 text-gray-900',
)}
>
<Helmet title={`${APP_NAME} — Service unavailable`} />
<div
className={classNames(
'w-full max-w-lg overflow-hidden rounded-xl border shadow-2xl',
isDark ? 'border-gray-700 bg-gray-800' : 'border-gray-200 bg-white',
)}
>
{/* Başlık */}
<div
className={classNames(
'flex items-center gap-3 border-b px-6 py-4',
isDark ? 'border-gray-700 bg-gray-700' : 'border-gray-200 bg-gray-50',
)}
>
<img
src={isDark ? '/img/logo/logo-dark-streamline.png' : '/img/logo/logo-light-streamline.png'}
alt={`${APP_NAME} logo`}
className="h-8 w-8 object-contain"
/>
<span className="text-sm font-semibold tracking-wide">{APP_NAME}</span>
<span
className={classNames(
'ml-auto inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium uppercase tracking-wider',
isDark
? 'border-amber-400/25 bg-amber-400/10 text-amber-200'
: 'border-amber-500/30 bg-amber-50 text-amber-700',
)}
>
<span
className={classNames(
'h-1.5 w-1.5 animate-pulse rounded-full',
isDark ? 'bg-amber-300' : 'bg-amber-500',
)}
/>
Offline
</span>
</div>
{/* Gövde */}
<div className="px-6 py-7">
<h5 className={isDark ? 'text-gray-100' : 'text-gray-900'}>
Service temporarily unavailable
</h5>
<p className={classNames('mt-3 text-sm leading-relaxed', isDark ? 'text-gray-400' : 'text-gray-600')}>
The application configuration could not be loaded. The server is running, but it cannot
reach its database right now. Your data is not affected — the application will come back
automatically once the database is available again.
</p>
<div
className={classNames(
'mt-5 flex items-center gap-2 rounded-lg border px-3 py-2 text-xs',
isDark
? 'border-gray-700 bg-gray-900 text-gray-400'
: 'border-gray-200 bg-gray-50 text-gray-500',
)}
>
<span
className={classNames(
'inline-block h-2 w-2 animate-ping rounded-full',
isDark ? 'bg-sky-400' : 'bg-sky-500',
)}
/>
{attempt > 0
? `Rechecking the connection… (attempt ${attempt})`
: 'Waiting to recheck the connection…'}
</div>
</div>
{/* Aksiyonlar */}
<div
className={classNames(
'flex flex-col-reverse items-stretch gap-3 border-t px-6 py-4 sm:flex-row sm:items-center sm:justify-between',
isDark ? 'border-gray-700 bg-gray-700' : 'border-gray-200 bg-gray-50',
)}
>
<span className={classNames('text-xs', isDark ? 'text-gray-400' : 'text-gray-500')}>
Administrators can run a database migration from the setup page.
</span>
<div className="flex gap-2">
<Button size="sm" onClick={() => window.location.assign(ROUTES_ENUM.setup)}>
Setup page
</Button>
<Button size="sm" variant="solid" onClick={() => window.location.reload()}>
Try again
</Button>
</div>
</div>
</div>
</div>
)
}
export default ServiceUnavailable

View file

@ -3,7 +3,6 @@ import { useNavigate } from 'react-router-dom'
import classNames from 'classnames'
import { Badge, Button, Input, Notification, toast } from '@/components/ui'
import Container from '@/components/shared/Container'
import ConfirmDialog from '@/components/shared/ConfirmDialog'
import {
FaTrash,
FaSync,
@ -14,14 +13,13 @@ import {
FaEdit,
FaCalendarAlt,
} from 'react-icons/fa'
import { FcAcceptDatabase } from 'react-icons/fc'
import DbMigrateButton from '@/components/shared/DbMigrateButton'
import { deleteWizardFile, getWizardFiles } from '@/services/wizard.service'
import { useCurrentMenuIcon } from '@/utils/hooks/useCurrentMenuIcon'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { useStoreState } from '@/store/store'
import { ROUTES_ENUM } from '@/routes/route.constant'
import { WizardFileInfoDto } from '@/proxy/admin/wizard/models'
import { UiEvalService } from '@/services/UiEvalService'
import navigationIcon from '@/proxy/menus/navigation-icon.config'
interface ConfirmState {
@ -40,7 +38,6 @@ const WizardFileManager = () => {
const [deletingFile, setDeletingFile] = useState<string | null>(null)
const [confirm, setConfirm] = useState<ConfirmState | null>(null)
const [search, setSearch] = useState('')
const [showDbMigrateDialog, setShowDbMigrateDialog] = useState(false)
const IconComponent = (icon: string) => {
return navigationIcon[icon] || FaDatabase // default icon
}
@ -140,15 +137,7 @@ const WizardFileManager = () => {
>
<FaSync />
</Button>
<Button
size="sm"
variant="default"
icon={<FcAcceptDatabase />}
onClick={() => setShowDbMigrateDialog(true)}
title={translate('::App.DbMigrate.StartMessage') || 'Run DB Migration'}
>
<span className="hidden sm:inline">{translate('::ListForms.ListForm.DbMigrate') || 'DB Migrate'}</span>
</Button>
<DbMigrateButton hideLabelOnMobile />
<Button
size="sm"
variant="solid"
@ -242,26 +231,6 @@ const WizardFileManager = () => {
</div>
</div>
{/* DB Migrate Confirmation Dialog */}
<ConfirmDialog
isOpen={showDbMigrateDialog}
type="info"
title={translate('::ListForms.ListForm.DbMigrate') || 'DB Migrate'}
cancelText={translate('::Cancel')}
confirmText={translate('::App.Platform.Execute') || 'Çalıştır'}
onCancel={() => setShowDbMigrateDialog(false)}
onClose={() => setShowDbMigrateDialog(false)}
onConfirm={() => {
setShowDbMigrateDialog(false)
UiEvalService.ApiDbMigrate()
}}
>
<p className="text-gray-600 dark:text-gray-400">
{translate('::App.DbMigrate.ConfirmMessage') ||
'Are you sure you want to start the database migration process?'}
</p>
</ConfirmDialog>
{/* Delete Confirm Dialog */}
{confirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">

View file

@ -2,7 +2,6 @@ import { lazy, Suspense, useState, useCallback, useEffect, useRef } from 'react'
import type { Dispatch, SetStateAction } from 'react'
import { Button, Checkbox, Dialog, Notification, toast } from '@/components/ui'
import Container from '@/components/shared/Container'
import ConfirmDialog from '@/components/shared/ConfirmDialog'
import { getDataSources } from '@/services/data-source.service'
import type { DataSourceDto } from '@/proxy/data-source'
import { DataSourceTypeEnum } from '@/proxy/form/models'
@ -27,8 +26,7 @@ import { Splitter } from '@/components/codeLayout/Splitter'
import { Helmet } from 'react-helmet'
import { useStoreState } from '@/store/store'
import { APP_NAME } from '@/constants/app.constant'
import { UiEvalService } from '@/services/UiEvalService'
import { FcAcceptDatabase } from 'react-icons/fc'
import DbMigrateButton from '@/components/shared/DbMigrateButton'
const SqlResultsGrid = lazy(() => import('./SqlResultsGrid'))
const SqlTableDesignerDialog = lazy(() => import('./SqlTableDesignerDialog'))
@ -92,7 +90,6 @@ const SqlQueryManager = () => {
const [selectedExplorerObjects, setSelectedExplorerObjects] = useState<
SqlExplorerSelectedObject[]
>([])
const [showDbMigrateConfirmDialog, setShowDbMigrateConfirmDialog] = useState(false)
const [showCopyDialog, setShowCopyDialog] = useState(false)
const [copyTargetDataSources, setCopyTargetDataSources] = useState<string[]>([])
const [overwriteIfExists, setOverwriteIfExists] = useState(false)
@ -1208,15 +1205,7 @@ GO`,
</option>
))}
</select>
<Button
size="sm"
variant="default"
icon={<FcAcceptDatabase />}
onClick={() => setShowDbMigrateConfirmDialog(true)}
title={translate('::App.DbMigrate.StartMessage') || 'Run DB Migration'}
>
{translate('::ListForms.ListForm.DbMigrate') || 'DB Migrate'}
</Button>
<DbMigrateButton />
<Button
size="sm"
variant="default"
@ -1380,26 +1369,6 @@ GO`,
</div>
</div>
{/* DB Migrate Confirmation Dialog */}
<ConfirmDialog
isOpen={showDbMigrateConfirmDialog}
type="info"
title={translate('::ListForms.ListForm.DbMigrate') || 'DB Migrate'}
cancelText={translate('::Cancel')}
confirmText={translate('::App.Platform.Execute') || 'Çalıştır'}
onCancel={() => setShowDbMigrateConfirmDialog(false)}
onClose={() => setShowDbMigrateConfirmDialog(false)}
onConfirm={() => {
setShowDbMigrateConfirmDialog(false)
UiEvalService.ApiDbMigrate()
}}
>
<p className="text-gray-600 dark:text-gray-400">
{translate('::App.DbMigrate.ConfirmMessage') ||
'Are you sure you want to start the database migration process?'}
</p>
</ConfirmDialog>
<Dialog
isOpen={showSqlDataFilesDialog}
onClose={() => setShowSqlDataFilesDialog(false)}

View file

@ -1,14 +1,14 @@
import { useEffect, useRef, useState } from 'react'
import classNames from 'classnames'
import { APP_NAME } from '@/constants/app.constant'
import { getMigrateUrl, getSetupStatus } from '@/services/setup.service'
import { applicationConfigurationUrl } from '@/services/abpConfig.service'
import { getSetupStatus, pollUntilServerReady, streamSetupMigrate } from '@/services/setup.service'
import { MigrateLogLevel, SetupStatusDto } from '@/proxy/setup/models'
import { Button, Dialog } from '@/components/ui'
import PasswordInput from '@/components/shared/PasswordInput'
import { useStoreState } from '@/store'
interface LogLine {
level: 'info' | 'warn' | 'error' | 'success' | 'restart' | 'done'
level: MigrateLogLevel | string
message: string
}
@ -32,82 +32,31 @@ const lightLevelClass: Record<string, string> = {
done: 'text-blue-600',
}
/**
* 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 mode = useStoreState((state) => state.theme.mode)
const isDark = mode === 'dark'
const [logs, setLogs] = useState<LogLine[]>([])
const [status, setStatus] = useState<MigrationStatus>('idle')
const [pollCountdown, setPollCountdown] = useState(0)
const [dbExists, setDbExists] = useState<boolean | null>(null)
const [setupStatus, setSetupStatus] = useState<SetupStatusDto | null>(null)
const [setupPassword, setSetupPassword] = useState('')
const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false)
const logEndRef = useRef<HTMLDivElement>(null)
const pollCancelRef = useRef<(() => void) | null>(null)
const abortRef = useRef<AbortController | null>(null)
const dbExists = setupStatus?.dbExists ?? null
// Auto-scroll to bottom when new logs arrive
useEffect(() => {
logEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [logs])
// Check DB existence on mount
// Check DB status on mount
useEffect(() => {
getSetupStatus()
.then((res) => setDbExists(res.data.dbExists))
.catch(() => setDbExists(false))
.then((res) => setSetupStatus(res.data))
.catch(() => setSetupStatus(null))
}, [])
// Cleanup on component unmount
@ -115,6 +64,8 @@ const DatabaseSetup = () => {
return () => {
pollCancelRef.current?.()
pollCancelRef.current = null
abortRef.current?.abort()
abortRef.current = null
}
}, [])
@ -149,82 +100,28 @@ const DatabaseSetup = () => {
setIsPasswordDialogOpen(false)
addLog('info', 'Starting migration...')
const url = getMigrateUrl()
const abortController = new AbortController()
abortRef.current = 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
}
addLog(level, message)
if (level === 'success') {
setStatus('success')
pollUntilReady()
} else if (level === 'error') {
setStatus('error')
} else if (level === 'restart') {
pollUntilReady()
}
} catch {
addLog('info', raw)
}
}
}
try {
const response = await fetch(url, {
method: 'POST',
headers: {
Accept: 'text/event-stream',
'X-Setup-Password': setupPassword,
},
signal: abortController.signal,
})
if (!response.ok || !response.body) {
addLog(
'error',
response.status === 403
? 'Invalid setup password or setup migration is not allowed.'
: `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') {
await streamSetupMigrate({
setupPassword,
signal: abortController.signal,
onLog: (entry) => addLog(entry.level, entry.message),
onSuccess: () => {
setStatus('success')
pollUntilReady()
},
onRestart: () => pollUntilReady(),
onError: (error) => {
setStatus((prev) => (prev === 'running' ? 'error' : prev))
addLog('error', 'Server connection lost or migration could not be completed.')
}
}
addLog('error', error.message)
},
onFinished: () => {
abortRef.current = null
// Akış "success"/"restart" görmeden bittiyse migration tamamlanmamıştır.
setStatus((prev) => (prev === 'running' ? 'error' : prev))
},
})
}
return (
@ -343,7 +240,11 @@ const DatabaseSetup = () => {
)}
>
<div className={classNames('text-xs', isDark ? 'text-gray-400' : 'text-gray-500')}>
{status === 'idle' && dbExists === true && 'Database already exists. Migration is not required.'}
{status === 'idle' && dbExists === true && (
setupStatus?.pendingMigrationCount
? `Database is online with ${setupStatus.pendingMigrationCount} pending migration(s).`
: 'Database is online and up to date. Migration is not required here.'
)}
{status === 'idle' && dbExists === false && 'Database not found. Press the button to start migration.'}
{status === 'idle' && dbExists === null && 'Checking database status...'}
{status === 'running' && 'Please wait, migration is in progress...'}
@ -454,7 +355,9 @@ const DatabaseSetup = () => {
</Dialog>
<p className={classNames('mt-6 text-xs', isDark ? 'text-gray-600' : 'text-gray-400')}>
This page is only visible when the database does not exist.
{dbExists === true
? 'The database is online. Use the DB Migrate action inside the application for routine migrations.'
: 'The database is not reachable. Migration requires the setup password.'}
</p>
</div>
)