Add full profiles and latency tests

This commit is contained in:
cesnimda
2026-03-22 12:06:25 +01:00
parent 91f6361055
commit 0fa481cab6
11 changed files with 704 additions and 103 deletions
+172 -25
View File
@@ -1,9 +1,10 @@
using System.Security.Claims; using System.Security.Claims;
using JobTrackerApi.Models; using JobTrackerApi.Models;
using JobTrackerApi.Services; using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Controllers; namespace JobTrackerApi.Controllers;
@@ -15,12 +16,15 @@ public sealed class AuthController : ControllerBase
private readonly UserManager<ApplicationUser> _users; private readonly UserManager<ApplicationUser> _users;
private readonly ITokenService _tokens; private readonly ITokenService _tokens;
private readonly IAppEmailSender _email; private readonly IAppEmailSender _email;
public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email) private readonly IGoogleTokenValidator _googleTokens;
public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens)
{ {
_cfg = cfg; _cfg = cfg;
_users = users; _users = users;
_tokens = tokens; _tokens = tokens;
_email = email; _email = email;
_googleTokens = googleTokens;
} }
[HttpGet("config")] [HttpGet("config")]
@@ -43,6 +47,19 @@ public sealed class AuthController : ControllerBase
public sealed record LoginRequest(string Email, string Password); public sealed record LoginRequest(string Email, string Password);
public sealed record RegisterRequest(string Email, string Password); public sealed record RegisterRequest(string Email, string Password);
public sealed record AuthResult(string AccessToken, string TokenType); public sealed record AuthResult(string AccessToken, string TokenType);
public sealed record GoogleLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
public sealed record MeResult(
string Provider,
string? Id,
string? Email,
string? UserName,
string? FirstName,
string? LastName,
string? DisplayName,
IList<string> Roles,
GoogleLinkDto? GoogleLink);
public sealed record UpdateProfileRequest(string? Email, string? UserName, string? FirstName, string? LastName, string? DisplayName);
public sealed record GoogleTokenRequest(string Token);
[HttpPost("login")] [HttpPost("login")]
[AllowAnonymous] [AllowAnonymous]
@@ -91,6 +108,44 @@ public sealed class AuthController : ControllerBase
return Ok(new AuthResult(token, "Bearer")); return Ok(new AuthResult(token, "Bearer"));
} }
[HttpPost("google/exchange")]
[AllowAnonymous]
public async Task<ActionResult<AuthResult>> ExchangeGoogleToken([FromBody] GoogleTokenRequest request, CancellationToken cancellationToken)
{
var token = (request.Token ?? "").Trim();
if (token.Length == 0) return BadRequest("Google token is required.");
GoogleTokenPrincipal google;
try
{
google = await _googleTokens.ValidateAsync(token, cancellationToken);
}
catch (Exception ex)
{
return Unauthorized(ex.Message);
}
var user = await _users.Users.FirstOrDefaultAsync(
x => x.GoogleSubject == google.Subject || (!string.IsNullOrWhiteSpace(google.Email) && x.GoogleEmail == google.Email),
cancellationToken);
if (user is null)
{
return Unauthorized("This Google account is not linked to a Job Tracker user yet.");
}
if (string.IsNullOrWhiteSpace(user.GoogleSubject) || !string.Equals(user.GoogleSubject, google.Subject, StringComparison.Ordinal))
{
user.GoogleSubject = google.Subject;
user.GoogleEmail = google.Email;
user.GoogleLinkedAt ??= DateTimeOffset.UtcNow;
await _users.UpdateAsync(user);
}
var appToken = await _tokens.CreateAccessTokenAsync(user, cancellationToken);
return Ok(new AuthResult(appToken, "Bearer"));
}
[HttpGet("me")] [HttpGet("me")]
[Authorize] [Authorize]
public async Task<IActionResult> Me(CancellationToken cancellationToken) public async Task<IActionResult> Me(CancellationToken cancellationToken)
@@ -99,48 +154,47 @@ public sealed class AuthController : ControllerBase
if (u is not null) if (u is not null)
{ {
var roles = await _users.GetRolesAsync(u); var roles = await _users.GetRolesAsync(u);
return Ok(new return Ok(ToMeResult(u, roles));
{
provider = "local",
id = u.Id,
email = u.Email,
userName = u.UserName,
roles
});
} }
// Google direct bearer tokens (or any other external JWT) wont map to an Identity user.
var email = User.FindFirstValue(ClaimTypes.Email) ?? User.FindFirstValue("email"); var email = User.FindFirstValue(ClaimTypes.Email) ?? User.FindFirstValue("email");
var sub = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"); var sub = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
var iss = User.FindFirstValue("iss") ?? ""; var iss = User.FindFirstValue("iss") ?? "";
var provider = iss.Contains("accounts.google.com", StringComparison.OrdinalIgnoreCase) ? "google" : "external"; var provider = iss.Contains("accounts.google.com", StringComparison.OrdinalIgnoreCase) ? "google" : "external";
return Ok(new return Ok(new MeResult(
{ Provider: provider,
provider, Id: sub,
id = sub, Email: email,
email, UserName: null,
roles = Array.Empty<string>() FirstName: User.FindFirstValue("given_name"),
}); LastName: User.FindFirstValue("family_name"),
DisplayName: User.FindFirstValue("name"),
Roles: Array.Empty<string>(),
GoogleLink: provider == "google" ? new GoogleLinkDto(false, email, null) : null));
} }
public sealed record UpdateProfileRequest(string? Email, string? UserName);
[HttpPut("profile")] [HttpPut("profile")]
[Authorize] [Authorize(AuthenticationSchemes = "local")]
public async Task<IActionResult> UpdateProfile([FromBody] UpdateProfileRequest request) public async Task<IActionResult> UpdateProfile([FromBody] UpdateProfileRequest request)
{ {
var u = await _users.GetUserAsync(User); var u = await _users.GetUserAsync(User);
if (u is null) if (u is null)
{ {
// Google bearer tokens are accepted, but they don't map to Identity users in this build.
return StatusCode(501, "Profile updates are only supported for local username/password accounts."); return StatusCode(501, "Profile updates are only supported for local username/password accounts.");
} }
var email = string.IsNullOrWhiteSpace(request.Email) ? null : request.Email.Trim(); var email = TrimOrNull(request.Email);
var userName = string.IsNullOrWhiteSpace(request.UserName) ? null : request.UserName.Trim(); var userName = TrimOrNull(request.UserName);
var firstName = TrimOrNull(request.FirstName);
var lastName = TrimOrNull(request.LastName);
var displayName = TrimOrNull(request.DisplayName);
if (email is not null) u.Email = email; if (email is not null) u.Email = email;
if (userName is not null) u.UserName = userName; if (userName is not null) u.UserName = userName;
u.FirstName = firstName;
u.LastName = lastName;
u.DisplayName = displayName;
var res = await _users.UpdateAsync(u); var res = await _users.UpdateAsync(u);
if (!res.Succeeded) if (!res.Succeeded)
@@ -149,10 +203,80 @@ public sealed class AuthController : ControllerBase
return NoContent(); return NoContent();
} }
[HttpPost("google/link")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<ActionResult<GoogleLinkDto>> LinkGoogle([FromBody] GoogleTokenRequest request, CancellationToken cancellationToken)
{
var user = await _users.GetUserAsync(User);
if (user is null)
{
return Unauthorized();
}
var token = (request.Token ?? "").Trim();
if (token.Length == 0) return BadRequest("Google token is required.");
GoogleTokenPrincipal google;
try
{
google = await _googleTokens.ValidateAsync(token, cancellationToken);
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
var conflict = await _users.Users
.Where(x => x.Id != user.Id)
.FirstOrDefaultAsync(x => x.GoogleSubject == google.Subject || (!string.IsNullOrWhiteSpace(google.Email) && x.GoogleEmail == google.Email), cancellationToken);
if (conflict is not null)
{
return Conflict("That Google account is already linked to another Job Tracker user.");
}
user.GoogleSubject = google.Subject;
user.GoogleEmail = google.Email;
user.GoogleLinkedAt = DateTimeOffset.UtcNow;
user.DisplayName ??= TrimOrNull(google.Name);
user.FirstName ??= TrimOrNull(google.GivenName);
user.LastName ??= TrimOrNull(google.FamilyName);
var result = await _users.UpdateAsync(user);
if (!result.Succeeded)
{
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
}
return Ok(new GoogleLinkDto(true, user.GoogleEmail, user.GoogleLinkedAt));
}
[HttpDelete("google/link")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<IActionResult> UnlinkGoogle()
{
var user = await _users.GetUserAsync(User);
if (user is null)
{
return Unauthorized();
}
user.GoogleSubject = null;
user.GoogleEmail = null;
user.GoogleLinkedAt = null;
var result = await _users.UpdateAsync(user);
if (!result.Succeeded)
{
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
}
return NoContent();
}
public sealed record ChangePasswordRequest(string CurrentPassword, string NewPassword); public sealed record ChangePasswordRequest(string CurrentPassword, string NewPassword);
[HttpPost("change-password")] [HttpPost("change-password")]
[Authorize] [Authorize(AuthenticationSchemes = "local")]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request) public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
{ {
var u = await _users.GetUserAsync(User); var u = await _users.GetUserAsync(User);
@@ -170,6 +294,7 @@ public sealed class AuthController : ControllerBase
return NoContent(); return NoContent();
} }
public sealed record RequestPasswordResetRequest(string Email); public sealed record RequestPasswordResetRequest(string Email);
[HttpPost("request-password-reset")] [HttpPost("request-password-reset")]
@@ -182,7 +307,6 @@ public sealed class AuthController : ControllerBase
var user = await _users.FindByEmailAsync(email); var user = await _users.FindByEmailAsync(email);
if (user is null || string.IsNullOrWhiteSpace(user.Email)) if (user is null || string.IsNullOrWhiteSpace(user.Email))
{ {
// Avoid user enumeration.
return NoContent(); return NoContent();
} }
@@ -229,5 +353,28 @@ public sealed class AuthController : ControllerBase
return NoContent(); return NoContent();
} }
private static string? TrimOrNull(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
private static MeResult ToMeResult(ApplicationUser user, IList<string> roles)
{
return new MeResult(
Provider: "local",
Id: user.Id,
Email: user.Email,
UserName: user.UserName,
FirstName: user.FirstName,
LastName: user.LastName,
DisplayName: user.DisplayName,
Roles: roles,
GoogleLink: new GoogleLinkDto(
Linked: !string.IsNullOrWhiteSpace(user.GoogleSubject),
Email: user.GoogleEmail,
LinkedAt: user.GoogleLinkedAt));
}
} }
+43 -5
View File
@@ -25,7 +25,17 @@ public sealed class UsersController : ControllerBase
_cfg = cfg; _cfg = cfg;
} }
public sealed record UserDto(string Id, string? Email, string? UserName, bool EmailConfirmed, List<string> Roles); public sealed record UserDto(
string Id,
string? Email,
string? UserName,
string? FirstName,
string? LastName,
string? DisplayName,
bool EmailConfirmed,
string? GoogleEmail,
DateTimeOffset? GoogleLinkedAt,
List<string> Roles);
[HttpGet] [HttpGet]
public async Task<ActionResult<List<UserDto>>> List(CancellationToken cancellationToken) public async Task<ActionResult<List<UserDto>>> List(CancellationToken cancellationToken)
@@ -38,13 +48,13 @@ public sealed class UsersController : ControllerBase
foreach (var u in items) foreach (var u in items)
{ {
var rs = await _users.GetRolesAsync(u); var rs = await _users.GetRolesAsync(u);
outList.Add(new UserDto(u.Id, u.Email, u.UserName, u.EmailConfirmed, rs.ToList())); outList.Add(ToDto(u, rs.ToList()));
} }
return Ok(outList); return Ok(outList);
} }
public sealed record CreateUserRequest(string Email, string Password, string[]? Roles); public sealed record CreateUserRequest(string Email, string Password, string? UserName, string? FirstName, string? LastName, string? DisplayName, string[]? Roles);
[HttpPost] [HttpPost]
public async Task<ActionResult<UserDto>> Create([FromBody] CreateUserRequest request, CancellationToken cancellationToken) public async Task<ActionResult<UserDto>> Create([FromBody] CreateUserRequest request, CancellationToken cancellationToken)
@@ -58,7 +68,15 @@ public sealed class UsersController : ControllerBase
var existing = await _users.FindByEmailAsync(email); var existing = await _users.FindByEmailAsync(email);
if (existing is not null) return BadRequest("User already exists."); if (existing is not null) return BadRequest("User already exists.");
var u = new ApplicationUser { UserName = email, Email = email, EmailConfirmed = true }; var u = new ApplicationUser
{
UserName = string.IsNullOrWhiteSpace(request.UserName) ? email : request.UserName.Trim(),
Email = email,
EmailConfirmed = true,
FirstName = TrimOrNull(request.FirstName),
LastName = TrimOrNull(request.LastName),
DisplayName = TrimOrNull(request.DisplayName)
};
var res = await _users.CreateAsync(u, password); var res = await _users.CreateAsync(u, password);
if (!res.Succeeded) if (!res.Succeeded)
return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description))); return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
@@ -72,7 +90,7 @@ public sealed class UsersController : ControllerBase
} }
var rs = await _users.GetRolesAsync(u); var rs = await _users.GetRolesAsync(u);
return Ok(new UserDto(u.Id, u.Email, u.UserName, u.EmailConfirmed, rs.ToList())); return Ok(ToDto(u, rs.ToList()));
} }
public sealed record SetRolesRequest(string[] Roles); public sealed record SetRolesRequest(string[] Roles);
@@ -167,4 +185,24 @@ public sealed class UsersController : ControllerBase
return NoContent(); return NoContent();
} }
private static UserDto ToDto(ApplicationUser user, List<string> roles)
{
return new UserDto(
user.Id,
user.Email,
user.UserName,
user.FirstName,
user.LastName,
user.DisplayName,
user.EmailConfirmed,
user.GoogleEmail,
user.GoogleLinkedAt,
roles);
}
private static string? TrimOrNull(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
} }
+27 -1
View File
@@ -95,6 +95,7 @@ builder.Services.AddDataProtection()
builder.Services.AddHostedService<RulesHostedService>(); builder.Services.AddHostedService<RulesHostedService>();
builder.Services.AddHostedService<DailyExportHostedService>(); builder.Services.AddHostedService<DailyExportHostedService>();
builder.Services.AddHostedService<JobEnrichmentHostedService>(); builder.Services.AddHostedService<JobEnrichmentHostedService>();
builder.Services.AddHostedService<SummarizerProbeHostedService>();
builder.Services.AddHttpClient("jobimport") builder.Services.AddHttpClient("jobimport")
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
@@ -112,6 +113,7 @@ builder.Services.AddHttpClient("summarizer", client =>
builder.Services.AddMemoryCache(); builder.Services.AddMemoryCache();
builder.Services.AddSingleton<ISummarizerService, SummarizerService>(); builder.Services.AddSingleton<ISummarizerService, SummarizerService>();
builder.Services.AddSingleton<IGoogleTokenValidator, GoogleTokenValidator>();
builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>(); builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>();
builder.Services.AddIdentityCore<ApplicationUser>(options => builder.Services.AddIdentityCore<ApplicationUser>(options =>
@@ -382,7 +384,13 @@ CREATE TABLE IF NOT EXISTS "AspNetUsers" (
"TwoFactorEnabled" INTEGER NOT NULL, "TwoFactorEnabled" INTEGER NOT NULL,
"LockoutEnd" TEXT NULL, "LockoutEnd" TEXT NULL,
"LockoutEnabled" INTEGER NOT NULL, "LockoutEnabled" INTEGER NOT NULL,
"AccessFailedCount" INTEGER NOT NULL "AccessFailedCount" INTEGER NOT NULL,
"FirstName" TEXT NULL,
"LastName" TEXT NULL,
"DisplayName" TEXT NULL,
"GoogleSubject" TEXT NULL,
"GoogleEmail" TEXT NULL,
"GoogleLinkedAt" TEXT NULL
); );
"""); """);
@@ -448,6 +456,18 @@ CREATE TABLE IF NOT EXISTS "AspNetUserTokens" (
} }
EnsureIdentityTables(conn); 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", "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;");
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", "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) static void EnsureUserRuleSettingsTable(DbConnection c)
{ {
@@ -605,3 +625,9 @@ app.UseAuthorization();
app.MapControllers(); app.MapControllers();
app.Run(); app.Run();
@@ -0,0 +1,64 @@
using System.IdentityModel.Tokens.Jwt;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
namespace JobTrackerApi.Services;
public sealed record GoogleTokenPrincipal(string Subject, string? Email, string? GivenName, string? FamilyName, string? Name);
public interface IGoogleTokenValidator
{
Task<GoogleTokenPrincipal> ValidateAsync(string idToken, CancellationToken cancellationToken = default);
}
public sealed class GoogleTokenValidator : IGoogleTokenValidator
{
private readonly IConfiguration _cfg;
private readonly IConfigurationManager<OpenIdConnectConfiguration> _configManager;
public GoogleTokenValidator(IConfiguration cfg)
{
_cfg = cfg;
_configManager = new ConfigurationManager<OpenIdConnectConfiguration>(
"https://accounts.google.com/.well-known/openid-configuration",
new OpenIdConnectConfigurationRetriever());
}
public async Task<GoogleTokenPrincipal> ValidateAsync(string idToken, CancellationToken cancellationToken = default)
{
var audience = (_cfg["Auth:GoogleClientId"] ?? "").Trim();
if (string.IsNullOrWhiteSpace(audience))
{
throw new InvalidOperationException("Google sign-in is not configured.");
}
var config = await _configManager.GetConfigurationAsync(cancellationToken);
var handler = new JwtSecurityTokenHandler();
var principal = handler.ValidateToken(idToken, new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuers = new[] { "accounts.google.com", "https://accounts.google.com" },
ValidateAudience = true,
ValidAudience = audience,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKeys = config.SigningKeys,
ClockSkew = TimeSpan.FromMinutes(2),
}, out _);
var subject = principal.FindFirst("sub")?.Value?.Trim();
if (string.IsNullOrWhiteSpace(subject))
{
throw new InvalidOperationException("Google token is missing a subject.");
}
return new GoogleTokenPrincipal(
Subject: subject,
Email: principal.FindFirst("email")?.Value?.Trim(),
GivenName: principal.FindFirst("given_name")?.Value?.Trim(),
FamilyName: principal.FindFirst("family_name")?.Value?.Trim(),
Name: principal.FindFirst("name")?.Value?.Trim()
);
}
}
+141
View File
@@ -6,6 +6,9 @@ using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace JobTrackerApi.Services namespace JobTrackerApi.Services
{ {
@@ -13,6 +16,11 @@ namespace JobTrackerApi.Services
bool Healthy, bool Healthy,
string? Model, string? Model,
double? HealthLatencyMs, double? HealthLatencyMs,
double? ProbeLatencyMs,
DateTimeOffset? LastProbeAt,
DateTimeOffset? LastProbeSuccessAt,
DateTimeOffset? LastProbeFailureAt,
int ProbeFailures,
int Requests, int Requests,
int CacheHits, int CacheHits,
int CacheMisses, int CacheMisses,
@@ -26,6 +34,7 @@ namespace JobTrackerApi.Services
public interface ISummarizerService public interface ISummarizerService
{ {
Task<string?> SummarizeAsync(string text, int maxLength = 150, int minLength = 30); Task<string?> SummarizeAsync(string text, int maxLength = 150, int minLength = 30);
Task RunProbeAsync(CancellationToken cancellationToken = default);
Task<SummarizerMetrics> GetMetricsAsync(CancellationToken cancellationToken = default); Task<SummarizerMetrics> GetMetricsAsync(CancellationToken cancellationToken = default);
} }
@@ -41,6 +50,11 @@ namespace JobTrackerApi.Services
private long _totalLatencyTicks; private long _totalLatencyTicks;
private DateTimeOffset? _lastSuccessAt; private DateTimeOffset? _lastSuccessAt;
private DateTimeOffset? _lastFailureAt; private DateTimeOffset? _lastFailureAt;
private double? _lastProbeLatencyMs;
private DateTimeOffset? _lastProbeAt;
private DateTimeOffset? _lastProbeSuccessAt;
private DateTimeOffset? _lastProbeFailureAt;
private int _probeFailures;
private string? _lastError; private string? _lastError;
public SummarizerService(IHttpClientFactory httpFactory, IMemoryCache cache) public SummarizerService(IHttpClientFactory httpFactory, IMemoryCache cache)
@@ -111,6 +125,69 @@ namespace JobTrackerApi.Services
} }
} }
public async Task RunProbeAsync(CancellationToken cancellationToken = default)
{
const string probeText = "Summarizer latency probe for job tracker telemetry.";
var client = _httpFactory.CreateClient("summarizer");
var payload = JsonSerializer.Serialize(new { text = probeText, max_length = 48, min_length = 12 });
using var content = new StringContent(payload, Encoding.UTF8, "application/json");
var sw = Stopwatch.StartNew();
try
{
using var res = await client.PostAsync("/summarize", content, cancellationToken);
sw.Stop();
lock (_metricsLock)
{
_lastProbeAt = DateTimeOffset.UtcNow;
_lastProbeLatencyMs = Math.Round(sw.Elapsed.TotalMilliseconds, 1);
}
if (!res.IsSuccessStatusCode)
{
Interlocked.Increment(ref _probeFailures);
lock (_metricsLock)
{
_lastProbeFailureAt = DateTimeOffset.UtcNow;
_lastError = $"Probe returned {(int)res.StatusCode}.";
}
return;
}
using var stream = await res.Content.ReadAsStreamAsync(cancellationToken);
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
if (!doc.RootElement.TryGetProperty("summary", out var summaryEl) || string.IsNullOrWhiteSpace(summaryEl.GetString()))
{
Interlocked.Increment(ref _probeFailures);
lock (_metricsLock)
{
_lastProbeFailureAt = DateTimeOffset.UtcNow;
_lastError = "Probe returned an empty summary.";
}
return;
}
lock (_metricsLock)
{
_lastProbeSuccessAt = DateTimeOffset.UtcNow;
_lastError = null;
}
}
catch (Exception ex)
{
sw.Stop();
Interlocked.Increment(ref _probeFailures);
lock (_metricsLock)
{
_lastProbeAt = DateTimeOffset.UtcNow;
_lastProbeLatencyMs = Math.Round(sw.Elapsed.TotalMilliseconds, 1);
_lastProbeFailureAt = DateTimeOffset.UtcNow;
_lastError = ex.Message;
}
}
}
public async Task<SummarizerMetrics> GetMetricsAsync(CancellationToken cancellationToken = default) public async Task<SummarizerMetrics> GetMetricsAsync(CancellationToken cancellationToken = default)
{ {
var client = _httpFactory.CreateClient("summarizer"); var client = _httpFactory.CreateClient("summarizer");
@@ -154,11 +231,19 @@ namespace JobTrackerApi.Services
DateTimeOffset? lastSuccessAt; DateTimeOffset? lastSuccessAt;
DateTimeOffset? lastFailureAt; DateTimeOffset? lastFailureAt;
double? probeLatencyMs;
DateTimeOffset? lastProbeAt;
DateTimeOffset? lastProbeSuccessAt;
DateTimeOffset? lastProbeFailureAt;
string? lastError; string? lastError;
lock (_metricsLock) lock (_metricsLock)
{ {
lastSuccessAt = _lastSuccessAt; lastSuccessAt = _lastSuccessAt;
lastFailureAt = _lastFailureAt; lastFailureAt = _lastFailureAt;
probeLatencyMs = _lastProbeLatencyMs;
lastProbeAt = _lastProbeAt;
lastProbeSuccessAt = _lastProbeSuccessAt;
lastProbeFailureAt = _lastProbeFailureAt;
lastError = _lastError; lastError = _lastError;
} }
@@ -175,6 +260,11 @@ namespace JobTrackerApi.Services
Healthy: healthy, Healthy: healthy,
Model: model, Model: model,
HealthLatencyMs: healthLatencyMs, HealthLatencyMs: healthLatencyMs,
ProbeLatencyMs: probeLatencyMs,
LastProbeAt: lastProbeAt,
LastProbeSuccessAt: lastProbeSuccessAt,
LastProbeFailureAt: lastProbeFailureAt,
ProbeFailures: Volatile.Read(ref _probeFailures),
Requests: requests, Requests: requests,
CacheHits: cacheHits, CacheHits: cacheHits,
CacheMisses: cacheMisses, CacheMisses: cacheMisses,
@@ -186,4 +276,55 @@ namespace JobTrackerApi.Services
); );
} }
} }
public sealed class SummarizerProbeHostedService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<SummarizerProbeHostedService> _logger;
private readonly IConfiguration _cfg;
public SummarizerProbeHostedService(IServiceScopeFactory scopeFactory, ILogger<SummarizerProbeHostedService> logger, IConfiguration cfg)
{
_scopeFactory = scopeFactory;
_logger = logger;
_cfg = cfg;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var enabled = _cfg.GetValue("Summarizer:ProbeEnabled", true);
if (!enabled)
{
return;
}
var intervalSeconds = Math.Clamp(_cfg.GetValue("Summarizer:ProbeIntervalSeconds", 300), 30, 3600);
var initialDelaySeconds = Math.Clamp(_cfg.GetValue("Summarizer:ProbeInitialDelaySeconds", 15), 0, 600);
if (initialDelaySeconds > 0)
{
await Task.Delay(TimeSpan.FromSeconds(initialDelaySeconds), stoppingToken);
}
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(intervalSeconds));
do
{
try
{
using var scope = _scopeFactory.CreateScope();
var summarizer = scope.ServiceProvider.GetRequiredService<ISummarizerService>();
await summarizer.RunProbeAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Summarizer latency probe failed.");
}
}
while (await timer.WaitForNextTickAsync(stoppingToken));
}
}
} }
+6 -1
View File
@@ -4,5 +4,10 @@ namespace JobTrackerApi.Models;
public sealed class ApplicationUser : IdentityUser public sealed class ApplicationUser : IdentityUser
{ {
public string? FirstName { get; set; }
public string? LastName { get; set; }
public string? DisplayName { get; set; }
public string? GoogleSubject { get; set; }
public string? GoogleEmail { get; set; }
public DateTimeOffset? GoogleLinkedAt { get; set; }
} }
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { Box, Button, Paper, Typography } from "@mui/material"; import { Box, Button, Paper, Typography } from "@mui/material";
@@ -11,7 +11,14 @@ type MeResponse = {
id?: string; id?: string;
email?: string; email?: string;
userName?: string; userName?: string;
firstName?: string;
lastName?: string;
displayName?: string;
roles?: string[]; roles?: string[];
googleLink?: {
linked: boolean;
email?: string | null;
} | null;
}; };
export default function AuthStatusCard() { export default function AuthStatusCard() {
@@ -30,6 +37,8 @@ export default function AuthStatusCard() {
.catch(() => setMe(null)); .catch(() => setMe(null));
}, [token]); }, [token]);
const label = useMemo(() => me?.displayName || [me?.firstName, me?.lastName].filter(Boolean).join(" ") || me?.email, [me]);
return ( return (
<Paper sx={{ mt: 2, p: 2 }}> <Paper sx={{ mt: 2, p: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}> <Typography variant="h6" sx={{ mb: 1 }}>
@@ -41,13 +50,18 @@ export default function AuthStatusCard() {
) : ( ) : (
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
<Typography sx={{ color: "text.secondary" }}> <Typography sx={{ color: "text.secondary" }}>
Signed in{me?.email ? ` as ${me.email}` : ""}{me?.provider ? ` (${me.provider})` : ""}. Signed in{label ? ` as ${label}` : ""}{me?.provider ? ` (${me.provider})` : ""}.
</Typography> </Typography>
{me?.roles && me.roles.length > 0 ? ( {me?.roles && me.roles.length > 0 ? (
<Typography sx={{ color: "text.secondary" }}> <Typography sx={{ color: "text.secondary" }}>
Roles: {me.roles.join(", ")} Roles: {me.roles.join(", ")}
</Typography> </Typography>
) : null} ) : null}
{me?.googleLink?.linked ? (
<Typography sx={{ color: "text.secondary" }}>
Google linked{me.googleLink.email ? `: ${me.googleLink.email}` : "."}
</Typography>
) : null}
<Box sx={{ mt: 1 }}> <Box sx={{ mt: 1 }}>
<Button <Button
@@ -66,4 +80,3 @@ export default function AuthStatusCard() {
</Paper> </Paper>
); );
} }
@@ -34,6 +34,11 @@ type SummarizerMetrics = {
healthy: boolean; healthy: boolean;
model?: string | null; model?: string | null;
healthLatencyMs?: number | null; healthLatencyMs?: number | null;
probeLatencyMs?: number | null;
lastProbeAt?: string | null;
lastProbeSuccessAt?: string | null;
lastProbeFailureAt?: string | null;
probeFailures: number;
requests: number; requests: number;
cacheHits: number; cacheHits: number;
cacheMisses: number; cacheMisses: number;
@@ -408,7 +413,7 @@ export default function DashboardView() {
{tab === 2 ? ( {tab === 2 ? (
<Paper sx={{ p: 2.25 }}> <Paper sx={{ p: 2.25 }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(4, 1fr)" }, gap: 2 }}> <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(4, 1fr)" }, gap: 2 }}>
{[{ label: "Service status", value: summarizerMetrics?.healthy ? "Healthy" : "Offline", sub: summarizerMetrics?.model || "Summarizer health check" }, { label: "Health latency", value: summarizerMetrics?.healthLatencyMs != null ? `${summarizerMetrics.healthLatencyMs} ms` : "-", sub: "Latest /health round-trip" }, { label: "Average latency", value: summarizerMetrics?.averageLatencyMs != null ? `${summarizerMetrics.averageLatencyMs} ms` : "-", sub: "Across API summary requests" }, { label: "Last success", value: formatRelative(summarizerMetrics?.lastSuccessAt), sub: "Recent successful summary request" }].map((m) => <Paper key={m.label} variant="outlined" sx={{ p: 2 }}><Typography variant="overline" sx={{ color: "text.secondary" }}>{m.label}</Typography><Typography variant="h5" sx={{ fontWeight: 950 }}>{m.value}</Typography><Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>{m.sub}</Typography></Paper>)} {[{ label: "Service status", value: summarizerMetrics?.healthy ? "Healthy" : "Offline", sub: summarizerMetrics?.model || "Summarizer health check" }, { label: "Health latency", value: summarizerMetrics?.healthLatencyMs != null ? `${summarizerMetrics.healthLatencyMs} ms` : "-", sub: "Latest /health round-trip" }, { label: "Probe latency", value: summarizerMetrics?.probeLatencyMs != null ? `${summarizerMetrics.probeLatencyMs} ms` : "-", sub: "Periodic small summarize request" }, { label: "Last success", value: formatRelative(summarizerMetrics?.lastProbeSuccessAt || summarizerMetrics?.lastSuccessAt), sub: "Recent successful latency sample" }].map((m) => <Paper key={m.label} variant="outlined" sx={{ p: 2 }}><Typography variant="overline" sx={{ color: "text.secondary" }}>{m.label}</Typography><Typography variant="h5" sx={{ fontWeight: 950 }}>{m.value}</Typography><Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>{m.sub}</Typography></Paper>)}
</Box> </Box>
<Paper variant="outlined" sx={{ p: 2, mt: 2 }}> <Paper variant="outlined" sx={{ p: 2, mt: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Telemetry</Typography> <Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Telemetry</Typography>
@@ -416,6 +421,7 @@ export default function DashboardView() {
<Typography variant="body2"><strong>Cache hits:</strong> {summarizerMetrics?.cacheHits ?? 0}</Typography> <Typography variant="body2"><strong>Cache hits:</strong> {summarizerMetrics?.cacheHits ?? 0}</Typography>
<Typography variant="body2"><strong>Cache misses:</strong> {summarizerMetrics?.cacheMisses ?? 0}</Typography> <Typography variant="body2"><strong>Cache misses:</strong> {summarizerMetrics?.cacheMisses ?? 0}</Typography>
<Typography variant="body2"><strong>Failures:</strong> {summarizerMetrics?.failures ?? 0}</Typography> <Typography variant="body2"><strong>Failures:</strong> {summarizerMetrics?.failures ?? 0}</Typography>
<Typography variant="body2"><strong>Probe failures:</strong> {summarizerMetrics?.probeFailures ?? 0}</Typography>
<Typography variant="body2"><strong>Last failure:</strong> {formatRelative(summarizerMetrics?.lastFailureAt)}</Typography> <Typography variant="body2"><strong>Last failure:</strong> {formatRelative(summarizerMetrics?.lastFailureAt)}</Typography>
<Typography variant="body2" sx={{ mt: 1, color: summarizerMetrics?.lastError ? "warning.main" : "text.secondary" }}>{summarizerMetrics?.lastError || "No recent summarizer errors recorded."}</Typography> <Typography variant="body2" sx={{ mt: 1, color: summarizerMetrics?.lastError ? "warning.main" : "text.secondary" }}>{summarizerMetrics?.lastError || "No recent summarizer errors recorded."}</Typography>
</Paper> </Paper>
@@ -424,3 +430,6 @@ export default function DashboardView() {
</Box> </Box>
); );
} }
+144 -27
View File
@@ -1,7 +1,8 @@
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import { Box, Button, Paper, Typography } from "@mui/material"; import { Box, Button, Paper, Typography } from "@mui/material";
import { api } from "../api";
import { clearAuthToken, decodeJwtPayload, getAuthToken, setAuthToken } from "../auth"; import { clearAuthToken, decodeJwtPayload, getAuthToken, setAuthToken } from "../auth";
import { useToast } from "../toast"; import { useToast } from "../toast";
@@ -11,6 +12,19 @@ declare global {
} }
} }
type MeResponse = {
provider?: "local" | "google" | "external";
email?: string;
displayName?: string;
firstName?: string;
lastName?: string;
googleLink?: {
linked: boolean;
email?: string | null;
linkedAt?: string | null;
} | null;
};
function loadGoogleScript(): Promise<void> { function loadGoogleScript(): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (window.google?.accounts?.id) return resolve(); if (window.google?.accounts?.id) return resolve();
@@ -33,74 +47,177 @@ function loadGoogleScript(): Promise<void> {
export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void }) { export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void }) {
const { toast } = useToast(); const { toast } = useToast();
const [token, setToken] = useState<string | null>(() => getAuthToken()); const [token, setToken] = useState<string | null>(() => getAuthToken());
const [me, setMe] = useState<MeResponse | null>(null);
const [working, setWorking] = useState(false);
const hostRef = useRef<HTMLDivElement | null>(null);
const clientId = (process.env.REACT_APP_GOOGLE_CLIENT_ID || "").trim(); const clientId = (process.env.REACT_APP_GOOGLE_CLIENT_ID || "").trim();
const payload = useMemo(() => (token ? decodeJwtPayload(token) : null), [token]); const payload = useMemo(() => (token ? decodeJwtPayload(token) : null), [token]);
const email = payload?.email as string | undefined; const isRawGoogleToken = payload?.iss === "accounts.google.com" || payload?.iss === "https://accounts.google.com";
async function refreshMe() {
if (!getAuthToken()) {
setMe(null);
return;
}
try {
const res = await api.get<MeResponse>("/auth/me");
setMe(res.data);
} catch {
setMe(null);
}
}
useEffect(() => { useEffect(() => {
if (!clientId) return; void refreshMe();
}, [token]);
useEffect(() => {
if (!token || !isRawGoogleToken) return;
let cancelled = false;
const exchange = async () => {
try {
const res = await api.post<{ accessToken: string }>("/auth/google/exchange", { token });
if (cancelled) return;
setAuthToken(res.data.accessToken);
setToken(res.data.accessToken);
toast("Signed in with Google.", "success");
onSignedIn?.();
} catch {
if (cancelled) return;
clearAuthToken();
setToken(null);
toast("This Google account is not linked yet. Sign in locally first to bind it.", "info");
}
};
void exchange();
return () => {
cancelled = true;
};
}, [token, isRawGoogleToken, onSignedIn, toast]);
useEffect(() => {
if (!clientId || !hostRef.current) return;
const shouldRenderButton = !token || isRawGoogleToken || (me?.provider === "local" && !me?.googleLink?.linked);
if (!shouldRenderButton) {
hostRef.current.innerHTML = "";
return;
}
void loadGoogleScript() void loadGoogleScript()
.then(() => { .then(() => {
if (!window.google?.accounts?.id) return; if (!window.google?.accounts?.id || !hostRef.current) return;
hostRef.current.innerHTML = "";
window.google.accounts.id.initialize({ window.google.accounts.id.initialize({
client_id: clientId, client_id: clientId,
callback: (resp: any) => { callback: async (resp: any) => {
if (resp?.credential) { const credential = resp?.credential as string | undefined;
setAuthToken(resp.credential); if (!credential) return;
setToken(resp.credential); setWorking(true);
toast("Signed in.", "success"); try {
onSignedIn?.(); if (me?.provider === "local") {
const res = await api.post<{ linked: boolean; email?: string | null }>("/auth/google/link", { token: credential });
toast(res.data?.email ? `Linked Google account ${res.data.email}.` : "Google account linked.", "success");
await refreshMe();
} else {
const res = await api.post<{ accessToken: string }>("/auth/google/exchange", { token: credential });
setAuthToken(res.data.accessToken);
setToken(res.data.accessToken);
toast("Signed in with Google.", "success");
onSignedIn?.();
}
} catch (e: any) {
const msg = e?.response?.data || e?.message || "Google authentication failed.";
toast(String(msg), "error");
} finally {
setWorking(false);
} }
}, },
}); });
window.google.accounts.id.renderButton(document.getElementById("gsi-btn"), { window.google.accounts.id.renderButton(hostRef.current, {
theme: "outline", theme: "outline",
size: "large", size: "large",
type: "standard", type: "standard",
shape: "pill", shape: "pill",
text: me?.provider === "local" ? "continue_with" : "signin_with",
}); });
}) })
.catch(() => toast("Google auth script failed to load.", "error")); .catch(() => toast("Google auth script failed to load.", "error"));
}, [clientId, onSignedIn, toast]); }, [clientId, me?.provider, me?.googleLink?.linked, onSignedIn, isRawGoogleToken, token, toast]);
return ( return (
<Paper sx={{ mt: 2, p: 2 }}> <Paper sx={{ mt: 2, p: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}> <Typography variant="h6" sx={{ mb: 1 }}>
Authentication (Google) Google account
</Typography> </Typography>
{!clientId && ( {!clientId && (
<Typography sx={{ color: "text.secondary" }}> <Typography sx={{ color: "text.secondary" }}>
Set `REACT_APP_GOOGLE_CLIENT_ID` in your UI environment to enable sign-in. Set `REACT_APP_GOOGLE_CLIENT_ID` in your UI environment to enable Google sign-in and account linking.
</Typography> </Typography>
)} )}
{clientId && ( {clientId && (
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
<div id="gsi-btn" /> {!token ? (
{token ? ( <Typography sx={{ color: "text.secondary" }}>
<> Sign in with a Google account that has already been linked to your Job Tracker user.
<Typography sx={{ color: "text.secondary" }}> </Typography>
Signed in{email ? ` as ${email}` : ""}. ) : me?.provider === "local" ? (
</Typography> <Typography sx={{ color: "text.secondary" }}>
{me.googleLink?.linked
? `Linked to ${me.googleLink.email || "your Google account"}.`
: "Bind a Google account to this user so you can sign in with Google and still keep your normal app roles and data."}
</Typography>
) : (
<Typography sx={{ color: "text.secondary" }}>
Exchange your Google sign-in for a normal Job Tracker session.
</Typography>
)}
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
<div ref={hostRef} />
{token ? (
<Button <Button
variant="outlined" variant="outlined"
onClick={() => { onClick={() => {
clearAuthToken(); clearAuthToken();
setToken(null); setToken(null);
setMe(null);
toast("Signed out.", "info"); toast("Signed out.", "info");
}} }}
> >
Sign out Sign out
</Button> </Button>
</> ) : null}
) : (
<Typography sx={{ color: "text.secondary" }}> {me?.provider === "local" && me.googleLink?.linked ? (
Sign in to unlock API access. <Button
variant="outlined"
color="warning"
disabled={working}
onClick={async () => {
try {
await api.delete("/auth/google/link");
toast("Google account unlinked.", "info");
await refreshMe();
} catch (e: any) {
const msg = e?.response?.data || e?.message || "Failed to unlink Google account.";
toast(String(msg), "error");
}
}}
>
Unlink Google
</Button>
) : null}
</Box>
{token && me?.email ? (
<Typography variant="body2" sx={{ color: "text.secondary" }}>
Signed in as {me.displayName || [me.firstName, me.lastName].filter(Boolean).join(" ") || me.email}.
</Typography> </Typography>
)} ) : null}
</Box> </Box>
)} )}
</Paper> </Paper>
+18 -2
View File
@@ -8,6 +8,11 @@ type SummarizerMetrics = {
healthy: boolean; healthy: boolean;
model?: string | null; model?: string | null;
healthLatencyMs?: number | null; healthLatencyMs?: number | null;
probeLatencyMs?: number | null;
lastProbeAt?: string | null;
lastProbeSuccessAt?: string | null;
lastProbeFailureAt?: string | null;
probeFailures: number;
requests: number; requests: number;
cacheHits: number; cacheHits: number;
cacheMisses: number; cacheMisses: number;
@@ -107,7 +112,13 @@ export default function AdminSystemPage() {
<Paper sx={{ p: 2 }}> <Paper sx={{ p: 2 }}>
<Typography variant="overline" sx={{ color: "text.secondary" }}>Summarizer</Typography> <Typography variant="overline" sx={{ color: "text.secondary" }}>Summarizer</Typography>
<Typography variant="h5" sx={{ fontWeight: 950 }}>{status?.summarizer.healthy ? "Healthy" : "Offline"}</Typography> <Typography variant="h5" sx={{ fontWeight: 950 }}>{status?.summarizer.healthy ? "Healthy" : "Offline"}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>{status?.summarizer.healthLatencyMs != null ? `${status.summarizer.healthLatencyMs} ms` : "No latency data"}</Typography> <Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>
{status?.summarizer.probeLatencyMs != null
? `${status.summarizer.probeLatencyMs} ms probe`
: status?.summarizer.healthLatencyMs != null
? `${status.summarizer.healthLatencyMs} ms health`
: "No latency data"}
</Typography>
</Paper> </Paper>
</Box> </Box>
@@ -134,7 +145,7 @@ export default function AdminSystemPage() {
<Paper sx={{ p: 2 }}> <Paper sx={{ p: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Summarizer telemetry</Typography> <Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Summarizer telemetry</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(4, 1fr)" }, gap: 2 }}> <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(5, 1fr)" }, gap: 2 }}>
<Box> <Box>
<Typography variant="overline" sx={{ color: "text.secondary" }}>Requests</Typography> <Typography variant="overline" sx={{ color: "text.secondary" }}>Requests</Typography>
<Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.requests ?? 0}</Typography> <Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.requests ?? 0}</Typography>
@@ -151,7 +162,12 @@ export default function AdminSystemPage() {
<Typography variant="overline" sx={{ color: "text.secondary" }}>Avg latency</Typography> <Typography variant="overline" sx={{ color: "text.secondary" }}>Avg latency</Typography>
<Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.averageLatencyMs != null ? `${status.summarizer.averageLatencyMs} ms` : "-"}</Typography> <Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.averageLatencyMs != null ? `${status.summarizer.averageLatencyMs} ms` : "-"}</Typography>
</Box> </Box>
<Box>
<Typography variant="overline" sx={{ color: "text.secondary" }}>Probe latency</Typography>
<Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.probeLatencyMs != null ? `${status.summarizer.probeLatencyMs} ms` : "-"}</Typography>
</Box>
</Box> </Box>
<Typography variant="body2" sx={{ mt: 1 }}><strong>Probe failures:</strong> {status?.summarizer.probeFailures ?? 0}</Typography>
{status?.summarizer.lastError ? <Alert severity="warning" sx={{ mt: 2 }}>{status.summarizer.lastError}</Alert> : null} {status?.summarizer.lastError ? <Alert severity="warning" sx={{ mt: 2 }}>{status.summarizer.lastError}</Alert> : null}
</Paper> </Paper>
</Box> </Box>
+63 -38
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { Avatar, Box, Button, Paper, TextField, Typography } from "@mui/material"; import { Alert, Avatar, Box, Button, Paper, TextField, Typography } from "@mui/material";
import { api } from "../api"; import { api } from "../api";
import { useToast } from "../toast"; import { useToast } from "../toast";
@@ -10,15 +10,26 @@ type MeResponse = {
id?: string; id?: string;
email?: string; email?: string;
userName?: string; userName?: string;
firstName?: string;
lastName?: string;
displayName?: string;
roles?: string[]; roles?: string[];
googleLink?: {
linked: boolean;
email?: string | null;
linkedAt?: string | null;
} | null;
}; };
function initialsFrom(s?: string) { function initialsFrom(values: Array<string | undefined>) {
const v = (s ?? "").trim(); const joined = values.map((x) => (x ?? "").trim()).filter(Boolean);
if (!v) return "?"; if (joined.length === 0) return "?";
const parts = v.split(/[\s@._-]+/).filter(Boolean); if (joined.length === 1) {
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); const parts = joined[0].split(/[\s@._-]+/).filter(Boolean);
return (parts[0][0] + parts[1][0]).toUpperCase(); if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[1][0]).toUpperCase();
}
return (joined[0][0] + joined[1][0]).toUpperCase();
} }
export default function ProfilePage() { export default function ProfilePage() {
@@ -28,62 +39,76 @@ export default function ProfilePage() {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [userName, setUserName] = useState(""); const [userName, setUserName] = useState("");
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [displayName, setDisplayName] = useState("");
const [currentPassword, setCurrentPassword] = useState(""); const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
async function loadProfile() {
try {
const r = await api.get<MeResponse>("/auth/me");
setMe(r.data);
setEmail(r.data?.email ?? "");
setUserName(r.data?.userName ?? "");
setFirstName(r.data?.firstName ?? "");
setLastName(r.data?.lastName ?? "");
setDisplayName(r.data?.displayName ?? "");
} catch {
setMe(null);
}
}
useEffect(() => { useEffect(() => {
api void loadProfile();
.get<MeResponse>("/auth/me")
.then((r) => {
setMe(r.data);
setEmail(r.data?.email ?? "");
setUserName(r.data?.userName ?? "");
})
.catch(() => setMe(null));
}, []); }, []);
const initials = useMemo(() => initialsFrom(me?.userName || me?.email), [me]); const initials = useMemo(
() => initialsFrom([me?.displayName, me?.firstName, me?.lastName, me?.userName, me?.email]),
[me],
);
const isLocal = me?.provider === "local"; const isLocal = me?.provider === "local";
const fullName = [me?.firstName, me?.lastName].filter(Boolean).join(" ");
return ( return (
<Paper sx={{ mt: 0, p: 2 }}> <Paper sx={{ mt: 0, p: 2 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}> <Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
<Avatar sx={{ width: 44, height: 44, fontWeight: 900 }}>{initials}</Avatar> <Avatar sx={{ width: 52, height: 52, fontWeight: 900 }}>{initials}</Avatar>
<Box> <Box>
<Typography variant="h5" sx={{ fontWeight: 900 }}> <Typography variant="h5" sx={{ fontWeight: 900 }}>
Profile Profile
</Typography> </Typography>
<Typography sx={{ color: "text.secondary" }}> <Typography sx={{ color: "text.secondary" }}>
{me?.email ? me.email : "—"} {me?.provider ? `(${me.provider})` : ""} {me?.displayName || fullName || me?.userName || me?.email || "-"}
</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{me?.email || "-"} {me?.provider ? `(${me.provider})` : ""}
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
<Box sx={{ mt: 3, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2 }}> <Box sx={{ mt: 3, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
<Box sx={{ gridColumn: "1 / -1" }}> <Box sx={{ gridColumn: "1 / -1" }}>
<Typography variant="h6">Account</Typography> <Typography variant="h6">Account</Typography>
{!isLocal ? ( {!isLocal ? (
<Typography sx={{ color: "text.secondary" }}> <Alert severity="info" sx={{ mt: 1 }}>
This account is authenticated via Google; profile updates are read-only in this build. This session is not using a local app token, so profile edits are read-only right now.
</Typography> </Alert>
) : null} ) : null}
</Box> </Box>
<TextField <TextField label="Display name" value={displayName} onChange={(e) => setDisplayName(e.target.value)} disabled={!isLocal} fullWidth />
label="Email" <TextField label="Username" value={userName} onChange={(e) => setUserName(e.target.value)} disabled={!isLocal} fullWidth />
value={email} <TextField label="First name" value={firstName} onChange={(e) => setFirstName(e.target.value)} disabled={!isLocal} fullWidth />
onChange={(e) => setEmail(e.target.value)} <TextField label="Last name" value={lastName} onChange={(e) => setLastName(e.target.value)} disabled={!isLocal} fullWidth />
disabled={!isLocal} <TextField label="Email" value={email} onChange={(e) => setEmail(e.target.value)} disabled={!isLocal} fullWidth sx={{ gridColumn: "1 / -1" }} />
fullWidth
/> <Box sx={{ gridColumn: "1 / -1" }}>
<TextField <Typography variant="body2" sx={{ color: "text.secondary" }}>
label="Username" Google account: {me?.googleLink?.linked ? `Linked${me.googleLink.email ? ` to ${me.googleLink.email}` : ""}` : "Not linked"}
value={userName} </Typography>
onChange={(e) => setUserName(e.target.value)} </Box>
disabled={!isLocal}
fullWidth
/>
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end" }}> <Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end" }}>
<Button <Button
@@ -92,7 +117,8 @@ export default function ProfilePage() {
onClick={async () => { onClick={async () => {
setLoading(true); setLoading(true);
try { try {
await api.put("/auth/profile", { email, userName }); await api.put("/auth/profile", { email, userName, firstName, lastName, displayName });
await loadProfile();
toast("Profile updated.", "success"); toast("Profile updated.", "success");
} catch (e: any) { } catch (e: any) {
const msg = e?.response?.data || e?.message || "Failed to update profile."; const msg = e?.response?.data || e?.message || "Failed to update profile.";
@@ -161,4 +187,3 @@ export default function ProfilePage() {
</Paper> </Paper>
); );
} }