go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/web/rpcexplorer/src/views/method.tsx (about) 1 // Copyright 2022 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 import { useEffect, useMemo, useRef, useState } from 'react'; 16 import { useParams, useSearchParams } from 'react-router-dom'; 17 18 import Alert from '@mui/material/Alert'; 19 import Box from '@mui/material/Box'; 20 import Button from '@mui/material/Button'; 21 import FormControlLabel from '@mui/material/FormControlLabel'; 22 import FormGroup from '@mui/material/FormGroup'; 23 import Grid from '@mui/material/Grid'; 24 import LinearProgress from '@mui/material/LinearProgress'; 25 import Link from '@mui/material/Link'; 26 import Switch from '@mui/material/Switch'; 27 import Tooltip from '@mui/material/Tooltip'; 28 29 import { useGlobals } from '../context/globals'; 30 31 import { AuthMethod, AuthSelector } from '../components/auth_selector'; 32 import { Doc } from '../components/doc'; 33 import { ErrorAlert } from '../components/error_alert'; 34 import { ExecuteIcon } from '../components/icons'; 35 import { OAuthError } from '../data/oauth'; 36 37 import { RequestEditor, RequestEditorRef } from '../components/request_editor'; 38 import { ResponseEditor } from '../components/response_editor'; 39 40 import { generateTraceID } from '../data/prpc'; 41 42 43 interface TraceInfo { 44 duration: number; 45 traceID: string; 46 traceURL: string; 47 } 48 49 50 const Method = () => { 51 const { serviceName, methodName } = useParams(); 52 const [searchParams, setSearchParams] = useSearchParams(); 53 const { descriptors, tokenClient } = useGlobals(); 54 const [authMethod, setAuthMethod] = useState(AuthMethod.load()); 55 const [tracingOn, setTracingOn] = useState(false); 56 const [running, setRunning] = useState(false); 57 const [response, setResponse] = useState(''); 58 const [traceInfo, setTraceInfo] = useState<TraceInfo | null>(null); 59 const [error, setError] = useState<Error | null>(null); 60 61 // Request editor is used via imperative methods since it can be too sluggish 62 // to update on key presses otherwise. 63 const requestEditor = useRef<RequestEditorRef>(null); 64 65 // Initial request body can be passed via `request` query parameter. 66 // Pretty-print it if it is a valid JSON. Memo this to avoid reparsing 67 // potentially large JSON all the time. 68 let initialRequest = searchParams.get('request') || '{}'; 69 initialRequest = useMemo(() => { 70 try { 71 return JSON.stringify(JSON.parse(initialRequest), null, 2); 72 } catch { 73 return initialRequest; 74 } 75 }, [initialRequest]); 76 77 // Persist changes to `authMethod` in the local storage. 78 useEffect(() => AuthMethod.store(authMethod), [authMethod]); 79 80 // Find the method descriptor. It will be used for auto-completion and for 81 // actually invoking the method. 82 const svc = descriptors.service(serviceName ?? 'unknown'); 83 if (svc === undefined) { 84 return ( 85 <Alert severity='error'> 86 Service <b>{serviceName ?? 'unknown'}</b> is not 87 registered in the server. 88 </Alert> 89 ); 90 } 91 const method = svc.method(methodName ?? 'unknown'); 92 if (method === undefined) { 93 return ( 94 <Alert severity='error'> 95 Method <b>{methodName ?? 'unknown'}</b> is not a part of 96 <b>{serviceName ?? 'unknown'}</b> service. 97 </Alert> 98 ); 99 } 100 101 const invokeMethod = () => { 102 if (!requestEditor.current) { 103 return; 104 } 105 106 // Try to get a parsed JSON request from the editor. Catch bad JSON errors. 107 let parsedReq: object; 108 try { 109 parsedReq = requestEditor.current.prepareRequest(); 110 } catch (err) { 111 if (err instanceof Error) { 112 setError(err); 113 } else { 114 setError(new Error(`${err}`)); 115 } 116 return; 117 } 118 119 // Use compact request serialization (strip spaces etc). 120 const normalizedReq = JSON.stringify(parsedReq); 121 122 // Update the current location to allow copy-pasting this request via URI. 123 setSearchParams((params) => { 124 if (normalizedReq != '{}') { 125 params.set('request', normalizedReq); 126 } else { 127 params.delete('request'); 128 } 129 return params; 130 }, { replace: true }); 131 132 // Deactivate the UI while the request is running. 133 setRunning(true); 134 setTraceInfo(null); 135 setError(null); 136 137 // Prepare trace ID if asked to trace the request. 138 const traceID = tracingOn ? generateTraceID() : ''; 139 let started = Date.now(); 140 141 // Grabs the authentication header and invokes the method. 142 const authAndInvoke = async () => { 143 let authorization = ''; 144 if (tokenClient.sessionState != 'loggedout') { 145 if (authMethod == AuthMethod.OAuth) { 146 authorization = `Bearer ${await tokenClient.accessToken()}`; 147 } 148 } 149 started = Date.now(); // restart the timer after getting a token 150 return await method.invoke(normalizedReq, authorization, traceID); 151 }; 152 authAndInvoke() 153 .then((response) => { 154 setResponse(response); 155 setError(null); 156 }) 157 .catch((error) => { 158 // Canceled OAuth flow is a user-initiated error, don't show it. 159 if (!(error instanceof OAuthError && error.cancelled)) { 160 setResponse(''); 161 setError(error); 162 } 163 }) 164 .finally(() => { 165 // Always show tracing info if asked, even on errors. 166 if (traceID) { 167 setTraceInfo({ 168 duration: Date.now() - started, 169 traceID: traceID, 170 traceURL: 'https://console.cloud.google.com/' + 171 `traces/list?tid=${traceID}`, 172 }); 173 } 174 // Reactivate the UI. 175 setRunning(false); 176 }); 177 }; 178 179 return ( 180 <Grid container spacing={2}> 181 <Grid item xs={12}> 182 <Doc markdown={method.doc} /> 183 </Grid> 184 185 <Grid item xs={12}> 186 <RequestEditor 187 ref={requestEditor} 188 requestType={descriptors.message(method.requestType)} 189 defaultValue={initialRequest} 190 readOnly={running} 191 onInvokeMethod={invokeMethod} 192 /> 193 </Grid> 194 195 <Grid item xs={2}> 196 <Button 197 variant='outlined' 198 disabled={running} 199 onClick={invokeMethod} 200 endIcon={<ExecuteIcon />}> 201 Execute 202 </Button> 203 </Grid> 204 205 <Grid item xs={8}> 206 <Box> 207 <AuthSelector 208 selected={authMethod} 209 onChange={setAuthMethod} 210 disabled={running} 211 anonOnly={tokenClient.sessionState == 'loggedout'} 212 /> 213 </Box> 214 </Grid> 215 216 <Grid item xs={2}> 217 <FormGroup> 218 <Tooltip title={ 219 'Attach Cloud Trace trace ID to the request. ' + 220 'Only works if the backend is configured to upload tracing data ' + 221 'to Cloud Trace.' 222 }> 223 <FormControlLabel control={ 224 <Switch 225 checked={tracingOn} 226 onChange={(_, checked) => setTracingOn(checked)} 227 /> 228 } label="Trace" /> 229 </Tooltip> 230 </FormGroup> 231 </Grid> 232 233 {traceInfo && 234 <Grid item xs={12}> 235 <Alert variant="outlined" icon={false} severity="info"> 236 {`Done in ${traceInfo.duration} ms. Trace ID is `} 237 <Link target="_blank" rel="noreferrer" href={traceInfo.traceURL}> 238 {traceInfo.traceID} 239 </Link> 240 {'.'} 241 </Alert> 242 </Grid> 243 } 244 {error && 245 <Grid item xs={12}> 246 <ErrorAlert error={error} /> 247 </Grid> 248 } 249 250 {running && 251 <Grid item xs={12}> 252 <LinearProgress /> 253 </Grid> 254 } 255 256 <Grid item xs={12}> 257 <ResponseEditor value={response} /> 258 </Grid> 259 </Grid> 260 ); 261 }; 262 263 264 export default Method;