Implement inference UI with image viewer

This commit is contained in:
Viet Anh Nguyen 2024-03-08 17:47:12 +07:00
parent 02bf41fd9a
commit 0db051470e
5 changed files with 638 additions and 102 deletions

View File

@ -1,3 +1,3 @@
VITE_PORT=8080
VITE_PROXY=https://107.120.133.22/
VITE_PROXY=http://42.96.42.13:9881
VITE_KUBEFLOW_HOST=https://107.120.133.22:8085

View File

@ -1,21 +1,226 @@
import { t } from '@lingui/macro';
import { Button, message, Upload } from 'antd';
import { Button, Input, Table, Tag, DatePicker, Form, Modal, Select, Spin, message, Upload } from 'antd';
import React, { useContext, useEffect, useRef, useState } from 'react';
import type { GetRef } from 'antd';
import { Layout } from 'antd';
import {
DownloadOutlined, CheckCircleOutlined,
ClockCircleFilled,
} from '@ant-design/icons';
import styled from 'styled-components';
const { Sider, Content } = Layout;
import { baseURL } from "request/api";
import { useHotkeys } from "react-hotkeys-hook";
import { Viewer } from '@react-pdf-viewer/core';
import { SbtPageHeader } from 'components/page-header';
import { useState } from 'react';
import { UploadOutlined } from '@ant-design/icons';
import type { GetProp, UploadFile, UploadProps } from 'antd';
import { JsonView, allExpanded, defaultStyles } from 'react-json-view-lite';
import 'react-json-view-lite/dist/index.css';
import { baseURL } from "request/api"
type FileType = Parameters<GetProp<UploadProps, 'beforeUpload'>>[0];
const ENABLE_REVIEW = false;
// Import the styles
import '@react-pdf-viewer/core/lib/styles/index.css';
const siderStyle: React.CSSProperties = {
backgroundColor: '#fafafa',
padding: 10,
width: 200,
};
const StyledTable = styled(Table)`
& .sbt-table-cell {
padding: 4px!important;
}
`;
type InputRef = GetRef<typeof Input>;
type FormInstance<T> = GetRef<typeof Form<T>>;
const EditableContext = React.createContext<FormInstance<any> | null>(null);
interface Item {
key: string;
accuracy: number;
revised: string;
action: string;
}
interface EditableRowProps {
index: number;
}
const EditableRow: React.FC<EditableRowProps> = ({ index, ...props }) => {
const [form] = Form.useForm();
return (
<Form form={form} component={false}>
<EditableContext.Provider value={form}>
<tr {...props} />
</EditableContext.Provider>
</Form>
);
};
interface EditableCellProps {
title: React.ReactNode;
editable: boolean;
children: React.ReactNode;
dataIndex: keyof Item;
record: Item;
handleSave: (record: Item) => void;
}
const EditableCell: React.FC<EditableCellProps> = ({
title,
editable,
children,
dataIndex,
record,
handleSave,
...restProps
}) => {
const [editing, setEditing] = useState(false);
const inputRef = useRef<InputRef>(null);
const form = useContext(EditableContext)!;
useEffect(() => {
if (editing) {
inputRef.current!.focus();
}
}, [editing]);
const toggleEdit = () => {
setEditing(!editing);
form.setFieldsValue({ [dataIndex]: record[dataIndex] });
};
const save = async () => {
try {
const values = await form.validateFields();
toggleEdit();
handleSave({ ...record, ...values });
} catch (errInfo) {
console.log('Save failed:', errInfo);
}
};
let childNode = children;
if (editable) {
childNode = editing ? (
<Form.Item
style={{ margin: 0 }}
name={dataIndex}
rules={[
{
required: true,
message: `${title} is required.`,
},
]}
>
<Input ref={inputRef} onPressEnter={save} onBlur={save} />
</Form.Item>
) : (
<div className="editable-cell-value-wrap" style={{ paddingRight: 24 }} onClick={toggleEdit}>
{children}
</div>
);
}
return <td {...restProps}>{childNode}</td>;
};
// type EditableTableProps = Parameters<typeof Table>[0];
const FileCard = ({ file, isSelected, onClick, setIsReasonModalOpen }) => {
const fileName = file["File Name"];
return (
<div style={{
border: '1px solid #ccc',
width: '200px',
backgroundColor: isSelected ? '#d4ecff' : '#fff',
padding: '4px 8px',
marginRight: '4px',
marginTop: '2px',
position: 'relative',
height: '100px',
overflow: 'hidden',
}} onClick={onClick}>
<div>
<span style={{
fontSize: '12px',
color: '#333',
fontWeight: 'bold',
padding: '4px 8px',
cursor: 'default',
}}>{file["Doc Type"].toUpperCase()}</span>
<span style={{
fontSize: '12px',
color: '#aaa',
fontWeight: 'bold',
padding: '4px 8px',
cursor: 'default',
maxWidth: '50px',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}>
{fileName ? fileName.substring(0, 25).replace("temp_", "") : fileName}
</span>
</div>
<div style={{
padding: '4px',
position: 'absolute',
bottom: 2,
right: 2,
}}>
<Button style={{
margin: '4px 2px',
}}
onClick={() => {
setIsReasonModalOpen(true);
}}
>
Review
</Button>
<Button style={{
margin: '4px 2px',
}} onClick={() => {
const downloadUrl = file["File URL"];
window.open(downloadUrl, '_blank');
}}>
<DownloadOutlined />
</Button>
</div>
</div>
);
};
const fetchRequest = async (id) => {
const token = localStorage.getItem('sbt-token') || '';
const response = await fetch(`${baseURL}/ctel/request/${id}/`, {
method: 'GET',
headers: {
"Authorization": `${JSON.parse(token)}`
}
});
return await (await response.json()).subscription_requests[0];
};
const InferencePage = () => {
const [invoiceFiles, setInvoiceFiles] = useState<UploadFile[]>([]);
const [imei1Files, setImei1Files] = useState<UploadFile[]>([]);
const [imei2Files, setImei2Files] = useState<UploadFile[]>([]);
const [uploading, setUploading] = useState(false);
const [jsonData, setJsonData] = useState({});
const [responseData, setResponseData] = useState(null);
const [finishedProcessing, setFinishedProcessing] = useState(false);
const handleUpload = () => {
@ -31,9 +236,7 @@ const InferencePage = () => {
}
formData.append('is_test_request', 'true');
setUploading(true);
setJsonData({
"message": "Please wait..."
})
setResponseData(null);
const token = localStorage.getItem('sbt-token') || '';
fetch(`${baseURL}/ctel/images/process_sync/`, {
method: 'POST',
@ -44,31 +247,223 @@ const InferencePage = () => {
})
.then(async (res) => {
const data = await res.json();
setJsonData(data);
if (data["status"] != "200") {
setResponseData(null);
return;
}
loadRequestById(data["request_id"]);
setFinishedProcessing(true);
return data;
})
.then(() => {
message.success('Upload successfully.');
})
.catch(() => {
.catch((e) => {
console.log(e);
message.error('Upload failed.');
})
.finally(() => {
setUploading(false);
});
};
const [loading, setLoading] = useState(false);
const [isReasonModalOpen, setIsReasonModalOpen] = useState(false);
const [selectedFileId, setSelectedFileId] = useState(0);
const [selectedFileData, setSelectedFileData] = useState(null);
const [selectedFileName, setSelectedFileName] = useState(null);
const [currentRequest, setCurrentRequest] = useState(null);
const [totalRequests, setTotalPages] = useState(0);
const [dataSource, setDataSource] = useState([]);
const setAndLoadSelectedFile = async (requestData, index) => {
setSelectedFileId(index);
if (!requestData["Files"][index]) {
setSelectedFileData("FAILED_TO_LOAD_FILE");
return;
};
const fileName = requestData["Files"][index]["File Name"];
const fileURL = requestData["Files"][index]["File URL"];
const response = await fetch(fileURL);
if (response.status === 200) {
setSelectedFileName(fileName);
setSelectedFileData(fileURL);
console.log("Loading file: " + fileName);
console.log("URL: " + fileURL);
} else {
setSelectedFileData("FAILED_TO_LOAD_FILE");
}
};
const loadRequestById = (requestID) => {
setLoading(true);
const requestData = fetchRequest(requestID);
requestData.then(async (data) => {
if (data) setCurrentRequest(data);
const predicted = (data && data["Reviewed Result"]) ? data["Reviewed Result"] : {};
const revised = (data && data["Reviewed Result"]) ? data["Reviewed Result"] : {};
const keys = Object.keys(predicted);
const tableRows = [];
for (let i = 0; i < keys.length; i++) {
let instance = {};
instance["key"] = keys[i];
instance["predicted"] = predicted[keys[i]];
instance["revised"] = revised[keys[i]];
tableRows.push(instance);
}
setDataSource(tableRows);
setLoading(false);
setAndLoadSelectedFile(data, 0);
}).finally(() => {
setLoading(false);
});
};
const components = {
body: {
row: EditableRow,
cell: EditableCell,
},
};
// "Key", "Accuracy", "Revised"
interface DataType {
key: string;
accuracy: number;
revised: string;
};
const updateRevisedData = async (newRevisedData: any) => {
const requestID = newRevisedData.request_id;
const token = localStorage.getItem('sbt-token') || '';
await fetch(`${baseURL}/ctel/request/${requestID}/`, {
method: 'POST',
headers: {
"Authorization": `${JSON.parse(token)}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"reviewed_result": newRevisedData
}),
}).catch((error) => {
console.log(error);
message.error("Could not update revised data");
});
};
const handleSave = (row: DataType) => {
const newData = [...dataSource];
const index = newData.findIndex((item) => row.key === item.key);
const item = newData[index];
newData.splice(index, 1, {
...item,
...row,
});
setDataSource(newData);
const newRevisedData = {};
for (let i = 0; i < newData.length; i++) {
newRevisedData[newData[i].key] = newData[i].revised;
}
updateRevisedData(newRevisedData).then(() => {
// "[Is Reviewed]" => true
setCurrentRequest({
...currentRequest,
["Is Reviewed"]: true,
})
})
};
const submitRevisedData = async () => {
const newData = [...dataSource];
const newRevisedData = {};
for (let i = 0; i < newData.length; i++) {
newRevisedData[newData[i].key] = newData[i].revised;
}
console.log(currentRequest)
updateRevisedData(newRevisedData).then(() => {
// "[Is Reviewed]" => true
setCurrentRequest({
...currentRequest,
["Is Reviewed"]: true,
})
})
};
const defaultColumns = [
{
title: 'Key',
dataIndex: 'key',
key: 'key',
width: 200,
},
{
title: 'Predicted',
dataIndex: 'predicted',
key: 'predicted',
},
{
title: (<div style={{
width: 120,
display: 'flex',
lineHeight: '32px',
marginLeft: 10,
}}>Revised&nbsp;&nbsp;
{ENABLE_REVIEW && <Button
onClick={() => {
if (!dataSource || !dataSource.length) return;
setDataSource(dataSource.map(item => {
item.revised = item.predicted;
return item;
}));
setTimeout(() => {
submitRevisedData();
}, 1000);
}}
>
Copy Predicted
</Button>}
</div>),
dataIndex: 'revised',
key: 'revised',
editable: ENABLE_REVIEW,
},
];
const columns = defaultColumns.map((col) => {
if (!col.editable) {
return col;
}
return {
...col,
onCell: (record: DataType) => ({
record,
editable: col.key != "request_id" && col.editable,
dataIndex: col.dataIndex,
title: col.title,
handleSave,
}),
};
});
const fileExtension = selectedFileName ? selectedFileName.split('.').pop() : '';
return (
<>
<div style={{
height: '100%',
position: 'relative',
}}>
<SbtPageHeader
title={t`Inference`}
/>
<p>
{t`Upload files to process. The requests here will not be used in accuracy or payment calculations.`}
</p>
<div style={{
paddingTop: "0.5rem"
display: 'flex',
flexDirection: 'row',
}}>
<div style={{
paddingTop: "0.5rem",
padding: "0.5rem",
height: "80px",
}}>
<Upload
onRemove={(file) => {
@ -82,11 +477,13 @@ const InferencePage = () => {
}}
fileList={invoiceFiles}
>
Invoice File: <Button disabled={finishedProcessing} icon={<UploadOutlined />}>Select Image/PDF</Button>
Invoice: <Button disabled={finishedProcessing} icon={<UploadOutlined />}>Select Image/PDF</Button>
</Upload>
</div>
<div style={{
paddingTop: "0.5rem"
paddingTop: "0.5rem",
padding: "0.5rem",
height: "80px",
}}>
<Upload
onRemove={(file) => {
@ -100,11 +497,13 @@ const InferencePage = () => {
}}
fileList={imei1Files}
>
IMEI File 1: <Button disabled={finishedProcessing} icon={<UploadOutlined />}>Select Image</Button>
IMEI 1: <Button disabled={finishedProcessing} icon={<UploadOutlined />}>Select Image</Button>
</Upload>
</div>
<div style={{
paddingTop: "0.5rem"
paddingTop: "0.5rem",
padding: "0.5rem",
height: "80px",
}}>
<Upload
onRemove={(file) => {
@ -118,15 +517,21 @@ const InferencePage = () => {
}}
fileList={imei2Files}
>
IMEI File 2: <Button disabled={finishedProcessing} icon={<UploadOutlined />}>Select Image</Button>
IMEI 2: <Button disabled={finishedProcessing} icon={<UploadOutlined />}>Select Image</Button>
</Upload>
</div>
<div style={{
paddingTop: "0.5rem",
padding: "0.5rem",
height: "80px",
display: 'flex',
flexDirection: 'row',
}}>
{!finishedProcessing && <Button
type="primary"
onClick={handleUpload}
disabled={imei1Files.length === 0 && imei2Files.length === 0}
loading={uploading}
style={{ marginTop: 16, marginBottom: 24 }}
>
{uploading ? 'Uploading' : 'Process Data'}
</Button>}
@ -134,20 +539,149 @@ const InferencePage = () => {
type="primary"
onClick={() => {
setFinishedProcessing(false);
setJsonData({});
setResponseData(null);
setInvoiceFiles([]);
setImei1Files([]);
setImei2Files([]);
}}
style={{ marginTop: 16, marginBottom: 24 }}
>
Reset
</Button>}
<div style={{
paddingTop: "0.5rem"
</div>
</div>
<div
style={{ height: '100%', position: 'absolute', top: 0, left: 0, width: '100%', background: "#00000033", zIndex: 1000, display: loading ? 'block' : 'none' }}
>
<Spin spinning={true} style={{
position: 'absolute',
top: '50%',
left: '50%',
marginTop: -12,
marginLeft: -12,
width: 24,
height: 24,
borderRadius: '50%',
}} size='large' />
</div>
<Layout style={{
overflow: 'auto',
width: '100%',
height: '100%',
maxWidth: '100%',
minHeight: '70%',
maxHeight: '70%',
display: 'flex',
padding: '8px',
}}>
<h3>Result:</h3>
<JsonView data={jsonData} shouldExpandNode={allExpanded} style={defaultStyles} />
<Content style={{
textAlign: 'center',
color: '#fff',
backgroundColor: '#efefef',
height: '100%',
display: 'flex',
flexDirection: 'row',
}}>
{currentRequest?.Files?.length && <div
style={{
width: "200px",
display: "flex",
flexDirection: "column",
flexGrow: 0,
}}>
<h2
style={{
color: "#333",
padding: 10,
fontWeight: 'bold',
fontSize: 18,
textTransform: 'uppercase'
}}
>Files ({currentRequest?.Files?.length})</h2>
{currentRequest?.Files.map((file, index) => (
<FileCard key={index} file={file} isSelected={index === selectedFileId} onClick={
() => {
setAndLoadSelectedFile(currentRequest, index);
}
} setIsReasonModalOpen={setIsReasonModalOpen} />
))}
</div>}
{selectedFileData && <div style={{
border: "1px solid #ccc",
flexGrow: 1,
height: '100%',
}}>
{selectedFileData === "FAILED_TO_LOAD_FILE" ? <p style={{ color: "#333" }}></p> : (fileExtension === "pdf" ? (<Viewer
fileUrl={selectedFileData}
/>) : <div style={{
height: "100%",
width: "100%",
overflow: "auto",
}}><img width={"100%"} src={selectedFileData} alt="file" /></div>)}
</div>}
</Content>
<Sider width="400px" style={siderStyle}>
<div>
<Input size='small' addonBefore="Request ID" style={{ marginBottom: "4px" }} readOnly value={currentRequest ? currentRequest.RequestID : ""} />
<Input size='small' addonBefore="Redemption" style={{ marginBottom: "4px" }} readOnly value={currentRequest?.RedemptionID ? currentRequest.RedemptionID : ""} />
<Input size='small' addonBefore="Uploaded date" style={{ marginBottom: "4px" }} readOnly value={currentRequest ? currentRequest.created_at : ""} />
<Input size='small' addonBefore="Request time" style={{ marginBottom: "4px" }} readOnly value={currentRequest ? currentRequest["Client Request Time (ms)"] : ""} />
<Input size='small' addonBefore="Processing time" style={{ marginBottom: "4px" }} readOnly value={currentRequest ? currentRequest["Server Processing Time (ms)"] : ""} />
<div style={{ marginBottom: "8px", marginTop: "8px", display: "flex" }}>
{currentRequest && (currentRequest["Is Reviewed"] ? <Tag icon={<CheckCircleOutlined />} color="success" style={{ padding: "4px 16px" }}>
Reviewed
</Tag> : <Tag icon={<ClockCircleFilled />} color="warning" style={{ padding: "4px 16px" }}>
Not Reviewed
</Tag>)}
</div>
</div>
</Sider>
</Layout>
<Modal
title={t`Review`}
open={isReasonModalOpen}
width={700}
onOk={
() => {
}
}
onCancel={
() => {
setIsReasonModalOpen(false);
}
}
>
<Form
style={{
marginTop: 30,
}}
>
<Form.Item
name='reason'
label={t`Reason for bad quality:`}
style={{
marginBottom: 10,
}}
>
<Select
placeholder='Select a reason'
style={{ width: 200 }}
options={[
{ value: 'invalid_image', label: t`Invalid image` },
{ value: 'missing_information', label: t`Missing information` },
{ value: 'too_blurry_text', label: t`Too blurry text` },
{ value: 'too_small_text', label: t`Too small text` },
{ value: 'handwritten', label: t`Handwritten` },
{ value: 'recheck', label: t`Recheck` },
]}
/>
</Form.Item>
</Form>
</Modal>
<StyledTable components={components}
rowClassName={() => 'editable-row'}
bordered dataSource={dataSource} columns={columns}
/>
</div>
</>
);

View File

@ -11,7 +11,9 @@ const environment = process.env.NODE_ENV;
const AXIOS_TIMEOUT_MS = 30 * 60 * 1000; // This config sastified long-live upload file request
const EXPIRED_PASSWORD_SIGNAL = 'expired_password';
export const baseURL = environment === 'development' ? 'http://42.96.42.13:9881/api' : '/api';
// export const baseURL = environment === 'development' ? 'http://42.96.42.13:9881/api' : '/api';
export const baseURL = '/api';
export const API = axios.create({
timeout: AXIOS_TIMEOUT_MS,

View File

@ -9,7 +9,7 @@ import { PrivateRoute, PublicRoute } from './guard-route';
const LoginPage = React.lazy(() => import('pages/login'));
const DashboardPage = React.lazy(() => import('pages/dashboard'));
const InferencePage = React.lazy(() => import('pages/inference'));
const InferencePage = React.lazy(() => import('pages/inference/index'));
const ReviewsPage = React.lazy(() => import('pages/reviews'));
const ReportsPage = React.lazy(() => import('pages/reports'));

View File

@ -45,11 +45,11 @@ export default defineConfig(({ mode = 'development' }) => {
viteTsconfigPaths(),
svgrPlugin(),
],
// server: {
// open: true,
// port: configPort(env),
// proxy: configProxy(env),
// },
server: {
open: true,
port: configPort(env),
proxy: configProxy(env),
},
define: {
'process.env': env,
},