// Copyright (c) 2021 Terminus, Inc. // // This program is free software: you can use, redistribute, and/or modify // it under the terms of the GNU Affero General Public License, version 3 // or later ("AGPL"), as published by the Free Software Foundation. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <http://www.gnu.org/licenses/>. import React from 'react'; import DiceConfigPage from 'app/config-page'; import { ErrorBoundary, FileEditor, ErdaIcon } from 'common'; import { Button, message, Input, Checkbox, Tooltip } from 'antd'; import routeInfoStore from 'core/stores/route'; import { statusColorMap } from 'app/config-page/utils'; import { useUpdateEffect, useThrottleFn } from 'react-use'; import { get } from 'lodash'; import { Form as PureForm } from 'dop/pages/form-editor/index'; import agent from 'agent'; import moment from 'moment'; import './debug.scss'; const stateIconMap = { success: <ErdaIcon type="check-one" fill={statusColorMap.success} />, error: <ErdaIcon type="close-one" fill={statusColorMap.error} />, }; const DebugConfigPage = () => { const pageRef = React.useRef(null); const cacheData = window.localStorage.getItem('config-page-debug'); const [text, setText] = React.useState(cacheData || defaultJson); const [logs, setLogs] = React.useState<ILog[]>([]); const [showCode, setShowCode] = React.useState(true); const [showLog, setShowLog] = React.useState(false); const [importValue, setImportValue] = React.useState(''); const [activeLog, setActiveLog] = React.useState(0); const { url, scenario, debug, ...restQuery } = routeInfoStore.useStore((s) => s.query); const [proxyApi, setProxyApi] = React.useState(url); const _defaultData = scenario ? { scenario: { scenarioKey: scenario, scenarioType: scenario, }, inParams: restQuery, } : defaultData; const [config, setConfig] = React.useState(_defaultData); useThrottleFn<string, any>( (newText) => { window.localStorage.setItem('config-page-debug', newText); return newText; }, 5000, [text], ); useUpdateEffect(() => { if (activeLog) { setConfig(logs?.[activeLog - 1]?.pageData); } }, [activeLog]); const getMock = React.useCallback( (payload: any) => { return agent .post(proxyApi) .send(payload) .then((response: any) => { return response.body.protocol ? response.body : response.body.data; }); }, [proxyApi], ); if (!debug) { return <DiceConfigPage scenarioType={scenario} scenarioKey={scenario} inParams={restQuery} />; } const updateMock = (_text?: string) => { try { const obj = new Function(`return ${_text || text}`)(); setConfig(obj); } catch (error) { message.error('内容有错误'); } }; const onExecOp = ({ cId, op, reload, updateInfo, pageData }: any) => { setLogs((prev) => { const reLogs = prev.concat({ time: moment().format('HH:mm:ss'), type: '操作', cId, opKey: op.text || op.key, command: JSON.stringify(op.command, null, 2), reload, data: JSON.stringify(updateInfo, null, 2), pageData, }); setActiveLog(reLogs.length); return reLogs; }); }; const exportLog = () => { const reLogs = logs.map((item) => { const { assertList, ...rest } = item; const _asserts = assertList?.filter((aItem) => { return aItem.key && aItem.operator && aItem.value; }); return { ...rest, assertList: _asserts }; }); const blob = new Blob([JSON.stringify(reLogs, null, 2)], { type: 'application/vnd.ms-excel;charset=utf-8' }); const fileName = `assert-log.txt`; const objectUrl = URL.createObjectURL(blob); const downloadLink = document.createElement('a'); downloadLink.href = objectUrl; downloadLink.setAttribute('download', fileName); document.body.appendChild(downloadLink); downloadLink.click(); window.URL.revokeObjectURL(downloadLink.href); }; const importLog = () => { try { const obj = new Function(`return ${importValue}`)(); setLogs(obj || []); setActiveLog((obj || []).length); } catch (error) { message.error('内容有错误'); } }; return ( <div className="h-full debug-page-container flex flex-col ml-4"> <div className="flex justify-between mb-1 item-center"> <div className="w-52"> <Checkbox checked={showCode} onChange={(e) => setShowCode(e.target.checked)}> 代码 </Checkbox> <Checkbox className="ml-2" checked={showLog} onChange={(e) => setShowLog(e.target.checked)}> 日志 </Checkbox> </div> <Input value={proxyApi} size="small" onChange={(e) => setProxyApi(e.target.value)} /> </div> <div className="debug-page flex-1 h-0 flex justify-between items-center"> <div className={`flex flex-col left h-full ${showCode || showLog ? '' : 'hide-left'}`}> {showCode ? ( <div className="flex-1"> <FileEditor autoHeight fileExtension="json" valueLimit={false} value={text} onChange={(_text) => { setText(_text); updateMock(_text); }} /> <Button type="primary" className="update-button" onClick={() => updateMock()}> 更新 </Button> <Button type="primary" className="request-button" onClick={() => { pageRef.current.reload(config); }} > 请求 </Button> </div> ) : null} {showLog ? ( <div className={`log-panel mt-2`}> <h3> 操作日志 <span className="ml-2 fake-link" onClick={() => { setLogs([]); setActiveLog(0); }} > 清空 </span> <span className="ml-2 fake-link" onClick={exportLog}> 导出 </span> <Tooltip overlayStyle={{ width: 400, maxWidth: 400 }} title={ <div> <Input.TextArea value={importValue} onChange={(e) => setImportValue(e.target.value)} /> <Button size="small" type="primary" onClick={importLog}> 导入 </Button> </div> } > <span className="ml-2 fake-link" onClick={importLog}> 导入 </span> </Tooltip> </h3> {logs.map((log, i) => { return ( <LogItem key={i} index={i + 1} activeLog={activeLog} setActiveLog={(l) => setActiveLog(l)} log={log} setLog={(_log) => setLogs((prev) => prev.map((item, idx) => (idx === i ? _log : item)))} /> ); })} </div> ) : null} </div> <div className={`right overflow-auto h-full ${showCode || showLog ? '' : 'full-right'}`}> <ErrorBoundary> <DiceConfigPage ref={pageRef} showLoading scenarioType={scenario || config?.scenario?.scenarioType} scenarioKey={scenario || config?.scenario?.scenarioKey} inParams={config?.inParams} debugConfig={config} onExecOp={onExecOp} useMock={getMock} forceMock={!!proxyApi} updateConfig={(v) => { setConfig(v); setText(JSON.stringify(v, null, 2)); }} /> </ErrorBoundary> </div> </div> </div> ); }; export default DebugConfigPage; interface ILogItemProps { index: number; log: ILog; activeLog: number; setLog: (log: ILog) => void; setActiveLog: (n: number) => void; } interface ILog { time: string; reload: boolean; type: string; cId: string; opKey: string; data: Obj; command: Obj; assertList: IAssert[]; pageData: Obj; } interface IAssert { key: string; operator: string; value: string; } const LogItem = (props: ILogItemProps) => { const { log, index, setLog, activeLog, setActiveLog } = props; const AssertForm = ( <PureForm onChange={(d) => { setLog({ ...log, assertList: d.assertList }); }} value={log} fields={[ { key: 'assertList', label: '断言', labelTip: '请依次填写断言key、比较、value', component: 'arrayObj', required: true, componentProps: { direction: 'row', objItems: [ { component: 'input', componentProps: { placeholder: '断言key值', size: 'small', }, key: 'key', labelTip: '例如:memberTable.state.value', options: 'k1:TCP', required: true, }, { component: 'select', options: getOperatorOptions(), key: 'operator', required: true, componentProps: { size: 'small', }, }, { component: 'input', componentProps: { placeholder: '断言值', size: 'small', }, key: 'value', required: true, }, { key: 'state', getComp: (assert: IAssert) => { const { key, operator, value } = assert; if (key && operator && value) { const curState = getAssertState(assert, log.pageData); return <div className="ml-1">{curState ? stateIconMap.success : stateIconMap.error}</div>; } return null; }, }, ], }, }, ]} /> ); const setActive = () => { activeLog !== index && setActiveLog(index); }; return ( <div className={`log-item py-2 cursor-pointer ${activeLog === index ? 'active-item' : ''}`} onClick={setActive}> <span> {index}: {log.reload && <ErdaIcon type="refresh1" />} {log.type} {log.cId}.{log.opKey} </span> {(log.data || log.command) && ( <Tooltip placement="top" overlayStyle={{ maxWidth: 600 }} title={ <div> {log.command ? ( <> <span>command: </span> <pre className="code-block overflow-auto" style={{ maxHeight: 200 }}> {log.command} </pre> </> ) : null} {log.data ? ( <> <span>data: </span> <pre className="code-block overflow-auto mt-2" style={{ maxHeight: 200 }}> {log.data} </pre> </> ) : null} </div> } > <span className="fake-link px-1">查看数据</span> </Tooltip> )} <Tooltip title={AssertForm} trigger="click" placement="right" overlayStyle={{ width: 600, maxWidth: 600 }}> <span className="px-1 fake-link">断言</span> </Tooltip> </div> ); }; const operatorMap = [ { key: '=', name: '等于' }, { key: '!=', name: '不等于' }, { key: '>', name: '大于' }, { key: '>=', name: '大等于' }, { key: '<', name: '小于' }, { key: '<=', name: '小等于' }, { key: 'includesBy', name: '包含于' }, { key: 'includes', name: '包含' }, ]; const getOperatorOptions = () => { return operatorMap.map(({ key, name }) => `${key}:${name}`).join(';'); }; const getAssertState = (assert: IAssert, pageData: Obj) => { const { key, value, operator } = assert; let state = false; const curValue = JSON.stringify(get(pageData, key)); switch (operator) { case '=': state = curValue === value; break; case '!=': state = curValue !== value; break; case '>': state = curValue > value; break; case '>=': state = curValue >= value; break; case '<': state = curValue < value; break; case '<=': state = curValue <= value; break; case 'includesBy': state = value.includes(curValue); break; case 'includes': state = curValue?.includes(value); break; default: break; } return state; }; const defaultData = { scenario: { scenarioType: 'project-list-my', scenarioKey: 'project-list-my', }, inParams: {}, }; const defaultJson = JSON.stringify(defaultData, null, 2);