@@ -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+
836943function 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+
12851429function getDemoProjectData ( projectId : string ) {
12861430 if ( projectId === "checkout" ) {
12871431 return {
0 commit comments