diff --git a/api/src/Sozsoft.Platform.Application.Contracts/Permissions/PlatformPermissionDefinitionProvider.cs b/api/src/Sozsoft.Platform.Application.Contracts/Permissions/PlatformPermissionDefinitionProvider.cs index a37ca42b..fc64f0c3 100644 --- a/api/src/Sozsoft.Platform.Application.Contracts/Permissions/PlatformPermissionDefinitionProvider.cs +++ b/api/src/Sozsoft.Platform.Application.Contracts/Permissions/PlatformPermissionDefinitionProvider.cs @@ -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); + } + + /// + /// Kurulum / veritabanı bakım yetkileri. ile + /// işaretlendiği için tenant kullanıcılarına hiç görünmez ve verilemez. + /// + 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) diff --git a/api/src/Sozsoft.Platform.Application.Contracts/Permissions/PlatformPermissions.cs b/api/src/Sozsoft.Platform.Application.Contracts/Permissions/PlatformPermissions.cs new file mode 100644 index 00000000..5e81d4f1 --- /dev/null +++ b/api/src/Sozsoft.Platform.Application.Contracts/Permissions/PlatformPermissions.cs @@ -0,0 +1,16 @@ +namespace Sozsoft.Platform.Permissions; + +public static class PlatformPermissions +{ + public const string GroupName = "App.Setup"; + + /// + /// Kurulum / veritabanı bakım işlemleri. Yalnızca host tarafında anlamlıdır; + /// tenant kullanıcıları host veritabanına migration uygulayamaz. + /// + public static class Setup + { + /// Veritabanı migration + seed işlemini tetikleme yetkisi. + public const string Migrate = GroupName + ".Migrate"; + } +} diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json b/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json index 25c7abed..8c51964e 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json +++ b/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json @@ -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", diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/PlatformIdentityDataSeeder.cs b/api/src/Sozsoft.Platform.DbMigrator/Seeds/PlatformIdentityDataSeeder.cs index bad9769b..54b64e6f 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/PlatformIdentityDataSeeder.cs +++ b/api/src/Sozsoft.Platform.DbMigrator/Seeds/PlatformIdentityDataSeeder.cs @@ -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; } } diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/Controllers/SetupController.cs b/api/src/Sozsoft.Platform.HttpApi.Host/Controllers/SetupController.cs index 52573ecc..0e53654b 100644 --- a/api/src/Sozsoft.Platform.HttpApi.Host/Controllers/SetupController.cs +++ b/api/src/Sozsoft.Platform.HttpApi.Host/Controllers/SetupController.cs @@ -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; /// -/// 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 +/// içindeki minimal uygulama devrededir. +/// İki yol da üzerinden aynı migration akışını kullanır. /// [Route("api/setup")] -[Authorize(Roles = "admin")] public class SetupController : ControllerBase { + /// + /// Yeni izin verilmemiş kurulumlarda kilitlenmemek için tanınan geriye dönük rol. + /// rollere atandıktan sonra kaldırılabilir. + /// + 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 _logger; public SetupController( IConfiguration configuration, IHostEnvironment env, + IHostApplicationLifetime lifetime, + IAuthorizationService authorizationService, + ICurrentTenant currentTenant, + IAbpDistributedLock distributedLock, + IUnitOfWorkManager unitOfWorkManager, BackgroundWorkerInitializer backgroundWorkerInitializer, - AbpBackgroundWorkerRecurringJobInitializer abpBackgroundWorkerRecurringJobInitializer) + AbpBackgroundWorkerRecurringJobInitializer abpBackgroundWorkerRecurringJobInitializer, + ILogger logger) { _configuration = configuration; _env = env; + _lifetime = lifetime; + _authorizationService = authorizationService; + _currentTenant = currentTenant; + _distributedLock = distributedLock; + _unitOfWorkManager = unitOfWorkManager; _backgroundWorkerInitializer = backgroundWorkerInitializer; _abpBackgroundWorkerRecurringJobInitializer = abpBackgroundWorkerRecurringJobInitializer; + _logger = logger; } + /// + /// 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. + /// [HttpGet("application-status")] [AllowAnonymous] - public IActionResult Status() + public async Task 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); } + /// + /// Migration + seed çalıştırır ve logları SSE olarak akıtır. + /// + /// Yetki: veritabanı ayaktayken host tarafında + /// izni (veya geriye dönük olarak admin rolü); veritabanı yokken kurulum parolası. + /// [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 + + /// + /// İki geçerli kimlik yolu vardır: + /// + /// 1. X-Setup-Password header'ı — veritabanına hiç ihtiyaç duymaz, bu yüzden + /// veritabanı düşmüşken de çalışır. + /// 2. Oturum açmış host kullanıcısı + 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. + /// + 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 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 GetPendingMigrationCountAsync(CancellationToken cancellationToken) + { + try + { + using var uow = _unitOfWorkManager.Begin(requiresNew: true, isTransactional: false); + + var dbContext = await uow.ServiceProvider + .GetRequiredService>() + .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; } } } diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/DbStartup/DatabaseReadiness.cs b/api/src/Sozsoft.Platform.HttpApi.Host/DbStartup/DatabaseReadiness.cs new file mode 100644 index 00000000..250dffd0 --- /dev/null +++ b/api/src/Sozsoft.Platform.HttpApi.Host/DbStartup/DatabaseReadiness.cs @@ -0,0 +1,27 @@ +namespace Sozsoft.Platform.DbStartup; + +/// +/// Veritabanının o anki hazırlık durumu. +/// +/// Katalog (database) sunucuda mevcut mu. +/// ABP çekirdek şeması (AbpRoles) oluşmuş mu. +/// +/// Uygulanmamış EF migration sayısı. Yalnızca tam ABP stack ayaktayken hesaplanabilir; +/// hesaplanamadığında null döner. +/// +/// Kontrol sırasında oluşan hata mesajı (varsa). +public sealed record DatabaseReadiness( + bool DatabaseExists, + bool SchemaReady, + int? PendingMigrationCount, + string? Error) +{ + /// Uygulamanın tam ABP stack ile açılabilmesi için yeterli mi. + 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 }; +} diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/DbStartup/DatabaseReadinessChecker.cs b/api/src/Sozsoft.Platform.HttpApi.Host/DbStartup/DatabaseReadinessChecker.cs new file mode 100644 index 00000000..4a4ff0a9 --- /dev/null +++ b/api/src/Sozsoft.Platform.HttpApi.Host/DbStartup/DatabaseReadinessChecker.cs @@ -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; + +/// +/// 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. +/// +public static class DatabaseReadinessChecker +{ + /// DB hazır olduğunda sonucun geçerli kalma süresi. + private static readonly TimeSpan ReadyCacheDuration = TimeSpan.FromSeconds(30); + + /// DB hazır değilken sonucun geçerli kalma süresi (hızlı toparlanma için kısa). + 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; + + /// + /// 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. + /// + 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); + } + } + + /// + /// Cache'i geçersiz kılar. Migration tamamlandıktan sonra çağrılır ki durum + /// bir sonraki istekte anında güncellensin. + /// + public static void Invalidate() + { + _cached = null; + _cachedAtTicks = 0; + } + + /// + /// Cache'i atlayarak doğrudan veritabanına sorar. Uygulama açılışında + /// (DI kurulmadan önce) kullanılır. + /// + 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); + } +} diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/DbStartup/SetupAppRunner.cs b/api/src/Sozsoft.Platform.HttpApi.Host/DbStartup/SetupAppRunner.cs index 9f3ad360..fac93888 100644 --- a/api/src/Sozsoft.Platform.HttpApi.Host/DbStartup/SetupAppRunner.cs +++ b/api/src/Sozsoft.Platform.HttpApi.Host/DbStartup/SetupAppRunner.cs @@ -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; /// /// 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 artık DB'yi hazır +/// bulur ve tam ABP stack ile açılır. /// internal static class SetupAppRunner { - private const string SetupPasswordHeaderName = "X-Setup-Password"; - - // Veritabanı Hazırlık Kontrolü - - /// - /// DB var mı ve AbpRoles tablosu oluşmuş mu diye kontrol eder. - /// Boş DB veya bağlantı hatası durumunda false döner. - /// - 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 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; + } } diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/DbStartup/SetupEventStream.cs b/api/src/Sozsoft.Platform.HttpApi.Host/DbStartup/SetupEventStream.cs new file mode 100644 index 00000000..6dbf7c28 --- /dev/null +++ b/api/src/Sozsoft.Platform.HttpApi.Host/DbStartup/SetupEventStream.cs @@ -0,0 +1,85 @@ +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; + +namespace Sozsoft.Platform.DbStartup; + +/// +/// 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. +/// +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); +} + +/// SSE olay seviyeleri — frontend bu değerlere göre davranır. +public static class SetupEventLevels +{ + public const string Info = "info"; + public const string Warn = "warn"; + public const string Error = "error"; + public const string Success = "success"; + + /// Sunucu yeniden başlıyor — istemci hazır olana kadar poll etmeli. + public const string Restart = "restart"; + + /// Akış bitti — başarı/başarısızlık bilgisi önceki olaylardan gelir. + public const string Done = "done"; +} diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/DbStartup/SetupMigrationRunner.cs b/api/src/Sozsoft.Platform.HttpApi.Host/DbStartup/SetupMigrationRunner.cs new file mode 100644 index 00000000..637ace79 --- /dev/null +++ b/api/src/Sozsoft.Platform.HttpApi.Host/DbStartup/SetupMigrationRunner.cs @@ -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 +} + +/// +/// DbMigrator sürecini çalıştıran tek merkez. +/// +/// Hem veritabanı yokken çalışan minimal kurulum uygulaması () +/// hem de canlı sistemdeki bu sınıfı kullanır; +/// böylece migration mantığı tek yerde tutulur. +/// +public static class SetupMigrationRunner +{ + /// + /// Aynı host üzerinde eşzamanlı migration'ı engeller. Bir migration sürerken + /// gelen ikinci istek alır. + /// + private static readonly SemaphoreSlim ExecutionGate = new(1, 1); + + private const int DefaultTimeoutMinutes = 30; + + /// + /// Sürecin yaşam süresini kontrol eden token. Bu istemci isteğinin token'ı DEĞİL, + /// uygulamanın ApplicationStopping token'ı olmalıdır. Aksi halde kullanıcı sekmeyi + /// kapattığında migration yarıda kesilir ve veritabanı tutarsız kalır. + /// + public static async Task 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 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("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 + } + } + + /// + /// 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ü. + /// + 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); diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/DbStartup/SetupPasswordValidator.cs b/api/src/Sozsoft.Platform.HttpApi.Host/DbStartup/SetupPasswordValidator.cs new file mode 100644 index 00000000..3ac035fa --- /dev/null +++ b/api/src/Sozsoft.Platform.HttpApi.Host/DbStartup/SetupPasswordValidator.cs @@ -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; + +/// +/// Veritabanı yokken (henüz kimlik doğrulama altyapısı ayakta değilken) migration'ı koruyan +/// tek mekanizma: X-Setup-Password header'ı ile Setup:MigrationPassword 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. +/// +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); + } + + /// Parola uzunluğunun zamanlama üzerinden sızmaması için sabit süreli karşılaştırma. + 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); + } +} diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/DbStartup/SetupStatusDto.cs b/api/src/Sozsoft.Platform.HttpApi.Host/DbStartup/SetupStatusDto.cs new file mode 100644 index 00000000..cfdd29c1 --- /dev/null +++ b/api/src/Sozsoft.Platform.HttpApi.Host/DbStartup/SetupStatusDto.cs @@ -0,0 +1,46 @@ +namespace Sozsoft.Platform.DbStartup; + +/// +/// /api/setup/application-status yanıtı. +/// +public sealed class SetupStatusDto +{ + /// + /// 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. + /// + public bool DbExists { get; init; } + + /// + /// Sunucu kurulum modunda mı (minimal uygulama) çalışıyor. + /// + /// Frontend yalnızca bu değer true iken /setup sayfasına yönlendirir. Tam ABP stack + /// ayaktayken bu değer her zaman false'tur; böylece canlı sistemde geçici bir + /// veritabanı sorunu anonim kullanıcıyı kurulum ekranına düşürmez. + /// + public bool SetupMode { get; init; } + + /// + /// Uygulanmamış EF migration sayısı. Yalnızca yetkili kullanıcıya ve tam stack ayaktayken + /// doldurulur; aksi halde null. + /// + public int? PendingMigrationCount { get; init; } + + /// Kontrol sırasında oluşan hata (yalnızca yetkili çağırana doldurulur). + 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, + }; + } +} diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/Extensions/PlatformApplicationConfigurationContributor.cs b/api/src/Sozsoft.Platform.HttpApi.Host/Extensions/PlatformApplicationConfigurationContributor.cs index 62969a6c..bd38a098 100644 --- a/api/src/Sozsoft.Platform.HttpApi.Host/Extensions/PlatformApplicationConfigurationContributor.cs +++ b/api/src/Sozsoft.Platform.HttpApi.Host/Extensions/PlatformApplicationConfigurationContributor.cs @@ -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(); context.ApplicationConfiguration.SetProperty("version", configuration.GetValue("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>(); - 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>(); - 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(); if (currentUser.Id.HasValue) diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformSessionValidationMiddleware.cs b/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformSessionValidationMiddleware.cs index 2dad3860..4c35da7f 100644 --- a/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformSessionValidationMiddleware.cs +++ b/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformSessionValidationMiddleware.cs @@ -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); } + /// + /// 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. + /// + 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; diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/PlatformHttpApiHostModule.cs b/api/src/Sozsoft.Platform.HttpApi.Host/PlatformHttpApiHostModule.cs index 3b0c7c84..a1facbf8 100644 --- a/api/src/Sozsoft.Platform.HttpApi.Host/PlatformHttpApiHostModule.cs +++ b/api/src/Sozsoft.Platform.HttpApi.Host/PlatformHttpApiHostModule.cs @@ -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(); - 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") && diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/Program.cs b/api/src/Sozsoft.Platform.HttpApi.Host/Program.cs index f631fa65..3039ae97 100644 --- a/api/src/Sozsoft.Platform.HttpApi.Host/Program.cs +++ b/api/src/Sozsoft.Platform.HttpApi.Host/Program.cs @@ -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."); } diff --git a/ui/src/components/layouts/Layouts.tsx b/ui/src/components/layouts/Layouts.tsx index 6fef18a1..ac222b02 100644 --- a/ui/src/components/layouts/Layouts.tsx +++ b/ui/src/components/layouts/Layouts.tsx @@ -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 + } + if (loading) { return (
diff --git a/ui/src/components/shared/DbMigrateButton.tsx b/ui/src/components/shared/DbMigrateButton.tsx new file mode 100644 index 00000000..538fcaef --- /dev/null +++ b/ui/src/components/shared/DbMigrateButton.tsx @@ -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 ( + <> + + + setIsConfirmOpen(false)} + onClose={() => setIsConfirmOpen(false)} + onConfirm={() => { + setIsConfirmOpen(false) + UiEvalService.ApiDbMigrate() + }} + > +

