Add full profiles and latency tests
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user