From cf99fd02fa0c7ac433ba8fdf12955f6d3cb9e662 Mon Sep 17 00:00:00 2001 From: Xenne <144433308+Xenne93@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:22:35 +0200 Subject: [PATCH] Add AI integration framework (Ollama, LM Studio, OpenAI, Anthropic, Universal) Lays the groundwork for AI-powered features (server monitoring, script/mod troubleshooting, rule-breaker detection, admin notifications) without building any of those features yet - this is just the plumbing: - New AiIntegrationSettings table (one row per SystemProfile): provider, base URL, encrypted API key, model name, enabled flag. API key is encrypted at rest via the existing IRconPasswordsCryptoService, same scheme as PanelSettings' Steam API key - New IAiService/AiService: sends chat-style requests to whichever provider is configured. OpenAI, LM Studio, and a generic "Universal" OpenAI-compatible endpoint all share one implementation (same request/response shape); Anthropic and Ollama each get their own, since their APIs differ (Anthropic separates the system prompt out; Ollama uses its own native /api/chat format instead of OpenAI's) - New "AI Integration" page, reachable via a link from Panel Settings: provider dropdown, conditional base URL (required for Ollama/LM Studio/Universal, optional override for OpenAI/Anthropic), masked API key with the same configured/remove pattern as the Steam key, model name, enable toggle, and a Test Connection button - Test Connection intentionally works regardless of the enable toggle - it's an explicit, admin-initiated diagnostic action, not an automated AI feature firing on its own, so requiring the feature to be "on" just to verify credentials would be backwards Tested end-to-end against the real OpenAI API with a deliberately invalid key: confirmed the request reaches api.openai.com, the Bearer auth header and JSON body are built correctly, and OpenAI's actual error response ("Incorrect API key provided...") surfaces back to the admin. Also verified settings persist across a reload and the base URL/API key guidance updates correctly per provider. --- .../Controllers/AiIntegrationController.cs | 162 ++ .../Database/AppDbContext.cs | 3 + .../Interfaces/IAiService.cs | 51 + ...11103_AddAiIntegrationSettings.Designer.cs | 2338 +++++++++++++++++ ...20260821211103_AddAiIntegrationSettings.cs | 59 + .../Migrations/AppDbContextModelSnapshot.cs | 51 + .../Models/AiIntegrationSettings.cs | 62 + RustRconServerManager.Backend/Program.cs | 2 + .../Services/AiService.cs | 335 +++ .../Pages/AiIntegration.razor | 378 +++ .../Pages/PanelSettings.razor | 16 + .../AiIntegration/AiIntegration_DTOs.cs | 51 + 12 files changed, 3508 insertions(+) create mode 100644 RustRconServerManager.Backend/Controllers/AiIntegrationController.cs create mode 100644 RustRconServerManager.Backend/Interfaces/IAiService.cs create mode 100644 RustRconServerManager.Backend/Migrations/20260821211103_AddAiIntegrationSettings.Designer.cs create mode 100644 RustRconServerManager.Backend/Migrations/20260821211103_AddAiIntegrationSettings.cs create mode 100644 RustRconServerManager.Backend/Models/AiIntegrationSettings.cs create mode 100644 RustRconServerManager.Backend/Services/AiService.cs create mode 100644 RustRconServerManager.Frontend/Pages/AiIntegration.razor create mode 100644 RustRconServerManager.Shared/AiIntegration/AiIntegration_DTOs.cs 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; +}