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
@@ -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";
@@ -11,7 +11,14 @@ type MeResponse = {
id?: string;
email?: string;
userName?: string;
firstName?: string;
lastName?: string;
displayName?: string;
roles?: string[];
googleLink?: {
linked: boolean;
email?: string | null;
} | null;
};
export default function AuthStatusCard() {
@@ -30,6 +37,8 @@ export default function AuthStatusCard() {
.catch(() => setMe(null));
}, [token]);
const label = useMemo(() => me?.displayName || [me?.firstName, me?.lastName].filter(Boolean).join(" ") || me?.email, [me]);
return (
<Paper sx={{ mt: 2, p: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
@@ -41,13 +50,18 @@ export default function AuthStatusCard() {
) : (
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
<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>
{me?.roles && me.roles.length > 0 ? (
<Typography sx={{ color: "text.secondary" }}>
Roles: {me.roles.join(", ")}
</Typography>
) : null}
{me?.googleLink?.linked ? (
<Typography sx={{ color: "text.secondary" }}>
Google linked{me.googleLink.email ? `: ${me.googleLink.email}` : "."}
</Typography>
) : null}
<Box sx={{ mt: 1 }}>
<Button
@@ -66,4 +80,3 @@ export default function AuthStatusCard() {
</Paper>
);
}
@@ -34,6 +34,11 @@ type SummarizerMetrics = {
healthy: boolean;
model?: string | null;
healthLatencyMs?: number | null;
probeLatencyMs?: number | null;
lastProbeAt?: string | null;
lastProbeSuccessAt?: string | null;
lastProbeFailureAt?: string | null;
probeFailures: number;
requests: number;
cacheHits: number;
cacheMisses: number;
@@ -408,7 +413,7 @@ export default function DashboardView() {
{tab === 2 ? (
<Paper sx={{ p: 2.25 }}>
<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>
<Paper variant="outlined" sx={{ p: 2, mt: 2 }}>
<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 misses:</strong> {summarizerMetrics?.cacheMisses ?? 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" sx={{ mt: 1, color: summarizerMetrics?.lastError ? "warning.main" : "text.secondary" }}>{summarizerMetrics?.lastError || "No recent summarizer errors recorded."}</Typography>
</Paper>
@@ -424,3 +430,6 @@ export default function DashboardView() {
</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 { api } from "../api";
import { clearAuthToken, decodeJwtPayload, getAuthToken, setAuthToken } from "../auth";
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> {
return new Promise((resolve, reject) => {
if (window.google?.accounts?.id) return resolve();
@@ -33,74 +47,177 @@ function loadGoogleScript(): Promise<void> {
export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void }) {
const { toast } = useToast();
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 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(() => {
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()
.then(() => {
if (!window.google?.accounts?.id) return;
if (!window.google?.accounts?.id || !hostRef.current) return;
hostRef.current.innerHTML = "";
window.google.accounts.id.initialize({
client_id: clientId,
callback: (resp: any) => {
if (resp?.credential) {
setAuthToken(resp.credential);
setToken(resp.credential);
toast("Signed in.", "success");
onSignedIn?.();
callback: async (resp: any) => {
const credential = resp?.credential as string | undefined;
if (!credential) return;
setWorking(true);
try {
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",
size: "large",
type: "standard",
shape: "pill",
text: me?.provider === "local" ? "continue_with" : "signin_with",
});
})
.catch(() => toast("Google auth script failed to load.", "error"));
}, [clientId, onSignedIn, toast]);
}, [clientId, me?.provider, me?.googleLink?.linked, onSignedIn, isRawGoogleToken, token, toast]);
return (
<Paper sx={{ mt: 2, p: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
Authentication (Google)
Google account
</Typography>
{!clientId && (
<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>
)}
{clientId && (
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
<div id="gsi-btn" />
{token ? (
<>
<Typography sx={{ color: "text.secondary" }}>
Signed in{email ? ` as ${email}` : ""}.
</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
{!token ? (
<Typography sx={{ color: "text.secondary" }}>
Sign in with a Google account that has already been linked to your Job Tracker user.
</Typography>
) : me?.provider === "local" ? (
<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
variant="outlined"
onClick={() => {
clearAuthToken();
setToken(null);
setMe(null);
toast("Signed out.", "info");
}}
>
Sign out
</Button>
</>
) : (
<Typography sx={{ color: "text.secondary" }}>
Sign in to unlock API access.
) : null}
{me?.provider === "local" && me.googleLink?.linked ? (
<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>
)}
) : null}
</Box>
)}
</Paper>
+18 -2
View File
@@ -8,6 +8,11 @@ type SummarizerMetrics = {
healthy: boolean;
model?: string | null;
healthLatencyMs?: number | null;
probeLatencyMs?: number | null;
lastProbeAt?: string | null;
lastProbeSuccessAt?: string | null;
lastProbeFailureAt?: string | null;
probeFailures: number;
requests: number;
cacheHits: number;
cacheMisses: number;
@@ -107,7 +112,13 @@ export default function AdminSystemPage() {
<Paper sx={{ p: 2 }}>
<Typography variant="overline" sx={{ color: "text.secondary" }}>Summarizer</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>
</Box>
@@ -134,7 +145,7 @@ export default function AdminSystemPage() {
<Paper sx={{ p: 2 }}>
<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>
<Typography variant="overline" sx={{ color: "text.secondary" }}>Requests</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="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.averageLatencyMs != null ? `${status.summarizer.averageLatencyMs} ms` : "-"}</Typography>
</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>
<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}
</Paper>
</Box>
+63 -38
View File
@@ -1,6 +1,6 @@
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 { useToast } from "../toast";
@@ -10,15 +10,26 @@ type MeResponse = {
id?: string;
email?: string;
userName?: string;
firstName?: string;
lastName?: string;
displayName?: string;
roles?: string[];
googleLink?: {
linked: boolean;
email?: string | null;
linkedAt?: string | null;
} | null;
};
function initialsFrom(s?: string) {
const v = (s ?? "").trim();
if (!v) return "?";
const parts = v.split(/[\s@._-]+/).filter(Boolean);
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[1][0]).toUpperCase();
function initialsFrom(values: Array<string | undefined>) {
const joined = values.map((x) => (x ?? "").trim()).filter(Boolean);
if (joined.length === 0) return "?";
if (joined.length === 1) {
const parts = joined[0].split(/[\s@._-]+/).filter(Boolean);
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() {
@@ -28,62 +39,76 @@ export default function ProfilePage() {
const [email, setEmail] = useState("");
const [userName, setUserName] = useState("");
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [displayName, setDisplayName] = useState("");
const [currentPassword, setCurrentPassword] = 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(() => {
api
.get<MeResponse>("/auth/me")
.then((r) => {
setMe(r.data);
setEmail(r.data?.email ?? "");
setUserName(r.data?.userName ?? "");
})
.catch(() => setMe(null));
void loadProfile();
}, []);
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 fullName = [me?.firstName, me?.lastName].filter(Boolean).join(" ");
return (
<Paper sx={{ mt: 0, p: 2 }}>
<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>
<Typography variant="h5" sx={{ fontWeight: 900 }}>
Profile
</Typography>
<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>
</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" }}>
<Typography variant="h6">Account</Typography>
{!isLocal ? (
<Typography sx={{ color: "text.secondary" }}>
This account is authenticated via Google; profile updates are read-only in this build.
</Typography>
<Alert severity="info" sx={{ mt: 1 }}>
This session is not using a local app token, so profile edits are read-only right now.
</Alert>
) : null}
</Box>
<TextField
label="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={!isLocal}
fullWidth
/>
<TextField
label="Username"
value={userName}
onChange={(e) => setUserName(e.target.value)}
disabled={!isLocal}
fullWidth
/>
<TextField label="Display name" value={displayName} onChange={(e) => setDisplayName(e.target.value)} disabled={!isLocal} fullWidth />
<TextField label="Username" value={userName} onChange={(e) => setUserName(e.target.value)} disabled={!isLocal} fullWidth />
<TextField label="First name" value={firstName} onChange={(e) => setFirstName(e.target.value)} disabled={!isLocal} fullWidth />
<TextField label="Last name" value={lastName} onChange={(e) => setLastName(e.target.value)} disabled={!isLocal} fullWidth />
<TextField label="Email" value={email} onChange={(e) => setEmail(e.target.value)} disabled={!isLocal} fullWidth sx={{ gridColumn: "1 / -1" }} />
<Box sx={{ gridColumn: "1 / -1" }}>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
Google account: {me?.googleLink?.linked ? `Linked${me.googleLink.email ? ` to ${me.googleLink.email}` : ""}` : "Not linked"}
</Typography>
</Box>
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end" }}>
<Button
@@ -92,7 +117,8 @@ export default function ProfilePage() {
onClick={async () => {
setLoading(true);
try {
await api.put("/auth/profile", { email, userName });
await api.put("/auth/profile", { email, userName, firstName, lastName, displayName });
await loadProfile();
toast("Profile updated.", "success");
} catch (e: any) {
const msg = e?.response?.data || e?.message || "Failed to update profile.";
@@ -161,4 +187,3 @@ export default function ProfilePage() {
</Paper>
);
}