Fix #767: Harden workspaces against silent error swallowing and 207 statuses

Ensures network errors, API failures, and 207 Partial Success statuses are surfaced correctly to the user instead of failing silently in the frontend UI.
This commit is contained in:
Sameer6305
2026-07-24 00:16:46 +05:30
parent b473e0bd8a
commit d6c7154fa9
12 changed files with 187 additions and 67 deletions
@@ -92,6 +92,7 @@ export function DecisionWorkspace() {
const [chainLoading, setChainLoading] = useState(false);
const [listLoading, setListLoading] = useState(true);
const [filter, setFilter] = useState("");
const [error, setError] = useState("");
// Tracks the active chain request so stale responses from rapid selections are ignored.
const chainCtrlRef = useRef<AbortController | null>(null);
@@ -100,13 +101,22 @@ export function DecisionWorkspace() {
const ctrl = new AbortController();
setListLoading(true);
fetch("/api/decisions", { signal: ctrl.signal })
.then((r) => r.ok ? r.json() : Promise.reject(r.status))
.then(async (r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data = await r.json();
if (r.status === 207) setError(data.message || "Warning: Partial success loading decisions.");
return data;
})
.then((data) => {
if (ctrl.signal.aborted) return;
setDecisions(data);
if (data.length > 0) void loadChain(data[0]);
})
.catch((e) => { if (e?.name !== "AbortError") console.error(e); })
.catch((e) => {
if (e?.name !== "AbortError") {
setError(e instanceof Error ? e.message : "Failed to load decisions.");
}
})
.finally(() => {
if (!ctrl.signal.aborted) setListLoading(false);
});
@@ -127,11 +137,16 @@ export function DecisionWorkspace() {
setChain([]);
try {
const res = await fetch(`/api/decisions/${encodeURIComponent(d.decision_id)}/chain`, { signal: ctrl.signal });
if (!res.ok) throw new Error(`${res.status}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (res.status === 207 && !ctrl.signal.aborted) {
setError(data.message || "Warning: Partial success loading chain.");
}
if (!ctrl.signal.aborted) setChain(data.chain || []);
} catch (e) {
if (e instanceof Error && e.name !== "AbortError") console.error(e);
if (e instanceof Error && e.name !== "AbortError") {
setError(e.message);
}
} finally {
if (!ctrl.signal.aborted) setChainLoading(false);
}
@@ -213,6 +228,12 @@ export function DecisionWorkspace() {
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", position: "relative" }}>
<div style={{ position: "absolute", inset: 0, background: "radial-gradient(ellipse 60% 40% at 70% 20%, rgba(74,163,255,0.04), transparent 55%)", pointerEvents: "none" }} />
{error ? (
<div style={{ padding: 12, borderRadius: 14, color: "#ffb4c2", background: "rgba(255,157,175,0.1)", border: "1px solid rgba(255,157,175,0.18)", margin: "16px 16px 0 16px", zIndex: 2, position: "relative" }}>
{error}
</div>
) : null}
{selected ? (
<div className="ws-scroll ws-padded ws-animate-in" style={{ position: "relative", zIndex: 1 }}>
{/* Decision header */}
@@ -192,6 +192,7 @@ export function EntityResolutionTab() {
}, [threshold]);
const handleMerge = useCallback(async (primaryId: string, duplicateId: string) => {
setScanError("");
try {
const res = await fetch("/api/enrich/merge", {
method: "POST",
@@ -200,6 +201,9 @@ export function EntityResolutionTab() {
});
if (!res.ok) throw new Error(`Merge failed (${res.status})`);
const data = await res.json();
if (res.status === 207) {
setScanError(data.message || "Warning: Partial merge.");
}
logEvent("merge", `Merged ${duplicateId}${primaryId} · ${data.edges_updated ?? 0} edges redirected`, {
primary: primaryId,
duplicate: duplicateId,
@@ -207,7 +211,7 @@ export function EntityResolutionTab() {
});
setPairs((prev) => prev.filter((p) => !(p.a.id === primaryId && p.b.id === duplicateId)));
} catch (err) {
console.error("[EntityResolution] merge failed", err);
setScanError(err instanceof Error ? err.message : "Merge failed");
}
}, []);
@@ -33,6 +33,7 @@ export function LineageDiagram() {
const [edges, setEdges] = useState<any[]>([]);
const [searchId, setSearchId] = useState("");
const [activeId, setActiveId] = useState("");
const [error, setError] = useState("");
const downloadReport = async (format: "json" | "markdown") => {
if (!activeId) return;
@@ -70,17 +71,18 @@ export function LineageDiagram() {
if (!res.ok) {
const text = await res.text();
console.error(`HTTP ${res.status}: API Route missing or failed.`, text.substring(0, 100));
return;
throw new Error(`HTTP ${res.status}: API Route missing or failed. ${text.substring(0, 100)}`);
}
const contentType = res.headers.get("content-type");
if (!contentType || !contentType.includes("application/json")) {
console.error("Backend returned non-JSON response (likely an HTML fallback). Check FastAPI routing.");
return;
throw new Error("Backend returned non-JSON response (likely an HTML fallback).");
}
const data = await res.json();
if (res.status === 207) {
setError(data.message || "Warning: Partial success loading lineage.");
}
const counters: Record<string, number> = { "group_agent": 0, "group_activity": 0, "group_entity": 0 };
@@ -109,7 +111,7 @@ export function LineageDiagram() {
setNodes([...xLanes, ...mappedNodes]);
setEdges(mappedEdges);
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Failed to load lineage.");
}
};
fetchLineage();
@@ -143,6 +145,12 @@ export function LineageDiagram() {
</button>
</div>
{error ? (
<div style={{ position: "absolute", top: 60, left: 14, right: 14, zIndex: 10, padding: 12, borderRadius: 14, color: "#ffb4c2", background: "rgba(255,157,175,0.1)", border: "1px solid rgba(255,157,175,0.18)" }}>
{error}
</div>
) : null}
{activeId ? (
<ReactFlow nodes={nodes} edges={edges} fitView>
<Background color="rgba(74,163,255,0.08)" gap={24} />
@@ -81,35 +81,40 @@ export function KGOverviewTab() {
fetch("/api/graph/nodes?limit=500"),
]);
if (statsRes.ok) {
const statsData: KGStats = await statsRes.json();
setStats(statsData);
if (!statsRes.ok) throw new Error(`Stats fetch failed (${statsRes.status})`);
if (!nodesRes.ok) throw new Error(`Nodes fetch failed (${nodesRes.status})`);
const statsData: KGStats = await statsRes.json();
setStats(statsData);
if (statsRes.status === 207) {
setError((statsData as any).message || "Warning: Partial success loading stats.");
}
if (nodesRes.ok) {
const nodesData: NodeListResponse = await nodesRes.json();
const nodes = nodesData.nodes ?? [];
setNodeTypeMap(buildTypeMap(nodes, "type"));
const nodesData: NodeListResponse = await nodesRes.json();
const nodes = nodesData.nodes ?? [];
setNodeTypeMap(buildTypeMap(nodes, "type"));
if (nodesRes.status === 207 && statsRes.status !== 207) { // Only overwrite error if not already set, or just append
setError((prev) => prev ? prev + " " + ((nodesData as any).message || "") : ((nodesData as any).message || "Warning: Partial success loading nodes."));
}
// Simulate neighbor counts via edges fetch for top-N
const edgesRes = await fetch("/api/graph/edges?limit=2000");
if (edgesRes.ok) {
const edgesData = await edgesRes.json();
const edges: { source: string; target: string }[] = edgesData.edges ?? [];
const degreeMap: Record<string, number> = {};
for (const edge of edges) {
degreeMap[edge.source] = (degreeMap[edge.source] ?? 0) + 1;
degreeMap[edge.target] = (degreeMap[edge.target] ?? 0) + 1;
}
const sorted = nodes
.map((n) => ({ node: n, neighborCount: degreeMap[n.id] ?? 0 }))
.sort((a, b) => b.neighborCount - a.neighborCount)
.slice(0, 10);
setTopNodes(sorted);
// Simulate neighbor counts via edges fetch for top-N
const edgesRes = await fetch("/api/graph/edges?limit=2000");
if (edgesRes.ok) {
const edgesData = await edgesRes.json();
const edges: { source: string; target: string }[] = edgesData.edges ?? [];
const degreeMap: Record<string, number> = {};
for (const edge of edges) {
degreeMap[edge.source] = (degreeMap[edge.source] ?? 0) + 1;
degreeMap[edge.target] = (degreeMap[edge.target] ?? 0) + 1;
}
const sorted = nodes
.map((n) => ({ node: n, neighborCount: degreeMap[n.id] ?? 0 }))
.sort((a, b) => b.neighborCount - a.neighborCount)
.slice(0, 10);
setTopNodes(sorted);
}
} catch {
setError("Failed to load graph overview. Ensure the server is running.");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load graph overview. Ensure the server is running.");
} finally {
setLoading(false);
}
@@ -135,6 +140,12 @@ export function KGOverviewTab() {
return (
<div className="ws-page">
{error ? (
<div style={{ margin: "16px 22px 0 22px", padding: 12, borderRadius: 14, color: "#ffb4c2", background: "rgba(255,157,175,0.1)", border: "1px solid rgba(255,157,175,0.18)" }}>
{error}
</div>
) : null}
{/* Header */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "16px 22px", borderBottom: "1px solid var(--ws-border)", flexShrink: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
@@ -255,13 +255,13 @@ export function OntologyManager() {
const params = new URLSearchParams();
if (searchQ) params.set("q", searchQ);
const res = await fetch(`/api/ontology/registry?${params}`);
if (res.ok) {
setEntries(await res.json());
} else {
setEntries([]);
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setEntries(data);
if (res.status === 207) flashMsg("err", data.message || "Warning: Partial success loading registry.");
} catch {
setEntries([]);
flashMsg("err", "Failed to load ontology registry");
} finally {
setLoading(false);
}
@@ -182,9 +182,11 @@ function DetailPanel({
setLoading(true);
setError("");
fetch(`/api/ontology/entity/${encodeURIComponent(uri)}`)
.then((r) => {
.then(async (r) => {
if (!r.ok) throw new Error("Not found");
return r.json();
const data = await r.json();
if (r.status === 207) setError(data.message || "Warning: Partial success loading entity.");
return data;
})
.then(setDetail)
.catch((e) => setError(e.message))
@@ -81,9 +81,11 @@ function ConceptDetailPanel({
setLoading(true);
setError("");
fetch(`/api/ontology/skos/concept/${encodeURIComponent(uri)}`)
.then((r) => {
.then(async (r) => {
if (!r.ok) throw new Error("Concept not found");
return r.json();
const data = await r.json();
if (r.status === 207) setError(data.message || "Warning: Partial success loading concept.");
return data;
})
.then(setDetail)
.catch((e) => setError(e.message))
@@ -323,14 +325,23 @@ function SchemePanel({
const [expanded, setExpanded] = useState(true);
const [hierarchy, setHierarchy] = useState<ConceptNode[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!expanded) return;
setLoading(true);
fetch(`/api/vocabulary/hierarchy?scheme=${encodeURIComponent(scheme.uri)}`)
.then((r) => (r.ok ? r.json() : []))
.then(async (r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data = await r.json();
if (r.status === 207) setError(data.message || "Warning: Partial success loading hierarchy.");
return data;
})
.then(setHierarchy)
.catch(() => setHierarchy([]))
.catch((err) => {
setHierarchy([]);
setError(err instanceof Error ? err.message : "Failed to load hierarchy.");
})
.finally(() => setLoading(false));
}, [scheme.uri, expanded]);
@@ -366,6 +377,7 @@ function SchemePanel({
{expanded && (
<div style={{ paddingBottom: 8 }}>
{error ? <div style={errorStyle}>{error}</div> : null}
{loading ? (
<div style={{ padding: "10px 20px", display: "flex", alignItems: "center", gap: 8 }}>
<Loader2 size={12} color="#4aa3ff" style={{ animation: "spin 0.8s linear infinite" }} />
@@ -412,7 +424,12 @@ export function SKOSVocabularyManager({ schemeUri }: Props) {
useEffect(() => {
setLoading(true);
fetch("/api/ontology/skos/schemes")
.then((r) => (r.ok ? r.json() : []))
.then(async (r) => {
if (!r.ok) throw new Error(`Failed to load schemes (${r.status})`);
const data = await r.json();
if (r.status === 207) setError(data.message || "Warning: Partial success loading schemes.");
return data;
})
.then(setSchemes)
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
@@ -424,6 +441,7 @@ export function SKOSVocabularyManager({ schemeUri }: Props) {
return (
<div style={managerShellStyle}>
{error ? <div style={errorStyle}>{error}</div> : null}
{/* Search bar */}
<div style={skosToolbarStyle}>
<div style={skosSearchBarStyle}>
@@ -628,6 +646,8 @@ const navLinkStyle: React.CSSProperties = {
textAlign: "left",
};
const errorStyle: React.CSSProperties = { padding: 12, borderRadius: 14, color: "#ffb4c2", background: "rgba(255,157,175,0.1)", border: "1px solid rgba(255,157,175,0.18)", margin: "0 10px 10px 10px", fontSize: 12 };
const centerStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
@@ -33,7 +33,10 @@ export function ShaclStudio({ onJumpToNode }: ShaclStudioProps) {
setRegistry(entries);
setSelectedUri((current) => current || entries[0]?.uri || "");
})
.catch(() => { /* backend unavailable — leave registry empty */ });
.catch((err) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : "Failed to load ontology registry.");
});
return () => {
cancelled = true;
};
@@ -47,6 +47,7 @@ export function VersionsTab() {
const [comparePair, setComparePair] = useState<{ v1: string; v2: string } | null>(null);
const [compareResult, setCompareResult] = useState<Record<string, any> | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const loadVersions = useCallback(async () => {
if (!ontologyUri) return;
@@ -55,9 +56,12 @@ export function VersionsTab() {
if (response.ok) {
const data = await response.json();
setVersions(data);
if (response.status === 207) setError(data.message || "Warning: Partial success loading versions.");
} else {
setError(`Failed to load versions (${response.status})`);
}
} catch (error) {
console.error("Failed to load versions:", error);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load versions.");
}
}, [ontologyUri]);
@@ -67,9 +71,12 @@ export function VersionsTab() {
if (response.ok) {
const data = await response.json();
setProposals(data);
if (response.status === 207) setError(data.message || "Warning: Partial success loading proposals.");
} else {
setError(`Failed to load proposals (${response.status})`);
}
} catch (error) {
console.error("Failed to load proposals:", error);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load proposals.");
}
}, []);
@@ -79,54 +86,76 @@ export function VersionsTab() {
}, [loadVersions, loadProposals]);
const approveProposal = useCallback(async (proposalId: string) => {
setError("");
try {
const response = await fetch(`/api/ontology/proposals/${proposalId}/approve`, {
method: "POST",
});
if (response.ok) {
alert("Proposal approved");
if (response.status === 207) {
const data = await response.json().catch(() => ({}));
setError(data.message || "Warning: Partial success approving proposal.");
} else {
alert("Proposal approved");
}
loadProposals();
} else {
setError(`Failed to approve proposal (${response.status})`);
}
} catch (error) {
console.error("Failed to approve proposal:", error);
alert("Failed to approve proposal");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to approve proposal.");
}
}, [loadProposals]);
const rejectProposal = useCallback(async (proposalId: string) => {
setError("");
try {
const response = await fetch(`/api/ontology/proposals/${proposalId}/reject`, {
method: "POST",
});
if (response.ok) {
alert("Proposal rejected");
if (response.status === 207) {
const data = await response.json().catch(() => ({}));
setError(data.message || "Warning: Partial success rejecting proposal.");
} else {
alert("Proposal rejected");
}
loadProposals();
} else {
setError(`Failed to reject proposal (${response.status})`);
}
} catch (error) {
console.error("Failed to reject proposal:", error);
alert("Failed to reject proposal");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to reject proposal.");
}
}, [loadProposals]);
const publishProposal = useCallback(async (proposalId: string) => {
setError("");
try {
const response = await fetch(`/api/ontology/proposals/${proposalId}/publish`, {
method: "POST",
});
if (response.ok) {
alert("Proposal published");
if (response.status === 207) {
const data = await response.json().catch(() => ({}));
setError(data.message || "Warning: Partial success publishing proposal.");
} else {
alert("Proposal published");
}
loadProposals();
loadVersions();
} else {
setError(`Failed to publish proposal (${response.status})`);
}
} catch (error) {
console.error("Failed to publish proposal:", error);
alert("Failed to publish proposal");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to publish proposal.");
}
}, [loadProposals, loadVersions]);
const runVersionComparison = useCallback(async () => {
if (!comparePair || !ontologyUri) return;
setIsLoading(true);
setError("");
try {
const response = await fetch(`/api/ontology/versions/${encodeURIComponent(ontologyUri)}/compare`, {
method: "POST",
@@ -138,11 +167,13 @@ export function VersionsTab() {
});
if (response.ok) {
const data = await response.json();
if (response.status === 207) setError(data.message || "Warning: Partial success comparing versions.");
setCompareResult(data);
} else {
setError(`Failed to compare versions (${response.status})`);
}
} catch (error) {
console.error("Failed to compare versions:", error);
alert("Failed to compare versions");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to compare versions.");
} finally {
setIsLoading(false);
}
@@ -265,6 +296,8 @@ export function VersionsTab() {
marginBottom: "12px",
};
const errorStyle: React.CSSProperties = { padding: "12px", borderRadius: "14px", color: "#ffb4c2", background: "rgba(255,157,175,0.1)", border: "1px solid rgba(255,157,175,0.18)", marginBottom: "16px" };
return (
<div style={containerStyle}>
<div style={headerStyle}>
@@ -278,6 +311,8 @@ export function VersionsTab() {
/>
</div>
{error ? <div style={errorStyle}>{error}</div> : null}
<div style={sectionStyle}>
<h2 style={sectionTitleStyle}>
<Layers size={16} />
@@ -20,7 +20,11 @@ async function parseResponse<T>(response: Response): Promise<T> {
}
throw new Error(detail);
}
return response.json() as Promise<T>;
const data = await response.json();
if (response.status === 207) {
console.warn("Partial Success:", data.message || "Warning: 207 Multi-Status");
}
return data as T;
}
export async function loadOntologyRegistry(): Promise<OntologyEntry[]> {
@@ -57,6 +57,7 @@ export function ReasoningWorkspace() {
});
const data = await response.json();
if (!response.ok) throw new Error(data.detail || `Status ${response.status}`);
if (response.status === 207) setError(data.message || "Warning: Partial success reasoning.");
setResult(data);
if (data.mutated) queryClient.invalidateQueries({ queryKey: ["graph", "full-load"] });
} catch (e) {
@@ -72,7 +72,17 @@ export function SparqlWorkspace() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query }),
});
if (!res.ok && res.status !== 400 && res.status !== 500) {
// Some specific errors may return 400/500 with JSON payload
if (!res.headers.get("content-type")?.includes("application/json")) {
throw new Error(`HTTP ${res.status}`);
}
}
const data = await res.json();
if (res.status === 207) {
data.error = data.message || "Warning: Partial success running query.";
}
if (data.error && data.error_line && monaco && editorRef.current) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(monaco as any).editor.setModelMarkers((editorRef.current as any).getModel(), "sparql", [{
@@ -81,6 +91,7 @@ export function SparqlWorkspace() {
endLineNumber: data.error_line,
endColumn: 100,
message: data.error,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
severity: (monaco as any).MarkerSeverity.Error,
}]);
}