From d09711dc97a5b00329ca83a4b2b931fe0e5629ca Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 21 Mar 2026 13:03:47 +0100 Subject: [PATCH] Add analytics to summerizer app and dashboard updates --- JobTrackerApi/Services/SummarizerService.cs | 134 +++- .../src/components/DashboardView.tsx | 729 ++++++++++++------ 2 files changed, 604 insertions(+), 259 deletions(-) diff --git a/JobTrackerApi/Services/SummarizerService.cs b/JobTrackerApi/Services/SummarizerService.cs index bb3800d..6a781f3 100644 --- a/JobTrackerApi/Services/SummarizerService.cs +++ b/JobTrackerApi/Services/SummarizerService.cs @@ -1,21 +1,47 @@ using System; +using System.Diagnostics; using System.Net.Http; using System.Text; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Caching.Memory; namespace JobTrackerApi.Services { + public sealed record SummarizerMetrics( + bool Healthy, + string? Model, + double? HealthLatencyMs, + int Requests, + int CacheHits, + int CacheMisses, + int Failures, + double? AverageLatencyMs, + DateTimeOffset? LastSuccessAt, + DateTimeOffset? LastFailureAt, + string? LastError + ); + public interface ISummarizerService { Task SummarizeAsync(string text, int maxLength = 150, int minLength = 30); + Task GetMetricsAsync(CancellationToken cancellationToken = default); } public class SummarizerService : ISummarizerService { private readonly IHttpClientFactory _httpFactory; private readonly IMemoryCache _cache; + private readonly object _metricsLock = new(); + private int _requests; + private int _cacheHits; + private int _cacheMisses; + private int _failures; + private long _totalLatencyTicks; + private DateTimeOffset? _lastSuccessAt; + private DateTimeOffset? _lastFailureAt; + private string? _lastError; public SummarizerService(IHttpClientFactory httpFactory, IMemoryCache cache) { @@ -28,15 +54,31 @@ namespace JobTrackerApi.Services if (string.IsNullOrWhiteSpace(text)) return null; var key = $"summ:{text.GetHashCode()}:{maxLength}:{minLength}"; - if (_cache.TryGetValue(key, out var cached)) return cached; + Interlocked.Increment(ref _requests); + + if (_cache.TryGetValue(key, out var cached)) + { + Interlocked.Increment(ref _cacheHits); + lock (_metricsLock) + { + _lastSuccessAt = DateTimeOffset.UtcNow; + _lastError = null; + } + return cached; + } + + Interlocked.Increment(ref _cacheMisses); var client = _httpFactory.CreateClient("summarizer"); var payload = JsonSerializer.Serialize(new { text, max_length = maxLength, min_length = minLength }); using var content = new StringContent(payload, Encoding.UTF8, "application/json"); + var sw = Stopwatch.StartNew(); try { var res = await client.PostAsync("/summarize", content); + sw.Stop(); + Interlocked.Add(ref _totalLatencyTicks, sw.ElapsedTicks); if (!res.IsSuccessStatusCode) return null; using var stream = await res.Content.ReadAsStreamAsync(); @@ -45,15 +87,103 @@ namespace JobTrackerApi.Services { var s = el.GetString(); if (!string.IsNullOrWhiteSpace(s)) _cache.Set(key, s, TimeSpan.FromHours(6)); + lock (_metricsLock) + { + _lastSuccessAt = DateTimeOffset.UtcNow; + _lastError = null; + } return s; } return null; } - catch + catch (Exception ex) { + sw.Stop(); + Interlocked.Add(ref _totalLatencyTicks, sw.ElapsedTicks); + Interlocked.Increment(ref _failures); + lock (_metricsLock) + { + _lastFailureAt = DateTimeOffset.UtcNow; + _lastError = ex.Message; + } return null; } } + + public async Task GetMetricsAsync(CancellationToken cancellationToken = default) + { + var client = _httpFactory.CreateClient("summarizer"); + string? model = null; + double? healthLatencyMs = null; + var healthy = false; + string? healthError = null; + + try + { + var sw = Stopwatch.StartNew(); + using var res = await client.GetAsync("/health", cancellationToken); + sw.Stop(); + healthLatencyMs = Math.Round(sw.Elapsed.TotalMilliseconds, 1); + healthy = res.IsSuccessStatusCode; + + if (healthy) + { + using var stream = await res.Content.ReadAsStreamAsync(cancellationToken); + using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + if (doc.RootElement.TryGetProperty("model", out var modelEl)) + { + model = modelEl.GetString(); + } + } + else + { + healthError = $"Health check returned {(int)res.StatusCode}."; + } + } + catch (Exception ex) + { + healthError = ex.Message; + } + + var requests = Volatile.Read(ref _requests); + var cacheHits = Volatile.Read(ref _cacheHits); + var cacheMisses = Volatile.Read(ref _cacheMisses); + var failures = Volatile.Read(ref _failures); + var totalLatencyTicks = Volatile.Read(ref _totalLatencyTicks); + + DateTimeOffset? lastSuccessAt; + DateTimeOffset? lastFailureAt; + string? lastError; + lock (_metricsLock) + { + lastSuccessAt = _lastSuccessAt; + lastFailureAt = _lastFailureAt; + lastError = _lastError; + } + + if (!healthy && !string.IsNullOrWhiteSpace(healthError)) + { + lastError = healthError; + } + + double? averageLatencyMs = requests > 0 + ? Math.Round(TimeSpan.FromTicks(totalLatencyTicks).TotalMilliseconds / requests, 1) + : null; + + return new SummarizerMetrics( + Healthy: healthy, + Model: model, + HealthLatencyMs: healthLatencyMs, + Requests: requests, + CacheHits: cacheHits, + CacheMisses: cacheMisses, + Failures: failures, + AverageLatencyMs: averageLatencyMs, + LastSuccessAt: lastSuccessAt, + LastFailureAt: lastFailureAt, + LastError: lastError + ); + } } } diff --git a/job-tracker-ui/src/components/DashboardView.tsx b/job-tracker-ui/src/components/DashboardView.tsx index 509cfc2..812b669 100644 --- a/job-tracker-ui/src/components/DashboardView.tsx +++ b/job-tracker-ui/src/components/DashboardView.tsx @@ -1,7 +1,6 @@ import React, { useEffect, useMemo, useState } from "react"; import { Box, Button, ButtonGroup, Divider, Paper, Tab, Tabs, TextField, Typography } from "@mui/material"; - import { alpha, useTheme } from "@mui/material/styles"; import { api } from "../api"; @@ -17,6 +16,19 @@ interface JobStats { type AnalyticsPoint = { month: string; applied: number; responses: number }; type TagPoint = { tag: string; count: number }; +type SummarizerMetrics = { + healthy: boolean; + model?: string | null; + healthLatencyMs?: number | null; + requests: number; + cacheHits: number; + cacheMisses: number; + failures: number; + averageLatencyMs?: number | null; + lastSuccessAt?: string | null; + lastFailureAt?: string | null; + lastError?: string | null; +}; function clamp(n: number, a: number, b: number) { return Math.max(a, Math.min(b, n)); @@ -36,6 +48,20 @@ function toPath(values: number[], w: number, h: number) { .join(" "); } +function formatRelative(ts?: string | null) { + if (!ts) return "Never"; + const d = new Date(ts); + if (Number.isNaN(d.getTime())) return "Unknown"; + const deltaMs = Date.now() - d.getTime(); + const mins = Math.round(deltaMs / 60000); + if (mins < 1) return "Just now"; + if (mins < 60) return `${mins}m ago`; + const hours = Math.round(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.round(hours / 24); + return `${days}d ago`; +} + export default function DashboardView() { const theme = useTheme(); const [stats, setStats] = useState(null); @@ -51,6 +77,7 @@ export default function DashboardView() { const [appliedCustom, setAppliedCustom] = useState<{ from: string; to: string } | null>(null); const [analytics, setAnalytics] = useState([]); const [tags, setTags] = useState([]); + const [summarizerMetrics, setSummarizerMetrics] = useState(null); useEffect(() => { api.get("/jobapplications/stats").then((r) => setStats(r.data)); @@ -73,12 +100,51 @@ export default function DashboardView() { .catch(() => setTags([])); }, [months, rangeMode, appliedCustom]); + useEffect(() => { + let cancelled = false; + let intervalId: number | undefined; + + const loadMetrics = async () => { + try { + const res = await api.get("/jobapplications/summarizer-metrics"); + if (!cancelled) setSummarizerMetrics(res.data); + } catch { + if (!cancelled) setSummarizerMetrics(null); + } + }; + + if (tab === 2) { + void loadMetrics(); + intervalId = window.setInterval(() => { + void loadMetrics(); + }, 30000); + } + + return () => { + cancelled = true; + if (intervalId) window.clearInterval(intervalId); + }; + }, [tab]); + const statusRows = useMemo(() => { const by = stats?.byStatus ?? {}; return Object.entries(by).sort((a, b) => b[1] - a[1]); }, [stats]); - const max = statusRows.length ? Math.max(...statusRows.map(([, v]) => v)) : 0; + const maxStatus = statusRows.length ? Math.max(...statusRows.map(([, v]) => v)) : 0; + const chartW = 860; + const chartH = 260; + + const appliedSeries = useMemo(() => analytics.map((x) => x.applied), [analytics]); + const responseSeries = useMemo(() => analytics.map((x) => x.responses), [analytics]); + const totalAppliedInRange = useMemo(() => appliedSeries.reduce((sum, value) => sum + value, 0), [appliedSeries]); + const totalResponsesInRange = useMemo(() => responseSeries.reduce((sum, value) => sum + value, 0), [responseSeries]); + const topSkill = tags[0]; + const topStatus = statusRows[0]; + const strongestMonth = useMemo(() => { + if (!analytics.length) return null; + return analytics.reduce((best, current) => (current.applied > best.applied ? current : best), analytics[0]); + }, [analytics]); const metricCards = useMemo(() => { return [ @@ -97,25 +163,24 @@ export default function DashboardView() { value: stats?.averageDaysSinceApplied ?? "-", sub: "Since applied", }, + { + label: "Responses logged", + value: totalResponsesInRange, + sub: "Current chart range", + }, { label: "In trash", value: stats?.deleted ?? "-", sub: "Soft-deleted", }, ]; - }, [stats]); + }, [stats, totalResponsesInRange]); - const chartW = 860; - const chartH = 260; + const pApplied = useMemo(() => toPath(appliedSeries, chartW, chartH), [appliedSeries]); + const pResponses = useMemo(() => toPath(responseSeries, chartW, chartH), [responseSeries]); - const appliedSeries = useMemo(() => analytics.map((x) => x.applied), [analytics]); - const responseSeries = useMemo(() => analytics.map((x) => x.responses), [analytics]); - - const p1 = useMemo(() => toPath(appliedSeries, chartW, chartH), [appliedSeries]); - const p2 = useMemo(() => toPath(responseSeries, chartW, chartH), [responseSeries]); - - const c1 = theme.palette.primary.main; - const c2 = alpha("#94a3b8", theme.palette.mode === "dark" ? 0.9 : 0.8); + const appliedColor = theme.palette.success.main; + const responsesColor = theme.palette.info.main; const tagColors = useMemo(() => { return [ @@ -124,278 +189,428 @@ export default function DashboardView() { theme.palette.warning.main, theme.palette.info.main, theme.palette.error.main, - alpha("#a855f7", 0.9), alpha("#f97316", 0.9), alpha("#14b8a6", 0.9), + alpha("#a855f7", 0.9), alpha("#64748b", 0.9), alpha("#0ea5e9", 0.9), ]; }, [theme.palette]); const tagTotal = useMemo(() => tags.reduce((acc, t) => acc + (t.count || 0), 0), [tags]); + const hitRate = useMemo(() => { + const requests = summarizerMetrics?.requests ?? 0; + if (!requests) return null; + return Math.round(((summarizerMetrics?.cacheHits ?? 0) / requests) * 100); + }, [summarizerMetrics]); + + const StatCard = ({ label, value, sub }: { label: string; value: React.ReactNode; sub: string }) => ( + + + {label} + + + {value} + + + {sub} + + + ); return ( setTab(v)} sx={{ mb: 2 }}> - - + + - - - {metricCards.map((m, idx) => ( - - {idx > 0 ? ( - - ) : null} - - {m.label} - - - {m.value} - - - {m.sub} - - - ))} - - - - - - - - Analysis - - - Monthly applied vs responses. - - - - - - {([6, 12, 24] as const).map((m) => ( - - ))} - - - - {rangeMode === "custom" ? ( - - setFromMonth(e.target.value)} - sx={{ width: 150 }} - /> - setToMonth(e.target.value)} - sx={{ width: 150 }} - /> - - - ) : null} - - - - - - - - - - - - - - - - - - {p2 ? ( - <> - - - - ) : null} - - {p1 ? ( - <> - - - - ) : null} - - - - {analytics.map((p) => ( - - {p.month.slice(5)} - + + ))} - - - + - - - - - Status breakdown - - - {statusRows.length === 0 ? ( - No data yet. - ) : ( - - {statusRows.map(([status, value]) => { - const tone = - status === "Rejected" - ? theme.palette.error.main - : status === "Waiting" || status === "Ghosted" - ? theme.palette.warning.main - : status === "Offer" - ? theme.palette.success.main - : theme.palette.primary.main; - - const w = max ? clamp(Math.round((value / max) * 100), 0, 100) : 0; - - return ( - - {status} - - - - {value} - - ); - })} + + + + + Application activity + + + Color-coded monthly trend for applications and responses. + - )} - - - - Top tags (skills) - + + + {([6, 12, 24] as const).map((m) => ( + + ))} + + - {tags.length === 0 ? ( - No tags yet. - ) : ( - - - - - {(() => { - const r = 52; - const circ = 2 * Math.PI * r; - let offset = 0; - return tags.map((t, i) => { - const pct = tagTotal ? t.count / tagTotal : 0; - const len = circ * pct; - const el = ( - - ); - offset += len; - return el; - }); - })()} - - - {tagTotal} - - - tags - - - + {rangeMode === "custom" ? ( + + setFromMonth(e.target.value)} sx={{ width: 150 }} /> + setToMonth(e.target.value)} sx={{ width: 150 }} /> + + + ) : null} + + - - {tags.slice(0, 8).map((t, i) => ( - - - - - {t.tag} - - - - {t.count} - - + + + + Applied + + + + Responses + + + + + + + + + + + + + + + + + + {[0.25, 0.5, 0.75].map((tick) => ( + + ))} + + {pResponses ? ( + <> + + + + ) : null} + + {pApplied ? ( + <> + + + + ) : null} + + + + {analytics.map((p) => ( + + {p.month.slice(5)} + ))} - )} + + + + + + Top skill + {topSkill?.tag ?? "No tags yet"} + + {topSkill ? `${topSkill.count} tagged jobs in the selected range.` : "Import or add tagged jobs to surface skill trends."} + + + + Busiest month + {strongestMonth?.month ?? "-"} + + {strongestMonth ? `${strongestMonth.applied} applications logged in your strongest month.` : "No monthly application data yet."} + + + + Top stage + {topStatus?.[0] ?? "No status data"} + + {topStatus ? `${topStatus[1]} applications are currently clustered in this stage.` : "Add a few jobs to reveal your pipeline shape."} + + - - + + ) : null} + + {tab === 1 ? ( + + + + + Status breakdown + + + {statusRows.length === 0 ? ( + No data yet. + ) : ( + + {statusRows.map(([status, value]) => { + const tone = + status === "Rejected" + ? theme.palette.error.main + : status === "Waiting" || status === "Ghosted" + ? theme.palette.warning.main + : status === "Offer" + ? theme.palette.success.main + : status === "Interview" + ? theme.palette.info.main + : theme.palette.primary.main; + + const w = maxStatus ? clamp(Math.round((value / maxStatus) * 100), 0, 100) : 0; + + return ( + + {status} + + + + {value} + + ); + })} + + )} + + + + Applications in range + {totalAppliedInRange} + + + Tracked skills + {tagTotal} + + + + + + + Top skills + + + {tags.length === 0 ? ( + No tags yet. + ) : ( + + + + + {(() => { + const r = 52; + const circ = 2 * Math.PI * r; + let offset = 0; + return tags.map((t, i) => { + const pct = tagTotal ? t.count / tagTotal : 0; + const len = circ * pct; + const el = ( + + ); + offset += len; + return el; + }); + })()} + + + {tagTotal} + + + skill tags + + + + + + {tags.slice(0, 8).map((t, i) => ( + + + + + {t.tag} + + + + {t.count} + + + ))} + + + )} + + + + ) : null} + + {tab === 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: "Cache hit rate", + value: hitRate != null ? `${hitRate}%` : "-", + sub: "API-side memory cache reuse", + }, + ].map((m, idx) => ( + + + + ))} + + + + + + Summarizer telemetry + + + Useful for spotting slowdowns, cache misses, or service outages in the local summarizer app. + + + + + Requests + {summarizerMetrics?.requests ?? 0} + + + Hits + {summarizerMetrics?.cacheHits ?? 0} + + + Misses + {summarizerMetrics?.cacheMisses ?? 0} + + + Failures + {summarizerMetrics?.failures ?? 0} + + + + + + Last activity + + Last success: {formatRelative(summarizerMetrics?.lastSuccessAt)} + Last failure: {formatRelative(summarizerMetrics?.lastFailureAt)} + Model: {summarizerMetrics?.model || "Unknown"} + + + + + + + Last error + + {summarizerMetrics?.lastError || "No recent summarizer errors recorded."} + + + + ) : null} ); }