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 (
+ <>
+ }
+ className={className}
+ onClick={() => setIsConfirmOpen(true)}
+ title={translate('::App.DbMigrate.StartMessage') || 'Run DB Migration'}
+ >
+ {hideLabelOnMobile ? {label} : label}
+
+
+ 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}
+
+
+
+ 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.
+
+
+