Files
jobtrackingapp/JobTrackerApi/Controllers/AdminSystemController.cs
T

263 lines
10 KiB
C#

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<IActionResult> RunSummarizerProbe(CancellationToken cancellationToken)
{
await _summarizer.RunProbeAsync(cancellationToken);
return NoContent();
}
[HttpGet("email-settings")]
public async Task<ActionResult<EmailSettingsAdminDto>> GetEmailSettings(CancellationToken cancellationToken)
{
return Ok(await _emailSettings.GetAdminDtoAsync(cancellationToken));
}
[HttpPut("email-settings")]
public async Task<ActionResult<EmailSettingsAdminDto>> 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<ActionResult<SystemStatusDto>> 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
));
}
}