diff --git a/assets/components/pages/repository/label.tsx b/assets/components/pages/repository/label.tsx new file mode 100644 index 0000000..2afcd7d --- /dev/null +++ b/assets/components/pages/repository/label.tsx @@ -0,0 +1,140 @@ +import React from "react"; +import { Grid, Alert, Typography } from "@mui/material"; +import Stack from "@mui/material/Stack"; + +import List from "@mui/material/List"; +import ListItem from "@mui/material/ListItem"; +import ListItemButton from "@mui/material/ListItemButton"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import ListItemText from "@mui/material/ListItemText"; +import Checkbox from "@mui/material/Checkbox"; + +import * as model from "@/components/model"; +import * as ui from "@/components/ui"; + +export default RepoLabels; + +function RepoLabels(props: { repo: model.repository }) { + type labelsStatus = { + isLoaded: boolean; + labels?: model.repoLabel[]; + err?: any; + }; + + const [status, setStatus] = React.useState({ + isLoaded: false, + }); + + const get = () => { + fetch(`/api/v1/repo-label`) + .then((res) => res.json()) + .then( + (result) => { + console.log("labels:", { result }); + if (result.error) { + setStatus({ isLoaded: true, err: result.error }); + } else { + setStatus({ + isLoaded: true, + labels: result.data, + }); + } + }, + (error) => { + console.log("got error:", { error }); + setStatus({ + isLoaded: true, + err: error.message, + }); + } + ); + }; + React.useEffect(get, []); + + if (!status.isLoaded) { + return Loading...; + } else if (status.err) { + return {status.err}; + } + + return ( + + {status.labels.map((label) => { + return ; + })} + + ); +} + +function RepoLabel(props: { repo: model.repository; label: model.repoLabel }) { + const initChecked = props.repo.edges.labels + ? props.repo.edges.labels.filter((label) => { + return label.id === props.label.id; + }).length > 0 + : false; + + const [status, setStatus] = React.useState<{ + done: boolean; + err?: any; + }>({ done: false }); + const [checked, setChecked] = React.useState(initChecked); + + const update = (value: boolean) => { + const method = value ? "POST" : "DELETE"; + const url = `/api/v1/repo-label/${props.label.id}/assign/${props.repo.id}`; + + fetch(url, { method }) + .then((res) => res.json()) + .then( + (result) => { + console.log({ result }); + setChecked(value); + setStatus({ done: true }); + }, + (error) => { + console.log("error:", { error }); + setStatus({ done: true, err: error }); + } + ); + }; + + return ( + {status.err} + ) : ( + Updated + ) + ) : ( + <> + ) + } + disablePadding> + { + setStatus({ done: false }); + update(!checked); + }} + dense> + + + + + + {props.label.description} + + } + /> + + + ); +} diff --git a/assets/components/pages/repository/repository.tsx b/assets/components/pages/repository/repository.tsx index 4705dbc..50bdede 100644 --- a/assets/components/pages/repository/repository.tsx +++ b/assets/components/pages/repository/repository.tsx @@ -1,18 +1,13 @@ import React from "react"; import { Grid, Alert, Typography } from "@mui/material"; -import GitHubIcon from "@mui/icons-material/GitHub"; import Stack from "@mui/material/Stack"; -import List from "@mui/material/List"; -import ListItem from "@mui/material/ListItem"; -import ListItemButton from "@mui/material/ListItemButton"; -import ListItemIcon from "@mui/material/ListItemIcon"; -import ListItemText from "@mui/material/ListItemText"; -import Checkbox from "@mui/material/Checkbox"; - import * as app from "@/components/app"; import * as model from "@/components/model"; -import * as ui from "@/components/ui"; + +import Labels from "./label"; +import Scans from "./scan"; +import VulnStatuses from "./vulnStatus"; export default function Repository(props: { owner: string; repo: string }) { type repoStatus = { @@ -62,10 +57,20 @@ export default function Repository(props: { owner: string; repo: string }) { } return ( - - Labels - - + <> + + Recent scan reports + + + + Status + + + + Labels + + + ); }; @@ -82,128 +87,3 @@ export default function Repository(props: { owner: string; repo: string }) { ); } - -function RepoLabels(props: { repo: model.repository }) { - type labelsStatus = { - isLoaded: boolean; - labels?: model.repoLabel[]; - err?: any; - }; - - const [status, setStatus] = React.useState({ - isLoaded: false, - }); - - const get = () => { - fetch(`/api/v1/repo-label`) - .then((res) => res.json()) - .then( - (result) => { - console.log("labels:", { result }); - if (result.error) { - setStatus({ isLoaded: true, err: result.error }); - } else { - setStatus({ - isLoaded: true, - labels: result.data, - }); - } - }, - (error) => { - console.log("got error:", { error }); - setStatus({ - isLoaded: true, - err: error.message, - }); - } - ); - }; - React.useEffect(get, []); - - if (!status.isLoaded) { - return Loading...; - } else if (status.err) { - return {status.err}; - } - - return ( - - {status.labels.map((label) => { - return ; - })} - - ); -} - -function RepoLabel(props: { repo: model.repository; label: model.repoLabel }) { - const initChecked = props.repo.edges.labels - ? props.repo.edges.labels.filter((label) => { - return label.id === props.label.id; - }).length > 0 - : false; - - const [status, setStatus] = React.useState<{ - done: boolean; - err?: any; - }>({ done: false }); - const [checked, setChecked] = React.useState(initChecked); - - const update = (value: boolean) => { - const method = value ? "POST" : "DELETE"; - const url = `/api/v1/repo-label/${props.label.id}/assign/${props.repo.id}`; - - fetch(url, { method }) - .then((res) => res.json()) - .then( - (result) => { - console.log({ result }); - setChecked(value); - setStatus({ done: true }); - }, - (error) => { - console.log("error:", { error }); - setStatus({ done: true, err: error }); - } - ); - }; - - return ( - {status.err} - ) : ( - Updated - ) - ) : ( - <> - ) - } - disablePadding> - { - setStatus({ done: false }); - update(!checked); - }} - dense> - - - - - - {props.label.description} - - } - /> - - - ); -} diff --git a/assets/components/pages/repository/scan.tsx b/assets/components/pages/repository/scan.tsx new file mode 100644 index 0000000..8fbcef5 --- /dev/null +++ b/assets/components/pages/repository/scan.tsx @@ -0,0 +1,144 @@ +import React from "react"; +import { Grid, Alert, Typography } from "@mui/material"; +import Link from "next/link"; + +import PlagiarismIcon from "@mui/icons-material/Plagiarism"; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, +} from "@mui/material"; + +import * as model from "@/components/model"; + +import ReactTimeAgo from "react-time-ago"; +import TimeAgo from "javascript-time-ago"; +import en from "javascript-time-ago/locale/en.json"; + +let addedDefaultLocale = false; +if (!addedDefaultLocale) { + TimeAgo.addDefaultLocale(en); + addedDefaultLocale = true; +} + +export default Scans; + +function Scans(props: { repo: model.repository }) { + type status = { + isLoaded: boolean; + scans?: model.scan[]; + err?: any; + }; + const [status, setStatus] = React.useState({ + isLoaded: false, + }); + + const get = () => { + fetch(`/api/v1/repository/${props.repo.owner}/${props.repo.name}/scan`) + .then((res) => res.json()) + .then( + (result) => { + console.log("scans:", { result }); + if (result.error) { + setStatus({ isLoaded: true, err: result.error }); + } else { + setStatus({ + isLoaded: true, + scans: result.data, + }); + } + }, + (error) => { + console.log("got error:", { error }); + setStatus({ + isLoaded: true, + err: error.message, + }); + } + ); + }; + React.useEffect(get, []); + + if (!status.isLoaded) { + return Loading...; + } else if (status.err) { + return {status.err}; + } + + return ( + + + + + + + + Scanned at + + + Target + + + Packages + + + Vulnerables + + + + + + {status.scans.map((scan) => { + return ( + + + + + + + + + + + + + + + + + {scan.branch} + {" "} + ( + + {scan.commit_id.substr(0, 7)} + + ) + + + + {scan.edges.packages.length} + + + + { + scan.edges.packages.filter((p) => { + return p.vuln_ids !== undefined; + }).length + } + + + + ); + })} + +
+
+
+ ); +} diff --git a/assets/components/pages/repository/vulnStatus.tsx b/assets/components/pages/repository/vulnStatus.tsx new file mode 100644 index 0000000..ee75f60 --- /dev/null +++ b/assets/components/pages/repository/vulnStatus.tsx @@ -0,0 +1,118 @@ +import React from "react"; +import { Grid, Alert, Typography } from "@mui/material"; +import Link from "next/link"; +import Avatar from "@mui/material/Avatar"; +import Stack from "@mui/material/Stack"; + +import PlagiarismIcon from "@mui/icons-material/Plagiarism"; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, +} from "@mui/material"; + +import * as model from "@/components/model"; + +import * as ui from "@/components/ui"; +import ReactTimeAgo from "react-time-ago"; +import TimeAgo from "javascript-time-ago"; +import en from "javascript-time-ago/locale/en.json"; +TimeAgo.addDefaultLocale(en); + +export default VulnStatuses; + +function VulnStatuses(props: { repo: model.repository }) { + const statusSet = props.repo.edges.status + .map((idx) => { + return idx.edges.latest; + }) + .filter((status) => { + return status.status !== "none"; + }); + if (statusSet.length === 0) { + return No status; + } + + return ( + + + + + + + + Vulnerability + + + Status + + + Expires at + + + By + + + Comment + + + + + {statusSet.map((vulnStatus) => { + return ( + + + + + + + + {vulnStatus.vuln_id} + + + + + {vulnStatus.status} + + + {vulnStatus.expires_at ? ( + + ) : ( + <> + )} + + + + + + + {vulnStatus.edges.author.login} + + + + + + {vulnStatus.comment} + + + ); + })} + +
+
+
+ ); +} diff --git a/assets/components/ui.tsx b/assets/components/ui.tsx index 78e75d8..8e0dec0 100644 --- a/assets/components/ui.tsx +++ b/assets/components/ui.tsx @@ -1,6 +1,23 @@ import Chip from "@mui/material/Chip"; import * as model from "@/components/model"; +import ReportProblemIcon from "@mui/icons-material/ReportProblem"; +import AccessAlarmIcon from "@mui/icons-material/AccessAlarm"; +import BuildIcon from "@mui/icons-material/Build"; +import BeenhereIcon from "@mui/icons-material/Beenhere"; +import Tooltip from "@mui/material/Tooltip"; + +import { makeStyles } from "@mui/styles"; + +const useStyles = makeStyles((theme) => ({ + vulnStatusIcon: { + marginTop: 4, + marginRight: 1, + marginLeft: 0, + marginBottom: 0, + }, +})); + function labelColor(hex: string) { var r = parseInt(hex.substr(1, 2), 16); var g = parseInt(hex.substr(3, 2), 16); @@ -25,3 +42,36 @@ export function RepoLabel(props: { /> ); } + +export function StatusIcon(props: { + status: model.vulnStatusType; + expiresAt?: number; +}) { + const classes = useStyles(); + switch (props.status) { + case "none": + return ; + case "mitigated": + return ; + case "unaffected": + return ; + case "snoozed": + const now = new Date(); + if (props.expiresAt) { + const diff = props.expiresAt - now.getTime() / 1000; + const expiresIn = + diff > 86400 + ? Math.floor(diff / 86000) + " days left" + : Math.floor(diff / 3600) + " hours left"; + + return ( + + + + ); + } else { + return ; + } + } + return; +} diff --git a/assets/package-lock.json b/assets/package-lock.json index d254975..d38a066 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -14,12 +14,13 @@ "@mui/icons-material": "^5.0.0", "@mui/material": "^5.0.0", "@mui/styles": "^5.0.0", - "javascript-time-ago": "^2.3.9", + "javascript-time-ago": "^2.3.10", "next": "^11.1.2", "react": "^17.0.2", "react-color": "^2.19.3", "react-dom": "^17.0.2", "react-markdown": "^7.0.1", + "react-time-ago": "^7.1.4", "strftime": "^0.10.0" }, "devDependencies": { @@ -2943,11 +2944,11 @@ "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" }, "node_modules/javascript-time-ago": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/javascript-time-ago/-/javascript-time-ago-2.3.9.tgz", - "integrity": "sha512-AhVoLXsN+CRNjVaTM837zIN/8uRzGy2G/8MTNw24bjBFpWyqMeGQCAoI5HOED7UKCqK2fuXDuAugBbmbODpzkA==", + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/javascript-time-ago/-/javascript-time-ago-2.3.10.tgz", + "integrity": "sha512-eeZx3B8ACZpFTiaow4Xl3YTIG9UjebwVjHEDnKyzJ1NBve1ZqJIgy97yoT9Esw+Vf+XgSk4YCpOsyK5X/ByRzQ==", "dependencies": { - "relative-time-format": "^1.0.5" + "relative-time-format": "^1.0.6" } }, "node_modules/jest-worker": { @@ -4269,6 +4270,11 @@ "node": ">=0.12" } }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" + }, "node_modules/picomatch": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz", @@ -4526,6 +4532,14 @@ "inherits": "~2.0.3" } }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -4651,6 +4665,20 @@ "node": ">=0.10.0" } }, + "node_modules/react-time-ago": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/react-time-ago/-/react-time-ago-7.1.4.tgz", + "integrity": "sha512-W52GqGQRSEaITqy5YPZ6i9fy0mhwpTzY0ScoF43jlAvNY0Kb5CCllXqHky3sPgkGJS7JELCd+dbOgiQqz779FQ==", + "dependencies": { + "prop-types": "^15.7.2", + "raf": "^3.4.1" + }, + "peerDependencies": { + "javascript-time-ago": "^2.3.7", + "react": ">=0.16.8", + "react-dom": ">=0.16.8" + } + }, "node_modules/react-transition-group": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.2.tgz", @@ -7631,11 +7659,11 @@ "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" }, "javascript-time-ago": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/javascript-time-ago/-/javascript-time-ago-2.3.9.tgz", - "integrity": "sha512-AhVoLXsN+CRNjVaTM837zIN/8uRzGy2G/8MTNw24bjBFpWyqMeGQCAoI5HOED7UKCqK2fuXDuAugBbmbODpzkA==", + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/javascript-time-ago/-/javascript-time-ago-2.3.10.tgz", + "integrity": "sha512-eeZx3B8ACZpFTiaow4Xl3YTIG9UjebwVjHEDnKyzJ1NBve1ZqJIgy97yoT9Esw+Vf+XgSk4YCpOsyK5X/ByRzQ==", "requires": { - "relative-time-format": "^1.0.5" + "relative-time-format": "^1.0.6" } }, "jest-worker": { @@ -8579,6 +8607,11 @@ "sha.js": "^2.4.8" } }, + "performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" + }, "picomatch": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz", @@ -8806,6 +8839,14 @@ "inherits": "~2.0.3" } }, + "raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "requires": { + "performance-now": "^2.1.0" + } + }, "randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -8907,6 +8948,15 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.8.3.tgz", "integrity": "sha512-X8jZHc7nCMjaCqoU+V2I0cOhNW+QMBwSUkeXnTi8IPe6zaRWfn60ZzvFDZqWPfmSJfjub7dDW1SP0jaHWLu/hg==" }, + "react-time-ago": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/react-time-ago/-/react-time-ago-7.1.4.tgz", + "integrity": "sha512-W52GqGQRSEaITqy5YPZ6i9fy0mhwpTzY0ScoF43jlAvNY0Kb5CCllXqHky3sPgkGJS7JELCd+dbOgiQqz779FQ==", + "requires": { + "prop-types": "^15.7.2", + "raf": "^3.4.1" + } + }, "react-transition-group": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.2.tgz", diff --git a/assets/package.json b/assets/package.json index 907f4ac..45bb5c0 100644 --- a/assets/package.json +++ b/assets/package.json @@ -19,12 +19,13 @@ "@mui/icons-material": "^5.0.0", "@mui/material": "^5.0.0", "@mui/styles": "^5.0.0", - "javascript-time-ago": "^2.3.9", + "javascript-time-ago": "^2.3.10", "next": "^11.1.2", "react": "^17.0.2", "react-color": "^2.19.3", "react-dom": "^17.0.2", "react-markdown": "^7.0.1", + "react-time-ago": "^7.1.4", "strftime": "^0.10.0" }, "devDependencies": { diff --git a/assets/pages/scan/[id].tsx b/assets/pages/scan/[id].tsx index 975fba8..e001bd3 100644 --- a/assets/pages/scan/[id].tsx +++ b/assets/pages/scan/[id].tsx @@ -102,8 +102,12 @@ function Scan() { - - {repo.owner}/{repo.name} + + + + {repo.owner}/{repo.name} + + diff --git a/pkg/infra/db/repository.go b/pkg/infra/db/repository.go index 6754b55..6d79a0d 100644 --- a/pkg/infra/db/repository.go +++ b/pkg/infra/db/repository.go @@ -5,6 +5,7 @@ import ( "github.com/m-mizutani/octovy/pkg/domain/model" "github.com/m-mizutani/octovy/pkg/infra/ent" "github.com/m-mizutani/octovy/pkg/infra/ent/repository" + "github.com/m-mizutani/octovy/pkg/infra/ent/scan" ) func (x *Client) CreateRepo(ctx *model.Context, repo *ent.Repository) (*ent.Repository, error) { @@ -129,7 +130,11 @@ func (x *Client) GetRepository(ctx *model.Context, repo *model.GitHubRepo) (*ent Where(repository.Owner(repo.Owner)). Where(repository.Name(repo.Name)). WithLabels(). - WithStatus(). + WithStatus(func(vsiq *ent.VulnStatusIndexQuery) { + vsiq.WithLatest(func(vsq *ent.VulnStatusQuery) { + vsq.WithAuthor() + }) + }). First(ctx) if err != nil { return nil, goerr.Wrap(err) @@ -139,5 +144,25 @@ func (x *Client) GetRepository(ctx *model.Context, repo *model.GitHubRepo) (*ent } func (x *Client) GetRepositoryScan(ctx *model.Context, req *model.GetRepoScanRequest) ([]*ent.Scan, error) { - panic("not implemented") + if x.lock { + x.mutex.Lock() + defer x.mutex.Unlock() + } + + resp, err := x.client.Repository.Query(). + Where(repository.Owner(req.Owner)). + Where(repository.Name(req.Name)). + WithScan(func(sq *ent.ScanQuery) { + sq.Order(ent.Desc(scan.FieldScannedAt)). + Offset(req.Offset). + Limit(req.Limit). + WithPackages() + }). + WithStatus(). + All(ctx) + if err != nil { + return nil, goerr.Wrap(err) + } + + return resp[0].Edges.Scan, nil } diff --git a/pkg/usecase/proxy.go b/pkg/usecase/proxy.go index db49a04..2edbf00 100644 --- a/pkg/usecase/proxy.go +++ b/pkg/usecase/proxy.go @@ -51,6 +51,9 @@ func (x *Usecase) GetRepository(ctx *model.Context, req *model.GitHubRepo) (*ent } func (x *Usecase) GetRepositoryScan(ctx *model.Context, req *model.GetRepoScanRequest) ([]*ent.Scan, error) { + if req.Limit == 0 { + req.Limit = 10 + } return x.infra.DB.GetRepositoryScan(ctx, req) }