diff --git a/openapi/v1.yaml b/openapi/v1.yaml index 7ec240d9..5476a800 100644 --- a/openapi/v1.yaml +++ b/openapi/v1.yaml @@ -1344,6 +1344,12 @@ paths: schema: type: boolean description: Own deployments. + - in: query + name: production_only + required: false + schema: + type: boolean + description: Return the deployments for the production environment. - in: query name: from required: false diff --git a/ui/src/App.tsx b/ui/src/App.tsx index aa9d7a23..b04d7208 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -10,6 +10,7 @@ import Repo from "./views/repo" import Deployment from "./views/deployment" import Settings from "./views/settings" import Members from "./views/members" +import Activities from "./views/activities" function App(): JSX.Element { return ( @@ -31,6 +32,9 @@ function App(): JSX.Element { + + + diff --git a/ui/src/apis/deployment.ts b/ui/src/apis/deployment.ts index 58da41ea..c9e6b488 100644 --- a/ui/src/apis/deployment.ts +++ b/ui/src/apis/deployment.ts @@ -150,16 +150,16 @@ function mapDeploymentStatusToString(status: DeploymentStatusEnum): string { } -export const searchDeployments = async (statuses: DeploymentStatusEnum[], owned: boolean, from?: Date, to?: Date, page = 1, perPage = 30): Promise => { +export const searchDeployments = async (statuses: DeploymentStatusEnum[], owned: boolean, productionOnly: boolean, from?: Date, to?: Date, page = 1, perPage = 30): Promise => { const ss: string[] = [] statuses.forEach((status) => { ss.push(mapDeploymentStatusToString(status)) }) const fromParam = (from)? `from=${from.toISOString()}` : "" - const toParam = (to)? `&to=to.toISOString()` : "" + const toParam = (to)? `&to=${to.toISOString()}` : "" - const deployments: Deployment[] = await _fetch(`${instance}/api/v1/search/deployments?statuses=${ss.join(",")}&owned=${owned}&${fromParam}&${toParam}&page=${page}&per_page=${perPage}`, { + const deployments: Deployment[] = await _fetch(`${instance}/api/v1/search/deployments?statuses=${ss.join(",")}&owned=${owned}&production_only=${productionOnly}&${fromParam}&${toParam}&page=${page}&per_page=${perPage}`, { headers, credentials: 'same-origin', }) diff --git a/ui/src/components/DeploymentRefCode.tsx b/ui/src/components/DeploymentRefCode.tsx index 5c477384..ae76293a 100644 --- a/ui/src/components/DeploymentRefCode.tsx +++ b/ui/src/components/DeploymentRefCode.tsx @@ -11,9 +11,9 @@ interface DeploymentRefCodeProps { export default function DeploymentRefCode(props: DeploymentRefCodeProps): JSX.Element { let ref: string if (props.deployment.type === DeploymentType.Commit) { - ref = props.deployment.ref.substr(0, 7) + ref = props.deployment.ref.substring(0, 7) } else if (props.deployment.type === DeploymentType.Branch && props.deployment.sha !== "") { - ref = `${props.deployment.ref}(${props.deployment.sha.substr(0, 7)})` + ref = `${props.deployment.ref}(${props.deployment.sha.substring(0, 7)})` } else { ref = props.deployment.ref } diff --git a/ui/src/components/RecentActivities.tsx b/ui/src/components/RecentActivities.tsx index bfe44441..16a4b4a0 100644 --- a/ui/src/components/RecentActivities.tsx +++ b/ui/src/components/RecentActivities.tsx @@ -29,6 +29,7 @@ interface DeploymentListProps { deployments: Deployment[] } +// TODO: Move the component into the main view. function DeploymentList(props: DeploymentListProps): JSX.Element { return { + switch (status) { + case DeploymentStatusEnum.Waiting: + return "gray" + case DeploymentStatusEnum.Created: + return "purple" + case DeploymentStatusEnum.Queued: + return "purple" + case DeploymentStatusEnum.Running: + return "purple" + case DeploymentStatusEnum.Success: + return "green" + case DeploymentStatusEnum.Failure: + return "red" + case DeploymentStatusEnum.Canceled: + return "gray" + default: + return "gray" + } +} \ No newline at end of file diff --git a/ui/src/components/partials/index.tsx b/ui/src/components/partials/index.tsx new file mode 100644 index 00000000..80d1de8f --- /dev/null +++ b/ui/src/components/partials/index.tsx @@ -0,0 +1,3 @@ +import { getStatusColor } from "./deploymentStatus" + +export { getStatusColor } \ No newline at end of file diff --git a/ui/src/redux/activities.tsx b/ui/src/redux/activities.tsx new file mode 100644 index 00000000..47107e44 --- /dev/null +++ b/ui/src/redux/activities.tsx @@ -0,0 +1,66 @@ +import { createSlice, createAsyncThunk } from "@reduxjs/toolkit" + +import { + searchDeployments as _searchDeployments, +} from "../apis" +import { Deployment, } from "../models" + +export const perPage = 30 + +interface ActivitiesState { + loading: boolean + deployments: Deployment[] + page: number +} + +const initialState: ActivitiesState = { + loading: false, + deployments: [], + page: 1, +} + +export const searchDeployments = createAsyncThunk< + Deployment[], { + start?: Date, + end?: Date, + productionOnly: boolean, + }, { + state: { activities: ActivitiesState } +}>( + "activities/searchDeployments", + async ({start, end, productionOnly}, { getState, rejectWithValue }) => { + const {page} = getState().activities + try { + return await _searchDeployments([], false, productionOnly, start, end, page, perPage) + } catch (e) { + return rejectWithValue(e) + } + } +) + +export const activitiesSlice = createSlice({ + name: "activities", + initialState, + reducers: { + increasePage: (state) => { + state.page = state.page + 1 + }, + decreasePage: (state) => { + state.page = state.page - 1 + }, + }, + extraReducers: builder => { + builder + .addCase(searchDeployments.pending, (state) => { + state.loading = true + state.deployments = [] + }) + .addCase(searchDeployments.fulfilled, (state, action) => { + state.loading = false + state.deployments = action.payload + }) + .addCase(searchDeployments.rejected, (state) => { + state.loading = false + }) + } +}) diff --git a/ui/src/redux/main.ts b/ui/src/redux/main.ts index f716b20d..f5d10a40 100644 --- a/ui/src/redux/main.ts +++ b/ui/src/redux/main.ts @@ -88,7 +88,7 @@ export const searchDeployments = createAsyncThunk getDefaultMiddleware({ serializableCheck: false diff --git a/ui/src/views/activities/ActivityHistory.tsx b/ui/src/views/activities/ActivityHistory.tsx new file mode 100644 index 00000000..838c5360 --- /dev/null +++ b/ui/src/views/activities/ActivityHistory.tsx @@ -0,0 +1,38 @@ +import { Timeline, Typography } from 'antd' +import moment from "moment" + +import { Deployment } from "../../models" +import DeploymentStatusBadge from "../../components/DeploymentStatusBadge" +import UserAvatar from '../../components/UserAvatar' +import DeploymentRefCode from '../../components/DeploymentRefCode' +import { getStatusColor } from "../../components/partials" + +const { Text } = Typography + +export interface ActivityHistoryProps { + deployments: Deployment[] +} + +export default function ActivityHistory(props: ActivityHistoryProps): JSX.Element { + return ( + + {props.deployments.map((d, idx) => { + return ( + + + + {`${d.repo?.namespace} / ${d.repo?.name}`} + + + #{d.number} + + + + deployed to {d.env} on {moment(d.createdAt).format("LLL")} + + + ) + })} + + ) +} diff --git a/ui/src/views/activities/SearchActivities.tsx b/ui/src/views/activities/SearchActivities.tsx new file mode 100644 index 00000000..7f64391e --- /dev/null +++ b/ui/src/views/activities/SearchActivities.tsx @@ -0,0 +1,63 @@ +import { Row, Col, Form, DatePicker, Button, Switch } from "antd" +import moment, { Moment } from "moment" + +export interface SearchActivitiesValues { + period?: [Moment, Moment] + productionOnly?: boolean +} + +export interface SearchActivitiesProps { + initialValues?: SearchActivitiesValues + onClickSearch(values: SearchActivitiesValues): void +} + +export default function SearchActivities(props: SearchActivitiesProps): JSX.Element { + const content = ( + <> + + + + + + + + + Search + + + > + ) + return ( + + {/* Mobile view */} + + + {content} + + + {/* Laptop */} + + + {content} + + + + ) +} \ No newline at end of file diff --git a/ui/src/views/activities/index.tsx b/ui/src/views/activities/index.tsx new file mode 100644 index 00000000..c084fd47 --- /dev/null +++ b/ui/src/views/activities/index.tsx @@ -0,0 +1,110 @@ +import { useEffect } from "react" +import { shallowEqual } from 'react-redux' +import { Helmet } from "react-helmet" + +import { useAppSelector, useAppDispatch } from "../../redux/hooks" +import { perPage, activitiesSlice, searchDeployments } from "../../redux/activities" + +import Main from "../main" +import SearchActivities, { SearchActivitiesProps, SearchActivitiesValues } from "./SearchActivities" +import ActivityHistory, { ActivityHistoryProps } from "./ActivityHistory" +import Pagination, { PaginationProps } from "../../components/Pagination" +import Spin from "../../components/Spin" + +const { actions } = activitiesSlice + +export default ():JSX.Element => { + const { + loading, + deployments, + page, + } = useAppSelector(state => state.activities, shallowEqual) + + const dispatch = useAppDispatch() + + useEffect(() => { + dispatch(searchDeployments({ + productionOnly: false + })) + + // eslint-disable-next-line + }, [dispatch]) + + const onClickSearch = (values: SearchActivitiesValues) => { + dispatch(searchDeployments({ + start: values.period? values.period[0].toDate() : undefined, + end: values.period? values.period[1].toDate() : undefined, + productionOnly: values.productionOnly? values.productionOnly : false + })) + } + + const onClickPrev = () => dispatch(actions.decreasePage()) + + const onClickNext = () => dispatch(actions.increasePage()) + + return ( + + + + ) +} + +interface ActivitiesProps extends SearchActivitiesProps, ActivityHistoryProps, PaginationProps { + loading: boolean +} + +function Activities({ + // Properties to search. + initialValues, + onClickSearch, + // Properties for the deployment history. + loading, + deployments, + // Pagination for the pagination. + disabledPrev, + disabledNext, + onClickPrev, + onClickNext +}: ActivitiesProps): JSX.Element { + return ( + <> + + Activities + + Activities + + Search + + + + History + + {(loading)? + + + + : + } + + + + + + > + ) +} diff --git a/ui/src/views/main/Header.tsx b/ui/src/views/main/Header.tsx index 9b6ab446..a54cff0b 100644 --- a/ui/src/views/main/Header.tsx +++ b/ui/src/views/main/Header.tsx @@ -32,8 +32,11 @@ export default function Header({ Home + + Activities + {(user?.admin)? - + Members : diff --git a/ui/src/views/repoHome/ActivityLogs.tsx b/ui/src/views/repoHome/ActivityLogs.tsx index 196b0837..7c7d2117 100644 --- a/ui/src/views/repoHome/ActivityLogs.tsx +++ b/ui/src/views/repoHome/ActivityLogs.tsx @@ -6,6 +6,7 @@ import { Deployment, DeploymentStatusEnum } from "../../models" import DeploymentStatusBadge from "../../components/DeploymentStatusBadge" import UserAvatar from '../../components/UserAvatar' import DeploymentRefCode from '../../components/DeploymentRefCode' +import { getStatusColor } from "../../components/partials" const { Text } = Typography @@ -37,25 +38,3 @@ export default function ActivityLogs({ deployments }: ActivityLogsProps): JSX.El ) } - -// https://ant.design/components/timeline/#Timeline.Item -const getStatusColor = (status: DeploymentStatusEnum) => { - switch (status) { - case DeploymentStatusEnum.Waiting: - return "gray" - case DeploymentStatusEnum.Created: - return "purple" - case DeploymentStatusEnum.Queued: - return "purple" - case DeploymentStatusEnum.Running: - return "purple" - case DeploymentStatusEnum.Success: - return "green" - case DeploymentStatusEnum.Failure: - return "red" - case DeploymentStatusEnum.Canceled: - return "gray" - default: - return "gray" - } -} \ No newline at end of file
+ + {`${d.repo?.namespace} / ${d.repo?.name}`} + + + #{d.number} + +
+ deployed to {d.env} on {moment(d.createdAt).format("LLL")} +