Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions web/src/components/shared/error-boundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// SPDX-FileCopyrightText: 2026 Vishnu Muthiah <vishnu.muthiah04@gmail.com>
// SPDX-License-Identifier: AGPL-3.0-only

import { Component, type ErrorInfo, type ReactNode } from "react";
import { AlertCircle, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";

interface Props {
children: ReactNode;
}

interface State {
hasError: boolean;
error: Error | null;
}

export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null };

static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}

componentDidCatch(error: Error, info: ErrorInfo) {
console.error("[ErrorBoundary]", error, info.componentStack);
}

render() {
if (this.state.hasError) {
return (
<div className="flex min-h-dvh items-center justify-center bg-surface-sunken p-6">
<div className="flex flex-col items-center gap-4 text-center">
<AlertCircle className="h-12 w-12 text-destructive/60" />
<h1 className="text-lg font-semibold">Something went wrong</h1>
<p className="max-w-md text-sm text-muted-foreground">
An unexpected error occurred. Please reload the page.
</p>
<Button
variant="outline"
onClick={() => window.location.reload()}
>
<RefreshCw className="mr-2 h-4 w-4" />
Reload page
</Button>
</div>
</div>
);
}

return this.props.children;
}
}
20 changes: 13 additions & 7 deletions web/src/hooks/use-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@

import { useEffect, useSyncExternalStore } from "react";
import { useRouter, useLocation } from "@tanstack/react-router";
import { auth, setUserRole, getUserRole, clearSession, refreshAccessToken } from "@/lib/api";
import { auth, setUserRole, getUserRole, clearSession, refreshAccessTokenWithReason } from "@/lib/api";

function isNetworkError(err: unknown): boolean {
return err instanceof TypeError || (typeof navigator !== "undefined" && !navigator.onLine);
}

