Claude güncellemesi Setup ve Migration
This commit is contained in:
parent
56b74b85a5
commit
a01ce1533a
34 changed files with 1670 additions and 743 deletions
|
|
@ -1,6 +1,7 @@
|
|||
using Sozsoft.Platform.Localization;
|
||||
using Volo.Abp.Authorization.Permissions;
|
||||
using Volo.Abp.Localization;
|
||||
using Volo.Abp.MultiTenancy;
|
||||
|
||||
namespace Sozsoft.Platform.Permissions;
|
||||
|
||||
|
|
@ -10,9 +11,23 @@ public class PlatformPermissionDefinitionProvider : PermissionDefinitionProvider
|
|||
{
|
||||
PermissionRemoveGroup(context);
|
||||
|
||||
//var myGroup = context.AddGroup(PlatformPermissions.GroupName);
|
||||
//Define your own permissions here. Example:
|
||||
//var perm = myGroup.AddPermission("hikaye_permission", L("Permission:MyPermission1"));
|
||||
DefineSetupPermissions(context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kurulum / veritabanı bakım yetkileri. <see cref="MultiTenancySides.Host"/> ile
|
||||
/// işaretlendiği için tenant kullanıcılarına hiç görünmez ve verilemez.
|
||||
/// </summary>
|
||||
private void DefineSetupPermissions(IPermissionDefinitionContext context)
|
||||
{
|
||||
var setupGroup = context.AddGroup(
|
||||
PlatformPermissions.GroupName,
|
||||
L("Permission:Setup"));
|
||||
|
||||
setupGroup.AddPermission(
|
||||
PlatformPermissions.Setup.Migrate,
|
||||
L("Permission:Setup.Migrate"),
|
||||
MultiTenancySides.Host);
|
||||
}
|
||||
|
||||
private static LocalizableString L(string name)
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
@ -3618,6 +3618,18 @@
|
|||
"en": "Are you sure you want to start database migration? Please make sure you have a backup before proceeding.",
|
||||
"tr": "Veritabanı geçişini başlatmak istediğinizden emin misiniz? Lütfen devam etmeden önce bir yedekleme yaptığınızdan emin olun."
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "Permission:Setup",
|
||||
"en": "Setup & Database Maintenance",
|
||||
"tr": "Kurulum ve Veritabanı Bakımı"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "Permission:Setup.Migrate",
|
||||
"en": "Run database migration",
|
||||
"tr": "Veritabanı geçişi çalıştırma"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ClearRedisCache.Message",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ using IdentityRole = Volo.Abp.Identity.IdentityRole;
|
|||
using IdentityUser = Volo.Abp.Identity.IdentityUser;
|
||||
using Volo.Abp.Domain.Repositories;
|
||||
using Sozsoft.Platform.Entities;
|
||||
using Sozsoft.Platform.Permissions;
|
||||
using Volo.Abp.TenantManagement;
|
||||
|
||||
namespace Sozsoft.Platform.Data.Seeds;
|
||||
|
|
@ -198,6 +199,28 @@ public class PlatformIdentityDataSeeder : IdentityDataSeeder
|
|||
]);
|
||||
}
|
||||
|
||||
/* Kurulum/migration yetkisi yalnızca host admin rolüne verilir.
|
||||
Yukarıdaki blok sadece ilk kurulumda çalıştığı için burada idempotent olarak eklenir. */
|
||||
if (tenantId == null)
|
||||
{
|
||||
var migrateGrant = await _permissionGrantRepository.FindAsync(
|
||||
PlatformPermissions.Setup.Migrate,
|
||||
"R",
|
||||
PlatformConsts.AbpIdentity.User.AdminRoleName);
|
||||
|
||||
if (migrateGrant == null)
|
||||
{
|
||||
await _permissionGrantRepository.InsertAsync(
|
||||
new PermissionGrant(
|
||||
Guid.NewGuid(),
|
||||
PlatformPermissions.Setup.Migrate,
|
||||
"R",
|
||||
PlatformConsts.AbpIdentity.User.AdminRoleName,
|
||||
tenantId),
|
||||
autoSave: true);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,204 +1,261 @@
|
|||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Sozsoft.Platform.DbStartup;
|
||||
using Sozsoft.Platform.EntityFrameworkCore;
|
||||
using Sozsoft.Platform.Permissions;
|
||||
using Volo.Abp.DistributedLocking;
|
||||
using Volo.Abp.EntityFrameworkCore;
|
||||
using Volo.Abp.MultiTenancy;
|
||||
using Volo.Abp.Uow;
|
||||
|
||||
namespace Sozsoft.Platform.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// DB hazır olduğunda bile /setup sayfasından migration çalıştırmaya olanak tanır.
|
||||
/// Canlı sistemde (tam ABP stack ayaktayken) veritabanı durumunu raporlar ve
|
||||
/// yetkili host kullanıcısının migration tetiklemesine izin verir.
|
||||
///
|
||||
/// Veritabanı hiç yokken bu controller çalışmaz; o durumda
|
||||
/// <see cref="DbStartup.SetupAppRunner"/> içindeki minimal uygulama devrededir.
|
||||
/// İki yol da <see cref="SetupMigrationRunner"/> üzerinden aynı migration akışını kullanır.
|
||||
/// </summary>
|
||||
[Route("api/setup")]
|
||||
[Authorize(Roles = "admin")]
|
||||
public class SetupController : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Yeni izin verilmemiş kurulumlarda kilitlenmemek için tanınan geriye dönük rol.
|
||||
/// <see cref="PlatformPermissions.Setup.Migrate"/> rollere atandıktan sonra kaldırılabilir.
|
||||
/// </summary>
|
||||
private const string LegacyAdminRoleName = "admin";
|
||||
|
||||
private const int DistributedLockTimeoutSeconds = 5;
|
||||
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IHostEnvironment _env;
|
||||
private readonly IHostApplicationLifetime _lifetime;
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly ICurrentTenant _currentTenant;
|
||||
private readonly IAbpDistributedLock _distributedLock;
|
||||
private readonly IUnitOfWorkManager _unitOfWorkManager;
|
||||
private readonly BackgroundWorkerInitializer _backgroundWorkerInitializer;
|
||||
private readonly AbpBackgroundWorkerRecurringJobInitializer _abpBackgroundWorkerRecurringJobInitializer;
|
||||
private readonly ILogger<SetupController> _logger;
|
||||
|
||||
public SetupController(
|
||||
IConfiguration configuration,
|
||||
IHostEnvironment env,
|
||||
IHostApplicationLifetime lifetime,
|
||||
IAuthorizationService authorizationService,
|
||||
ICurrentTenant currentTenant,
|
||||
IAbpDistributedLock distributedLock,
|
||||
IUnitOfWorkManager unitOfWorkManager,
|
||||
BackgroundWorkerInitializer backgroundWorkerInitializer,
|
||||
AbpBackgroundWorkerRecurringJobInitializer abpBackgroundWorkerRecurringJobInitializer)
|
||||
AbpBackgroundWorkerRecurringJobInitializer abpBackgroundWorkerRecurringJobInitializer,
|
||||
ILogger<SetupController> logger)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_env = env;
|
||||
_lifetime = lifetime;
|
||||
_authorizationService = authorizationService;
|
||||
_currentTenant = currentTenant;
|
||||
_distributedLock = distributedLock;
|
||||
_unitOfWorkManager = unitOfWorkManager;
|
||||
_backgroundWorkerInitializer = backgroundWorkerInitializer;
|
||||
_abpBackgroundWorkerRecurringJobInitializer = abpBackgroundWorkerRecurringJobInitializer;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Veritabanı durumu. Anonim çağırana yalnızca "hazır mı" bilgisi döner;
|
||||
/// bekleyen migration sayısı ve hata detayı sadece yetkili host kullanıcısına verilir.
|
||||
/// </summary>
|
||||
[HttpGet("application-status")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult Status()
|
||||
public async Task<SetupStatusDto> StatusAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(new { dbExists = SetupAppRunner.DatabaseIsReady(_configuration) });
|
||||
var readiness = DatabaseReadinessChecker.GetStatus(_configuration);
|
||||
var canMigrate = await CanMigrateAsync();
|
||||
|
||||
if (canMigrate && readiness.IsReady)
|
||||
{
|
||||
readiness = readiness.WithPendingMigrations(
|
||||
await GetPendingMigrationCountAsync(cancellationToken));
|
||||
}
|
||||
|
||||
return SetupStatusDto.From(readiness, includeDiagnostics: canMigrate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Migration + seed çalıştırır ve logları SSE olarak akıtır.
|
||||
///
|
||||
/// Yetki: veritabanı ayaktayken host tarafında <see cref="PlatformPermissions.Setup.Migrate"/>
|
||||
/// izni (veya geriye dönük olarak admin rolü); veritabanı yokken kurulum parolası.
|
||||
/// </summary>
|
||||
[HttpPost("migrate")]
|
||||
[AllowAnonymous]
|
||||
public async Task Migrate(CancellationToken ct)
|
||||
public async Task MigrateAsync()
|
||||
{
|
||||
var databaseIsReady = SetupAppRunner.DatabaseIsReady(_configuration);
|
||||
if (databaseIsReady)
|
||||
{
|
||||
if (User?.IsInRole("admin") != true)
|
||||
{
|
||||
Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
return;
|
||||
}
|
||||
}
|
||||
else if (!SetupAppRunner.IsValidSetupPassword(_configuration, Request.Headers))
|
||||
var (authorized, denialReason) = await AuthorizeMigrateRequestAsync();
|
||||
if (!authorized)
|
||||
{
|
||||
_logger.LogWarning("Database migration request denied: {Reason}", denialReason);
|
||||
Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
Response.ContentType = "text/plain; charset=utf-8";
|
||||
await Response.WriteAsync(denialReason);
|
||||
return;
|
||||
}
|
||||
|
||||
Response.ContentType = "text/event-stream; charset=utf-8";
|
||||
Response.Headers["Cache-Control"] = "no-cache, no-store";
|
||||
Response.Headers["X-Accel-Buffering"] = "no";
|
||||
await Response.Body.FlushAsync(ct);
|
||||
var stream = new SetupEventStream(Response);
|
||||
await stream.StartAsync();
|
||||
|
||||
async Task Send(string level, string message)
|
||||
// Çok örnekli (multi-instance) dağıtımlarda aynı anda tek migration çalışsın.
|
||||
await using var lockHandle = await _distributedLock.TryAcquireAsync(
|
||||
"Sozsoft.Platform.SetupMigration",
|
||||
TimeSpan.FromSeconds(DistributedLockTimeoutSeconds),
|
||||
_lifetime.ApplicationStopping);
|
||||
|
||||
if (lockHandle is null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(new { level, message });
|
||||
await Response.WriteAsync($"data: {payload}\n\n", ct);
|
||||
await Response.Body.FlushAsync(ct);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
var migratorPath = _configuration["Setup:MigratorPath"]
|
||||
?? Path.GetFullPath(Path.Combine(_env.ContentRootPath, "..", "Sozsoft.Platform.DbMigrator"));
|
||||
|
||||
await Send("info", "Database migration and seeding are being initiated...");
|
||||
await Send("info", $"Migrator path: {migratorPath}");
|
||||
|
||||
var extraArgs = _configuration["Setup:MigratorArgs"] ?? "--Seed=true";
|
||||
|
||||
string fileName;
|
||||
string arguments;
|
||||
string workingDirectory;
|
||||
|
||||
if (migratorPath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) && System.IO.File.Exists(migratorPath))
|
||||
{
|
||||
fileName = "dotnet";
|
||||
arguments = $"\"{migratorPath}\" {extraArgs}";
|
||||
workingDirectory = Path.GetDirectoryName(migratorPath)!;
|
||||
}
|
||||
else if (Directory.Exists(migratorPath))
|
||||
{
|
||||
var dllFiles = Directory.GetFiles(migratorPath, "*.DbMigrator.dll", SearchOption.TopDirectoryOnly);
|
||||
if (dllFiles.Length == 0)
|
||||
dllFiles = Directory.GetFiles(migratorPath, "*Migrator*.dll", SearchOption.TopDirectoryOnly);
|
||||
|
||||
if (dllFiles.Length > 0)
|
||||
{
|
||||
fileName = "dotnet";
|
||||
arguments = $"\"{dllFiles[0]}\" {extraArgs}";
|
||||
workingDirectory = migratorPath;
|
||||
}
|
||||
else
|
||||
{
|
||||
fileName = "dotnet";
|
||||
arguments = $"run --project \"{migratorPath}\" -- {extraArgs}";
|
||||
workingDirectory = migratorPath;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await Send("error", $"Migrator path not found or invalid: {migratorPath}");
|
||||
await Send("done", "Failed.");
|
||||
await stream.ErrorAsync("Another migration is already in progress. Please try again later.");
|
||||
await stream.DoneAsync("Failed.");
|
||||
return;
|
||||
}
|
||||
|
||||
await Send("info", $"Running: {fileName} {arguments}");
|
||||
_logger.LogWarning(
|
||||
"Database migration triggered via /api/setup/migrate by user {UserId}.",
|
||||
User?.Identity?.Name ?? "(setup password)");
|
||||
|
||||
Process? process = null;
|
||||
// ApplicationStopping kullanılıyor: istemci sekmesini kapatsa da migration tamamlanır.
|
||||
var outcome = await SetupMigrationRunner.ExecuteAsync(
|
||||
_configuration, _env.ContentRootPath, stream, _lifetime.ApplicationStopping);
|
||||
|
||||
if (outcome != MigrationOutcome.Succeeded)
|
||||
{
|
||||
_logger.LogWarning("Database migration finished with outcome {Outcome}.", outcome);
|
||||
return;
|
||||
}
|
||||
|
||||
await stream.InfoAsync("Migration and seed completed successfully.");
|
||||
|
||||
// Yeni/değişen tanımlar Hangfire'a yansısın diye worker'lar tazelenir.
|
||||
try
|
||||
{
|
||||
process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = fileName,
|
||||
Arguments = arguments,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = workingDirectory,
|
||||
}
|
||||
};
|
||||
|
||||
process.Start();
|
||||
|
||||
async Task ReadStream(StreamReader reader, string level)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (await reader.ReadLineAsync(ct) is { } line)
|
||||
{
|
||||
await Send(level, line);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
await Task.WhenAll(
|
||||
ReadStream(process.StandardOutput, "info"),
|
||||
ReadStream(process.StandardError, "warn"));
|
||||
|
||||
await process.WaitForExitAsync(ct);
|
||||
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
await Send("info", "Migration and seed completed successfully.");
|
||||
|
||||
try
|
||||
{
|
||||
await Send("info", "Loading background workers into Hangfire...");
|
||||
await _abpBackgroundWorkerRecurringJobInitializer.RunAsync(ct);
|
||||
await _backgroundWorkerInitializer.RunAsync();
|
||||
await Send("success", "Background workers loaded successfully.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Send("error", $"Background workers could not be loaded: {ex.Message}");
|
||||
await Send("done", "Failed.");
|
||||
return;
|
||||
}
|
||||
|
||||
await Send("done", "Completed.");
|
||||
}
|
||||
else
|
||||
{
|
||||
await Send("error", $"Migration failed. Exit code: {process.ExitCode}");
|
||||
await Send("done", "Failed.");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
await Send("warn", "Migration request was canceled.");
|
||||
await stream.InfoAsync("Loading background workers into Hangfire...");
|
||||
await _abpBackgroundWorkerRecurringJobInitializer.RunAsync(_lifetime.ApplicationStopping);
|
||||
await _backgroundWorkerInitializer.RunAsync();
|
||||
await stream.SuccessAsync("Background workers loaded successfully.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Send("error", $"Migration failed: {ex.Message}");
|
||||
await Send("done", "Failed.");
|
||||
_logger.LogError(ex, "Background workers could not be reloaded after migration.");
|
||||
await stream.ErrorAsync($"Background workers could not be loaded: {ex.Message}");
|
||||
await stream.DoneAsync("Failed.");
|
||||
return;
|
||||
}
|
||||
finally
|
||||
|
||||
await stream.DoneAsync("Completed.");
|
||||
}
|
||||
|
||||
// Yetkilendirme
|
||||
|
||||
/// <summary>
|
||||
/// İki geçerli kimlik yolu vardır:
|
||||
///
|
||||
/// 1. <c>X-Setup-Password</c> header'ı — veritabanına hiç ihtiyaç duymaz, bu yüzden
|
||||
/// veritabanı düşmüşken de çalışır.
|
||||
/// 2. Oturum açmış host kullanıcısı + <see cref="PlatformPermissions.Setup.Migrate"/> izni —
|
||||
/// izin kontrolü veritabanı okuması gerektirdiği için yalnızca veritabanı ayaktayken
|
||||
/// (örn. yalnızca bekleyen migration varken) kullanılabilir.
|
||||
///
|
||||
/// Reddetme nedeni çağırana döndürülür; aksi halde "yetkisiz" hatası teşhis edilemez oluyordu.
|
||||
/// Nedenler yapılandırma/izin adından fazlasını sızdırmaz.
|
||||
/// </summary>
|
||||
private async Task<(bool Authorized, string Reason)> AuthorizeMigrateRequestAsync()
|
||||
{
|
||||
if (Request.Headers.ContainsKey(SetupPasswordValidator.HeaderName))
|
||||
{
|
||||
process?.Dispose();
|
||||
if (!SetupPasswordValidator.IsConfigured(_configuration))
|
||||
{
|
||||
return (false, "Setup password is not configured on the server (Setup:MigrationPassword).");
|
||||
}
|
||||
|
||||
return SetupPasswordValidator.IsValid(_configuration, Request.Headers)
|
||||
? (true, string.Empty)
|
||||
: (false, "Invalid setup password.");
|
||||
}
|
||||
|
||||
if (!DatabaseReadinessChecker.GetStatus(_configuration).IsReady)
|
||||
{
|
||||
return (false,
|
||||
"The database is not available, so permissions cannot be verified. " +
|
||||
$"Send the {SetupPasswordValidator.HeaderName} header to run migration.");
|
||||
}
|
||||
|
||||
if (User?.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
return (false, "Authentication is required to run database migration.");
|
||||
}
|
||||
|
||||
if (_currentTenant.Id is not null)
|
||||
{
|
||||
return (false, "Database migration can only be run by a host user.");
|
||||
}
|
||||
|
||||
return await CanMigrateAsync()
|
||||
? (true, string.Empty)
|
||||
: (false, $"The '{PlatformPermissions.Setup.Migrate}' permission is required.");
|
||||
}
|
||||
|
||||
private async Task<bool> CanMigrateAsync()
|
||||
{
|
||||
if (User?.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Host veritabanına migration uygulanır — tenant kullanıcıları tetikleyemez.
|
||||
if (_currentTenant.Id is not null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((await _authorizationService.AuthorizeAsync(User, PlatformPermissions.Setup.Migrate)).Succeeded)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return User.IsInRole(LegacyAdminRoleName);
|
||||
}
|
||||
|
||||
// Bekleyen migration sayısı
|
||||
|
||||
private async Task<int?> GetPendingMigrationCountAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var uow = _unitOfWorkManager.Begin(requiresNew: true, isTransactional: false);
|
||||
|
||||
var dbContext = await uow.ServiceProvider
|
||||
.GetRequiredService<IDbContextProvider<PlatformDbContext>>()
|
||||
.GetDbContextAsync();
|
||||
|
||||
var pending = await dbContext.Database.GetPendingMigrationsAsync(cancellationToken);
|
||||
return pending.Count();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Bu bilgi opsiyonel — alınamıyorsa durum raporu yine de dönmeli.
|
||||
_logger.LogDebug(ex, "Pending migration count could not be determined.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,182 +1,41 @@
|
|||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Npgsql;
|
||||
using Serilog;
|
||||
using static Sozsoft.Settings.SettingsConsts;
|
||||
|
||||
namespace Sozsoft.Platform;
|
||||
namespace Sozsoft.Platform.DbStartup;
|
||||
|
||||
/// <summary>
|
||||
/// Veritabanı henüz hazır değilken çalışan minimal kurulum uygulaması.
|
||||
/// Tam ABP stack yüklemez; sadece /api/setup/* endpointlerini sunar.
|
||||
///
|
||||
/// Migration tamamlandığında uygulama kendini durdurur; process yöneticisi (systemd, IIS,
|
||||
/// container orchestrator) yeniden başlattığında <see cref="Program"/> artık DB'yi hazır
|
||||
/// bulur ve tam ABP stack ile açılır.
|
||||
/// </summary>
|
||||
internal static class SetupAppRunner
|
||||
{
|
||||
private const string SetupPasswordHeaderName = "X-Setup-Password";
|
||||
|
||||
// Veritabanı Hazırlık Kontrolü
|
||||
|
||||
/// <summary>
|
||||
/// DB var mı ve AbpRoles tablosu oluşmuş mu diye kontrol eder.
|
||||
/// Boş DB veya bağlantı hatası durumunda false döner.
|
||||
/// </summary>
|
||||
public static bool DatabaseIsReady(IConfiguration configuration)
|
||||
{
|
||||
var connectionString = configuration.GetConnectionString(DefaultDatabaseProvider);
|
||||
if (string.IsNullOrWhiteSpace(connectionString))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
if (DefaultDatabaseProvider == DatabaseProvider.SqlServer)
|
||||
return SqlServerIsReady(connectionString);
|
||||
|
||||
if (DefaultDatabaseProvider == DatabaseProvider.PostgreSql)
|
||||
return PostgreSqlIsReady(connectionString);
|
||||
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning("Veritabanı hazırlık kontrolü başarısız: {Error}", ex.Message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsValidSetupPassword(IConfiguration configuration, IHeaderDictionary headers)
|
||||
{
|
||||
var configuredPassword = configuration["Setup:MigrationPassword"];
|
||||
if (string.IsNullOrWhiteSpace(configuredPassword))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return headers.TryGetValue(SetupPasswordHeaderName, out var providedPassword) &&
|
||||
string.Equals(providedPassword.ToString(), configuredPassword, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static bool SqlServerIsReady(string connectionString)
|
||||
{
|
||||
var csb = new SqlConnectionStringBuilder(connectionString);
|
||||
var dbName = csb.InitialCatalog;
|
||||
if (string.IsNullOrEmpty(dbName))
|
||||
return false;
|
||||
|
||||
// 1) master'a bağlan — DB varlığını kontrol et
|
||||
var masterCsb = new SqlConnectionStringBuilder(connectionString)
|
||||
{
|
||||
InitialCatalog = "master",
|
||||
ConnectTimeout = 8
|
||||
};
|
||||
|
||||
using var masterConn = new SqlConnection(masterCsb.ConnectionString);
|
||||
masterConn.Open();
|
||||
|
||||
using var dbCheck = new SqlCommand(
|
||||
"SELECT COUNT(1) FROM sys.databases WHERE name = @n", masterConn);
|
||||
dbCheck.Parameters.AddWithValue("@n", dbName);
|
||||
if ((int)dbCheck.ExecuteScalar() == 0)
|
||||
return false;
|
||||
|
||||
// 2) Hedef DB'ye bağlan — AbpRoles tablosunun varlığını kontrol et
|
||||
csb.ConnectTimeout = 8;
|
||||
using var dbConn = new SqlConnection(csb.ConnectionString);
|
||||
dbConn.Open();
|
||||
|
||||
using var tableCheck = new SqlCommand(
|
||||
"SELECT COUNT(1) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'AbpRoles'",
|
||||
dbConn);
|
||||
return (int)tableCheck.ExecuteScalar() > 0;
|
||||
}
|
||||
|
||||
private static bool PostgreSqlIsReady(string connectionString)
|
||||
{
|
||||
var csb = new NpgsqlConnectionStringBuilder(connectionString);
|
||||
var dbName = csb.Database;
|
||||
if (string.IsNullOrEmpty(dbName))
|
||||
return false;
|
||||
|
||||
var maintenanceCsb = new NpgsqlConnectionStringBuilder(connectionString)
|
||||
{
|
||||
Database = "postgres",
|
||||
Timeout = 8,
|
||||
CommandTimeout = 8
|
||||
};
|
||||
|
||||
using var maintenanceConn = new NpgsqlConnection(maintenanceCsb.ConnectionString);
|
||||
maintenanceConn.Open();
|
||||
|
||||
using var dbCheck = new NpgsqlCommand(
|
||||
"SELECT COUNT(1) FROM pg_database WHERE datname = @n",
|
||||
maintenanceConn);
|
||||
dbCheck.Parameters.AddWithValue("n", dbName);
|
||||
if (Convert.ToInt32(dbCheck.ExecuteScalar()) == 0)
|
||||
return false;
|
||||
|
||||
csb.Timeout = 8;
|
||||
csb.CommandTimeout = 8;
|
||||
using var dbConn = new NpgsqlConnection(csb.ConnectionString);
|
||||
dbConn.Open();
|
||||
|
||||
using var tableCheck = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
|
||||
AND lower(table_name) = lower('AbpRoles')
|
||||
""",
|
||||
dbConn);
|
||||
return Convert.ToInt32(tableCheck.ExecuteScalar()) > 0;
|
||||
}
|
||||
|
||||
// Minimal Kurulum Uygulaması
|
||||
|
||||
public static async Task<int> RunAsync(string[] args, IConfiguration configuration)
|
||||
{
|
||||
Log.Warning("Veritabanı hazır değil — kurulum modu başlatılıyor.");
|
||||
|
||||
if (!SetupPasswordValidator.IsConfigured(configuration))
|
||||
{
|
||||
Log.Warning(
|
||||
"Setup:MigrationPassword yapılandırılmamış — /api/setup/migrate tüm istekleri reddedecek. " +
|
||||
"Kurulumu tamamlamak için bu değeri tanımlayın.");
|
||||
}
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
var extraOrigins = (configuration["App:CorsOrigins"] ?? "")
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var baseDomain = configuration["App:BaseDomain"]?.Trim();
|
||||
|
||||
builder.Services.AddCors(o => o.AddPolicy("Setup", policy =>
|
||||
policy.AllowAnyHeader()
|
||||
.AllowAnyMethod()
|
||||
.AllowCredentials()
|
||||
.SetIsOriginAllowed(origin =>
|
||||
{
|
||||
if (!Uri.TryCreate(origin, UriKind.Absolute, out var uri)) return false;
|
||||
var host = uri.Host.ToLowerInvariant();
|
||||
|
||||
if (host is "localhost" or "127.0.0.1" or "[::1]")
|
||||
return true;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(baseDomain))
|
||||
{
|
||||
var bd = baseDomain.ToLowerInvariant();
|
||||
if (host == bd || host.EndsWith("." + bd))
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var o in extraOrigins)
|
||||
if (Uri.TryCreate(o, UriKind.Absolute, out var eo) &&
|
||||
eo.Host.Equals(host, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
})));
|
||||
.SetIsOriginAllowed(origin => IsAllowedOrigin(configuration, origin))));
|
||||
|
||||
builder.Host.UseSerilog();
|
||||
|
||||
|
|
@ -184,153 +43,102 @@ internal static class SetupAppRunner
|
|||
app.UseCors("Setup");
|
||||
|
||||
app.MapGet("/api/setup/application-status", (IConfiguration cfg) =>
|
||||
Results.Ok(new { dbExists = DatabaseIsReady(cfg) }));
|
||||
Results.Ok(SetupStatusDto.From(
|
||||
DatabaseReadinessChecker.GetStatus(cfg),
|
||||
// Uygulama henüz servis vermiyor; bağlantı hatasını kurulumu yapana göstermek
|
||||
// teşhis için gereklidir.
|
||||
includeDiagnostics: true,
|
||||
setupMode: true)));
|
||||
|
||||
app.MapPost("/api/setup/migrate", async (IConfiguration cfg, IHostEnvironment env,
|
||||
IHostApplicationLifetime lifetime, HttpContext ctx, CancellationToken ct) =>
|
||||
app.MapPost("/api/setup/migrate", async (
|
||||
IConfiguration cfg,
|
||||
IHostEnvironment env,
|
||||
IHostApplicationLifetime lifetime,
|
||||
HttpContext ctx) =>
|
||||
{
|
||||
if (!IsValidSetupPassword(cfg, ctx.Request.Headers))
|
||||
// Reddetme nedeni ayrıştırılır: "parola yanlış" ile "sunucuda parola hiç
|
||||
// tanımlı değil" tamamen farklı müdahaleler gerektirir.
|
||||
if (!SetupPasswordValidator.IsConfigured(cfg))
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
await ctx.Response.WriteAsync("Invalid setup password.", ct);
|
||||
ctx.Response.ContentType = "text/plain; charset=utf-8";
|
||||
await ctx.Response.WriteAsync(
|
||||
"Setup password is not configured on the server (Setup:MigrationPassword).");
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.Response.ContentType = "text/event-stream; charset=utf-8";
|
||||
ctx.Response.Headers["Cache-Control"] = "no-cache, no-store";
|
||||
ctx.Response.Headers["X-Accel-Buffering"] = "no";
|
||||
await ctx.Response.Body.FlushAsync(ct);
|
||||
|
||||
async Task Send(string level, string message)
|
||||
if (!SetupPasswordValidator.IsValid(cfg, ctx.Request.Headers))
|
||||
{
|
||||
try
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(new { level, message });
|
||||
await ctx.Response.WriteAsync($"data: {payload}\n\n", ct);
|
||||
await ctx.Response.Body.FlushAsync(ct);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
var migratorPath = cfg["Setup:MigratorPath"]
|
||||
?? Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "Sozsoft.Platform.DbMigrator"));
|
||||
|
||||
await Send("info", "Database migration and seeding are being initiated...");
|
||||
await Send("info", $"Migrator path: {migratorPath}");
|
||||
|
||||
var extraArgs = cfg["Setup:MigratorArgs"] ?? "--Seed=true";
|
||||
|
||||
string fileName;
|
||||
string arguments;
|
||||
string workingDirectory;
|
||||
|
||||
if (migratorPath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) && File.Exists(migratorPath))
|
||||
{
|
||||
// Doğrudan DLL yolu verilmiş — "--" separator YOK, doğrudan argüman
|
||||
fileName = "dotnet";
|
||||
arguments = $"\"{migratorPath}\" {extraArgs}";
|
||||
workingDirectory = Path.GetDirectoryName(migratorPath)!;
|
||||
}
|
||||
else if (Directory.Exists(migratorPath))
|
||||
{
|
||||
// Klasör verilmiş — içinde publish edilmiş DLL var mı?
|
||||
var dllFiles = Directory.GetFiles(migratorPath, "*.DbMigrator.dll", SearchOption.TopDirectoryOnly);
|
||||
if (dllFiles.Length == 0)
|
||||
dllFiles = Directory.GetFiles(migratorPath, "*Migrator*.dll", SearchOption.TopDirectoryOnly);
|
||||
|
||||
if (dllFiles.Length > 0)
|
||||
{
|
||||
// Publish çıktısı — SDK gerekmez, "--" separator YOK
|
||||
fileName = "dotnet";
|
||||
arguments = $"\"{dllFiles[0]}\" {extraArgs}";
|
||||
workingDirectory = migratorPath;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Kaynak proje klasörü — geliştirme ortamı, "--" gerekli
|
||||
fileName = "dotnet";
|
||||
arguments = $"run --project \"{migratorPath}\" -- {extraArgs}";
|
||||
workingDirectory = migratorPath;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await Send("error", $"Migrator path not found or invalid: {migratorPath}");
|
||||
await Send("done", "Failed.");
|
||||
ctx.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
ctx.Response.ContentType = "text/plain; charset=utf-8";
|
||||
await ctx.Response.WriteAsync("Invalid setup password.");
|
||||
return;
|
||||
}
|
||||
|
||||
await Send("info", $"Running: {fileName} {arguments}");
|
||||
var stream = new SetupEventStream(ctx.Response);
|
||||
await stream.StartAsync();
|
||||
|
||||
Process? process = null;
|
||||
try
|
||||
// Süreç ömrü istemci isteğine değil uygulama ömrüne bağlı:
|
||||
// kullanıcı sekmeyi kapatsa da migration yarıda kesilmez.
|
||||
var outcome = await SetupMigrationRunner.ExecuteAsync(
|
||||
cfg, env.ContentRootPath, stream, lifetime.ApplicationStopping);
|
||||
|
||||
if (outcome != MigrationOutcome.Succeeded)
|
||||
{
|
||||
process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = fileName,
|
||||
Arguments = arguments,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = workingDirectory,
|
||||
}
|
||||
};
|
||||
|
||||
process.Start();
|
||||
|
||||
async Task ReadStream(StreamReader reader, string level)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (await reader.ReadLineAsync(ct) is { } line)
|
||||
{
|
||||
await Send(level, line);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
await Task.WhenAll(
|
||||
ReadStream(process.StandardOutput, "info"),
|
||||
ReadStream(process.StandardError, "warn"));
|
||||
|
||||
await process.WaitForExitAsync(ct);
|
||||
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
await Send("success", "Migration and seed completed successfully.");
|
||||
await Send("restart", "Application server is restarting...");
|
||||
await Send("done", "Completed.");
|
||||
|
||||
_ = Task.Delay(1500).ContinueWith(_ => lifetime.StopApplication());
|
||||
}
|
||||
else
|
||||
{
|
||||
await Send("error", $"Migration failed. Exit code: {process.ExitCode}");
|
||||
await Send("done", "Failed.");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
await Send("warn", "Migration request was canceled.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Send("error", $"Migration failed: {ex.Message}");
|
||||
await Send("done", "Failed.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
process?.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
await stream.SuccessAsync("Migration and seed completed successfully.");
|
||||
await stream.RestartAsync("Application server is restarting...");
|
||||
await stream.DoneAsync("Completed.");
|
||||
|
||||
// İstemcinin "restart" olayını işleyip poll'a geçmesi için kısa gecikme.
|
||||
_ = Task.Delay(TimeSpan.FromSeconds(1.5))
|
||||
.ContinueWith(_ => lifetime.StopApplication(), TaskScheduler.Default);
|
||||
});
|
||||
|
||||
app.MapFallback(() => Results.StatusCode(503));
|
||||
// Kurulum modunda başka hiçbir endpoint anlamlı değil.
|
||||
app.MapFallback(() => Results.StatusCode(StatusCodes.Status503ServiceUnavailable));
|
||||
|
||||
await app.RunAsync();
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static bool IsAllowedOrigin(IConfiguration configuration, string origin)
|
||||
{
|
||||
if (!Uri.TryCreate(origin, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var host = uri.Host.ToLowerInvariant();
|
||||
if (host is "localhost" or "127.0.0.1" or "[::1]")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var baseDomain = configuration["App:BaseDomain"]?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(baseDomain))
|
||||
{
|
||||
var bd = baseDomain.ToLowerInvariant();
|
||||
if (host == bd || host.EndsWith("." + bd))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
var extraOrigins = (configuration["App:CorsOrigins"] ?? string.Empty)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
foreach (var allowed in extraOrigins)
|
||||
{
|
||||
if (Uri.TryCreate(allowed, UriKind.Absolute, out var allowedUri) &&
|
||||
allowedUri.Host.Equals(host, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -9,7 +9,6 @@ using Sozsoft.Platform.Identity;
|
|||
using Volo.Abp.AspNetCore.Mvc.ApplicationConfigurations;
|
||||
using Volo.Abp.Data;
|
||||
using Volo.Abp.DependencyInjection;
|
||||
using Volo.Abp.Domain.Entities;
|
||||
using Volo.Abp.Domain.Repositories;
|
||||
using Volo.Abp.Identity;
|
||||
using Volo.Abp.Users;
|
||||
|
|
@ -26,13 +25,15 @@ public class PlatformApplicationConfigurationContributor : IApplicationConfigura
|
|||
var configuration = context.ServiceProvider.GetRequiredService<IConfiguration>();
|
||||
context.ApplicationConfiguration.SetProperty("version", configuration.GetValue<string>("App:Version"));
|
||||
|
||||
// GetListAsync boş liste döner, null dönmez — bu yüzden null kontrolü yok.
|
||||
// Veritabanı erişilemezse çağrı exception atar; bu durum istek boru hattında,
|
||||
// buraya gelinmeden 503 ile karşılanır (bkz. PlatformHttpApiHostModule).
|
||||
var routeRepository = context.ServiceProvider.GetRequiredService<IRepository<Route, Guid>>();
|
||||
var items = await routeRepository.GetListAsync() ?? throw new EntityNotFoundException("RecordNotFound");
|
||||
context.ApplicationConfiguration.SetProperty("routes", items);
|
||||
context.ApplicationConfiguration.SetProperty("routes", await routeRepository.GetListAsync());
|
||||
|
||||
var customComponentRepository = context.ServiceProvider.GetRequiredService<IRepository<CustomComponent, Guid>>();
|
||||
var customComponents = await customComponentRepository.GetListAsync() ?? throw new EntityNotFoundException("RecordNotFound");
|
||||
context.ApplicationConfiguration.SetProperty("customComponents", customComponents);
|
||||
context.ApplicationConfiguration.SetProperty(
|
||||
"customComponents", await customComponentRepository.GetListAsync());
|
||||
|
||||
var currentUser = context.ServiceProvider.GetRequiredService<ICurrentUser>();
|
||||
if (currentUser.Id.HasValue)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ using System.Linq;
|
|||
using System.Threading.Tasks;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Sozsoft.Platform.DbStartup;
|
||||
using Volo.Abp;
|
||||
using Volo.Abp.DependencyInjection;
|
||||
using Volo.Abp.Identity;
|
||||
|
|
@ -14,23 +16,29 @@ namespace Sozsoft.Platform.Identity;
|
|||
|
||||
public class PlatformSessionValidationMiddleware : IMiddleware, ITransientDependency
|
||||
{
|
||||
private const string SetupPathPrefix = "/api/setup";
|
||||
private const string SetupStatusPath = "/api/setup/application-status";
|
||||
|
||||
private readonly IIdentitySessionRepository sessionRepository;
|
||||
private readonly ICurrentTenant currentTenant;
|
||||
private readonly IUnitOfWorkManager unitOfWorkManager;
|
||||
private readonly IConfiguration configuration;
|
||||
|
||||
public PlatformSessionValidationMiddleware(
|
||||
IIdentitySessionRepository sessionRepository,
|
||||
ICurrentTenant currentTenant,
|
||||
IUnitOfWorkManager unitOfWorkManager)
|
||||
IUnitOfWorkManager unitOfWorkManager,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
this.sessionRepository = sessionRepository;
|
||||
this.currentTenant = currentTenant;
|
||||
this.unitOfWorkManager = unitOfWorkManager;
|
||||
this.configuration = configuration;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
|
||||
{
|
||||
if (context.Request.Path.StartsWithSegments("/api/setup"))
|
||||
if (ShouldSkipSessionValidation(context))
|
||||
{
|
||||
await next(context);
|
||||
return;
|
||||
|
|
@ -77,6 +85,32 @@ public class PlatformSessionValidationMiddleware : IMiddleware, ITransientDepend
|
|||
await next(context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kurulum endpointleri için oturum doğrulaması ne zaman atlanır.
|
||||
///
|
||||
/// - Durum sorgusu: anonim erişime açık, oturum bilgisine hiç ihtiyaç duymaz.
|
||||
/// - Diğer /api/setup istekleri: yalnızca veritabanı erişilemezken atlanır. O durumda
|
||||
/// oturum zaten doğrulanamaz (kayıtlar veritabanında) ve doğrulamaya çalışmak, tam da
|
||||
/// veritabanını onaracak endpoint'i 503 ile bloke eder. Güvenlik kaybı yoktur: veritabanı
|
||||
/// düşükken migration yalnızca kurulum parolasıyla çalışır, oturum yetki vermez
|
||||
/// (bkz. SetupController.AuthorizeMigrateRequestAsync).
|
||||
///
|
||||
/// Veritabanı ayaktayken kurulum endpointleri de normal oturum doğrulamasından geçer;
|
||||
/// böylece iptal edilmiş bir oturum migration tetikleyemez.
|
||||
/// </summary>
|
||||
private bool ShouldSkipSessionValidation(HttpContext context)
|
||||
{
|
||||
var path = context.Request.Path;
|
||||
|
||||
if (!path.StartsWithSegments(SetupPathPrefix))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return path.StartsWithSegments(SetupStatusPath) ||
|
||||
!DatabaseReadinessChecker.GetStatus(configuration).IsReady;
|
||||
}
|
||||
|
||||
private static Guid? ParseTenantId(string value)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(value) && Guid.TryParse(value, out var id) ? id : null;
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ using Microsoft.AspNetCore.Builder;
|
|||
using Microsoft.AspNetCore.Cors;
|
||||
using Microsoft.AspNetCore.Extensions.DependencyInjection;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Sozsoft.Platform.DbStartup;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.ResponseCompression;
|
||||
using Microsoft.Extensions.Caching.StackExchangeRedis;
|
||||
|
|
@ -506,20 +507,30 @@ public class PlatformHttpApiHostModule : AbpModule
|
|||
app.MapAbpStaticAssets();
|
||||
app.UseRouting();
|
||||
app.UseCors();
|
||||
// Not: /api/setup/application-status artık SetupController tarafından karşılanıyor.
|
||||
// Buradaki kopya middleware kaldırıldı — durum mantığı tek yerde (DatabaseReadinessChecker).
|
||||
|
||||
// Veritabanı erişilemezken uygulama konfigürasyonu üretilemez. İsteği burada,
|
||||
// EF sorguları ve bağlantı zaman aşımları devreye girmeden kesiyoruz: aksi halde
|
||||
// istemci 10+ saniye bekleyip, contributor'ın içinde patlayan bir bağlantı
|
||||
// hatasından türeyen belirsiz bir yanıt alıyordu. Kontrol cache'li, maliyeti yok.
|
||||
app.Use(async (httpContext, next) =>
|
||||
{
|
||||
if (httpContext.Request.Path.StartsWithSegments("/api/setup/application-status"))
|
||||
if (httpContext.Request.Path.StartsWithSegments("/api/abp/application-configuration"))
|
||||
{
|
||||
var configuration = httpContext.RequestServices.GetRequiredService<IConfiguration>();
|
||||
var dbExists = SetupAppRunner.DatabaseIsReady(configuration);
|
||||
|
||||
httpContext.Response.ContentType = "application/json; charset=utf-8";
|
||||
await httpContext.Response.WriteAsJsonAsync(new { dbExists });
|
||||
return;
|
||||
if (!DatabaseReadinessChecker.GetStatus(configuration).IsReady)
|
||||
{
|
||||
httpContext.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
|
||||
httpContext.Response.ContentType = "text/plain; charset=utf-8";
|
||||
await httpContext.Response.WriteAsync("The database is not reachable.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
if (context.Request.Path.StartsWithSegments("/messengerhub") &&
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Sozsoft.Platform.Enums;
|
||||
using Sozsoft.Platform.DbStartup;
|
||||
using Sozsoft.Platform.DynamicServices;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
|
@ -35,12 +36,20 @@ public class Program
|
|||
.WriteTo.Console()
|
||||
.CreateLogger();
|
||||
|
||||
if (!SetupAppRunner.DatabaseIsReady(configuration))
|
||||
if (!DatabaseReadinessChecker.Probe(configuration).IsReady)
|
||||
{
|
||||
var setupResult = await SetupAppRunner.RunAsync(args, configuration);
|
||||
if (setupResult != 0)
|
||||
return setupResult;
|
||||
// Migration başarılı — DB artık hazır, tam ABP başlatmasına geç
|
||||
|
||||
// Kurulum uygulaması durdu — DB gerçekten hazır mı, tekrar doğrula.
|
||||
DatabaseReadinessChecker.Invalidate();
|
||||
if (!DatabaseReadinessChecker.Probe(configuration).IsReady)
|
||||
{
|
||||
Log.Fatal("Kurulum modu sonlandı ancak veritabanı hâlâ hazır değil — uygulama başlatılmıyor.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Log.Warning("Migration tamamlandı — tam uygulama başlatılıyor.");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import useLocale from '@/utils/hooks/useLocale'
|
|||
import { useDynamicRoutes } from '@/routes/dynamicRoutesContext'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { hasSubdomain } from '@/utils/subdomain'
|
||||
import { ROUTES_ENUM } from '@/routes/route.constant'
|
||||
import ServiceUnavailable from '@/views/ServiceUnavailable'
|
||||
|
||||
export type LayoutType =
|
||||
| typeof LAYOUT_TYPE_CLASSIC
|
||||
|
|
@ -78,6 +80,7 @@ class LayoutChunkErrorBoundary extends Component<
|
|||
const Layout = () => {
|
||||
const location = useLocation()
|
||||
const layoutType = useStoreState((s) => s.theme.layout.type) as LayoutType
|
||||
const bootstrapFailed = useStoreState((s) => s.base.common.bootstrapFailed)
|
||||
const { routes, loading } = useDynamicRoutes()
|
||||
const { authenticated } = useAuth()
|
||||
|
||||
|
|
@ -124,6 +127,13 @@ const Layout = () => {
|
|||
return PublicLayout
|
||||
}, [isAdminPath, route, layoutType, authenticated, currentPath])
|
||||
|
||||
// Uygulama konfigürasyonu hiç yüklenemedi. Layout zinciri config'e bağımlı olduğu için
|
||||
// burada, layout seçilmeden önce kesiyoruz. /setup dışarıda bırakılır ki yönetici
|
||||
// müdahale edebilsin.
|
||||
if (bootstrapFailed && currentPath !== ROUTES_ENUM.setup) {
|
||||
return <ServiceUnavailable />
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-auto flex-col h-[100vh]">
|
||||
|
|
|
|||
69
ui/src/components/shared/DbMigrateButton.tsx
Normal file
69
ui/src/components/shared/DbMigrateButton.tsx
Normal 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
|
||||
|
|
@ -17,34 +17,46 @@ let didInit = false
|
|||
|
||||
const Theme = (props: CommonProps) => {
|
||||
const { getConfig } = useStoreActions((a) => a.abpConfig)
|
||||
const { setSetupMode } = useStoreActions((a) => a.base.common)
|
||||
const { setSetupMode, setBootstrapFailed } = useStoreActions((a) => a.base.common)
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
if (!didInit) {
|
||||
didInit = true
|
||||
if (didInit) return
|
||||
didInit = true
|
||||
|
||||
// Direkt /setup'a gelindiyse — hemen setupMode=true yap (loading gate açılsın)
|
||||
if (location.pathname === ROUTES_ENUM.setup) {
|
||||
setSetupMode(true)
|
||||
return
|
||||
// Direkt /setup'a gelindiyse — hemen setupMode=true yap (loading gate açılsın)
|
||||
if (location.pathname === ROUTES_ENUM.setup) {
|
||||
setSetupMode(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Doğrudan config yüklenir. Her sayfa açılışında ayrıca veritabanı probe'u
|
||||
// atılmaz — bu, canlı sistemde gereksiz bir istek ve DB bağlantısıydı.
|
||||
//
|
||||
// silentError: açılıştaki config hatası global hata diyaloğunu tetiklemez;
|
||||
// kurulum modu kararı aşağıda verilir. Aksi halde kullanıcı kurulum sayfasının
|
||||
// üstünde anlamsız bir "503" penceresi görüyordu.
|
||||
getConfig({ silentError: true }).catch(async () => {
|
||||
// Config alınamadı. Sunucu gerçekten kurulum modunda mı, yoksa geçici bir
|
||||
// arıza mı? Yönlendirme SADECE sunucu kurulum modundayken yapılır; böylece
|
||||
// çalışan bir sistemde veritabanı sorunu kullanıcıyı kurulum ekranına düşürmez.
|
||||
try {
|
||||
const { data } = await getSetupStatus()
|
||||
if (data.setupMode) {
|
||||
setSetupMode(true)
|
||||
navigate(ROUTES_ENUM.setup, { replace: true })
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Sunucuya hiç ulaşılamıyor — aşağıdaki bakım ekranı gösterilir.
|
||||
}
|
||||
|
||||
// Veritabanı var mı kontrol et; yoksa setup sayfasına yönlendir
|
||||
getSetupStatus()
|
||||
.then((res) => {
|
||||
if (!res.data.dbExists) {
|
||||
setSetupMode(true)
|
||||
navigate(ROUTES_ENUM.setup, { replace: true })
|
||||
} else {
|
||||
getConfig(false)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
getConfig(false)
|
||||
})
|
||||
}
|
||||
// Sunucu kurulum modunda değil ama uygulama da açılamıyor.
|
||||
// Bu bayrak olmadan loading gate hiç kapanmıyor ve ekran sonsuza kadar
|
||||
// spinner'da kalıyordu.
|
||||
setBootstrapFailed(true)
|
||||
})
|
||||
}, [])
|
||||
|
||||
function getThemeStyle() {
|
||||
|
|
|
|||
|
|
@ -1,2 +1,5 @@
|
|||
export const GLOBAL_SEARCH = 'App.Definitions.GlobalSearch'
|
||||
export const AI_ASSISTANT = 'App.Definitions.AiBot.Asistant'
|
||||
|
||||
/** Veritabanı migration + seed tetikleme yetkisi (host tarafı). */
|
||||
export const DB_MIGRATE = 'App.Setup.Migrate'
|
||||
|
|
|
|||
|
|
@ -1,9 +1,19 @@
|
|||
export type MigrateLogLevel = 'info' | 'warn' | 'error' | 'success' | 'restart' | 'done'
|
||||
|
||||
export interface MigrateLogEntry {
|
||||
level: string
|
||||
level: MigrateLogLevel | string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface SetupStatusDto {
|
||||
/** Uygulamanın tam olarak açılabilmesi için veritabanı hazır mı (katalog + çekirdek şema). */
|
||||
dbExists: boolean
|
||||
error?: string
|
||||
}
|
||||
/**
|
||||
* Sunucu minimal kurulum modunda mı çalışıyor.
|
||||
* /setup'a otomatik yönlendirme SADECE bu değer true iken yapılır.
|
||||
*/
|
||||
setupMode: boolean
|
||||
/** Uygulanmamış EF migration sayısı — yalnızca yetkili kullanıcıya döner. */
|
||||
pendingMigrationCount?: number | null
|
||||
error?: string | null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,9 +69,12 @@ export const DynamicRouter: React.FC = () => {
|
|||
[routes, components],
|
||||
)
|
||||
|
||||
// /setup path'inde loading bekleme — setup route her zaman erişilebilir olmalı
|
||||
if (loading && location.pathname !== '/setup') return <div>Loading...</div>
|
||||
if (error && location.pathname !== '/setup') return <div>Hata: {error}</div>
|
||||
// /setup path'inde loading bekleme — setup route her zaman erişilebilir olmalı.
|
||||
// bootstrapFailed durumu Layouts'ta, layout seçilmeden önce ele alınır.
|
||||
const isSetupPath = location.pathname === ROUTES_ENUM.setup
|
||||
|
||||
if (loading && !isSetupPath) return <div>Loading...</div>
|
||||
if (error && !isSetupPath) return <div>Hata: {error}</div>
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export const useDynamicRoutes = () => {
|
|||
export const DynamicRoutesProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const extraProperties = useStoreState((state) => state.abpConfig?.config?.extraProperties)
|
||||
const setupMode = useStoreState((state) => state.base.common.setupMode)
|
||||
const bootstrapFailed = useStoreState((state) => state.base.common.bootstrapFailed)
|
||||
const [routes, setRoutes] = useState<RouteDto[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
|
@ -49,12 +50,13 @@ export const DynamicRoutesProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||
useEffect(() => {
|
||||
if (extraProperties) {
|
||||
loadRoutesFromConfig()
|
||||
} else if (setupMode) {
|
||||
// Veritabanı mevcut değil — setup modunda loading'i kapat
|
||||
} else if (setupMode || bootstrapFailed) {
|
||||
// Config hiç gelmeyecek (sunucu kurulum modunda veya erişilemiyor).
|
||||
// Loading gate'i kapat ki kullanıcı sonsuz spinner yerine ilgili ekranı görsün.
|
||||
setLoading(false)
|
||||
setRoutes([])
|
||||
}
|
||||
}, [extraProperties, setupMode])
|
||||
}, [extraProperties, setupMode, bootstrapFailed])
|
||||
|
||||
return (
|
||||
<DynamicRoutesContext.Provider value={{ routes, loading, error, reload: loadRoutesFromConfig }}>
|
||||
|
|
|
|||
|
|
@ -54,6 +54,10 @@ export abstract class UiEvalService {
|
|||
}, '::App.KickUser.Message')
|
||||
}
|
||||
|
||||
/**
|
||||
* Canlı sistemde migration tetikler (SQL Query Manager ve Wizard dosya yöneticisi ortak kullanır).
|
||||
* Yetki sunucu tarafında `App.Setup.Migrate` izniyle doğrulanır.
|
||||
*/
|
||||
static ApiDbMigrate = () => {
|
||||
toast.push(
|
||||
<Notification type="info" duration={3000}>
|
||||
|
|
@ -61,13 +65,27 @@ export abstract class UiEvalService {
|
|||
</Notification>,
|
||||
{ placement: 'bottom-end' },
|
||||
)
|
||||
|
||||
openDbMigrateLogPanel()
|
||||
streamSetupMigrate(
|
||||
(entry) => {
|
||||
dispatchMigrateLog(entry)
|
||||
|
||||
let failed = false
|
||||
|
||||
streamSetupMigrate({
|
||||
onLog: dispatchMigrateLog,
|
||||
onError: (err) => {
|
||||
failed = true
|
||||
toast.push(
|
||||
<Notification type="danger" duration={6000}>
|
||||
{UiEvalService.translate('::App.DbMigrate.ErrorMessage')}
|
||||
{`: ${err.message}`}
|
||||
</Notification>,
|
||||
{ placement: 'bottom-end' },
|
||||
)
|
||||
},
|
||||
() => {
|
||||
onFinished: () => {
|
||||
dispatchMigrateDone()
|
||||
if (failed) return
|
||||
|
||||
toast.push(
|
||||
<Notification type="success" duration={5000}>
|
||||
{UiEvalService.translate('::App.DbMigrate.EndMessage')}
|
||||
|
|
@ -75,17 +93,7 @@ export abstract class UiEvalService {
|
|||
{ placement: 'bottom-end' },
|
||||
)
|
||||
},
|
||||
(err) => {
|
||||
dispatchMigrateDone()
|
||||
toast.push(
|
||||
<Notification type="danger" duration={6000}>
|
||||
{UiEvalService.translate('::App.DbMigrate.ErrorMessage')}
|
||||
{err instanceof Error ? `: ${err.message}` : ''}
|
||||
</Notification>,
|
||||
{ placement: 'bottom-end' },
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,10 +8,14 @@ import apiService from './api.service'
|
|||
export const applicationConfigurationUrl = (includeLocalizationResources: boolean) =>
|
||||
`/api/abp/application-configuration?includeLocalizationResources=${includeLocalizationResources}`
|
||||
|
||||
export const getAppConfig = (includeLocalizationResources: boolean) =>
|
||||
export const getAppConfig = (
|
||||
includeLocalizationResources: boolean,
|
||||
options?: { silentError?: boolean },
|
||||
) =>
|
||||
apiService.fetchData<ApplicationConfigurationDto>({
|
||||
method: 'GET',
|
||||
url: applicationConfigurationUrl(includeLocalizationResources),
|
||||
silentError: options?.silentError,
|
||||
})
|
||||
|
||||
export const getLocalizations = ({
|
||||
|
|
|
|||
|
|
@ -129,9 +129,11 @@ platformApiService.interceptors.response.use(
|
|||
platformApiService.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
console.log('Error interceptor')
|
||||
console.error(error)
|
||||
if (!error.silent) {
|
||||
|
||||
// error.silent: interceptor'ın kendisi hatayı çözdü (token yenileme, tenant fallback).
|
||||
// config.silentError: çağıran taraf hatayı kendisi ele alacağını bildirdi.
|
||||
if (!error.silent && !error.config?.silentError) {
|
||||
const { messages } = store.getActions().base
|
||||
messages.addError({
|
||||
id: crypto.randomUUID(),
|
||||
|
|
|
|||
|
|
@ -1,63 +1,215 @@
|
|||
import apiService from './api.service'
|
||||
import { store } from '@/store'
|
||||
import { MigrateLogEntry, SetupStatusDto } from '@/proxy/setup/models'
|
||||
import { applicationConfigurationUrl } from './abpConfig.service'
|
||||
import { MigrateLogEntry, MigrateLogLevel, SetupStatusDto } from '@/proxy/setup/models'
|
||||
|
||||
/**
|
||||
* Sunucunun veritabanı/kurulum durumu.
|
||||
*
|
||||
* Hata durumu her çağıran tarafından ayrıca ele alınır (kurulum moduna geçiş kararı,
|
||||
* durum kartı), bu yüzden global hata diyaloğu bastırılır.
|
||||
*/
|
||||
export const getSetupStatus = () =>
|
||||
apiService.fetchData<SetupStatusDto>({
|
||||
method: 'GET',
|
||||
url: '/api/setup/application-status',
|
||||
silentError: true,
|
||||
})
|
||||
|
||||
export const getMigrateUrl = (): string => {
|
||||
const getMigrateUrl = (): string => {
|
||||
const base = import.meta.env.VITE_API_URL ?? ''
|
||||
return `${base}/api/setup/migrate`
|
||||
}
|
||||
|
||||
export const streamSetupMigrate = async (
|
||||
onLog: (entry: MigrateLogEntry) => void,
|
||||
onDone: () => void,
|
||||
onError: (err: unknown) => void,
|
||||
): Promise<void> => {
|
||||
const token = store.getState().auth.session.token
|
||||
const url = getMigrateUrl()
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
Accept: 'text/event-stream',
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
onError(new Error(`HTTP ${response.status}`))
|
||||
return
|
||||
}
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) {
|
||||
onError(new Error('No response body'))
|
||||
return
|
||||
}
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6)) as MigrateLogEntry
|
||||
onLog(data)
|
||||
} catch { /* parse hatası, atla */ }
|
||||
/**
|
||||
* Sunucu yeniden başlatıldıktan sonra tam ABP stack ile ayağa kalkana kadar
|
||||
* application-configuration endpoint'ini poll eder.
|
||||
*
|
||||
* Arka arkaya iki başarılı yanıt beklenir; tek başarılı yanıt sunucu henüz
|
||||
* tüm modülleri yüklemeden de dönebilir.
|
||||
*
|
||||
* @returns Poll'u iptal eden fonksiyon.
|
||||
*/
|
||||
export const pollUntilServerReady = (
|
||||
onReady: () => void,
|
||||
onAttempt?: (attempt: number) => void,
|
||||
): (() => void) => {
|
||||
const REQUIRED_CONSECUTIVE_SUCCESS = 2
|
||||
const INITIAL_DELAY_MS = 3000
|
||||
const SUCCESS_RETRY_MS = 1000
|
||||
const FAILURE_RETRY_MS = 2000
|
||||
|
||||
let attempt = 0
|
||||
let successCount = 0
|
||||
let timerId: ReturnType<typeof setTimeout> | null = null
|
||||
let cancelled = false
|
||||
|
||||
const tick = async () => {
|
||||
if (cancelled) return
|
||||
|
||||
attempt++
|
||||
onAttempt?.(attempt)
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${import.meta.env.VITE_API_URL ?? ''}${applicationConfigurationUrl(false)}`,
|
||||
{ method: 'GET', headers: { Accept: 'application/json' }, cache: 'no-store' },
|
||||
)
|
||||
|
||||
if (res.status === 200) {
|
||||
const json = await res.json()
|
||||
if (json && typeof json.currentUser === 'object') {
|
||||
successCount++
|
||||
if (successCount >= REQUIRED_CONSECUTIVE_SUCCESS) {
|
||||
onReady()
|
||||
return
|
||||
}
|
||||
timerId = setTimeout(tick, SUCCESS_RETRY_MS)
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* sunucu henüz ayakta değil */
|
||||
}
|
||||
onDone()
|
||||
} catch (err) {
|
||||
onError(err)
|
||||
|
||||
successCount = 0
|
||||
timerId = setTimeout(tick, FAILURE_RETRY_MS)
|
||||
}
|
||||
|
||||
// Sunucunun kapanması için kısa bir bekleme.
|
||||
timerId = setTimeout(tick, INITIAL_DELAY_MS)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (timerId) clearTimeout(timerId)
|
||||
}
|
||||
}
|
||||
|
||||
export interface MigrateStreamOptions {
|
||||
/**
|
||||
* Kurulum parolası. Veritabanı yokken (sunucu kurulum modundayken) zorunludur;
|
||||
* canlı sistemde bunun yerine oturum token'ı ile yetkilendirme yapılır.
|
||||
*/
|
||||
setupPassword?: string
|
||||
signal?: AbortSignal
|
||||
/** Her log satırı için çağrılır. */
|
||||
onLog?: (entry: MigrateLogEntry) => void
|
||||
/** Migration başarıyla tamamlandığında. */
|
||||
onSuccess?: () => void
|
||||
/** Sunucu yeniden başlıyor — istemci hazır olana kadar poll etmeli. */
|
||||
onRestart?: () => void
|
||||
/** Akış hata ile bitti. */
|
||||
onError?: (error: Error) => void
|
||||
/** Başarılı veya başarısız — akış sona erdi. */
|
||||
onFinished?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* /api/setup/migrate SSE akışının tek istemcisi.
|
||||
*
|
||||
* Hem /setup sayfası hem SQL Query Manager hem de Wizard dosya yöneticisi bunu kullanır;
|
||||
* SSE ayrıştırma mantığı yalnızca burada bulunur.
|
||||
*/
|
||||
export const streamSetupMigrate = async (options: MigrateStreamOptions = {}): Promise<void> => {
|
||||
const { setupPassword, signal, onLog, onSuccess, onRestart, onError, onFinished } = options
|
||||
|
||||
// Kurulum parolası kullanılıyorsa oturum token'ı gönderilmez. Sunucu zaten parola
|
||||
// yolunu seçtiğinde token'a bakmaz; token göndermek yalnızca veritabanına bağımlı
|
||||
// ara katmanların (oturum doğrulama, dinamik claim'ler) veritabanı düşükken
|
||||
// devreye girip isteği bloke etmesine yol açar.
|
||||
const token = setupPassword ? null : store.getState().auth.session.token
|
||||
let failed = false
|
||||
|
||||
const fail = (error: Error) => {
|
||||
if (failed) return
|
||||
failed = true
|
||||
onError?.(error)
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(getMigrateUrl(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(setupPassword ? { 'X-Setup-Password': setupPassword } : {}),
|
||||
},
|
||||
signal,
|
||||
})
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
// Sunucu reddetme nedenini gövdede düz metin olarak döner (yanlış parola,
|
||||
// eksik yapılandırma, eksik izin). Bunu yutmak hatayı teşhis edilemez yapıyordu.
|
||||
const serverReason = await response.text().catch(() => '')
|
||||
|
||||
fail(
|
||||
new Error(
|
||||
serverReason.trim() ||
|
||||
(response.status === 403
|
||||
? 'Not authorized to run database migration.'
|
||||
: `Server responded with status ${response.status}`),
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let sawSuccess = false
|
||||
|
||||
const handleEntry = (entry: MigrateLogEntry) => {
|
||||
const level = (entry.level ?? 'info') as MigrateLogLevel
|
||||
|
||||
if (level === 'done') {
|
||||
// Sunucu "done" gönderdi ama öncesinde success/restart gelmediyse akış başarısızdır.
|
||||
if (!sawSuccess) fail(new Error(entry.message || 'Migration failed.'))
|
||||
return
|
||||
}
|
||||
|
||||
onLog?.(entry)
|
||||
|
||||
if (level === 'success') {
|
||||
sawSuccess = true
|
||||
onSuccess?.()
|
||||
} else if (level === 'restart') {
|
||||
sawSuccess = true
|
||||
onRestart?.()
|
||||
} else if (level === 'error') {
|
||||
fail(new Error(entry.message || 'Migration failed.'))
|
||||
}
|
||||
}
|
||||
|
||||
const flushLines = (chunk: string) => {
|
||||
buffer += chunk
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed.startsWith('data:')) continue
|
||||
|
||||
const raw = trimmed.slice(5).trim()
|
||||
if (!raw) continue
|
||||
|
||||
try {
|
||||
handleEntry(JSON.parse(raw) as MigrateLogEntry)
|
||||
} catch {
|
||||
onLog?.({ level: 'info', message: raw })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
flushLines(decoder.decode(value, { stream: true }))
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error)?.name !== 'AbortError') {
|
||||
fail(err instanceof Error ? err : new Error(String(err)))
|
||||
}
|
||||
} finally {
|
||||
onFinished?.()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,10 +13,20 @@ import {
|
|||
|
||||
export type Texts = Record<string, Record<string, string>>
|
||||
|
||||
export interface GetConfigOptions {
|
||||
/** Localization kaynaklarını da zorla yeniden yükle. */
|
||||
reloadTexts?: boolean
|
||||
/** Hata global hata diyaloğunu açmasın — çağıran taraf kendisi ele alır. */
|
||||
silentError?: boolean
|
||||
}
|
||||
|
||||
/** Geriye dönük uyumluluk: boolean payload `reloadTexts` anlamına gelir. */
|
||||
export type GetConfigPayload = boolean | GetConfigOptions
|
||||
|
||||
export interface AbpConfigModel {
|
||||
config?: ApplicationConfigurationDto
|
||||
setConfig: Action<AbpConfigModel, ApplicationConfigurationDto | undefined>
|
||||
getConfig: Thunk<AbpConfigModel, boolean, Injections>
|
||||
getConfig: Thunk<AbpConfigModel, GetConfigPayload, Injections>
|
||||
texts?: Texts
|
||||
setTexts: Action<AbpConfigModel, Texts | undefined>
|
||||
getTexts: Thunk<AbpConfigModel, ApplicationLocalizationRequestDto, Injections>
|
||||
|
|
@ -33,15 +43,18 @@ export const abpConfigModel: AbpConfigModel = {
|
|||
state.config = payload
|
||||
}),
|
||||
getConfig: thunk(async (actions, payload, helpers) => {
|
||||
const { reloadTexts = false, silentError = false } =
|
||||
typeof payload === 'boolean' ? { reloadTexts: payload } : (payload ?? {})
|
||||
|
||||
const service = helpers.injections.abpConfigService
|
||||
const result = await service.getAppConfig(payload)
|
||||
const result = await service.getAppConfig(reloadTexts, { silentError })
|
||||
|
||||
// AppConfig'den gelen culture mevcuttan farklı ise localizationları da al
|
||||
const newCulture = result.data?.localization.currentCulture.cultureName
|
||||
const currentCulture = helpers.getState().config?.localization.currentCulture.cultureName
|
||||
const isCultureDifferent = newCulture && currentCulture !== newCulture
|
||||
const isTextsEmpty = !helpers.getState().texts
|
||||
if (payload || isCultureDifferent || isTextsEmpty) {
|
||||
if (reloadTexts || isCultureDifferent || isTextsEmpty) {
|
||||
await actions.getTexts({
|
||||
cultureName: newCulture ?? currentCulture ?? appConfig.locale,
|
||||
onlyDynamics: false,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,12 @@ export interface BaseStoreModel {
|
|||
common: {
|
||||
currentRouteKey: string
|
||||
tabHasFocus: boolean
|
||||
setupMode: boolean /** Veritabanı mevcut değilse true — setup sayfasına yönlendirme için */
|
||||
setupMode: boolean /** Sunucu kurulum modunda — setup sayfasına yönlendirme için */
|
||||
/**
|
||||
* Açılışta uygulama konfigürasyonu alınamadı ve sunucu kurulum modunda da değil.
|
||||
* Uygulama çalışamaz durumda; kullanıcıya sonsuz spinner yerine bakım ekranı gösterilir.
|
||||
*/
|
||||
bootstrapFailed: boolean
|
||||
}
|
||||
messages: {
|
||||
errors: StoreError[]
|
||||
|
|
@ -26,6 +31,7 @@ export interface BaseStoreActions {
|
|||
setCurrentRouteKey: Action<BaseStoreModel['common'], string>
|
||||
setTabHasFocus: Action<BaseStoreModel['common'], boolean>
|
||||
setSetupMode: Action<BaseStoreModel['common'], boolean>
|
||||
setBootstrapFailed: Action<BaseStoreModel['common'], boolean>
|
||||
}
|
||||
messages: {
|
||||
addError: Action<BaseStoreModel['messages'], StoreError>
|
||||
|
|
@ -38,7 +44,7 @@ export interface BaseStoreActions {
|
|||
export type BaseModel = BaseStoreModel & BaseStoreActions
|
||||
|
||||
const initialState: BaseStoreModel = {
|
||||
common: { currentRouteKey: '', tabHasFocus: false, setupMode: false },
|
||||
common: { currentRouteKey: '', tabHasFocus: false, setupMode: false, bootstrapFailed: false },
|
||||
messages: {
|
||||
errors: [],
|
||||
// success: [],
|
||||
|
|
@ -58,6 +64,9 @@ export const baseModel: BaseModel = {
|
|||
setSetupMode: action((state, payload) => {
|
||||
state.setupMode = payload
|
||||
}),
|
||||
setBootstrapFailed: action((state, payload) => {
|
||||
state.bootstrapFailed = payload
|
||||
}),
|
||||
},
|
||||
messages: {
|
||||
...initialState.messages,
|
||||
|
|
|
|||
14
ui/src/types/axios.d.ts
vendored
Normal file
14
ui/src/types/axios.d.ts
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import 'axios'
|
||||
|
||||
declare module 'axios' {
|
||||
interface AxiosRequestConfig {
|
||||
/**
|
||||
* true ise bu isteğin hatası global hata diyaloğunu açmaz.
|
||||
*
|
||||
* Hatayı çağıran tarafın kendisi ele aldığında kullanılır (örn. uygulama açılışında
|
||||
* config yüklenemediğinde kurulum moduna geçiş kararı). Hata yine de reject edilir;
|
||||
* yalnızca kullanıcıya gösterilen genel bildirim bastırılır.
|
||||
*/
|
||||
silentError?: boolean
|
||||
}
|
||||
}
|
||||
135
ui/src/views/ServiceUnavailable.tsx
Normal file
135
ui/src/views/ServiceUnavailable.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import classNames from 'classnames'
|
||||
import { Helmet } from 'react-helmet'
|
||||
import { Button } from '@/components/ui'
|
||||
import { APP_NAME } from '@/constants/app.constant'
|
||||
import { ROUTES_ENUM } from '@/routes/route.constant'
|
||||
import { pollUntilServerReady } from '@/services/setup.service'
|
||||
import { useStoreState } from '@/store'
|
||||
|
||||
/**
|
||||
* Uygulama konfigürasyonu alınamadığında gösterilir (tipik olarak veritabanı erişilemez
|
||||
* durumdayken).
|
||||
*
|
||||
* Kullanıcı bilinçli olarak kurulum sihirbazına yönlendirilmez — çalışan bir sistemde
|
||||
* geçici bir arıza kimseyi kurulum ekranına düşürmemeli. Bunun yerine durum açıkça
|
||||
* belirtilir, sunucu arka planda izlenir ve toparlandığı anda sayfa kendini yeniler.
|
||||
*/
|
||||
const ServiceUnavailable = () => {
|
||||
const isDark = useStoreState((state) => state.theme.mode) === 'dark'
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
|
||||
// Sunucu toparlandığında kullanıcının sayfayı elle yenilemesi gerekmesin.
|
||||
useEffect(() => {
|
||||
const cancel = pollUntilServerReady(
|
||||
() => window.location.reload(),
|
||||
(currentAttempt) => setAttempt(currentAttempt),
|
||||
)
|
||||
return cancel
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'min-h-screen flex flex-col items-center justify-center p-6 transition-colors',
|
||||
isDark ? 'bg-gray-900 text-gray-100' : 'bg-gray-100 text-gray-900',
|
||||
)}
|
||||
>
|
||||
<Helmet title={`${APP_NAME} — Service unavailable`} />
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
'w-full max-w-lg overflow-hidden rounded-xl border shadow-2xl',
|
||||
isDark ? 'border-gray-700 bg-gray-800' : 'border-gray-200 bg-white',
|
||||
)}
|
||||
>
|
||||
{/* Başlık */}
|
||||
<div
|
||||
className={classNames(
|
||||
'flex items-center gap-3 border-b px-6 py-4',
|
||||
isDark ? 'border-gray-700 bg-gray-700' : 'border-gray-200 bg-gray-50',
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={isDark ? '/img/logo/logo-dark-streamline.png' : '/img/logo/logo-light-streamline.png'}
|
||||
alt={`${APP_NAME} logo`}
|
||||
className="h-8 w-8 object-contain"
|
||||
/>
|
||||
<span className="text-sm font-semibold tracking-wide">{APP_NAME}</span>
|
||||
|
||||
<span
|
||||
className={classNames(
|
||||
'ml-auto inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium uppercase tracking-wider',
|
||||
isDark
|
||||
? 'border-amber-400/25 bg-amber-400/10 text-amber-200'
|
||||
: 'border-amber-500/30 bg-amber-50 text-amber-700',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={classNames(
|
||||
'h-1.5 w-1.5 animate-pulse rounded-full',
|
||||
isDark ? 'bg-amber-300' : 'bg-amber-500',
|
||||
)}
|
||||
/>
|
||||
Offline
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Gövde */}
|
||||
<div className="px-6 py-7">
|
||||
<h5 className={isDark ? 'text-gray-100' : 'text-gray-900'}>
|
||||
Service temporarily unavailable
|
||||
</h5>
|
||||
|
||||
<p className={classNames('mt-3 text-sm leading-relaxed', isDark ? 'text-gray-400' : 'text-gray-600')}>
|
||||
The application configuration could not be loaded. The server is running, but it cannot
|
||||
reach its database right now. Your data is not affected — the application will come back
|
||||
automatically once the database is available again.
|
||||
</p>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
'mt-5 flex items-center gap-2 rounded-lg border px-3 py-2 text-xs',
|
||||
isDark
|
||||
? 'border-gray-700 bg-gray-900 text-gray-400'
|
||||
: 'border-gray-200 bg-gray-50 text-gray-500',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={classNames(
|
||||
'inline-block h-2 w-2 animate-ping rounded-full',
|
||||
isDark ? 'bg-sky-400' : 'bg-sky-500',
|
||||
)}
|
||||
/>
|
||||
{attempt > 0
|
||||
? `Rechecking the connection… (attempt ${attempt})`
|
||||
: 'Waiting to recheck the connection…'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Aksiyonlar */}
|
||||
<div
|
||||
className={classNames(
|
||||
'flex flex-col-reverse items-stretch gap-3 border-t px-6 py-4 sm:flex-row sm:items-center sm:justify-between',
|
||||
isDark ? 'border-gray-700 bg-gray-700' : 'border-gray-200 bg-gray-50',
|
||||
)}
|
||||
>
|
||||
<span className={classNames('text-xs', isDark ? 'text-gray-400' : 'text-gray-500')}>
|
||||
Administrators can run a database migration from the setup page.
|
||||
</span>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => window.location.assign(ROUTES_ENUM.setup)}>
|
||||
Setup page
|
||||
</Button>
|
||||
<Button size="sm" variant="solid" onClick={() => window.location.reload()}>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ServiceUnavailable
|
||||
|
|
@ -3,7 +3,6 @@ import { useNavigate } from 'react-router-dom'
|
|||
import classNames from 'classnames'
|
||||
import { Badge, Button, Input, Notification, toast } from '@/components/ui'
|
||||
import Container from '@/components/shared/Container'
|
||||
import ConfirmDialog from '@/components/shared/ConfirmDialog'
|
||||
import {
|
||||
FaTrash,
|
||||
FaSync,
|
||||
|
|
@ -14,14 +13,13 @@ import {
|
|||
FaEdit,
|
||||
FaCalendarAlt,
|
||||
} from 'react-icons/fa'
|
||||
import { FcAcceptDatabase } from 'react-icons/fc'
|
||||
import DbMigrateButton from '@/components/shared/DbMigrateButton'
|
||||
import { deleteWizardFile, getWizardFiles } from '@/services/wizard.service'
|
||||
import { useCurrentMenuIcon } from '@/utils/hooks/useCurrentMenuIcon'
|
||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||
import { useStoreState } from '@/store/store'
|
||||
import { ROUTES_ENUM } from '@/routes/route.constant'
|
||||
import { WizardFileInfoDto } from '@/proxy/admin/wizard/models'
|
||||
import { UiEvalService } from '@/services/UiEvalService'
|
||||
import navigationIcon from '@/proxy/menus/navigation-icon.config'
|
||||
|
||||
interface ConfirmState {
|
||||
|
|
@ -40,7 +38,6 @@ const WizardFileManager = () => {
|
|||
const [deletingFile, setDeletingFile] = useState<string | null>(null)
|
||||
const [confirm, setConfirm] = useState<ConfirmState | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [showDbMigrateDialog, setShowDbMigrateDialog] = useState(false)
|
||||
const IconComponent = (icon: string) => {
|
||||
return navigationIcon[icon] || FaDatabase // default icon
|
||||
}
|
||||
|
|
@ -140,15 +137,7 @@ const WizardFileManager = () => {
|
|||
>
|
||||
<FaSync />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
icon={<FcAcceptDatabase />}
|
||||
onClick={() => setShowDbMigrateDialog(true)}
|
||||
title={translate('::App.DbMigrate.StartMessage') || 'Run DB Migration'}
|
||||
>
|
||||
<span className="hidden sm:inline">{translate('::ListForms.ListForm.DbMigrate') || 'DB Migrate'}</span>
|
||||
</Button>
|
||||
<DbMigrateButton hideLabelOnMobile />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="solid"
|
||||
|
|
@ -242,26 +231,6 @@ const WizardFileManager = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* DB Migrate Confirmation Dialog */}
|
||||
<ConfirmDialog
|
||||
isOpen={showDbMigrateDialog}
|
||||
type="info"
|
||||
title={translate('::ListForms.ListForm.DbMigrate') || 'DB Migrate'}
|
||||
cancelText={translate('::Cancel')}
|
||||
confirmText={translate('::App.Platform.Execute') || 'Çalıştır'}
|
||||
onCancel={() => setShowDbMigrateDialog(false)}
|
||||
onClose={() => setShowDbMigrateDialog(false)}
|
||||
onConfirm={() => {
|
||||
setShowDbMigrateDialog(false)
|
||||
UiEvalService.ApiDbMigrate()
|
||||
}}
|
||||
>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{translate('::App.DbMigrate.ConfirmMessage') ||
|
||||
'Are you sure you want to start the database migration process?'}
|
||||
</p>
|
||||
</ConfirmDialog>
|
||||
|
||||
{/* Delete Confirm Dialog */}
|
||||
{confirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { lazy, Suspense, useState, useCallback, useEffect, useRef } from 'react'
|
|||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import { Button, Checkbox, Dialog, Notification, toast } from '@/components/ui'
|
||||
import Container from '@/components/shared/Container'
|
||||
import ConfirmDialog from '@/components/shared/ConfirmDialog'
|
||||
import { getDataSources } from '@/services/data-source.service'
|
||||
import type { DataSourceDto } from '@/proxy/data-source'
|
||||
import { DataSourceTypeEnum } from '@/proxy/form/models'
|
||||
|
|
@ -27,8 +26,7 @@ import { Splitter } from '@/components/codeLayout/Splitter'
|
|||
import { Helmet } from 'react-helmet'
|
||||
import { useStoreState } from '@/store/store'
|
||||
import { APP_NAME } from '@/constants/app.constant'
|
||||
import { UiEvalService } from '@/services/UiEvalService'
|
||||
import { FcAcceptDatabase } from 'react-icons/fc'
|
||||
import DbMigrateButton from '@/components/shared/DbMigrateButton'
|
||||
|
||||
const SqlResultsGrid = lazy(() => import('./SqlResultsGrid'))
|
||||
const SqlTableDesignerDialog = lazy(() => import('./SqlTableDesignerDialog'))
|
||||
|
|
@ -92,7 +90,6 @@ const SqlQueryManager = () => {
|
|||
const [selectedExplorerObjects, setSelectedExplorerObjects] = useState<
|
||||
SqlExplorerSelectedObject[]
|
||||
>([])
|
||||
const [showDbMigrateConfirmDialog, setShowDbMigrateConfirmDialog] = useState(false)
|
||||
const [showCopyDialog, setShowCopyDialog] = useState(false)
|
||||
const [copyTargetDataSources, setCopyTargetDataSources] = useState<string[]>([])
|
||||
const [overwriteIfExists, setOverwriteIfExists] = useState(false)
|
||||
|
|
@ -1208,15 +1205,7 @@ GO`,
|
|||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
icon={<FcAcceptDatabase />}
|
||||
onClick={() => setShowDbMigrateConfirmDialog(true)}
|
||||
title={translate('::App.DbMigrate.StartMessage') || 'Run DB Migration'}
|
||||
>
|
||||
{translate('::ListForms.ListForm.DbMigrate') || 'DB Migrate'}
|
||||
</Button>
|
||||
<DbMigrateButton />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
|
|
@ -1380,26 +1369,6 @@ GO`,
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* DB Migrate Confirmation Dialog */}
|
||||
<ConfirmDialog
|
||||
isOpen={showDbMigrateConfirmDialog}
|
||||
type="info"
|
||||
title={translate('::ListForms.ListForm.DbMigrate') || 'DB Migrate'}
|
||||
cancelText={translate('::Cancel')}
|
||||
confirmText={translate('::App.Platform.Execute') || 'Çalıştır'}
|
||||
onCancel={() => setShowDbMigrateConfirmDialog(false)}
|
||||
onClose={() => setShowDbMigrateConfirmDialog(false)}
|
||||
onConfirm={() => {
|
||||
setShowDbMigrateConfirmDialog(false)
|
||||
UiEvalService.ApiDbMigrate()
|
||||
}}
|
||||
>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{translate('::App.DbMigrate.ConfirmMessage') ||
|
||||
'Are you sure you want to start the database migration process?'}
|
||||
</p>
|
||||
</ConfirmDialog>
|
||||
|
||||
<Dialog
|
||||
isOpen={showSqlDataFilesDialog}
|
||||
onClose={() => setShowSqlDataFilesDialog(false)}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import classNames from 'classnames'
|
||||
import { APP_NAME } from '@/constants/app.constant'
|
||||
import { getMigrateUrl, getSetupStatus } from '@/services/setup.service'
|
||||
import { applicationConfigurationUrl } from '@/services/abpConfig.service'
|
||||
import { getSetupStatus, pollUntilServerReady, streamSetupMigrate } from '@/services/setup.service'
|
||||
import { MigrateLogLevel, SetupStatusDto } from '@/proxy/setup/models'
|
||||
import { Button, Dialog } from '@/components/ui'
|
||||
import PasswordInput from '@/components/shared/PasswordInput'
|
||||
import { useStoreState } from '@/store'
|
||||
|
||||
interface LogLine {
|
||||
level: 'info' | 'warn' | 'error' | 'success' | 'restart' | 'done'
|
||||
level: MigrateLogLevel | string
|
||||
message: string
|
||||
}
|
||||
|
||||
|
|
@ -32,82 +32,31 @@ const lightLevelClass: Record<string, string> = {
|
|||
done: 'text-blue-600',
|
||||
}
|
||||
|
||||
/**
|
||||
* Sunucu yeniden başlayana kadar ABP config endpoint'ini poll eder.
|
||||
* Arka arkaya 2 başarılı yanıt alındığında onReady çağrılır.
|
||||
* @param onReady Sunucu hazır olduğunda çağrılacak callback
|
||||
* @param onAttempt Her denemede kaçıncı deneme olduğunu bildiren opsiyonel callback
|
||||
*/
|
||||
const pollUntilServerReady = (onReady: () => void, onAttempt?: (attempt: number) => void): (() => void) => {
|
||||
const REQUIRED_SUCCESS = 2
|
||||
let attempt = 0
|
||||
let successCount = 0
|
||||
let timerId: ReturnType<typeof setTimeout> | null = null
|
||||
let cancelled = false
|
||||
|
||||
const tick = async () => {
|
||||
if (cancelled) return
|
||||
attempt++
|
||||
onAttempt?.(attempt)
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${import.meta.env.VITE_API_URL ?? ''}${applicationConfigurationUrl(false)}`,
|
||||
{ method: 'GET', headers: { Accept: 'application/json' }, cache: 'no-store' },
|
||||
)
|
||||
if (res.status === 200) {
|
||||
try {
|
||||
const json = await res.json()
|
||||
if (json && typeof json.currentUser === 'object') {
|
||||
successCount++
|
||||
if (successCount >= REQUIRED_SUCCESS) {
|
||||
onReady()
|
||||
return
|
||||
}
|
||||
timerId = setTimeout(tick, 1000)
|
||||
return
|
||||
}
|
||||
} catch { /* parse hatası */ }
|
||||
}
|
||||
successCount = 0
|
||||
} catch {
|
||||
successCount = 0
|
||||
}
|
||||
timerId = setTimeout(tick, 2000)
|
||||
}
|
||||
|
||||
// İlk denemeden önce kısa bekleme (sunucunun kapanma süresi)
|
||||
timerId = setTimeout(tick, 3000)
|
||||
|
||||
// İptal fonksiyonu döner
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (timerId) clearTimeout(timerId)
|
||||
}
|
||||
}
|
||||
|
||||
const DatabaseSetup = () => {
|
||||
const mode = useStoreState((state) => state.theme.mode)
|
||||
const isDark = mode === 'dark'
|
||||
const [logs, setLogs] = useState<LogLine[]>([])
|
||||
const [status, setStatus] = useState<MigrationStatus>('idle')
|
||||
const [pollCountdown, setPollCountdown] = useState(0)
|
||||
const [dbExists, setDbExists] = useState<boolean | null>(null)
|
||||
const [setupStatus, setSetupStatus] = useState<SetupStatusDto | null>(null)
|
||||
const [setupPassword, setSetupPassword] = useState('')
|
||||
const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false)
|
||||
const logEndRef = useRef<HTMLDivElement>(null)
|
||||
const pollCancelRef = useRef<(() => void) | null>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const dbExists = setupStatus?.dbExists ?? null
|
||||
|
||||
// Auto-scroll to bottom when new logs arrive
|
||||
useEffect(() => {
|
||||
logEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [logs])
|
||||
|
||||
// Check DB existence on mount
|
||||
// Check DB status on mount
|
||||
useEffect(() => {
|
||||
getSetupStatus()
|
||||
.then((res) => setDbExists(res.data.dbExists))
|
||||
.catch(() => setDbExists(false))
|
||||
.then((res) => setSetupStatus(res.data))
|
||||
.catch(() => setSetupStatus(null))
|
||||
}, [])
|
||||
|
||||
// Cleanup on component unmount
|
||||
|
|
@ -115,6 +64,8 @@ const DatabaseSetup = () => {
|
|||
return () => {
|
||||
pollCancelRef.current?.()
|
||||
pollCancelRef.current = null
|
||||
abortRef.current?.abort()
|
||||
abortRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
|
@ -149,82 +100,28 @@ const DatabaseSetup = () => {
|
|||
setIsPasswordDialogOpen(false)
|
||||
addLog('info', 'Starting migration...')
|
||||
|
||||
const url = getMigrateUrl()
|
||||
const abortController = new AbortController()
|
||||
abortRef.current = abortController
|
||||
|
||||
const parseChunk = (chunk: string) => {
|
||||
// SSE format: "data: {...}\n\n"
|
||||
const lines = chunk.split('\n')
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed.startsWith('data:')) continue
|
||||
const raw = trimmed.slice(5).trim()
|
||||
if (!raw) continue
|
||||
try {
|
||||
const data = JSON.parse(raw) as { level?: string; message?: string }
|
||||
const level = (data.level ?? 'info') as LogLine['level']
|
||||
const message = data.message ?? raw
|
||||
|
||||
if (level === 'done') {
|
||||
setStatus((prev) => {
|
||||
if (prev === 'running') return 'error'
|
||||
return prev
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
addLog(level, message)
|
||||
|
||||
if (level === 'success') {
|
||||
setStatus('success')
|
||||
pollUntilReady()
|
||||
} else if (level === 'error') {
|
||||
setStatus('error')
|
||||
} else if (level === 'restart') {
|
||||
pollUntilReady()
|
||||
}
|
||||
} catch {
|
||||
addLog('info', raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
'X-Setup-Password': setupPassword,
|
||||
},
|
||||
signal: abortController.signal,
|
||||
})
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
addLog(
|
||||
'error',
|
||||
response.status === 403
|
||||
? 'Invalid setup password or setup migration is not allowed.'
|
||||
: `Server responded with status ${response.status}`,
|
||||
)
|
||||
setStatus('error')
|
||||
return
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
parseChunk(decoder.decode(value, { stream: true }))
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err?.name !== 'AbortError') {
|
||||
await streamSetupMigrate({
|
||||
setupPassword,
|
||||
signal: abortController.signal,
|
||||
onLog: (entry) => addLog(entry.level, entry.message),
|
||||
onSuccess: () => {
|
||||
setStatus('success')
|
||||
pollUntilReady()
|
||||
},
|
||||
onRestart: () => pollUntilReady(),
|
||||
onError: (error) => {
|
||||
setStatus((prev) => (prev === 'running' ? 'error' : prev))
|
||||
addLog('error', 'Server connection lost or migration could not be completed.')
|
||||
}
|
||||
}
|
||||
addLog('error', error.message)
|
||||
},
|
||||
onFinished: () => {
|
||||
abortRef.current = null
|
||||
// Akış "success"/"restart" görmeden bittiyse migration tamamlanmamıştır.
|
||||
setStatus((prev) => (prev === 'running' ? 'error' : prev))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -343,7 +240,11 @@ const DatabaseSetup = () => {
|
|||
)}
|
||||
>
|
||||
<div className={classNames('text-xs', isDark ? 'text-gray-400' : 'text-gray-500')}>
|
||||
{status === 'idle' && dbExists === true && 'Database already exists. Migration is not required.'}
|
||||
{status === 'idle' && dbExists === true && (
|
||||
setupStatus?.pendingMigrationCount
|
||||
? `Database is online with ${setupStatus.pendingMigrationCount} pending migration(s).`
|
||||
: 'Database is online and up to date. Migration is not required here.'
|
||||
)}
|
||||
{status === 'idle' && dbExists === false && 'Database not found. Press the button to start migration.'}
|
||||
{status === 'idle' && dbExists === null && 'Checking database status...'}
|
||||
{status === 'running' && 'Please wait, migration is in progress...'}
|
||||
|
|
@ -454,7 +355,9 @@ const DatabaseSetup = () => {
|
|||
</Dialog>
|
||||
|
||||
<p className={classNames('mt-6 text-xs', isDark ? 'text-gray-600' : 'text-gray-400')}>
|
||||
This page is only visible when the database does not exist.
|
||||
{dbExists === true
|
||||
? 'The database is online. Use the DB Migrate action inside the application for routine migrations.'
|
||||
: 'The database is not reachable. Migration requires the setup password.'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue