Claude güncellemesi Setup ve Migration

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

View file

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

View file

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

View file

@ -3618,6 +3618,18 @@
"en": "Are you sure you want to start database migration? Please make sure you have a backup before proceeding.", "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." "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", "resourceName": "Platform",
"key": "App.ClearRedisCache.Message", "key": "App.ClearRedisCache.Message",

View file

@ -14,6 +14,7 @@ using IdentityRole = Volo.Abp.Identity.IdentityRole;
using IdentityUser = Volo.Abp.Identity.IdentityUser; using IdentityUser = Volo.Abp.Identity.IdentityUser;
using Volo.Abp.Domain.Repositories; using Volo.Abp.Domain.Repositories;
using Sozsoft.Platform.Entities; using Sozsoft.Platform.Entities;
using Sozsoft.Platform.Permissions;
using Volo.Abp.TenantManagement; using Volo.Abp.TenantManagement;
namespace Sozsoft.Platform.Data.Seeds; 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; return result;
} }
} }

View file

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

View file

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

View file

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

View file

@ -1,182 +1,41 @@
using System; using System;
using System.Diagnostics;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Npgsql;
using Serilog; using Serilog;
using static Sozsoft.Settings.SettingsConsts;
namespace Sozsoft.Platform; namespace Sozsoft.Platform.DbStartup;
/// <summary> /// <summary>
/// Veritabanı henüz hazır değilken çalışan minimal kurulum uygulaması. /// Veritabanı henüz hazır değilken çalışan minimal kurulum uygulaması.
/// Tam ABP stack yüklemez; sadece /api/setup/* endpointlerini sunar. /// Tam ABP stack yüklemez; sadece /api/setup/* endpointlerini sunar.
///
/// Migration tamamlandığında uygulama kendini durdurur; process yöneticisi (systemd, IIS,
/// container orchestrator) yeniden başlattığında <see cref="Program"/> artık DB'yi hazır
/// bulur ve tam ABP stack ile açılır.
/// </summary> /// </summary>
internal static class SetupAppRunner internal static class SetupAppRunner
{ {
private const string SetupPasswordHeaderName = "X-Setup-Password";
// Veritabanı Hazırlık Kontrolü
/// <summary>
/// DB var mı ve AbpRoles tablosu oluşmuş mu diye kontrol eder.
/// Boş DB veya bağlantı hatası durumunda false döner.
/// </summary>
public static bool DatabaseIsReady(IConfiguration configuration)
{
var connectionString = configuration.GetConnectionString(DefaultDatabaseProvider);
if (string.IsNullOrWhiteSpace(connectionString))
return false;
try
{
if (DefaultDatabaseProvider == DatabaseProvider.SqlServer)
return SqlServerIsReady(connectionString);
if (DefaultDatabaseProvider == DatabaseProvider.PostgreSql)
return PostgreSqlIsReady(connectionString);
return false;
}
catch (Exception ex)
{
Log.Warning("Veritabanı hazırlık kontrolü başarısız: {Error}", ex.Message);
return false;
}
}
public static bool IsValidSetupPassword(IConfiguration configuration, IHeaderDictionary headers)
{
var configuredPassword = configuration["Setup:MigrationPassword"];
if (string.IsNullOrWhiteSpace(configuredPassword))
{
return false;
}
return headers.TryGetValue(SetupPasswordHeaderName, out var providedPassword) &&
string.Equals(providedPassword.ToString(), configuredPassword, StringComparison.Ordinal);
}
private static bool SqlServerIsReady(string connectionString)
{
var csb = new SqlConnectionStringBuilder(connectionString);
var dbName = csb.InitialCatalog;
if (string.IsNullOrEmpty(dbName))
return false;
// 1) master'a bağlan — DB varlığını kontrol et
var masterCsb = new SqlConnectionStringBuilder(connectionString)
{
InitialCatalog = "master",
ConnectTimeout = 8
};
using var masterConn = new SqlConnection(masterCsb.ConnectionString);
masterConn.Open();
using var dbCheck = new SqlCommand(
"SELECT COUNT(1) FROM sys.databases WHERE name = @n", masterConn);
dbCheck.Parameters.AddWithValue("@n", dbName);
if ((int)dbCheck.ExecuteScalar() == 0)
return false;
// 2) Hedef DB'ye bağlan — AbpRoles tablosunun varlığını kontrol et
csb.ConnectTimeout = 8;
using var dbConn = new SqlConnection(csb.ConnectionString);
dbConn.Open();
using var tableCheck = new SqlCommand(
"SELECT COUNT(1) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'AbpRoles'",
dbConn);
return (int)tableCheck.ExecuteScalar() > 0;
}
private static bool PostgreSqlIsReady(string connectionString)
{
var csb = new NpgsqlConnectionStringBuilder(connectionString);
var dbName = csb.Database;
if (string.IsNullOrEmpty(dbName))
return false;
var maintenanceCsb = new NpgsqlConnectionStringBuilder(connectionString)
{
Database = "postgres",
Timeout = 8,
CommandTimeout = 8
};
using var maintenanceConn = new NpgsqlConnection(maintenanceCsb.ConnectionString);
maintenanceConn.Open();
using var dbCheck = new NpgsqlCommand(
"SELECT COUNT(1) FROM pg_database WHERE datname = @n",
maintenanceConn);
dbCheck.Parameters.AddWithValue("n", dbName);
if (Convert.ToInt32(dbCheck.ExecuteScalar()) == 0)
return false;
csb.Timeout = 8;
csb.CommandTimeout = 8;
using var dbConn = new NpgsqlConnection(csb.ConnectionString);
dbConn.Open();
using var tableCheck = new NpgsqlCommand(
"""
SELECT COUNT(1)
FROM information_schema.tables
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
AND lower(table_name) = lower('AbpRoles')
""",
dbConn);
return Convert.ToInt32(tableCheck.ExecuteScalar()) > 0;
}
// Minimal Kurulum Uygulaması
public static async Task<int> RunAsync(string[] args, IConfiguration configuration) public static async Task<int> RunAsync(string[] args, IConfiguration configuration)
{ {
Log.Warning("Veritabanı hazır değil — kurulum modu başlatılıyor."); 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 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 => builder.Services.AddCors(o => o.AddPolicy("Setup", policy =>
policy.AllowAnyHeader() policy.AllowAnyHeader()
.AllowAnyMethod() .AllowAnyMethod()
.AllowCredentials() .AllowCredentials()
.SetIsOriginAllowed(origin => .SetIsOriginAllowed(origin => IsAllowedOrigin(configuration, 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;
})));
builder.Host.UseSerilog(); builder.Host.UseSerilog();
@ -184,153 +43,102 @@ internal static class SetupAppRunner
app.UseCors("Setup"); app.UseCors("Setup");
app.MapGet("/api/setup/application-status", (IConfiguration cfg) => 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, app.MapPost("/api/setup/migrate", async (
IHostApplicationLifetime lifetime, HttpContext ctx, CancellationToken ct) => 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; 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; return;
} }
ctx.Response.ContentType = "text/event-stream; charset=utf-8"; if (!SetupPasswordValidator.IsValid(cfg, ctx.Request.Headers))
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)
{ {
try ctx.Response.StatusCode = StatusCodes.Status403Forbidden;
{ ctx.Response.ContentType = "text/plain; charset=utf-8";
var payload = JsonSerializer.Serialize(new { level, message }); await ctx.Response.WriteAsync("Invalid setup password.");
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.");
return; return;
} }
await Send("info", $"Running: {fileName} {arguments}"); var stream = new SetupEventStream(ctx.Response);
await stream.StartAsync();
Process? process = null; // Süreç ömrü istemci isteğine değil uygulama ömrüne bağlı:
try // 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 return;
{
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();
} }
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(); await app.RunAsync();
return 0; return 0;
} }
private static bool IsAllowedOrigin(IConfiguration configuration, string origin)
{
if (!Uri.TryCreate(origin, UriKind.Absolute, out var uri))
{
return false;
}
var host = uri.Host.ToLowerInvariant();
if (host is "localhost" or "127.0.0.1" or "[::1]")
{
return true;
}
var baseDomain = configuration["App:BaseDomain"]?.Trim();
if (!string.IsNullOrWhiteSpace(baseDomain))
{
var bd = baseDomain.ToLowerInvariant();
if (host == bd || host.EndsWith("." + bd))
{
return true;
}
}
var extraOrigins = (configuration["App:CorsOrigins"] ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var allowed in extraOrigins)
{
if (Uri.TryCreate(allowed, UriKind.Absolute, out var allowedUri) &&
allowedUri.Host.Equals(host, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -20,6 +20,7 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Extensions.DependencyInjection; using Microsoft.AspNetCore.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Sozsoft.Platform.DbStartup;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.ResponseCompression; using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.Extensions.Caching.StackExchangeRedis; using Microsoft.Extensions.Caching.StackExchangeRedis;
@ -506,20 +507,30 @@ public class PlatformHttpApiHostModule : AbpModule
app.MapAbpStaticAssets(); app.MapAbpStaticAssets();
app.UseRouting(); app.UseRouting();
app.UseCors(); 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) => app.Use(async (httpContext, next) =>
{ {
if (httpContext.Request.Path.StartsWithSegments("/api/setup/application-status")) if (httpContext.Request.Path.StartsWithSegments("/api/abp/application-configuration"))
{ {
var configuration = httpContext.RequestServices.GetRequiredService<IConfiguration>(); var configuration = httpContext.RequestServices.GetRequiredService<IConfiguration>();
var dbExists = SetupAppRunner.DatabaseIsReady(configuration); if (!DatabaseReadinessChecker.GetStatus(configuration).IsReady)
{
httpContext.Response.ContentType = "application/json; charset=utf-8"; httpContext.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
await httpContext.Response.WriteAsJsonAsync(new { dbExists }); httpContext.Response.ContentType = "text/plain; charset=utf-8";
return; await httpContext.Response.WriteAsync("The database is not reachable.");
return;
}
} }
await next(); await next();
}); });
app.Use(async (context, next) => app.Use(async (context, next) =>
{ {
if (context.Request.Path.StartsWithSegments("/messengerhub") && if (context.Request.Path.StartsWithSegments("/messengerhub") &&

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO; using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
using Sozsoft.Platform.Enums; using Sozsoft.Platform.Enums;
using Sozsoft.Platform.DbStartup;
using Sozsoft.Platform.DynamicServices; using Sozsoft.Platform.DynamicServices;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
@ -35,12 +36,20 @@ public class Program
.WriteTo.Console() .WriteTo.Console()
.CreateLogger(); .CreateLogger();
if (!SetupAppRunner.DatabaseIsReady(configuration)) if (!DatabaseReadinessChecker.Probe(configuration).IsReady)
{ {
var setupResult = await SetupAppRunner.RunAsync(args, configuration); var setupResult = await SetupAppRunner.RunAsync(args, configuration);
if (setupResult != 0) if (setupResult != 0)
return setupResult; 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."); Log.Warning("Migration tamamlandı — tam uygulama başlatılıyor.");
} }

View file

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

View file

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

View file

@ -17,34 +17,46 @@ let didInit = false
const Theme = (props: CommonProps) => { const Theme = (props: CommonProps) => {
const { getConfig } = useStoreActions((a) => a.abpConfig) 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 navigate = useNavigate()
const location = useLocation() const location = useLocation()
useEffect(() => { useEffect(() => {
if (!didInit) { if (didInit) return
didInit = true didInit = true
// Direkt /setup'a gelindiyse — hemen setupMode=true yap (loading gate açılsın) // Direkt /setup'a gelindiyse — hemen setupMode=true yap (loading gate açılsın)
if (location.pathname === ROUTES_ENUM.setup) { if (location.pathname === ROUTES_ENUM.setup) {
setSetupMode(true) setSetupMode(true)
return 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 // Sunucu kurulum modunda değil ama uygulama da açılamıyor.
getSetupStatus() // Bu bayrak olmadan loading gate hiç kapanmıyor ve ekran sonsuza kadar
.then((res) => { // spinner'da kalıyordu.
if (!res.data.dbExists) { setBootstrapFailed(true)
setSetupMode(true) })
navigate(ROUTES_ENUM.setup, { replace: true })
} else {
getConfig(false)
}
})
.catch(() => {
getConfig(false)
})
}
}, []) }, [])
function getThemeStyle() { function getThemeStyle() {

View file

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

View file

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

View file

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

View file

@ -20,6 +20,7 @@ export const useDynamicRoutes = () => {
export const DynamicRoutesProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const DynamicRoutesProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const extraProperties = useStoreState((state) => state.abpConfig?.config?.extraProperties) const extraProperties = useStoreState((state) => state.abpConfig?.config?.extraProperties)
const setupMode = useStoreState((state) => state.base.common.setupMode) const setupMode = useStoreState((state) => state.base.common.setupMode)
const bootstrapFailed = useStoreState((state) => state.base.common.bootstrapFailed)
const [routes, setRoutes] = useState<RouteDto[]>([]) const [routes, setRoutes] = useState<RouteDto[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -49,12 +50,13 @@ export const DynamicRoutesProvider: React.FC<{ children: React.ReactNode }> = ({
useEffect(() => { useEffect(() => {
if (extraProperties) { if (extraProperties) {
loadRoutesFromConfig() loadRoutesFromConfig()
} else if (setupMode) { } else if (setupMode || bootstrapFailed) {
// Veritabanı mevcut değil — setup modunda loading'i kapat // 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) setLoading(false)
setRoutes([]) setRoutes([])
} }
}, [extraProperties, setupMode]) }, [extraProperties, setupMode, bootstrapFailed])
return ( return (
<DynamicRoutesContext.Provider value={{ routes, loading, error, reload: loadRoutesFromConfig }}> <DynamicRoutesContext.Provider value={{ routes, loading, error, reload: loadRoutesFromConfig }}>

View file

@ -54,6 +54,10 @@ export abstract class UiEvalService {
}, '::App.KickUser.Message') }, '::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 = () => { static ApiDbMigrate = () => {
toast.push( toast.push(
<Notification type="info" duration={3000}> <Notification type="info" duration={3000}>
@ -61,13 +65,27 @@ export abstract class UiEvalService {
</Notification>, </Notification>,
{ placement: 'bottom-end' }, { placement: 'bottom-end' },
) )
openDbMigrateLogPanel() openDbMigrateLogPanel()
streamSetupMigrate(
(entry) => { let failed = false
dispatchMigrateLog(entry)
streamSetupMigrate({
onLog: dispatchMigrateLog,
onError: (err) => {
failed = true
toast.push(
<Notification type="danger" duration={6000}>
{UiEvalService.translate('::App.DbMigrate.ErrorMessage')}
{`: ${err.message}`}
</Notification>,
{ placement: 'bottom-end' },
)
}, },
() => { onFinished: () => {
dispatchMigrateDone() dispatchMigrateDone()
if (failed) return
toast.push( toast.push(
<Notification type="success" duration={5000}> <Notification type="success" duration={5000}>
{UiEvalService.translate('::App.DbMigrate.EndMessage')} {UiEvalService.translate('::App.DbMigrate.EndMessage')}
@ -75,17 +93,7 @@ export abstract class UiEvalService {
{ placement: 'bottom-end' }, { placement: 'bottom-end' },
) )
}, },
(err) => { })
dispatchMigrateDone()
toast.push(
<Notification type="danger" duration={6000}>
{UiEvalService.translate('::App.DbMigrate.ErrorMessage')}
{err instanceof Error ? `: ${err.message}` : ''}
</Notification>,
{ placement: 'bottom-end' },
)
},
)
} }
} }

View file

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

View file

@ -129,9 +129,11 @@ platformApiService.interceptors.response.use(
platformApiService.interceptors.response.use( platformApiService.interceptors.response.use(
(response) => response, (response) => response,
async (error) => { async (error) => {
console.log('Error interceptor')
console.error(error) 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 const { messages } = store.getActions().base
messages.addError({ messages.addError({
id: crypto.randomUUID(), id: crypto.randomUUID(),

View file

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

View file

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

View file

@ -13,7 +13,12 @@ export interface BaseStoreModel {
common: { common: {
currentRouteKey: string currentRouteKey: string
tabHasFocus: boolean 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 */
/**
* ı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: { messages: {
errors: StoreError[] errors: StoreError[]
@ -26,6 +31,7 @@ export interface BaseStoreActions {
setCurrentRouteKey: Action<BaseStoreModel['common'], string> setCurrentRouteKey: Action<BaseStoreModel['common'], string>
setTabHasFocus: Action<BaseStoreModel['common'], boolean> setTabHasFocus: Action<BaseStoreModel['common'], boolean>
setSetupMode: Action<BaseStoreModel['common'], boolean> setSetupMode: Action<BaseStoreModel['common'], boolean>
setBootstrapFailed: Action<BaseStoreModel['common'], boolean>
} }
messages: { messages: {
addError: Action<BaseStoreModel['messages'], StoreError> addError: Action<BaseStoreModel['messages'], StoreError>
@ -38,7 +44,7 @@ export interface BaseStoreActions {
export type BaseModel = BaseStoreModel & BaseStoreActions export type BaseModel = BaseStoreModel & BaseStoreActions
const initialState: BaseStoreModel = { const initialState: BaseStoreModel = {
common: { currentRouteKey: '', tabHasFocus: false, setupMode: false }, common: { currentRouteKey: '', tabHasFocus: false, setupMode: false, bootstrapFailed: false },
messages: { messages: {
errors: [], errors: [],
// success: [], // success: [],
@ -58,6 +64,9 @@ export const baseModel: BaseModel = {
setSetupMode: action((state, payload) => { setSetupMode: action((state, payload) => {
state.setupMode = payload state.setupMode = payload
}), }),
setBootstrapFailed: action((state, payload) => {
state.bootstrapFailed = payload
}),
}, },
messages: { messages: {
...initialState.messages, ...initialState.messages,

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

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

View file

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

View file

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

View file

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

View file

@ -1,14 +1,14 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import classNames from 'classnames' import classNames from 'classnames'
import { APP_NAME } from '@/constants/app.constant' import { APP_NAME } from '@/constants/app.constant'
import { getMigrateUrl, getSetupStatus } from '@/services/setup.service' import { getSetupStatus, pollUntilServerReady, streamSetupMigrate } from '@/services/setup.service'
import { applicationConfigurationUrl } from '@/services/abpConfig.service' import { MigrateLogLevel, SetupStatusDto } from '@/proxy/setup/models'
import { Button, Dialog } from '@/components/ui' import { Button, Dialog } from '@/components/ui'
import PasswordInput from '@/components/shared/PasswordInput' import PasswordInput from '@/components/shared/PasswordInput'
import { useStoreState } from '@/store' import { useStoreState } from '@/store'
interface LogLine { interface LogLine {
level: 'info' | 'warn' | 'error' | 'success' | 'restart' | 'done' level: MigrateLogLevel | string
message: string message: string
} }
@ -32,82 +32,31 @@ const lightLevelClass: Record<string, string> = {
done: 'text-blue-600', done: 'text-blue-600',
} }
/**
* Sunucu yeniden başlayana kadar ABP config endpoint'ini poll eder.
* Arka arkaya 2 başarılı yanıt alındığında onReady çağrılır.
* @param onReady Sunucu hazır olduğunda çağrılacak callback
* @param onAttempt Her denemede kaçıncı deneme olduğunu bildiren opsiyonel callback
*/
const pollUntilServerReady = (onReady: () => void, onAttempt?: (attempt: number) => void): (() => void) => {
const REQUIRED_SUCCESS = 2
let attempt = 0
let successCount = 0
let timerId: ReturnType<typeof setTimeout> | null = null
let cancelled = false
const tick = async () => {
if (cancelled) return
attempt++
onAttempt?.(attempt)
try {
const res = await fetch(
`${import.meta.env.VITE_API_URL ?? ''}${applicationConfigurationUrl(false)}`,
{ method: 'GET', headers: { Accept: 'application/json' }, cache: 'no-store' },
)
if (res.status === 200) {
try {
const json = await res.json()
if (json && typeof json.currentUser === 'object') {
successCount++
if (successCount >= REQUIRED_SUCCESS) {
onReady()
return
}
timerId = setTimeout(tick, 1000)
return
}
} catch { /* parse hatası */ }
}
successCount = 0
} catch {
successCount = 0
}
timerId = setTimeout(tick, 2000)
}
// İlk denemeden önce kısa bekleme (sunucunun kapanma süresi)
timerId = setTimeout(tick, 3000)
// İptal fonksiyonu döner
return () => {
cancelled = true
if (timerId) clearTimeout(timerId)
}
}
const DatabaseSetup = () => { const DatabaseSetup = () => {
const mode = useStoreState((state) => state.theme.mode) const mode = useStoreState((state) => state.theme.mode)
const isDark = mode === 'dark' const isDark = mode === 'dark'
const [logs, setLogs] = useState<LogLine[]>([]) const [logs, setLogs] = useState<LogLine[]>([])
const [status, setStatus] = useState<MigrationStatus>('idle') const [status, setStatus] = useState<MigrationStatus>('idle')
const [pollCountdown, setPollCountdown] = useState(0) const [pollCountdown, setPollCountdown] = useState(0)
const [dbExists, setDbExists] = useState<boolean | null>(null) const [setupStatus, setSetupStatus] = useState<SetupStatusDto | null>(null)
const [setupPassword, setSetupPassword] = useState('') const [setupPassword, setSetupPassword] = useState('')
const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false) const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false)
const logEndRef = useRef<HTMLDivElement>(null) const logEndRef = useRef<HTMLDivElement>(null)
const pollCancelRef = useRef<(() => void) | null>(null) const pollCancelRef = useRef<(() => void) | null>(null)
const abortRef = useRef<AbortController | null>(null)
const dbExists = setupStatus?.dbExists ?? null
// Auto-scroll to bottom when new logs arrive // Auto-scroll to bottom when new logs arrive
useEffect(() => { useEffect(() => {
logEndRef.current?.scrollIntoView({ behavior: 'smooth' }) logEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [logs]) }, [logs])
// Check DB existence on mount // Check DB status on mount
useEffect(() => { useEffect(() => {
getSetupStatus() getSetupStatus()
.then((res) => setDbExists(res.data.dbExists)) .then((res) => setSetupStatus(res.data))
.catch(() => setDbExists(false)) .catch(() => setSetupStatus(null))
}, []) }, [])
// Cleanup on component unmount // Cleanup on component unmount
@ -115,6 +64,8 @@ const DatabaseSetup = () => {
return () => { return () => {
pollCancelRef.current?.() pollCancelRef.current?.()
pollCancelRef.current = null pollCancelRef.current = null
abortRef.current?.abort()
abortRef.current = null
} }
}, []) }, [])
@ -149,82 +100,28 @@ const DatabaseSetup = () => {
setIsPasswordDialogOpen(false) setIsPasswordDialogOpen(false)
addLog('info', 'Starting migration...') addLog('info', 'Starting migration...')
const url = getMigrateUrl()
const abortController = new AbortController() const abortController = new AbortController()
abortRef.current = abortController
const parseChunk = (chunk: string) => { await streamSetupMigrate({
// SSE format: "data: {...}\n\n" setupPassword,
const lines = chunk.split('\n') signal: abortController.signal,
for (const line of lines) { onLog: (entry) => addLog(entry.level, entry.message),
const trimmed = line.trim() onSuccess: () => {
if (!trimmed.startsWith('data:')) continue setStatus('success')
const raw = trimmed.slice(5).trim() pollUntilReady()
if (!raw) continue },
try { onRestart: () => pollUntilReady(),
const data = JSON.parse(raw) as { level?: string; message?: string } onError: (error) => {
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') {
setStatus((prev) => (prev === 'running' ? 'error' : prev)) 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 ( return (
@ -343,7 +240,11 @@ const DatabaseSetup = () => {
)} )}
> >
<div className={classNames('text-xs', isDark ? 'text-gray-400' : 'text-gray-500')}> <div className={classNames('text-xs', isDark ? 'text-gray-400' : 'text-gray-500')}>
{status === 'idle' && dbExists === true && 'Database already exists. Migration is not required.'} {status === 'idle' && dbExists === true && (
setupStatus?.pendingMigrationCount
? `Database is online with ${setupStatus.pendingMigrationCount} pending migration(s).`
: 'Database is online and up to date. Migration is not required here.'
)}
{status === 'idle' && dbExists === false && 'Database not found. Press the button to start migration.'} {status === 'idle' && dbExists === false && 'Database not found. Press the button to start migration.'}
{status === 'idle' && dbExists === null && 'Checking database status...'} {status === 'idle' && dbExists === null && 'Checking database status...'}
{status === 'running' && 'Please wait, migration is in progress...'} {status === 'running' && 'Please wait, migration is in progress...'}
@ -454,7 +355,9 @@ const DatabaseSetup = () => {
</Dialog> </Dialog>
<p className={classNames('mt-6 text-xs', isDark ? 'text-gray-600' : 'text-gray-400')}> <p className={classNames('mt-6 text-xs', isDark ? 'text-gray-600' : 'text-gray-400')}>
This page is only visible when the database does not exist. {dbExists === true
? 'The database is online. Use the DB Migrate action inside the application for routine migrations.'
: 'The database is not reachable. Migration requires the setup password.'}
</p> </p>
</div> </div>
) )