Merge pull request #78 from DeronW/dev

feat(homepage): add online detection page
This commit is contained in:
delong.wang
2023-05-22 16:35:18 +08:00
committed by GitHub
16 changed files with 9261 additions and 3483 deletions

3
homepage/.npmrc Normal file
View File

@@ -0,0 +1,3 @@
save-prefix=""
engine-strict=true
registry="https://registry.npmmirror.com"

View File

@@ -1,10 +1,32 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: false,
const isProduction = process.env.NODE_ENV == "production";
const isDevelopment = process.env.NODE_ENV == "development";
const prodConfig = {
output: "export",
images: {
unoptimized: true,
};
const devConfig = {
async rewrites() {
return [
{
source: "/api/poc/:path*",
destination:
"https://waf-ce.chaitin.cn/api/poc/:path*",
},
];
},
};
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: false,
images: { unoptimized: true },
};
Object.assign(
nextConfig,
isProduction && prodConfig,
isDevelopment && devConfig
);
module.exports = nextConfig;

6043
homepage/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -25,6 +25,7 @@
"eslint-config-next": "13.3.0",
"github-slugger": "^2.0.0",
"gray-matter": "^4.0.3",
"highlight.js": "11.8.0",
"next": "13.3.0",
"next-mdx-remote": "^4.4.1",
"react": "18.2.0",

5818
homepage/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
export { submitSampleSet, getSampleSet, getSampleSetResult, getSampleDetail };
const BASE_API = "/api/poc/";
function submitSampleSet(data: Array<{ content: string; tag: string }>) {
return fetch(BASE_API + "list", {
method: "POST",
headers: { "Content-Type": "application/json" },
mode: "cors",
// credentials: "include",
body: JSON.stringify(data),
}).then((res) => res.json());
}
function getSampleSet(id: string) {
return fetch(BASE_API + "list?id=" + id).then((res) => res.json());
}
function getSampleDetail(id: string) {
return fetch(BASE_API + "detail?id=" + id).then((res) => res.json());
}
async function getSampleSetResult(id: string, timeout: number = 60) {
const startAt = new Date().getTime();
const isTimeout = () => new Date().getTime() - startAt > timeout * 1000;
const maxRetry = 20;
for (let i = 0; i < maxRetry; i++) {
const res = await fetch(BASE_API + "results?id=" + id).then((res) =>
res.json()
);
if (res.code == 0 && res.data.data) {
return { data: res.data.data, timeout: false };
}
if (isTimeout()) break;
await new Promise((r) => setTimeout(r, 2000));
}
return { data: [], timeout: true };
}

View File

