343 lines
14 KiB
C#
343 lines
14 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 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<IActionResult> RunSummarizerProbe(CancellationToken cancellationToken)
|
|
{
|
|
await _summarizer.RunProbeAsync(cancellationToken);
|
|
return NoContent();
|
|
}
|
|
|
|
[HttpGet("email-settings")]
|
|
public async Task<ActionResult<EmailSettingsAdminDto>> GetEmailSettings(CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
return Ok(await _emailSettings.GetAdminDtoAsync(cancellationToken));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return Ok(BuildFallbackEmailSettings(ex.Message));
|
|
}
|
|
}
|
|
|
|
[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("cv-benchmark")]
|
|
public async Task<ActionResult<CvBenchmarkStatusDto>> 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<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,
|
|
OllamaVersion: null,
|
|
OllamaInstalledModels: Array.Empty<string>(),
|
|
OllamaLoadedModels: Array.Empty<string>(),
|
|
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
|
|
));
|
|
}
|
|
}
|