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 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; } [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) { return Ok(await _emailSettings.GetAdminDtoAsync(cancellationToken)); } [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] 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, 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()); var emailSettings = await _emailSettings.GetSnapshotAsync(cancellationToken); 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 )); } }