From 4c49ffb0d615fb17e0faf13dfc48e0ab18e254e5 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 22 Mar 2026 21:37:30 +0100 Subject: [PATCH] feat: improve admin observability and translation-first summaries --- JobTrackerApi.Tests/ProductionConfigTests.cs | 11 + .../Controllers/AdminSystemController.cs | 76 +++++ .../Controllers/JobApplicationsController.cs | 28 +- JobTrackerApi/Program.cs | 303 +++++++++--------- JobTrackerApi/Services/SummarizerService.cs | 24 ++ .../src/components/JobDetailsDialog.tsx | 18 +- job-tracker-ui/src/pages/AdminSystemPage.tsx | 238 ++++++++++---- tools/summarizer/app.py | 148 ++++++--- 8 files changed, 585 insertions(+), 261 deletions(-) diff --git a/JobTrackerApi.Tests/ProductionConfigTests.cs b/JobTrackerApi.Tests/ProductionConfigTests.cs index 5390847..2a52302 100644 --- a/JobTrackerApi.Tests/ProductionConfigTests.cs +++ b/JobTrackerApi.Tests/ProductionConfigTests.cs @@ -15,6 +15,17 @@ public sealed class ProductionConfigTests Assert.NotNull(method); } + [Fact] + public void Summarizer_metrics_include_runtime_device_details() + { + var ctor = typeof(SummarizerMetrics).GetConstructors().Single(); + var parameterNames = ctor.GetParameters().Select(x => x.Name).ToHashSet(StringComparer.OrdinalIgnoreCase); + + Assert.Contains("device", parameterNames); + Assert.Contains("gpuAvailable", parameterNames); + Assert.Contains("gpuName", parameterNames); + } + [Fact] public void Profile_cv_controller_supports_pdf_and_docx_extensions() { diff --git a/JobTrackerApi/Controllers/AdminSystemController.cs b/JobTrackerApi/Controllers/AdminSystemController.cs index c76ced9..82c3279 100644 --- a/JobTrackerApi/Controllers/AdminSystemController.cs +++ b/JobTrackerApi/Controllers/AdminSystemController.cs @@ -1,4 +1,5 @@ using System.Reflection; +using System.Runtime.InteropServices; using JobTrackerApi.Data; using JobTrackerApi.Services; using Microsoft.AspNetCore.Authorization; @@ -29,6 +30,9 @@ public sealed class AdminSystemController : ControllerBase 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, @@ -37,6 +41,9 @@ public sealed class AdminSystemController : ControllerBase string? BuildStamp, StorageStatusDto Storage, EmailStatusDto Email, + DatabaseStatusDto Database, + RuntimeStatusDto Runtime, + AuthStatusDto Auth, SummarizerMetrics Summarizer ); @@ -65,6 +72,8 @@ public sealed class AdminSystemController : ControllerBase [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); @@ -80,6 +89,53 @@ public sealed class AdminSystemController : ControllerBase 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 && jobs.Count == 0 && companies == 0) + { + dbWarning = "Connected, but no data is present yet. Check whether this is the intended production database."; + } + + var gmailConfigured = !string.IsNullOrWhiteSpace((_cfg["Google:GmailClientSecret"] ?? string.Empty).Trim()) + && !string.IsNullOrWhiteSpace((_cfg["Google:GmailRedirectUri"] ?? string.Empty).Trim()); return Ok(new SystemStatusDto( Environment: _env.EnvironmentName, @@ -104,6 +160,26 @@ public sealed class AdminSystemController : ControllerBase From: (_cfg["Email:From"] ?? string.Empty).Trim(), FromName: (_cfg["Email:FromName"] ?? string.Empty).Trim() ), + 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 + ), Summarizer: summarizer )); } diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index adfc4eb..540cf1d 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -71,6 +71,20 @@ namespace JobTrackerApi.Controllers return new string(value.Trim().ToLowerInvariant().Where(char.IsLetterOrDigit).ToArray()); } + private static string BuildSummarySource(JobApplication job) + { + // Prefer translated text for summaries and skill extraction so non-English + // postings become easier to understand while keeping the original text intact. + var parts = new[] + { + job.TranslatedDescription, + job.Description, + job.Notes + }; + + return string.Join("\n\n", parts.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x!.Trim())); + } + private static string? NormalizeTags(string? raw) { var normalized = SplitTags(raw) @@ -363,8 +377,9 @@ namespace JobTrackerApi.Controllers .MaxAsync(c => (DateTime?)c.Date, cancellationToken); var d = RulesEngine.Evaluate(settings, job, now, lm); - // Return persisted short summary and compute a fuller summary on demand for the details view. - var full = await _summarizer.SummarizeAsync(job.Description ?? job.Notes ?? "", 250, 40); + // Prefer translated content for the detailed summary so Norwegian postings + // surface readable English analysis while the original text remains available. + var full = await _summarizer.SummarizeAsync(BuildSummarySource(job), 250, 40); return Ok(new JobApplicationDto( Id: job.Id, @@ -596,7 +611,7 @@ namespace JobTrackerApi.Controllers // Generate and persist a short summary at creation time to avoid repeated model calls. try { - var shortSum = await _summarizer.SummarizeAsync(job.Description ?? job.Notes ?? "", 160, 60); + var shortSum = await _summarizer.SummarizeAsync(BuildSummarySource(job), 160, 60); job.ShortSummary = shortSum; } catch @@ -752,11 +767,10 @@ namespace JobTrackerApi.Controllers if (job is null) return NotFound(); - var sourceText = string.Join("\n\n", new[] { job.Description, job.TranslatedDescription, job.Notes } - .Where(x => !string.IsNullOrWhiteSpace(x))); + var sourceText = BuildSummarySource(job); if (string.IsNullOrWhiteSpace(sourceText)) { - return BadRequest("This job does not have enough description or notes to generate a summary and skills."); + return BadRequest("This job does not have enough translated text, description, or notes to generate a summary and skills."); } var tags = SkillTagger.Detect(sourceText) @@ -1710,7 +1724,7 @@ Candidate master CV: )) .ToList(); - return Ok(new DuplicateCheckResult(matches.Count > 0, matches)); + return Ok(new DuplicateCheckResult(matches.Any(), matches)); } [HttpGet("{id:int}/followup-draft")] diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index fa4fb9b..cf0dddb 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -37,13 +37,22 @@ builder.Services.AddDbContext((sp, options) => var cfg = sp.GetRequiredService(); var paths = sp.GetRequiredService(); + var provider = (cfg["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant(); var cs = cfg.GetConnectionString("JobTracker"); if (string.IsNullOrWhiteSpace(cs)) { cs = $"Data Source={paths.GetDbPath()}"; + provider = "sqlite"; } - options.UseSqlite(cs); + if (provider is "mysql" or "mariadb") + { + options.UseMySql(cs, ServerVersion.AutoDetect(cs)); + } + else + { + options.UseSqlite(cs); + } // We create Identity tables on startup in environments where `dotnet ef` isn't available. // That can cause EF to detect "pending model changes" and throw on Migrate(). Ignore it. @@ -299,67 +308,71 @@ using (var scope = app.Services.CreateScope()) var paths = scope.ServiceProvider.GetRequiredService(); var users = scope.ServiceProvider.GetRequiredService>(); var roles = scope.ServiceProvider.GetRequiredService>(); + var provider = (app.Configuration["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant(); + var useSqliteBootstrap = provider is not "mysql" and not "mariadb"; - // Bridge older dev DBs that were modified via ad-hoc ALTER TABLE (before migrations were applied). - // If the schema already contains the columns added by migration 20260310195000, record that migration - // so EF doesn't try to apply it again and fail on duplicate columns. - const string legacyMigrationId = "20260310195000_AddJobFieldsAndSoftDelete"; - const string legacyProductVersion = "7.0.17"; - - using DbConnection conn = db.Database.GetDbConnection(); - conn.Open(); - - static bool HasTable(DbConnection c, string table) + if (useSqliteBootstrap) { - using var cmd = c.CreateCommand(); - cmd.CommandText = "SELECT 1 FROM sqlite_master WHERE type='table' AND name=$name LIMIT 1;"; - var p = cmd.CreateParameter(); - p.ParameterName = "$name"; - p.Value = table; - cmd.Parameters.Add(p); - return cmd.ExecuteScalar() is not null; - } + // Bridge older dev DBs that were modified via ad-hoc ALTER TABLE (before migrations were applied). + // If the schema already contains the columns added by migration 20260310195000, record that migration + // so EF doesn't try to apply it again and fail on duplicate columns. + const string legacyMigrationId = "20260310195000_AddJobFieldsAndSoftDelete"; + const string legacyProductVersion = "7.0.17"; - static bool HasColumn(DbConnection c, string table, string column) - { - using var cmd = c.CreateCommand(); - cmd.CommandText = $"SELECT 1 FROM pragma_table_info('{table}') WHERE name = '{column}' LIMIT 1;"; - return cmd.ExecuteScalar() is not null; - } + using DbConnection conn = db.Database.GetDbConnection(); + conn.Open(); - static bool HasMigration(DbConnection c, string migrationId) - { - if (!HasTable(c, "__EFMigrationsHistory")) return false; - using var cmd = c.CreateCommand(); - cmd.CommandText = "SELECT 1 FROM __EFMigrationsHistory WHERE MigrationId=$id LIMIT 1;"; - var p = cmd.CreateParameter(); - p.ParameterName = "$id"; - p.Value = migrationId; - cmd.Parameters.Add(p); - return cmd.ExecuteScalar() is not null; - } + static bool HasTable(DbConnection c, string table) + { + using var cmd = c.CreateCommand(); + cmd.CommandText = "SELECT 1 FROM sqlite_master WHERE type='table' AND name=$name LIMIT 1;"; + var p = cmd.CreateParameter(); + p.ParameterName = "$name"; + p.Value = table; + cmd.Parameters.Add(p); + return cmd.ExecuteScalar() is not null; + } - static void Exec(DbConnection c, string sql) - { - using var cmd = c.CreateCommand(); - cmd.CommandText = sql; - cmd.ExecuteNonQuery(); - } + static bool HasColumn(DbConnection c, string table, string column) + { + using var cmd = c.CreateCommand(); + cmd.CommandText = $"SELECT 1 FROM pragma_table_info('{table}') WHERE name = '{column}' LIMIT 1;"; + return cmd.ExecuteScalar() is not null; + } - static void EnsureColumn(DbConnection c, string table, string column, string ddl) - { - // Fresh databases won't have the table until EF migrations run. - if (!HasTable(c, table)) return; - if (!HasColumn(c, table, column)) Exec(c, ddl); - } + static bool HasMigration(DbConnection c, string migrationId) + { + if (!HasTable(c, "__EFMigrationsHistory")) return false; + using var cmd = c.CreateCommand(); + cmd.CommandText = "SELECT 1 FROM __EFMigrationsHistory WHERE MigrationId=$id LIMIT 1;"; + var p = cmd.CreateParameter(); + p.ParameterName = "$id"; + p.Value = migrationId; + cmd.Parameters.Add(p); + return cmd.ExecuteScalar() is not null; + } - static void EnsureIdentityTables(DbConnection c) - { - // EF migrations are used for the app schema. In some environments `dotnet ef` isn’t available, - // so create the ASP.NET Core Identity tables directly if they don’t exist yet. - if (HasTable(c, "AspNetUsers")) return; + static void Exec(DbConnection c, string sql) + { + using var cmd = c.CreateCommand(); + cmd.CommandText = sql; + cmd.ExecuteNonQuery(); + } - Exec(c, """ + static void EnsureColumn(DbConnection c, string table, string column, string ddl) + { + // Fresh databases won't have the table until EF migrations run. + if (!HasTable(c, table)) return; + if (!HasColumn(c, table, column)) Exec(c, ddl); + } + + static void EnsureIdentityTables(DbConnection c) + { + // EF migrations are used for the app schema. In some environments `dotnet ef` isn’t available, + // so create the ASP.NET Core Identity tables directly if they don’t exist yet. + if (HasTable(c, "AspNetUsers")) return; + + Exec(c, """ CREATE TABLE IF NOT EXISTS "AspNetRoles" ( "Id" TEXT NOT NULL CONSTRAINT "PK_AspNetRoles" PRIMARY KEY, "Name" TEXT NULL, @@ -368,7 +381,7 @@ CREATE TABLE IF NOT EXISTS "AspNetRoles" ( ); """); - Exec(c, """ + Exec(c, """ CREATE TABLE IF NOT EXISTS "AspNetUsers" ( "Id" TEXT NOT NULL CONSTRAINT "PK_AspNetUsers" PRIMARY KEY, "UserName" TEXT NULL, @@ -394,7 +407,7 @@ CREATE TABLE IF NOT EXISTS "AspNetUsers" ( ); """); - Exec(c, """ + Exec(c, """ CREATE TABLE IF NOT EXISTS "AspNetRoleClaims" ( "Id" INTEGER NOT NULL CONSTRAINT "PK_AspNetRoleClaims" PRIMARY KEY AUTOINCREMENT, "RoleId" TEXT NOT NULL, @@ -404,7 +417,7 @@ CREATE TABLE IF NOT EXISTS "AspNetRoleClaims" ( ); """); - Exec(c, """ + Exec(c, """ CREATE TABLE IF NOT EXISTS "AspNetUserClaims" ( "Id" INTEGER NOT NULL CONSTRAINT "PK_AspNetUserClaims" PRIMARY KEY AUTOINCREMENT, "UserId" TEXT NOT NULL, @@ -414,7 +427,7 @@ CREATE TABLE IF NOT EXISTS "AspNetUserClaims" ( ); """); - Exec(c, """ + Exec(c, """ CREATE TABLE IF NOT EXISTS "AspNetUserLogins" ( "LoginProvider" TEXT NOT NULL, "ProviderKey" TEXT NOT NULL, @@ -425,7 +438,7 @@ CREATE TABLE IF NOT EXISTS "AspNetUserLogins" ( ); """); - Exec(c, """ + Exec(c, """ CREATE TABLE IF NOT EXISTS "AspNetUserRoles" ( "UserId" TEXT NOT NULL, "RoleId" TEXT NOT NULL, @@ -435,7 +448,7 @@ CREATE TABLE IF NOT EXISTS "AspNetUserRoles" ( ); """); - Exec(c, """ + Exec(c, """ CREATE TABLE IF NOT EXISTS "AspNetUserTokens" ( "UserId" TEXT NOT NULL, "LoginProvider" TEXT NOT NULL, @@ -446,29 +459,29 @@ CREATE TABLE IF NOT EXISTS "AspNetUserTokens" ( ); """); - Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "RoleNameIndex" ON "AspNetRoles" ("NormalizedName");"""); - Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetRoleClaims_RoleId" ON "AspNetRoleClaims" ("RoleId");"""); - Exec(c, """CREATE INDEX IF NOT EXISTS "EmailIndex" ON "AspNetUsers" ("NormalizedEmail");"""); - Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "UserNameIndex" ON "AspNetUsers" ("NormalizedUserName");"""); - Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetUserClaims_UserId" ON "AspNetUserClaims" ("UserId");"""); - Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetUserLogins_UserId" ON "AspNetUserLogins" ("UserId");"""); - Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetUserRoles_RoleId" ON "AspNetUserRoles" ("RoleId");"""); - } + Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "RoleNameIndex" ON "AspNetRoles" ("NormalizedName");"""); + Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetRoleClaims_RoleId" ON "AspNetRoleClaims" ("RoleId");"""); + Exec(c, """CREATE INDEX IF NOT EXISTS "EmailIndex" ON "AspNetUsers" ("NormalizedEmail");"""); + Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "UserNameIndex" ON "AspNetUsers" ("NormalizedUserName");"""); + Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetUserClaims_UserId" ON "AspNetUserClaims" ("UserId");"""); + Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetUserLogins_UserId" ON "AspNetUserLogins" ("UserId");"""); + Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetUserRoles_RoleId" ON "AspNetUserRoles" ("RoleId");"""); + } - EnsureIdentityTables(conn); - EnsureColumn(conn, "AspNetUsers", "FirstName", "ALTER TABLE AspNetUsers ADD COLUMN FirstName TEXT NULL;"); - EnsureColumn(conn, "AspNetUsers", "LastName", "ALTER TABLE AspNetUsers ADD COLUMN LastName TEXT NULL;"); - EnsureColumn(conn, "AspNetUsers", "DisplayName", "ALTER TABLE AspNetUsers ADD COLUMN DisplayName TEXT NULL;"); - EnsureColumn(conn, "AspNetUsers", "ProfileCvText", "ALTER TABLE AspNetUsers ADD COLUMN ProfileCvText TEXT NULL;"); - EnsureColumn(conn, "AspNetUsers", "GoogleSubject", "ALTER TABLE AspNetUsers ADD COLUMN GoogleSubject TEXT NULL;"); - EnsureColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE AspNetUsers ADD COLUMN GoogleEmail TEXT NULL;"); - EnsureColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE AspNetUsers ADD COLUMN GoogleLinkedAt TEXT NULL;"); + EnsureIdentityTables(conn); + EnsureColumn(conn, "AspNetUsers", "FirstName", "ALTER TABLE AspNetUsers ADD COLUMN FirstName TEXT NULL;"); + EnsureColumn(conn, "AspNetUsers", "LastName", "ALTER TABLE AspNetUsers ADD COLUMN LastName TEXT NULL;"); + EnsureColumn(conn, "AspNetUsers", "DisplayName", "ALTER TABLE AspNetUsers ADD COLUMN DisplayName TEXT NULL;"); + EnsureColumn(conn, "AspNetUsers", "ProfileCvText", "ALTER TABLE AspNetUsers ADD COLUMN ProfileCvText TEXT NULL;"); + EnsureColumn(conn, "AspNetUsers", "GoogleSubject", "ALTER TABLE AspNetUsers ADD COLUMN GoogleSubject TEXT NULL;"); + EnsureColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE AspNetUsers ADD COLUMN GoogleEmail TEXT NULL;"); + EnsureColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE AspNetUsers ADD COLUMN GoogleLinkedAt TEXT NULL;"); - static void EnsureUserRuleSettingsTable(DbConnection c) - { - if (HasTable(c, "UserRuleSettings")) return; + static void EnsureUserRuleSettingsTable(DbConnection c) + { + if (HasTable(c, "UserRuleSettings")) return; - Exec(c, """ + Exec(c, """ CREATE TABLE IF NOT EXISTS "UserRuleSettings" ( "OwnerUserId" TEXT NOT NULL CONSTRAINT "PK_UserRuleSettings" PRIMARY KEY, "AppliedFollowUpDays" INTEGER NOT NULL, @@ -479,13 +492,13 @@ CREATE TABLE IF NOT EXISTS "UserRuleSettings" ( "FeedbackGhostDays" INTEGER NOT NULL ); """); - } + } - EnsureUserRuleSettingsTable(conn); + EnsureUserRuleSettingsTable(conn); - static void EnsureGmailConnectionsTable(DbConnection c) - { - Exec(c, """ + static void EnsureGmailConnectionsTable(DbConnection c) + { + Exec(c, """ CREATE TABLE IF NOT EXISTS "GmailConnections" ( "Id" INTEGER NOT NULL CONSTRAINT "PK_GmailConnections" PRIMARY KEY AUTOINCREMENT, "OwnerUserId" TEXT NOT NULL, @@ -499,69 +512,70 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" ( ); """); - Exec(c, """CREATE INDEX IF NOT EXISTS "IX_GmailConnections_OwnerUserId" ON "GmailConnections" ("OwnerUserId");"""); - Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_GmailConnections_OwnerUserId_GmailAddress" ON "GmailConnections" ("OwnerUserId", "GmailAddress");"""); - } + Exec(c, """CREATE INDEX IF NOT EXISTS "IX_GmailConnections_OwnerUserId" ON "GmailConnections" ("OwnerUserId");"""); + Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_GmailConnections_OwnerUserId_GmailAddress" ON "GmailConnections" ("OwnerUserId", "GmailAddress");"""); + } - EnsureGmailConnectionsTable(conn); + EnsureGmailConnectionsTable(conn); - // Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded, - // and at least one of the new columns already exists. - var isLegacy = - HasMigration(conn, "20260310174114_AddCorrespondence") && - !HasMigration(conn, legacyMigrationId) && - (HasColumn(conn, "Companies", "Source") || HasColumn(conn, "JobApplications", "IsDeleted")); + // Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded, + // and at least one of the new columns already exists. + var isLegacy = + HasMigration(conn, "20260310174114_AddCorrespondence") && + !HasMigration(conn, legacyMigrationId) && + (HasColumn(conn, "Companies", "Source") || HasColumn(conn, "JobApplications", "IsDeleted")); - if (isLegacy) - { - EnsureColumn(conn, "Companies", "Source", "ALTER TABLE Companies ADD COLUMN Source TEXT NULL;"); - EnsureColumn(conn, "JobApplications", "IsDeleted", "ALTER TABLE JobApplications ADD COLUMN IsDeleted INTEGER NOT NULL DEFAULT 0;"); - EnsureColumn(conn, "JobApplications", "DeletedAt", "ALTER TABLE JobApplications ADD COLUMN DeletedAt TEXT NULL;"); - EnsureColumn(conn, "JobApplications", "Location", "ALTER TABLE JobApplications ADD COLUMN Location TEXT NULL;"); - EnsureColumn(conn, "JobApplications", "Salary", "ALTER TABLE JobApplications ADD COLUMN Salary TEXT NULL;"); - EnsureColumn(conn, "JobApplications", "NextAction", "ALTER TABLE JobApplications ADD COLUMN NextAction TEXT NULL;"); - EnsureColumn(conn, "JobApplications", "FollowUpAt", "ALTER TABLE JobApplications ADD COLUMN FollowUpAt TEXT NULL;"); + if (isLegacy) + { + EnsureColumn(conn, "Companies", "Source", "ALTER TABLE Companies ADD COLUMN Source TEXT NULL;"); + EnsureColumn(conn, "JobApplications", "IsDeleted", "ALTER TABLE JobApplications ADD COLUMN IsDeleted INTEGER NOT NULL DEFAULT 0;"); + EnsureColumn(conn, "JobApplications", "DeletedAt", "ALTER TABLE JobApplications ADD COLUMN DeletedAt TEXT NULL;"); + EnsureColumn(conn, "JobApplications", "Location", "ALTER TABLE JobApplications ADD COLUMN Location TEXT NULL;"); + EnsureColumn(conn, "JobApplications", "Salary", "ALTER TABLE JobApplications ADD COLUMN Salary TEXT NULL;"); + EnsureColumn(conn, "JobApplications", "NextAction", "ALTER TABLE JobApplications ADD COLUMN NextAction TEXT NULL;"); + EnsureColumn(conn, "JobApplications", "FollowUpAt", "ALTER TABLE JobApplications ADD COLUMN FollowUpAt TEXT NULL;"); - // Ensure the persisted short summary column exists for older dev DBs. - EnsureColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE JobApplications ADD COLUMN ShortSummary TEXT NULL;"); + // Ensure the persisted short summary column exists for older dev DBs. + EnsureColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE JobApplications ADD COLUMN ShortSummary TEXT NULL;"); - // Multi-user support: scope data to the authenticated user. - EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;"); - EnsureColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE JobApplications ADD COLUMN OwnerUserId TEXT NULL;"); + // Multi-user support: scope data to the authenticated user. + EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;"); + EnsureColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE JobApplications ADD COLUMN OwnerUserId TEXT NULL;"); - // Legacy DBs may be missing later correspondence columns (Subject/Channel). - if (HasTable(conn, "Correspondences")) - { + // Legacy DBs may be missing later correspondence columns (Subject/Channel). + if (HasTable(conn, "Correspondences")) + { + EnsureColumn(conn, "Correspondences", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;"); + EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel TEXT NULL;"); + EnsureColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE Correspondences ADD COLUMN ExternalMessageId TEXT NULL;"); + } + + // Record the migration as applied. + Exec( + conn, + "INSERT INTO __EFMigrationsHistory (MigrationId, ProductVersion) " + + $"VALUES ('{legacyMigrationId}', '{legacyProductVersion}');" + ); + } + + // Some dev DBs may not match the "legacy" fingerprint above but still lack + // the ShortSummary column. Ensure it exists unconditionally if missing. + EnsureColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE JobApplications ADD COLUMN ShortSummary TEXT NULL;"); + EnsureColumn(conn, "JobApplications", "TailoredCvText", "ALTER TABLE JobApplications ADD COLUMN TailoredCvText TEXT NULL;"); + EnsureColumn(conn, "JobApplications", "TailoredCvUpdatedAt", "ALTER TABLE JobApplications ADD COLUMN TailoredCvUpdatedAt TEXT NULL;"); + EnsureColumn(conn, "JobApplications", "RecruiterMessageDraft", "ALTER TABLE JobApplications ADD COLUMN RecruiterMessageDraft TEXT NULL;"); + + // Ensure ownership columns exist even on non-legacy DBs. + EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;"); + EnsureColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE JobApplications ADD COLUMN OwnerUserId TEXT NULL;"); EnsureColumn(conn, "Correspondences", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;"); EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel TEXT NULL;"); EnsureColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE Correspondences ADD COLUMN ExternalMessageId TEXT NULL;"); + + // Ensure data folder exists before creating/opening SQLite files. + Directory.CreateDirectory(paths.DataRoot); } - // Record the migration as applied. - Exec( - conn, - "INSERT INTO __EFMigrationsHistory (MigrationId, ProductVersion) " + - $"VALUES ('{legacyMigrationId}', '{legacyProductVersion}');" - ); - } - - // Some dev DBs may not match the "legacy" fingerprint above but still lack - // the ShortSummary column. Ensure it exists unconditionally if missing. - EnsureColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE JobApplications ADD COLUMN ShortSummary TEXT NULL;"); - EnsureColumn(conn, "JobApplications", "TailoredCvText", "ALTER TABLE JobApplications ADD COLUMN TailoredCvText TEXT NULL;"); - EnsureColumn(conn, "JobApplications", "TailoredCvUpdatedAt", "ALTER TABLE JobApplications ADD COLUMN TailoredCvUpdatedAt TEXT NULL;"); - EnsureColumn(conn, "JobApplications", "RecruiterMessageDraft", "ALTER TABLE JobApplications ADD COLUMN RecruiterMessageDraft TEXT NULL;"); - - // Ensure ownership columns exist even on non-legacy DBs. - EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;"); - EnsureColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE JobApplications ADD COLUMN OwnerUserId TEXT NULL;"); - EnsureColumn(conn, "Correspondences", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;"); - EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel TEXT NULL;"); - EnsureColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE Correspondences ADD COLUMN ExternalMessageId TEXT NULL;"); - - // Ensure data folder exists before creating/opening SQLite files. - Directory.CreateDirectory(paths.DataRoot); - db.Database.Migrate(); // Optional: seed an initial admin user for local username/password login. @@ -602,16 +616,11 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" ( var admin = users.FindByEmailAsync(adminEmail).GetAwaiter().GetResult(); if (admin is not null) { - using var cmd = conn.CreateCommand(); - cmd.CommandText = """ -UPDATE Companies SET OwnerUserId=$uid WHERE OwnerUserId IS NULL; -UPDATE JobApplications SET OwnerUserId=$uid WHERE OwnerUserId IS NULL; -"""; - var p = cmd.CreateParameter(); - p.ParameterName = "$uid"; - p.Value = admin.Id; - cmd.Parameters.Add(p); - cmd.ExecuteNonQuery(); + db.Database.ExecuteSqlRaw( + "UPDATE Companies SET OwnerUserId = {0} WHERE OwnerUserId IS NULL;" + + "UPDATE JobApplications SET OwnerUserId = {0} WHERE OwnerUserId IS NULL;", + admin.Id + ); } } } diff --git a/JobTrackerApi/Services/SummarizerService.cs b/JobTrackerApi/Services/SummarizerService.cs index c29c749..c72abe7 100644 --- a/JobTrackerApi/Services/SummarizerService.cs +++ b/JobTrackerApi/Services/SummarizerService.cs @@ -16,6 +16,9 @@ namespace JobTrackerApi.Services public sealed record SummarizerMetrics( bool Healthy, string? Model, + string? Device, + bool? GpuAvailable, + string? GpuName, double? HealthLatencyMs, double? ProbeLatencyMs, DateTimeOffset? LastProbeAt, @@ -216,6 +219,9 @@ namespace JobTrackerApi.Services { var client = _httpFactory.CreateClient("summarizer"); string? model = null; + string? device = null; + bool? gpuAvailable = null; + string? gpuName = null; double? healthLatencyMs = null; var healthy = false; string? healthError = null; @@ -236,6 +242,21 @@ namespace JobTrackerApi.Services { model = modelEl.GetString(); } + + if (doc.RootElement.TryGetProperty("device", out var deviceEl)) + { + device = deviceEl.GetString(); + } + + if (doc.RootElement.TryGetProperty("gpu_available", out var gpuAvailableEl) && gpuAvailableEl.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + gpuAvailable = gpuAvailableEl.GetBoolean(); + } + + if (doc.RootElement.TryGetProperty("gpu_name", out var gpuNameEl)) + { + gpuName = gpuNameEl.GetString(); + } } else { @@ -283,6 +304,9 @@ namespace JobTrackerApi.Services return new SummarizerMetrics( Healthy: healthy, Model: model, + Device: device, + GpuAvailable: gpuAvailable, + GpuName: gpuName, HealthLatencyMs: healthLatencyMs, ProbeLatencyMs: probeLatencyMs, LastProbeAt: lastProbeAt, diff --git a/job-tracker-ui/src/components/JobDetailsDialog.tsx b/job-tracker-ui/src/components/JobDetailsDialog.tsx index 7bf1da7..3b8aa21 100644 --- a/job-tracker-ui/src/components/JobDetailsDialog.tsx +++ b/job-tracker-ui/src/components/JobDetailsDialog.tsx @@ -155,7 +155,10 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) { const title = job ? `${job.company?.name ?? ""} - ${job.jobTitle}` : "Job Application"; const checklist = [job?.hasResume ? "Resume" : null, job?.hasCoverLetter ? "Cover letter" : null, job?.hasPortfolio ? "Portfolio" : null, job?.hasOtherAttachment ? "Other" : null].filter(Boolean).join(", ") || ""; const summaryFirstText = job?.fullSummary ?? job?.shortSummary ?? "No summary yet."; - const rawDescriptionText = job?.translatedDescription || job?.description || ""; + const translatedDescriptionText = job?.translatedDescription?.trim() || ""; + const originalDescriptionText = job?.description?.trim() || ""; + const showTranslatedText = translatedDescriptionText.length > 0; + const showOriginalText = originalDescriptionText.length > 0; const fitLevel = useMemo(() => getFitLevel(candidateFit), [candidateFit]); return ( @@ -219,7 +222,18 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) { {summaryFirstText} - {rawDescriptionText ? Original role text{rawDescriptionText} : null} + {showTranslatedText ? ( + + Translated role text + {translatedDescriptionText} + + ) : null} + {showOriginalText ? ( + + Original role text + {originalDescriptionText} + + ) : null} Notes{job?.notes ?? ""} )} diff --git a/job-tracker-ui/src/pages/AdminSystemPage.tsx b/job-tracker-ui/src/pages/AdminSystemPage.tsx index 5a4a8eb..e5d44b5 100644 --- a/job-tracker-ui/src/pages/AdminSystemPage.tsx +++ b/job-tracker-ui/src/pages/AdminSystemPage.tsx @@ -1,12 +1,23 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; -import { Alert, Box, Button, Paper, Typography } from "@mui/material"; +import { + Alert, + Box, + Button, + Chip, + Paper, + Stack, + Typography, +} from "@mui/material"; import { api } from "../api"; type SummarizerMetrics = { healthy: boolean; model?: string | null; + device?: string | null; + gpuAvailable?: boolean; + gpuName?: string | null; healthLatencyMs?: number | null; probeLatencyMs?: number | null; lastProbeAt?: string | null; @@ -18,6 +29,8 @@ type SummarizerMetrics = { cacheMisses: number; failures: number; averageLatencyMs?: number | null; + lastSuccessAt?: string | null; + lastFailureAt?: string | null; lastError?: string | null; }; @@ -44,6 +57,26 @@ type SystemStatus = { from?: string | null; fromName?: string | null; }; + database: { + provider: string; + looksConfigured: boolean; + canConnect: boolean; + target?: string | null; + usesFileStorage: boolean; + warning?: string | null; + }; + runtime: { + framework: string; + osDescription: string; + processArchitecture: string; + machineName?: string | null; + }; + auth: { + required: boolean; + hasJwtKey: boolean; + googleConfigured: boolean; + gmailConfigured: boolean; + }; summarizer: SummarizerMetrics; }; @@ -58,6 +91,25 @@ function displayMetadata(value?: string | null) { return value && value.trim().length > 0 ? value : "-"; } +function formatDate(value?: string | null) { + return value ? new Date(value).toLocaleString() : "-"; +} + +function SummaryCard({ title, value, subtitle, tone = "default" }: { title: string; value: string; subtitle?: string; tone?: "default" | "success" | "warning" | "error" }) { + const color = tone === "success" ? "success.main" : tone === "warning" ? "warning.main" : tone === "error" ? "error.main" : "text.primary"; + return ( + + {title} + {value} + {subtitle || "-"} + + ); +} + +function DetailRow({ label, value }: { label: string; value: React.ReactNode }) { + return {label}: {value}; +} + export default function AdminSystemPage() { const [status, setStatus] = useState(null); const [loading, setLoading] = useState(false); @@ -82,12 +134,26 @@ export default function AdminSystemPage() { void load(); }, []); + const dbTone = useMemo(() => { + if (!status) return "default" as const; + if (!status.database.looksConfigured || !status.database.canConnect) return "error" as const; + if (status.database.warning) return "warning" as const; + return "success" as const; + }, [status]); + + const summarizerTone = useMemo(() => { + if (!status) return "default" as const; + if (!status.summarizer.healthy) return "error" as const; + if (status.summarizer.probeFailures > 0 || status.summarizer.failures > 0) return "warning" as const; + return "success" as const; + }, [status]); + return ( System status - Quick operational view of storage, email, and summarizer health. + Production diagnostics for runtime, database, auth, email, and summarizer health.