From c26f0cb5bc86b88f267f3d252103aa615bfd78dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sedat=20=C3=96zt=C3=BCrk?= Date: Thu, 23 Apr 2026 13:36:51 +0300 Subject: [PATCH] DbMigrator UI --- .../DbStartup/SetupAppRunner.cs | 249 ++++++++ .../Sozsoft.Platform.HttpApi.Host/Program.cs | 19 +- .../Controllers/SetupController.cs | 16 + ui/src/components/layouts/Layouts.tsx | 7 +- ui/src/components/template/Theme.tsx | 27 +- ui/src/routes/dynamicRouter.tsx | 19 +- ui/src/routes/dynamicRoutesContext.tsx | 7 +- ui/src/routes/route.constant.ts | 1 + ui/src/services/abpConfig.service.ts | 5 +- ui/src/services/classroom/signalr.tsx | 573 ------------------ ui/src/services/classroom/webrtc.tsx | 358 ----------- ui/src/services/setup.service.ts | 21 + ui/src/store/base.model.ts | 9 +- ui/src/views/setup/DatabaseSetup.tsx | 218 +++++++ 14 files changed, 588 insertions(+), 941 deletions(-) create mode 100644 api/src/Sozsoft.Platform.HttpApi.Host/DbStartup/SetupAppRunner.cs create mode 100644 api/src/Sozsoft.Platform.HttpApi/Controllers/SetupController.cs delete mode 100644 ui/src/services/classroom/signalr.tsx delete mode 100644 ui/src/services/classroom/webrtc.tsx create mode 100644 ui/src/services/setup.service.ts create mode 100644 ui/src/views/setup/DatabaseSetup.tsx diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/DbStartup/SetupAppRunner.cs b/api/src/Sozsoft.Platform.HttpApi.Host/DbStartup/SetupAppRunner.cs new file mode 100644 index 0000000..261c4f8 --- /dev/null +++ b/api/src/Sozsoft.Platform.HttpApi.Host/DbStartup/SetupAppRunner.cs @@ -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; + +/// +/// Veritabanı henüz hazır değilken çalışan minimal kurulum uygulaması. +/// Tam ABP stack yüklemez; sadece /api/setup/* endpointlerini sunar. +/// +internal static class SetupAppRunner +{ + // Veritabanı Hazırlık Kontrolü + + /// + /// DB var mı ve AbpRoles tablosu oluşmuş mu diye kontrol eder. + /// Boş DB veya bağlantı hatası durumunda false döner. + /// + public static bool DatabaseIsReady(IConfiguration configuration) + { + var connectionString = configuration.GetConnectionString(DefaultDatabaseProvider); + if (string.IsNullOrWhiteSpace(connectionString)) + return false; + + try + { + if (DefaultDatabaseProvider == DatabaseProvider.SqlServer) + return SqlServerIsReady(connectionString); + +#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 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; + } +} diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/Program.cs b/api/src/Sozsoft.Platform.HttpApi.Host/Program.cs index cfd0230..9dde959 100644 --- a/api/src/Sozsoft.Platform.HttpApi.Host/Program.cs +++ b/api/src/Sozsoft.Platform.HttpApi.Host/Program.cs @@ -5,7 +5,6 @@ using System.Threading.Tasks; using Sozsoft.Platform.Enums; using Sozsoft.Platform.DynamicServices; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -26,6 +25,24 @@ public class Program .AddJsonFile($"appsettings.{Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? ""}.json", true) .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 { diff --git a/api/src/Sozsoft.Platform.HttpApi/Controllers/SetupController.cs b/api/src/Sozsoft.Platform.HttpApi/Controllers/SetupController.cs new file mode 100644 index 0000000..9935dcf --- /dev/null +++ b/api/src/Sozsoft.Platform.HttpApi/Controllers/SetupController.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Mvc; +using Volo.Abp.AspNetCore.Mvc; + +namespace Sozsoft.Platform.Controllers; + +/// +/// Tam ABP uygulaması çalışırken setup durumunu döner. +/// SetupAppRunner'ın aynı endpoint'i DB hazır olmadığında dbExists=false döner. +/// +[ApiController] +[Route("api/setup")] +public class SetupController : AbpControllerBase +{ + [HttpGet("status")] + public IActionResult Status() => Ok(new { dbExists = true }); +} diff --git a/ui/src/components/layouts/Layouts.tsx b/ui/src/components/layouts/Layouts.tsx index 544c8b4..136a511 100644 --- a/ui/src/components/layouts/Layouts.tsx +++ b/ui/src/components/layouts/Layouts.tsx @@ -60,6 +60,11 @@ const Layout = () => { }, [routes, currentPath]) 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 if (isAdminPath) { return layouts[layoutType] @@ -78,7 +83,7 @@ const Layout = () => { return AuthLayout } return PublicLayout - }, [isAdminPath, route, layoutType, authenticated]) + }, [isAdminPath, route, layoutType, authenticated, currentPath]) if (loading) { return ( diff --git a/ui/src/components/template/Theme.tsx b/ui/src/components/template/Theme.tsx index 9ecab66..746755c 100644 --- a/ui/src/components/template/Theme.tsx +++ b/ui/src/components/template/Theme.tsx @@ -8,17 +8,42 @@ import { useSetting } from '@/utils/hooks/useSetting' import useTabFocus from '@/utils/hooks/useTabFocus' import { useEffect } from 'react' 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 const Theme = (props: CommonProps) => { // ABP App Config'i uygulama acilirken al const { getConfig } = useStoreActions((a) => a.abpConfig) + const { setSetupMode } = useStoreActions((a) => a.base.common) + const navigate = useNavigate() + const location = useLocation() useEffect(() => { if (!didInit) { 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) + }) } }, []) diff --git a/ui/src/routes/dynamicRouter.tsx b/ui/src/routes/dynamicRouter.tsx index 2e3d9f0..93e06ba 100644 --- a/ui/src/routes/dynamicRouter.tsx +++ b/ui/src/routes/dynamicRouter.tsx @@ -1,6 +1,6 @@ // DynamicRouter.tsx 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 { useDynamicRoutes } from './dynamicRoutesContext' import { useComponents } from '@/contexts/ComponentContext' @@ -13,15 +13,18 @@ import { hasSubdomain } from '@/utils/subdomain' // AccessDenied ve NotFound'u dinamiklikten çıkarıyoruz const AccessDenied = React.lazy(() => import('@/views/AccessDenied')) const NotFound = React.lazy(() => import('@/views/NotFound')) +const DatabaseSetup = React.lazy(() => import('@/views/setup/DatabaseSetup')) export const DynamicRouter: React.FC = () => { const { routes, loading, error } = useDynamicRoutes() const { registeredComponents, renderComponent, isComponentRegistered } = useComponents() + const location = useLocation() const dynamicRoutes = React.useMemo(() => mapDynamicRoutes(routes), [routes]) - if (loading) return
Loading...
- if (error) return
Hata: {error}
+ // /setup path'inde loading bekleme — setup route her zaman erişilebilir olmalı + if (loading && location.pathname !== '/setup') return
Loading...
+ if (error && location.pathname !== '/setup') return
Hata: {error}
return ( @@ -126,6 +129,16 @@ export const DynamicRouter: React.FC = () => { } /> + + {/* İlk kurulum — veritabanı mevcut değilse gösterilir */} + Loading...}> + + + } + /> ) } diff --git a/ui/src/routes/dynamicRoutesContext.tsx b/ui/src/routes/dynamicRoutesContext.tsx index 6215b6a..00c1d95 100644 --- a/ui/src/routes/dynamicRoutesContext.tsx +++ b/ui/src/routes/dynamicRoutesContext.tsx @@ -19,6 +19,7 @@ export const useDynamicRoutes = () => { export const DynamicRoutesProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const extraProperties = useStoreState((state) => state.abpConfig?.config?.extraProperties) + const setupMode = useStoreState((state) => state.base.common.setupMode) const [routes, setRoutes] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -48,8 +49,12 @@ export const DynamicRoutesProvider: React.FC<{ children: React.ReactNode }> = ({ useEffect(() => { if (extraProperties) { loadRoutesFromConfig() + } else if (setupMode) { + // Veritabanı mevcut değil — setup modunda loading'i kapat + setLoading(false) + setRoutes([]) } - }, [extraProperties]) + }, [extraProperties, setupMode]) return ( diff --git a/ui/src/routes/route.constant.ts b/ui/src/routes/route.constant.ts index 7f1cc99..f6ed2f9 100644 --- a/ui/src/routes/route.constant.ts +++ b/ui/src/routes/route.constant.ts @@ -1,4 +1,5 @@ export const ROUTES_ENUM = { + setup: '/setup', public: { home: '/home', about: '/about', diff --git a/ui/src/services/abpConfig.service.ts b/ui/src/services/abpConfig.service.ts index d478568..c88e54f 100644 --- a/ui/src/services/abpConfig.service.ts +++ b/ui/src/services/abpConfig.service.ts @@ -5,10 +5,13 @@ import { } from '../proxy/config/models' import apiService from './api.service' +export const applicationConfigurationUrl = (includeLocalizationResources: boolean) => + `/api/abp/application-configuration?includeLocalizationResources=${includeLocalizationResources}` + export const getAppConfig = (includeLocalizationResources: boolean) => apiService.fetchData({ method: 'GET', - url: `/api/abp/application-configuration?includeLocalizationResources=${includeLocalizationResources}`, + url: applicationConfigurationUrl(includeLocalizationResources), }) export const getLocalizations = ({ diff --git a/ui/src/services/classroom/signalr.tsx b/ui/src/services/classroom/signalr.tsx deleted file mode 100644 index 06303e5..0000000 --- a/ui/src/services/classroom/signalr.tsx +++ /dev/null @@ -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(, { - 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( - , - { 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(, { - placement: 'top-end', - }) - }) - - this.connection.on('Warning', (message: string) => { - toast.push(, { - placement: 'top-end', - }) - }) - - this.connection.on('Info', (message: string) => { - toast.push(, { - placement: 'top-end', - }) - }) - - this.connection.onreconnecting(() => { - if (this.isKicked) { - toast.push( - , - ) - this.connection.stop() - throw new Error('Reconnect blocked after kick') - } - }) - - this.connection.on('ForceDisconnect', async (message: string) => { - this.isKicked = true - toast.push(, { - 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 { - 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(, { - placement: 'top-end', - }) - } catch { - toast.push( - , - { placement: 'top-end' }, - ) - this.isConnected = false - } - } - - async joinClass( - sessionId: string, - userId: string, - userName: string, - isTeacher: boolean, - isActive: boolean, - ): Promise { - if (!this.isConnected) { - toast.push( - , - ) - return - } - - this.currentSessionId = sessionId - try { - await this.connection.invoke('JoinClass', sessionId, userId, userName, isTeacher, isActive) - } catch { - toast.push(, { - placement: 'top-end', - }) - } - } - - async leaveClass(sessionId: string): Promise { - 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(, { - placement: 'top-end', - }) - } - } - - async sendChatMessage( - sessionId: string, - senderId: string, - senderName: string, - message: string, - isTeacher: boolean, - ): Promise { - 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(, { - placement: 'top-end', - }) - } - } - - async sendPrivateMessage( - sessionId: string, - senderId: string, - senderName: string, - message: string, - recipientId: string, - recipientName: string, - isTeacher: boolean, - ): Promise { - 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(, { - placement: 'top-end', - }) - } - } - - async sendAnnouncement( - sessionId: string, - senderId: string, - senderName: string, - message: string, - isTeacher: boolean, - ): Promise { - 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(, { - placement: 'top-end', - }) - } - } - - async muteParticipant( - sessionId: string, - userId: string, - isMuted: boolean, - isTeacher: boolean, - ): Promise { - if (!this.isConnected) { - setTimeout(() => { - this.onParticipantMuted?.(userId, isMuted) - }, 100) - return - } - - try { - await this.connection.invoke('MuteParticipant', sessionId, userId, isMuted, isTeacher) - } catch { - toast.push(, { - placement: 'top-end', - }) - } - } - - async raiseHand(sessionId: string, studentId: string, studentName: string): Promise { - if (!this.isConnected) { - setTimeout(() => { - this.onHandRaiseReceived?.(studentId) - }, 100) - return - } - - try { - await this.connection.invoke('RaiseHand', sessionId, studentId, studentName) - } catch { - toast.push(, { - placement: 'top-end', - }) - } - } - - async kickParticipant(sessionId: string, participantId: string, userName: string): Promise { - if (!this.isConnected) { - setTimeout(() => { - this.onParticipantLeft?.({ userId: participantId, sessionId, userName }) - }, 100) - return - } - - try { - await this.connection.invoke('KickParticipant', sessionId, participantId) - } catch { - toast.push(, { - placement: 'top-end', - }) - } - } - - async approveHandRaise(sessionId: string, studentId: string): Promise { - if (!this.isConnected) { - setTimeout(() => { - this.onHandRaiseDismissed?.(studentId) - }, 100) - return - } - - try { - await this.connection.invoke('ApproveHandRaise', sessionId, studentId) - } catch { - toast.push(, { - placement: 'top-end', - }) - } - } - - async dismissHandRaise(sessionId: string, studentId: string): Promise { - if (!this.isConnected) { - setTimeout(() => { - this.onHandRaiseDismissed?.(studentId) - }, 100) - return - } - - try { - await this.connection.invoke('DismissHandRaise', sessionId, studentId) - } catch { - toast.push(, { - 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 { - if (this.isConnected && this.currentSessionId) { - try { - await this.connection.invoke('LeaveClass', this.currentSessionId) - } catch { - toast.push(, { - 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 - } -} diff --git a/ui/src/services/classroom/webrtc.tsx b/ui/src/services/classroom/webrtc.tsx deleted file mode 100644 index ae4f5fd..0000000 --- a/ui/src/services/classroom/webrtc.tsx +++ /dev/null @@ -1,358 +0,0 @@ -import { toast } from '@/components/ui' -import Notification from '@/components/ui/Notification' - -export class WebRTCService { - private peerConnections: Map = new Map() - private retryCounts: Map = 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 = 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 { - 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( - , - { placement: 'top-end' }, - ) - throw new Error('Media devices access failed') - } - } - - async createPeerConnection(userId: string): Promise { - 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( - , - ) - this.retryCounts.set(userId, retries + 1) - await this.restartIce(peerConnection, userId) - } else { - toast.push( - , - { 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( - , - ) - } - } - 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 { - 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(, { - placement: 'top-end', - }) - throw new Error('Offer creation failed') - } - } - - async createAnswer( - userId: string, - offer: RTCSessionDescriptionInit, - ): Promise { - 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(, { - placement: 'top-end', - }) - throw new Error('Answer creation failed') - } - } - - async handleAnswer(userId: string, answer: RTCSessionDescriptionInit): Promise { - 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 { - 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( - , - ) - } - } 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 { - 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(, { - placement: 'top-end', - }) - } - } - } - - async toggleAudio(enabled: boolean): Promise { - 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(, { - 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(, { - placement: 'top-end', - }) - } - } catch { - toast.push(, { - 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(, { - placement: 'top-end', - }) - } - if (sender.track?.readyState !== 'ended') { - sender.track?.stop() - } - } - }) - }) - } -} diff --git a/ui/src/services/setup.service.ts b/ui/src/services/setup.service.ts new file mode 100644 index 0000000..6ec7081 --- /dev/null +++ b/ui/src/services/setup.service.ts @@ -0,0 +1,21 @@ +import apiService from './api.service' + +export interface SetupStatusDto { + dbExists: boolean + error?: string +} + +export const getSetupStatus = () => + apiService.fetchData({ + 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` +} diff --git a/ui/src/store/base.model.ts b/ui/src/store/base.model.ts index 2d9b54a..40bae63 100644 --- a/ui/src/store/base.model.ts +++ b/ui/src/store/base.model.ts @@ -13,6 +13,7 @@ export interface BaseStoreModel { common: { currentRouteKey: string tabHasFocus: boolean + setupMode: boolean /** Veritabanı mevcut değilse true — setup sayfasına yönlendirme için */ } messages: { errors: StoreError[] @@ -24,6 +25,7 @@ export interface BaseStoreActions { common: { setCurrentRouteKey: Action setTabHasFocus: Action + setSetupMode: Action } messages: { addError: Action @@ -36,12 +38,12 @@ export interface BaseStoreActions { export type BaseModel = BaseStoreModel & BaseStoreActions const initialState: BaseStoreModel = { - common: { currentRouteKey: '', tabHasFocus: false }, + common: { currentRouteKey: '', tabHasFocus: false, setupMode: false }, messages: { errors: [], // success: [], warning: [], - }, + } } export const baseModel: BaseModel = { @@ -53,6 +55,9 @@ export const baseModel: BaseModel = { setTabHasFocus: action((state, payload) => { state.tabHasFocus = payload }), + setSetupMode: action((state, payload) => { + state.setupMode = payload + }), }, messages: { ...initialState.messages, diff --git a/ui/src/views/setup/DatabaseSetup.tsx b/ui/src/views/setup/DatabaseSetup.tsx new file mode 100644 index 0000000..6df856a --- /dev/null +++ b/ui/src/views/setup/DatabaseSetup.tsx @@ -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 = { + 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([]) + const [status, setStatus] = useState('idle') + const [pollCountdown, setPollCountdown] = useState(0) + const logEndRef = useRef(null) + const eventSourceRef = useRef(null) + const pollTimerRef = useRef | 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 ( +
+ {/* Header */} +
+

{APP_NAME}

+

Initial Setup — Creating Database

+
+ + {/* Status Card */} +
+ {/* Card Header */} +
+
+ + + {status === 'idle' && 'Ready to Setup'} + {status === 'running' && 'Migration Running...'} + {status === 'success' && 'Migration Completed ✓'} + {status === 'restarting' && `Server Restarting... (attempt ${pollCountdown})`} + {status === 'error' && 'Migration Failed ✗'} + +
+ {logs.length} log lines +
+ + {/* Log Terminal */} +
+ {logs.length === 0 ? ( +

Logs will appear here when migration starts...

+ ) : ( + logs.map((line, i) => ( +
+ {String(i + 1).padStart(4, '0')} + {line.message} +
+ )) + )} +
+
+ + {/* Action Area */} +
+
+ {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.'} +
+ +
+ {(status === 'idle' || status === 'error') && ( + + )} + + {status === 'restarting' && ( +
+ + Waiting... +
+ )} +
+
+
+ +

+ This page is only visible when the database does not exist. +

+
+ ) +} + +// ─── Status Indicator ──────────────────────────────────────────────────────── + +const StatusIndicator = ({ status }: { status: MigrationStatus }) => { + const cls: Record = { + 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 +} + +export default DatabaseSetup + +