From b211c36137d983791e5f212ae8293d4c0d607620 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:59:01 +0000 Subject: [PATCH] Guard fetch call sites against non-JSON (HTML) responses Add a shared `fetchJson` helper that checks `response.ok` and the content-type before calling `.json()`, throwing a clean, catchable `HttpResponseError` when the body isn't JSON (e.g. a 5xx gateway page, an auth redirect, or a Django error page starting with ``) instead of letting `.json()` blow up with an uncaught `SyntaxError: Unexpected token '<'`. Route the ~20 frontend fetch call sites through it, and give `Layout.tsx`'s `fetchHostname` the try/catch it was missing (the one spot that produced an uncaught error on every page load). Generated-By: PostHog Code Task-Id: 91df810a-1048-4296-9272-4630a410b5fc --- frontend/src/Layout.tsx | 13 ++-- frontend/src/pages/AITools/AIToolsPage.tsx | 12 ++-- .../AITools/NaturalLanguageQueryEditor.tsx | 50 ++++++++------ frontend/src/pages/Backups/Backups.tsx | 8 +-- .../src/pages/Backups/ScheduledBackups.tsx | 15 ++-- frontend/src/pages/Clusters/Clusters.tsx | 4 +- frontend/src/pages/DiskUsage/DiskUsage.tsx | 4 +- frontend/src/pages/Errors/Errors.tsx | 4 +- frontend/src/pages/Logs/Logs.tsx | 57 +++++++++------- frontend/src/pages/Operations/Operations.tsx | 4 +- frontend/src/pages/Overview/Overview.tsx | 4 +- frontend/src/pages/QueryEditor/Benchmark.tsx | 4 +- .../src/pages/QueryEditor/QueryEditor.tsx | 4 +- .../src/pages/QueryEditor/SavedQueries.tsx | 16 +++-- frontend/src/pages/QueryEditor/SavedQuery.tsx | 4 +- .../pages/RunningQueries/RunningQueries.tsx | 14 ++-- .../src/pages/SchemaStats/SchemaStats.tsx | 10 ++- .../src/pages/SchemaStats/SchemaTable.tsx | 14 ++-- .../pages/SlowQueries/ExampleQueriesTab.tsx | 4 +- frontend/src/pages/SlowQueries/ExplainTab.tsx | 4 +- frontend/src/pages/SlowQueries/MetricsTab.tsx | 4 +- .../pages/SlowQueries/NormalizedQueryTab.tsx | 4 +- .../src/pages/SlowQueries/SlowQueries.tsx | 4 +- frontend/src/utils/api.ts | 68 +++++++++++++++++++ 24 files changed, 211 insertions(+), 118 deletions(-) create mode 100644 frontend/src/utils/api.ts diff --git a/frontend/src/Layout.tsx b/frontend/src/Layout.tsx index e2948eb..94d1725 100644 --- a/frontend/src/Layout.tsx +++ b/frontend/src/Layout.tsx @@ -30,8 +30,9 @@ import { ToolOutlined, SaveOutlined, } from '@ant-design/icons' -import { ConfigProvider, MenuProps } from 'antd' +import { ConfigProvider, MenuProps, notification } from 'antd' import { Layout, Menu } from 'antd' +import { fetchJson } from './utils/api' import QueryEditorPage from './pages/QueryEditor/QueryEditorPage' import AIToolsPage from './pages/AITools/AIToolsPage' @@ -66,9 +67,13 @@ export default function AppLayout(): JSX.Element { const [hostname, setHostname] = useState('') const fetchHostname = async () => { - const response = await fetch(`/api/analyze/hostname`) - const responseJson = await response.json() - setHostname(responseJson.hostname) + try { + const responseJson = await fetchJson(`/api/analyze/hostname`) + setHostname(responseJson.hostname) + } catch (err) { + // A transient HTML response (gateway/auth/error page) shouldn't blow up the whole app + notification.error({ message: 'Failed to load hostname' }) + } } useEffect(() => { diff --git a/frontend/src/pages/AITools/AIToolsPage.tsx b/frontend/src/pages/AITools/AIToolsPage.tsx index 5e2bdf6..e631cb1 100644 --- a/frontend/src/pages/AITools/AIToolsPage.tsx +++ b/frontend/src/pages/AITools/AIToolsPage.tsx @@ -2,16 +2,20 @@ import React, { useEffect, useState } from 'react' import { Tabs } from 'antd' import { useHistory } from 'react-router-dom' import NaturalLanguageQueryEditor from './NaturalLanguageQueryEditor' +import { fetchJson } from '../../utils/api' export default function AIToolsPage() { const history = useHistory() const [error, setError] = useState(null) const loadData = async () => { - const res = await fetch('/api/analyze/ai_tools_available') - const resJson = await res.json() - if ('error' in resJson) { - setError(resJson['error']) + try { + const resJson = await fetchJson('/api/analyze/ai_tools_available') + if ('error' in resJson) { + setError(resJson['error']) + } + } catch (err) { + setError('Failed to load AI tools') } } diff --git a/frontend/src/pages/AITools/NaturalLanguageQueryEditor.tsx b/frontend/src/pages/AITools/NaturalLanguageQueryEditor.tsx index f896984..118f672 100644 --- a/frontend/src/pages/AITools/NaturalLanguageQueryEditor.tsx +++ b/frontend/src/pages/AITools/NaturalLanguageQueryEditor.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react' -import { Select, Checkbox, Button, Table, ConfigProvider } from 'antd' +import { Select, Checkbox, Button, Table, ConfigProvider, notification } from 'antd' import TextArea from 'antd/es/input/TextArea' // @ts-ignore import { highlight, languages } from 'prismjs/components/prism-core' // @ts-ignore @@ -8,6 +8,7 @@ import 'prismjs/themes/prism.css' import Editor from 'react-simple-code-editor' // @ts-ignore import { format } from 'sql-formatter-plus' +import { fetchJson } from '../../utils/api' export interface TableData { table: string @@ -29,31 +30,38 @@ export default function NaturalLanguageQueryEditor() { const runQuery = async () => { setLoading(true) setSql(null) - const res = await fetch('/api/analyze/natural_language_query', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - query: query, - tables_to_query: tablesToQuery, - readonly: readonly || false, - }), - }) - const resJson = await res.json() - if (resJson.error) { - setError(resJson.error) + try { + const resJson = await fetchJson('/api/analyze/natural_language_query', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: query, + tables_to_query: tablesToQuery, + readonly: readonly || false, + }), + }) + if (resJson.error) { + setError(resJson.error) + setData([]) + } else { + setData(resJson.result) + } + setSql(resJson.sql) + } catch (err) { + setError(String(err)) setData([]) - } else { - setData(resJson.result) } - setSql(resJson.sql) setLoading(false) } const loadTableData = async () => { - const res = await fetch('/api/analyze/tables') - const resJson = await res.json() - setTables(resJson) + try { + const resJson = await fetchJson('/api/analyze/tables') + setTables(resJson) + } catch (err) { + notification.error({ message: 'Failed to load tables' }) + } } useEffect(() => { diff --git a/frontend/src/pages/Backups/Backups.tsx b/frontend/src/pages/Backups/Backups.tsx index 741cf37..e85b767 100644 --- a/frontend/src/pages/Backups/Backups.tsx +++ b/frontend/src/pages/Backups/Backups.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react' import { usePollingEffect } from '../../utils/usePollingEffect' import { ColumnType } from 'antd/es/table' import { Table, Button, Form, Input, Checkbox, Modal, Tag, Col, Progress, Row, Tooltip, notification } from 'antd' +import { fetchJson } from '../../utils/api' import useSWR, { mutate } from 'swr' interface BackupRow { @@ -45,7 +46,7 @@ export default function Backups() { // Validate and get form values const values = await form.validateFields() setConfirmLoading(true) - const res = await fetch(`/api/backups`, { + const resJson = await fetchJson(`/api/backups`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -55,7 +56,7 @@ export default function Backups() { setOpen(false) setConfirmLoading(false) mutate('/api/backups') - return await res.json() + return resJson } catch (error) { notification.error({ message: 'Creating backup failed', @@ -73,8 +74,7 @@ export default function Backups() { const loadData = async (url: string) => { try { - const res = await fetch(url) - const resJson = await res.json() + const resJson = await fetchJson(url) const backups = { backups: resJson } return backups } catch (err) { diff --git a/frontend/src/pages/Backups/ScheduledBackups.tsx b/frontend/src/pages/Backups/ScheduledBackups.tsx index db77737..37b74ae 100644 --- a/frontend/src/pages/Backups/ScheduledBackups.tsx +++ b/frontend/src/pages/Backups/ScheduledBackups.tsx @@ -20,6 +20,7 @@ import { import DeleteOutlined from '@ant-design/icons/DeleteOutlined' import EditOutlined from '@ant-design/icons/EditOutlined' import { Clusters } from '../Clusters/Clusters' +import { fetchJson } from '../../utils/api' import useSWR, { mutate } from 'swr' interface ScheduleRow { @@ -69,7 +70,7 @@ export default function ScheduledBackups() { const url = editingRow ? `/api/scheduled_backups/${editingRow.id}` : '/api/scheduled_backups' const values = await form.validateFields() setConfirmLoading(true) - const res = await fetch(url, { + const resJson = await fetchJson(url, { method: method, headers: { 'Content-Type': 'application/json', @@ -80,7 +81,7 @@ export default function ScheduledBackups() { setConfirmLoading(false) setEditingRow(null) mutate('/api/scheduled_backups') - return await res.json() + return resJson } catch (error) { notification.error({ message: 'Creating backup failed', @@ -111,8 +112,7 @@ export default function ScheduledBackups() { const fetchBackups = async (url: string) => { try { - const res = await fetch(url) - const resJson = await res.json() + const resJson = await fetchJson(url) const backups = { backups: resJson.results } return backups } catch (err) { @@ -121,8 +121,7 @@ export default function ScheduledBackups() { } const fetchClusters = async (url: string) => { try { - const res = await fetch(url) - const resJson = await res.json() + const resJson = await fetchJson(url) const clusters = { clusters: resJson } return clusters } catch (err) { @@ -146,7 +145,7 @@ export default function ScheduledBackups() { render: (_, sched) => { const toggleEnabled = async () => { try { - const res = await fetch(`/api/scheduled_backups/${sched.id}`, { + const resJson = await fetchJson(`/api/scheduled_backups/${sched.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', @@ -154,7 +153,7 @@ export default function ScheduledBackups() { body: JSON.stringify({ enabled: !sched.enabled }), }) mutate('/api/scheduled_backups') - return await res.json() + return resJson } catch (error) { notification.error({ message: 'Failed to toggle backup', diff --git a/frontend/src/pages/Clusters/Clusters.tsx b/frontend/src/pages/Clusters/Clusters.tsx index 184ecd4..24fa6b0 100644 --- a/frontend/src/pages/Clusters/Clusters.tsx +++ b/frontend/src/pages/Clusters/Clusters.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react' import { ColumnType } from 'antd/es/table' import { Table, Col, Row, Tooltip, notification } from 'antd' +import { fetchJson } from '../../utils/api' import useSWR from 'swr' interface ClusterNode { @@ -31,8 +32,7 @@ export interface Clusters { export default function Clusters() { const loadData = async (url: string) => { try { - const res = await fetch(url) - const resJson = await res.json() + const resJson = await fetchJson(url) const clusters = { clusters: resJson } return clusters } catch (err) { diff --git a/frontend/src/pages/DiskUsage/DiskUsage.tsx b/frontend/src/pages/DiskUsage/DiskUsage.tsx index a04d5ec..80f36b0 100644 --- a/frontend/src/pages/DiskUsage/DiskUsage.tsx +++ b/frontend/src/pages/DiskUsage/DiskUsage.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react' import { Pie } from '@ant-design/plots' import { Card, Spin, Row, Col, notification } from 'antd' +import { fetchJson } from '../../utils/api' import useSWR from 'swr' @@ -13,8 +14,7 @@ interface NodeData { export function DiskUsage(): JSX.Element { const loadData = async (url: string) => { try { - const res = await fetch(url) - const resJson = await res.json() + const resJson = await fetchJson(url) return resJson } catch { notification.error({ message: 'Failed to load data' }) diff --git a/frontend/src/pages/Errors/Errors.tsx b/frontend/src/pages/Errors/Errors.tsx index 2bccd80..051d386 100644 --- a/frontend/src/pages/Errors/Errors.tsx +++ b/frontend/src/pages/Errors/Errors.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react' import { Table, notification } from 'antd' import { ColumnsType } from 'antd/es/table' import { isoTimestampToHumanReadable } from '../../utils/dateUtils' +import { fetchJson } from '../../utils/api' import useSWR from 'swr' @@ -32,8 +33,7 @@ export default function CollapsibleTable() { const loadData = async (url: string) => { try { - const res = await fetch(url) - const resJson = await res.json() + const resJson = await fetchJson(url) const slowQueriesData = resJson.map((error: ErrorData, idx: number) => ({ key: idx, ...error })) return slowQueriesData diff --git a/frontend/src/pages/Logs/Logs.tsx b/frontend/src/pages/Logs/Logs.tsx index 15d6e0a..57dc4e8 100644 --- a/frontend/src/pages/Logs/Logs.tsx +++ b/frontend/src/pages/Logs/Logs.tsx @@ -1,6 +1,7 @@ -import { Table, Typography, Input, Card, ConfigProvider, Empty } from 'antd' +import { Table, Typography, Input, Card, ConfigProvider, Empty, notification } from 'antd' import React, { useEffect, useState } from 'react' import { Column } from '@ant-design/charts' +import { fetchJson } from '../../utils/api' const { Paragraph } = Typography @@ -42,36 +43,42 @@ export default function Logs() { const fetchLogs = async (messageIlike = '') => { setLoadingLogs(true) - const res = await fetch(url, { - method: 'POST', - body: JSON.stringify({ message_ilike: messageIlike }), - headers: { - 'Content-Type': 'application/json', - }, - }) - const resJson = await res.json() - if (resJson.error) { - setError(resJson.error) - } else { - setLogs(resJson) + try { + const resJson = await fetchJson(url, { + method: 'POST', + body: JSON.stringify({ message_ilike: messageIlike }), + headers: { + 'Content-Type': 'application/json', + }, + }) + if (resJson.error) { + setError(resJson.error) + } else { + setLogs(resJson) + } + } catch (err) { + notification.error({ message: 'Failed to load logs' }) } setLoadingLogs(false) } const fetchLogsFrequency = async (messageIlike = '') => { setLoadingLogsFrequency(true) - const res = await fetch('/api/analyze/logs_frequency', { - method: 'POST', - body: JSON.stringify({ message_ilike: messageIlike }), - headers: { - 'Content-Type': 'application/json', - }, - }) - const resJson = await res.json() - if (resJson.error) { - setError(resJson.error) - } else { - setLogsFrequency(resJson) + try { + const resJson = await fetchJson('/api/analyze/logs_frequency', { + method: 'POST', + body: JSON.stringify({ message_ilike: messageIlike }), + headers: { + 'Content-Type': 'application/json', + }, + }) + if (resJson.error) { + setError(resJson.error) + } else { + setLogsFrequency(resJson) + } + } catch (err) { + notification.error({ message: 'Failed to load logs' }) } setLoadingLogsFrequency(false) } diff --git a/frontend/src/pages/Operations/Operations.tsx b/frontend/src/pages/Operations/Operations.tsx index 24d9a88..db11f58 100644 --- a/frontend/src/pages/Operations/Operations.tsx +++ b/frontend/src/pages/Operations/Operations.tsx @@ -10,6 +10,7 @@ import { Button, Input, Progress, Table, Tabs, notification } from 'antd' import TextArea from 'antd/es/input/TextArea' import { ColumnType } from 'antd/es/table' import { isoTimestampToHumanReadable } from '../../utils/dateUtils' +import { fetchJson } from '../../utils/api' import useSWR from 'swr' @@ -77,8 +78,7 @@ export function OperationControls({ export function OperationsList(): JSX.Element { const fetchAndUpdateOperationsIfNeeded = async (url: string) => { - const response = await fetch(url) - const responseJson = await response.json() + const responseJson = await fetchJson(url) const results = responseJson.results if (JSON.stringify(results) !== JSON.stringify(operations)) { return results diff --git a/frontend/src/pages/Overview/Overview.tsx b/frontend/src/pages/Overview/Overview.tsx index c28939b..5bd6ebc 100644 --- a/frontend/src/pages/Overview/Overview.tsx +++ b/frontend/src/pages/Overview/Overview.tsx @@ -3,6 +3,7 @@ import { Line } from '@ant-design/charts' import { Card, Col, Row, Tooltip, notification } from 'antd' import InfoCircleOutlined from '@ant-design/icons/InfoCircleOutlined' import { clickhouseTips } from './tips' +import { fetchJson } from '../../utils/api' import useSWR from 'swr' interface MetricData { @@ -20,8 +21,7 @@ interface QueryGraphsData { export default function Overview() { const loadData = async (url: string) => { try { - const res = await fetch(url) - const resJson = await res.json() + const resJson = await fetchJson(url) const execution_count = resJson.execution_count const memory_usage = resJson.memory_usage const read_bytes = resJson.read_bytes diff --git a/frontend/src/pages/QueryEditor/Benchmark.tsx b/frontend/src/pages/QueryEditor/Benchmark.tsx index 9c6263c..57c8aa9 100644 --- a/frontend/src/pages/QueryEditor/Benchmark.tsx +++ b/frontend/src/pages/QueryEditor/Benchmark.tsx @@ -6,6 +6,7 @@ import 'prismjs/components/prism-sql' import 'prismjs/themes/prism.css' import Editor from 'react-simple-code-editor' import { Column } from '@ant-design/charts' +import { fetchJson } from '../../utils/api' import useSWR from 'swr' export interface BenchmarkingData { @@ -48,14 +49,13 @@ export default function QueryBenchmarking() { try { setData(null) setError(null) - const res = await fetch('/api/analyze/benchmark', { + const resJson = await fetchJson('/api/analyze/benchmark', { method: 'POST', body: JSON.stringify({ query1, query2 }), headers: { 'Content-Type': 'application/json', }, }) - const resJson = await res.json() if (resJson.error) { setError(resJson) } else { diff --git a/frontend/src/pages/QueryEditor/QueryEditor.tsx b/frontend/src/pages/QueryEditor/QueryEditor.tsx index 66919ba..68bb010 100644 --- a/frontend/src/pages/QueryEditor/QueryEditor.tsx +++ b/frontend/src/pages/QueryEditor/QueryEditor.tsx @@ -7,6 +7,7 @@ import 'prismjs/themes/prism.css' import Editor from 'react-simple-code-editor' import { v4 as uuidv4 } from 'uuid' import SaveOutlined from '@ant-design/icons/SaveOutlined' +import { fetchJson } from '../../utils/api' function CreateSavedQueryModal({ modalOpen = false, @@ -68,14 +69,13 @@ export default function QueryEditor() { setRunningQueryId(queryId) try { setData([]) - const res = await fetch('/api/analyze/query', { + const resJson = await fetchJson('/api/analyze/query', { method: 'POST', body: JSON.stringify({ sql, query_id: queryId }), headers: { 'Content-Type': 'application/json', }, }) - const resJson = await res.json() if (resJson.error) { setError(resJson.error) } else { diff --git a/frontend/src/pages/QueryEditor/SavedQueries.tsx b/frontend/src/pages/QueryEditor/SavedQueries.tsx index d1ab985..c241cb8 100644 --- a/frontend/src/pages/QueryEditor/SavedQueries.tsx +++ b/frontend/src/pages/QueryEditor/SavedQueries.tsx @@ -1,10 +1,11 @@ -import { Table, Button, Row, Col, Tooltip } from 'antd' +import { Table, Button, Row, Col, Tooltip, notification } from 'antd' import React, { useEffect, useState } from 'react' import { ColumnType } from 'antd/es/table' import SavedQuery from './SavedQuery' import ReloadOutlined from '@ant-design/icons/ReloadOutlined' import { useHistory } from 'react-router-dom' import { isoTimestampToHumanReadable } from '../../utils/dateUtils' +import { fetchJson } from '../../utils/api' export interface SavedQueryData { id: number @@ -18,11 +19,14 @@ export default function SavedQueries({ match }: { match: { params: { id: string const history = useHistory() const loadData = async () => { - const res = await fetch('/api/saved_queries') - const resJson = await res.json() - setSavedQueries(resJson.results) - if (match && match.params && match.params.id) { - setActiveQuery(resJson.results.find((q: SavedQueryData) => q.id === Number(match.params.id)) || null) + try { + const resJson = await fetchJson('/api/saved_queries') + setSavedQueries(resJson.results) + if (match && match.params && match.params.id) { + setActiveQuery(resJson.results.find((q: SavedQueryData) => q.id === Number(match.params.id)) || null) + } + } catch (err) { + notification.error({ message: 'Failed to load saved queries' }) } } diff --git a/frontend/src/pages/QueryEditor/SavedQuery.tsx b/frontend/src/pages/QueryEditor/SavedQuery.tsx index bffd658..743ee27 100644 --- a/frontend/src/pages/QueryEditor/SavedQuery.tsx +++ b/frontend/src/pages/QueryEditor/SavedQuery.tsx @@ -6,6 +6,7 @@ import 'prismjs/components/prism-sql' import 'prismjs/themes/prism.css' import Editor from 'react-simple-code-editor' import { SavedQueryData } from './SavedQueries' +import { fetchJson } from '../../utils/api' export default function SavedQuery({ id, query, name }: SavedQueryData) { const [error, setError] = useState('') @@ -17,14 +18,13 @@ export default function SavedQuery({ id, query, name }: SavedQueryData) { try { setData([]) setError('') - const res = await fetch('/api/analyze/query', { + const resJson = await fetchJson('/api/analyze/query', { method: 'POST', body: JSON.stringify({ sql: query }), headers: { 'Content-Type': 'application/json', }, }) - const resJson = await res.json() if (resJson.error) { setError(resJson.error) } else { diff --git a/frontend/src/pages/RunningQueries/RunningQueries.tsx b/frontend/src/pages/RunningQueries/RunningQueries.tsx index f60ae87..b4f21dd 100644 --- a/frontend/src/pages/RunningQueries/RunningQueries.tsx +++ b/frontend/src/pages/RunningQueries/RunningQueries.tsx @@ -2,6 +2,7 @@ import { Table, Button, notification, Typography, Tooltip, Spin } from 'antd' import { usePollingEffect } from '../../utils/usePollingEffect' import React, { useState } from 'react' import { ColumnType } from 'antd/es/table' +import { fetchJson } from '../../utils/api' const { Paragraph } = Typography @@ -23,7 +24,7 @@ function KillQueryButton({ queryId }: any) { const killQuery = async () => { setIsLoading(true) try { - const res = await fetch(`/api/analyze/${queryId}/kill_query`, { + const resJson = await fetchJson(`/api/analyze/${queryId}/kill_query`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', @@ -34,7 +35,7 @@ function KillQueryButton({ queryId }: any) { }) setIsKilled(true) setIsLoading(false) - return await res.json() + return resJson } catch (err) { setIsLoading(false) notification.error({ @@ -103,9 +104,12 @@ export default function RunningQueries() { usePollingEffect( async () => { setLoadingRunningQueries(true) - const res = await fetch('/api/analyze/running_queries') - const resJson = await res.json() - setRunningQueries(resJson) + try { + const resJson = await fetchJson('/api/analyze/running_queries') + setRunningQueries(resJson) + } catch (err) { + notification.error({ message: 'Failed to load running queries' }) + } setLoadingRunningQueries(false) }, [], diff --git a/frontend/src/pages/SchemaStats/SchemaStats.tsx b/frontend/src/pages/SchemaStats/SchemaStats.tsx index 8ce1406..3767afe 100644 --- a/frontend/src/pages/SchemaStats/SchemaStats.tsx +++ b/frontend/src/pages/SchemaStats/SchemaStats.tsx @@ -1,7 +1,8 @@ // @ts-nocheck import React, { useEffect, useState } from 'react' import { Treemap } from '@ant-design/charts' -import { Spin, Table } from 'antd' +import { Spin, Table, notification } from 'antd' +import { fetchJson } from '../../utils/api' import { useHistory } from 'react-router-dom' @@ -45,17 +46,14 @@ export default function Schema() { const loadData = async () => { try { - const res = await fetch('/api/analyze/tables') - const resJson = await res.json() + const resJson = await fetchJson('/api/analyze/tables') const filteredRes = resJson.filter((r: { total_bytes: number }) => r.total_bytes > 0) const filteredResUrls = filteredRes .map((fr: { name: string }) => `/api/analyze/${fr.name}/schema`) .slice(0, 1) - const nestedRes = await Promise.all( - filteredResUrls.map((_url: string) => fetch(_url).then((res2) => res2.json())) - ) + const nestedRes = await Promise.all(filteredResUrls.map((_url: string) => fetchJson(_url))) const configDataChildren = filteredRes.map((table: { name: string; total_bytes: number }) => ({ value: table.total_bytes, diff --git a/frontend/src/pages/SchemaStats/SchemaTable.tsx b/frontend/src/pages/SchemaStats/SchemaTable.tsx index c577547..65d7c02 100644 --- a/frontend/src/pages/SchemaStats/SchemaTable.tsx +++ b/frontend/src/pages/SchemaStats/SchemaTable.tsx @@ -3,6 +3,7 @@ import React, { useEffect } from 'react' import { usePollingEffect } from '../../utils/usePollingEffect' import { Treemap } from '@ant-design/charts' import { Table, Tabs, TabsProps, notification } from 'antd' +import { fetchJson } from '../../utils/api' import { useHistory } from 'react-router-dom' @@ -57,13 +58,9 @@ export function ColumnsData({ table }: { table: string }): JSX.Element { usePollingEffect( async () => setSchema( - await fetch(url) - .then((response) => { - return response.json() - }) - .catch((err) => { - return [] - }) + await fetchJson(url).catch((err) => { + return [] + }) ), [], { interval: 3000 } // optional @@ -91,8 +88,7 @@ export function PartsData({ table }: { table: string }): JSX.Element { const loadData = async () => { try { - const res = await fetch(`/api/analyze/${table}/parts`) - const resJson = await res.json() + const resJson = await fetchJson(`/api/analyze/${table}/parts`) setPartData(resJson) } catch { notification.error({ message: 'Failed to load data' }) diff --git a/frontend/src/pages/SlowQueries/ExampleQueriesTab.tsx b/frontend/src/pages/SlowQueries/ExampleQueriesTab.tsx index 9bf731a..3df51bb 100644 --- a/frontend/src/pages/SlowQueries/ExampleQueriesTab.tsx +++ b/frontend/src/pages/SlowQueries/ExampleQueriesTab.tsx @@ -8,14 +8,14 @@ import Editor from 'react-simple-code-editor' // @ts-ignore import { Table, notification } from 'antd' import { NoDataSpinner, QueryDetailData } from './QueryDetail' +import { fetchJson } from '../../utils/api' export default function ExampleQueriesTab({ query_hash }: { query_hash: string }) { const [data, setData] = useState<{ example_queries: QueryDetailData['example_queries'] } | null>(null) const loadData = async () => { try { - const res = await fetch(`/api/analyze/${query_hash}/query_examples`) - const resJson = await res.json() + const resJson = await fetchJson(`/api/analyze/${query_hash}/query_examples`) setData(resJson) } catch { notification.error({ message: 'Failed to load data' }) diff --git a/frontend/src/pages/SlowQueries/ExplainTab.tsx b/frontend/src/pages/SlowQueries/ExplainTab.tsx index 6be3fc2..c1f92b5 100644 --- a/frontend/src/pages/SlowQueries/ExplainTab.tsx +++ b/frontend/src/pages/SlowQueries/ExplainTab.tsx @@ -8,14 +8,14 @@ import Editor from 'react-simple-code-editor' // @ts-ignore import { NoDataSpinner, QueryDetailData, copyToClipboard } from './QueryDetail' import { notification } from 'antd' +import { fetchJson } from '../../utils/api' export default function ExplainTab({ query_hash }: { query_hash: string }) { const [data, setData] = useState<{ explain: QueryDetailData['explain'] } | null>(null) const loadData = async () => { try { - const res = await fetch(`/api/analyze/${query_hash}/query_explain`) - const resJson = await res.json() + const resJson = await fetchJson(`/api/analyze/${query_hash}/query_explain`) setData(resJson) } catch { notification.error({ message: 'Failed to load data' }) diff --git a/frontend/src/pages/SlowQueries/MetricsTab.tsx b/frontend/src/pages/SlowQueries/MetricsTab.tsx index b76ed18..ad98969 100644 --- a/frontend/src/pages/SlowQueries/MetricsTab.tsx +++ b/frontend/src/pages/SlowQueries/MetricsTab.tsx @@ -4,6 +4,7 @@ import { Line } from '@ant-design/plots' import { Card, Col, Row, Tooltip, notification } from 'antd' import InfoCircleOutlined from '@ant-design/icons/InfoCircleOutlined' import { NoDataSpinner, QueryDetailData } from './QueryDetail' +import { fetchJson } from '../../utils/api' export default function MetricsTab({ query_hash }: { query_hash: string }) { const [data, setData] = useState | null>( @@ -12,8 +13,7 @@ export default function MetricsTab({ query_hash }: { query_hash: string }) { const loadData = async () => { try { - const res = await fetch(`/api/analyze/${query_hash}/query_metrics`) - const resJson = await res.json() + const resJson = await fetchJson(`/api/analyze/${query_hash}/query_metrics`) setData(resJson) } catch { notification.error({ message: 'Failed to load data' }) diff --git a/frontend/src/pages/SlowQueries/NormalizedQueryTab.tsx b/frontend/src/pages/SlowQueries/NormalizedQueryTab.tsx index a2eae15..7b67dd1 100644 --- a/frontend/src/pages/SlowQueries/NormalizedQueryTab.tsx +++ b/frontend/src/pages/SlowQueries/NormalizedQueryTab.tsx @@ -9,14 +9,14 @@ import Editor from 'react-simple-code-editor' import { format } from 'sql-formatter-plus' import { NoDataSpinner, copyToClipboard } from './QueryDetail' import { notification } from 'antd' +import { fetchJson } from '../../utils/api' export default function NormalizedQueryTab({ query_hash }: { query_hash: string }) { const [data, setData] = useState<{ query: string } | null>(null) const loadData = async () => { try { - const res = await fetch(`/api/analyze/${query_hash}/query_normalized`) - const resJson = await res.json() + const resJson = await fetchJson(`/api/analyze/${query_hash}/query_normalized`) setData(resJson) } catch { notification.error({ message: 'Failed to load data' }) diff --git a/frontend/src/pages/SlowQueries/SlowQueries.tsx b/frontend/src/pages/SlowQueries/SlowQueries.tsx index 7d9938b..70e093b 100644 --- a/frontend/src/pages/SlowQueries/SlowQueries.tsx +++ b/frontend/src/pages/SlowQueries/SlowQueries.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react' import { Select, Table, Typography, notification } from 'antd' import { useHistory } from 'react-router-dom' import { ColumnType } from 'antd/es/table' +import { fetchJson } from '../../utils/api' const { Paragraph } = Typography interface SlowQueryData { @@ -76,8 +77,7 @@ export default function CollapsibleTable() { setSlowQueries([]) setLoadingSlowQueries(true) try { - const res = await fetch(`/api/analyze/slow_queries?time_range=${timeRange}`) - const resJson = await res.json() + const resJson = await fetchJson(`/api/analyze/slow_queries?time_range=${timeRange}`) const slowQueriesData = resJson.map((error: SlowQueryData, idx: number) => ({ key: idx, ...error })) setSlowQueries(slowQueriesData) setLoadingSlowQueries(false) diff --git a/frontend/src/utils/api.ts b/frontend/src/utils/api.ts new file mode 100644 index 0000000..ebca5c4 --- /dev/null +++ b/frontend/src/utils/api.ts @@ -0,0 +1,68 @@ +import { notification } from 'antd' + +const JSON_CONTENT_TYPE = 'application/json' + +/** + * Error thrown when a response can't be treated as JSON – e.g. the backend or a + * proxy handed back an HTML document (a 5xx gateway page, an auth redirect, or a + * Django error page starting with ``). Calling `.json()` on such a + * body throws a cryptic `SyntaxError: Unexpected token '<'`; this gives us a clean, + * catchable error instead. + */ +export class HttpResponseError extends Error { + status: number + contentType: string + + constructor(status: number, contentType: string, body?: string) { + const snippet = (body || '').trim().slice(0, 200) + super( + `Expected a JSON response but received "${contentType || 'no content-type'}" (HTTP ${status}). ` + + 'The server may have returned an HTML error or redirect page.' + + (snippet ? ` Response starts with: ${snippet}` : '') + ) + this.name = 'HttpResponseError' + this.status = status + this.contentType = contentType + } +} + +/** + * Wrapper around `fetch` that guards against non-JSON responses before parsing. + * + * It checks `response.ok` and the `content-type` header, and only calls `.json()` + * when the body actually is JSON. When it isn't, it throws an `HttpResponseError` + * (which callers can catch and surface gracefully) rather than letting `.json()` + * blow up with an uncaught `SyntaxError`. + * + * JSON responses are returned as-is regardless of status code, so callers that + * inspect error fields on non-2xx JSON bodies keep working. + */ +export async function fetchJson(input: RequestInfo | URL, init?: RequestInit): Promise { + const response = await fetch(input, init) + const contentType = response.headers.get('content-type') || '' + + if (!contentType.toLowerCase().includes(JSON_CONTENT_TYPE)) { + const body = await response.text().catch(() => '') + throw new HttpResponseError(response.status, contentType, body) + } + + return response.json() +} + +/** + * Convenience wrapper that runs `fetchJson` and surfaces an antd notification if the + * response isn't JSON (or the request fails), returning `undefined` instead of throwing. + * Handy for the fire-and-forget loaders that don't have their own error handling. + */ +export async function fetchJsonWithNotification( + input: RequestInfo | URL, + init?: RequestInit, + message = 'Failed to load data' +): Promise { + try { + return await fetchJson(input, init) + } catch (err) { + notification.error({ message }) + return undefined + } +}