@@ -0,0 +1,46 @@
import TableContainer from "@mui/material/TableContainer";
import Box from "@mui/material/Box";
import Table from "@mui/material/Table";
import TableRow from "@mui/material/TableRow";
import TableHead from "@mui/material/TableHead";
import TableCell from "@mui/material/TableCell";
import TableBody from "@mui/material/TableBody";
import Paper from "@mui/material/Paper";
import Title from "@/components/Home/Title";
import type { ResultRowsType } from "./types";
export default Result;
function Result({ rows }: { rows: ResultRowsType }) {
return (
<Box sx={{ mt: 4 }}>
<Title title="测试结果" sx={{ fontSize: "16px", marginBottom: "16px" }} />
<TableContainer component={Paper}>
<Table sx={{ minWidth: 650 }}>
<TableHead>
<TableRow>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows.map((row, index) => (
<TableRow key={index}>
<TableCell>{row.engine}</TableCell>
<TableCell>{row.version}</TableCell>
<TableCell>{row.detectionRate}</TableCell>
<TableCell>{row.failedRate}</TableCell>
<TableCell>{row.accuracy}</TableCell>
<TableCell>{row.cost}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
);
}

View File

@@ -0,0 +1,24 @@
import Typography from "@mui/material/Typography";
export default SampleCount;
function SampleCount({
total,
normal,
attack,
}: {
total: number;
normal: number;
attack: number;
}) {
return (
<div style={{ display: "flex" }}>
<Typography> {total} HTTP </Typography>
<Typography sx={{ color: "success.main" }}>
{normal}
</Typography>
<Typography sx={{ color: "error.main" }}> {attack} </Typography>
</div>
);
}

View File

@@ -0,0 +1,142 @@
import { useState } from "react";
import {
Box,
Button,
Table,
TableHead,
TableRow,
TableBody,
Typography,
Dialog,
DialogActions,
DialogContent,
} from "@mui/material";
import Accordion from "@mui/material/Accordion";
import AccordionSummary from "@mui/material/AccordionSummary";
import AccordionDetails from "@mui/material/AccordionDetails";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import TableCell from "@mui/material/TableCell";
import Title from "@/components/Home/Title";
import SampleCount from "@/components/detection/SampleCount";
import SamplesForm from "@/components/detection/SamplesForm";
import hljs from "highlight.js";
import { Message } from "@/components";
import { getSampleDetail } from "@/api/detection";
import { sizeLength } from "@/utils";
import type { RecordSamplesType } from "./types";
export default SampleList;
interface SampleListProps {
value: RecordSamplesType;
onSetIdChange: (id: string) => void;
}
function SampleList({ value, onSetIdChange }: SampleListProps) {
const [open, setOpen] = useState(false);
const [detail, setDetail] = useState("");
const handleDetail = (id: string) => async () => {
const res = await getSampleDetail(id);
if (res.code != 0) {
Message.error(res.msg || "获取详情失败");
return;
}
const highlighted = hljs.highlight(res.data.content, {
language: "http",
});
setDetail(highlighted.value);
setOpen(true);
};
const handleClose = () => {
setDetail("");
setOpen(false);
};
return (
<>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
marginBottom: "18px",
alignItems: "center",
}}
>
<Title title="测试样本" sx={{ fontSize: "16px" }} />
<SamplesForm onSetIdChange={onSetIdChange} />
</Box>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<SampleCount
total={value.length}
normal={value.filter((i) => !i.isAttack).length}
attack={value.filter((i) => i.isAttack).length}
/>
</AccordionSummary>
<AccordionDetails>
<Table>
<TableHead>
<TableRow>
<TableCell width={150}></TableCell>
<TableCell width={150}></TableCell>
<TableCell></TableCell>
<TableCell width={100}></TableCell>
</TableRow>
</TableHead>
<TableBody>
{value.map((row, index) => (
<TableRow
key={index}
sx={{ "&:last-child td, &:last-child th": { border: 0 } }}
>
<TableCell>
{row.isAttack ? (
<Typography sx={{ color: "error.main" }}>
</Typography>
) : (
<Typography sx={{ color: "success.main" }}>
</Typography>
)}
</TableCell>
<TableCell>{sizeLength(row.size)}</TableCell>
<TableCell>
<Typography
title={row.summary}
noWrap
sx={{ width: "600px" }}
>
{row.summary}
</Typography>
</TableCell>
<TableCell>
<Button onClick={handleDetail(row.id)}></Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</AccordionDetails>
</Accordion>
<Dialog open={open} onClose={handleClose}>
<DialogContent sx={{ marginBottom: 0 }}>
<Box
component="code"
style={{ whiteSpace: "pre-line", wordBreak: "break-all" }}
dangerouslySetInnerHTML={{ __html: detail }}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}></Button>
</DialogActions>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,244 @@
import { useState } from "react";
import {
Box,
Button,
RadioGroup,
StepLabel,
TextField,
FormControlLabel,
Radio,
} from "@mui/material";
import {
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
} from "@mui/material";
import Stepper from "@mui/material/Stepper";
import Step from "@mui/material/Step";
import SampleCount from "@/components/detection/SampleCount";
import {
Table,
TableHead,
TableRow,
TableCell,
TableBody,
} from "@mui/material";
import { sampleLength, sampleSummary } from "@/utils";
export default SampleSteps;
interface SampleStepsProps {
// onDetect: (samples: Array<{ sample: string; isAttack: boolean }>) => void;
onDetect: (value: { sample: string; isAttack: boolean }) => void;
}
function SampleSteps({ onDetect }: SampleStepsProps) {
const [activeStep, setActiveStep] = useState(0);
const [completed, setCompleted] = useState([false, false, false]);
const [sampleText, setSampleText] = useState("");
const [sampleIsAttack, setSampleIsAttack] = useState(false);
const [sampleTextError, setSampleTextError] = useState("");
const [count, setCount] = useState({
total: 0,
normal: 0,
attack: 0,
});
const nextStep = () => {
setActiveStep(activeStep + 1);
completed[activeStep] = true;
setCompleted([...completed]);
};
const handleCommit = () => {
if (sampleText == "") {
setSampleTextError("样本内容不能为空");
return;
}
nextStep();
};
const handleMark = (v: Array<{ isAttack: boolean }>) => {
const isAttack = v[0].isAttack;
setSampleIsAttack(isAttack);
setCount({ total: 1, normal: isAttack ? 0 : 1, attack: isAttack ? 1 : 0 });
nextStep();
};
const handleDetect = () => {
onDetect({
sample: sampleText,
isAttack: sampleIsAttack,
});
};
const handleStep = (n: number) => {
if(completed[n]) setActiveStep(n)
}
return (
<Box>
<Stepper nonLinear activeStep={activeStep} sx={{ marginBottom: 2 }}>
<Step completed={completed[0]} onClick={() => handleStep(0)}>
<StepLabel></StepLabel>
</Step>
<Step completed={completed[1]} onClick={() => handleStep(1)}>
<StepLabel></StepLabel>
</Step>
<Step completed={completed[2]} onClick={() => handleStep(2)}>
<StepLabel></StepLabel>
</Step>
</Stepper>
{activeStep == 0 && (
<Box sx={{ p: 1 }}>
<TextField
multiline
fullWidth
minRows={4}
error={sampleTextError != ""}
helperText={sampleTextError}
value={sampleText}
onChange={(e) => setSampleText(e.target.value)}
placeholder={`GET /path/api HTTP/1.1
Host: example.com`}
sx={{ mb: 2 }}
/>
<Button fullWidth variant="contained" onClick={handleCommit}>
</Button>
</Box>
)}
{activeStep == 1 && (
<SampleMarkable onChange={handleMark} samples={[sampleText]} />
)}
{activeStep == 2 && (
<Box>
<SampleCount
total={count.total}
normal={count.normal}
attack={count.attack}
/>
<Button
sx={{ mt: 2 }}
fullWidth
variant="contained"
onClick={handleDetect}
>
</Button>
</Box>
)}
</Box>
);
}
function SampleMarkable({
samples,
onChange,
}: {
samples: Array<string>;
onChange: (value: Array<{ sample: string; isAttack: boolean }>) => void;
}) {
const [sampleDetail, setSampleDetail] = useState("");
const [rows, setRows] = useState(() => {
const rows: Array<{
isAttack: boolean;
summary: string;
size: string;
raw: string;
}> = [];
samples.forEach((i) => {
rows.push({
isAttack: false,
summary: sampleSummary(i),
raw: i,
size: sampleLength(i),
});
});
return rows;
});
const handle = () => {
onChange(rows.map((i) => ({ sample: i.raw, isAttack: i.isAttack })));
};
const handleType = (n: number) => (v: string) => {
rows[n].isAttack = v == "attack";
setRows([...rows]);
};
const handleSampleDetail = (text: string) => () => {
setSampleDetail(text);
};
const handleClose = () => {
setSampleDetail("");
};
return (
<Box>
<Table sx={{ mb: 2 }}>
<TableHead>
<TableRow>
<TableCell width={350}></TableCell>
<TableCell width={150}></TableCell>
<TableCell></TableCell>
<TableCell width={100}></TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows.map((row, index) => (
<TableRow
key={index}
sx={{ "&:last-child td, &:last-child th": { border: 0 } }}
>
<TableCell>
<RadioGroup
value={row.isAttack ? "attack" : "normal"}
onChange={(e) => handleType(index)(e.target.value)}
sx={{ flexDirection: "row" }}
>
<FormControlLabel
value="attack"
control={<Radio sx={{ color: "divider" }} />}
label="攻击样本"
/>
<FormControlLabel
value="normal"
control={<Radio sx={{ color: "divider" }} />}
label="普通样本"
/>
</RadioGroup>
</TableCell>
<TableCell>{row.size}</TableCell>
<TableCell>{row.summary}</TableCell>
<TableCell>
<Button onClick={handleSampleDetail(row.raw)}></Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Button onClick={handle} fullWidth variant="contained">
</Button>
<Dialog open={sampleDetail != ""} onClick={handleClose}>
<DialogTitle></DialogTitle>
<DialogContent>
<DialogContentText>{sampleDetail}</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}></Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@@ -0,0 +1,111 @@
import { useState } from "react";
import { Message } from "@/components";
import { submitSampleSet, getSampleSetResult } from "@/api/detection";
import CircularProgress from "@mui/material/CircularProgress";
import CloseIcon from "@mui/icons-material/Close";
import IconButton from "@mui/material/IconButton";
import SampleSteps from "@/components/detection/SampleSteps";
import Button from "@mui/material/Button";
import Modal from "@mui/material/Modal";
import Box from "@mui/material/Box";
import Title from "@/components/Home/Title";
import type { RecordSamplesType } from "./types";
export default SamplesForm;
function SamplesForm({
onSetIdChange,
}: {
onSetIdChange: (id: string) => void;
}) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const testHandler = () => {
setOpen(true);
};
const closeHandler = () => {
setOpen(false);
};
const getSetId = async (content: string, tag: string) => {
const res = await submitSampleSet([{ content, tag }]);
if (res.code != 0) throw res.msg;
if (res.data.total != 1) throw "样本数量错误";
return res.data.id;
};
const submit = async ({
sample,
isAttack,
}: {
sample: string;
isAttack: boolean;
}) => {
setLoading(true);
try {
const setId = await getSetId(sample, isAttack ? "black" : "white");
onSetIdChange(setId);
} catch (e) {
Message.error(("解析失败: " + e) as string);
}
setOpen(false);
setLoading(false);
};
return (
<>
<Button variant="contained" onClick={testHandler}>
</Button>
<Modal open={open} onClose={closeHandler}>
<Box
sx={{
position: "absolute" as "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 750,
bgcolor: "background.paper",
boxShadow: 24,
borderRadius: "6px",
p: 3,
}}
>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
{loading && (
<Box
sx={{
position: "absolute",
top: "0",
left: "0",
right: "0",
bottom: "0",
display: "flex",
justifyContent: "center",
alignItems: "center",
background: "rgba(0,0,0,0.3)",
}}
>
<CircularProgress />
</Box>
)}
<Title title="测试我的样本" sx={{ fontSize: "18px", mb: 2 }} />
<IconButton onClick={closeHandler}>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
<Box>
<SampleSteps onDetect={submit} />
</Box>
</Box>
</Modal>
</>
);
}

View File

@@ -0,0 +1,107 @@
const BuiltinAttackSamples = [
`
POST /doUpload.action HTTP/1.1
Host: localhost:8080
Content-Length: 1000000000
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryXd004BVJN9pBYBL2
------WebKitFormBoundaryXd004BVJN9pBYBL2
Content-Disposition: form-data; name="upload"; filename="ok"
<%eval request("sb")%>
------WebKitFormBoundaryXd004BVJN9pBYBL2--
`,
`GET /fe/Channel/25545911?tabNum=cat%20%2Fetc%2Fhosts HTTP/1.1
Host: monster
User-Agent: Chrome`,
`GET /scripts/%2e%2e/%2e/Windows/System32/cmd.exe?/c+dir+c HTTP/1.1
Host: a.cn
User-Agent: Chrome
Cookie: 2333;`,
`GET /?s=/../../../etc/login\0.img HTTP/1.1
Host: monster
User-Agent: Chrome`,
`POST / HTTP/1.1
Host: monster
User-Agent: Chrome
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary4JGjXRl94NnI4Og7
------WebKitFormBoundary4JGjXRl94NnI4Og7
Content-Disposition: form-data; name="file"; filename="hack.asp%00.png"
Content-Type: application/octet-stream
<%@codepage=65000%><%response.codepage=65001:eval(request("key"))%>
------WebKitFormBoundary4JGjXRl94NnI4Og7--"`,
`GET /?s=%ac%ed%00%05%73%72%00%1a%63%6f%6d%2e%63%74%2e%61%72%61%6c%65%69%69%2e%74%65%73%74%2e%50%65%72%73%6f%6e%00%00%00%00%00%00%00%01%02%00%08%49%00%03%61%61%61%43%00%03%63%63%63%42%00%03%64%64%64%5a%00%03%65%65%65%4a%00%03%66%66%66%46%00%03%67%67%67%44%00%03%68%68%68%4c%00%03%62%62%62%74%00%12%4c%6a%61%76%61%2f%6c%61%6e%67%2f%53%74%72%69%6e%67%3b%78%70%00%00%00%01%00%62%65%01%00%00%00%00%00%00%00%01%3f%80%00%00%40%00%00%00%00%00%00%00%74%00%03%61%61%61 HTTP/1.1
Host: monster
User-Agent: Chrome`,
`GET / HTTP/1.1
Host: a.cn
Content-Type: a
Origin: b
User-Agent: c
X-Forwarded-For: d
Cookie: FOOID=1;
A: AAAAAAAAA
Referer: %{(#nike='multipart/form-data').(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(#_memberAccess?(#_memberAccess=#dm):((#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)))).(#cmd='ifconfig').(#iswin=(@java.lang.System@getProperty('os.name').toLowerCase().contains('win'))).(#cmds=(#iswin?{'cmd.exe','/c',#cmd}:{'/bin/bash','-c',#cmd})).(#p=new java.lang.ProcessBuilder(#cmds)).(#p.redirectErrorStream(true)).(#process=#p.start()).(#ros=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream())).(@org.apache.commons.io.IOUtils@copy(#process.getInputStream(),#ros)).(#ros.flush())}`,
`GET / HTTP/1.1
Host: monster
User-Agent: Chrome
Content-Type: application/x-www-form-urlencoded
Content-Length: 55
<?php echo $a; ?>`,
`GET / HTTP/1.1
Host: monster
User-Agent: Chrome
Content-Type: application/x-www-form-urlencoded
Content-Length: 55
a=O:9:"FileClass":1:{s:8:"filename";s:10:"config.php";}`,
`GET / HTTP/1.1
Host: monster
User-Agent: () { :;}; echo; echo $(/bin/ls -al)
Referer: Chrome
Cookie: 2333;`,
`GET /somepath?q=1'%20or%201=1 HTTP/1.1`,
`GET /hello?content=hello&user=ftp://a.com HTTP/1.1
Host: www.baidu.com
User-Agent: Chrome
Referer: http://www.qq.com/abc.html`,
`GET /?flag=%7B%7Bconfig.__class__.__init__.__globals__%5B'os'%5D.popen('id').read()%7D%7D HTTP/1.1
Host: 1.2.3.4
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:97.0) Gecko/20100101 Firefox/97.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Connection: close`,
`POST / HTTP/1.1
<?xml version="1.0" encoding="utf-8"?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:template match="/fruits">
<xsl:value-of select="system-property('xsl:vendor')"/>
</xsl:template>
</xsl:stylesheet>`,
`GET /somepath?q=<script>alert('XSS')</script> HTTP/1.1`,
`POST / HTTP/1.1
<?xml version="1.0"?>
<!DOCTYPE data [
<!ELEMENT data (#ANY)>
<!ENTITY file SYSTEM "file:///etc/passwd">
]>
<data>&file;</data>`,
];
const BuiltinNonAttackSamples = [
`GET / HTTP/1.1
Host: 1.2.3.4`,
`POST / HTTP/1.1
name=haha&submit=submit`,
];
export { BuiltinAttackSamples, BuiltinNonAttackSamples };

View File

@@ -0,0 +1,23 @@
export type RecordSamplesType = Array<{
id: string;
size: number;
isAttack: boolean;
summary: string;
}>;
export type SampleDetailType = {
id: string;
size: number;
isAttack: boolean;
raw: string;
summary: string;
};
export type ResultRowsType = Array<{
engine: string;
version: string;
detectionRate: number;
failedRate: number;
accuracy: number;
cost: string;
}>;

View File

@@ -201,6 +201,17 @@ export default function DrawerAppBar(props: Props) {
>
</Box>
{/* <Box
sx={{
color: pathname.startsWith("/detection")
? "primary.main"
: "#fff",
}}
component={Link}
href="/detection"
>
防护效果
</Box> */}
<Box
sx={{
color: "#fff",

View File

@@ -0,0 +1,85 @@
import { useEffect, useState } from "react";
import Container from "@mui/material/Container";
import Result from "@/components/detection/Result";
import { useRouter } from "next/router";
import "highlight.js/styles/a11y-light.css";
import { getSampleSet, getSampleSetResult } from "@/api/detection";
import { Message } from "@/components";
import type {
RecordSamplesType,
ResultRowsType,
} from "@/components/detection/types";
import SampleList from "@/components/detection/SampleList";
export default Detection;
function Detection() {
const router = useRouter();
const [samples, setSamples] = useState<RecordSamplesType>([]);
const [result, setResult] = useState<ResultRowsType>([]);
useEffect(() => {
// useRouter 中获取 参数会有延迟,所以先判断有没有 id 参数
const realSetId =
new URLSearchParams(location.search).get("id") || "default";
const setId = (router.query.id as string) || "default";
if (setId !== realSetId) return;
// 查询样本集合
getSampleSet(setId).then((res) => {
if (res.code != 0) {
Message.error("测试集合 " + setId + ": " + res.msg);
return;
}
if (!res.data.data) {
Message.error("测试集合 " + setId + ": 获取结果为空");
return;
}
setSamples(
res.data.data?.map((i: any) => ({
id: i.id,
summary: i.summary,
size: i.length,
isAttack: i.tag == "black",
}))
);
});
// 查询样本集合结果
getSampleSetResult(setId).then(({ data, timeout }) => {
if (timeout) {
Message.error("获取检测集结果超时");
return;
}
setResult(
data.map((i: any) => ({
engine: i.engine,
version: i.version,
detectionRate: percent(i.recall),
failedRate: percent(i.fdr),
accuracy: percent(i.accuracy),
cost: i.elapsed + "毫秒",
}))
);
});
}, [router.query.id]);
const handleSetId = (id: string) => {
router.push({
pathname: router.pathname,
query: { id },
});
};
return (
<Container sx={{ mb: 2 }}>
<div style={{ height: "100px" }}></div>
<SampleList value={samples} onSetIdChange={handleSetId} />
<Result rows={result} />
</Container>
);
}
function percent(v: number) {
return Math.round(v * 10000) / 100 + "%";
}

View File

@@ -1 +1,16 @@
export * from "./render";
export { sampleLength, sizeLength, sampleSummary };
function sampleLength(s: string) {
const l = new Blob([s]).size;
return l > 1024 * 2 ? Math.round(l / 1024) + "KB" : l + "B";
}
function sizeLength(l: number) {
return l > 1024 * 2 ? Math.round(l / 1024) + "KB" : l + "B";
}
function sampleSummary(s: string) {
return s.split("\n").slice(0, 2).join(" ").slice(0, 60);
}