Skip to content

Commit ac3a788

Browse files
committed
fix: surface real API errors and add request detail view
Stop the dashboard from silently fabricating a fake success when the API rejects a write (invalid webhook URL, invalid key name) — show the real error instead, reserving the offline-demo fallback for genuinely unreachable APIs. Also add a request detail drawer so clicking a row in the Request Explorer shows full event metadata.
1 parent eac116e commit ac3a788

3 files changed

Lines changed: 260 additions & 35 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,6 @@ Initial alpha release.
2929
- Added incident status workflows, timeline records, alert destination management, and queued alert delivery records for high and critical incidents.
3030
- Added webhook alert delivery with atomic queue claiming, exponential backoff retries, and terminal failure handling after repeated delivery attempts.
3131
- Fixed the dashboard project switcher: analytics, incidents, API keys, and alerts endpoints now resolve the authenticated caller's requested project instead of always returning the demo project's data. Anonymous (unauthenticated) requests remain locked to the demo project. The dashboard now authenticates with the shared dev API key by default in both Docker Compose and local development, so this works without extra setup.
32+
- Fixed the dashboard silently faking success when the API rejects a request (invalid webhook URL, invalid key name): rejected requests now surface the real error message instead of fabricating a local record that vanishes on the next refresh. A genuinely unreachable API still falls back to offline demo mode.
33+
- Added a request detail view: clicking a row in the Request Explorer opens the full event metadata (trace ID, headers context, auth state, GraphQL/EVM fields, threat signals).
3234
- Added CI, issue templates, contribution guide, security policy, roadmap, and dashboard screenshot.

‎apps/dashboard/src/main.tsx‎

Lines changed: 179 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ function App() {
212212
const [readiness, setReadiness] = useState<Readiness>(demoReadiness);
213213
const [systemMetrics, setSystemMetrics] = useState<SystemMetrics>(demoSystemMetrics);
214214
const [liveStatus, setLiveStatus] = useState("Connecting");
215+
const [actionError, setActionError] = useState<string | null>(null);
215216
const selectedProject =
216217
session.projects.find((project) => project.id === selectedProjectId) ?? session.projects[0];
217218

@@ -277,83 +278,117 @@ function App() {
277278
}, [isAuthenticated, requestFilters, selectedProjectId]);
278279

