diff --git a/RustRconServerManager.Backend/Controllers/AiIntegrationController.cs b/RustRconServerManager.Backend/Controllers/AiIntegrationController.cs new file mode 100644 index 0000000..da7ff2c --- /dev/null +++ b/RustRconServerManager.Backend/Controllers/AiIntegrationController.cs @@ -0,0 +1,162 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using RustRconServerManager.Backend.Database; +using RustRconServerManager.Backend.Extensions; +using RustRconServerManager.Backend.Interfaces; +using RustRconServerManager.Backend.Models; +using RustRconServerManager.Shared.AiIntegration; + +namespace RustRconServerManager.Backend.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class AiIntegrationController : ControllerBase +{ + private readonly AppDbContext _dbContext; + private readonly ILogger _logger; + private readonly IRconPasswordsCryptoService _cryptoService; + private readonly IAiService _aiService; + + public AiIntegrationController( + AppDbContext dbContext, + ILogger logger, + IRconPasswordsCryptoService cryptoService, + IAiService aiService) + { + _dbContext = dbContext; + _logger = logger; + _cryptoService = cryptoService; + _aiService = aiService; + } + + /// + /// Gets the current AI integration settings for the caller's SystemProfile. Admin-only, + /// same as the rest of Panel Settings' provider-credential endpoints. + /// + [HttpGet("settings")] + public async Task GetSettings() + { + try + { + var currentUser = await User.GetUser(_dbContext); + if (!currentUser.isAdmin) + return Forbid(); + + var settings = await _dbContext.AiIntegrationSettings + .FirstOrDefaultAsync(s => s.SystemProfileId == currentUser.SystemProfileId); + + return Ok(MapToDto(settings)); + } + catch (Exception ex) + { + _logger.LogError(ex, "[AiIntegrationController] Error getting AI integration settings"); + return StatusCode(500, new { error = "Error retrieving AI integration settings" }); + } + } + + [HttpPut("settings")] + public async Task SetSettings([FromBody] SetAiIntegrationSettingsDto dto) + { + try + { + var currentUser = await User.GetUser(_dbContext); + if (!currentUser.isAdmin) + return Forbid(); + + if (!AiProviders.All.Contains(dto.Provider)) + return BadRequest(new { error = $"Unknown provider '{dto.Provider}'." }); + + var requiresBaseUrl = dto.Provider is AiProviders.Ollama or AiProviders.LmStudio or AiProviders.Universal; + if (requiresBaseUrl && string.IsNullOrWhiteSpace(dto.BaseUrl)) + return BadRequest(new { error = "Base URL is required for this provider." }); + + if (!string.IsNullOrWhiteSpace(dto.BaseUrl) && !Uri.TryCreate(dto.BaseUrl.Trim(), UriKind.Absolute, out _)) + return BadRequest(new { error = "Base URL is not a valid URL." }); + + if (string.IsNullOrWhiteSpace(dto.Model)) + return BadRequest(new { error = "A model name is required." }); + + var settings = await _dbContext.AiIntegrationSettings + .FirstOrDefaultAsync(s => s.SystemProfileId == currentUser.SystemProfileId); + + if (settings == null) + { + settings = new AiIntegrationSettings + { + SystemProfileId = currentUser.SystemProfileId, + CreatedAt = DateTime.UtcNow + }; + _dbContext.AiIntegrationSettings.Add(settings); + } + + settings.Provider = dto.Provider; + settings.BaseUrl = string.IsNullOrWhiteSpace(dto.BaseUrl) ? null : dto.BaseUrl.Trim(); + settings.Model = dto.Model.Trim(); + settings.IsEnabled = dto.IsEnabled; + settings.UpdatedAt = DateTime.UtcNow; + + if (dto.RemoveApiKey) + { + settings.EncryptedApiKey = null; + } + else if (!string.IsNullOrWhiteSpace(dto.ApiKey)) + { + settings.EncryptedApiKey = _cryptoService.Encrypt(dto.ApiKey.Trim()); + } + + await _dbContext.SaveChangesAsync(); + + _logger.LogInformation("[AiIntegrationController] User {UserId} updated AI integration settings (provider {Provider})", + currentUser.Id, settings.Provider); + + return Ok(MapToDto(settings)); + } + catch (Exception ex) + { + _logger.LogError(ex, "[AiIntegrationController] Error saving AI integration settings"); + return StatusCode(500, new { error = "Error saving AI integration settings" }); + } + } + + /// + /// Sends a minimal test prompt to the currently-saved provider config to confirm the + /// endpoint, model, and credentials actually work. + /// + [HttpPost("test")] + public async Task TestConnection() + { + try + { + var currentUser = await User.GetUser(_dbContext); + if (!currentUser.isAdmin) + return Forbid(); + + var result = await _aiService.TestConnectionAsync(currentUser.SystemProfileId); + return Ok(result); + } + catch (Exception ex) + { + _logger.LogError(ex, "[AiIntegrationController] Error testing AI connection"); + return StatusCode(500, new { error = "Error testing AI connection" }); + } + } + + private static AiIntegrationSettingsDto MapToDto(AiIntegrationSettings? settings) + { + if (settings == null) + { + return new AiIntegrationSettingsDto(); + } + + return new AiIntegrationSettingsDto + { + Provider = settings.Provider, + BaseUrl = settings.BaseUrl, + Model = settings.Model, + IsEnabled = settings.IsEnabled, + ApiKeyConfigured = !string.IsNullOrWhiteSpace(settings.EncryptedApiKey) + }; + } +} diff --git a/RustRconServerManager.Backend/Database/AppDbContext.cs b/RustRconServerManager.Backend/Database/AppDbContext.cs index 22b26e7..ffd6d07 100644 --- a/RustRconServerManager.Backend/Database/AppDbContext.cs +++ b/RustRconServerManager.Backend/Database/AppDbContext.cs @@ -47,6 +47,9 @@ public AppDbContext(DbContextOptions options):base(options) // Database set for panel settings public DbSet PanelSettings { get; set; } + // Database set for AI provider configuration (Ollama/LM Studio/OpenAI/Anthropic/Universal) + public DbSet AiIntegrationSettings { get; set; } + // Database set for user sessions (multi-session support) public DbSet UserSessions { get; set; } diff --git a/RustRconServerManager.Backend/Interfaces/IAiService.cs b/RustRconServerManager.Backend/Interfaces/IAiService.cs new file mode 100644 index 0000000..ef4958d --- /dev/null +++ b/RustRconServerManager.Backend/Interfaces/IAiService.cs @@ -0,0 +1,51 @@ +using RustRconServerManager.Backend.Models; +using RustRconServerManager.Shared.AiIntegration; + +namespace RustRconServerManager.Backend.Interfaces; + +/// +/// A single message in an AI chat request. Provider clients translate this into whatever +/// shape that provider's API actually expects (OpenAI/LM Studio/Ollama/Universal all use an +/// OpenAI-style {role, content} messages array already; Anthropic separates the system +/// prompt out, which AiService handles internally). +/// +public class AiChatMessage +{ + public string Role { get; set; } = "user"; // "system" | "user" | "assistant" + public string Content { get; set; } = string.Empty; +} + +public class AiChatResult +{ + public bool Success { get; set; } + public string? Content { get; set; } + public string? ErrorMessage { get; set; } +} + +/// +/// Talks to whichever AI provider a SystemProfile has configured. This is the framework +/// other AI-powered features (server monitoring, script/mod repair suggestions, rule-breaker +/// detection, admin notifications, etc.) are meant to build on - none of those exist yet, +/// this just makes "send this profile's configured AI provider a chat request" possible. +/// +public interface IAiService +{ + /// + /// Loads the raw settings row for a profile, or null if none has been saved yet. + /// + Task GetSettingsAsync(int systemProfileId); + + /// + /// Sends a minimal request to the configured provider to confirm the endpoint, model, + /// and credentials actually work - used by the "Test Connection" button. + /// + Task TestConnectionAsync(int systemProfileId); + + /// + /// Sends a chat request to the profile's configured provider. Returns + /// Success = false (with ErrorMessage set) if AI isn't configured/enabled for this + /// profile, or if the provider call fails - callers should treat that as "AI isn't + /// available right now" rather than throwing. + /// + Task SendChatAsync(int systemProfileId, List messages, CancellationToken cancellationToken = default); +} diff --git a/RustRconServerManager.Backend/Migrations/20260821211103_AddAiIntegrationSettings.Designer.cs b/RustRconServerManager.Backend/Migrations/20260821211103_AddAiIntegrationSettings.Designer.cs new file mode 100644 index 0000000..f15f35c --- /dev/null +++ b/RustRconServerManager.Backend/Migrations/20260821211103_AddAiIntegrationSettings.Designer.cs @@ -0,0 +1,2338 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using RustRconServerManager.Backend.Database; + +#nullable disable + +namespace RustRconServerManager.Backend.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260821211103_AddAiIntegrationSettings")] + partial class AddAiIntegrationSettings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("varchar(255)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("longtext"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("longtext"); + + b.Property("ClaimValue") + .HasColumnType("longtext"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("longtext"); + + b.Property("ClaimValue") + .HasColumnType("longtext"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("varchar(255)"); + + b.Property("ProviderKey") + .HasColumnType("varchar(255)"); + + b.Property("ProviderDisplayName") + .HasColumnType("longtext"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("varchar(255)"); + + b.Property("RoleId") + .HasColumnType("varchar(255)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("varchar(255)"); + + b.Property("LoginProvider") + .HasColumnType("varchar(255)"); + + b.Property("Name") + .HasColumnType("varchar(255)"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.AggregatedStat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AggregationType") + .HasColumnType("int"); + + b.Property("Avg") + .HasColumnType("double"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Max") + .HasColumnType("double"); + + b.Property("Min") + .HasColumnType("double"); + + b.Property("SampleCount") + .HasColumnType("int"); + + b.Property("ServerId") + .HasColumnType("int"); + + b.Property("Stat") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ServerId", "Stat", "Timestamp", "AggregationType"); + + b.ToTable("AggregatedStats"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.AiIntegrationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BaseUrl") + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedApiKey") + .HasColumnType("longtext"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Model") + .HasColumnType("longtext"); + + b.Property("Provider") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("SystemProfileId") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("SystemProfileId"); + + b.ToTable("AiIntegrationSettings"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("varchar(255)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DiscordId") + .HasColumnType("longtext"); + + b.Property("DisplayName") + .HasColumnType("longtext"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("HasChosenUsername") + .HasColumnType("tinyint(1)"); + + b.Property("IsModerator") + .HasColumnType("tinyint(1)"); + + b.Property("LastLoginAt") + .HasColumnType("datetime(6)"); + + b.Property("LockoutEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LockoutEnd") + .HasColumnType("datetime(6)"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("longtext"); + + b.Property("PasswordResetCode") + .HasColumnType("longtext"); + + b.Property("PasswordResetCodeExpiry") + .HasColumnType("datetime(6)"); + + b.Property("PhoneNumber") + .HasColumnType("longtext"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("SecurityStamp") + .HasColumnType("longtext"); + + b.Property("SelectedServerId") + .HasColumnType("int"); + + b.Property("SessionHash") + .HasColumnType("longtext"); + + b.Property("SteamId") + .HasColumnType("longtext"); + + b.Property("SystemProfileId") + .HasColumnType("int"); + + b.Property("Theme") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("TwoFactorEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Website") + .HasColumnType("longtext"); + + b.Property("isAdmin") + .HasColumnType("tinyint(1)"); + + b.Property("isLoginBlocked") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("SystemProfileId"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Details") + .HasColumnType("longtext"); + + b.Property("IpAddress") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Role") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ServerId") + .HasColumnType("int"); + + b.Property("StatusCode") + .HasColumnType("int"); + + b.Property("UserEmail") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.ChatMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Channel") + .HasColumnType("longtext"); + + b.Property("IsFlagged") + .HasColumnType("tinyint(1)"); + + b.Property("Message") + .HasColumnType("longtext"); + + b.Property("PlayerName") + .HasColumnType("longtext"); + + b.Property("ServerId") + .HasColumnType("int"); + + b.Property("SteamId") + .HasColumnType("longtext"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.ToTable("ChatMessages"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.DeveloperVacBanOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DaysSinceLastBan") + .HasColumnType("int"); + + b.Property("NumberOfVACBans") + .HasColumnType("int"); + + b.Property("SteamId") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("VACBanned") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("SteamId") + .IsUnique(); + + b.ToTable("DeveloperVacBanOverrides"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.DevicePushToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("LastUsedAt") + .HasColumnType("datetime(6)"); + + b.Property("Platform") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Token") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("DevicePushTokens"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.IpVpnCache", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CheckedAt") + .HasColumnType("datetime(6)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime(6)"); + + b.Property("IpAddress") + .IsRequired() + .HasMaxLength(45) + .HasColumnType("varchar(45)"); + + b.Property("IsVpn") + .HasColumnType("tinyint(1)"); + + b.Property("Provider") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("ProxyType") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("IpAddress") + .IsUnique(); + + b.ToTable("IpVpnCache"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.LegalConsent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AcceptedAt") + .HasColumnType("datetime(6)"); + + b.Property("AcceptedPrivacyPolicy") + .HasColumnType("tinyint(1)"); + + b.Property("AcceptedTermsAndConditions") + .HasColumnType("tinyint(1)"); + + b.Property("ConsentAnonymousMetrics") + .HasColumnType("tinyint(1)"); + + b.Property("IpAddress") + .IsRequired() + .HasMaxLength(45) + .HasColumnType("varchar(45)"); + + b.Property("PrivacyVersion") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("TermsVersion") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UserId") + .HasColumnType("varchar(255)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("LegalConsents"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.MapData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ImageData") + .IsRequired() + .HasColumnType("longblob"); + + b.Property("ImageHeight") + .HasColumnType("int"); + + b.Property("ImagePath") + .HasColumnType("longtext"); + + b.Property("ImageWidth") + .HasColumnType("int"); + + b.Property("MapSeed") + .HasColumnType("int"); + + b.Property("MapSize") + .HasColumnType("int"); + + b.Property("ServerId") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ServerId") + .IsUnique(); + + b.ToTable("MapData"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.ModeratorPagePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("GrantedAt") + .HasColumnType("datetime(6)"); + + b.Property("PageRoute") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PageRoute") + .IsUnique(); + + b.ToTable("ModeratorPagePermissions"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.ModeratorServerPermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("GrantedAt") + .HasColumnType("datetime(6)"); + + b.Property("RconServerId") + .HasColumnType("int"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.HasKey("Id"); + + b.HasIndex("RconServerId"); + + b.HasIndex("UserId", "RconServerId") + .IsUnique(); + + b.ToTable("ModeratorServerPermissions"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.PanelSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AnalyticsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("AutoUpdateEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DeveloperModeEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LastAnalyticsSentAt") + .HasColumnType("datetime(6)"); + + b.Property("MinimumLogLevel") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("SteamApiKeyEncrypted") + .HasColumnType("longtext"); + + b.Property("SystemProfileId") + .HasColumnType("int"); + + b.Property("TimezoneId") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("SystemProfileId") + .IsUnique(); + + b.ToTable("PanelSettings"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.PlayerBan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Expiry") + .HasColumnType("bigint"); + + b.Property("Group") + .HasColumnType("longtext"); + + b.Property("InternalNote") + .HasColumnType("longtext"); + + b.Property("IsGlobalBan") + .HasColumnType("tinyint(1)"); + + b.Property("IsLifted") + .HasColumnType("tinyint(1)"); + + b.Property("LiftedAt") + .HasColumnType("datetime(6)"); + + b.Property("LiftedBy") + .HasColumnType("longtext"); + + b.Property("Notes") + .HasColumnType("longtext"); + + b.Property("ServerId") + .HasColumnType("int"); + + b.Property("SteamId") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Username") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("PlayerBans"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.PlayerBanHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BannedAt") + .HasColumnType("datetime(6)"); + + b.Property("BannedBy") + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DurationHours") + .HasColumnType("bigint"); + + b.Property("ExpiryDate") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("IsGlobalBan") + .HasColumnType("tinyint(1)"); + + b.Property("LiftReason") + .HasColumnType("longtext"); + + b.Property("LiftedAt") + .HasColumnType("datetime(6)"); + + b.Property("LiftedBy") + .HasColumnType("longtext"); + + b.Property("PlayerBanId") + .HasColumnType("int"); + + b.Property("PlayerName") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ServerId") + .HasColumnType("int"); + + b.Property("SteamId") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.ToTable("PlayerBanHistories"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.PlayerIpHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Country") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("FirstUsed") + .HasColumnType("datetime(6)"); + + b.Property("IpAddress") + .IsRequired() + .HasMaxLength(45) + .HasColumnType("varchar(45)"); + + b.Property("IsVpn") + .HasColumnType("tinyint(1)"); + + b.Property("LastUsed") + .HasColumnType("datetime(6)"); + + b.Property("ServerId") + .HasColumnType("int"); + + b.Property("SteamId") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("varchar(17)"); + + b.Property("TimesUsed") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SteamId", "ServerId"); + + b.HasIndex("SteamId", "ServerId", "IpAddress") + .IsUnique(); + + b.ToTable("PlayerIpHistory"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.PlayerKillLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsPVP") + .HasColumnType("tinyint(1)"); + + b.Property("KilledById") + .HasColumnType("longtext"); + + b.Property("KilledByName") + .HasColumnType("longtext"); + + b.Property("KilledPlayerId") + .HasColumnType("longtext"); + + b.Property("KilledPlayerName") + .HasColumnType("longtext"); + + b.Property("ServerId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("PlayerKillLogs"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.PlayerNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("varchar(450)"); + + b.Property("Note") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ServerId") + .HasColumnType("int"); + + b.Property("SteamId") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("varchar(17)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.ToTable("PlayerNotes"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.PlayerReport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AdminNotes") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsArchived") + .HasColumnType("tinyint(1)"); + + b.Property("Message") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("ReportedId") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("ReportedName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("ReporterId") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("ReporterName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReviewedByUserId") + .HasColumnType("varchar(255)"); + + b.Property("ServerId") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ReviewedByUserId"); + + b.ToTable("PlayerReports"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.PluginVersionCache", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CachedAt") + .HasColumnType("datetime(6)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime(6)"); + + b.Property("LatestVersion") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PluginName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("PluginUrl") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Source") + .HasColumnType("int"); + + b.Property("UmodRateLimitRemaining") + .HasColumnType("int"); + + b.Property("UmodRateLimitTotal") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("PluginName"); + + b.ToTable("PluginVersionCache"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.PresetCommand", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Command") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasColumnType("longtext"); + + b.Property("IsGlobal") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RconServerId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("RconServerId"); + + b.ToTable("PresetCommands"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.RconLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Message") + .HasColumnType("longtext"); + + b.Property("ServerId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("RconLogEntries"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.RconServer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasColumnType("longtext"); + + b.Property("EncryptedHost") + .HasColumnType("longtext"); + + b.Property("EncryptedPassword") + .HasColumnType("longtext"); + + b.Property("EnvironmentSecret") + .HasColumnType("longtext"); + + b.Property("GamePort") + .HasColumnType("int"); + + b.Property("LastSeen") + .HasColumnType("datetime(6)"); + + b.Property("LatestEntityCount") + .HasColumnType("int"); + + b.Property("LatestFpsCount") + .HasColumnType("int"); + + b.Property("LatestJoiningPlayers") + .HasColumnType("int"); + + b.Property("LatestMap") + .HasColumnType("longtext"); + + b.Property("LatestMemoryUsage") + .HasColumnType("int"); + + b.Property("LatestPlayerCount") + .HasColumnType("int"); + + b.Property("LatestQueuedPlayers") + .HasColumnType("int"); + + b.Property("LatestServerProtocol") + .HasColumnType("longtext"); + + b.Property("LatestServerVersion") + .HasColumnType("int"); + + b.Property("LatestUptime") + .HasColumnType("int"); + + b.Property("ModFramework") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("QueryPort") + .HasColumnType("int"); + + b.Property("RconPort") + .HasColumnType("int"); + + b.Property("RrsmModInitialized") + .HasColumnType("tinyint(1)"); + + b.Property("RustRconServerManagerModInstalled") + .HasColumnType("tinyint(1)"); + + b.Property("ServerHeaderImageData") + .HasColumnType("longblob"); + + b.Property("ServerHostname") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ServerLogoImageData") + .HasColumnType("longblob"); + + b.Property("SystemProfileId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SystemProfileId"); + + b.ToTable("RconServers"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.RustItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Description") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("ShortName") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("StackSize") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("RustItems"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.ScheduledCommand", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Command") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DayOfMonth") + .HasColumnType("int"); + + b.Property("DaysOfWeek") + .HasColumnType("longtext"); + + b.Property("Description") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ExecuteAt") + .HasColumnType("datetime(6)"); + + b.Property("ExecutionCount") + .HasColumnType("int"); + + b.Property("ExecutionHour") + .HasColumnType("int"); + + b.Property("ExecutionMinute") + .HasColumnType("int"); + + b.Property("IntervalHours") + .HasColumnType("int"); + + b.Property("IntervalMinutes") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("LastExecutedAt") + .HasColumnType("datetime(6)"); + + b.Property("LastExecutionError") + .HasColumnType("longtext"); + + b.Property("LastExecutionSuccess") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("NextExecutionAt") + .HasColumnType("datetime(6)"); + + b.Property("RconServerId") + .HasColumnType("int"); + + b.Property("ScheduleType") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UtcOffsetMinutes") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RconServerId"); + + b.ToTable("ScheduledCommands"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.ServerPluginSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("PluginName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("RustServerId") + .HasColumnType("int"); + + b.Property("Source") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("RustServerId", "PluginName") + .IsUnique(); + + b.ToTable("ServerPluginSources"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.ServerProtectionSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BanDurationMinutes") + .HasColumnType("int"); + + b.Property("BlockPrivateSteamProfiles") + .HasColumnType("tinyint(1)"); + + b.Property("CountryFilterMode") + .HasColumnType("int"); + + b.Property("CountryList") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EnablePublicBanProtection") + .HasColumnType("tinyint(1)"); + + b.Property("EnableVacBanProtection") + .HasColumnType("tinyint(1)"); + + b.Property("EnableVpnCheck") + .HasColumnType("tinyint(1)"); + + b.Property("EnableVpnProtection") + .HasColumnType("tinyint(1)"); + + b.Property("EnableWhitelistOnly") + .HasColumnType("tinyint(1)"); + + b.Property("MaxPublicBans") + .HasColumnType("int"); + + b.Property("MaxVacBans") + .HasColumnType("int"); + + b.Property("MinDaysSinceLastVACBan") + .HasColumnType("int"); + + b.Property("PrivateSteamProfileAction") + .HasColumnType("int"); + + b.Property("PublicBanAction") + .HasColumnType("int"); + + b.Property("ServerId") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("VacBanAction") + .HasColumnType("int"); + + b.Property("VpnProtectionAction") + .HasColumnType("int"); + + b.Property("WhitelistOnlyKickMessage") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("WhitelistedSteamIds") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.ToTable("ServerProtectionSettings"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.ServerWebhookSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EnablePlayerBanWebhook") + .HasColumnType("tinyint(1)"); + + b.Property("EnablePlayerConnectWebhook") + .HasColumnType("tinyint(1)"); + + b.Property("EnablePlayerDisconnectWebhook") + .HasColumnType("tinyint(1)"); + + b.Property("EnablePlayerKickWebhook") + .HasColumnType("tinyint(1)"); + + b.Property("EnablePlayerKillWebhook") + .HasColumnType("tinyint(1)"); + + b.Property("EnablePlayerReportWebhook") + .HasColumnType("tinyint(1)"); + + b.Property("EnableServerOfflineWebhook") + .HasColumnType("tinyint(1)"); + + b.Property("EnableServerOnlineWebhook") + .HasColumnType("tinyint(1)"); + + b.Property("EnableServerProtectionWebhook") + .HasColumnType("tinyint(1)"); + + b.Property("PlayerBanCustomContent") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("PlayerBanFormat") + .HasColumnType("int"); + + b.Property("PlayerBanTextContent") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("PlayerBanWebhookUrl") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("PlayerConnectCustomContent") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("PlayerConnectFormat") + .HasColumnType("int"); + + b.Property("PlayerConnectTextContent") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("PlayerConnectWebhookUrl") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("PlayerDisconnectCustomContent") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("PlayerDisconnectFormat") + .HasColumnType("int"); + + b.Property("PlayerDisconnectTextContent") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("PlayerDisconnectWebhookUrl") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("PlayerKickCustomContent") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("PlayerKickFormat") + .HasColumnType("int"); + + b.Property("PlayerKickTextContent") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("PlayerKickWebhookUrl") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("PlayerKillCustomContent") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("PlayerKillFormat") + .HasColumnType("int"); + + b.Property("PlayerKillTextContent") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("PlayerKillWebhookUrl") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("PlayerReportCustomContent") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("PlayerReportFormat") + .HasColumnType("int"); + + b.Property("PlayerReportTextContent") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("PlayerReportWebhookUrl") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ServerId") + .HasColumnType("int"); + + b.Property("ServerOfflineCustomContent") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ServerOfflineFormat") + .HasColumnType("int"); + + b.Property("ServerOfflineTextContent") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ServerOfflineWebhookUrl") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ServerOnlineCustomContent") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ServerOnlineFormat") + .HasColumnType("int"); + + b.Property("ServerOnlineTextContent") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ServerOnlineWebhookUrl") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ServerProtectionCustomContent") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ServerProtectionFormat") + .HasColumnType("int"); + + b.Property("ServerProtectionTextContent") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ServerProtectionWebhookUrl") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.ToTable("ServerWebhookSettings"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.SleepingBagData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("OwnerName") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("OwnerSteamId") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("PositionX") + .HasColumnType("float"); + + b.Property("PositionY") + .HasColumnType("float"); + + b.Property("PositionZ") + .HasColumnType("float"); + + b.Property("ServerId") + .HasColumnType("int"); + + b.Property("Type") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.ToTable("SleepingBags"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.StatsHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ServerId") + .HasColumnType("int"); + + b.Property("Stat") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("Value") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("ServerId", "Stat", "CreatedAt"); + + b.ToTable("StatsHistories"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.SteamPlayer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Avatar") + .HasColumnType("longtext"); + + b.Property("AvatarLastUpdated") + .HasColumnType("datetime(6)"); + + b.Property("Country") + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DaysSinceLastVACBan") + .HasColumnType("int"); + + b.Property("FirstSeen") + .HasColumnType("datetime(6)"); + + b.Property("IsOnline") + .HasColumnType("tinyint(1)"); + + b.Property("LastIp") + .HasColumnType("longtext"); + + b.Property("LastSeen") + .HasColumnType("datetime(6)"); + + b.Property("LatestHealth") + .HasColumnType("float"); + + b.Property("LatestPing") + .HasColumnType("int"); + + b.Property("LatestPositionX") + .HasColumnType("float"); + + b.Property("LatestPositionY") + .HasColumnType("float"); + + b.Property("LatestPositionZ") + .HasColumnType("float"); + + b.Property("LatestTeamId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("NumberOfVACBans") + .HasColumnType("int"); + + b.Property("ProfileVisibility") + .HasColumnType("int"); + + b.Property("RustPlaytimeMinutes") + .HasColumnType("int"); + + b.Property("ServerId") + .HasColumnType("int"); + + b.Property("SteamAccountCreated") + .HasColumnType("datetime(6)"); + + b.Property("SteamId") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("VACBanned") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.ToTable("SteamPlayers"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.SystemProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Secret") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("SystemProfiles"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.ToolCupboardData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AuthorizedPlayers") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("OwnerName") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("OwnerSteamId") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("PositionX") + .HasColumnType("float"); + + b.Property("PositionY") + .HasColumnType("float"); + + b.Property("PositionZ") + .HasColumnType("float"); + + b.Property("ServerId") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.ToTable("ToolCupboards"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.Trigger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ActionType") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ActionValue") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ChatConditionType") + .HasColumnType("longtext"); + + b.Property("ChatConditionValue") + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DelaySeconds") + .HasColumnType("int"); + + b.Property("Description") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("LastExecutionError") + .HasColumnType("longtext"); + + b.Property("LastExecutionSuccess") + .HasColumnType("tinyint(1)"); + + b.Property("LastTriggeredAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RconServerId") + .HasColumnType("int"); + + b.Property("TriggerCount") + .HasColumnType("int"); + + b.Property("TriggerEvent") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WebhookUrl") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("RconServerId"); + + b.ToTable("Triggers"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.UserNotificationPreference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("PlayerBanned") + .HasColumnType("tinyint(1)"); + + b.Property("PlayerOffline") + .HasColumnType("tinyint(1)"); + + b.Property("PlayerOnline") + .HasColumnType("tinyint(1)"); + + b.Property("PlayerReported") + .HasColumnType("tinyint(1)"); + + b.Property("ServerId") + .HasColumnType("int"); + + b.Property("ServerOffline") + .HasColumnType("tinyint(1)"); + + b.Property("ServerOnline") + .HasColumnType("tinyint(1)"); + + b.Property("ServerProtection") + .HasColumnType("tinyint(1)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.HasKey("Id"); + + b.HasIndex("ServerId"); + + b.HasIndex("UserId", "ServerId") + .IsUnique(); + + b.ToTable("UserNotificationPreferences"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DeviceName") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ExpiresAt") + .HasColumnType("datetime(6)"); + + b.Property("IpAddress") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IsRevoked") + .HasColumnType("tinyint(1)"); + + b.Property("LastActivityAt") + .HasColumnType("datetime(6)"); + + b.Property("SessionHash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UserAgent") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsRevoked"); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("RustRconServerManager.Backend.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("RustRconServerManager.Backend.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("RustRconServerManager.Backend.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("RustRconServerManager.Backend.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.AiIntegrationSettings", b => + { + b.HasOne("RustRconServerManager.Backend.Models.SystemProfile", "SystemProfile") + .WithMany() + .HasForeignKey("SystemProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemProfile"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.ApplicationUser", b => + { + b.HasOne("RustRconServerManager.Backend.Models.SystemProfile", "SystemProfile") + .WithMany() + .HasForeignKey("SystemProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemProfile"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.DevicePushToken", b => + { + b.HasOne("RustRconServerManager.Backend.Models.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.LegalConsent", b => + { + b.HasOne("RustRconServerManager.Backend.Models.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.MapData", b => + { + b.HasOne("RustRconServerManager.Backend.Models.RconServer", "Server") + .WithOne() + .HasForeignKey("RustRconServerManager.Backend.Models.MapData", "ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Server"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.ModeratorPagePermission", b => + { + b.HasOne("RustRconServerManager.Backend.Models.ApplicationUser", "User") + .WithMany("PagePermissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.ModeratorServerPermission", b => + { + b.HasOne("RustRconServerManager.Backend.Models.RconServer", "RconServer") + .WithMany("ModeratorPermissions") + .HasForeignKey("RconServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("RustRconServerManager.Backend.Models.ApplicationUser", "User") + .WithMany("ServerPermissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("RconServer"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.PanelSettings", b => + { + b.HasOne("RustRconServerManager.Backend.Models.SystemProfile", "SystemProfile") + .WithOne("PanelSettings") + .HasForeignKey("RustRconServerManager.Backend.Models.PanelSettings", "SystemProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemProfile"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.PlayerReport", b => + { + b.HasOne("RustRconServerManager.Backend.Models.ApplicationUser", "ReviewedBy") + .WithMany() + .HasForeignKey("ReviewedByUserId"); + + b.Navigation("ReviewedBy"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.PresetCommand", b => + { + b.HasOne("RustRconServerManager.Backend.Models.RconServer", "RconServer") + .WithMany() + .HasForeignKey("RconServerId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RconServer"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.RconServer", b => + { + b.HasOne("RustRconServerManager.Backend.Models.SystemProfile", "SystemProfile") + .WithMany() + .HasForeignKey("SystemProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemProfile"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.ScheduledCommand", b => + { + b.HasOne("RustRconServerManager.Backend.Models.RconServer", "RconServer") + .WithMany() + .HasForeignKey("RconServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("RconServer"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.ServerPluginSource", b => + { + b.HasOne("RustRconServerManager.Backend.Models.RconServer", "RconServer") + .WithMany() + .HasForeignKey("RustServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("RconServer"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.ServerProtectionSettings", b => + { + b.HasOne("RustRconServerManager.Backend.Models.RconServer", "Server") + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Server"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.ServerWebhookSettings", b => + { + b.HasOne("RustRconServerManager.Backend.Models.RconServer", "Server") + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Server"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.Trigger", b => + { + b.HasOne("RustRconServerManager.Backend.Models.RconServer", "RconServer") + .WithMany() + .HasForeignKey("RconServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("RconServer"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.UserNotificationPreference", b => + { + b.HasOne("RustRconServerManager.Backend.Models.RconServer", "Server") + .WithMany() + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("RustRconServerManager.Backend.Models.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Server"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.UserSession", b => + { + b.HasOne("RustRconServerManager.Backend.Models.ApplicationUser", "User") + .WithMany("Sessions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.ApplicationUser", b => + { + b.Navigation("PagePermissions"); + + b.Navigation("ServerPermissions"); + + b.Navigation("Sessions"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.RconServer", b => + { + b.Navigation("ModeratorPermissions"); + }); + + modelBuilder.Entity("RustRconServerManager.Backend.Models.SystemProfile", b => + { + b.Navigation("PanelSettings") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/RustRconServerManager.Backend/Migrations/20260821211103_AddAiIntegrationSettings.cs b/RustRconServerManager.Backend/Migrations/20260821211103_AddAiIntegrationSettings.cs new file mode 100644 index 0000000..ab6005e --- /dev/null +++ b/RustRconServerManager.Backend/Migrations/20260821211103_AddAiIntegrationSettings.cs @@ -0,0 +1,59 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RustRconServerManager.Backend.Migrations +{ + /// + public partial class AddAiIntegrationSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AiIntegrationSettings", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + SystemProfileId = table.Column(type: "int", nullable: false), + Provider = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + BaseUrl = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + EncryptedApiKey = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + Model = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + IsEnabled = table.Column(type: "tinyint(1)", nullable: false), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AiIntegrationSettings", x => x.Id); + table.ForeignKey( + name: "FK_AiIntegrationSettings_SystemProfiles_SystemProfileId", + column: x => x.SystemProfileId, + principalTable: "SystemProfiles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_AiIntegrationSettings_SystemProfileId", + table: "AiIntegrationSettings", + column: "SystemProfileId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AiIntegrationSettings"); + } + } +} diff --git a/RustRconServerManager.Backend/Migrations/AppDbContextModelSnapshot.cs b/RustRconServerManager.Backend/Migrations/AppDbContextModelSnapshot.cs index 0a02fdb..45a3f62 100644 --- a/RustRconServerManager.Backend/Migrations/AppDbContextModelSnapshot.cs +++ b/RustRconServerManager.Backend/Migrations/AppDbContextModelSnapshot.cs @@ -197,6 +197,46 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AggregatedStats"); }); + modelBuilder.Entity("RustRconServerManager.Backend.Models.AiIntegrationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BaseUrl") + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedApiKey") + .HasColumnType("longtext"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Model") + .HasColumnType("longtext"); + + b.Property("Provider") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("SystemProfileId") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("SystemProfileId"); + + b.ToTable("AiIntegrationSettings"); + }); + modelBuilder.Entity("RustRconServerManager.Backend.Models.ApplicationUser", b => { b.Property("Id") @@ -2061,6 +2101,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("RustRconServerManager.Backend.Models.AiIntegrationSettings", b => + { + b.HasOne("RustRconServerManager.Backend.Models.SystemProfile", "SystemProfile") + .WithMany() + .HasForeignKey("SystemProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemProfile"); + }); + modelBuilder.Entity("RustRconServerManager.Backend.Models.ApplicationUser", b => { b.HasOne("RustRconServerManager.Backend.Models.SystemProfile", "SystemProfile") diff --git a/RustRconServerManager.Backend/Models/AiIntegrationSettings.cs b/RustRconServerManager.Backend/Models/AiIntegrationSettings.cs new file mode 100644 index 0000000..fc04d12 --- /dev/null +++ b/RustRconServerManager.Backend/Models/AiIntegrationSettings.cs @@ -0,0 +1,62 @@ +namespace RustRconServerManager.Backend.Models; + +/// +/// AI provider configuration for a SystemProfile. One row per profile, created on first +/// save from the AI Integration page. Holds the connection details the AiService needs to +/// talk to whichever provider is configured - the actual AI-powered features (monitoring, +/// rule-breaker detection, etc.) are built on top of this and don't live here. +/// +public class AiIntegrationSettings +{ + public int Id { get; set; } + + /// + /// Foreign key to SystemProfile + /// + public int SystemProfileId { get; set; } + + /// + /// Navigation property to SystemProfile + /// + public SystemProfile SystemProfile { get; set; } = null!; + + /// + /// Which provider to talk to - "Ollama", "LmStudio", "OpenAI", "Anthropic", or + /// "Universal" (a generic OpenAI-compatible endpoint, for providers/proxies not + /// explicitly listed). Validated against AiProviders.All at the controller layer + /// rather than modeled as a DB enum, matching how ModFramework etc. are stored elsewhere + /// in this codebase. + /// + public string Provider { get; set; } = "OpenAI"; + + /// + /// Base URL of the provider's API. Required for Ollama/LmStudio/Universal (self-hosted, + /// no sane default); optional override for OpenAI/Anthropic, which fall back to their + /// public API endpoints when left blank. + /// + public string? BaseUrl { get; set; } + + /// + /// API key, encrypted at rest via IRconPasswordsCryptoService (same scheme as + /// PanelSettings.SteamApiKeyEncrypted - the crypto service isn't actually RCON-specific, + /// just named after its original use). Not required for a typical local Ollama/LM + /// Studio setup with no auth in front of it. + /// + public string? EncryptedApiKey { get; set; } + + /// + /// Model identifier to request, e.g. "gpt-4o", "claude-sonnet-4-5-20250929", "llama3.1". + /// + public string? Model { get; set; } + + /// + /// Master on/off switch. AI-powered features should check this (not just "is a provider + /// configured") before calling out, so an admin can pause AI usage without clearing + /// their saved configuration. + /// + public bool IsEnabled { get; set; } = false; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/RustRconServerManager.Backend/Program.cs b/RustRconServerManager.Backend/Program.cs index 941a295..aab2a3c 100644 --- a/RustRconServerManager.Backend/Program.cs +++ b/RustRconServerManager.Backend/Program.cs @@ -63,6 +63,8 @@ // Register Discord Webhook Service (for Discord event notifications) builder.Services.AddHttpClient(); +builder.Services.AddHttpClient(); + // Register Email Service (for sending password recovery emails via local SMTP) builder.Services.AddScoped(); diff --git a/RustRconServerManager.Backend/Services/AiService.cs b/RustRconServerManager.Backend/Services/AiService.cs new file mode 100644 index 0000000..d6fea1c --- /dev/null +++ b/RustRconServerManager.Backend/Services/AiService.cs @@ -0,0 +1,335 @@ +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using RustRconServerManager.Backend.Database; +using RustRconServerManager.Backend.Interfaces; +using RustRconServerManager.Backend.Models; +using RustRconServerManager.Shared.AiIntegration; + +namespace RustRconServerManager.Backend.Services; + +public class AiService : IAiService +{ + private readonly HttpClient _httpClient; + private readonly IServiceScopeFactory _scopeFactory; + private readonly IRconPasswordsCryptoService _cryptoService; + private readonly ILogger _logger; + + private const string DefaultOpenAiBaseUrl = "https://api.openai.com"; + private const string DefaultAnthropicBaseUrl = "https://api.anthropic.com"; + private const string AnthropicVersion = "2023-06-01"; + + public AiService( + HttpClient httpClient, + IServiceScopeFactory scopeFactory, + IRconPasswordsCryptoService cryptoService, + ILogger logger) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); + _cryptoService = cryptoService ?? throw new ArgumentNullException(nameof(cryptoService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _httpClient.Timeout = TimeSpan.FromSeconds(60); // AI responses can take a while, especially local models + } + + public async Task GetSettingsAsync(int systemProfileId) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + return await db.AiIntegrationSettings + .FirstOrDefaultAsync(s => s.SystemProfileId == systemProfileId); + } + + public async Task TestConnectionAsync(int systemProfileId) + { + var settings = await GetSettingsAsync(systemProfileId); + + if (settings == null) + { + return new TestAiConnectionResultDto { Success = false, Message = "No AI provider has been configured yet." }; + } + + // Deliberately does NOT check settings.IsEnabled - testing the connection is an + // explicit, admin-initiated action, not an automated AI feature firing on its own, + // so it should work even while AI integration is still switched off (e.g. verifying + // credentials before turning the feature on for real). + var messages = new List + { + new() { Role = "user", Content = "Reply with only the single word: OK" } + }; + + var result = await SendChatInternalAsync(systemProfileId, settings, messages, CancellationToken.None); + + if (!result.Success) + { + return new TestAiConnectionResultDto { Success = false, Message = result.ErrorMessage ?? "Connection test failed." }; + } + + var preview = (result.Content ?? string.Empty).Trim(); + if (preview.Length > 200) + { + preview = preview.Substring(0, 200) + "..."; + } + + return new TestAiConnectionResultDto { Success = true, Message = $"Connected successfully. Response: \"{preview}\"" }; + } + + public async Task SendChatAsync(int systemProfileId, List messages, CancellationToken cancellationToken = default) + { + var settings = await GetSettingsAsync(systemProfileId); + + if (settings == null || !settings.IsEnabled) + { + return new AiChatResult { Success = false, ErrorMessage = "AI integration is not configured or is disabled for this system profile." }; + } + + return await SendChatInternalAsync(systemProfileId, settings, messages, cancellationToken); + } + + private async Task SendChatInternalAsync(int systemProfileId, AiIntegrationSettings settings, List messages, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(settings.Model)) + { + return new AiChatResult { Success = false, ErrorMessage = "No AI model configured." }; + } + + string? apiKey = null; + if (!string.IsNullOrWhiteSpace(settings.EncryptedApiKey)) + { + try + { + apiKey = _cryptoService.Decrypt(settings.EncryptedApiKey); + } + catch (Exception ex) + { + _logger.LogError(ex, "[AiService] Failed to decrypt API key for SystemProfile {SystemProfileId}", systemProfileId); + return new AiChatResult { Success = false, ErrorMessage = "Stored API key could not be decrypted." }; + } + } + + try + { + return settings.Provider switch + { + AiProviders.Anthropic => await SendAnthropicAsync(settings, apiKey, messages, cancellationToken), + AiProviders.Ollama => await SendOllamaAsync(settings, apiKey, messages, cancellationToken), + AiProviders.LmStudio or AiProviders.OpenAI or AiProviders.Universal + => await SendOpenAiCompatibleAsync(settings, apiKey, messages, cancellationToken), + _ => new AiChatResult { Success = false, ErrorMessage = $"Unknown AI provider '{settings.Provider}'." } + }; + } + catch (TaskCanceledException) + { + return new AiChatResult { Success = false, ErrorMessage = "Request to the AI provider timed out." }; + } + catch (HttpRequestException ex) + { + _logger.LogWarning(ex, "[AiService] HTTP error calling {Provider} for SystemProfile {SystemProfileId}", settings.Provider, systemProfileId); + return new AiChatResult { Success = false, ErrorMessage = $"Could not reach the AI provider: {ex.Message}" }; + } + catch (Exception ex) + { + _logger.LogError(ex, "[AiService] Unexpected error calling {Provider} for SystemProfile {SystemProfileId}", settings.Provider, systemProfileId); + return new AiChatResult { Success = false, ErrorMessage = "Unexpected error while contacting the AI provider." }; + } + } + + /// + /// OpenAI, LM Studio, and a generic "Universal" OpenAI-compatible endpoint all speak the + /// same request/response shape (POST {base}/v1/chat/completions), so one implementation + /// covers all three. + /// + private async Task SendOpenAiCompatibleAsync(AiIntegrationSettings settings, string? apiKey, List messages, CancellationToken ct) + { + var baseUrl = ResolveBaseUrl(settings, settings.Provider == AiProviders.OpenAI ? DefaultOpenAiBaseUrl : null); + if (baseUrl == null) + { + return new AiChatResult { Success = false, ErrorMessage = "No base URL configured for this provider." }; + } + + var requestBody = new + { + model = settings.Model, + messages = messages.Select(m => new { role = m.Role, content = m.Content }), + max_tokens = 1024 + }; + + using var request = new HttpRequestMessage(HttpMethod.Post, $"{baseUrl}/v1/chat/completions") + { + Content = new StringContent(JsonSerializer.Serialize(requestBody), Encoding.UTF8, "application/json") + }; + + if (!string.IsNullOrWhiteSpace(apiKey)) + { + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiKey); + } + + using var response = await _httpClient.SendAsync(request, ct); + var body = await response.Content.ReadAsStringAsync(ct); + + if (!response.IsSuccessStatusCode) + { + return new AiChatResult { Success = false, ErrorMessage = ExtractErrorMessage(body, response.StatusCode) }; + } + + try + { + using var doc = JsonDocument.Parse(body); + var content = doc.RootElement + .GetProperty("choices")[0] + .GetProperty("message") + .GetProperty("content") + .GetString(); + + return new AiChatResult { Success = true, Content = content }; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[AiService] Unexpected response shape from OpenAI-compatible provider"); + return new AiChatResult { Success = false, ErrorMessage = "Received an unexpected response from the AI provider." }; + } + } + + private async Task SendAnthropicAsync(AiIntegrationSettings settings, string? apiKey, List messages, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(apiKey)) + { + return new AiChatResult { Success = false, ErrorMessage = "An API key is required for Anthropic." }; + } + + var baseUrl = ResolveBaseUrl(settings, DefaultAnthropicBaseUrl)!; + + // Anthropic wants the system prompt as its own top-level field, not in the messages array. + var systemPrompt = string.Join("\n\n", messages.Where(m => m.Role == "system").Select(m => m.Content)); + var conversationMessages = messages + .Where(m => m.Role != "system") + .Select(m => new { role = m.Role, content = m.Content }) + .ToList(); + + var requestBody = new Dictionary + { + ["model"] = settings.Model, + ["max_tokens"] = 1024, + ["messages"] = conversationMessages + }; + if (!string.IsNullOrWhiteSpace(systemPrompt)) + { + requestBody["system"] = systemPrompt; + } + + using var request = new HttpRequestMessage(HttpMethod.Post, $"{baseUrl}/v1/messages") + { + Content = new StringContent(JsonSerializer.Serialize(requestBody), Encoding.UTF8, "application/json") + }; + request.Headers.Add("x-api-key", apiKey); + request.Headers.Add("anthropic-version", AnthropicVersion); + + using var response = await _httpClient.SendAsync(request, ct); + var body = await response.Content.ReadAsStringAsync(ct); + + if (!response.IsSuccessStatusCode) + { + return new AiChatResult { Success = false, ErrorMessage = ExtractErrorMessage(body, response.StatusCode) }; + } + + try + { + using var doc = JsonDocument.Parse(body); + var content = doc.RootElement + .GetProperty("content")[0] + .GetProperty("text") + .GetString(); + + return new AiChatResult { Success = true, Content = content }; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[AiService] Unexpected response shape from Anthropic"); + return new AiChatResult { Success = false, ErrorMessage = "Received an unexpected response from the AI provider." }; + } + } + + private async Task SendOllamaAsync(AiIntegrationSettings settings, string? apiKey, List messages, CancellationToken ct) + { + var baseUrl = ResolveBaseUrl(settings, null); + if (baseUrl == null) + { + return new AiChatResult { Success = false, ErrorMessage = "No base URL configured for Ollama." }; + } + + var requestBody = new + { + model = settings.Model, + messages = messages.Select(m => new { role = m.Role, content = m.Content }), + stream = false + }; + + using var request = new HttpRequestMessage(HttpMethod.Post, $"{baseUrl}/api/chat") + { + Content = new StringContent(JsonSerializer.Serialize(requestBody), Encoding.UTF8, "application/json") + }; + + // A plain local Ollama install has no auth, but some setups sit behind a + // reverse proxy that does - send it if one was configured. + if (!string.IsNullOrWhiteSpace(apiKey)) + { + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiKey); + } + + using var response = await _httpClient.SendAsync(request, ct); + var body = await response.Content.ReadAsStringAsync(ct); + + if (!response.IsSuccessStatusCode) + { + return new AiChatResult { Success = false, ErrorMessage = ExtractErrorMessage(body, response.StatusCode) }; + } + + try + { + using var doc = JsonDocument.Parse(body); + var content = doc.RootElement + .GetProperty("message") + .GetProperty("content") + .GetString(); + + return new AiChatResult { Success = true, Content = content }; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[AiService] Unexpected response shape from Ollama"); + return new AiChatResult { Success = false, ErrorMessage = "Received an unexpected response from Ollama. Is the model name correct?" }; + } + } + + private static string? ResolveBaseUrl(AiIntegrationSettings settings, string? fallback) + { + var baseUrl = string.IsNullOrWhiteSpace(settings.BaseUrl) ? fallback : settings.BaseUrl.Trim(); + return baseUrl?.TrimEnd('/'); + } + + private static string ExtractErrorMessage(string responseBody, System.Net.HttpStatusCode statusCode) + { + try + { + using var doc = JsonDocument.Parse(responseBody); + if (doc.RootElement.TryGetProperty("error", out var errorEl)) + { + if (errorEl.ValueKind == JsonValueKind.Object && errorEl.TryGetProperty("message", out var msgEl)) + { + return $"{(int)statusCode}: {msgEl.GetString()}"; + } + if (errorEl.ValueKind == JsonValueKind.String) + { + return $"{(int)statusCode}: {errorEl.GetString()}"; + } + } + } + catch (JsonException) + { + // Fall through to the generic message below. + } + + return $"AI provider returned {(int)statusCode}."; + } +} diff --git a/RustRconServerManager.Frontend/Pages/AiIntegration.razor b/RustRconServerManager.Frontend/Pages/AiIntegration.razor new file mode 100644 index 0000000..55eedd0 --- /dev/null +++ b/RustRconServerManager.Frontend/Pages/AiIntegration.razor @@ -0,0 +1,378 @@ +@page "/ai-integration" +@using Microsoft.AspNetCore.Authorization +@using RustRconServerManager.Frontend.Components.Layout +@using RustRconServerManager.Shared.AiIntegration +@using System.Net.Http.Json +@inject HttpClient Http +@attribute [Authorize] + + +
+
+

AI Integration

+

Connect an AI provider for server monitoring, script/mod troubleshooting, rule-breaker detection, and admin notifications.

+
+
+ + @if (isLoading) + { +
+
+
+ } + else if (forbidden) + { + +
+ +

Only admins can configure AI integration.

+
+
+ } + else + { +
+ +
+
+ +

+ This is the connection the panel's AI-powered features will use. Nothing is sent to the + provider unless a specific AI feature actually calls it - saving this configuration alone + doesn't send any data anywhere. +

+
+ +
+ + +
+ +
+ + + @if (!RequiresBaseUrl) + { + Leave blank to use the default @provider API endpoint. + } +
+ +
+ + +
+ +
+ +
+ API Key + @if (apiKeyConfigured) + { + Configured + } + else + { + Not configured + } +
+ +
+ + @if (!RequiresApiKey) + { + Usually not needed for a local @(provider == AiProviders.Ollama ? "Ollama" : "LM Studio") install unless it sits behind an authenticated proxy. + } +
+ +
+ +
+ Enable AI integration + +
+ +
+ + + @if (apiKeyConfigured) + { + + } +
+ + @if (errorMessage != null) + { +
@errorMessage
+ } + @if (!string.IsNullOrEmpty(successMessage)) + { +
@successMessage
+ } + @if (testResultMessage != null) + { +
@testResultMessage
+ } +
+
+
+ } +
+ +@code { + private bool isLoading = true; + private bool forbidden = false; + private bool isSaving = false; + private bool isTesting = false; + + private string provider = AiProviders.OpenAI; + private string baseUrl = string.Empty; + private string model = string.Empty; + private bool isEnabled = false; + private string apiKeyInput = string.Empty; + private bool apiKeyConfigured = false; + + private string? errorMessage; + private string? successMessage; + private string? testResultMessage; + private bool testResultSuccess; + + private bool RequiresBaseUrl => provider is AiProviders.Ollama or AiProviders.LmStudio or AiProviders.Universal; + private bool RequiresApiKey => provider is AiProviders.OpenAI or AiProviders.Anthropic; + + private string BaseUrlPlaceholder => provider switch + { + AiProviders.Ollama => "http://localhost:11434", + AiProviders.LmStudio => "http://localhost:1234", + AiProviders.Universal => "https://your-provider.example.com", + AiProviders.OpenAI => "https://api.openai.com (default)", + AiProviders.Anthropic => "https://api.anthropic.com (default)", + _ => "" + }; + + private string ModelPlaceholder => provider switch + { + AiProviders.OpenAI => "gpt-4o", + AiProviders.Anthropic => "claude-sonnet-4-5-20250929", + AiProviders.Ollama => "llama3.1", + AiProviders.LmStudio => "local-model", + _ => "model-name" + }; + + private string ApiKeyPlaceholder => RequiresApiKey ? "Enter your API key" : "Enter your API key (if required)"; + + protected override async Task OnInitializedAsync() + { + await LoadSettings(); + } + + private async Task LoadSettings() + { + try + { + isLoading = true; + var response = await Http.GetAsync("/api/AiIntegration/settings"); + + if (response.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + forbidden = true; + return; + } + + var settings = await response.Content.ReadFromJsonAsync(); + if (settings != null) + { + provider = settings.Provider; + baseUrl = settings.BaseUrl ?? string.Empty; + model = settings.Model ?? string.Empty; + isEnabled = settings.IsEnabled; + apiKeyConfigured = settings.ApiKeyConfigured; + } + } + catch (Exception ex) + { + errorMessage = $"Error loading settings: {ex.Message}"; + } + finally + { + isLoading = false; + } + } + + private void OnProviderChanged() + { + // A base URL typed for one provider almost never makes sense for another - clear it + // so switching providers doesn't accidentally save a stale/wrong endpoint. + baseUrl = string.Empty; + } + + private async Task SaveSettings() + { + if (string.IsNullOrWhiteSpace(model)) + { + errorMessage = "Please enter a model name."; + return; + } + + if (RequiresBaseUrl && string.IsNullOrWhiteSpace(baseUrl)) + { + errorMessage = "Base URL is required for this provider."; + return; + } + + try + { + isSaving = true; + errorMessage = null; + successMessage = null; + testResultMessage = null; + + var dto = new SetAiIntegrationSettingsDto + { + Provider = provider, + BaseUrl = string.IsNullOrWhiteSpace(baseUrl) ? null : baseUrl.Trim(), + Model = model.Trim(), + IsEnabled = isEnabled, + ApiKey = string.IsNullOrWhiteSpace(apiKeyInput) ? null : apiKeyInput.Trim() + }; + + var response = await Http.PutAsJsonAsync("/api/AiIntegration/settings", dto); + + if (response.IsSuccessStatusCode) + { + var settings = await response.Content.ReadFromJsonAsync(); + apiKeyConfigured = settings?.ApiKeyConfigured ?? apiKeyConfigured; + apiKeyInput = string.Empty; + successMessage = "Settings saved successfully!"; + } + else + { + errorMessage = await ReadErrorMessage(response); + } + } + catch (Exception ex) + { + errorMessage = $"Error: {ex.Message}"; + } + finally + { + isSaving = false; + } + } + + private async Task RemoveApiKey() + { + try + { + isSaving = true; + errorMessage = null; + successMessage = null; + + var dto = new SetAiIntegrationSettingsDto + { + Provider = provider, + BaseUrl = string.IsNullOrWhiteSpace(baseUrl) ? null : baseUrl.Trim(), + Model = string.IsNullOrWhiteSpace(model) ? "unset" : model.Trim(), + IsEnabled = isEnabled, + RemoveApiKey = true + }; + + var response = await Http.PutAsJsonAsync("/api/AiIntegration/settings", dto); + + if (response.IsSuccessStatusCode) + { + apiKeyConfigured = false; + apiKeyInput = string.Empty; + successMessage = "API key removed."; + } + else + { + errorMessage = await ReadErrorMessage(response); + } + } + catch (Exception ex) + { + errorMessage = $"Error: {ex.Message}"; + } + finally + { + isSaving = false; + } + } + + private async Task TestConnection() + { + try + { + isTesting = true; + testResultMessage = null; + + var response = await Http.PostAsync("/api/AiIntegration/test", null); + var result = await response.Content.ReadFromJsonAsync(); + + testResultSuccess = result?.Success ?? false; + testResultMessage = result?.Message ?? "No response from server."; + } + catch (Exception ex) + { + testResultSuccess = false; + testResultMessage = $"Error: {ex.Message}"; + } + finally + { + isTesting = false; + } + } + + private static async Task ReadErrorMessage(HttpResponseMessage response) + { + try + { + var doc = await response.Content.ReadFromJsonAsync(); + if (doc.TryGetProperty("error", out var err)) + { + return err.GetString() ?? "An error occurred."; + } + } + catch + { + // Fall through + } + + return "An error occurred."; + } +} diff --git a/RustRconServerManager.Frontend/Pages/PanelSettings.razor b/RustRconServerManager.Frontend/Pages/PanelSettings.razor index be62592..4d05e42 100644 --- a/RustRconServerManager.Frontend/Pages/PanelSettings.razor +++ b/RustRconServerManager.Frontend/Pages/PanelSettings.razor @@ -7,9 +7,11 @@ @using System.Net.Http.Json @using System.Text.Json @using RustRconServerManager.Shared.Scheduler +@using RustRconServerManager.Frontend.Services @inject ILocalStorageService LocalStorage @inject HttpClient Http @inject NavigationManager Nav +@inject NavigationHelper NavHelper @inject IJSRuntime JSRuntime @implements IAsyncDisposable @@ -304,6 +306,20 @@ } + + +
+
+ +

Connect an AI provider (Ollama, LM Studio, OpenAI, Anthropic, or a custom OpenAI-compatible endpoint) for server monitoring, script/mod troubleshooting, rule-breaker detection, and admin notifications.

+
+ + + Open AI Integration + +
+
+
diff --git a/RustRconServerManager.Shared/AiIntegration/AiIntegration_DTOs.cs b/RustRconServerManager.Shared/AiIntegration/AiIntegration_DTOs.cs new file mode 100644 index 0000000..59fdae2 --- /dev/null +++ b/RustRconServerManager.Shared/AiIntegration/AiIntegration_DTOs.cs @@ -0,0 +1,51 @@ +namespace RustRconServerManager.Shared.AiIntegration; + +/// +/// The set of AI providers the panel knows how to talk to. Kept as plain strings (not a +/// C# enum) so they round-trip through the DB/DTOs the same way other provider-style +/// fields do elsewhere in this codebase (e.g. RconServer.ModFramework). +/// +public static class AiProviders +{ + public const string Ollama = "Ollama"; + public const string LmStudio = "LmStudio"; + public const string OpenAI = "OpenAI"; + public const string Anthropic = "Anthropic"; + public const string Universal = "Universal"; + + public static readonly string[] All = { Ollama, LmStudio, OpenAI, Anthropic, Universal }; +} + +public class AiIntegrationSettingsDto +{ + public string Provider { get; set; } = AiProviders.OpenAI; + public string? BaseUrl { get; set; } + public string? Model { get; set; } + public bool IsEnabled { get; set; } + public bool ApiKeyConfigured { get; set; } +} + +public class SetAiIntegrationSettingsDto +{ + public string Provider { get; set; } = AiProviders.OpenAI; + public string? BaseUrl { get; set; } + + /// + /// New API key to store. Empty/omitted leaves the current key unchanged unless RemoveApiKey is set. + /// + public string? ApiKey { get; set; } + + /// + /// When true, clears the stored key regardless of ApiKey. + /// + public bool RemoveApiKey { get; set; } = false; + + public string? Model { get; set; } + public bool IsEnabled { get; set; } +} + +public class TestAiConnectionResultDto +{ + public bool Success { get; set; } + public string Message { get; set; } = string.Empty; +}