+ {translate('::App.DbMigrate.ConfirmMessage') || + 'Are you sure you want to start the database migration process?'} +

+
+ + ) +} + +export default DbMigrateButton diff --git a/ui/src/components/template/Theme.tsx b/ui/src/components/template/Theme.tsx index 2342e7d5..226c5aae 100644 --- a/ui/src/components/template/Theme.tsx +++ b/ui/src/components/template/Theme.tsx @@ -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() { diff --git a/ui/src/constants/permission.constant.ts b/ui/src/constants/permission.constant.ts index a101d318..ce676a96 100644 --- a/ui/src/constants/permission.constant.ts +++ b/ui/src/constants/permission.constant.ts @@ -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' diff --git a/ui/src/proxy/setup/models.ts b/ui/src/proxy/setup/models.ts index e99675be..4e51cd0f 100644 --- a/ui/src/proxy/setup/models.ts +++ b/ui/src/proxy/setup/models.ts @@ -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 -} \ No newline at end of file + /** + * 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 +} diff --git a/ui/src/routes/dynamicRouter.tsx b/ui/src/routes/dynamicRouter.tsx index 67ea6440..08fc9a5d 100644 --- a/ui/src/routes/dynamicRouter.tsx +++ b/ui/src/routes/dynamicRouter.tsx @@ -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
Loading...
- if (error && location.pathname !== '/setup') return
Hata: {error}
+ // /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
Loading...
+ if (error && !isSetupPath) return
Hata: {error}
return ( diff --git a/ui/src/routes/dynamicRoutesContext.tsx b/ui/src/routes/dynamicRoutesContext.tsx index 00c1d957..f4d85abf 100644 --- a/ui/src/routes/dynamicRoutesContext.tsx +++ b/ui/src/routes/dynamicRoutesContext.tsx @@ -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([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(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 ( diff --git a/ui/src/services/UiEvalService.tsx b/ui/src/services/UiEvalService.tsx index cc5fc3e8..e2589674 100644 --- a/ui/src/services/UiEvalService.tsx +++ b/ui/src/services/UiEvalService.tsx @@ -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( @@ -61,13 +65,27 @@ export abstract class UiEvalService { , { placement: 'bottom-end' }, ) + openDbMigrateLogPanel() - streamSetupMigrate( - (entry) => { - dispatchMigrateLog(entry) + + let failed = false + + streamSetupMigrate({ + onLog: dispatchMigrateLog, + onError: (err) => { + failed = true + toast.push( + + {UiEvalService.translate('::App.DbMigrate.ErrorMessage')} + {`: ${err.message}`} + , + { placement: 'bottom-end' }, + ) }, - () => { + onFinished: () => { dispatchMigrateDone() + if (failed) return + toast.push( {UiEvalService.translate('::App.DbMigrate.EndMessage')} @@ -75,17 +93,7 @@ export abstract class UiEvalService { { placement: 'bottom-end' }, ) }, - (err) => { - dispatchMigrateDone() - toast.push( - - {UiEvalService.translate('::App.DbMigrate.ErrorMessage')} - {err instanceof Error ? `: ${err.message}` : ''} - , - { placement: 'bottom-end' }, - ) - }, - ) + }) } } diff --git a/ui/src/services/abpConfig.service.ts b/ui/src/services/abpConfig.service.ts index c88e54f1..7ea9b1d1 100644 --- a/ui/src/services/abpConfig.service.ts +++ b/ui/src/services/abpConfig.service.ts @@ -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({ method: 'GET', url: applicationConfigurationUrl(includeLocalizationResources), + silentError: options?.silentError, }) export const getLocalizations = ({ diff --git a/ui/src/services/platformApi.service.ts b/ui/src/services/platformApi.service.ts index 1a1673b5..5899358d 100644 --- a/ui/src/services/platformApi.service.ts +++ b/ui/src/services/platformApi.service.ts @@ -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(), diff --git a/ui/src/services/setup.service.ts b/ui/src/services/setup.service.ts index 42d9fcc3..5da8e6cc 100644 --- a/ui/src/services/setup.service.ts +++ b/ui/src/services/setup.service.ts @@ -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({ 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 => { - 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 | 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 => { + 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?.() + } +} diff --git a/ui/src/store/abpConfig.model.ts b/ui/src/store/abpConfig.model.ts index 7fe05023..cbd794cb 100644 --- a/ui/src/store/abpConfig.model.ts +++ b/ui/src/store/abpConfig.model.ts @@ -13,10 +13,20 @@ import { export type Texts = Record> +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 - getConfig: Thunk + getConfig: Thunk texts?: Texts setTexts: Action getTexts: Thunk @@ -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, diff --git a/ui/src/store/base.model.ts b/ui/src/store/base.model.ts index 40bae639..66e7ec61 100644 --- a/ui/src/store/base.model.ts +++ b/ui/src/store/base.model.ts @@ -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 setTabHasFocus: Action setSetupMode: Action + setBootstrapFailed: Action } messages: { addError: Action @@ -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, diff --git a/ui/src/types/axios.d.ts b/ui/src/types/axios.d.ts new file mode 100644 index 00000000..71c92507 --- /dev/null +++ b/ui/src/types/axios.d.ts @@ -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 + } +} diff --git a/ui/src/views/ServiceUnavailable.tsx b/ui/src/views/ServiceUnavailable.tsx new file mode 100644 index 00000000..de471b4e --- /dev/null +++ b/ui/src/views/ServiceUnavailable.tsx @@ -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 ( +
+ + +
+ {/* Başlık */} +
+ {`${APP_NAME} + {APP_NAME} + + + + Offline + +
+ + {/* Gövde */} +
+
+ Service temporarily unavailable +
+ +

+ 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. +

+ +
+ + {attempt > 0 + ? `Rechecking the connection… (attempt ${attempt})` + : 'Waiting to recheck the connection…'} +
+
+ + {/* Aksiyonlar */} +
+ + Administrators can run a database migration from the setup page. + + +
+ + +
+
+
+
+ ) +} + +export default ServiceUnavailable diff --git a/ui/src/views/admin/listForm/wizard/WizardFileManager.tsx b/ui/src/views/admin/listForm/wizard/WizardFileManager.tsx index 661302e9..389b63ee 100644 --- a/ui/src/views/admin/listForm/wizard/WizardFileManager.tsx +++ b/ui/src/views/admin/listForm/wizard/WizardFileManager.tsx @@ -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(null) const [confirm, setConfirm] = useState(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 = () => { > - +
- {/* DB Migrate Confirmation Dialog */} - setShowDbMigrateDialog(false)} - onClose={() => setShowDbMigrateDialog(false)} - onConfirm={() => { - setShowDbMigrateDialog(false) - UiEvalService.ApiDbMigrate() - }} - > -

- {translate('::App.DbMigrate.ConfirmMessage') || - 'Are you sure you want to start the database migration process?'} -

-
- {/* Delete Confirm Dialog */} {confirm && (
diff --git a/ui/src/views/developerKit/SqlQueryManager.tsx b/ui/src/views/developerKit/SqlQueryManager.tsx index 4351e1a1..d7ada267 100644 --- a/ui/src/views/developerKit/SqlQueryManager.tsx +++ b/ui/src/views/developerKit/SqlQueryManager.tsx @@ -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([]) const [overwriteIfExists, setOverwriteIfExists] = useState(false) @@ -1208,15 +1205,7 @@ GO`, ))} - +
- {/* DB Migrate Confirmation Dialog */} - setShowDbMigrateConfirmDialog(false)} - onClose={() => setShowDbMigrateConfirmDialog(false)} - onConfirm={() => { - setShowDbMigrateConfirmDialog(false) - UiEvalService.ApiDbMigrate() - }} - > -

- {translate('::App.DbMigrate.ConfirmMessage') || - 'Are you sure you want to start the database migration process?'} -

-
- setShowSqlDataFilesDialog(false)} diff --git a/ui/src/views/setup/DatabaseSetup.tsx b/ui/src/views/setup/DatabaseSetup.tsx index a39748ee..8b03f91c 100644 --- a/ui/src/views/setup/DatabaseSetup.tsx +++ b/ui/src/views/setup/DatabaseSetup.tsx @@ -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 = { 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 | 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([]) const [status, setStatus] = useState('idle') const [pollCountdown, setPollCountdown] = useState(0) - const [dbExists, setDbExists] = useState(null) + const [setupStatus, setSetupStatus] = useState(null) const [setupPassword, setSetupPassword] = useState('') const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false) const logEndRef = useRef(null) const pollCancelRef = useRef<(() => void) | null>(null) + const abortRef = useRef(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 = () => { )} >
- {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 = () => {

- 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.'}

)