Add analytics to summerizer app and dashboard updates
This commit is contained in:
@@ -1,21 +1,47 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
|
||||||
namespace JobTrackerApi.Services
|
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
|
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<SummarizerMetrics> GetMetricsAsync(CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SummarizerService : ISummarizerService
|
public class SummarizerService : ISummarizerService
|
||||||
{
|
{
|
||||||
private readonly IHttpClientFactory _httpFactory;
|
private readonly IHttpClientFactory _httpFactory;
|
||||||
private readonly IMemoryCache _cache;
|
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)
|
public SummarizerService(IHttpClientFactory httpFactory, IMemoryCache cache)
|
||||||
{
|
{
|
||||||
@@ -28,15 +54,31 @@ namespace JobTrackerApi.Services
|
|||||||
if (string.IsNullOrWhiteSpace(text)) return null;
|
if (string.IsNullOrWhiteSpace(text)) return null;
|
||||||
|
|
||||||
var key = $"summ:{text.GetHashCode()}:{maxLength}:{minLength}";
|
var key = $"summ:{text.GetHashCode()}:{maxLength}:{minLength}";
|
||||||
if (_cache.TryGetValue<string>(key, out var cached)) return cached;
|
Interlocked.Increment(ref _requests);
|
||||||
|
|
||||||
|
if (_cache.TryGetValue<string>(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 client = _httpFactory.CreateClient("summarizer");
|
||||||
var payload = JsonSerializer.Serialize(new { text, max_length = maxLength, min_length = minLength });
|
var payload = JsonSerializer.Serialize(new { text, max_length = maxLength, min_length = minLength });
|
||||||
using var content = new StringContent(payload, Encoding.UTF8, "application/json");
|
using var content = new StringContent(payload, Encoding.UTF8, "application/json");
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var res = await client.PostAsync("/summarize", content);
|
var res = await client.PostAsync("/summarize", content);
|
||||||
|
sw.Stop();
|
||||||
|
Interlocked.Add(ref _totalLatencyTicks, sw.ElapsedTicks);
|
||||||
if (!res.IsSuccessStatusCode) return null;
|
if (!res.IsSuccessStatusCode) return null;
|
||||||
|
|
||||||
using var stream = await res.Content.ReadAsStreamAsync();
|
using var stream = await res.Content.ReadAsStreamAsync();
|
||||||
@@ -45,15 +87,103 @@ namespace JobTrackerApi.Services
|
|||||||
{
|
{
|
||||||
var s = el.GetString();
|
var s = el.GetString();
|
||||||
if (!string.IsNullOrWhiteSpace(s)) _cache.Set(key, s, TimeSpan.FromHours(6));
|
if (!string.IsNullOrWhiteSpace(s)) _cache.Set(key, s, TimeSpan.FromHours(6));
|
||||||
|
lock (_metricsLock)
|
||||||
|
{
|
||||||
|
_lastSuccessAt = DateTimeOffset.UtcNow;
|
||||||
|
_lastError = null;
|
||||||
|
}
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<SummarizerMetrics> 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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { Box, Button, ButtonGroup, Divider, Paper, Tab, Tabs, TextField, Typography } from "@mui/material";
|
import { Box, Button, ButtonGroup, Divider, Paper, Tab, Tabs, TextField, Typography } from "@mui/material";
|
||||||
|
|
||||||
import { alpha, useTheme } from "@mui/material/styles";
|
import { alpha, useTheme } from "@mui/material/styles";
|
||||||
|
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
@@ -17,6 +16,19 @@ interface JobStats {
|
|||||||
|
|
||||||
type AnalyticsPoint = { month: string; applied: number; responses: number };
|
type AnalyticsPoint = { month: string; applied: number; responses: number };
|
||||||
type TagPoint = { tag: string; count: 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) {
|
function clamp(n: number, a: number, b: number) {
|
||||||
return Math.max(a, Math.min(b, n));
|
return Math.max(a, Math.min(b, n));
|
||||||
@@ -36,6 +48,20 @@ function toPath(values: number[], w: number, h: number) {
|
|||||||
.join(" ");
|
.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() {
|
export default function DashboardView() {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const [stats, setStats] = useState<JobStats | null>(null);
|
const [stats, setStats] = useState<JobStats | null>(null);
|
||||||
@@ -51,6 +77,7 @@ export default function DashboardView() {
|
|||||||
const [appliedCustom, setAppliedCustom] = useState<{ from: string; to: string } | null>(null);
|
const [appliedCustom, setAppliedCustom] = useState<{ from: string; to: string } | null>(null);
|
||||||
const [analytics, setAnalytics] = useState<AnalyticsPoint[]>([]);
|
const [analytics, setAnalytics] = useState<AnalyticsPoint[]>([]);
|
||||||
const [tags, setTags] = useState<TagPoint[]>([]);
|
const [tags, setTags] = useState<TagPoint[]>([]);
|
||||||
|
const [summarizerMetrics, setSummarizerMetrics] = useState<SummarizerMetrics | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get<JobStats>("/jobapplications/stats").then((r) => setStats(r.data));
|
api.get<JobStats>("/jobapplications/stats").then((r) => setStats(r.data));
|
||||||
@@ -73,12 +100,51 @@ export default function DashboardView() {
|
|||||||
.catch(() => setTags([]));
|
.catch(() => setTags([]));
|
||||||
}, [months, rangeMode, appliedCustom]);
|
}, [months, rangeMode, appliedCustom]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let intervalId: number | undefined;
|
||||||
|
|
||||||
|
const loadMetrics = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get<SummarizerMetrics>("/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 statusRows = useMemo(() => {
|
||||||
const by = stats?.byStatus ?? {};
|
const by = stats?.byStatus ?? {};
|
||||||
return Object.entries(by).sort((a, b) => b[1] - a[1]);
|
return Object.entries(by).sort((a, b) => b[1] - a[1]);
|
||||||
}, [stats]);
|
}, [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(() => {
|
const metricCards = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
@@ -97,25 +163,24 @@ export default function DashboardView() {
|
|||||||
value: stats?.averageDaysSinceApplied ?? "-",
|
value: stats?.averageDaysSinceApplied ?? "-",
|
||||||
sub: "Since applied",
|
sub: "Since applied",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Responses logged",
|
||||||
|
value: totalResponsesInRange,
|
||||||
|
sub: "Current chart range",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "In trash",
|
label: "In trash",
|
||||||
value: stats?.deleted ?? "-",
|
value: stats?.deleted ?? "-",
|
||||||
sub: "Soft-deleted",
|
sub: "Soft-deleted",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}, [stats]);
|
}, [stats, totalResponsesInRange]);
|
||||||
|
|
||||||
const chartW = 860;
|
const pApplied = useMemo(() => toPath(appliedSeries, chartW, chartH), [appliedSeries]);
|
||||||
const chartH = 260;
|
const pResponses = useMemo(() => toPath(responseSeries, chartW, chartH), [responseSeries]);
|
||||||
|
|
||||||
const appliedSeries = useMemo(() => analytics.map((x) => x.applied), [analytics]);
|
const appliedColor = theme.palette.success.main;
|
||||||
const responseSeries = useMemo(() => analytics.map((x) => x.responses), [analytics]);
|
const responsesColor = theme.palette.info.main;
|
||||||
|
|
||||||
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 tagColors = useMemo(() => {
|
const tagColors = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
@@ -124,278 +189,428 @@ export default function DashboardView() {
|
|||||||
theme.palette.warning.main,
|
theme.palette.warning.main,
|
||||||
theme.palette.info.main,
|
theme.palette.info.main,
|
||||||
theme.palette.error.main,
|
theme.palette.error.main,
|
||||||
alpha("#a855f7", 0.9),
|
|
||||||
alpha("#f97316", 0.9),
|
alpha("#f97316", 0.9),
|
||||||
alpha("#14b8a6", 0.9),
|
alpha("#14b8a6", 0.9),
|
||||||
|
alpha("#a855f7", 0.9),
|
||||||
alpha("#64748b", 0.9),
|
alpha("#64748b", 0.9),
|
||||||
alpha("#0ea5e9", 0.9),
|
alpha("#0ea5e9", 0.9),
|
||||||
];
|
];
|
||||||
}, [theme.palette]);
|
}, [theme.palette]);
|
||||||
|
|
||||||
const tagTotal = useMemo(() => tags.reduce((acc, t) => acc + (t.count || 0), 0), [tags]);
|
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 }) => (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
minHeight: 118,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="overline" sx={{ color: "text.secondary", lineHeight: 1.4 }}>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h4" sx={{ mt: 0.5, fontWeight: 950, lineHeight: 1.1 }}>
|
||||||
|
{value}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.75 }}>
|
||||||
|
{sub}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
|
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
|
||||||
<Tab label="Overview" />
|
<Tab label="Overview" />
|
||||||
<Tab label="User Behavior" disabled />
|
<Tab label="Pipeline" />
|
||||||
<Tab label="Performance" disabled />
|
<Tab label="Summarizer" />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<Paper sx={{ p: 2.25 }}>
|
{tab === 0 ? (
|
||||||
<Box
|
<>
|
||||||
sx={{
|
<Paper sx={{ p: 0.5 }}>
|
||||||
display: "grid",
|
<Box
|
||||||
gridTemplateColumns: { xs: "1fr", md: "repeat(4, 1fr)" },
|
sx={{
|
||||||
gap: { xs: 2, md: 0 },
|
display: "grid",
|
||||||
}}
|
gridTemplateColumns: { xs: "1fr", sm: "repeat(2, 1fr)", xl: "repeat(5, 1fr)" },
|
||||||
>
|
}}
|
||||||
{metricCards.map((m, idx) => (
|
>
|
||||||
<Box key={m.label} sx={{ px: { xs: 0, md: 2 } }}>
|
{metricCards.map((m, idx) => (
|
||||||
{idx > 0 ? (
|
<Box
|
||||||
<Divider
|
key={m.label}
|
||||||
orientation="vertical"
|
sx={{
|
||||||
flexItem
|
borderLeft: { xs: "none", xl: idx === 0 ? "none" : `1px solid ${theme.palette.divider}` },
|
||||||
sx={{ display: { xs: "none", md: "block" }, position: "absolute", height: 68, mt: 1.5 }}
|
borderTop: {
|
||||||
/>
|
xs: idx === 0 ? "none" : `1px solid ${theme.palette.divider}`,
|
||||||
) : null}
|
sm: idx < 2 ? "none" : `1px solid ${theme.palette.divider}`,
|
||||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>
|
xl: "none",
|
||||||
{m.label}
|
},
|
||||||
</Typography>
|
|
||||||
<Typography variant="h4" sx={{ mt: 0.25 }}>
|
|
||||||
{m.value}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
|
||||||
{m.sub}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Paper sx={{ mt: 2, p: 2.25 }}>
|
|
||||||
<Box sx={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h6" sx={{ fontWeight: 950 }}>
|
|
||||||
Analysis
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
|
||||||
Monthly applied vs responses.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ display: "flex", flexWrap: "wrap", alignItems: "center", justifyContent: "flex-end", gap: 1 }}>
|
|
||||||
<ButtonGroup size="small" variant="outlined">
|
|
||||||
{([6, 12, 24] as const).map((m) => (
|
|
||||||
<Button
|
|
||||||
key={m}
|
|
||||||
variant={rangeMode === "preset" && months === m ? "contained" : "outlined"}
|
|
||||||
onClick={() => {
|
|
||||||
setRangeMode("preset");
|
|
||||||
setMonths(m);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{m} mo
|
<StatCard label={m.label} value={m.value} sub={m.sub} />
|
||||||
</Button>
|
</Box>
|
||||||
))}
|
|
||||||
<Button
|
|
||||||
variant={rangeMode === "custom" ? "contained" : "outlined"}
|
|
||||||
onClick={() => {
|
|
||||||
setRangeMode("custom");
|
|
||||||
setAppliedCustom({ from: fromMonth, to: toMonth });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Custom
|
|
||||||
</Button>
|
|
||||||
</ButtonGroup>
|
|
||||||
|
|
||||||
{rangeMode === "custom" ? (
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
|
||||||
<TextField
|
|
||||||
size="small"
|
|
||||||
label="From"
|
|
||||||
type="month"
|
|
||||||
value={fromMonth}
|
|
||||||
onChange={(e) => setFromMonth(e.target.value)}
|
|
||||||
sx={{ width: 150 }}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
size="small"
|
|
||||||
label="To"
|
|
||||||
type="month"
|
|
||||||
value={toMonth}
|
|
||||||
onChange={(e) => setToMonth(e.target.value)}
|
|
||||||
sx={{ width: 150 }}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="contained"
|
|
||||||
onClick={() => setAppliedCustom({ from: fromMonth, to: toMonth })}
|
|
||||||
>
|
|
||||||
Apply
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ mt: 2, overflowX: "auto" }}>
|
|
||||||
<Box sx={{ minWidth: chartW }}>
|
|
||||||
<svg width={chartW} height={chartH} viewBox={`0 0 ${chartW} ${chartH}`}>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="fillA" x1="0" x2="0" y1="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor={alpha(c1, 0.45)} />
|
|
||||||
<stop offset="100%" stopColor={alpha(c1, 0.02)} />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="fillB" x1="0" x2="0" y1="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor={alpha(c2, 0.35)} />
|
|
||||||
<stop offset="100%" stopColor={alpha(c2, 0.02)} />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
{p2 ? (
|
|
||||||
<>
|
|
||||||
<path d={`${p2} L ${chartW} ${chartH} L 0 ${chartH} Z`} fill="url(#fillB)" />
|
|
||||||
<path d={p2} fill="none" stroke={alpha(c2, 0.85)} strokeWidth="2" />
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{p1 ? (
|
|
||||||
<>
|
|
||||||
<path d={`${p1} L ${chartW} ${chartH} L 0 ${chartH} Z`} fill="url(#fillA)" />
|
|
||||||
<path d={p1} fill="none" stroke={alpha(c1, 0.95)} strokeWidth="2.5" />
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<Box sx={{ display: "flex", justifyContent: "space-between", mt: 1, color: "text.secondary" }}>
|
|
||||||
{analytics.map((p) => (
|
|
||||||
<Typography key={p.month} variant="caption" sx={{ width: `${100 / Math.max(1, analytics.length)}%`, textAlign: "center" }}>
|
|
||||||
{p.month.slice(5)}
|
|
||||||
</Typography>
|
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Paper>
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Paper sx={{ mt: 2, p: 2.25 }}>
|
<Paper sx={{ mt: 2, p: 2.25 }}>
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1.2fr 0.8fr" }, gap: 2.5 }}>
|
<Box sx={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>
|
<Typography variant="h6" sx={{ fontWeight: 950 }}>
|
||||||
Status breakdown
|
Application activity
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
{statusRows.length === 0 ? (
|
Color-coded monthly trend for applications and responses.
|
||||||
<Typography sx={{ color: "text.secondary" }}>No data yet.</Typography>
|
</Typography>
|
||||||
) : (
|
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
|
||||||
{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 (
|
|
||||||
<Box key={status} sx={{ display: "grid", gridTemplateColumns: "160px 1fr 60px", gap: 1, alignItems: "center" }}>
|
|
||||||
<Typography sx={{ fontWeight: 850 }}>{status}</Typography>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
height: 10,
|
|
||||||
borderRadius: 999,
|
|
||||||
background: theme.palette.mode === "dark" ? alpha("#94a3b8", 0.16) : alpha("#0f172a", 0.08),
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: `${w}%`,
|
|
||||||
height: "100%",
|
|
||||||
background: `linear-gradient(90deg, ${alpha(tone, 0.75)}, ${alpha(tone, 0.30)})`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Typography sx={{ textAlign: "right", fontWeight: 900 }}>{value}</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box>
|
<Box sx={{ display: "flex", flexWrap: "wrap", alignItems: "center", justifyContent: "flex-end", gap: 1 }}>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>
|
<ButtonGroup size="small" variant="outlined">
|
||||||
Top tags (skills)
|
{([6, 12, 24] as const).map((m) => (
|
||||||
</Typography>
|
<Button
|
||||||
|
key={m}
|
||||||
|
variant={rangeMode === "preset" && months === m ? "contained" : "outlined"}
|
||||||
|
onClick={() => {
|
||||||
|
setRangeMode("preset");
|
||||||
|
setMonths(m);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{m} mo
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant={rangeMode === "custom" ? "contained" : "outlined"}
|
||||||
|
onClick={() => {
|
||||||
|
setRangeMode("custom");
|
||||||
|
setAppliedCustom({ from: fromMonth, to: toMonth });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Custom
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
|
||||||
{tags.length === 0 ? (
|
{rangeMode === "custom" ? (
|
||||||
<Typography sx={{ color: "text.secondary" }}>No tags yet.</Typography>
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||||
) : (
|
<TextField size="small" label="From" type="month" value={fromMonth} onChange={(e) => setFromMonth(e.target.value)} sx={{ width: 150 }} />
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: "132px 1fr", gap: 2, alignItems: "center" }}>
|
<TextField size="small" label="To" type="month" value={toMonth} onChange={(e) => setToMonth(e.target.value)} sx={{ width: 150 }} />
|
||||||
<Box sx={{ display: "flex", justifyContent: "center" }}>
|
<Button size="small" variant="contained" onClick={() => setAppliedCustom({ from: fromMonth, to: toMonth })}>
|
||||||
<svg width="132" height="132" viewBox="0 0 132 132">
|
Apply
|
||||||
<circle cx="66" cy="66" r="52" stroke={alpha(theme.palette.text.primary, 0.10)} strokeWidth="14" fill="none" />
|
</Button>
|
||||||
{(() => {
|
</Box>
|
||||||
const r = 52;
|
) : null}
|
||||||
const circ = 2 * Math.PI * r;
|
</Box>
|
||||||
let offset = 0;
|
</Box>
|
||||||
return tags.map((t, i) => {
|
|
||||||
const pct = tagTotal ? t.count / tagTotal : 0;
|
|
||||||
const len = circ * pct;
|
|
||||||
const el = (
|
|
||||||
<circle
|
|
||||||
key={t.tag}
|
|
||||||
cx="66"
|
|
||||||
cy="66"
|
|
||||||
r={r}
|
|
||||||
fill="none"
|
|
||||||
stroke={tagColors[i % tagColors.length]}
|
|
||||||
strokeWidth="14"
|
|
||||||
strokeDasharray={`${len} ${circ}`}
|
|
||||||
strokeDashoffset={-offset}
|
|
||||||
transform="rotate(-90 66 66)"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
offset += len;
|
|
||||||
return el;
|
|
||||||
});
|
|
||||||
})()}
|
|
||||||
<circle cx="66" cy="66" r="39" fill={theme.palette.background.paper} />
|
|
||||||
<text x="66" y="62" textAnchor="middle" fontSize="16" fontWeight="900" fill={theme.palette.text.primary}>
|
|
||||||
{tagTotal}
|
|
||||||
</text>
|
|
||||||
<text x="66" y="80" textAnchor="middle" fontSize="11" fill={alpha(theme.palette.text.primary, 0.65)}>
|
|
||||||
tags
|
|
||||||
</text>
|
|
||||||
</svg>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75, minWidth: 0 }}>
|
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", mt: 1.5 }}>
|
||||||
{tags.slice(0, 8).map((t, i) => (
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
<Box key={t.tag} sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1 }}>
|
<Box sx={{ width: 12, height: 12, borderRadius: 999, bgcolor: appliedColor }} />
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, minWidth: 0 }}>
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>Applied</Typography>
|
||||||
<Box sx={{ width: 10, height: 10, borderRadius: 999, bgcolor: tagColors[i % tagColors.length], flex: "0 0 auto" }} />
|
</Box>
|
||||||
<Typography variant="body2" noWrap sx={{ fontWeight: 700 }}>
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
{t.tag}
|
<Box sx={{ width: 12, height: 12, borderRadius: 999, bgcolor: responsesColor }} />
|
||||||
</Typography>
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>Responses</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 900, flex: "0 0 auto" }}>
|
</Box>
|
||||||
{t.count}
|
|
||||||
</Typography>
|
<Box sx={{ mt: 2, overflowX: "auto" }}>
|
||||||
</Box>
|
<Box sx={{ minWidth: chartW }}>
|
||||||
|
<svg width={chartW} height={chartH} viewBox={`0 0 ${chartW} ${chartH}`}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="fillApplied" x1="0" x2="0" y1="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor={alpha(appliedColor, 0.35)} />
|
||||||
|
<stop offset="100%" stopColor={alpha(appliedColor, 0.03)} />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="fillResponses" x1="0" x2="0" y1="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor={alpha(responsesColor, 0.28)} />
|
||||||
|
<stop offset="100%" stopColor={alpha(responsesColor, 0.03)} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{[0.25, 0.5, 0.75].map((tick) => (
|
||||||
|
<line
|
||||||
|
key={tick}
|
||||||
|
x1="0"
|
||||||
|
x2={chartW}
|
||||||
|
y1={Math.round(chartH * tick)}
|
||||||
|
y2={Math.round(chartH * tick)}
|
||||||
|
stroke={alpha(theme.palette.text.primary, 0.08)}
|
||||||
|
strokeDasharray="5 5"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{pResponses ? (
|
||||||
|
<>
|
||||||
|
<path d={`${pResponses} L ${chartW} ${chartH} L 0 ${chartH} Z`} fill="url(#fillResponses)" />
|
||||||
|
<path d={pResponses} fill="none" stroke={alpha(responsesColor, 0.95)} strokeWidth="2.5" />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{pApplied ? (
|
||||||
|
<>
|
||||||
|
<path d={`${pApplied} L ${chartW} ${chartH} L 0 ${chartH} Z`} fill="url(#fillApplied)" />
|
||||||
|
<path d={pApplied} fill="none" stroke={alpha(appliedColor, 0.95)} strokeWidth="2.5" />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "space-between", mt: 1, color: "text.secondary" }}>
|
||||||
|
{analytics.map((p) => (
|
||||||
|
<Typography key={p.month} variant="caption" sx={{ width: `${100 / Math.max(1, analytics.length)}%`, textAlign: "center" }}>
|
||||||
|
{p.month.slice(5)}
|
||||||
|
</Typography>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 2, display: "grid", gridTemplateColumns: { xs: "1fr", lg: "repeat(3, 1fr)" }, gap: 2 }}>
|
||||||
|
<Paper sx={{ p: 2.25 }}>
|
||||||
|
<Typography variant="overline" sx={{ color: "text.secondary" }}>Top skill</Typography>
|
||||||
|
<Typography variant="h5" sx={{ mt: 0.5, fontWeight: 950 }}>{topSkill?.tag ?? "No tags yet"}</Typography>
|
||||||
|
<Typography variant="body2" sx={{ mt: 1, color: "text.secondary" }}>
|
||||||
|
{topSkill ? `${topSkill.count} tagged jobs in the selected range.` : "Import or add tagged jobs to surface skill trends."}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
<Paper sx={{ p: 2.25 }}>
|
||||||
|
<Typography variant="overline" sx={{ color: "text.secondary" }}>Busiest month</Typography>
|
||||||
|
<Typography variant="h5" sx={{ mt: 0.5, fontWeight: 950 }}>{strongestMonth?.month ?? "-"}</Typography>
|
||||||
|
<Typography variant="body2" sx={{ mt: 1, color: "text.secondary" }}>
|
||||||
|
{strongestMonth ? `${strongestMonth.applied} applications logged in your strongest month.` : "No monthly application data yet."}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
<Paper sx={{ p: 2.25 }}>
|
||||||
|
<Typography variant="overline" sx={{ color: "text.secondary" }}>Top stage</Typography>
|
||||||
|
<Typography variant="h5" sx={{ mt: 0.5, fontWeight: 950 }}>{topStatus?.[0] ?? "No status data"}</Typography>
|
||||||
|
<Typography variant="body2" sx={{ mt: 1, color: "text.secondary" }}>
|
||||||
|
{topStatus ? `${topStatus[1]} applications are currently clustered in this stage.` : "Add a few jobs to reveal your pipeline shape."}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</>
|
||||||
</Paper>
|
) : null}
|
||||||
|
|
||||||
|
{tab === 1 ? (
|
||||||
|
<Paper sx={{ p: 2.25 }}>
|
||||||
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1.2fr 0.8fr" }, gap: 2.5 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>
|
||||||
|
Status breakdown
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{statusRows.length === 0 ? (
|
||||||
|
<Typography sx={{ color: "text.secondary" }}>No data yet.</Typography>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||||
|
{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 (
|
||||||
|
<Box key={status} sx={{ display: "grid", gridTemplateColumns: "160px 1fr 60px", gap: 1, alignItems: "center" }}>
|
||||||
|
<Typography sx={{ fontWeight: 850 }}>{status}</Typography>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
height: 10,
|
||||||
|
borderRadius: 999,
|
||||||
|
background: theme.palette.mode === "dark" ? alpha("#94a3b8", 0.16) : alpha("#0f172a", 0.08),
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: `${w}%`,
|
||||||
|
height: "100%",
|
||||||
|
background: `linear-gradient(90deg, ${alpha(tone, 0.85)}, ${alpha(tone, 0.32)})`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Typography sx={{ textAlign: "right", fontWeight: 900 }}>{value}</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, 1fr)" }, gap: 1.5, mt: 2.25 }}>
|
||||||
|
<Paper variant="outlined" sx={{ p: 1.75, borderRadius: 2.5 }}>
|
||||||
|
<Typography variant="overline" sx={{ color: "text.secondary" }}>Applications in range</Typography>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 950 }}>{totalAppliedInRange}</Typography>
|
||||||
|
</Paper>
|
||||||
|
<Paper variant="outlined" sx={{ p: 1.75, borderRadius: 2.5 }}>
|
||||||
|
<Typography variant="overline" sx={{ color: "text.secondary" }}>Tracked skills</Typography>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 950 }}>{tagTotal}</Typography>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>
|
||||||
|
Top skills
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{tags.length === 0 ? (
|
||||||
|
<Typography sx={{ color: "text.secondary" }}>No tags yet.</Typography>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: "grid", gridTemplateColumns: "132px 1fr", gap: 2, alignItems: "center" }}>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "center" }}>
|
||||||
|
<svg width="132" height="132" viewBox="0 0 132 132">
|
||||||
|
<circle cx="66" cy="66" r="52" stroke={alpha(theme.palette.text.primary, 0.10)} strokeWidth="14" fill="none" />
|
||||||
|
{(() => {
|
||||||
|
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 = (
|
||||||
|
<circle
|
||||||
|
key={t.tag}
|
||||||
|
cx="66"
|
||||||
|
cy="66"
|
||||||
|
r={r}
|
||||||
|
fill="none"
|
||||||
|
stroke={tagColors[i % tagColors.length]}
|
||||||
|
strokeWidth="14"
|
||||||
|
strokeDasharray={`${len} ${circ}`}
|
||||||
|
strokeDashoffset={-offset}
|
||||||
|
transform="rotate(-90 66 66)"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
offset += len;
|
||||||
|
return el;
|
||||||
|
});
|
||||||
|
})()}
|
||||||
|
<circle cx="66" cy="66" r="39" fill={theme.palette.background.paper} />
|
||||||
|
<text x="66" y="62" textAnchor="middle" fontSize="16" fontWeight="900" fill={theme.palette.text.primary}>
|
||||||
|
{tagTotal}
|
||||||
|
</text>
|
||||||
|
<text x="66" y="80" textAnchor="middle" fontSize="11" fill={alpha(theme.palette.text.primary, 0.65)}>
|
||||||
|
skill tags
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75, minWidth: 0 }}>
|
||||||
|
{tags.slice(0, 8).map((t, i) => (
|
||||||
|
<Box key={t.tag} sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1 }}>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1, minWidth: 0 }}>
|
||||||
|
<Box sx={{ width: 10, height: 10, borderRadius: 999, bgcolor: tagColors[i % tagColors.length], flex: "0 0 auto" }} />
|
||||||
|
<Typography variant="body2" noWrap sx={{ fontWeight: 700 }}>
|
||||||
|
{t.tag}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 900, flex: "0 0 auto" }}>
|
||||||
|
{t.count}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{tab === 2 ? (
|
||||||
|
<>
|
||||||
|
<Paper sx={{ p: 0.5 }}>
|
||||||
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, 1fr)", xl: "repeat(4, 1fr)" } }}>
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
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) => (
|
||||||
|
<Box key={m.label} sx={{ borderLeft: { xs: "none", xl: idx === 0 ? "none" : `1px solid ${theme.palette.divider}` }, borderTop: { xs: idx === 0 ? "none" : `1px solid ${theme.palette.divider}`, sm: idx < 2 ? "none" : `1px solid ${theme.palette.divider}`, xl: "none" } }}>
|
||||||
|
<StatCard label={m.label} value={m.value} sub={m.sub} />
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper sx={{ mt: 2, p: 2.25 }}>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>
|
||||||
|
Summarizer telemetry
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary", mb: 2 }}>
|
||||||
|
Useful for spotting slowdowns, cache misses, or service outages in the local summarizer app.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(2, 1fr)" }, gap: 2 }}>
|
||||||
|
<Paper variant="outlined" sx={{ p: 2, borderRadius: 2.5 }}>
|
||||||
|
<Typography variant="overline" sx={{ color: "text.secondary" }}>Requests</Typography>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 950, mb: 1.5 }}>{summarizerMetrics?.requests ?? 0}</Typography>
|
||||||
|
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 1 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>Hits</Typography>
|
||||||
|
<Typography sx={{ fontWeight: 900 }}>{summarizerMetrics?.cacheHits ?? 0}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>Misses</Typography>
|
||||||
|
<Typography sx={{ fontWeight: 900 }}>{summarizerMetrics?.cacheMisses ?? 0}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>Failures</Typography>
|
||||||
|
<Typography sx={{ fontWeight: 900 }}>{summarizerMetrics?.failures ?? 0}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper variant="outlined" sx={{ p: 2, borderRadius: 2.5 }}>
|
||||||
|
<Typography variant="overline" sx={{ color: "text.secondary" }}>Last activity</Typography>
|
||||||
|
<Box sx={{ mt: 1.25, display: "flex", flexDirection: "column", gap: 1 }}>
|
||||||
|
<Typography variant="body2"><strong>Last success:</strong> {formatRelative(summarizerMetrics?.lastSuccessAt)}</Typography>
|
||||||
|
<Typography variant="body2"><strong>Last failure:</strong> {formatRelative(summarizerMetrics?.lastFailureAt)}</Typography>
|
||||||
|
<Typography variant="body2"><strong>Model:</strong> {summarizerMetrics?.model || "Unknown"}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
|
||||||
|
<Typography variant="overline" sx={{ color: "text.secondary" }}>Last error</Typography>
|
||||||
|
<Typography variant="body2" sx={{ mt: 0.75, color: summarizerMetrics?.lastError ? "warning.main" : "text.secondary" }}>
|
||||||
|
{summarizerMetrics?.lastError || "No recent summarizer errors recorded."}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user