279280
async function createDashboardApiKey() {
280-
try {
281-
const response = await apiFetch("/v1/api-keys", selectedProjectId, {
281+
setActionError(null);
282+
const outcome = await submitToApi(() =>
283+
apiFetch("/v1/api-keys", selectedProjectId, {
282284
method: "POST",
283285
body: JSON.stringify({ name: newKeyName })
284-
});
285-
if (!response.ok) throw new Error("key_create_failed");
286-
const key = (await response.json()) as ApiKeyRecord;
287-
setApiKeys((current) => [key, ...current]);
288-
setNewKeySecret(key.key ?? "");
289-
} catch {
286+
})
287+
);
288+
289+
if (outcome.kind === "unreachable") {
290290
const key = createDemoApiKey(newKeyName);
291291
setApiKeys((current) => [key, ...current]);
292292
setNewKeySecret(key.key ?? "");
293+
return;
294+
}
295+
if (outcome.kind === "rejected") {
296+
setActionError(outcome.message);
297+
return;
293298
}
299+
300+
const key = (await outcome.response.json()) as ApiKeyRecord;
301+
setApiKeys((current) => [key, ...current]);
302+
setNewKeySecret(key.key ?? "");
294303
}
295304

296305
async function revokeDashboardApiKey(keyId: string) {
297-
try {
298-
const response = await apiFetch(`/v1/api-keys/${keyId}`, selectedProjectId, { method: "DELETE" });
299-
if (!response.ok) throw new Error("key_revoke_failed");
300-
} catch {
301-
// Offline demo mode keeps the interaction local while Docker is unavailable.
306+
setActionError(null);
307+
const outcome = await submitToApi(() => apiFetch(`/v1/api-keys/${keyId}`, selectedProjectId, { method: "DELETE" }));
308+
309+
if (outcome.kind === "rejected") {
310+
setActionError(outcome.message);
311+
return;
302312
}
303313

314+
// A network-unreachable API still applies the change locally so offline demo mode stays usable.
304315
setApiKeys((current) =>
305316
current.map((key) => (key.id === keyId ? { ...key, revoked_at: new Date().toISOString() } : key))
306317
);
307318
}
308319

309320
async function updateDashboardIncidentStatus(incidentId: string, status: IncidentStatus) {
310-
try {
311-
const response = await apiFetch(`/v1/incidents/${incidentId}/status`, selectedProjectId, {
321+
setActionError(null);
322+
const outcome = await submitToApi(() =>
323+
apiFetch(`/v1/incidents/${incidentId}/status`, selectedProjectId, {
312324
method: "PATCH",
313325
body: JSON.stringify({ status })
314-
});
315-
if (!response.ok) throw new Error("incident_update_failed");
316-
const incident = (await response.json()) as Incident;
317-
setIncidents((current) => current.map((entry) => (entry.id === incidentId ? incident : entry)));
318-
} catch {
326+
})
327+
);
328+
329+
if (outcome.kind === "rejected") {
330+
setActionError(outcome.message);
331+
return;
332+
}
333+
if (outcome.kind === "unreachable") {
319334
setIncidents((current) =>
320335
current.map((entry) =>
321336
entry.id === incidentId ? { ...entry, status, updated_at: new Date().toISOString() } : entry
322337
)
323338
);
339+
return;
324340
}
341+
342+
const incident = (await outcome.response.json()) as Incident;
343+
setIncidents((current) => current.map((entry) => (entry.id === incidentId ? incident : entry)));
325344
}
326345

327346
async function createDashboardAlertDestination() {
328-
try {
329-
const response = await apiFetch("/v1/alert-destinations", selectedProjectId, {
347+
setActionError(null);
348+
const outcome = await submitToApi(() =>
349+
apiFetch("/v1/alert-destinations", selectedProjectId, {
330350
method: "POST",
331351
body: JSON.stringify(newDestination)
332-
});
333-
if (!response.ok) throw new Error("alert_destination_create_failed");
334-
const destination = (await response.json()) as AlertDestination;
335-
setAlertDestinations((current) => [destination, ...current]);
336-
} catch {
352+
})
353+
);
354+
355+
if (outcome.kind === "unreachable") {
337356
setAlertDestinations((current) => [createDemoAlertDestination(newDestination), ...current]);
357+
return;
358+
}
359+
if (outcome.kind === "rejected") {
360+
setActionError(outcome.message);
361+
return;
338362
}
363+
364+
const destination = (await outcome.response.json()) as AlertDestination;
365+
setAlertDestinations((current) => [destination, ...current]);
339366
}
340367

341368
async function setDashboardAlertDestinationStatus(destinationId: string, enabled: boolean) {
342-
try {
343-
const response = await apiFetch(`/v1/alert-destinations/${destinationId}`, selectedProjectId, {
369+
setActionError(null);
370+
const outcome = await submitToApi(() =>
371+
apiFetch(`/v1/alert-destinations/${destinationId}`, selectedProjectId, {
344372
method: "PATCH",
345373
body: JSON.stringify({ enabled })
346-
});
347-
if (!response.ok) throw new Error("alert_destination_update_failed");
348-
const destination = (await response.json()) as AlertDestination;
349-
setAlertDestinations((current) => current.map((entry) => (entry.id === destinationId ? destination : entry)));
350-
} catch {
374+
})
375+
);
376+
377+
if (outcome.kind === "rejected") {
378+
setActionError(outcome.message);
379+
return;
380+
}
381+
if (outcome.kind === "unreachable") {
351382
setAlertDestinations((current) =>
352383
current.map((entry) =>
353384
entry.id === destinationId ? { ...entry, enabled, updated_at: new Date().toISOString() } : entry
354385
)
355386
);
387+
return;
356388
}
389+
390+
const destination = (await outcome.response.json()) as AlertDestination;
391+
setAlertDestinations((current) => current.map((entry) => (entry.id === destinationId ? destination : entry)));
357392
}
358393

359394
async function loadRequests() {
@@ -489,6 +524,16 @@ function App() {
489524
</div>
490525
</header>
491526

527+
{actionError && (
528+
<div className="action-error" role="alert">
529+
<XCircle size={16} />
530+
<span>{actionError}</span>
531+
<button className="dismiss-button" onClick={() => setActionError(null)} title="Dismiss">
532+
Dismiss
533+
</button>
534+
</div>
535+
)}
536+
492537
<section className="metrics">
493538
<Metric label="Events" value={overview?.totals.events ?? 0} icon={<Activity size={18} />} />
494539
<Metric
@@ -739,6 +784,9 @@ function RequestExplorer(props: {
739784
onChange: (filters: RequestFilters) => void;
740785
onRefresh: () => void;
741786
}) {
787+
const [selectedRequestId, setSelectedRequestId] = useState<string | null>(null);
788+
const selectedRequest = props.requests.find((request) => request.id === selectedRequestId) ?? null;
789+
742790
const setFilter = (key: keyof RequestFilters, value: string) => {
743791
props.onChange({ ...props.filters, [key]: value });
744792
};
@@ -804,7 +852,7 @@ function RequestExplorer(props: {
804852
</thead>
805853
<tbody>
806854
{props.requests.map((request) => (
807-
<tr key={request.id}>
855+
<tr key={request.id} className="request-row" onClick={() => setSelectedRequestId(request.id)}>
808856
<td>{new Date(request.timestamp).toLocaleTimeString()}</td>
809857
<td>{request.method}</td>
810858
<td>
@@ -829,10 +877,69 @@ function RequestExplorer(props: {
829877
</tbody>
830878
</table>
831879
</div>
880+
881+
{selectedRequest && <RequestDetail request={selectedRequest} onClose={() => setSelectedRequestId(null)} />}
832882
</Panel>
833883
);
834884
}
835885

886+
function RequestDetail(props: { request: RequestRecord; onClose: () => void }) {
887+
const r = props.request;
888+
const fields: Array<[string, string | number | undefined | null]> = [
889+
["Request ID", r.id],
890+
["Trace ID", r.trace_id],
891+
["Parent Span ID", r.parent_span_id],
892+
["Timestamp", new Date(r.timestamp).toLocaleString()],
893+
["Service", r.service_name],
894+
["Environment", r.environment],
895+
["Kind", r.kind],
896+
["Method", r.method],
897+
["Path", r.path],
898+
["Route", r.route],
899+
["IP", r.ip],
900+
["User Agent", r.user_agent],
901+
["Status Code", r.status_code],
902+
["Latency", `${r.latency_ms}ms`],
903+
["Body Bytes", r.body_bytes],
904+
["Auth Present", r.auth_present ? "Yes" : "No"],
905+
["Auth Failed", r.auth_failed ? "Yes" : "No"],
906+
["GraphQL Operation", r.graphql_operation_name],
907+
["GraphQL Operation Type", r.graphql_operation_type],
908+
["EVM RPC Method", r.evm_rpc_method],
909+
["EVM Chain ID", r.evm_chain_id],
910+
["EVM Provider", r.evm_provider],
911+
["Wallet Address", r.wallet_address],
912+
["Contract Address", r.contract_address],
913+
["Threat Score", r.threat_score],
914+
["Threat Severity", r.threat_severity]
915+
];
916+
917+
return (
918+
<div className="detail-overlay" onClick={props.onClose}>
919+
<div className="detail-panel" onClick={(event) => event.stopPropagation()}>
920+
<header>
921+
<h3>
922+
{r.method} {r.route ?? r.path}
923+
</h3>
924+
<button className="dismiss-button" onClick={props.onClose}>
925+
Close
926+
</button>
927+
</header>
928+
<dl>
929+
{fields
930+
.filter(([, value]) => value !== undefined && value !== null && value !== "")
931+
.map(([label, value]) => (
932+
<React.Fragment key={label}>
933+
<dt>{label}</dt>
934+
<dd>{value}</dd>
935+
</React.Fragment>
936+
))}
937+
</dl>
938+
</div>
939+
</div>
940+
);
941+
}
942+
836943
function IncidentPanel(props: {
837944
incidents: Incident[];
838945
filters: { status: string; severity: string };
@@ -1282,6 +1389,43 @@ async function apiFetch(path: string, projectId: string, init: RequestInit = {})
12821389
});
12831390
}
12841391

1392+
type ApiOutcome =
1393+
| { kind: "ok"; response: Response }
1394+
| { kind: "rejected"; response: Response; message: string }
1395+
| { kind: "unreachable" };
1396+
1397+
// Distinguishes a genuinely unreachable API (network error, Docker down — safe to fall back to
1398+
// offline demo mode) from a reachable API that rejected the request (must surface the real error
1399+
// rather than silently pretending the action succeeded).
1400+
async function submitToApi(request: () => Promise<Response>): Promise<ApiOutcome> {
1401+
let response: Response;
1402+
try {
1403+
response = await request();
1404+
} catch {
1405+
return { kind: "unreachable" };
1406+
}
1407+
1408+
if (!response.ok) {
1409+
return { kind: "rejected", response, message: await readErrorMessage(response) };
1410+
}
1411+
1412+
return { kind: "ok", response };
1413+
}
1414+
1415+
async function readErrorMessage(response: Response): Promise<string> {
1416+
try {
1417+
const body = (await response.json()) as { error?: string; details?: { fieldErrors?: Record<string, string[]> } };
1418+
const fieldMessage = Object.values(body.details?.fieldErrors ?? {})
1419+
.flat()
1420+
.find(Boolean);
1421+
if (fieldMessage) return fieldMessage;
1422+
if (body.error) return body.error.replace(/_/g, " ");
1423+
} catch {
1424+
// Response body wasn't JSON — fall through to the generic message below.
1425+
}
1426+
return `Request failed (${response.status}).`;
1427+
}
1428+
12851429
function getDemoProjectData(projectId: string) {
12861430
if (projectId === "checkout") {
12871431
return {

0 commit comments

Comments
 (0)