using System.Reflection; using System.Runtime.InteropServices; using JobTrackerApi.Data; using JobTrackerApi.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace JobTrackerApi.Controllers; [ApiController] [Route("api/admin/system")] [Authorize(Roles = "Admin")] public sealed class AdminSystemController : ControllerBase { private readonly IConfiguration _cfg; private readonly AppPaths _paths; private readonly JobTrackerContext _db; private readonly ISummarizerService _summarizer; private readonly IWebHostEnvironment _env; private readonly IEmailSettingsResolver _emailSettings; public AdminSystemController(IConfiguration cfg, AppPaths paths, JobTrackerContext db, ISummarizerService summarizer, IWebHostEnvironment env, IEmailSettingsResolver emailSettings) { _cfg = cfg; _paths = paths; _db = db; _summarizer = summarizer; _env = env; _emailSettings = emailSettings; } public sealed record StorageStatusDto(string DataRoot, string DbPath, bool DbExists, long? DbSizeBytes, int CompanyCount, int JobCount, int DeletedCount); public sealed record EmailStatusDto(bool Enabled, string? Host, int Port, bool EnableSsl, string? From, string? FromName); public sealed record DatabaseStatusDto(string Provider, bool LooksConfigured, bool CanConnect, string? Target, bool UsesFileStorage, string? Warning); public sealed record RuntimeStatusDto(string Framework, string OSDescription, string ProcessArchitecture, string? MachineName); public sealed record AuthStatusDto(bool Required, bool HasJwtKey, bool GoogleConfigured, bool GmailConfigured); public sealed record CvBenchmarkStatusDto(string? IndexJson, string? ReportMarkdown, string RootPath, DateTimeOffset? LastUpdatedAtUtc); public sealed record SystemStatusDto( string Environment, string ContentRoot, string Version, string? CommitSha, string? BuildStamp, StorageStatusDto Storage, EmailStatusDto Email, DatabaseStatusDto Database, RuntimeStatusDto Runtime, AuthStatusDto Auth, AiServiceMetrics Ai ); private static string? NormalizeBuildMetadata(string? value) { var trimmed = (value ?? string.Empty).Trim(); if (string.IsNullOrWhiteSpace(trimmed)) return null; // Ignore unresolved shell/compose placeholders that would otherwise leak // directly into the admin UI, e.g. $(git rev-parse --short HEAD) or ${APP_COMMIT_SHA}. if ((trimmed.StartsWith("$(") && trimmed.EndsWith(")")) || (trimmed.StartsWith("${") && trimmed.EndsWith("}"))) { return null; } return trimmed; } private EmailSettingsSnapshot BuildFallbackEmailSettingsSnapshot() { var host = (_cfg["Email:SmtpHost"] ?? string.Empty).Trim(); var user = (_cfg["Email:SmtpUser"] ?? string.Empty).Trim(); var password = (_cfg["Email:SmtpPassword"] ?? string.Empty).Trim(); var from = (_cfg["Email:From"] ?? user).Trim(); var fromName = (_cfg["Email:FromName"] ?? "Jobbjakt").Trim(); var port = _cfg.GetValue("Email:SmtpPort", 587); if (port <= 0) port = 587; var enableSsl = _cfg.GetValue("Email:SmtpEnableSsl", true); var timeoutMs = _cfg.GetValue("Email:SmtpTimeoutMs", 15000); if (timeoutMs <= 0) timeoutMs = 15000; var enabled = _cfg.GetValue("Email:Enabled", false); return new EmailSettingsSnapshot( Enabled: enabled, Host: host, Port: port, User: user, Password: password, From: from, FromName: fromName, EnableSsl: enableSsl, TimeoutMs: timeoutMs, UsesOverrides: false, HasPassword: !string.IsNullOrWhiteSpace(password)); } private EmailSettingsAdminDto BuildFallbackEmailSettings(string? reason = null) { var snapshot = BuildFallbackEmailSettingsSnapshot(); return new EmailSettingsAdminDto( Enabled: snapshot.Enabled, Host: snapshot.Host, Port: snapshot.Port, User: snapshot.User, From: snapshot.From, FromName: string.IsNullOrWhiteSpace(reason) ? snapshot.FromName : $"{snapshot.FromName} (fallback)", EnableSsl: snapshot.EnableSsl, TimeoutMs: snapshot.TimeoutMs, UsesOverrides: snapshot.UsesOverrides, HasPassword: snapshot.HasPassword); } [HttpPost("ai/probe")] [HttpPost("summarizer/probe")] public async Task RunSummarizerProbe(CancellationToken cancellationToken) { await _summarizer.RunProbeAsync(cancellationToken); return NoContent(); } [HttpGet("email-settings")] public async Task> GetEmailSettings(CancellationToken cancellationToken) { try { return Ok(await _emailSettings.GetAdminDtoAsync(cancellationToken)); } catch (Exception ex) { return Ok(BuildFallbackEmailSettings(ex.Message)); } } [HttpPut("email-settings")] public async Task> UpdateEmailSettings([FromBody] UpdateEmailSettingsRequest request, CancellationToken cancellationToken) { if (request.Port <= 0) return BadRequest("SMTP port must be greater than 0."); if (request.TimeoutMs <= 0) return BadRequest("SMTP timeout must be greater than 0."); return Ok(await _emailSettings.UpdateAsync(request, cancellationToken)); } [HttpGet("cv-benchmark")] public async Task> GetCvBenchmarkStatus(CancellationToken cancellationToken) { var indexPath = Path.Combine(_paths.CvBenchmarksRoot, "index.json"); var reportPath = Path.Combine(_paths.CvBenchmarksRoot, "report.md"); var indexJson = System.IO.File.Exists(indexPath) ? await System.IO.File.ReadAllTextAsync(indexPath, cancellationToken) : null; var reportMarkdown = System.IO.File.Exists(reportPath) ? await System.IO.File.ReadAllTextAsync(reportPath, cancellationToken) : null; var lastUpdated = new[] { System.IO.File.Exists(indexPath) ? System.IO.File.GetLastWriteTimeUtc(indexPath) : (DateTime?)null, System.IO.File.Exists(reportPath) ? System.IO.File.GetLastWriteTimeUtc(reportPath) : (DateTime?)null, }.Where(value => value.HasValue).Select(value => value!.Value).DefaultIfEmpty().Max(); return Ok(new CvBenchmarkStatusDto(indexJson, reportMarkdown, _paths.CvBenchmarksRoot, lastUpdated == default ? null : new DateTimeOffset(DateTime.SpecifyKind(lastUpdated, DateTimeKind.Utc)))); } [HttpGet] public async Task> Get(CancellationToken cancellationToken) { var provider = (_cfg["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant(); var usesFileStorage = provider is not "mysql" and not "mariadb"; var dbPath = _paths.GetDbPath(); var dbFile = new FileInfo(dbPath); var jobCount = 0; var deletedCount = 0; var companies = 0; string? statusWarning = null; try { jobCount = await _db.JobApplications.AsNoTracking().CountAsync(cancellationToken); deletedCount = await _db.JobApplications.AsNoTracking().CountAsync(x => x.IsDeleted, cancellationToken); companies = await _db.Companies.AsNoTracking().CountAsync(cancellationToken); } catch (Exception ex) { statusWarning = $"Data query failed: {ex.GetType().Name}"; } AiServiceMetrics ai; try { ai = await _summarizer.GetMetricsAsync(cancellationToken); } catch (Exception ex) { ai = new AiServiceMetrics( Healthy: false, Model: null, Device: null, GpuAvailable: false, GpuName: null, OcrAvailable: false, OcrLanguages: null, OllamaConfigured: null, OllamaReachable: null, OllamaModel: null, OllamaModelAvailable: null, OllamaVersion: null, OllamaInstalledModels: Array.Empty(), OllamaLoadedModels: Array.Empty(), OllamaLoadedCount: 0, HealthLatencyMs: null, ProbeLatencyMs: null, LastProbeAt: null, LastProbeSuccessAt: null, LastProbeFailureAt: null, ProbeFailures: 0, Requests: 0, CacheHits: 0, CacheMisses: 0, Failures: 0, AverageLatencyMs: null, OcrRequests: 0, OcrFailures: 0, AverageOcrLatencyMs: null, LastOcrSuccessAt: null, LastOcrFailureAt: null, LastSuccessAt: null, LastFailureAt: null, LastError: ex.Message); } var version = NormalizeBuildMetadata(_cfg["App:Version"]); if (string.IsNullOrWhiteSpace(version)) { version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown"; } var commitSha = NormalizeBuildMetadata(_cfg["App:CommitSha"]); var buildStamp = NormalizeBuildMetadata(_cfg["App:BuildStamp"]); var connectionString = _cfg.GetConnectionString("JobTracker") ?? string.Empty; var looksConfigured = !string.IsNullOrWhiteSpace(connectionString) || usesFileStorage; string? dbTarget; if (usesFileStorage) { dbTarget = dbPath; } else if (string.IsNullOrWhiteSpace(connectionString)) { dbTarget = null; } else { dbTarget = connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries) .Select(part => part.Trim()) .FirstOrDefault(part => part.StartsWith("server=", StringComparison.OrdinalIgnoreCase) || part.StartsWith("host=", StringComparison.OrdinalIgnoreCase) || part.StartsWith("database=", StringComparison.OrdinalIgnoreCase)); } bool canConnect; try { canConnect = await _db.Database.CanConnectAsync(cancellationToken); } catch { canConnect = false; } string? dbWarning = null; if (!looksConfigured) { dbWarning = "Connection string is missing."; } else if (!canConnect) { dbWarning = "Database connection failed."; } else if (!usesFileStorage && jobCount == 0 && companies == 0) { dbWarning = "Connected, but no data is present yet. Check whether this is the intended production database."; } if (!string.IsNullOrWhiteSpace(statusWarning)) { dbWarning = string.IsNullOrWhiteSpace(dbWarning) ? statusWarning : $"{dbWarning} {statusWarning}"; } var gmailConfigured = !string.IsNullOrWhiteSpace((_cfg["Google:GmailClientSecret"] ?? string.Empty).Trim()) && !string.IsNullOrWhiteSpace((_cfg["Google:GmailRedirectUri"] ?? string.Empty).Trim()); EmailSettingsSnapshot emailSettings; try { emailSettings = await _emailSettings.GetSnapshotAsync(cancellationToken); } catch (Exception) { emailSettings = BuildFallbackEmailSettingsSnapshot(); } return Ok(new SystemStatusDto( Environment: _env.EnvironmentName, ContentRoot: _env.ContentRootPath, Version: version, CommitSha: string.IsNullOrWhiteSpace(commitSha) ? null : commitSha, BuildStamp: string.IsNullOrWhiteSpace(buildStamp) ? null : buildStamp, Storage: new StorageStatusDto( DataRoot: _paths.DataRoot, DbPath: dbPath, DbExists: dbFile.Exists, DbSizeBytes: dbFile.Exists ? dbFile.Length : null, CompanyCount: companies, JobCount: jobCount, DeletedCount: deletedCount ), Email: new EmailStatusDto( Enabled: emailSettings.Enabled, Host: emailSettings.Host, Port: emailSettings.Port, EnableSsl: emailSettings.EnableSsl, From: emailSettings.From, FromName: emailSettings.FromName ), Database: new DatabaseStatusDto( Provider: provider, LooksConfigured: looksConfigured, CanConnect: canConnect, Target: dbTarget, UsesFileStorage: usesFileStorage, Warning: dbWarning ), Runtime: new RuntimeStatusDto( Framework: RuntimeInformation.FrameworkDescription, OSDescription: RuntimeInformation.OSDescription, ProcessArchitecture: RuntimeInformation.ProcessArchitecture.ToString(), MachineName: Environment.MachineName ), Auth: new AuthStatusDto( Required: _cfg.GetValue("Auth:Require", false), HasJwtKey: !string.IsNullOrWhiteSpace((_cfg["Auth:JwtKey"] ?? string.Empty).Trim()), GoogleConfigured: !string.IsNullOrWhiteSpace((_cfg["Auth:GoogleClientId"] ?? string.Empty).Trim()), GmailConfigured: gmailConfigured ), Ai: ai )); } }