function subscribe(cb: () => void) {
window.addEventListener("storage", cb);
Expand Down Expand Up @@ -44,15 +48,15 @@ export function useAuthGuard() {

// New tab: no access token but refresh token exists. Try silent refresh.
if (isRefreshing) {
refreshAccessToken().then((ok) => {
if (ok) {
// Token restored, trigger whoami to resolve role
refreshAccessTokenWithReason().then((result) => {
if (result === "ok") {
window.dispatchEvent(new Event("storage"));
} else {
} else if (result === "rejected") {
clearSession();
window.dispatchEvent(new Event("storage"));
router.navigate({ to: "/login", replace: true });
}
// "network_error": do nothing, leave session intact
});
return;
}
Expand All @@ -67,7 +71,8 @@ export function useAuthGuard() {
auth.whoami().then((user) => {
setUserRole(user.role);
window.dispatchEvent(new Event("storage"));
}).catch(() => {
}).catch((err) => {
if (isNetworkError(err)) return;
clearSession();
window.dispatchEvent(new Event("storage"));
router.navigate({ to: "/login", replace: true });
Expand Down Expand Up @@ -95,7 +100,8 @@ export function useOptionalAuth() {
auth.whoami().then((user) => {
setUserRole(user.role);
window.dispatchEvent(new Event("storage"));
}).catch(() => {
}).catch((err) => {
if (isNetworkError(err)) return;
clearSession();
window.dispatchEvent(new Event("storage"));
});
Expand Down
5 changes: 3 additions & 2 deletions web/src/hooks/use-deployment-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import { useQuery } from "@tanstack/react-query";
import { config, type PublicConfig } from "@/lib/api";

export function useDeploymentConfig() {
const { data, isLoading } = useQuery<PublicConfig>({
const { data, isLoading, isError } = useQuery<PublicConfig>({
queryKey: ["config", "public"],
queryFn: config.public,
staleTime: 5 * 60 * 1000, // cache for 5 minutes
retry: 1,
retry: 2,
});

return {
Expand All @@ -26,6 +26,7 @@ export function useDeploymentConfig() {
brandingAppName: data?.branding_app_name ?? null,
brandingWordmark: data?.branding_wordmark ?? null,
loading: isLoading,
configError: isError,
};
}

Expand Down
48 changes: 34 additions & 14 deletions web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,11 +160,13 @@ export function getUserAvatar(): string | null {
return localStorage.getItem(STORAGE_KEY_USER_AVATAR);
}

let _refreshPromise: Promise<boolean> | null = null;
let _refreshPromise: Promise<RefreshResult> | null = null;

async function _tryRefreshToken(): Promise<boolean> {
type RefreshResult = "ok" | "rejected" | "network_error";

async function _tryRefreshToken(): Promise<RefreshResult> {
const refreshToken = getRefreshToken();
if (!refreshToken) return false;
if (!refreshToken) return "rejected";

try {
const res = await fetch(`${API}/auth/token/refresh`, {
Expand All @@ -173,13 +175,13 @@ async function _tryRefreshToken(): Promise<boolean> {
body: JSON.stringify({ refresh_token: refreshToken }),
});

if (!res.ok) return false;
if (!res.ok) return "rejected";

const data = await res.json();
setTokens(data.access_token, data.refresh_token);
return true;
return "ok";
} catch {
return false;
return "network_error";
}
}

Expand All @@ -188,6 +190,11 @@ async function _tryRefreshToken(): Promise<boolean> {
* Returns true if the access token was restored successfully.
*/
export async function refreshAccessToken(): Promise<boolean> {
const result = await _tryRefreshToken();
return result === "ok";
}

export async function refreshAccessTokenWithReason(): Promise<RefreshResult> {
return _tryRefreshToken();
}

Expand Down Expand Up @@ -225,9 +232,9 @@ async function request<T = unknown>(
_refreshPromise = null;
});
}
const refreshed = await _refreshPromise;
const refreshResult = await _refreshPromise;

if (refreshed) {
if (refreshResult === "ok") {
// Retry the original request with new token
const newToken = getAccessToken();
if (newToken) headers["Authorization"] = `Bearer ${newToken}`;
Expand All @@ -241,15 +248,17 @@ async function request<T = unknown>(
if (retryRes.status === 204) return undefined as T;
return retryRes.json() as Promise<T>;
}
// Retry failed but refresh succeeded, so the individual call
// fails gracefully without killing the session
const retryText = await retryRes.text().catch(() => "Request failed");
const retryErr = new Error(retryText);
(retryErr as Error & { status: number }).status = retryRes.status;
throw retryErr;
}

// Refresh itself failed: session is truly expired
if (refreshResult === "network_error") {
throw new Error("Network unavailable");
}

// Real rejection: session is truly expired
clearSession();
if (typeof window !== "undefined") {
window.location.href = "/login?reason=session_expired";
Expand Down Expand Up @@ -290,7 +299,9 @@ async function request<T = unknown>(
: JSON.stringify(parsed.error);
}
} catch {
// not JSON, use raw text
if (detail.length > 200 || detail.includes("Traceback") || detail.includes("Error:")) {
detail = `Request failed (${response.status})`;
}
}
}
const err = new Error(detail);
Expand Down Expand Up @@ -917,10 +928,19 @@ export const insights = {
selection ?? {},
),
exportHtml: async (agentId: string, reportId: string): Promise<void> => {
const token = getAccessToken();
const res = await fetch(`${API}/agents/${agentId}/insights/reports/${reportId}/export/html`, {
let token = getAccessToken();
let res = await fetch(`${API}/agents/${agentId}/insights/reports/${reportId}/export/html`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (res.status === 401) {
const refreshed = await _tryRefreshToken();
if (refreshed) {
token = getAccessToken();
res = await fetch(`${API}/agents/${agentId}/insights/reports/${reportId}/export/html`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
}
}
if (!res.ok) throw new Error("Export failed");
const blob = await res.blob();
const url = URL.createObjectURL(blob);
Expand Down
3 changes: 2 additions & 1 deletion web/src/pages/admin/audit-log.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@


import { useState, useMemo, useCallback, useRef } from "react";
import { toast } from "sonner";
import { useSearch, useLocation } from "@tanstack/react-router";
import {
ScrollText,
Expand Down Expand Up @@ -230,7 +231,7 @@ export default function AuditLogPage() {
a.click();
URL.revokeObjectURL(url);
} catch {
// export failed
toast.error("Failed to export audit log. Please try again.");
}
}, [searchQuery]);

Expand Down
12 changes: 10 additions & 2 deletions web/src/pages/admin/dashboard/components/adoption-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,19 @@ import { DashboardRangeContext } from "../context";

export function AdoptionTab() {
const range = useContext(DashboardRangeContext);
const { data: adoption, isLoading: adoptionLoading } = useExecAdoption();
const { data: agents, isLoading: agentsLoading } = useExecAgentCounts();
const { data: adoption, isLoading: adoptionLoading, isError: adoptionError } = useExecAdoption();
const { data: agents, isLoading: agentsLoading, isError: agentsError } = useExecAgentCounts();
const { data: usage } = useExecUsageByCategory(range);
const { data: platforms } = useExecPlatformCoverage();

if (adoptionError || agentsError) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<p className="text-sm text-muted-foreground">Failed to load adoption data. Check your connection and try again.</p>
</div>
);
}

if (adoptionLoading || agentsLoading) {
return (
<div className="space-y-6 pt-4">
Expand Down
13 changes: 11 additions & 2 deletions web/src/pages/admin/dashboard/components/cost-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useExecCostSummary, useExecROIProjections } from "@/hooks/use-api";
import { exec } from "@/lib/api";
import { StatCard } from "./stat-card";
import { Loader2, TrendingUp } from "lucide-react";
import { toast } from "sonner";
import { DashboardRangeContext } from "../context";

const DEFAULT_CATEGORIES = [
Expand Down Expand Up @@ -41,7 +42,7 @@ function BaselinesConfigForm({ onSaved }: { onSaved: () => void }) {
});
onSaved();
} catch {
// error handled by React Query elsewhere
toast.error("Failed to save baselines. Please try again.");
} finally {
setSaving(false);
}
Expand Down Expand Up @@ -207,9 +208,17 @@ function ROIProjections() {

export function CostTab() {
const range = useContext(DashboardRangeContext);
const { data: cost, isLoading, refetch } = useExecCostSummary(range);
const { data: cost, isLoading, isError, refetch } = useExecCostSummary(range);
const [showEditBaselines, setShowEditBaselines] = useState(false);

if (isError) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<p className="text-sm text-muted-foreground">Failed to load cost data. Check your connection and try again.</p>
</div>
);
}

if (isLoading) {
return (
<div className="space-y-6 pt-4">
Expand Down
10 changes: 9 additions & 1 deletion web/src/pages/admin/dashboard/components/departments-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,17 @@ import { DashboardRangeContext } from "../context";

export function DepartmentsTab() {
const range = useContext(DashboardRangeContext);
const { data: depts, isLoading: deptsLoading } = useExecDepartments(range);
const { data: depts, isLoading: deptsLoading, isError: deptsError } = useExecDepartments(range);
const { data: tokens, isLoading: tokensLoading } = useExecDeptTokens(range);

if (deptsError) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<p className="text-sm text-muted-foreground">Failed to load department data. Check your connection and try again.</p>
</div>
);
}

if (deptsLoading) {
return (
<div className="space-y-6 pt-4">
Expand Down
10 changes: 9 additions & 1 deletion web/src/pages/admin/dashboard/components/insights-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,15 @@ function DeveloperBreakdown() {
}

export function InsightsTab() {
const { data: insights, isLoading } = useExecAIInsights();
const { data: insights, isLoading, isError } = useExecAIInsights();

if (isError) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<p className="text-sm text-muted-foreground">Failed to load insights. Check your connection and try again.</p>
</div>
);
}

if (isLoading) {
return (
Expand Down
10 changes: 9 additions & 1 deletion web/src/pages/admin/dashboard/components/investments-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,17 @@ function deriveRadarData(p: ExecPlatformScore, best: { latency: number; cost: nu
}

export function InvestmentsTab() {
const { data: platforms, isLoading } = useExecPlatforms();
const { data: platforms, isLoading, isError } = useExecPlatforms();
const [selected, setSelected] = useState(0);

if (isError) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<p className="text-sm text-muted-foreground">Failed to load platform data. Check your connection and try again.</p>
</div>
);
}

if (isLoading) {
return (
<div className="space-y-6 pt-4">
Expand Down
10 changes: 9 additions & 1 deletion web/src/pages/admin/dashboard/components/velocity-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,17 @@ function Sparkline({ data }: { data: number[] }) {
}

export function VelocityTab() {
const { data: velocity, isLoading: velLoading } = useExecVelocity();
const { data: velocity, isLoading: velLoading, isError: velError } = useExecVelocity();
const { data: topAgents, isLoading: agentsLoading } = useExecTopAgents(10);

if (velError) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<p className="text-sm text-muted-foreground">Failed to load velocity data. Check your connection and try again.</p>
</div>
);
}

if (velLoading) {
return (
<div className="space-y-6 pt-4">
Expand Down
Loading
Loading