Claude güncellemesi SqlQueryManager, Notification, FileManager
This commit is contained in:
parent
a5ee6de819
commit
783c1fe1d3
32 changed files with 764 additions and 705 deletions
|
|
@ -40,19 +40,4 @@ public interface ISqlObjectManagerAppService : IApplicationService
|
||||||
/// Non-existing files are ignored.
|
/// Non-existing files are ignored.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task DeleteSqlDataFilesAsync(DeleteSqlDataFilesDto input);
|
Task DeleteSqlDataFilesAsync(DeleteSqlDataFilesDto input);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Lists .sql files currently available under DbMigrator Seeds/SqlData.
|
|
||||||
/// </summary>
|
|
||||||
Task<List<SqlDataFileDto>> GetSqlDataFilesAsync(string dataDirectoryName = "SqlData", string relativePath = "");
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads a .sql seed file content from the selected data directory.
|
|
||||||
/// </summary>
|
|
||||||
Task<string> GetSqlDataFileContentAsync(string dataDirectoryName = "SqlData", string relativePath = "");
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Moves a SQL seed file between the selected data directory root and HostData.
|
|
||||||
/// </summary>
|
|
||||||
Task MoveSqlDataFileAsync(MoveSqlDataFileDto input);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
using System;
|
|
||||||
|
|
||||||
namespace Sozsoft.SqlQueryManager.Application.Contracts;
|
|
||||||
|
|
||||||
public class SqlDataFileDto
|
|
||||||
{
|
|
||||||
public string FileName { get; set; } = string.Empty;
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
public string RelativePath { get; set; } = string.Empty;
|
|
||||||
public bool IsDirectory { get; set; }
|
|
||||||
public DateTime CreatedAt { get; set; }
|
|
||||||
}
|
|
||||||
|
|
@ -46,10 +46,3 @@ public class DeleteSqlDataFilesDto
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<string> FileNames { get; set; } = new();
|
public List<string> FileNames { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class MoveSqlDataFileDto
|
|
||||||
{
|
|
||||||
public string DataDirectoryName { get; set; } = string.Empty;
|
|
||||||
public string SourceRelativePath { get; set; } = string.Empty;
|
|
||||||
public string TargetRelativePath { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -954,107 +954,6 @@ FROM (
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("api/app/sql-object-manager/sql-data-files")]
|
|
||||||
public Task<List<SqlDataFileDto>> GetSqlDataFilesAsync(
|
|
||||||
[FromQuery] string dataDirectoryName = "SqlData",
|
|
||||||
[FromQuery] string relativePath = "")
|
|
||||||
{
|
|
||||||
ValidateTenantAccess();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var rootPath = ResolveSqlDataOutputPath(dataDirectoryName);
|
|
||||||
var outputPath = ResolveSqlDataChildPath(rootPath, relativePath);
|
|
||||||
if (!Directory.Exists(outputPath))
|
|
||||||
return Task.FromResult(new List<SqlDataFileDto>());
|
|
||||||
|
|
||||||
var directories = Directory.GetDirectories(outputPath, "*", SearchOption.TopDirectoryOnly)
|
|
||||||
.Where(d => string.Equals(Path.GetFileName(d), "HostData", StringComparison.OrdinalIgnoreCase))
|
|
||||||
.Select(d => new SqlDataFileDto
|
|
||||||
{
|
|
||||||
FileName = Path.GetFileName(d)!,
|
|
||||||
Name = Path.GetFileName(d)!,
|
|
||||||
RelativePath = BuildSqlDataRelativePath(relativePath, Path.GetFileName(d)!),
|
|
||||||
IsDirectory = true,
|
|
||||||
CreatedAt = Directory.GetCreationTime(d)
|
|
||||||
});
|
|
||||||
|
|
||||||
var files = Directory.GetFiles(outputPath, "*.sql", SearchOption.TopDirectoryOnly)
|
|
||||||
.Select(f => new SqlDataFileDto
|
|
||||||
{
|
|
||||||
FileName = Path.GetFileName(f)!,
|
|
||||||
Name = Path.GetFileName(f)!,
|
|
||||||
RelativePath = BuildSqlDataRelativePath(relativePath, Path.GetFileName(f)!),
|
|
||||||
IsDirectory = false,
|
|
||||||
CreatedAt = File.GetCreationTime(f)
|
|
||||||
})
|
|
||||||
.Where(x => !string.IsNullOrWhiteSpace(x.Name));
|
|
||||||
|
|
||||||
var entries = directories
|
|
||||||
.Concat(files)
|
|
||||||
.OrderByDescending(x => x.IsDirectory)
|
|
||||||
.ThenBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return Task.FromResult(entries);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to list SQL seed files: {Message}", ex.Message);
|
|
||||||
return Task.FromResult(new List<SqlDataFileDto>());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("api/app/sql-object-manager/sql-data-file-content")]
|
|
||||||
public async Task<string> GetSqlDataFileContentAsync(
|
|
||||||
[FromQuery] string dataDirectoryName = "SqlData",
|
|
||||||
[FromQuery] string relativePath = "")
|
|
||||||
{
|
|
||||||
ValidateTenantAccess();
|
|
||||||
|
|
||||||
var rootPath = ResolveSqlDataOutputPath(dataDirectoryName);
|
|
||||||
var filePath = ResolveSqlDataChildPath(rootPath, relativePath);
|
|
||||||
|
|
||||||
if (!File.Exists(filePath))
|
|
||||||
throw new Volo.Abp.UserFriendlyException("SQL seed file was not found.");
|
|
||||||
|
|
||||||
if (!string.Equals(Path.GetExtension(filePath), ".sql", StringComparison.OrdinalIgnoreCase))
|
|
||||||
throw new Volo.Abp.UserFriendlyException("Only .sql files can be previewed.");
|
|
||||||
|
|
||||||
return await File.ReadAllTextAsync(filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("api/app/sql-object-manager/move-sql-data-file")]
|
|
||||||
public Task MoveSqlDataFileAsync(MoveSqlDataFileDto input)
|
|
||||||
{
|
|
||||||
ValidateTenantAccess();
|
|
||||||
|
|
||||||
if (input == null)
|
|
||||||
throw new Volo.Abp.UserFriendlyException("Invalid move request.");
|
|
||||||
|
|
||||||
var rootPath = ResolveSqlDataOutputPath(input.DataDirectoryName);
|
|
||||||
var sourcePath = ResolveSqlDataChildPath(rootPath, input.SourceRelativePath);
|
|
||||||
var targetPath = ResolveSqlDataChildPath(rootPath, input.TargetRelativePath);
|
|
||||||
|
|
||||||
if (!File.Exists(sourcePath))
|
|
||||||
throw new Volo.Abp.UserFriendlyException("Source file was not found.");
|
|
||||||
|
|
||||||
if (!string.Equals(Path.GetExtension(sourcePath), ".sql", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
!string.Equals(Path.GetExtension(targetPath), ".sql", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
throw new Volo.Abp.UserFriendlyException("Only .sql files can be moved.");
|
|
||||||
}
|
|
||||||
|
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
|
|
||||||
|
|
||||||
if (File.Exists(targetPath))
|
|
||||||
throw new Volo.Abp.UserFriendlyException("A file with the same name already exists in the target folder.");
|
|
||||||
|
|
||||||
File.Move(sourcePath, targetPath);
|
|
||||||
_logger.LogInformation("SQL seed file moved from {SourcePath} to {TargetPath}", sourcePath, targetPath);
|
|
||||||
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
private string ResolveSqlDataOutputPath()
|
private string ResolveSqlDataOutputPath()
|
||||||
{
|
{
|
||||||
|
|
@ -1090,55 +989,4 @@ FROM (
|
||||||
: "SqlData";
|
: "SqlData";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ResolveSqlDataChildPath(string rootPath, string relativePath)
|
|
||||||
{
|
|
||||||
var normalized = NormalizeSqlDataRelativePath(relativePath);
|
|
||||||
var fullPath = Path.GetFullPath(Path.Combine(rootPath, normalized));
|
|
||||||
var fullRoot = Path.GetFullPath(rootPath);
|
|
||||||
var fullRootWithSeparator = fullRoot.EndsWith(Path.DirectorySeparatorChar)
|
|
||||||
? fullRoot
|
|
||||||
: fullRoot + Path.DirectorySeparatorChar;
|
|
||||||
|
|
||||||
if (!string.Equals(fullPath, fullRoot, StringComparison.OrdinalIgnoreCase) &&
|
|
||||||
!fullPath.StartsWith(fullRootWithSeparator, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
throw new Volo.Abp.UserFriendlyException("Invalid path.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return fullPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string NormalizeSqlDataRelativePath(string relativePath)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(relativePath))
|
|
||||||
return string.Empty;
|
|
||||||
|
|
||||||
var normalized = relativePath.Replace('\\', '/').Trim('/');
|
|
||||||
var parts = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
|
|
||||||
if (parts.Length == 0)
|
|
||||||
return string.Empty;
|
|
||||||
|
|
||||||
if (parts.Any(p => p == "." || p == ".." || p.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0))
|
|
||||||
throw new Volo.Abp.UserFriendlyException("Invalid path.");
|
|
||||||
|
|
||||||
var isRootFile = parts.Length == 1 && parts[0].EndsWith(".sql", StringComparison.OrdinalIgnoreCase);
|
|
||||||
var isHostDataFolder = parts.Length == 1 && string.Equals(parts[0], "HostData", StringComparison.OrdinalIgnoreCase);
|
|
||||||
var isHostDataFile = parts.Length == 2 &&
|
|
||||||
string.Equals(parts[0], "HostData", StringComparison.OrdinalIgnoreCase) &&
|
|
||||||
parts[1].EndsWith(".sql", StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
if (!isRootFile && !isHostDataFolder && !isHostDataFile)
|
|
||||||
throw new Volo.Abp.UserFriendlyException("Invalid path.");
|
|
||||||
|
|
||||||
return Path.Combine(parts);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string BuildSqlDataRelativePath(string parentRelativePath, string name)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(parentRelativePath))
|
|
||||||
return name;
|
|
||||||
|
|
||||||
return $"{parentRelativePath.Trim('/', '\\')}/{name}";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,10 @@ using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Sozsoft.Platform.BlobStoring;
|
using Sozsoft.Platform.BlobStoring;
|
||||||
|
using Sozsoft.Platform.Permissions;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Volo.Abp;
|
using Volo.Abp;
|
||||||
using Volo.Abp.Application.Services;
|
using Volo.Abp.Application.Services;
|
||||||
|
|
@ -24,10 +26,20 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
private readonly ICurrentTenant _currentTenant;
|
private readonly ICurrentTenant _currentTenant;
|
||||||
private readonly BlobManager _blobContainer;
|
private readonly BlobManager _blobContainer;
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
|
private readonly IHostEnvironment _hostEnvironment;
|
||||||
|
|
||||||
private const string FolderMarkerSuffix = ".folder";
|
private const string FolderMarkerSuffix = ".folder";
|
||||||
private const string IndexFileName = "index.json";
|
private const string IndexFileName = "index.json";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// CDN altinda fiziksel karsiligi olmayan, DbMigrator projesinin Seeds klasorune baglanan
|
||||||
|
/// sanal kok klasor. Sadece host baglaminda ve <see cref="PlatformPermissions.Setup.Migrate"/>
|
||||||
|
/// yetkisi olan kullaniciya gorunur.
|
||||||
|
/// </summary>
|
||||||
|
private const string SeedsFolderName = "Seeds";
|
||||||
|
|
||||||
|
private const string DbMigratorProjectName = "Sozsoft.Platform.DbMigrator";
|
||||||
|
|
||||||
private static readonly HashSet<string> ProtectedFolders = new(StringComparer.OrdinalIgnoreCase)
|
private static readonly HashSet<string> ProtectedFolders = new(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
BlobContainerNames.Intranet,
|
BlobContainerNames.Intranet,
|
||||||
|
|
@ -35,18 +47,21 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
BlobContainerNames.Import,
|
BlobContainerNames.Import,
|
||||||
BlobContainerNames.Messenger,
|
BlobContainerNames.Messenger,
|
||||||
BlobContainerNames.Note,
|
BlobContainerNames.Note,
|
||||||
BlobContainerNames.Backup
|
BlobContainerNames.Backup,
|
||||||
|
SeedsFolderName
|
||||||
};
|
};
|
||||||
|
|
||||||
public FileManagementAppService(
|
public FileManagementAppService(
|
||||||
ICurrentTenant currentTenant,
|
ICurrentTenant currentTenant,
|
||||||
BlobManager blobContainer,
|
BlobManager blobContainer,
|
||||||
IConfiguration configuration
|
IConfiguration configuration,
|
||||||
|
IHostEnvironment hostEnvironment
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
_currentTenant = currentTenant;
|
_currentTenant = currentTenant;
|
||||||
_blobContainer = blobContainer;
|
_blobContainer = blobContainer;
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
|
_hostEnvironment = hostEnvironment;
|
||||||
}
|
}
|
||||||
|
|
||||||
private const string HostFolderName = "host";
|
private const string HostFolderName = "host";
|
||||||
|
|
@ -67,6 +82,119 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
: Path.Combine(cdnBasePath, TenantsFolderName, effectiveTenantId);
|
: Path.Combine(cdnBasePath, TenantsFolderName, effectiveTenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsSeedsPath(string? relativePath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(relativePath))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var firstPart = relativePath.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
|
||||||
|
return string.Equals(firstPart, SeedsFolderName, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Host + migration yetkisi olmadan DbMigrator Seeds klasoru ne listelenir ne de degistirilir.</summary>
|
||||||
|
private async Task<bool> CanAccessSeedsAsync(string tenantId)
|
||||||
|
{
|
||||||
|
return IsHostTenant(tenantId) &&
|
||||||
|
await AuthorizationService.IsGrantedAsync(PlatformPermissions.Setup.Migrate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>DbMigrator projesinin Seeds klasorunu bulur (config ile override edilebilir).</summary>
|
||||||
|
private string ResolveSeedsRootPath()
|
||||||
|
{
|
||||||
|
var configuredPath = _configuration["App:DbMigratorSeedsPath"];
|
||||||
|
if (!string.IsNullOrWhiteSpace(configuredPath))
|
||||||
|
{
|
||||||
|
return Path.GetFullPath(configuredPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
var dir = new DirectoryInfo(_hostEnvironment.ContentRootPath);
|
||||||
|
|
||||||
|
while (dir != null)
|
||||||
|
{
|
||||||
|
var candidate = Path.Combine(dir.FullName, "src", DbMigratorProjectName, "Seeds");
|
||||||
|
if (Directory.Exists(candidate))
|
||||||
|
{
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate = Path.Combine(dir.FullName, DbMigratorProjectName, "Seeds");
|
||||||
|
if (Directory.Exists(candidate))
|
||||||
|
{
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
dir = dir.Parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.Combine(_hostEnvironment.ContentRootPath, "Seeds");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mantiksal yolu (CDN ya da sanal Seeds koku) fiziksel yola cevirir ve kok disina cikilmasini engeller.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<string> ResolveFullPathAsync(string cdnBasePath, string tenantId, string? relativePath)
|
||||||
|
{
|
||||||
|
string rootPath;
|
||||||
|
var pathInsideRoot = relativePath ?? string.Empty;
|
||||||
|
|
||||||
|
if (IsSeedsPath(relativePath))
|
||||||
|
{
|
||||||
|
if (!await CanAccessSeedsAsync(tenantId))
|
||||||
|
{
|
||||||
|
throw new UserFriendlyException($"'{SeedsFolderName}' folder is not accessible.");
|
||||||
|
}
|
||||||
|
|
||||||
|
rootPath = ResolveSeedsRootPath();
|
||||||
|
pathInsideRoot = string.Join('/', relativePath!
|
||||||
|
.Replace('\\', '/')
|
||||||
|
.Split('/', StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Skip(1));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
rootPath = GetCdnTenantRootPath(cdnBasePath, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var fullRoot = Path.GetFullPath(rootPath);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(pathInsideRoot))
|
||||||
|
{
|
||||||
|
return fullRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fullPath = Path.GetFullPath(Path.Combine(fullRoot, ToSystemPath(pathInsideRoot)));
|
||||||
|
var fullRootWithSeparator = fullRoot.EndsWith(Path.DirectorySeparatorChar)
|
||||||
|
? fullRoot
|
||||||
|
: fullRoot + Path.DirectorySeparatorChar;
|
||||||
|
|
||||||
|
if (!string.Equals(fullPath, fullRoot, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
!fullPath.StartsWith(fullRootWithSeparator, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
throw new UserFriendlyException("Invalid path");
|
||||||
|
}
|
||||||
|
|
||||||
|
return fullPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FileMetadata CreateDirectoryMetadata(DirectoryInfo dirInfo, string relativePath, string? parentPath, string tenantId, int childCount)
|
||||||
|
{
|
||||||
|
return new FileMetadata
|
||||||
|
{
|
||||||
|
Id = relativePath.Replace("/", "|"),
|
||||||
|
Name = dirInfo.Name,
|
||||||
|
Type = "folder",
|
||||||
|
CreatedAt = dirInfo.CreationTime,
|
||||||
|
ModifiedAt = dirInfo.LastWriteTime,
|
||||||
|
Path = relativePath,
|
||||||
|
ParentId = parentPath ?? "",
|
||||||
|
IsReadOnly = false,
|
||||||
|
TenantId = tenantId == "host" ? null : tenantId,
|
||||||
|
ChildCount = childCount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private static string NormalizeExtension(string? extensionOrFileName)
|
private static string NormalizeExtension(string? extensionOrFileName)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(extensionOrFileName))
|
if (string.IsNullOrWhiteSpace(extensionOrFileName))
|
||||||
|
|
@ -169,23 +297,34 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
var fullPath = GetCdnTenantRootPath(cdnBasePath, tenantId);
|
var isRootLevel = string.IsNullOrEmpty(folderPath);
|
||||||
|
var showSeedsFolder = isRootLevel && await CanAccessSeedsAsync(tenantId);
|
||||||
if (!string.IsNullOrEmpty(folderPath))
|
var fullPath = await ResolveFullPathAsync(cdnBasePath, tenantId, folderPath);
|
||||||
{
|
|
||||||
fullPath = Path.Combine(fullPath, ToSystemPath(folderPath));
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!Directory.Exists(fullPath))
|
if (!Directory.Exists(fullPath))
|
||||||
{
|
{
|
||||||
Logger.LogWarning($"Directory does not exist: {fullPath}");
|
Logger.LogWarning($"Directory does not exist: {fullPath}");
|
||||||
|
|
||||||
|
if (showSeedsFolder)
|
||||||
|
{
|
||||||
|
AddSeedsFolderItem(items, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Klasörleri listele
|
if (showSeedsFolder)
|
||||||
var directories = Directory.GetDirectories(fullPath);
|
{
|
||||||
|
AddSeedsFolderItem(items, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Klasörleri listele (sanal Seeds klasoru ile ayni adli fiziksel klasor gizlenir)
|
||||||
|
var directories = Directory.GetDirectories(fullPath)
|
||||||
|
.Where(d => !showSeedsFolder ||
|
||||||
|
!string.Equals(new DirectoryInfo(d).Name, SeedsFolderName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToArray();
|
||||||
foreach (var dir in directories)
|
foreach (var dir in directories)
|
||||||
{
|
{
|
||||||
var dirInfo = new DirectoryInfo(dir);
|
var dirInfo = new DirectoryInfo(dir);
|
||||||
|
|
@ -255,6 +394,24 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Kok listeye DbMigrator Seeds klasorunu sanal klasor olarak ekler.</summary>
|
||||||
|
private void AddSeedsFolderItem(List<FileMetadata> items, string tenantId)
|
||||||
|
{
|
||||||
|
var seedsRootPath = ResolveSeedsRootPath();
|
||||||
|
if (!Directory.Exists(seedsRootPath))
|
||||||
|
{
|
||||||
|
Logger.LogWarning("DbMigrator seeds directory was not found: {SeedsPath}", seedsRootPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dirInfo = new DirectoryInfo(seedsRootPath);
|
||||||
|
var childCount = Directory.GetFileSystemEntries(seedsRootPath, "*", SearchOption.TopDirectoryOnly).Length;
|
||||||
|
|
||||||
|
var metadata = CreateDirectoryMetadata(dirInfo, SeedsFolderName, string.Empty, tenantId, childCount);
|
||||||
|
metadata.Name = SeedsFolderName;
|
||||||
|
items.Add(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task SaveFolderIndexAsync(List<FileMetadata> items, string tenantId, string? parentId = null)
|
private async Task SaveFolderIndexAsync(List<FileMetadata> items, string tenantId, string? parentId = null)
|
||||||
{
|
{
|
||||||
var indexPath = GetTenantPrefix(tenantId) + (string.IsNullOrEmpty(parentId) ? IndexFileName : $"{parentId}/{IndexFileName}");
|
var indexPath = GetTenantPrefix(tenantId) + (string.IsNullOrEmpty(parentId) ? IndexFileName : $"{parentId}/{IndexFileName}");
|
||||||
|
|
@ -307,15 +464,14 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
}
|
}
|
||||||
|
|
||||||
var tenantId = GetEffectiveTenantId(input.TenantId);
|
var tenantId = GetEffectiveTenantId(input.TenantId);
|
||||||
var parentPath = GetCdnTenantRootPath(cdnBasePath, tenantId);
|
|
||||||
|
|
||||||
string? decodedParentId = null;
|
string? decodedParentId = null;
|
||||||
if (!string.IsNullOrEmpty(input.ParentId))
|
if (!string.IsNullOrEmpty(input.ParentId))
|
||||||
{
|
{
|
||||||
decodedParentId = DecodeIdAsPath(input.ParentId);
|
decodedParentId = DecodeIdAsPath(input.ParentId);
|
||||||
parentPath = Path.Combine(parentPath, ToSystemPath(decodedParentId));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var parentPath = await ResolveFullPathAsync(cdnBasePath, tenantId, decodedParentId);
|
||||||
var folderPath = Path.Combine(parentPath, input.Name);
|
var folderPath = Path.Combine(parentPath, input.Name);
|
||||||
|
|
||||||
// Klasör zaten var mı kontrol et
|
// Klasör zaten var mı kontrol et
|
||||||
|
|
@ -383,12 +539,7 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
throw new UserFriendlyException("CDN path is not configured");
|
throw new UserFriendlyException("CDN path is not configured");
|
||||||
}
|
}
|
||||||
|
|
||||||
var fullCdnPath = GetCdnTenantRootPath(cdnBasePath, tenantId);
|
var fullCdnPath = await ResolveFullPathAsync(cdnBasePath, tenantId, decodedParentId);
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(decodedParentId))
|
|
||||||
{
|
|
||||||
fullCdnPath = Path.Combine(fullCdnPath, ToSystemPath(decodedParentId));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dizini oluştur
|
// Dizini oluştur
|
||||||
Directory.CreateDirectory(fullCdnPath);
|
Directory.CreateDirectory(fullCdnPath);
|
||||||
|
|
@ -556,7 +707,7 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
|
|
||||||
var effectiveTenantId = GetEffectiveTenantId(tenantId);
|
var effectiveTenantId = GetEffectiveTenantId(tenantId);
|
||||||
var actualPath = DecodeIdAsPath(id);
|
var actualPath = DecodeIdAsPath(id);
|
||||||
var fullPath = Path.Combine(GetCdnTenantRootPath(cdnBasePath, effectiveTenantId), ToSystemPath(actualPath));
|
var fullPath = await ResolveFullPathAsync(cdnBasePath, effectiveTenantId, actualPath);
|
||||||
|
|
||||||
if (Directory.Exists(fullPath))
|
if (Directory.Exists(fullPath))
|
||||||
{
|
{
|
||||||
|
|
@ -599,7 +750,7 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
ValidateNotProtectedFolder(itemId, "delete");
|
ValidateNotProtectedFolder(itemId, "delete");
|
||||||
|
|
||||||
var actualPath = DecodeIdAsPath(itemId);
|
var actualPath = DecodeIdAsPath(itemId);
|
||||||
var fullPath = Path.Combine(GetCdnTenantRootPath(cdnBasePath, tenantId), ToSystemPath(actualPath));
|
var fullPath = await ResolveFullPathAsync(cdnBasePath, tenantId, actualPath);
|
||||||
|
|
||||||
if (Directory.Exists(fullPath))
|
if (Directory.Exists(fullPath))
|
||||||
{
|
{
|
||||||
|
|
@ -643,7 +794,6 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
}
|
}
|
||||||
|
|
||||||
var tenantId = GetEffectiveTenantId(input.TenantId);
|
var tenantId = GetEffectiveTenantId(input.TenantId);
|
||||||
var basePath = GetCdnTenantRootPath(cdnBasePath, tenantId);
|
|
||||||
|
|
||||||
string? targetPath = null;
|
string? targetPath = null;
|
||||||
if (!string.IsNullOrEmpty(input.TargetFolderId))
|
if (!string.IsNullOrEmpty(input.TargetFolderId))
|
||||||
|
|
@ -659,17 +809,17 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var sourcePath = DecodeIdAsPath(itemId);
|
var sourcePath = DecodeIdAsPath(itemId);
|
||||||
var sourceFullPath = Path.Combine(basePath, ToSystemPath(sourcePath));
|
var sourceFullPath = await ResolveFullPathAsync(cdnBasePath, tenantId, sourcePath);
|
||||||
|
|
||||||
// Get source item name
|
// Get source item name
|
||||||
var sourceItemName = Path.GetFileName(sourcePath);
|
var sourceItemName = Path.GetFileName(sourcePath);
|
||||||
|
|
||||||
// Generate unique name if item already exists in target
|
// Generate unique name if item already exists in target
|
||||||
var targetItemPath = string.IsNullOrEmpty(targetPath) ? sourceItemName : $"{targetPath}/{sourceItemName}";
|
var targetItemPath = string.IsNullOrEmpty(targetPath) ? sourceItemName : $"{targetPath}/{sourceItemName}";
|
||||||
var targetFullPath = Path.Combine(basePath, ToSystemPath(targetItemPath));
|
var targetFullPath = await ResolveFullPathAsync(cdnBasePath, tenantId, targetItemPath);
|
||||||
|
|
||||||
var uniqueTargetPath = GetUniqueItemPath(targetFullPath, sourceItemName);
|
var uniqueTargetPath = GetUniqueItemPath(targetFullPath, sourceItemName);
|
||||||
var finalTargetPath = uniqueTargetPath.Replace(basePath + Path.DirectorySeparatorChar, "").Replace(Path.DirectorySeparatorChar, '/');
|
var finalTargetPath = BuildLogicalPath(targetPath, Path.GetFileName(uniqueTargetPath));
|
||||||
|
|
||||||
if (Directory.Exists(sourceFullPath))
|
if (Directory.Exists(sourceFullPath))
|
||||||
{
|
{
|
||||||
|
|
@ -756,7 +906,6 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
}
|
}
|
||||||
|
|
||||||
var tenantId = GetEffectiveTenantId(input.TenantId);
|
var tenantId = GetEffectiveTenantId(input.TenantId);
|
||||||
var basePath = GetCdnTenantRootPath(cdnBasePath, tenantId);
|
|
||||||
|
|
||||||
string? targetPath = null;
|
string? targetPath = null;
|
||||||
if (!string.IsNullOrEmpty(input.TargetFolderId))
|
if (!string.IsNullOrEmpty(input.TargetFolderId))
|
||||||
|
|
@ -775,14 +924,14 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
ValidateNotProtectedFolder(itemId, "move");
|
ValidateNotProtectedFolder(itemId, "move");
|
||||||
|
|
||||||
var sourcePath = DecodeIdAsPath(itemId);
|
var sourcePath = DecodeIdAsPath(itemId);
|
||||||
var sourceFullPath = Path.Combine(basePath, ToSystemPath(sourcePath));
|
var sourceFullPath = await ResolveFullPathAsync(cdnBasePath, tenantId, sourcePath);
|
||||||
|
|
||||||
// Get source item name
|
// Get source item name
|
||||||
var sourceItemName = Path.GetFileName(sourcePath);
|
var sourceItemName = Path.GetFileName(sourcePath);
|
||||||
|
|
||||||
// Generate target path
|
// Generate target path
|
||||||
var targetItemPath = string.IsNullOrEmpty(targetPath) ? sourceItemName : $"{targetPath}/{sourceItemName}";
|
var targetItemPath = string.IsNullOrEmpty(targetPath) ? sourceItemName : $"{targetPath}/{sourceItemName}";
|
||||||
var targetFullPath = Path.Combine(basePath, ToSystemPath(targetItemPath));
|
var targetFullPath = await ResolveFullPathAsync(cdnBasePath, tenantId, targetItemPath);
|
||||||
|
|
||||||
// Check if moving to same location
|
// Check if moving to same location
|
||||||
if (Path.GetFullPath(sourceFullPath) == Path.GetFullPath(targetFullPath))
|
if (Path.GetFullPath(sourceFullPath) == Path.GetFullPath(targetFullPath))
|
||||||
|
|
@ -793,7 +942,7 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
|
|
||||||
// Generate unique name if item already exists in target
|
// Generate unique name if item already exists in target
|
||||||
var uniqueTargetPath = GetUniqueItemPath(targetFullPath, sourceItemName);
|
var uniqueTargetPath = GetUniqueItemPath(targetFullPath, sourceItemName);
|
||||||
var finalTargetPath = uniqueTargetPath.Replace(basePath + Path.DirectorySeparatorChar, "").Replace(Path.DirectorySeparatorChar, '/');
|
var finalTargetPath = BuildLogicalPath(targetPath, Path.GetFileName(uniqueTargetPath));
|
||||||
|
|
||||||
if (Directory.Exists(sourceFullPath))
|
if (Directory.Exists(sourceFullPath))
|
||||||
{
|
{
|
||||||
|
|
@ -882,7 +1031,7 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
|
|
||||||
var effectiveTenantId = GetEffectiveTenantId(tenantId);
|
var effectiveTenantId = GetEffectiveTenantId(tenantId);
|
||||||
var actualPath = DecodeIdAsPath(id);
|
var actualPath = DecodeIdAsPath(id);
|
||||||
var fullFilePath = Path.Combine(GetCdnTenantRootPath(cdnBasePath, effectiveTenantId), ToSystemPath(actualPath));
|
var fullFilePath = await ResolveFullPathAsync(cdnBasePath, effectiveTenantId, actualPath);
|
||||||
|
|
||||||
if (!File.Exists(fullFilePath))
|
if (!File.Exists(fullFilePath))
|
||||||
{
|
{
|
||||||
|
|
@ -1077,6 +1226,11 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
return new FolderPathDto { Path = pathItems };
|
return new FolderPathDto { Path = pathItems };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string BuildLogicalPath(string? parentPath, string name)
|
||||||
|
{
|
||||||
|
return string.IsNullOrEmpty(parentPath) ? name : $"{parentPath}/{name}";
|
||||||
|
}
|
||||||
|
|
||||||
private string GetUniqueItemPath(string targetPath, string originalName)
|
private string GetUniqueItemPath(string targetPath, string originalName)
|
||||||
{
|
{
|
||||||
if (!File.Exists(targetPath) && !Directory.Exists(targetPath))
|
if (!File.Exists(targetPath) && !Directory.Exists(targetPath))
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,196 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Sozsoft.Platform.EntityFrameworkCore;
|
||||||
|
using Volo.Abp.DependencyInjection;
|
||||||
|
using Volo.Abp.EntityFrameworkCore;
|
||||||
|
using Volo.Abp.Uow;
|
||||||
|
using static Sozsoft.Settings.SettingsConsts;
|
||||||
|
|
||||||
|
namespace Sozsoft.Platform.Data.Seeds;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds/{SqlData|PostgresData}/{HostData|TenantData}/ExecuteData klasöründeki .sql dosyaları
|
||||||
|
/// iki aşamada işlenir:
|
||||||
|
/// 1) Migration/seed sırasında <see cref="SqlDataSeeder"/> tarafından, üst klasörle (HostData/TenantData)
|
||||||
|
/// aynı kapsamda çalıştırılır (nesne oluşturulur/güncellenir).
|
||||||
|
/// 2) Tüm migration ve seed işlemleri bittikten sonra bu sınıf, dosya adından türetilen stored
|
||||||
|
/// procedure'ü ilgili bağlamda (host ya da tenant) varlığı doğrulanarak çalıştırır.
|
||||||
|
/// Dosya adı = procedure adı. İsteğe bağlı olarak "schema.Procedure.sql" formatı da desteklenir.
|
||||||
|
/// </summary>
|
||||||
|
public class AfterAllMigrationsSqlExecutor : IAfterAllMigrationsSqlExecutor, ITransientDependency
|
||||||
|
{
|
||||||
|
/// <summary>HostData/TenantData altındaki, migration sonrası da çalıştırılacak script klasörünün adı.</summary>
|
||||||
|
public const string DirectoryName = "ExecuteData";
|
||||||
|
|
||||||
|
/// <summary>Host kapsamındaki script klasörü.</summary>
|
||||||
|
public const string HostDataDirectoryName = "HostData";
|
||||||
|
|
||||||
|
/// <summary>Tenant kapsamındaki script klasörü (henüz kullanılmıyorsa sessizce atlanır).</summary>
|
||||||
|
public const string TenantDataDirectoryName = "TenantData";
|
||||||
|
|
||||||
|
private static readonly Regex SafeIdentifierRegex =
|
||||||
|
new(@"^[A-Za-z_][A-Za-z0-9_]{0,126}$", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
private readonly IDbContextProvider<PlatformDbContext> _dbContextProvider;
|
||||||
|
private readonly IUnitOfWorkManager _unitOfWorkManager;
|
||||||
|
private readonly ILogger<AfterAllMigrationsSqlExecutor> _logger;
|
||||||
|
|
||||||
|
public AfterAllMigrationsSqlExecutor(
|
||||||
|
IDbContextProvider<PlatformDbContext> dbContextProvider,
|
||||||
|
IUnitOfWorkManager unitOfWorkManager,
|
||||||
|
ILogger<AfterAllMigrationsSqlExecutor> logger)
|
||||||
|
{
|
||||||
|
_dbContextProvider = dbContextProvider;
|
||||||
|
_unitOfWorkManager = unitOfWorkManager;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ExecuteAsync(Guid? tenantId = null)
|
||||||
|
{
|
||||||
|
var scopeDirectoryName = tenantId == null ? HostDataDirectoryName : TenantDataDirectoryName;
|
||||||
|
var directoryPath = GetScriptDirectoryPath(scopeDirectoryName);
|
||||||
|
|
||||||
|
if (!Directory.Exists(directoryPath))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("{Directory} directory not found, skipping AfterAllMigrationsSqlExecutor.", directoryPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sqlFiles = Directory.GetFiles(directoryPath, "*.sql")
|
||||||
|
.OrderBy(f => Path.GetFileName(f), StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (sqlFiles.Length == 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("No .sql files found in {Directory}, skipping AfterAllMigrationsSqlExecutor.", directoryPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"AfterAllMigrationsSqlExecutor started for {Scope}. {Count} procedure(s) to be executed.",
|
||||||
|
tenantId == null ? "Host" : $"Tenant {tenantId}",
|
||||||
|
sqlFiles.Length);
|
||||||
|
|
||||||
|
var failed = new List<string>();
|
||||||
|
|
||||||
|
foreach (var filePath in sqlFiles)
|
||||||
|
{
|
||||||
|
var objectName = Path.GetFileNameWithoutExtension(filePath);
|
||||||
|
|
||||||
|
if (!TryResolveObjectName(objectName, out var schemaName, out var procedureName))
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Skipped {FileName}: file name is not a valid procedure identifier ([schema.]ProcedureName).",
|
||||||
|
Path.GetFileName(filePath));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var uow = _unitOfWorkManager.Begin(requiresNew: true, isTransactional: false);
|
||||||
|
|
||||||
|
var dbContext = await _dbContextProvider.GetDbContextAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Executing after-all-migrations procedure {Schema}.{Procedure} from {FileName}",
|
||||||
|
schemaName,
|
||||||
|
procedureName,
|
||||||
|
Path.GetFileName(filePath));
|
||||||
|
|
||||||
|
await dbContext.Database.ExecuteSqlRawAsync(BuildExecuteScript(schemaName, procedureName));
|
||||||
|
|
||||||
|
await uow.CompleteAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Executed after-all-migrations procedure {Schema}.{Procedure}",
|
||||||
|
schemaName,
|
||||||
|
procedureName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
failed.Add($"{schemaName}.{procedureName}");
|
||||||
|
_logger.LogError(ex, "After-all-migrations procedure failed: {Schema}.{Procedure}", schemaName, procedureName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed.Count > 0)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"AfterAllMigrationsSqlExecutor completed with {FailedCount} failed procedure(s): {Failed}",
|
||||||
|
failed.Count,
|
||||||
|
string.Join(", ", failed));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogInformation("AfterAllMigrationsSqlExecutor completed. {Count} procedure(s) executed.", sqlFiles.Length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetScriptDirectoryPath(string scopeDirectoryName)
|
||||||
|
{
|
||||||
|
var dataDirectoryName = DefaultDatabaseProvider == DatabaseProvider.PostgreSql
|
||||||
|
? "PostgresData"
|
||||||
|
: "SqlData";
|
||||||
|
|
||||||
|
return Path.Combine(Directory.GetCurrentDirectory(), "Seeds", dataDirectoryName, scopeDirectoryName, DirectoryName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dosya adını [schema.]ProcedureName olarak çözer ve her iki parçayı da beyaz liste ile doğrular.
|
||||||
|
/// Doğrulanmayan hiçbir ad SQL metnine yazılmaz.
|
||||||
|
/// </summary>
|
||||||
|
private static bool TryResolveObjectName(string fileName, out string schemaName, out string procedureName)
|
||||||
|
{
|
||||||
|
var defaultSchema = DefaultDatabaseProvider == DatabaseProvider.PostgreSql ? "public" : "dbo";
|
||||||
|
|
||||||
|
schemaName = defaultSchema;
|
||||||
|
procedureName = fileName;
|
||||||
|
|
||||||
|
var parts = fileName.Split('.');
|
||||||
|
if (parts.Length == 2)
|
||||||
|
{
|
||||||
|
schemaName = parts[0];
|
||||||
|
procedureName = parts[1];
|
||||||
|
}
|
||||||
|
else if (parts.Length > 2)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SafeIdentifierRegex.IsMatch(schemaName) && SafeIdentifierRegex.IsMatch(procedureName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildExecuteScript(string schemaName, string procedureName)
|
||||||
|
{
|
||||||
|
if (DefaultDatabaseProvider == DatabaseProvider.PostgreSql)
|
||||||
|
{
|
||||||
|
return $@"
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_proc p
|
||||||
|
JOIN pg_namespace n ON n.oid = p.pronamespace
|
||||||
|
WHERE n.nspname = '{schemaName}' AND p.proname = '{procedureName}' AND p.prokind = 'p'
|
||||||
|
) THEN
|
||||||
|
CALL ""{schemaName}"".""{procedureName}""();
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'Procedure {schemaName}.{procedureName} not found, skipped.';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $@"
|
||||||
|
IF OBJECT_ID(N'[{schemaName}].[{procedureName}]', N'P') IS NOT NULL
|
||||||
|
EXEC [{schemaName}].[{procedureName}];
|
||||||
|
ELSE
|
||||||
|
PRINT N'Procedure [{schemaName}].[{procedureName}] not found, skipped.';";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -111,18 +111,28 @@ public class SqlDataSeeder : IDataSeedContributor, ITransientDependency
|
||||||
: "SqlData";
|
: "SqlData";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string[] GetSqlFiles(string dataDirectoryPath, bool includeHostData)
|
private static string[] GetSqlFiles(string dataDirectoryPath, bool isHostSeed)
|
||||||
{
|
{
|
||||||
var sqlFiles = Directory.GetFiles(dataDirectoryPath, "*.sql")
|
var sqlFiles = Directory.GetFiles(dataDirectoryPath, "*.sql")
|
||||||
.OrderBy(f => Path.GetFileName(f))
|
.OrderBy(f => Path.GetFileName(f))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
if (includeHostData)
|
var scopeDirectoryName = isHostSeed
|
||||||
|
? AfterAllMigrationsSqlExecutor.HostDataDirectoryName
|
||||||
|
: AfterAllMigrationsSqlExecutor.TenantDataDirectoryName;
|
||||||
|
|
||||||
|
var scopePath = Path.Combine(dataDirectoryPath, scopeDirectoryName);
|
||||||
|
if (Directory.Exists(scopePath))
|
||||||
{
|
{
|
||||||
var hostDataPath = Path.Combine(dataDirectoryPath, "HostData");
|
sqlFiles.AddRange(Directory.GetFiles(scopePath, "*.sql")
|
||||||
if (Directory.Exists(hostDataPath))
|
.OrderBy(f => Path.GetFileName(f)));
|
||||||
|
|
||||||
|
// {HostData|TenantData}/ExecuteData: nesneler burada da oluşturulur, ayrıca tüm
|
||||||
|
// migration bittikten sonra AfterAllMigrationsSqlExecutor dosya adı üzerinden çalıştırır.
|
||||||
|
var executeDataPath = Path.Combine(scopePath, AfterAllMigrationsSqlExecutor.DirectoryName);
|
||||||
|
if (Directory.Exists(executeDataPath))
|
||||||
{
|
{
|
||||||
sqlFiles.AddRange(Directory.GetFiles(hostDataPath, "*.sql")
|
sqlFiles.AddRange(Directory.GetFiles(executeDataPath, "*.sql")
|
||||||
.OrderBy(f => Path.GetFileName(f)));
|
.OrderBy(f => Path.GetFileName(f)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -18440,21 +18440,15 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"resourceName": "Platform",
|
"resourceName": "Platform",
|
||||||
"key": "App.SqlQueryManager.MoveFiles",
|
"key": "App.SqlQueryManager.OpenFileManager",
|
||||||
"en": "Move to Host Folder",
|
"en": "Seed Files",
|
||||||
"tr": "Host Klasörüne Taşı"
|
"tr": "Seed Dosyaları"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"resourceName": "Platform",
|
"resourceName": "Platform",
|
||||||
"key": "App.SqlQueryManager.MoveToHostData",
|
"key": "App.SqlQueryManager.OpenFileManagerHint",
|
||||||
"en": "Move to Host Data",
|
"en": "Manage DbMigrator Seeds folder in File Manager",
|
||||||
"tr": "Host Klasörüne Taşı"
|
"tr": "DbMigrator Seeds klasörünü Dosya Yöneticisi'nde yönet"
|
||||||
},
|
|
||||||
{
|
|
||||||
"resourceName": "Platform",
|
|
||||||
"key": "App.SqlQueryManager.MoveOut",
|
|
||||||
"en": "Move Out",
|
|
||||||
"tr": "Dışarı Taşı"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"resourceName": "Platform",
|
"resourceName": "Platform",
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,18 @@
|
||||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
</Content>
|
</Content>
|
||||||
|
<Content Include="Seeds\SqlData\HostData\ExecuteData\*.sql">
|
||||||
|
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||||
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
<Content Include="Seeds\SqlData\TenantData\*.sql">
|
||||||
|
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||||
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
<Content Include="Seeds\SqlData\TenantData\ExecuteData\*.sql">
|
||||||
|
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||||
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
<Content Include="Seeds\PostgresData\*.sql">
|
<Content Include="Seeds\PostgresData\*.sql">
|
||||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
|
|
@ -97,6 +109,18 @@
|
||||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
</Content>
|
</Content>
|
||||||
|
<Content Include="Seeds\PostgresData\HostData\ExecuteData\*.sql">
|
||||||
|
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||||
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
<Content Include="Seeds\PostgresData\TenantData\*.sql">
|
||||||
|
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||||
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
<Content Include="Seeds\PostgresData\TenantData\ExecuteData\*.sql">
|
||||||
|
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||||
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Sozsoft.Platform.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tüm migration ve seed işlemleri tamamlandıktan sonra çalıştırılacak SQL adımlarını yürütür.
|
||||||
|
/// Uygulaması DbMigrator projesindedir; migrator dışındaki host'larda kayıtlı olmayabilir.
|
||||||
|
/// </summary>
|
||||||
|
public interface IAfterAllMigrationsSqlExecutor
|
||||||
|
{
|
||||||
|
/// <param name="tenantId">
|
||||||
|
/// null ise HostData/ExecuteData, aksi halde TenantData/ExecuteData klasörü işlenir.
|
||||||
|
/// </param>
|
||||||
|
Task ExecuteAsync(Guid? tenantId = null);
|
||||||
|
}
|
||||||
|
|
@ -26,19 +26,22 @@ public class PlatformDbMigrationService : ITransientDependency
|
||||||
private readonly ITenantRepository _tenantRepository;
|
private readonly ITenantRepository _tenantRepository;
|
||||||
private readonly ICurrentTenant _currentTenant;
|
private readonly ICurrentTenant _currentTenant;
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
|
private readonly IEnumerable<IAfterAllMigrationsSqlExecutor> _afterAllMigrationsSqlExecutors;
|
||||||
|
|
||||||
public PlatformDbMigrationService(
|
public PlatformDbMigrationService(
|
||||||
IDataSeeder dataSeeder,
|
IDataSeeder dataSeeder,
|
||||||
IEnumerable<IPlatformDbSchemaMigrator> dbSchemaMigrators,
|
IEnumerable<IPlatformDbSchemaMigrator> dbSchemaMigrators,
|
||||||
ITenantRepository tenantRepository,
|
ITenantRepository tenantRepository,
|
||||||
ICurrentTenant currentTenant,
|
ICurrentTenant currentTenant,
|
||||||
IConfiguration configuration)
|
IConfiguration configuration,
|
||||||
|
IEnumerable<IAfterAllMigrationsSqlExecutor> afterAllMigrationsSqlExecutors)
|
||||||
{
|
{
|
||||||
_dataSeeder = dataSeeder;
|
_dataSeeder = dataSeeder;
|
||||||
_dbSchemaMigrators = dbSchemaMigrators;
|
_dbSchemaMigrators = dbSchemaMigrators;
|
||||||
_tenantRepository = tenantRepository;
|
_tenantRepository = tenantRepository;
|
||||||
_currentTenant = currentTenant;
|
_currentTenant = currentTenant;
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
|
_afterAllMigrationsSqlExecutors = afterAllMigrationsSqlExecutors;
|
||||||
|
|
||||||
Logger = NullLogger<PlatformDbMigrationService>.Instance;
|
Logger = NullLogger<PlatformDbMigrationService>.Instance;
|
||||||
}
|
}
|
||||||
|
|
@ -100,9 +103,59 @@ public class PlatformDbMigrationService : ITransientDependency
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.LogInformation("Successfully completed all database migrations.");
|
Logger.LogInformation("Successfully completed all database migrations.");
|
||||||
|
|
||||||
|
await ExecuteAfterAllMigrationsSqlAsync(tenants);
|
||||||
|
|
||||||
Logger.LogInformation("You can safely end this process...");
|
Logger.LogInformation("You can safely end this process...");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tüm host ve tenant migration/seed adımları bittikten sonra, {HostData|TenantData}/ExecuteData
|
||||||
|
/// klasöründeki script'lerin dosya adından türeyen procedure'lerini ilgili bağlamda çalıştırır.
|
||||||
|
/// TenantData klasörü yoksa tenant adımı sessizce atlanır.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ExecuteAfterAllMigrationsSqlAsync(List<Tenant> tenants)
|
||||||
|
{
|
||||||
|
if (!_afterAllMigrationsSqlExecutors.Any())
|
||||||
|
{
|
||||||
|
Logger.LogInformation("No after-all-migrations SQL executor registered, skipping ExecuteData scripts.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.LogInformation(
|
||||||
|
"Executing after-all-migrations SQL scripts for host and {TenantCount} tenant(s)...",
|
||||||
|
tenants.Count);
|
||||||
|
|
||||||
|
using (_currentTenant.Change(null))
|
||||||
|
{
|
||||||
|
foreach (var executor in _afterAllMigrationsSqlExecutors)
|
||||||
|
{
|
||||||
|
Logger.LogInformation("Running after-all-migrations SQL scripts for host...");
|
||||||
|
await executor.ExecuteAsync();
|
||||||
|
Logger.LogInformation("Completed after-all-migrations SQL scripts for host.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var tenant in tenants)
|
||||||
|
{
|
||||||
|
using (_currentTenant.Change(tenant.Id))
|
||||||
|
{
|
||||||
|
foreach (var executor in _afterAllMigrationsSqlExecutors)
|
||||||
|
{
|
||||||
|
Logger.LogInformation(
|
||||||
|
"Running after-all-migrations SQL scripts for {TenantName} tenant...",
|
||||||
|
tenant.Name);
|
||||||
|
await executor.ExecuteAsync(tenant.Id);
|
||||||
|
Logger.LogInformation(
|
||||||
|
"Completed after-all-migrations SQL scripts for {TenantName} tenant.",
|
||||||
|
tenant.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.LogInformation("Successfully completed all after-all-migrations SQL scripts.");
|
||||||
|
}
|
||||||
|
|
||||||
private async Task MigrateDatabaseSchemaAsync(Tenant? tenant = null)
|
private async Task MigrateDatabaseSchemaAsync(Tenant? tenant = null)
|
||||||
{
|
{
|
||||||
Logger.LogInformation(
|
Logger.LogInformation(
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
"AttachmentsPath": "/etc/api/mail-queue/attachments",
|
"AttachmentsPath": "/etc/api/mail-queue/attachments",
|
||||||
"CdnUrl": "https://dev-cdn.sozsoft.com",
|
"CdnUrl": "https://dev-cdn.sozsoft.com",
|
||||||
"CdnPath": "/etc/api/cdn",
|
"CdnPath": "/etc/api/cdn",
|
||||||
|
"DbMigratorSeedsPath": "/etc/api/seeds",
|
||||||
"BaseDomain": "sozsoft.com",
|
"BaseDomain": "sozsoft.com",
|
||||||
"BackupPath": "/var/opt/mssql/backup"
|
"BackupPath": "/var/opt/mssql/backup"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
"AttachmentsPath": "/etc/api/mail-queue/attachments",
|
"AttachmentsPath": "/etc/api/mail-queue/attachments",
|
||||||
"CdnUrl": "https://cdn.sozsoft.com",
|
"CdnUrl": "https://cdn.sozsoft.com",
|
||||||
"CdnPath": "/etc/api/cdn",
|
"CdnPath": "/etc/api/cdn",
|
||||||
|
"DbMigratorSeedsPath": "/etc/api/seeds",
|
||||||
"BaseDomain": "sozsoft.com",
|
"BaseDomain": "sozsoft.com",
|
||||||
"BackupPath": "/var/opt/mssql/backup"
|
"BackupPath": "/var/opt/mssql/backup"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ networks:
|
||||||
volumes:
|
volumes:
|
||||||
cdn:
|
cdn:
|
||||||
api-keys:
|
api-keys:
|
||||||
|
# DbMigrator imajindaki Seeds klasoru ilk calistirmada bu volume'e kopyalanir;
|
||||||
|
# api konteyneri ayni volume'u /etc/api/seeds altinda okuyup yazar.
|
||||||
|
seeds:
|
||||||
sql-backups:
|
sql-backups:
|
||||||
external: true
|
external: true
|
||||||
name: sozsoft-platform-data_sql-backups
|
name: sozsoft-platform-data_sql-backups
|
||||||
|
|
@ -21,6 +24,8 @@ services:
|
||||||
environment:
|
environment:
|
||||||
- ASPNETCORE_ENVIRONMENT=Dev
|
- ASPNETCORE_ENVIRONMENT=Dev
|
||||||
- SEED=${SEED}
|
- SEED=${SEED}
|
||||||
|
volumes:
|
||||||
|
- seeds:/srv/app/Seeds
|
||||||
networks:
|
networks:
|
||||||
- db
|
- db
|
||||||
|
|
||||||
|
|
@ -35,6 +40,7 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- cdn:/etc/api/cdn
|
- cdn:/etc/api/cdn
|
||||||
- sql-backups:/etc/api/cdn/host/backup
|
- sql-backups:/etc/api/cdn/host/backup
|
||||||
|
- seeds:/etc/api/seeds
|
||||||
- api-keys:/root/.aspnet/DataProtection-Keys
|
- api-keys:/root/.aspnet/DataProtection-Keys
|
||||||
networks:
|
networks:
|
||||||
- db
|
- db
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,14 @@ import ScrollBar from '@/components/ui/ScrollBar'
|
||||||
import Spinner from '@/components/ui/Spinner'
|
import Spinner from '@/components/ui/Spinner'
|
||||||
import Tooltip from '@/components/ui/Tooltip'
|
import Tooltip from '@/components/ui/Tooltip'
|
||||||
import { APP_NAME, AVATAR_URL } from '@/constants/app.constant'
|
import { APP_NAME, AVATAR_URL } from '@/constants/app.constant'
|
||||||
import NotificationChannels from '@/constants/notification-channel.enum'
|
import Tag from '@/components/ui/Tag'
|
||||||
|
import NotificationChannels, {
|
||||||
|
NotificationChannelColors,
|
||||||
|
} from '@/constants/notification-channel.enum'
|
||||||
|
import { NotificationDto } from '@/proxy/notification/models'
|
||||||
import { ROUTES_ENUM } from '@/routes/route.constant'
|
import { ROUTES_ENUM } from '@/routes/route.constant'
|
||||||
import {
|
import {
|
||||||
getList,
|
getList,
|
||||||
updateRead,
|
|
||||||
updateReadAll,
|
updateReadAll,
|
||||||
updateReadMany,
|
updateReadMany,
|
||||||
updateSentMany,
|
updateSentMany,
|
||||||
|
|
@ -32,8 +35,15 @@ import { Notification as Notify, toast } from '../ui'
|
||||||
|
|
||||||
dayjs.extend(relativeTime)
|
dayjs.extend(relativeTime)
|
||||||
|
|
||||||
type NotificationList = {
|
type NotificationListItem = {
|
||||||
id: string
|
/**
|
||||||
|
* Aynı mesaj birden fazla kanalda (UiActivity + UiToast + Mail …) satır
|
||||||
|
* ürettiği için kayıtlar tek satırda birleştirilir; okundu işaretlemede
|
||||||
|
* gruptaki tüm id'ler birlikte güncellenir.
|
||||||
|
*/
|
||||||
|
ids: string[]
|
||||||
|
/** Satırın hangi kanallardan geldiği; rozet olarak gösterilir. */
|
||||||
|
channels: string[]
|
||||||
creatorId: string
|
creatorId: string
|
||||||
tenantId?: string
|
tenantId?: string
|
||||||
notificationType: string
|
notificationType: string
|
||||||
|
|
@ -42,8 +52,75 @@ type NotificationList = {
|
||||||
readed: boolean
|
readed: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const notificationHeight = 'h-72'
|
const notificationHeight = 'h-[26rem]'
|
||||||
const notificationInterval = 120000 // 2 minutes
|
const notificationInterval = 120000 // 2 minutes
|
||||||
|
const foregroundPollThrottle = 30000 // 30 seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dropdown, Activity Log ekranıyla aynı kapsamı gösterir: kullanıcıya giden her
|
||||||
|
* kanal listelenir, satırda hangi kanallardan gittiği rozetle belirtilir.
|
||||||
|
*/
|
||||||
|
const inboxChannels = Object.values(NotificationChannels)
|
||||||
|
|
||||||
|
/** Tarayıcıda gösterim üretebilen, dolayısıyla isSent takibi yapılan kanallar. */
|
||||||
|
const pushChannels: string[] = [NotificationChannels.UiToast, NotificationChannels.Desktop]
|
||||||
|
|
||||||
|
/** Dropdown'da tutulan kayıt sayısı. Tamamı için "View all activity" ekranı var. */
|
||||||
|
const inboxSize = 50
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uzun süre uzak kalınıp dönüldüğünde onlarca toast birden açılmasın; en yeniler
|
||||||
|
* gösterilir, kalanı okunmamış olarak badge ve listede görünür.
|
||||||
|
*/
|
||||||
|
const maxPushBurst = 5
|
||||||
|
|
||||||
|
/** Aynı gönderimden doğan farklı kanal satırlarını eşleştirme toleransı. */
|
||||||
|
const duplicateWindowMs = 30000
|
||||||
|
|
||||||
|
const notificationDate = (value: NotificationDto['creationTime']) =>
|
||||||
|
value instanceof Date ? value.toISOString() : String(value ?? '')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aynı mesajın kanal başına düşen kopyalarını tek satırda toplar. Liste
|
||||||
|
* `CreationTime desc` sıralı geldiği için kopyalar ardışıktır.
|
||||||
|
*/
|
||||||
|
const groupNotifications = (items: NotificationDto[]): NotificationListItem[] => {
|
||||||
|
const groups: NotificationListItem[] = []
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const date = notificationDate(item.creationTime)
|
||||||
|
const time = dayjs(date).valueOf()
|
||||||
|
|
||||||
|
const target = groups.find(
|
||||||
|
(group) =>
|
||||||
|
group.notificationType === item.notificationType &&
|
||||||
|
group.message === item.message &&
|
||||||
|
Math.abs(dayjs(group.date).valueOf() - time) <= duplicateWindowMs,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
target.ids.push(item.id)
|
||||||
|
if (!target.channels.includes(item.notificationChannel)) {
|
||||||
|
target.channels.push(item.notificationChannel)
|
||||||
|
}
|
||||||
|
target.readed = target.readed && item.isRead
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
groups.push({
|
||||||
|
ids: [item.id],
|
||||||
|
channels: [item.notificationChannel],
|
||||||
|
notificationType: item.notificationType,
|
||||||
|
message: item.message,
|
||||||
|
date,
|
||||||
|
creatorId: item.creatorId as string,
|
||||||
|
tenantId: item.tenantId,
|
||||||
|
readed: item.isRead,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
const NotificationToggle = ({
|
const NotificationToggle = ({
|
||||||
className,
|
className,
|
||||||
|
|
@ -72,7 +149,7 @@ const NotificationToggle = ({
|
||||||
const _Notification = ({ className }: { className?: string }) => {
|
const _Notification = ({ className }: { className?: string }) => {
|
||||||
const { translate } = useLocalization()
|
const { translate } = useLocalization()
|
||||||
|
|
||||||
const [notificationList, setNotificationList] = useState<NotificationList[]>([])
|
const [notificationList, setNotificationList] = useState<NotificationListItem[]>([])
|
||||||
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0)
|
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
|
@ -80,25 +157,35 @@ const _Notification = ({ className }: { className?: string }) => {
|
||||||
// gösterilmesini engeller.
|
// gösterilmesini engeller.
|
||||||
const pushedIds = useRef(new Set<string>())
|
const pushedIds = useRef(new Set<string>())
|
||||||
const listLoaded = useRef(false)
|
const listLoaded = useRef(false)
|
||||||
|
const lastPollAt = useRef(0)
|
||||||
|
|
||||||
const { bgTheme } = useThemeClass()
|
const { bgTheme } = useThemeClass()
|
||||||
const { larger } = useResponsive()
|
const { larger } = useResponsive()
|
||||||
|
|
||||||
const direction = useStoreState((state) => state.theme.direction)
|
const direction = useStoreState((state) => state.theme.direction)
|
||||||
const tabHasFocus = useStoreState((a) => a.base.common.tabHasFocus)
|
|
||||||
const tabHasFocusRef = useRef(tabHasFocus)
|
|
||||||
useEffect(() => {
|
|
||||||
tabHasFocusRef.current = tabHasFocus
|
|
||||||
}, [tabHasFocus])
|
|
||||||
|
|
||||||
const getUnreadCount = useCallback(async () => {
|
/**
|
||||||
|
* Badge ve dropdown içeriği tek istekten beslenir; böylece rozetteki sayı ile
|
||||||
|
* listedeki okunmamış kayıt sayısı her zaman aynı kalır.
|
||||||
|
*/
|
||||||
|
const getInbox = useCallback(async () => {
|
||||||
const resp = await getList({
|
const resp = await getList({
|
||||||
channels: [NotificationChannels.UiActivity],
|
channels: inboxChannels,
|
||||||
isListRequest: false,
|
// Kanallar arasında UiToast olduğu için servis, geçmişi son 24 saatle
|
||||||
isRead: false,
|
// sınırlayan push filtresini uygulamasın diye liste modunda çağrılır.
|
||||||
maxResultCount: 1,
|
isListRequest: true,
|
||||||
|
sorting: 'CreationTime desc',
|
||||||
|
maxResultCount: inboxSize,
|
||||||
})
|
})
|
||||||
setUnreadNotificationCount(resp.data?.totalCount ?? 0)
|
|
||||||
|
const items = resp.data.items ?? []
|
||||||
|
const groups = groupNotifications(items)
|
||||||
|
|
||||||
|
setNotificationList(groups)
|
||||||
|
setUnreadNotificationCount(groups.filter((a) => !a.readed).length)
|
||||||
|
listLoaded.current = true
|
||||||
|
|
||||||
|
return items
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const getPushNotifications = useCallback(async () => {
|
const getPushNotifications = useCallback(async () => {
|
||||||
|
|
@ -112,6 +199,7 @@ const _Notification = ({ className }: { className?: string }) => {
|
||||||
channels,
|
channels,
|
||||||
isListRequest: false,
|
isListRequest: false,
|
||||||
isSent: false,
|
isSent: false,
|
||||||
|
sorting: 'CreationTime desc',
|
||||||
maxResultCount: 1000,
|
maxResultCount: 1000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -123,7 +211,7 @@ const _Notification = ({ className }: { className?: string }) => {
|
||||||
}
|
}
|
||||||
pending.forEach((a) => pushedIds.current.add(a.id))
|
pending.forEach((a) => pushedIds.current.add(a.id))
|
||||||
|
|
||||||
for (const notification of pending) {
|
for (const notification of pending.slice(0, maxPushBurst)) {
|
||||||
if (notification.notificationChannel === NotificationChannels.UiToast) {
|
if (notification.notificationChannel === NotificationChannels.UiToast) {
|
||||||
toast.push(
|
toast.push(
|
||||||
<Notify type="success" duration={0} closable={true}>
|
<Notify type="success" duration={0} closable={true}>
|
||||||
|
|
@ -149,10 +237,15 @@ const _Notification = ({ className }: { className?: string }) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const processedIds = pending.map((a) => a.id)
|
// Yalnızca "gösterildi" işaretlenir; okundu bilgisi kullanıcının kendi
|
||||||
await updateSentMany(processedIds, true)
|
// aksiyonuna bırakılır, aksi hâlde mesaj badge'e hiç düşmezdi.
|
||||||
await updateReadMany(processedIds, true)
|
await updateSentMany(
|
||||||
}, [])
|
pending.map((a) => a.id),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
|
||||||
|
await getInbox()
|
||||||
|
}, [getInbox])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ('Notification' in window && window.Notification.permission === 'default') {
|
if ('Notification' in window && window.Notification.permission === 'default') {
|
||||||
|
|
@ -160,81 +253,81 @@ const _Notification = ({ className }: { className?: string }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const poll = () => {
|
const poll = () => {
|
||||||
getUnreadCount()
|
// Sekme arka plandaysa istek atma. Pencerenin OS odağı yoksa (IDE'ye
|
||||||
|
// geçmek, ikinci ekran, ayrık DevTools) sekme hâlâ görünür sayılır ve
|
||||||
|
// yoklama devam eder.
|
||||||
|
if (document.visibilityState === 'hidden') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastPollAt.current = performance.now()
|
||||||
|
getInbox()
|
||||||
getPushNotifications()
|
getPushNotifications()
|
||||||
}
|
}
|
||||||
|
|
||||||
poll()
|
poll()
|
||||||
const intervalId = setInterval(() => {
|
const intervalId = setInterval(poll, notificationInterval)
|
||||||
if (tabHasFocusRef.current) {
|
|
||||||
poll()
|
// Sekme arka plandan öne geldiğinde bir sonraki tick'i beklemeden tazele;
|
||||||
|
// hızlı sekme geçişlerinde istek yağmuru olmasın diye kısa aralıklar elenir.
|
||||||
|
const onForeground = () => {
|
||||||
|
if (document.visibilityState !== 'visible') {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}, notificationInterval)
|
if (performance.now() - lastPollAt.current < foregroundPollThrottle) {
|
||||||
|
return
|
||||||
return () => clearInterval(intervalId)
|
}
|
||||||
}, [getUnreadCount, getPushNotifications])
|
poll()
|
||||||
|
|
||||||
const onNotificationOpen = useCallback(async () => {
|
|
||||||
const currentUnread = notificationList.filter((a) => !a.readed).length
|
|
||||||
if (listLoaded.current && currentUnread === unreadNotificationCount) {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true)
|
document.addEventListener('visibilitychange', onForeground)
|
||||||
try {
|
window.addEventListener('focus', onForeground)
|
||||||
const resp = await getList({
|
|
||||||
channels: [NotificationChannels.UiActivity],
|
|
||||||
isListRequest: false,
|
|
||||||
maxResultCount: 1000,
|
|
||||||
})
|
|
||||||
const items = resp.data.items ?? []
|
|
||||||
if (items.length > 0) {
|
|
||||||
await updateSentMany(
|
|
||||||
items.map((notification) => notification.id),
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
setNotificationList(
|
return () => {
|
||||||
items.map((a) => ({
|
clearInterval(intervalId)
|
||||||
id: a.id,
|
document.removeEventListener('visibilitychange', onForeground)
|
||||||
notificationType: a.notificationType,
|
window.removeEventListener('focus', onForeground)
|
||||||
date: a.creationTime.toLocaleString(),
|
}
|
||||||
message: a.message,
|
}, [getInbox, getPushNotifications])
|
||||||
creatorId: a.creatorId,
|
|
||||||
tenantId: a.tenantId,
|
const onNotificationOpen = useCallback(async () => {
|
||||||
readed: a.isRead,
|
setLoading(!listLoaded.current)
|
||||||
})) as NotificationList[],
|
try {
|
||||||
)
|
const items = await getInbox()
|
||||||
listLoaded.current = true
|
|
||||||
|
// Panelde görülen kayıtlar için ayrıca toast/desktop açılmasın. isSent
|
||||||
|
// yalnızca tarayıcı kanallarında UI'nin sorumluluğunda; Mail/Sms gibi
|
||||||
|
// kanalların gönderim durumu backend'e ait olduğu için dokunulmaz.
|
||||||
|
const unsentIds = items
|
||||||
|
.filter((a) => !a.isSent && pushChannels.includes(a.notificationChannel))
|
||||||
|
.map((a) => a.id)
|
||||||
|
if (unsentIds.length > 0) {
|
||||||
|
unsentIds.forEach((id) => pushedIds.current.add(id))
|
||||||
|
await updateSentMany(unsentIds, true)
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [notificationList, unreadNotificationCount])
|
}, [getInbox])
|
||||||
|
|
||||||
const onMarkAllAsRead = useCallback(async () => {
|
const onMarkAllAsRead = useCallback(async () => {
|
||||||
await updateReadAll(NotificationChannels.UiActivity, true)
|
await Promise.all(inboxChannels.map((channel) => updateReadAll(channel, true)))
|
||||||
|
|
||||||
setNotificationList((list) => list.map((item) => ({ ...item, readed: true })))
|
setNotificationList((list) => list.map((item) => ({ ...item, readed: true })))
|
||||||
setUnreadNotificationCount(0)
|
setUnreadNotificationCount(0)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const onMarkAsRead = useCallback(
|
const onMarkAsRead = useCallback(async (item: NotificationListItem) => {
|
||||||
async (id: string) => {
|
if (item.readed) {
|
||||||
const target = notificationList.find((item) => item.id === id)
|
return
|
||||||
if (!target || target.readed) {
|
}
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await updateRead(id, true)
|
await updateReadMany(item.ids, true)
|
||||||
|
|
||||||
setNotificationList((list) =>
|
setNotificationList((list) =>
|
||||||
list.map((item) => (item.id === id ? { ...item, readed: true } : item)),
|
list.map((a) => (a.ids[0] === item.ids[0] ? { ...a, readed: true } : a)),
|
||||||
)
|
)
|
||||||
setUnreadNotificationCount((count) => Math.max(0, count - 1))
|
setUnreadNotificationCount((count) => Math.max(0, count - 1))
|
||||||
},
|
}, [])
|
||||||
[notificationList],
|
|
||||||
)
|
|
||||||
|
|
||||||
const noResult = !loading && listLoaded.current && notificationList.length === 0
|
const noResult = !loading && listLoaded.current && notificationList.length === 0
|
||||||
|
|
||||||
|
|
@ -244,7 +337,7 @@ const _Notification = ({ className }: { className?: string }) => {
|
||||||
renderTitle={
|
renderTitle={
|
||||||
<NotificationToggle unreadCount={unreadNotificationCount} className={className} />
|
<NotificationToggle unreadCount={unreadNotificationCount} className={className} />
|
||||||
}
|
}
|
||||||
menuClass="p-0 min-w-[280px] md:min-w-[340px]"
|
menuClass="p-0 min-w-[320px] md:min-w-[440px]"
|
||||||
placement={larger.md ? 'bottom-end' : 'bottom-center'}
|
placement={larger.md ? 'bottom-end' : 'bottom-center'}
|
||||||
onOpen={onNotificationOpen}
|
onOpen={onNotificationOpen}
|
||||||
>
|
>
|
||||||
|
|
@ -266,24 +359,36 @@ const _Notification = ({ className }: { className?: string }) => {
|
||||||
<ScrollBar direction={direction}>
|
<ScrollBar direction={direction}>
|
||||||
{notificationList.map((item, index) => (
|
{notificationList.map((item, index) => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.ids[0]}
|
||||||
className={`relative flex px-4 py-4 cursor-pointer hover:bg-gray-50 active:bg-gray-100 dark:hover:bg-black dark:hover:bg-opacity-20 ${
|
className={`relative flex px-4 py-4 cursor-pointer hover:bg-gray-50 active:bg-gray-100 dark:hover:bg-black dark:hover:bg-opacity-20 ${
|
||||||
!isLastChild(notificationList, index)
|
!isLastChild(notificationList, index)
|
||||||
? 'border-b border-gray-200 dark:border-gray-600'
|
? 'border-b border-gray-200 dark:border-gray-600'
|
||||||
: ''
|
: ''
|
||||||
}`}
|
}`}
|
||||||
onClick={() => onMarkAsRead(item.id)}
|
onClick={() => onMarkAsRead(item)}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<Avatar shape="circle" src={AVATAR_URL(item.creatorId, item.tenantId)} />
|
<Avatar shape="circle" src={AVATAR_URL(item.creatorId, item.tenantId)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="ltr:ml-3 rtl:mr-3">
|
<div className="ltr:ml-3 rtl:mr-3 ltr:mr-6 rtl:ml-6 min-w-0">
|
||||||
<div>
|
<div>
|
||||||
{item.notificationType && (
|
{item.notificationType && (
|
||||||
<span className="font-semibold heading-text">{item.notificationType} </span>
|
<span className="font-semibold heading-text">{item.notificationType} </span>
|
||||||
)}
|
)}
|
||||||
<div>{item.message}</div>
|
<div>{item.message}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||||
|
{item.channels.map((channel) => (
|
||||||
|
<Tag
|
||||||
|
key={channel}
|
||||||
|
prefix
|
||||||
|
className="text-xs"
|
||||||
|
prefixClass={NotificationChannelColors[channel]}
|
||||||
|
>
|
||||||
|
{channel}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<span className="text-xs">{dayjs(item.date).fromNow()}</span>
|
<span className="text-xs">{dayjs(item.date).fromNow()}</span>
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<Badge
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,12 @@ export class SqlObjectManagerService {
|
||||||
{ apiName: this.apiName, ...config },
|
{ apiName: this.apiName, ...config },
|
||||||
)
|
)
|
||||||
|
|
||||||
getTableColumns = (dataSourceCode: string, schemaName: string, tableName: string, config?: Partial<Config>) =>
|
getTableColumns = (
|
||||||
|
dataSourceCode: string,
|
||||||
|
schemaName: string,
|
||||||
|
tableName: string,
|
||||||
|
config?: Partial<Config>,
|
||||||
|
) =>
|
||||||
apiService.fetchData<DatabaseColumnDto[], void>(
|
apiService.fetchData<DatabaseColumnDto[], void>(
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
|
@ -39,7 +44,12 @@ export class SqlObjectManagerService {
|
||||||
{ apiName: this.apiName, ...config },
|
{ apiName: this.apiName, ...config },
|
||||||
)
|
)
|
||||||
|
|
||||||
getTableCreateScript = (dataSourceCode: string, schemaName: string, tableName: string, config?: Partial<Config>) =>
|
getTableCreateScript = (
|
||||||
|
dataSourceCode: string,
|
||||||
|
schemaName: string,
|
||||||
|
tableName: string,
|
||||||
|
config?: Partial<Config>,
|
||||||
|
) =>
|
||||||
apiService.fetchData<string, void>(
|
apiService.fetchData<string, void>(
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
|
@ -49,7 +59,12 @@ export class SqlObjectManagerService {
|
||||||
{ apiName: this.apiName, ...config },
|
{ apiName: this.apiName, ...config },
|
||||||
)
|
)
|
||||||
|
|
||||||
getNativeObjectDefinition = (dataSourceCode: string, schemaName: string, objectName: string, config?: Partial<Config>) =>
|
getNativeObjectDefinition = (
|
||||||
|
dataSourceCode: string,
|
||||||
|
schemaName: string,
|
||||||
|
objectName: string,
|
||||||
|
config?: Partial<Config>,
|
||||||
|
) =>
|
||||||
apiService.fetchData<string, void>(
|
apiService.fetchData<string, void>(
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
|
@ -78,42 +93,6 @@ export class SqlObjectManagerService {
|
||||||
},
|
},
|
||||||
{ apiName: this.apiName, ...config },
|
{ apiName: this.apiName, ...config },
|
||||||
)
|
)
|
||||||
|
|
||||||
getSqlDataFiles = (dataDirectoryName = 'SqlData', relativePath = '', config?: Partial<Config>) =>
|
|
||||||
apiService.fetchData<
|
|
||||||
{ fileName: string; name: string; relativePath: string; isDirectory: boolean; createdAt: string }[],
|
|
||||||
void
|
|
||||||
>(
|
|
||||||
{
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/app/sql-object-manager/sql-data-files',
|
|
||||||
params: { dataDirectoryName, relativePath },
|
|
||||||
},
|
|
||||||
{ apiName: this.apiName, ...config },
|
|
||||||
)
|
|
||||||
|
|
||||||
getSqlDataFileContent = (dataDirectoryName = 'SqlData', relativePath: string, config?: Partial<Config>) =>
|
|
||||||
apiService.fetchData<string, void>(
|
|
||||||
{
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/app/sql-object-manager/sql-data-file-content',
|
|
||||||
params: { dataDirectoryName, relativePath },
|
|
||||||
},
|
|
||||||
{ apiName: this.apiName, ...config },
|
|
||||||
)
|
|
||||||
|
|
||||||
moveSqlDataFile = (
|
|
||||||
input: { dataDirectoryName: string; sourceRelativePath: string; targetRelativePath: string },
|
|
||||||
config?: Partial<Config>,
|
|
||||||
) =>
|
|
||||||
apiService.fetchData<void, { dataDirectoryName: string; sourceRelativePath: string; targetRelativePath: string }>(
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/app/sql-object-manager/move-sql-data-file',
|
|
||||||
data: input,
|
|
||||||
},
|
|
||||||
{ apiName: this.apiName, ...config },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sqlObjectManagerService = new SqlObjectManagerService()
|
export const sqlObjectManagerService = new SqlObjectManagerService()
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,12 @@ const buildDownloadUrl = (item: FileItemType, fallbackTenantId?: string): string
|
||||||
return `${VITE_CDN_URL}/${tenantSegment}/${segments.map(encodeURIComponent).join('/')}`
|
return `${VITE_CDN_URL}/${tenantSegment}/${segments.map(encodeURIComponent).join('/')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** DbMigrator Seeds klasoru CDN altinda degil, backend sanal kok olarak sunar. */
|
||||||
|
const isSeedsItem = (item: FileItemType): boolean => {
|
||||||
|
const relativePath = item.path || item.id.replace(/\|/g, '/')
|
||||||
|
return relativePath.split('/')[0]?.toLowerCase() === 'seeds'
|
||||||
|
}
|
||||||
|
|
||||||
/** Keyboard shortcuts must not hijack typing inside form controls. */
|
/** Keyboard shortcuts must not hijack typing inside form controls. */
|
||||||
const isEditableTarget = (target: EventTarget | null): boolean => {
|
const isEditableTarget = (target: EventTarget | null): boolean => {
|
||||||
const element = target as HTMLElement | null
|
const element = target as HTMLElement | null
|
||||||
|
|
@ -253,9 +259,7 @@ const FileManager = () => {
|
||||||
case 'type':
|
case 'type':
|
||||||
return a.type.localeCompare(b.type) * direction
|
return a.type.localeCompare(b.type) * direction
|
||||||
case 'modified':
|
case 'modified':
|
||||||
return (
|
return (new Date(a.modifiedAt).getTime() - new Date(b.modifiedAt).getTime()) * direction
|
||||||
(new Date(a.modifiedAt).getTime() - new Date(b.modifiedAt).getTime()) * direction
|
|
||||||
)
|
|
||||||
default:
|
default:
|
||||||
return a.name.localeCompare(b.name) * direction
|
return a.name.localeCompare(b.name) * direction
|
||||||
}
|
}
|
||||||
|
|
@ -360,10 +364,7 @@ const FileManager = () => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setRenaming(true)
|
setRenaming(true)
|
||||||
await fileManagementService.renameItem(
|
await fileManagementService.renameItem({ id: itemToRename.id, newName }, currentTenantId)
|
||||||
{ id: itemToRename.id, newName },
|
|
||||||
currentTenantId,
|
|
||||||
)
|
|
||||||
// Ids are path based, so the renamed item's id is no longer valid
|
// Ids are path based, so the renamed item's id is no longer valid
|
||||||
setSelectedItems([])
|
setSelectedItems([])
|
||||||
await refresh()
|
await refresh()
|
||||||
|
|
@ -406,22 +407,41 @@ const FileManager = () => {
|
||||||
}
|
}
|
||||||
}, [currentTenantId, itemsToDelete, notify, refresh])
|
}, [currentTenantId, itemsToDelete, notify, refresh])
|
||||||
|
|
||||||
|
const triggerDownload = (href: string, fileName: string) => {
|
||||||
|
const anchor = document.createElement('a')
|
||||||
|
anchor.href = href
|
||||||
|
anchor.download = fileName
|
||||||
|
anchor.target = '_blank'
|
||||||
|
anchor.rel = 'noopener noreferrer'
|
||||||
|
document.body.appendChild(anchor)
|
||||||
|
anchor.click()
|
||||||
|
document.body.removeChild(anchor)
|
||||||
|
}
|
||||||
|
|
||||||
const handleDownload = useCallback(
|
const handleDownload = useCallback(
|
||||||
(item: FileItemType) => {
|
async (item: FileItemType) => {
|
||||||
|
// Sanal Seeds klasoru CDN altinda olmadigi icin dosya API uzerinden stream edilir.
|
||||||
|
if (isSeedsItem(item)) {
|
||||||
|
try {
|
||||||
|
const blob = await fileManagementService.downloadFile(item.id, currentTenantId)
|
||||||
|
const objectUrl = URL.createObjectURL(blob)
|
||||||
|
triggerDownload(objectUrl, item.name)
|
||||||
|
URL.revokeObjectURL(objectUrl)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Download failed:', error)
|
||||||
|
notify('danger', 'Failed to download file')
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const url = buildDownloadUrl(item, currentTenantId)
|
const url = buildDownloadUrl(item, currentTenantId)
|
||||||
if (!url) {
|
if (!url) {
|
||||||
notify('danger', 'File location is invalid')
|
notify('danger', 'File location is invalid')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const anchor = document.createElement('a')
|
triggerDownload(url, item.name)
|
||||||
anchor.href = url
|
|
||||||
anchor.download = item.name
|
|
||||||
anchor.target = '_blank'
|
|
||||||
anchor.rel = 'noopener noreferrer'
|
|
||||||
document.body.appendChild(anchor)
|
|
||||||
anchor.click()
|
|
||||||
document.body.removeChild(anchor)
|
|
||||||
},
|
},
|
||||||
[currentTenantId, notify],
|
[currentTenantId, notify],
|
||||||
)
|
)
|
||||||
|
|
@ -889,7 +909,9 @@ const FileManager = () => {
|
||||||
icon={allSelected ? <FaCheckSquare /> : <FaSquare />}
|
icon={allSelected ? <FaCheckSquare /> : <FaSquare />}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-gray-600 hover:text-blue-600 flex-shrink-0"
|
className="text-gray-600 hover:text-blue-600 flex-shrink-0"
|
||||||
title={allSelected ? 'Deselect all selectable items' : 'Select all selectable items'}
|
title={
|
||||||
|
allSelected ? 'Deselect all selectable items' : 'Select all selectable items'
|
||||||
|
}
|
||||||
onClick={allSelected ? deselectAllItems : selectAllItems}
|
onClick={allSelected ? deselectAllItems : selectAllItems}
|
||||||
>
|
>
|
||||||
<span className="hidden lg:inline">
|
<span className="hidden lg:inline">
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,7 @@ const getFileTypeLabel = (item: FileItemType, translate: (key: string) => string
|
||||||
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(extension || ''))
|
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(extension || ''))
|
||||||
return translate('::FileManager.Archive') // 'Arşiv'
|
return translate('::FileManager.Archive') // 'Arşiv'
|
||||||
if (['txt', 'md'].includes(extension || '')) return translate('::FileManager.Text') // 'Metin'
|
if (['txt', 'md'].includes(extension || '')) return translate('::FileManager.Text') // 'Metin'
|
||||||
if (['json', 'xml', 'css', 'js', 'ts', 'html'].includes(extension || ''))
|
if (['xml', 'css', 'js', 'ts', 'html'].includes(extension || ''))
|
||||||
return translate('::FileManager.Code') // 'Kod'
|
return translate('::FileManager.Code') // 'Kod'
|
||||||
|
|
||||||
return extension?.toUpperCase() || translate('::FileManager.File')
|
return extension?.toUpperCase() || translate('::FileManager.File')
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { lazy, Suspense, useState, useCallback, useEffect, useRef } from 'react'
|
import { lazy, Suspense, useState, useCallback, useEffect, useRef } from 'react'
|
||||||
import type { Dispatch, SetStateAction } from 'react'
|
import { Button, Dialog, Notification, toast } from '@/components/ui'
|
||||||
import { Button, Checkbox, Dialog, Notification, toast } from '@/components/ui'
|
|
||||||
import Container from '@/components/shared/Container'
|
import Container from '@/components/shared/Container'
|
||||||
import { getDataSources } from '@/services/data-source.service'
|
import { getDataSources } from '@/services/data-source.service'
|
||||||
import type { DataSourceDto } from '@/proxy/data-source'
|
import type { DataSourceDto } from '@/proxy/data-source'
|
||||||
|
|
@ -13,12 +12,11 @@ import {
|
||||||
FaFileAlt,
|
FaFileAlt,
|
||||||
FaCopy,
|
FaCopy,
|
||||||
FaExclamationTriangle,
|
FaExclamationTriangle,
|
||||||
FaArrowLeft,
|
|
||||||
FaArrowRight,
|
|
||||||
FaEye,
|
|
||||||
FaCheckCircle,
|
FaCheckCircle,
|
||||||
FaFolderOpen,
|
FaFolderOpen,
|
||||||
} from 'react-icons/fa'
|
} from 'react-icons/fa'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { ROUTES_ENUM } from '@/routes/route.constant'
|
||||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||||
import SqlObjectExplorer, { type SqlExplorerSelectedObject } from './SqlObjectExplorer'
|
import SqlObjectExplorer, { type SqlExplorerSelectedObject } from './SqlObjectExplorer'
|
||||||
import SqlEditor, { SqlEditorRef } from './SqlEditor'
|
import SqlEditor, { SqlEditorRef } from './SqlEditor'
|
||||||
|
|
@ -52,14 +50,6 @@ interface SqlCopyResultItem {
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SqlDataExplorerEntry {
|
|
||||||
fileName: string
|
|
||||||
name: string
|
|
||||||
relativePath: string
|
|
||||||
isDirectory: boolean
|
|
||||||
createdAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const SqlQueryManager = () => {
|
const SqlQueryManager = () => {
|
||||||
const { translate } = useLocalization()
|
const { translate } = useLocalization()
|
||||||
const editorRef = useRef<SqlEditorRef>(null)
|
const editorRef = useRef<SqlEditorRef>(null)
|
||||||
|
|
@ -98,14 +88,6 @@ const SqlQueryManager = () => {
|
||||||
const [showCopyResultDialog, setShowCopyResultDialog] = useState(false)
|
const [showCopyResultDialog, setShowCopyResultDialog] = useState(false)
|
||||||
const [copyDialogMode, setCopyDialogMode] = useState<'objects' | 'sql'>('objects')
|
const [copyDialogMode, setCopyDialogMode] = useState<'objects' | 'sql'>('objects')
|
||||||
const [sqlScriptForCopy, setSqlScriptForCopy] = useState('')
|
const [sqlScriptForCopy, setSqlScriptForCopy] = useState('')
|
||||||
const [showSqlDataFilesDialog, setShowSqlDataFilesDialog] = useState(false)
|
|
||||||
const [isLoadingSqlDataFiles, setIsLoadingSqlDataFiles] = useState(false)
|
|
||||||
const [isMovingSqlDataFile, setIsMovingSqlDataFile] = useState(false)
|
|
||||||
const [isPreviewingSqlDataFile, setIsPreviewingSqlDataFile] = useState(false)
|
|
||||||
const [sqlDataRootFiles, setSqlDataRootFiles] = useState<SqlDataExplorerEntry[]>([])
|
|
||||||
const [sqlDataHostFiles, setSqlDataHostFiles] = useState<SqlDataExplorerEntry[]>([])
|
|
||||||
const [selectedRootSqlDataFiles, setSelectedRootSqlDataFiles] = useState<string[]>([])
|
|
||||||
const [selectedHostSqlDataFiles, setSelectedHostSqlDataFiles] = useState<string[]>([])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadDataSources()
|
loadDataSources()
|
||||||
|
|
@ -174,7 +156,6 @@ const SqlQueryManager = () => {
|
||||||
(item) => item.code === state.selectedDataSource,
|
(item) => item.code === state.selectedDataSource,
|
||||||
)?.dataSourceType
|
)?.dataSourceType
|
||||||
const isPostgreSql = selectedDataSourceType === DataSourceTypeEnum.Postgresql
|
const isPostgreSql = selectedDataSourceType === DataSourceTypeEnum.Postgresql
|
||||||
const sqlDataDirectoryName = isPostgreSql ? 'PostgresData' : 'SqlData'
|
|
||||||
|
|
||||||
const getTableCreateScript = async (schemaName: string, tableName: string): Promise<string> => {
|
const getTableCreateScript = async (schemaName: string, tableName: string): Promise<string> => {
|
||||||
if (!state.selectedDataSource) return ''
|
if (!state.selectedDataSource) return ''
|
||||||
|
|
@ -920,112 +901,6 @@ GO`,
|
||||||
const copyErrorCount = copyResults.filter((x) => x.status === 'error').length
|
const copyErrorCount = copyResults.filter((x) => x.status === 'error').length
|
||||||
const copySkippedCount = copyResults.filter((x) => x.status === 'skipped').length
|
const copySkippedCount = copyResults.filter((x) => x.status === 'skipped').length
|
||||||
|
|
||||||
const loadSqlDataFiles = async () => {
|
|
||||||
setIsLoadingSqlDataFiles(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [rootResponse, hostResponse] = await Promise.all([
|
|
||||||
sqlObjectManagerService.getSqlDataFiles(sqlDataDirectoryName, ''),
|
|
||||||
sqlObjectManagerService.getSqlDataFiles(sqlDataDirectoryName, 'HostData'),
|
|
||||||
])
|
|
||||||
|
|
||||||
setSqlDataRootFiles(normalizeSqlDataEntries(rootResponse.data || [], ''))
|
|
||||||
setSqlDataHostFiles(normalizeSqlDataEntries(hostResponse.data || [], 'HostData'))
|
|
||||||
setSelectedRootSqlDataFiles([])
|
|
||||||
setSelectedHostSqlDataFiles([])
|
|
||||||
} catch (error: any) {
|
|
||||||
setSqlDataRootFiles([])
|
|
||||||
setSqlDataHostFiles([])
|
|
||||||
setSelectedRootSqlDataFiles([])
|
|
||||||
setSelectedHostSqlDataFiles([])
|
|
||||||
toast.push(
|
|
||||||
<Notification type="danger" title={translate('::App.Platform.Error')}>
|
|
||||||
{error.response?.data?.error?.message ||
|
|
||||||
translate('::App.Platform.FailedToLoadFiles') ||
|
|
||||||
'SQL dosya listesi yuklenemedi.'}
|
|
||||||
</Notification>,
|
|
||||||
{ placement: 'bottom-end' },
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
setIsLoadingSqlDataFiles(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleOpenSqlDataFilesDialog = async () => {
|
|
||||||
setShowSqlDataFilesDialog(true)
|
|
||||||
await loadSqlDataFiles()
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizeSqlDataEntries = (
|
|
||||||
files: SqlDataExplorerEntry[],
|
|
||||||
parentRelativePath: '' | 'HostData',
|
|
||||||
) =>
|
|
||||||
files
|
|
||||||
.filter((file) => !file.isDirectory)
|
|
||||||
.map((file) => {
|
|
||||||
const fileName = file.name || file.fileName || file.relativePath?.split('/').pop() || ''
|
|
||||||
const relativePath =
|
|
||||||
file.relativePath || (parentRelativePath ? `${parentRelativePath}/${fileName}` : fileName)
|
|
||||||
|
|
||||||
return {
|
|
||||||
...file,
|
|
||||||
fileName,
|
|
||||||
name: fileName,
|
|
||||||
relativePath,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((file) => Boolean(file.name && file.relativePath))
|
|
||||||
|
|
||||||
const toggleSqlDataFileSelection = (
|
|
||||||
setSelectedFiles: Dispatch<SetStateAction<string[]>>,
|
|
||||||
relativePath: string,
|
|
||||||
) => {
|
|
||||||
setSelectedFiles((current) =>
|
|
||||||
current.includes(relativePath)
|
|
||||||
? current.filter((item) => item !== relativePath)
|
|
||||||
: [...current, relativePath],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMoveSqlDataFiles = async (direction: 'toHostData' | 'toRoot') => {
|
|
||||||
const selectedRelativePaths =
|
|
||||||
direction === 'toHostData' ? selectedRootSqlDataFiles : selectedHostSqlDataFiles
|
|
||||||
|
|
||||||
if (selectedRelativePaths.length === 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsMovingSqlDataFile(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (const sourceRelativePath of selectedRelativePaths) {
|
|
||||||
const name = sourceRelativePath.split('/').pop() || sourceRelativePath
|
|
||||||
await sqlObjectManagerService.moveSqlDataFile({
|
|
||||||
dataDirectoryName: sqlDataDirectoryName,
|
|
||||||
sourceRelativePath,
|
|
||||||
targetRelativePath: direction === 'toHostData' ? `HostData/${name}` : name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
await loadSqlDataFiles()
|
|
||||||
toast.push(
|
|
||||||
<Notification type="success" title={translate('::App.Platform.Success')}>
|
|
||||||
{translate('::App.Platform.OperationCompleted') || 'Dosya tasindi.'}
|
|
||||||
</Notification>,
|
|
||||||
{ placement: 'bottom-end' },
|
|
||||||
)
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.push(
|
|
||||||
<Notification type="danger" title={translate('::App.Platform.Error')}>
|
|
||||||
{error.response?.data?.error?.message || 'Dosya tasinamadi.'}
|
|
||||||
</Notification>,
|
|
||||||
{ placement: 'bottom-end' },
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
setIsMovingSqlDataFile(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleResultsMutation = async (mutationQuery: string) => {
|
const handleResultsMutation = async (mutationQuery: string) => {
|
||||||
if (!state.selectedDataSource || !state.lastExecutedQuery) return
|
if (!state.selectedDataSource || !state.lastExecutedQuery) return
|
||||||
|
|
||||||
|
|
@ -1054,125 +929,6 @@ GO`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePreviewSqlDataFile = async (file: SqlDataExplorerEntry) => {
|
|
||||||
if (!file.relativePath || isPreviewingSqlDataFile) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsPreviewingSqlDataFile(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await sqlObjectManagerService.getSqlDataFileContent(
|
|
||||||
sqlDataDirectoryName,
|
|
||||||
file.relativePath,
|
|
||||||
)
|
|
||||||
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
editorContent: response.data || '',
|
|
||||||
executionResult: null,
|
|
||||||
tableColumns: null,
|
|
||||||
isDirty: false,
|
|
||||||
}))
|
|
||||||
setShowSqlDataFilesDialog(false)
|
|
||||||
|
|
||||||
toast.push(
|
|
||||||
<Notification type="success" title={translate('::App.Platform.Success')}>
|
|
||||||
{translate('::App.SqlQueryManager.ScriptLoadedToEditor') ||
|
|
||||||
'SQL dosyasi Query Editor icine yuklendi.'}
|
|
||||||
</Notification>,
|
|
||||||
{ placement: 'bottom-end' },
|
|
||||||
)
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.push(
|
|
||||||
<Notification type="danger" title={translate('::App.Platform.Error')}>
|
|
||||||
{error.response?.data?.error?.message || 'SQL dosyasi okunamadi.'}
|
|
||||||
</Notification>,
|
|
||||||
{ placement: 'bottom-end' },
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
setIsPreviewingSqlDataFile(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderSqlDataPane = (
|
|
||||||
title: string,
|
|
||||||
files: SqlDataExplorerEntry[],
|
|
||||||
selectedFiles: string[],
|
|
||||||
setSelectedFiles: Dispatch<SetStateAction<string[]>>,
|
|
||||||
) => (
|
|
||||||
<div className="flex min-h-[260px] flex-1 flex-col overflow-hidden rounded border border-gray-200 dark:border-gray-700">
|
|
||||||
<div className="flex shrink-0 items-center justify-between border-b border-gray-200 bg-gray-50 px-3 py-2 dark:border-gray-700 dark:bg-gray-800">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h6 className="text-sm font-semibold text-gray-800 dark:text-gray-100">{title}</h6>
|
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{selectedFiles.length}/{files.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Checkbox
|
|
||||||
checked={files.length > 0 && selectedFiles.length === files.length}
|
|
||||||
disabled={files.length === 0 || isMovingSqlDataFile}
|
|
||||||
onChange={(event) =>
|
|
||||||
setSelectedFiles(event ? files.map((file) => file.relativePath) : [])
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{translate('::FileManager.SelectAll')}
|
|
||||||
</Checkbox>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
{files.length === 0 ? (
|
|
||||||
<p className="px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{translate('::App.SqlQueryManager.NoSqlDataFiles') || 'Dosya bulunamadi.'}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<ul className="divide-y divide-gray-200 dark:divide-gray-700">
|
|
||||||
{files.map((file) => {
|
|
||||||
const selected = selectedFiles.includes(file.relativePath)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li key={file.relativePath || file.fileName}>
|
|
||||||
<div
|
|
||||||
className={`flex cursor-pointer items-center gap-3 px-3 py-2 text-xs ${
|
|
||||||
selected
|
|
||||||
? 'bg-blue-50 text-blue-700 dark:bg-blue-950/40 dark:text-blue-200'
|
|
||||||
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-800'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<label className="flex min-w-0 flex-1 cursor-pointer items-center gap-3">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="h-4 w-4 shrink-0"
|
|
||||||
checked={selected}
|
|
||||||
disabled={isMovingSqlDataFile || isPreviewingSqlDataFile}
|
|
||||||
onChange={() =>
|
|
||||||
toggleSqlDataFileSelection(setSelectedFiles, file.relativePath)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<FaFileAlt className="shrink-0 text-gray-400" />
|
|
||||||
<span className="min-w-0 flex-1 truncate">{file.name || file.fileName}</span>
|
|
||||||
</label>
|
|
||||||
<span className="hidden shrink-0 text-xs text-gray-400 dark:text-gray-500 sm:inline">
|
|
||||||
{new Date(file.createdAt).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="plain"
|
|
||||||
icon={<FaEye />}
|
|
||||||
onClick={() => handlePreviewSqlDataFile(file)}
|
|
||||||
loading={isPreviewingSqlDataFile}
|
|
||||||
disabled={isMovingSqlDataFile || isPreviewingSqlDataFile}
|
|
||||||
title={translate('::App.Platform.Preview') || 'Preview'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container
|
<Container
|
||||||
className="flex flex-col overflow-x-hidden overflow-y-auto lg:overflow-hidden"
|
className="flex flex-col overflow-x-hidden overflow-y-auto lg:overflow-hidden"
|
||||||
|
|
@ -1206,16 +962,21 @@ GO`,
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<DbMigrateButton />
|
<DbMigrateButton />
|
||||||
<Button
|
{/* Seed dosyalari (DbMigrator/Seeds) File Manager uzerinden yonetilir. */}
|
||||||
size="sm"
|
<Link to={ROUTES_ENUM.protected.admin.files} target="_blank">
|
||||||
variant="default"
|
<Button
|
||||||
icon={<FaFolderOpen />}
|
size="sm"
|
||||||
onClick={handleOpenSqlDataFilesDialog}
|
variant="default"
|
||||||
className="shadow-sm px-2 py-1"
|
icon={<FaFolderOpen />}
|
||||||
title={translate('::App.SqlQueryManager.MoveFiles')}
|
className="shadow-sm px-2 py-1"
|
||||||
>
|
title={
|
||||||
{translate('::App.SqlQueryManager.MoveFiles')}
|
translate('::App.SqlQueryManager.OpenFileManagerHint') ||
|
||||||
</Button>
|
'DbMigrator Seeds klasorunu Dosya Yoneticisi icinde yonet'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{translate('::App.SqlQueryManager.OpenFileManager') || 'Seed Dosyalari'}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||||
|
|
@ -1369,83 +1130,6 @@ GO`,
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog
|
|
||||||
isOpen={showSqlDataFilesDialog}
|
|
||||||
onClose={() => setShowSqlDataFilesDialog(false)}
|
|
||||||
onRequestClose={() => setShowSqlDataFilesDialog(false)}
|
|
||||||
width={1050}
|
|
||||||
contentClassName="max-h-[90vh] overflow-hidden"
|
|
||||||
>
|
|
||||||
<Dialog.Body className="flex max-h-[90vh] min-h-[420px] flex-col gap-2">
|
|
||||||
<div className="mb-4 shrink-0">
|
|
||||||
<div>
|
|
||||||
<h5>{translate('::App.SqlQueryManager.MoveFiles')}</h5>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoadingSqlDataFiles ? (
|
|
||||||
<p className="mb-4 text-gray-600 dark:text-gray-400">{translate('::App.Loading')}</p>
|
|
||||||
) : (
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-3 lg:flex-row">
|
|
||||||
{renderSqlDataPane(
|
|
||||||
sqlDataDirectoryName,
|
|
||||||
sqlDataRootFiles,
|
|
||||||
selectedRootSqlDataFiles,
|
|
||||||
setSelectedRootSqlDataFiles,
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex shrink-0 flex-row items-center justify-center gap-2 lg:w-24 lg:flex-col">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="default"
|
|
||||||
icon={<FaArrowRight />}
|
|
||||||
onClick={() => handleMoveSqlDataFiles('toHostData')}
|
|
||||||
loading={isMovingSqlDataFile}
|
|
||||||
disabled={isMovingSqlDataFile || selectedRootSqlDataFiles.length === 0}
|
|
||||||
title={
|
|
||||||
translate('::App.SqlQueryManager.MoveToHostData') || 'HostData klasorune tasi'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="default"
|
|
||||||
icon={<FaArrowLeft />}
|
|
||||||
onClick={() => handleMoveSqlDataFiles('toRoot')}
|
|
||||||
loading={isMovingSqlDataFile}
|
|
||||||
disabled={isMovingSqlDataFile || selectedHostSqlDataFiles.length === 0}
|
|
||||||
title={translate('::App.SqlQueryManager.MoveOut') || 'Disari tasi'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{renderSqlDataPane(
|
|
||||||
'HostData',
|
|
||||||
sqlDataHostFiles,
|
|
||||||
selectedHostSqlDataFiles,
|
|
||||||
setSelectedHostSqlDataFiles,
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Dialog.Body>
|
|
||||||
|
|
||||||
<Dialog.Footer className="flex justify-end gap-2 pt-3 mt-1">
|
|
||||||
<Button
|
|
||||||
variant="plain"
|
|
||||||
onClick={loadSqlDataFiles}
|
|
||||||
loading={isLoadingSqlDataFiles}
|
|
||||||
disabled={isLoadingSqlDataFiles || isMovingSqlDataFile}
|
|
||||||
>
|
|
||||||
{translate('::App.Platform.Refresh') || 'Yenile'}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="solid"
|
|
||||||
onClick={() => setShowSqlDataFilesDialog(false)}
|
|
||||||
disabled={isMovingSqlDataFile}
|
|
||||||
>
|
|
||||||
{translate('::Close') || translate('::Cancel') || 'Kapat'}
|
|
||||||
</Button>
|
|
||||||
</Dialog.Footer>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Template Confirmation Dialog */}
|
{/* Template Confirmation Dialog */}
|
||||||
<Dialog
|
<Dialog
|
||||||
isOpen={showTemplateConfirmDialog}
|
isOpen={showTemplateConfirmDialog}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue