DbMigrator UI
This commit is contained in:
parent
3db9fc332b
commit
c26f0cb5bc
14 changed files with 588 additions and 941 deletions
|
|
@ -0,0 +1,249 @@
|
||||||
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Serilog;
|
||||||
|
using static Sozsoft.Settings.SettingsConsts;
|
||||||
|
|
||||||
|
namespace Sozsoft.Platform;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Veritabanı henüz hazır değilken çalışan minimal kurulum uygulaması.
|
||||||
|
/// Tam ABP stack yüklemez; sadece /api/setup/* endpointlerini sunar.
|
||||||
|
/// </summary>
|
||||||
|
internal static class SetupAppRunner
|
||||||
|
{
|
||||||
|
// Veritabanı Hazırlık Kontrolü
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DB var mı ve AbpRoles tablosu oluşmuş mu diye kontrol eder.
|
||||||
|
/// Boş DB veya bağlantı hatası durumunda false döner.
|
||||||
|
/// </summary>
|
||||||
|
public static bool DatabaseIsReady(IConfiguration configuration)
|
||||||
|
{
|
||||||
|
var connectionString = configuration.GetConnectionString(DefaultDatabaseProvider);
|
||||||
|
if (string.IsNullOrWhiteSpace(connectionString))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (DefaultDatabaseProvider == DatabaseProvider.SqlServer)
|
||||||
|
return SqlServerIsReady(connectionString);
|
||||||
|
|
||||||
|
#pragma warning disable CS0162
|
||||||
|
return true; // Diğer sağlayıcılar için geçici — ileride PostgreSQL desteği eklenecek
|
||||||
|
#pragma warning restore CS0162
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning("Veritabanı hazırlık kontrolü başarısız: {Error}", ex.Message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SqlServerIsReady(string connectionString)
|
||||||
|
{
|
||||||
|
var csb = new SqlConnectionStringBuilder(connectionString);
|
||||||
|
var dbName = csb.InitialCatalog;
|
||||||
|
if (string.IsNullOrEmpty(dbName))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// 1) master'a bağlan — DB varlığını kontrol et
|
||||||
|
var masterCsb = new SqlConnectionStringBuilder(connectionString)
|
||||||
|
{
|
||||||
|
InitialCatalog = "master",
|
||||||
|
ConnectTimeout = 8
|
||||||
|
};
|
||||||
|
|
||||||
|
using var masterConn = new SqlConnection(masterCsb.ConnectionString);
|
||||||
|
masterConn.Open();
|
||||||
|
|
||||||
|
using var dbCheck = new SqlCommand(
|
||||||
|
"SELECT COUNT(1) FROM sys.databases WHERE name = @n", masterConn);
|
||||||
|
dbCheck.Parameters.AddWithValue("@n", dbName);
|
||||||
|
if ((int)dbCheck.ExecuteScalar() == 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// 2) Hedef DB'ye bağlan — AbpRoles tablosunun varlığını kontrol et
|
||||||
|
csb.ConnectTimeout = 8;
|
||||||
|
using var dbConn = new SqlConnection(csb.ConnectionString);
|
||||||
|
dbConn.Open();
|
||||||
|
|
||||||
|
using var tableCheck = new SqlCommand(
|
||||||
|
"SELECT COUNT(1) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'AbpRoles'",
|
||||||
|
dbConn);
|
||||||
|
return (int)tableCheck.ExecuteScalar() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal Kurulum Uygulaması
|
||||||
|
|
||||||
|
public static async Task<int> RunAsync(string[] args, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
Log.Warning("Veritabanı hazır değil — kurulum modu başlatılıyor.");
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
var extraOrigins = (configuration["App:CorsOrigins"] ?? "")
|
||||||
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
var baseDomain = configuration["App:BaseDomain"]?.Trim();
|
||||||
|
|
||||||
|
builder.Services.AddCors(o => o.AddPolicy("Setup", policy =>
|
||||||
|
policy.AllowAnyHeader()
|
||||||
|
.AllowAnyMethod()
|
||||||
|
.AllowCredentials()
|
||||||
|
.SetIsOriginAllowed(origin =>
|
||||||
|
{
|
||||||
|
if (!Uri.TryCreate(origin, UriKind.Absolute, out var uri)) return false;
|
||||||
|
var host = uri.Host.ToLowerInvariant();
|
||||||
|
|
||||||
|
if (host is "localhost" or "127.0.0.1" or "[::1]")
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(baseDomain))
|
||||||
|
{
|
||||||
|
var bd = baseDomain.ToLowerInvariant();
|
||||||
|
if (host == bd || host.EndsWith("." + bd))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var o in extraOrigins)
|
||||||
|
if (Uri.TryCreate(o, UriKind.Absolute, out var eo) &&
|
||||||
|
eo.Host.Equals(host, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
})));
|
||||||
|
|
||||||
|
builder.Host.UseSerilog();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
app.UseCors("Setup");
|
||||||
|
|
||||||
|
app.MapGet("/api/setup/status", (IConfiguration cfg) =>
|
||||||
|
Results.Ok(new { dbExists = DatabaseIsReady(cfg) }));
|
||||||
|
|
||||||
|
app.MapGet("/api/setup/migrate", async (IConfiguration cfg, IHostEnvironment env,
|
||||||
|
IHostApplicationLifetime lifetime, HttpContext ctx, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
ctx.Response.ContentType = "text/event-stream; charset=utf-8";
|
||||||
|
ctx.Response.Headers["Cache-Control"] = "no-cache, no-store";
|
||||||
|
ctx.Response.Headers["X-Accel-Buffering"] = "no";
|
||||||
|
await ctx.Response.Body.FlushAsync(ct);
|
||||||
|
|
||||||
|
async Task Send(string level, string message)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var payload = JsonSerializer.Serialize(new { level, message });
|
||||||
|
await ctx.Response.WriteAsync($"data: {payload}\n\n", ct);
|
||||||
|
await ctx.Response.Body.FlushAsync(ct);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DatabaseIsReady(cfg))
|
||||||
|
{
|
||||||
|
await Send("warn", "Veritabanı zaten hazır. Migration atlanıyor.");
|
||||||
|
await Send("done", "Tamamlandı.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var migratorPath = cfg["Setup:MigratorPath"]
|
||||||
|
?? Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "Sozsoft.Platform.DbMigrator"));
|
||||||
|
|
||||||
|
await Send("info", "Veritabanı migration ve seed başlatılıyor...");
|
||||||
|
await Send("info", $"Migrator yolu: {migratorPath}");
|
||||||
|
|
||||||
|
var fileName = "dotnet";
|
||||||
|
var arguments = migratorPath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? $"\"{migratorPath}\""
|
||||||
|
: $"run --project \"{migratorPath}\"";
|
||||||
|
|
||||||
|
// seed=true her zaman geçirilir — başlangıç kurulumu için zorunlu.
|
||||||
|
// appsettings Setup:MigratorArgs ile override edilebilir.
|
||||||
|
var extraArgs = cfg["Setup:MigratorArgs"] ?? "seed=true";
|
||||||
|
arguments += $" -- {extraArgs}";
|
||||||
|
|
||||||
|
await Send("info", $"Çalıştırılıyor: {fileName} {arguments}");
|
||||||
|
|
||||||
|
Process? process = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
process = new Process
|
||||||
|
{
|
||||||
|
StartInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = fileName,
|
||||||
|
Arguments = arguments,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
WorkingDirectory = migratorPath,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
process.Start();
|
||||||
|
|
||||||
|
async Task ReadStream(StreamReader reader, string level)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (!reader.EndOfStream)
|
||||||
|
{
|
||||||
|
var line = await reader.ReadLineAsync(ct);
|
||||||
|
if (line != null) await Send(level, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.WhenAll(
|
||||||
|
ReadStream(process.StandardOutput, "info"),
|
||||||
|
ReadStream(process.StandardError, "warn"));
|
||||||
|
|
||||||
|
await process.WaitForExitAsync(ct);
|
||||||
|
|
||||||
|
if (process.ExitCode == 0)
|
||||||
|
{
|
||||||
|
await Send("success", "Migration ve seed başarıyla tamamlandı.");
|
||||||
|
await Send("restart", "Uygulama sunucusu yeniden başlatılıyor...");
|
||||||
|
await Send("done", "Tamamlandı.");
|
||||||
|
|
||||||
|
_ = Task.Delay(1500).ContinueWith(_ => lifetime.StopApplication());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await Send("error", $"Migration başarısız. Çıkış kodu: {process.ExitCode}");
|
||||||
|
await Send("done", "Hata ile sonlandı.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
await Send("warn", "Migration isteği iptal edildi.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await Send("error", $"Migration hatası: {ex.Message}");
|
||||||
|
await Send("done", "Hata ile sonlandı.");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
process?.Dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapFallback(() => Results.StatusCode(503));
|
||||||
|
|
||||||
|
await app.RunAsync();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,6 @@ using System.Threading.Tasks;
|
||||||
using Sozsoft.Platform.Enums;
|
using Sozsoft.Platform.Enums;
|
||||||
using Sozsoft.Platform.DynamicServices;
|
using Sozsoft.Platform.DynamicServices;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
@ -26,6 +25,24 @@ public class Program
|
||||||
.AddJsonFile($"appsettings.{Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? ""}.json", true)
|
.AddJsonFile($"appsettings.{Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? ""}.json", true)
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
|
// ── Veritabanı varlık kontrolü ────────────────────────────────────────
|
||||||
|
// Serilog SQL sink kurulmadan ve ABP modülleri yüklenmeden önce yapılmalı.
|
||||||
|
// DB yoksa minimal setup uygulaması çalıştırılır.
|
||||||
|
Log.Logger = new LoggerConfiguration()
|
||||||
|
.MinimumLevel.Warning()
|
||||||
|
.WriteTo.Console()
|
||||||
|
.CreateLogger();
|
||||||
|
|
||||||
|
if (!SetupAppRunner.DatabaseIsReady(configuration))
|
||||||
|
{
|
||||||
|
var setupResult = await SetupAppRunner.RunAsync(args, configuration);
|
||||||
|
if (setupResult != 0)
|
||||||
|
return setupResult;
|
||||||
|
// Migration başarılı — DB artık hazır, tam ABP başlatmasına geç
|
||||||
|
Log.Warning("Migration tamamlandı — tam uygulama başlatılıyor.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Veritabanı mevcut — tam ABP başlatma ─────────────────────────────
|
||||||
|
|
||||||
var columnWriters = new Dictionary<string, ColumnWriterBase>
|
var columnWriters = new Dictionary<string, ColumnWriterBase>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Volo.Abp.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Sozsoft.Platform.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tam ABP uygulaması çalışırken setup durumunu döner.
|
||||||
|
/// SetupAppRunner'ın aynı endpoint'i DB hazır olmadığında dbExists=false döner.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/setup")]
|
||||||
|
public class SetupController : AbpControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("status")]
|
||||||
|
public IActionResult Status() => Ok(new { dbExists = true });
|
||||||
|
}
|
||||||
|
|
@ -60,6 +60,11 @@ const Layout = () => {
|
||||||
}, [routes, currentPath])
|
}, [routes, currentPath])
|
||||||
|
|
||||||
const AppLayout = useMemo(() => {
|
const AppLayout = useMemo(() => {
|
||||||
|
// 0) Setup path ise minimal blank layout
|
||||||
|
if (currentPath === '/setup') {
|
||||||
|
return layouts[LAYOUT_TYPE_BLANK]
|
||||||
|
}
|
||||||
|
|
||||||
// 1) Admin path ise, route bulunmasa bile admin layout'u göster
|
// 1) Admin path ise, route bulunmasa bile admin layout'u göster
|
||||||
if (isAdminPath) {
|
if (isAdminPath) {
|
||||||
return layouts[layoutType]
|
return layouts[layoutType]
|
||||||
|
|
@ -78,7 +83,7 @@ const Layout = () => {
|
||||||
return AuthLayout
|
return AuthLayout
|
||||||
}
|
}
|
||||||
return PublicLayout
|
return PublicLayout
|
||||||
}, [isAdminPath, route, layoutType, authenticated])
|
}, [isAdminPath, route, layoutType, authenticated, currentPath])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -8,17 +8,42 @@ import { useSetting } from '@/utils/hooks/useSetting'
|
||||||
import useTabFocus from '@/utils/hooks/useTabFocus'
|
import useTabFocus from '@/utils/hooks/useTabFocus'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { Helmet } from 'react-helmet'
|
import { Helmet } from 'react-helmet'
|
||||||
|
import { useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import { getSetupStatus } from '@/services/setup.service'
|
||||||
|
import { ROUTES_ENUM } from '@/routes/route.constant'
|
||||||
|
|
||||||
let didInit = false
|
let didInit = false
|
||||||
|
|
||||||
const Theme = (props: CommonProps) => {
|
const Theme = (props: CommonProps) => {
|
||||||
// ABP App Config'i uygulama acilirken al
|
// ABP App Config'i uygulama acilirken al
|
||||||
const { getConfig } = useStoreActions((a) => a.abpConfig)
|
const { getConfig } = useStoreActions((a) => a.abpConfig)
|
||||||
|
const { setSetupMode } = useStoreActions((a) => a.base.common)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!didInit) {
|
if (!didInit) {
|
||||||
didInit = true
|
didInit = true
|
||||||
getConfig(false)
|
|
||||||
|
// Direkt /setup'a gelindiyse — hemen setupMode=true yap (loading gate açılsın)
|
||||||
|
if (location.pathname === ROUTES_ENUM.setup) {
|
||||||
|
setSetupMode(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Veritabanı var mı kontrol et; yoksa setup sayfasına yönlendir
|
||||||
|
getSetupStatus()
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.data.dbExists) {
|
||||||
|
setSetupMode(true)
|
||||||
|
navigate(ROUTES_ENUM.setup, { replace: true })
|
||||||
|
} else {
|
||||||
|
getConfig(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
getConfig(false)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// DynamicRouter.tsx
|
// DynamicRouter.tsx
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
||||||
import { mapDynamicRoutes } from './dynamicRouteLoader'
|
import { mapDynamicRoutes } from './dynamicRouteLoader'
|
||||||
import { useDynamicRoutes } from './dynamicRoutesContext'
|
import { useDynamicRoutes } from './dynamicRoutesContext'
|
||||||
import { useComponents } from '@/contexts/ComponentContext'
|
import { useComponents } from '@/contexts/ComponentContext'
|
||||||
|
|
@ -13,15 +13,18 @@ import { hasSubdomain } from '@/utils/subdomain'
|
||||||
// AccessDenied ve NotFound'u dinamiklikten çıkarıyoruz
|
// AccessDenied ve NotFound'u dinamiklikten çıkarıyoruz
|
||||||
const AccessDenied = React.lazy(() => import('@/views/AccessDenied'))
|
const AccessDenied = React.lazy(() => import('@/views/AccessDenied'))
|
||||||
const NotFound = React.lazy(() => import('@/views/NotFound'))
|
const NotFound = React.lazy(() => import('@/views/NotFound'))
|
||||||
|
const DatabaseSetup = React.lazy(() => import('@/views/setup/DatabaseSetup'))
|
||||||
|
|
||||||
export const DynamicRouter: React.FC = () => {
|
export const DynamicRouter: React.FC = () => {
|
||||||
const { routes, loading, error } = useDynamicRoutes()
|
const { routes, loading, error } = useDynamicRoutes()
|
||||||
const { registeredComponents, renderComponent, isComponentRegistered } = useComponents()
|
const { registeredComponents, renderComponent, isComponentRegistered } = useComponents()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
const dynamicRoutes = React.useMemo(() => mapDynamicRoutes(routes), [routes])
|
const dynamicRoutes = React.useMemo(() => mapDynamicRoutes(routes), [routes])
|
||||||
|
|
||||||
if (loading) return <div>Loading...</div>
|
// /setup path'inde loading bekleme — setup route her zaman erişilebilir olmalı
|
||||||
if (error) return <div>Hata: {error}</div>
|
if (loading && location.pathname !== '/setup') return <div>Loading...</div>
|
||||||
|
if (error && location.pathname !== '/setup') return <div>Hata: {error}</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|
@ -126,6 +129,16 @@ export const DynamicRouter: React.FC = () => {
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* İlk kurulum — veritabanı mevcut değilse gösterilir */}
|
||||||
|
<Route
|
||||||
|
path={ROUTES_ENUM.setup}
|
||||||
|
element={
|
||||||
|
<React.Suspense fallback={<div>Loading...</div>}>
|
||||||
|
<DatabaseSetup />
|
||||||
|
</React.Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ export const useDynamicRoutes = () => {
|
||||||
|
|
||||||
export const DynamicRoutesProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
export const DynamicRoutesProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
const extraProperties = useStoreState((state) => state.abpConfig?.config?.extraProperties)
|
const extraProperties = useStoreState((state) => state.abpConfig?.config?.extraProperties)
|
||||||
|
const setupMode = useStoreState((state) => state.base.common.setupMode)
|
||||||
const [routes, setRoutes] = useState<RouteDto[]>([])
|
const [routes, setRoutes] = useState<RouteDto[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
@ -48,8 +49,12 @@ export const DynamicRoutesProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (extraProperties) {
|
if (extraProperties) {
|
||||||
loadRoutesFromConfig()
|
loadRoutesFromConfig()
|
||||||
|
} else if (setupMode) {
|
||||||
|
// Veritabanı mevcut değil — setup modunda loading'i kapat
|
||||||
|
setLoading(false)
|
||||||
|
setRoutes([])
|
||||||
}
|
}
|
||||||
}, [extraProperties])
|
}, [extraProperties, setupMode])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DynamicRoutesContext.Provider value={{ routes, loading, error, reload: loadRoutesFromConfig }}>
|
<DynamicRoutesContext.Provider value={{ routes, loading, error, reload: loadRoutesFromConfig }}>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
export const ROUTES_ENUM = {
|
export const ROUTES_ENUM = {
|
||||||
|
setup: '/setup',
|
||||||
public: {
|
public: {
|
||||||
home: '/home',
|
home: '/home',
|
||||||
about: '/about',
|
about: '/about',
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,13 @@ import {
|
||||||
} from '../proxy/config/models'
|
} from '../proxy/config/models'
|
||||||
import apiService from './api.service'
|
import apiService from './api.service'
|
||||||
|
|
||||||
|
export const applicationConfigurationUrl = (includeLocalizationResources: boolean) =>
|
||||||
|
`/api/abp/application-configuration?includeLocalizationResources=${includeLocalizationResources}`
|
||||||
|
|
||||||
export const getAppConfig = (includeLocalizationResources: boolean) =>
|
export const getAppConfig = (includeLocalizationResources: boolean) =>
|
||||||
apiService.fetchData<ApplicationConfigurationDto>({
|
apiService.fetchData<ApplicationConfigurationDto>({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: `/api/abp/application-configuration?includeLocalizationResources=${includeLocalizationResources}`,
|
url: applicationConfigurationUrl(includeLocalizationResources),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const getLocalizations = ({
|
export const getLocalizations = ({
|
||||||
|
|
|
||||||
|
|
@ -1,573 +0,0 @@
|
||||||
import { ClassroomAttendanceDto, ClassroomChatDto, HandRaiseDto } from '@/proxy/classroom/models'
|
|
||||||
import { ROUTES_ENUM } from '@/routes/route.constant'
|
|
||||||
import { store } from '@/store/store'
|
|
||||||
import * as signalR from '@microsoft/signalr'
|
|
||||||
import { toast } from '@/components/ui'
|
|
||||||
import Notification from '@/components/ui/Notification'
|
|
||||||
|
|
||||||
export class SignalRService {
|
|
||||||
private connection!: signalR.HubConnection
|
|
||||||
private isConnected: boolean = false
|
|
||||||
private currentSessionId?: string
|
|
||||||
private isKicked: boolean = false
|
|
||||||
|
|
||||||
private onAttendanceUpdate?: (record: ClassroomAttendanceDto) => void
|
|
||||||
private onParticipantJoined?: (
|
|
||||||
userId: string,
|
|
||||||
name: string,
|
|
||||||
isTeacher: boolean,
|
|
||||||
isActive: boolean,
|
|
||||||
) => void
|
|
||||||
private onParticipantLeft?: (payload: {
|
|
||||||
userId: string
|
|
||||||
sessionId: string
|
|
||||||
userName: string
|
|
||||||
}) => void
|
|
||||||
private onChatMessage?: (message: ClassroomChatDto) => void
|
|
||||||
private onParticipantMuted?: (userId: string, isMuted: boolean) => void
|
|
||||||
private onHandRaiseReceived?: (studentId: string) => void
|
|
||||||
private onHandRaiseDismissed?: (studentId: string) => void
|
|
||||||
private onOfferReceived?: (fromUserId: string, offer: RTCSessionDescriptionInit) => void
|
|
||||||
private onAnswerReceived?: (fromUserId: string, answer: RTCSessionDescriptionInit) => void
|
|
||||||
private onIceCandidateReceived?: (fromUserId: string, candidate: RTCIceCandidateInit) => void
|
|
||||||
private onForceCleanup?: () => void
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
const { auth } = store.getState()
|
|
||||||
|
|
||||||
this.connection = new signalR.HubConnectionBuilder()
|
|
||||||
.withUrl(`${import.meta.env.VITE_API_URL}/classroomhub`, {
|
|
||||||
accessTokenFactory: () => auth.session.token || '',
|
|
||||||
})
|
|
||||||
.configureLogging(signalR.LogLevel.Information)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
this.setupEventHandlers()
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupEventHandlers() {
|
|
||||||
if (!this.connection) return
|
|
||||||
|
|
||||||
this.connection.on('AttendanceUpdated', (record: ClassroomAttendanceDto) => {
|
|
||||||
this.onAttendanceUpdate?.(record)
|
|
||||||
})
|
|
||||||
|
|
||||||
this.connection.on(
|
|
||||||
'ParticipantJoined',
|
|
||||||
(userId: string, name: string, isTeacher: boolean, isActive: boolean) => {
|
|
||||||
this.onParticipantJoined?.(userId, name, isTeacher, isActive)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
this.connection.on(
|
|
||||||
'ParticipantLeft',
|
|
||||||
(payload: { userId: string; sessionId: string; userName: string }) => {
|
|
||||||
this.onParticipantLeft?.(payload)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
this.connection.on('ChatMessage', (message: any) => {
|
|
||||||
this.onChatMessage?.(message)
|
|
||||||
})
|
|
||||||
|
|
||||||
this.connection.on('ParticipantMuted', (userId: string, isMuted: boolean) => {
|
|
||||||
this.onParticipantMuted?.(userId, isMuted)
|
|
||||||
})
|
|
||||||
|
|
||||||
this.connection.on('HandRaiseReceived', (payload: any) => {
|
|
||||||
this.onHandRaiseReceived?.(payload.studentId)
|
|
||||||
})
|
|
||||||
|
|
||||||
this.connection.on('HandRaiseDismissed', (payload: any) => {
|
|
||||||
this.onHandRaiseDismissed?.(payload.studentId)
|
|
||||||
})
|
|
||||||
|
|
||||||
this.connection.on('ReceiveOffer', (fromUserId: string, offer: RTCSessionDescriptionInit) => {
|
|
||||||
this.onOfferReceived?.(fromUserId, offer)
|
|
||||||
})
|
|
||||||
|
|
||||||
this.connection.on('ReceiveAnswer', (fromUserId: string, answer: RTCSessionDescriptionInit) => {
|
|
||||||
this.onAnswerReceived?.(fromUserId, answer)
|
|
||||||
})
|
|
||||||
|
|
||||||
this.connection.on(
|
|
||||||
'ReceiveIceCandidate',
|
|
||||||
(fromUserId: string, candidate: RTCIceCandidateInit) => {
|
|
||||||
this.onIceCandidateReceived?.(fromUserId, candidate)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
this.connection.onreconnected(async () => {
|
|
||||||
this.isConnected = true
|
|
||||||
toast.push(<Notification title="🔄 Bağlantı tekrar kuruldu" type="success" />, {
|
|
||||||
placement: 'top-end',
|
|
||||||
})
|
|
||||||
|
|
||||||
if (this.currentSessionId && store.getState().auth.user) {
|
|
||||||
const u = store.getState().auth.user
|
|
||||||
await this.joinClass(this.currentSessionId, u.id, u.name, u.role === 'teacher', true)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
this.connection.onclose(async () => {
|
|
||||||
if (this.isKicked) {
|
|
||||||
toast.push(
|
|
||||||
<Notification title="⚠️ Bağlantı koptu, yeniden bağlanılıyor..." type="warning" />,
|
|
||||||
{ placement: 'top-end' },
|
|
||||||
)
|
|
||||||
this.isConnected = false
|
|
||||||
this.currentSessionId = undefined
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isConnected = false
|
|
||||||
try {
|
|
||||||
if (this.currentSessionId) {
|
|
||||||
await this.connection.invoke('LeaveClass', this.currentSessionId)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
this.currentSessionId = undefined
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
this.connection.on('Error', (message: string) => {
|
|
||||||
toast.push(<Notification title={`❌ Hata: ${message}`} type="danger" />, {
|
|
||||||
placement: 'top-end',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
this.connection.on('Warning', (message: string) => {
|
|
||||||
toast.push(<Notification title={`⚠️ Uyarı: ${message}`} type="warning" />, {
|
|
||||||
placement: 'top-end',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
this.connection.on('Info', (message: string) => {
|
|
||||||
toast.push(<Notification title={`ℹ️ Bilgi: ${message}`} type="info" />, {
|
|
||||||
placement: 'top-end',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
this.connection.onreconnecting(() => {
|
|
||||||
if (this.isKicked) {
|
|
||||||
toast.push(
|
|
||||||
<Notification
|
|
||||||
title="❌ Sınıftan çıkarıldığınız için yeniden bağlanma engellendi"
|
|
||||||
type="danger"
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
this.connection.stop()
|
|
||||||
throw new Error('Reconnect blocked after kick')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
this.connection.on('ForceDisconnect', async (message: string) => {
|
|
||||||
this.isKicked = true
|
|
||||||
toast.push(<Notification title={`❌ Sınıftan çıkarıldınız: ${message}`} type="danger" />, {
|
|
||||||
placement: 'top-end',
|
|
||||||
})
|
|
||||||
|
|
||||||
if (this.onForceCleanup) {
|
|
||||||
this.onForceCleanup()
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.connection.stop()
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
this.isConnected = false
|
|
||||||
|
|
||||||
if (this.currentSessionId && store.getState().auth.user) {
|
|
||||||
this.onParticipantLeft?.({
|
|
||||||
userId: store.getState().auth.user.id,
|
|
||||||
sessionId: this.currentSessionId,
|
|
||||||
userName: store.getState().auth.user.name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
this.currentSessionId = undefined
|
|
||||||
window.location.href = ROUTES_ENUM.protected.coordinator.classroom.classes
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async start(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const startPromise = this.connection.start()
|
|
||||||
const timeout = new Promise((_, reject) =>
|
|
||||||
setTimeout(() => reject(new Error('Bağlantı zaman aşımına uğradı')), 10000),
|
|
||||||
)
|
|
||||||
|
|
||||||
await Promise.race([startPromise, timeout])
|
|
||||||
this.isConnected = true
|
|
||||||
toast.push(<Notification title="✅ Bağlantı kuruldu" type="success" />, {
|
|
||||||
placement: 'top-end',
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
toast.push(
|
|
||||||
<Notification
|
|
||||||
title="⚠️ Sunucuya bağlanılamadı. Lütfen sayfayı yenileyin veya internet bağlantınızı kontrol edin."
|
|
||||||
type="danger"
|
|
||||||
/>,
|
|
||||||
{ placement: 'top-end' },
|
|
||||||
)
|
|
||||||
this.isConnected = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async joinClass(
|
|
||||||
sessionId: string,
|
|
||||||
userId: string,
|
|
||||||
userName: string,
|
|
||||||
isTeacher: boolean,
|
|
||||||
isActive: boolean,
|
|
||||||
): Promise<void> {
|
|
||||||
if (!this.isConnected) {
|
|
||||||
toast.push(
|
|
||||||
<Notification
|
|
||||||
title="⚠️ Bağlantı yok. Sınıfa katılmadan önce bağlantıyı kontrol edin."
|
|
||||||
type="warning"
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.currentSessionId = sessionId
|
|
||||||
try {
|
|
||||||
await this.connection.invoke('JoinClass', sessionId, userId, userName, isTeacher, isActive)
|
|
||||||
} catch {
|
|
||||||
toast.push(<Notification title="❌ Sınıfa katılamadı" type="danger" />, {
|
|
||||||
placement: 'top-end',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async leaveClass(sessionId: string): Promise<void> {
|
|
||||||
const { auth } = store.getState()
|
|
||||||
|
|
||||||
if (!this.isConnected) {
|
|
||||||
this.onParticipantLeft?.({ userId: auth.user.id, sessionId, userName: auth.user.name })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.connection.invoke('LeaveClass', sessionId)
|
|
||||||
this.currentSessionId = undefined
|
|
||||||
} catch {
|
|
||||||
toast.push(<Notification title="⚠️ Çıkış başarısız" type="warning" />, {
|
|
||||||
placement: 'top-end',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendChatMessage(
|
|
||||||
sessionId: string,
|
|
||||||
senderId: string,
|
|
||||||
senderName: string,
|
|
||||||
message: string,
|
|
||||||
isTeacher: boolean,
|
|
||||||
): Promise<void> {
|
|
||||||
if (!this.isConnected) {
|
|
||||||
const chatMessage: ClassroomChatDto = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
sessionId,
|
|
||||||
senderId,
|
|
||||||
senderName,
|
|
||||||
message,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
isTeacher,
|
|
||||||
messageType: 'public',
|
|
||||||
}
|
|
||||||
setTimeout(() => {
|
|
||||||
this.onChatMessage?.(chatMessage)
|
|
||||||
}, 100)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.connection.invoke(
|
|
||||||
'SendChatMessage',
|
|
||||||
sessionId,
|
|
||||||
senderId,
|
|
||||||
senderName,
|
|
||||||
message,
|
|
||||||
isTeacher,
|
|
||||||
'public',
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
toast.push(<Notification title="❌ Mesaj gönderilemedi" type="danger" />, {
|
|
||||||
placement: 'top-end',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendPrivateMessage(
|
|
||||||
sessionId: string,
|
|
||||||
senderId: string,
|
|
||||||
senderName: string,
|
|
||||||
message: string,
|
|
||||||
recipientId: string,
|
|
||||||
recipientName: string,
|
|
||||||
isTeacher: boolean,
|
|
||||||
): Promise<void> {
|
|
||||||
if (!this.isConnected) {
|
|
||||||
const chatMessage: ClassroomChatDto = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
sessionId,
|
|
||||||
senderId,
|
|
||||||
senderName,
|
|
||||||
message,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
isTeacher,
|
|
||||||
recipientId,
|
|
||||||
recipientName,
|
|
||||||
messageType: 'private',
|
|
||||||
}
|
|
||||||
setTimeout(() => {
|
|
||||||
this.onChatMessage?.(chatMessage)
|
|
||||||
}, 100)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.connection.invoke(
|
|
||||||
'SendPrivateMessage',
|
|
||||||
sessionId,
|
|
||||||
senderId,
|
|
||||||
senderName,
|
|
||||||
message,
|
|
||||||
recipientId,
|
|
||||||
recipientName,
|
|
||||||
isTeacher,
|
|
||||||
'private',
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
toast.push(<Notification title="❌ Özel mesaj gönderilemedi" type="danger" />, {
|
|
||||||
placement: 'top-end',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendAnnouncement(
|
|
||||||
sessionId: string,
|
|
||||||
senderId: string,
|
|
||||||
senderName: string,
|
|
||||||
message: string,
|
|
||||||
isTeacher: boolean,
|
|
||||||
): Promise<void> {
|
|
||||||
if (!this.isConnected) {
|
|
||||||
const chatMessage: ClassroomChatDto = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
sessionId,
|
|
||||||
senderId,
|
|
||||||
senderName,
|
|
||||||
message,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
isTeacher,
|
|
||||||
messageType: 'announcement',
|
|
||||||
}
|
|
||||||
setTimeout(() => {
|
|
||||||
this.onChatMessage?.(chatMessage)
|
|
||||||
}, 100)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.connection.invoke(
|
|
||||||
'SendAnnouncement',
|
|
||||||
sessionId,
|
|
||||||
senderId,
|
|
||||||
senderName,
|
|
||||||
message,
|
|
||||||
isTeacher,
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
toast.push(<Notification title="❌ Duyuru gönderilemedi" type="danger" />, {
|
|
||||||
placement: 'top-end',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async muteParticipant(
|
|
||||||
sessionId: string,
|
|
||||||
userId: string,
|
|
||||||
isMuted: boolean,
|
|
||||||
isTeacher: boolean,
|
|
||||||
): Promise<void> {
|
|
||||||
if (!this.isConnected) {
|
|
||||||
setTimeout(() => {
|
|
||||||
this.onParticipantMuted?.(userId, isMuted)
|
|
||||||
}, 100)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.connection.invoke('MuteParticipant', sessionId, userId, isMuted, isTeacher)
|
|
||||||
} catch {
|
|
||||||
toast.push(<Notification title="⚠️ Katılımcı susturulamadı" type="warning" />, {
|
|
||||||
placement: 'top-end',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async raiseHand(sessionId: string, studentId: string, studentName: string): Promise<void> {
|
|
||||||
if (!this.isConnected) {
|
|
||||||
setTimeout(() => {
|
|
||||||
this.onHandRaiseReceived?.(studentId)
|
|
||||||
}, 100)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.connection.invoke('RaiseHand', sessionId, studentId, studentName)
|
|
||||||
} catch {
|
|
||||||
toast.push(<Notification title="❌ El kaldırma başarısız" type="danger" />, {
|
|
||||||
placement: 'top-end',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async kickParticipant(sessionId: string, participantId: string, userName: string): Promise<void> {
|
|
||||||
if (!this.isConnected) {
|
|
||||||
setTimeout(() => {
|
|
||||||
this.onParticipantLeft?.({ userId: participantId, sessionId, userName })
|
|
||||||
}, 100)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.connection.invoke('KickParticipant', sessionId, participantId)
|
|
||||||
} catch {
|
|
||||||
toast.push(<Notification title="❌ Katılımcı atılamadı" type="danger" />, {
|
|
||||||
placement: 'top-end',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async approveHandRaise(sessionId: string, studentId: string): Promise<void> {
|
|
||||||
if (!this.isConnected) {
|
|
||||||
setTimeout(() => {
|
|
||||||
this.onHandRaiseDismissed?.(studentId)
|
|
||||||
}, 100)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.connection.invoke('ApproveHandRaise', sessionId, studentId)
|
|
||||||
} catch {
|
|
||||||
toast.push(<Notification title="⚠️ El kaldırma onayı başarısız" type="warning" />, {
|
|
||||||
placement: 'top-end',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async dismissHandRaise(sessionId: string, studentId: string): Promise<void> {
|
|
||||||
if (!this.isConnected) {
|
|
||||||
setTimeout(() => {
|
|
||||||
this.onHandRaiseDismissed?.(studentId)
|
|
||||||
}, 100)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.connection.invoke('DismissHandRaise', sessionId, studentId)
|
|
||||||
} catch {
|
|
||||||
toast.push(<Notification title="⚠️ El indirme başarısız" type="warning" />, {
|
|
||||||
placement: 'top-end',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendOffer(sessionId: string, targetUserId: string, offer: RTCSessionDescriptionInit) {
|
|
||||||
if (!this.isConnected) return
|
|
||||||
await this.connection.invoke('SendOffer', sessionId, targetUserId, offer)
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendAnswer(sessionId: string, targetUserId: string, answer: RTCSessionDescriptionInit) {
|
|
||||||
if (!this.isConnected) return
|
|
||||||
await this.connection.invoke('SendAnswer', sessionId, targetUserId, answer)
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendIceCandidate(sessionId: string, targetUserId: string, candidate: RTCIceCandidateInit) {
|
|
||||||
if (!this.isConnected) return
|
|
||||||
await this.connection.invoke('SendIceCandidate', sessionId, targetUserId, candidate)
|
|
||||||
}
|
|
||||||
|
|
||||||
setExistingParticipantsHandler(callback: (participants: any[]) => void) {
|
|
||||||
this.connection.on('ExistingParticipants', callback)
|
|
||||||
}
|
|
||||||
|
|
||||||
setAttendanceUpdatedHandler(callback: (record: ClassroomAttendanceDto) => void) {
|
|
||||||
this.onAttendanceUpdate = callback
|
|
||||||
}
|
|
||||||
|
|
||||||
setParticipantJoinHandler(
|
|
||||||
callback: (userId: string, name: string, isTeacher: boolean, isActive: boolean) => void,
|
|
||||||
) {
|
|
||||||
this.onParticipantJoined = callback
|
|
||||||
}
|
|
||||||
|
|
||||||
setParticipantLeaveHandler(
|
|
||||||
callback: (payload: { userId: string; sessionId: string; userName: string }) => void,
|
|
||||||
) {
|
|
||||||
this.onParticipantLeft = callback
|
|
||||||
}
|
|
||||||
|
|
||||||
setChatMessageReceivedHandler(callback: (message: ClassroomChatDto) => void) {
|
|
||||||
this.onChatMessage = callback
|
|
||||||
}
|
|
||||||
|
|
||||||
setParticipantMutedHandler(callback: (userId: string, isMuted: boolean) => void) {
|
|
||||||
this.onParticipantMuted = callback
|
|
||||||
}
|
|
||||||
|
|
||||||
setHandRaiseReceivedHandler(callback: (studentId: string) => void) {
|
|
||||||
this.onHandRaiseReceived = callback
|
|
||||||
}
|
|
||||||
|
|
||||||
setHandRaiseDismissedHandler(callback: (studentId: string) => void) {
|
|
||||||
this.onHandRaiseDismissed = callback
|
|
||||||
}
|
|
||||||
|
|
||||||
setOfferReceivedHandler(
|
|
||||||
callback: (fromUserId: string, offer: RTCSessionDescriptionInit) => void,
|
|
||||||
) {
|
|
||||||
this.onOfferReceived = callback
|
|
||||||
}
|
|
||||||
|
|
||||||
setAnswerReceivedHandler(
|
|
||||||
callback: (fromUserId: string, answer: RTCSessionDescriptionInit) => void,
|
|
||||||
) {
|
|
||||||
this.onAnswerReceived = callback
|
|
||||||
}
|
|
||||||
|
|
||||||
setIceCandidateReceivedHandler(
|
|
||||||
callback: (fromUserId: string, candidate: RTCIceCandidateInit) => void,
|
|
||||||
) {
|
|
||||||
this.onIceCandidateReceived = callback
|
|
||||||
}
|
|
||||||
|
|
||||||
async disconnect(): Promise<void> {
|
|
||||||
if (this.isConnected && this.currentSessionId) {
|
|
||||||
try {
|
|
||||||
await this.connection.invoke('LeaveClass', this.currentSessionId)
|
|
||||||
} catch {
|
|
||||||
toast.push(<Notification title="⚠️ Bağlantı koparılırken hata" type="warning" />, {
|
|
||||||
placement: 'top-end',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (this.connection) {
|
|
||||||
await this.connection.stop()
|
|
||||||
}
|
|
||||||
this.isConnected = false
|
|
||||||
this.currentSessionId = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
getConnectionState(): boolean {
|
|
||||||
return this.isConnected
|
|
||||||
}
|
|
||||||
|
|
||||||
setForceCleanupHandler(callback: () => void) {
|
|
||||||
this.onForceCleanup = callback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,358 +0,0 @@
|
||||||
import { toast } from '@/components/ui'
|
|
||||||
import Notification from '@/components/ui/Notification'
|
|
||||||
|
|
||||||
export class WebRTCService {
|
|
||||||
private peerConnections: Map<string, RTCPeerConnection> = new Map()
|
|
||||||
private retryCounts: Map<string, number> = new Map()
|
|
||||||
private maxRetries = 3
|
|
||||||
private signalRService: any
|
|
||||||
private sessionId: string = ''
|
|
||||||
|
|
||||||
private localStream: MediaStream | null = null
|
|
||||||
private onRemoteStream?: (userId: string, stream: MediaStream) => void
|
|
||||||
private onIceCandidate?: (userId: string, candidate: RTCIceCandidateInit) => void
|
|
||||||
private candidateBuffer: Map<string, RTCIceCandidateInit[]> = new Map()
|
|
||||||
|
|
||||||
private rtcConfiguration: RTCConfiguration = {
|
|
||||||
iceServers: [
|
|
||||||
{
|
|
||||||
urls: [
|
|
||||||
'stun:turn.sozsoft.com:3478',
|
|
||||||
'turn:turn.sozsoft.com:3478?transport=udp',
|
|
||||||
'turn:turn.sozsoft.com:3478?transport=tcp',
|
|
||||||
'turns:turn.sozsoft.com:5349?transport=tcp',
|
|
||||||
],
|
|
||||||
username: 'webrtc',
|
|
||||||
credential: 'strongpassword123',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
async initializeLocalStream(enableAudio: boolean, enableVideo: boolean): Promise<MediaStream> {
|
|
||||||
try {
|
|
||||||
this.localStream = await navigator.mediaDevices.getUserMedia({
|
|
||||||
video: {
|
|
||||||
width: { ideal: 1280 },
|
|
||||||
height: { ideal: 720 },
|
|
||||||
frameRate: { ideal: 30 },
|
|
||||||
},
|
|
||||||
audio: {
|
|
||||||
echoCancellation: true,
|
|
||||||
noiseSuppression: true,
|
|
||||||
autoGainControl: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
this.localStream.getAudioTracks().forEach((track) => (track.enabled = enableAudio))
|
|
||||||
this.localStream.getVideoTracks().forEach((track) => (track.enabled = enableVideo))
|
|
||||||
|
|
||||||
return this.localStream
|
|
||||||
} catch {
|
|
||||||
toast.push(
|
|
||||||
<Notification
|
|
||||||
title="❌ Kamera/Mikrofon erişilemedi. Tarayıcı ayarlarınızı veya izinleri kontrol edin."
|
|
||||||
type="danger"
|
|
||||||
/>,
|
|
||||||
{ placement: 'top-end' },
|
|
||||||
)
|
|
||||||
throw new Error('Media devices access failed')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async createPeerConnection(userId: string): Promise<RTCPeerConnection> {
|
|
||||||
const peerConnection = new RTCPeerConnection(this.rtcConfiguration)
|
|
||||||
this.peerConnections.set(userId, peerConnection)
|
|
||||||
this.retryCounts.set(userId, 0)
|
|
||||||
|
|
||||||
if (this.localStream) {
|
|
||||||
this.localStream.getTracks().forEach((track) => {
|
|
||||||
peerConnection.addTrack(track, this.localStream!)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
peerConnection.ontrack = (event) => {
|
|
||||||
const [remoteStream] = event.streams
|
|
||||||
this.onRemoteStream?.(userId, remoteStream)
|
|
||||||
}
|
|
||||||
|
|
||||||
peerConnection.onicecandidate = (event) => {
|
|
||||||
if (event.candidate) {
|
|
||||||
this.onIceCandidate?.(userId, event.candidate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
peerConnection.onconnectionstatechange = async () => {
|
|
||||||
const state = peerConnection.connectionState
|
|
||||||
|
|
||||||
if (state === 'closed') {
|
|
||||||
this.closePeerConnection(userId)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state === 'failed') {
|
|
||||||
let retries = this.retryCounts.get(userId) ?? 0
|
|
||||||
if (retries < this.maxRetries) {
|
|
||||||
toast.push(
|
|
||||||
<Notification
|
|
||||||
title={`⚠️ Bağlantı başarısız, yeniden deneniyor (${retries + 1}/${this.maxRetries})`}
|
|
||||||
type="warning"
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
this.retryCounts.set(userId, retries + 1)
|
|
||||||
await this.restartIce(peerConnection, userId)
|
|
||||||
} else {
|
|
||||||
toast.push(
|
|
||||||
<Notification
|
|
||||||
title={`❌ Bağlantı kurulamadı (${this.maxRetries} deneme başarısız).`}
|
|
||||||
type="danger"
|
|
||||||
/>,
|
|
||||||
{ placement: 'top-end' },
|
|
||||||
)
|
|
||||||
this.closePeerConnection(userId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.candidateBuffer.has(userId)) {
|
|
||||||
for (const cand of this.candidateBuffer.get(userId)!) {
|
|
||||||
try {
|
|
||||||
await peerConnection.addIceCandidate(cand)
|
|
||||||
} catch {
|
|
||||||
toast.push(
|
|
||||||
<Notification
|
|
||||||
title={`⚠️ ICE candidate eklenemedi. Kullanıcı: ${userId}`}
|
|
||||||
type="warning"
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.candidateBuffer.delete(userId)
|
|
||||||
}
|
|
||||||
|
|
||||||
return peerConnection
|
|
||||||
}
|
|
||||||
|
|
||||||
setSignalRService(signalRService: any, sessionId: string) {
|
|
||||||
this.signalRService = signalRService
|
|
||||||
this.sessionId = sessionId
|
|
||||||
}
|
|
||||||
|
|
||||||
setIceCandidateHandler(callback: (userId: string, candidate: RTCIceCandidateInit) => void) {
|
|
||||||
this.onIceCandidate = callback
|
|
||||||
}
|
|
||||||
|
|
||||||
async createOffer(userId: string): Promise<RTCSessionDescriptionInit> {
|
|
||||||
const pc = this.peerConnections.get(userId)
|
|
||||||
if (!pc) throw new Error('Peer connection not found')
|
|
||||||
|
|
||||||
try {
|
|
||||||
const offer = await pc.createOffer()
|
|
||||||
await pc.setLocalDescription(offer)
|
|
||||||
return offer
|
|
||||||
} catch {
|
|
||||||
toast.push(<Notification title="❌ Offer oluşturulamadı" type="danger" />, {
|
|
||||||
placement: 'top-end',
|
|
||||||
})
|
|
||||||
throw new Error('Offer creation failed')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async createAnswer(
|
|
||||||
userId: string,
|
|
||||||
offer: RTCSessionDescriptionInit,
|
|
||||||
): Promise<RTCSessionDescriptionInit> {
|
|
||||||
const pc = this.peerConnections.get(userId)
|
|
||||||
if (!pc) throw new Error('Peer connection not found')
|
|
||||||
|
|
||||||
try {
|
|
||||||
await pc.setRemoteDescription(offer)
|
|
||||||
const answer = await pc.createAnswer()
|
|
||||||
await pc.setLocalDescription(answer)
|
|
||||||
return answer
|
|
||||||
} catch {
|
|
||||||
toast.push(<Notification title="❌ Answer oluşturulamadı" type="danger" />, {
|
|
||||||
placement: 'top-end',
|
|
||||||
})
|
|
||||||
throw new Error('Answer creation failed')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleAnswer(userId: string, answer: RTCSessionDescriptionInit): Promise<void> {
|
|
||||||
const peerConnection = this.peerConnections.get(userId)
|
|
||||||
if (!peerConnection) throw new Error('Peer connection not found')
|
|
||||||
await peerConnection.setRemoteDescription(answer)
|
|
||||||
}
|
|
||||||
|
|
||||||
async addIceCandidate(userId: string, candidate: RTCIceCandidateInit): Promise<void> {
|
|
||||||
const pc = this.peerConnections.get(userId)
|
|
||||||
if (!pc) {
|
|
||||||
if (!this.candidateBuffer.has(userId)) {
|
|
||||||
this.candidateBuffer.set(userId, [])
|
|
||||||
}
|
|
||||||
this.candidateBuffer.get(userId)!.push(candidate)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pc.signalingState === 'stable' || pc.signalingState === 'have-remote-offer') {
|
|
||||||
try {
|
|
||||||
await pc.addIceCandidate(candidate)
|
|
||||||
} catch {
|
|
||||||
toast.push(
|
|
||||||
<Notification
|
|
||||||
title={`⚠️ ICE candidate eklenemedi. Kullanıcı: ${userId}`}
|
|
||||||
type="warning"
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (!this.candidateBuffer.has(userId)) {
|
|
||||||
this.candidateBuffer.set(userId, [])
|
|
||||||
}
|
|
||||||
this.candidateBuffer.get(userId)!.push(candidate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onRemoteStreamReceived(callback: (userId: string, stream: MediaStream) => void) {
|
|
||||||
this.onRemoteStream = callback
|
|
||||||
}
|
|
||||||
|
|
||||||
async toggleVideo(enabled: boolean): Promise<void> {
|
|
||||||
if (!this.localStream) return
|
|
||||||
let videoTrack = this.localStream.getVideoTracks()[0]
|
|
||||||
|
|
||||||
if (videoTrack) {
|
|
||||||
videoTrack.enabled = enabled
|
|
||||||
} else if (enabled) {
|
|
||||||
try {
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ video: true })
|
|
||||||
const newTrack = stream.getVideoTracks()[0]
|
|
||||||
if (newTrack) {
|
|
||||||
this.localStream!.addTrack(newTrack)
|
|
||||||
this.peerConnections.forEach((pc) => {
|
|
||||||
const sender = pc.getSenders().find((s) => s.track?.kind === newTrack.kind)
|
|
||||||
if (sender) {
|
|
||||||
sender.replaceTrack(newTrack)
|
|
||||||
} else {
|
|
||||||
pc.addTrack(newTrack, this.localStream!)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.push(<Notification title="❌ Kamera açılamadı" type="danger" />, {
|
|
||||||
placement: 'top-end',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async toggleAudio(enabled: boolean): Promise<void> {
|
|
||||||
if (!this.localStream) return
|
|
||||||
let audioTrack = this.localStream.getAudioTracks()[0]
|
|
||||||
|
|
||||||
if (audioTrack) {
|
|
||||||
audioTrack.enabled = enabled
|
|
||||||
} else if (enabled) {
|
|
||||||
try {
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
|
||||||
const newTrack = stream.getAudioTracks()[0]
|
|
||||||
if (newTrack) {
|
|
||||||
this.localStream!.addTrack(newTrack)
|
|
||||||
this.peerConnections.forEach((pc) => {
|
|
||||||
const sender = pc.getSenders().find((s) => s.track?.kind === newTrack.kind)
|
|
||||||
if (sender) {
|
|
||||||
sender.replaceTrack(newTrack)
|
|
||||||
} else {
|
|
||||||
pc.addTrack(newTrack, this.localStream!)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.push(<Notification title="❌ Mikrofon açılamadı" type="danger" />, {
|
|
||||||
placement: 'top-end',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getLocalStream(): MediaStream | null {
|
|
||||||
return this.localStream
|
|
||||||
}
|
|
||||||
|
|
||||||
private async restartIce(peerConnection: RTCPeerConnection, userId: string) {
|
|
||||||
try {
|
|
||||||
const offer = await peerConnection.createOffer({ iceRestart: true })
|
|
||||||
await peerConnection.setLocalDescription(offer)
|
|
||||||
|
|
||||||
if (this.signalRService) {
|
|
||||||
await this.signalRService.sendOffer(this.sessionId, userId, offer)
|
|
||||||
} else {
|
|
||||||
toast.push(<Notification title="⚠️ Tekrar bağlanma başarısız" type="warning" />, {
|
|
||||||
placement: 'top-end',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.push(<Notification title="❌ ICE restart başarısız" type="danger" />, {
|
|
||||||
placement: 'top-end',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
closePeerConnection(userId: string): void {
|
|
||||||
const peerConnection = this.peerConnections.get(userId)
|
|
||||||
if (peerConnection) {
|
|
||||||
peerConnection.getSenders().forEach((sender) => sender.track?.stop())
|
|
||||||
peerConnection.close()
|
|
||||||
this.peerConnections.delete(userId)
|
|
||||||
this.retryCounts.delete(userId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getPeerConnection(userId: string): RTCPeerConnection | undefined {
|
|
||||||
return this.peerConnections.get(userId)
|
|
||||||
}
|
|
||||||
|
|
||||||
closeAllConnections(): void {
|
|
||||||
this.peerConnections.forEach((pc) => {
|
|
||||||
pc.getSenders().forEach((sender) => sender.track?.stop())
|
|
||||||
pc.close()
|
|
||||||
})
|
|
||||||
this.peerConnections.clear()
|
|
||||||
|
|
||||||
if (this.localStream) {
|
|
||||||
this.localStream.getTracks().forEach((track) => track.stop())
|
|
||||||
this.localStream = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addStreamToPeers(stream: MediaStream) {
|
|
||||||
this.peerConnections.forEach((pc) => {
|
|
||||||
stream.getTracks().forEach((track) => {
|
|
||||||
const alreadyHas = pc.getSenders().some((s) => s.track?.id === track.id)
|
|
||||||
if (!alreadyHas) {
|
|
||||||
pc.addTrack(track, stream)
|
|
||||||
track.onended = () => {
|
|
||||||
this.removeTrackFromPeers(track)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
removeTrackFromPeers(track: MediaStreamTrack) {
|
|
||||||
this.peerConnections.forEach((pc) => {
|
|
||||||
pc.getSenders().forEach((sender) => {
|
|
||||||
if (sender.track === track) {
|
|
||||||
try {
|
|
||||||
pc.removeTrack(sender)
|
|
||||||
} catch {
|
|
||||||
toast.push(<Notification title="⚠️ Track silinemedi" type="warning" />, {
|
|
||||||
placement: 'top-end',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (sender.track?.readyState !== 'ended') {
|
|
||||||
sender.track?.stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
21
ui/src/services/setup.service.ts
Normal file
21
ui/src/services/setup.service.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import apiService from './api.service'
|
||||||
|
|
||||||
|
export interface SetupStatusDto {
|
||||||
|
dbExists: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSetupStatus = () =>
|
||||||
|
apiService.fetchData<SetupStatusDto>({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/setup/status',
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the SSE URL for migration streaming.
|
||||||
|
* Usage: new EventSource(getMigrateUrl())
|
||||||
|
*/
|
||||||
|
export const getMigrateUrl = (): string => {
|
||||||
|
const base = import.meta.env.VITE_API_URL ?? ''
|
||||||
|
return `${base}/api/setup/migrate`
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@ export interface BaseStoreModel {
|
||||||
common: {
|
common: {
|
||||||
currentRouteKey: string
|
currentRouteKey: string
|
||||||
tabHasFocus: boolean
|
tabHasFocus: boolean
|
||||||
|
setupMode: boolean /** Veritabanı mevcut değilse true — setup sayfasına yönlendirme için */
|
||||||
}
|
}
|
||||||
messages: {
|
messages: {
|
||||||
errors: StoreError[]
|
errors: StoreError[]
|
||||||
|
|
@ -24,6 +25,7 @@ export interface BaseStoreActions {
|
||||||
common: {
|
common: {
|
||||||
setCurrentRouteKey: Action<BaseStoreModel['common'], string>
|
setCurrentRouteKey: Action<BaseStoreModel['common'], string>
|
||||||
setTabHasFocus: Action<BaseStoreModel['common'], boolean>
|
setTabHasFocus: Action<BaseStoreModel['common'], boolean>
|
||||||
|
setSetupMode: Action<BaseStoreModel['common'], boolean>
|
||||||
}
|
}
|
||||||
messages: {
|
messages: {
|
||||||
addError: Action<BaseStoreModel['messages'], StoreError>
|
addError: Action<BaseStoreModel['messages'], StoreError>
|
||||||
|
|
@ -36,12 +38,12 @@ export interface BaseStoreActions {
|
||||||
export type BaseModel = BaseStoreModel & BaseStoreActions
|
export type BaseModel = BaseStoreModel & BaseStoreActions
|
||||||
|
|
||||||
const initialState: BaseStoreModel = {
|
const initialState: BaseStoreModel = {
|
||||||
common: { currentRouteKey: '', tabHasFocus: false },
|
common: { currentRouteKey: '', tabHasFocus: false, setupMode: false },
|
||||||
messages: {
|
messages: {
|
||||||
errors: [],
|
errors: [],
|
||||||
// success: [],
|
// success: [],
|
||||||
warning: [],
|
warning: [],
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const baseModel: BaseModel = {
|
export const baseModel: BaseModel = {
|
||||||
|
|
@ -53,6 +55,9 @@ export const baseModel: BaseModel = {
|
||||||
setTabHasFocus: action((state, payload) => {
|
setTabHasFocus: action((state, payload) => {
|
||||||
state.tabHasFocus = payload
|
state.tabHasFocus = payload
|
||||||
}),
|
}),
|
||||||
|
setSetupMode: action((state, payload) => {
|
||||||
|
state.setupMode = payload
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
messages: {
|
messages: {
|
||||||
...initialState.messages,
|
...initialState.messages,
|
||||||
|
|
|
||||||
218
ui/src/views/setup/DatabaseSetup.tsx
Normal file
218
ui/src/views/setup/DatabaseSetup.tsx
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { APP_NAME } from '@/constants/app.constant'
|
||||||
|
import { getMigrateUrl } from '@/services/setup.service'
|
||||||
|
import { applicationConfigurationUrl, getAppConfig } from '@/services/abpConfig.service'
|
||||||
|
|
||||||
|
interface LogLine {
|
||||||
|
level: 'info' | 'warn' | 'error' | 'success' | 'restart' | 'done'
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type MigrationStatus = 'idle' | 'running' | 'success' | 'error' | 'restarting'
|
||||||
|
|
||||||
|
const levelClass: Record<string, string> = {
|
||||||
|
info: 'text-gray-300',
|
||||||
|
warn: 'text-yellow-400',
|
||||||
|
error: 'text-red-400',
|
||||||
|
success: 'text-green-400',
|
||||||
|
restart: 'text-blue-400',
|
||||||
|
done: 'text-blue-400',
|
||||||
|
}
|
||||||
|
|
||||||
|
const DatabaseSetup = () => {
|
||||||
|
const [logs, setLogs] = useState<LogLine[]>([])
|
||||||
|
const [status, setStatus] = useState<MigrationStatus>('idle')
|
||||||
|
const [pollCountdown, setPollCountdown] = useState(0)
|
||||||
|
const logEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
const eventSourceRef = useRef<EventSource | null>(null)
|
||||||
|
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
// Auto-scroll to bottom when new logs arrive
|
||||||
|
useEffect(() => {
|
||||||
|
logEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}, [logs])
|
||||||
|
|
||||||
|
// Cleanup on component unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
eventSourceRef.current?.close()
|
||||||
|
if (pollTimerRef.current) clearTimeout(pollTimerRef.current)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const addLog = (level: LogLine['level'], message: string) => {
|
||||||
|
setLogs((prev) => [...prev, { level, message }])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Poll ABP config endpoint until server is ready ──────────────
|
||||||
|
const pollUntilReady = () => {
|
||||||
|
setStatus('restarting')
|
||||||
|
|
||||||
|
let attempt = 0
|
||||||
|
|
||||||
|
const tick = async () => {
|
||||||
|
attempt++
|
||||||
|
setPollCountdown(attempt)
|
||||||
|
try {
|
||||||
|
const res = await fetch(applicationConfigurationUrl(false), { method: 'GET' })
|
||||||
|
if (res.status !== 503) {
|
||||||
|
// Full ABP application is ready — reload the page
|
||||||
|
window.location.href = '/'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Server not responding yet — expected, retry
|
||||||
|
}
|
||||||
|
pollTimerRef.current = setTimeout(tick, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// İlk denemeden önce kısa bir bekleme (sunucunun kapanma süresi)
|
||||||
|
pollTimerRef.current = setTimeout(tick, 2500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const startMigration = () => {
|
||||||
|
if (status === 'running') return
|
||||||
|
|
||||||
|
setLogs([])
|
||||||
|
setStatus('running')
|
||||||
|
addLog('info', 'Starting migration...')
|
||||||
|
|
||||||
|
const url = getMigrateUrl()
|
||||||
|
const es = new EventSource(url)
|
||||||
|
eventSourceRef.current = es
|
||||||
|
|
||||||
|
es.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data) as { level?: string; message?: string }
|
||||||
|
const level = (data.level ?? 'info') as LogLine['level']
|
||||||
|
const message = data.message ?? event.data
|
||||||
|
|
||||||
|
if (level === 'done') {
|
||||||
|
es.close()
|
||||||
|
eventSourceRef.current = null
|
||||||
|
// If we received a "restart" event, switch to poll mode; otherwise keep success/error state
|
||||||
|
setStatus((prev) => {
|
||||||
|
if (prev === 'running') return 'error' // failed — no restart event received
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
addLog(level, message)
|
||||||
|
|
||||||
|
if (level === 'success') {
|
||||||
|
setStatus('success')
|
||||||
|
} else if (level === 'error') {
|
||||||
|
setStatus('error')
|
||||||
|
} else if (level === 'restart') {
|
||||||
|
// Backend is stopping the minimal app — start polling
|
||||||
|
pollUntilReady()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
addLog('info', event.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
es.onerror = () => {
|
||||||
|
es.close()
|
||||||
|
eventSourceRef.current = null
|
||||||
|
setStatus((prev) => (prev === 'running' ? 'error' : prev))
|
||||||
|
addLog('error', 'Server connection lost or migration could not be completed.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-900 flex flex-col items-center justify-center p-6 text-white">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
<h1 className="text-3xl font-bold mb-2">{APP_NAME}</h1>
|
||||||
|
<p className="text-gray-400 text-lg">Initial Setup — Creating Database</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Card */}
|
||||||
|
<div className="w-full max-w-4xl bg-gray-800 rounded-xl shadow-2xl overflow-hidden">
|
||||||
|
{/* Card Header */}
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 bg-gray-700 border-b border-gray-600">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<StatusIndicator status={status} />
|
||||||
|
<span className="font-semibold text-sm tracking-wide">
|
||||||
|
{status === 'idle' && 'Ready to Setup'}
|
||||||
|
{status === 'running' && 'Migration Running...'}
|
||||||
|
{status === 'success' && 'Migration Completed ✓'}
|
||||||
|
{status === 'restarting' && `Server Restarting... (attempt ${pollCountdown})`}
|
||||||
|
{status === 'error' && 'Migration Failed ✗'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-400">{logs.length} log lines</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Log Terminal */}
|
||||||
|
<div className="h-96 overflow-y-auto bg-gray-950 px-4 py-3 font-mono text-xs leading-relaxed">
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<p className="text-gray-600 mt-2">Logs will appear here when migration starts...</p>
|
||||||
|
) : (
|
||||||
|
logs.map((line, i) => (
|
||||||
|
<div key={i} className={`${levelClass[line.level] ?? 'text-gray-300'} mb-0.5`}>
|
||||||
|
<span className="text-gray-600 mr-2 select-none">{String(i + 1).padStart(4, '0')}</span>
|
||||||
|
{line.message}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<div ref={logEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Area */}
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 bg-gray-700 border-t border-gray-600">
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
{status === 'idle' && 'Database not found. Press the button to start migration.'}
|
||||||
|
{status === 'running' && 'Please wait, migration is in progress...'}
|
||||||
|
{status === 'success' && 'Migration completed. Server is restarting...'}
|
||||||
|
{status === 'restarting' &&
|
||||||
|
'Waiting for application server to be ready, you will be redirected automatically...'}
|
||||||
|
{status === 'error' &&
|
||||||
|
'Review the errors in the logs, fix the issue, and try again.'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{(status === 'idle' || status === 'error') && (
|
||||||
|
<button
|
||||||
|
onClick={startMigration}
|
||||||
|
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{status === 'error' ? 'Retry' : 'Start Setup'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'restarting' && (
|
||||||
|
<div className="flex items-center gap-2 text-blue-400 text-sm">
|
||||||
|
<span className="inline-block w-3 h-3 rounded-full bg-blue-400 animate-ping" />
|
||||||
|
Waiting...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-6 text-xs text-gray-600">
|
||||||
|
This page is only visible when the database does not exist.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Status Indicator ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const StatusIndicator = ({ status }: { status: MigrationStatus }) => {
|
||||||
|
const cls: Record<MigrationStatus, string> = {
|
||||||
|
idle: 'bg-gray-500',
|
||||||
|
running: 'bg-yellow-400 animate-pulse',
|
||||||
|
success: 'bg-green-500',
|
||||||
|
restarting: 'bg-blue-400 animate-ping',
|
||||||
|
error: 'bg-red-500',
|
||||||
|
}
|
||||||
|
return <span className={`inline-block w-3 h-3 rounded-full ${cls[status]}`} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DatabaseSetup
|
||||||
|
|
||||||
|
|
||||||
Loading…
Reference in a new issue