diff --git a/src/ui/components/CustomTabs/CustomTabs.tsx b/src/ui/components/CustomTabs/CustomTabs.tsx index 39639710a..e24243cf4 100644 --- a/src/ui/components/CustomTabs/CustomTabs.tsx +++ b/src/ui/components/CustomTabs/CustomTabs.tsx @@ -24,6 +24,7 @@ import CardBody from '../Card/CardBody'; import CardHeader from '../Card/CardHeader'; import styles from '../../assets/jss/material-dashboard-react/components/customTabsStyle'; import { SvgIconProps } from '@material-ui/core'; +import Badge from '@material-ui/core/Badge'; const useStyles = makeStyles(styles as any); @@ -33,6 +34,7 @@ export type TabItem = { tabName: string; tabIcon?: React.ComponentType; tabContent: React.ReactNode; + badge?: number; }; interface CustomTabsProps { @@ -81,6 +83,13 @@ const CustomTabs: React.FC = ({ > {tabs.map((prop, key) => { const icon = prop.tabIcon ? { icon: } : {}; + const label = prop.badge ? ( + + {prop.tabName} + + ) : ( + prop.tabName + ); return ( = ({ wrapper: classes.tabWrapper, }} key={key} - label={prop.tabName} + label={label} {...icon} /> ); diff --git a/src/ui/views/PushDetails/PushDetails.tsx b/src/ui/views/PushDetails/PushDetails.tsx index 7060c2b2e..09768cff5 100644 --- a/src/ui/views/PushDetails/PushDetails.tsx +++ b/src/ui/views/PushDetails/PushDetails.tsx @@ -24,25 +24,24 @@ import Card from '../../components/Card/Card'; import CardIcon from '../../components/Card/CardIcon'; import CardBody from '../../components/Card/CardBody'; import CardHeader, { CardHeaderColor } from '../../components/Card/CardHeader'; -import CardFooter from '../../components/Card/CardFooter'; import Button from '../../components/CustomButtons/Button'; +import CustomTabs from '../../components/CustomTabs/CustomTabs'; +import CommitDataTable from './components/CommitDataTable'; import Diff from './components/Diff'; +import StepsTimeline from './components/StepsTimeline'; import Attestation from './components/Attestation'; import AttestationInfo from './components/AttestationInfo'; import RejectionInfo from './components/RejectionInfo'; import Reject from './components/Reject'; -import Table from '@material-ui/core/Table'; -import TableBody from '@material-ui/core/TableBody'; -import TableHead from '@material-ui/core/TableHead'; -import TableRow from '@material-ui/core/TableRow'; -import TableCell from '@material-ui/core/TableCell'; import { getPush, authorisePush, rejectPush, cancelPush } from '../../services/git-push'; import type { ServiceResult } from '../../services/errors'; -import { CheckCircle, Visibility, Cancel, Block } from '@material-ui/icons'; +import { CheckCircle, Visibility, Cancel, Block, List as ListIcon } from '@material-ui/icons'; +import CodeIcon from '@material-ui/icons/Code'; +import TimelineIcon from '@material-ui/icons/Timeline'; import Snackbar from '@material-ui/core/Snackbar'; import { PushActionView } from '../../types'; import { trimPrefixRefsHeads, trimTrailingDotGit } from '../../../db/helper'; -import { generateEmailLink, getGitProvider } from '../../utils'; +import { getGitProvider } from '../../utils'; const Dashboard: React.FC = () => { const { id } = useParams<{ id: string }>(); @@ -115,6 +114,8 @@ const Dashboard: React.FC = () => { if (isError) throw new Error(message || 'Something went wrong ...'); if (!push) return
No push data found
; + const errorCount = push.steps?.filter((step) => step.error).length ?? 0; + let headerData: { title: string; color: CardHeaderColor } = { title: 'Pending', color: 'warning', @@ -249,44 +250,29 @@ const Dashboard: React.FC = () => { - - -

{headerData.title}

-
- - - - - Timestamp - Committer - Author - Message - - - - {push.commitData?.map((c) => ( - - - {moment.unix(Number(c.commitTimestamp || 0)).toString()} - - {generateEmailLink(c.committer, c.committerEmail)} - {generateEmailLink(c.author, c.authorEmail)} - {c.message} - - ))} - -
-
-
- - - - - - - + , + }, + { + tabName: 'Changes', + tabIcon: CodeIcon, + tabContent: , + }, + { + tabName: 'Steps', + tabIcon: TimelineIcon, + tabContent: , + badge: errorCount > 0 ? errorCount : undefined, + }, + ]} + /> diff --git a/src/ui/views/PushDetails/components/CommitDataTable.tsx b/src/ui/views/PushDetails/components/CommitDataTable.tsx new file mode 100644 index 000000000..5253cec77 --- /dev/null +++ b/src/ui/views/PushDetails/components/CommitDataTable.tsx @@ -0,0 +1,60 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import moment from 'moment'; +import Table from '@material-ui/core/Table'; +import TableBody from '@material-ui/core/TableBody'; +import TableHead from '@material-ui/core/TableHead'; +import TableRow from '@material-ui/core/TableRow'; +import TableCell from '@material-ui/core/TableCell'; +import { generateEmailLink } from '../../../utils'; +import { CommitData } from '../../../../proxy/processors/types'; + +interface CommitDataTableProps { + commitData: CommitData[]; +} + +const CommitDataTable: React.FC = ({ commitData }) => { + if (commitData.length === 0) { + return

No commits found for this push.

; + } + + return ( + + + + Timestamp + Committer + Author + Message + + + + {commitData.map((c) => ( + + {moment.unix(Number(c.commitTimestamp || 0)).toString()} + {generateEmailLink(c.committer, c.committerEmail)} + {generateEmailLink(c.author, c.authorEmail)} + {c.message} + + ))} + +
+ ); +}; + +export default CommitDataTable; diff --git a/src/ui/views/PushDetails/components/StepsTimeline.tsx b/src/ui/views/PushDetails/components/StepsTimeline.tsx new file mode 100644 index 000000000..2480b180d --- /dev/null +++ b/src/ui/views/PushDetails/components/StepsTimeline.tsx @@ -0,0 +1,302 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState } from 'react'; +import { makeStyles } from '@material-ui/core/styles'; +import Accordion from '@material-ui/core/Accordion'; +import AccordionSummary from '@material-ui/core/AccordionSummary'; +import AccordionDetails from '@material-ui/core/AccordionDetails'; +import Typography from '@material-ui/core/Typography'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import CheckCircleIcon from '@material-ui/icons/CheckCircle'; +import ErrorIcon from '@material-ui/icons/Error'; +import WarningIcon from '@material-ui/icons/Warning'; +import Chip from '@material-ui/core/Chip'; +import Box from '@material-ui/core/Box'; +import { StepData } from '../../../../proxy/actions/Step'; + +const useStyles = makeStyles((theme) => ({ + root: { + width: '100%', + }, + summary: { + padding: theme.spacing(2), + marginBottom: theme.spacing(2), + backgroundColor: '#f5f5f5', + borderRadius: theme.spacing(1), + }, + summaryTitle: { + marginTop: 0, + marginBottom: theme.spacing(1), + }, + summaryStats: { + display: 'flex', + gap: theme.spacing(2), + flexWrap: 'wrap', + }, + timeline: { + position: 'relative', + paddingLeft: theme.spacing(4), + '&::before': { + content: '""', + position: 'absolute', + left: '19px', + top: '20px', + bottom: '20px', + width: '2px', + backgroundColor: '#e0e0e0', + }, + }, + stepAccordion: { + marginBottom: theme.spacing(2), + boxShadow: '0 2px 4px rgba(0,0,0,0.1)', + '&::before': { + display: 'none', + }, + }, + stepSummary: { + minHeight: '56px !important', + '& .MuiAccordionSummary-content': { + alignItems: 'center', + margin: '12px 0', + }, + }, + stepIcon: { + position: 'absolute', + left: '-28px', + backgroundColor: 'white', + borderRadius: '50%', + padding: '2px', + zIndex: 1, + }, + stepContent: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(2), + flex: 1, + }, + stepName: { + fontWeight: 500, + fontFamily: 'monospace', + fontSize: '14px', + }, + stepDetails: { + display: 'block', + padding: theme.spacing(2), + backgroundColor: '#fafafa', + }, + messageBox: { + padding: theme.spacing(1.5), + marginBottom: theme.spacing(2), + borderRadius: theme.spacing(0.5), + fontFamily: 'monospace', + fontSize: '13px', + }, + errorMessage: { + backgroundColor: '#ffebee', + color: '#c62828', + border: '1px solid #ef9a9a', + }, + blockedMessage: { + backgroundColor: '#fff3e0', + color: '#e65100', + border: '1px solid #ffb74d', + }, + logsContainer: { + marginTop: theme.spacing(1), + }, + logsTitle: { + fontWeight: 'bold', + marginBottom: theme.spacing(1), + }, + logItem: { + padding: theme.spacing(1), + marginBottom: theme.spacing(0.5), + backgroundColor: '#f5f5f5', + borderLeft: '3px solid #9e9e9e', + fontFamily: 'monospace', + fontSize: '12px', + wordBreak: 'break-word', + }, +})); + +interface StepsTimelineProps { + steps: StepData[]; +} + +const StepsTimeline: React.FC = ({ steps }) => { + const classes = useStyles(); + const [expanded, setExpanded] = useState( + () => steps.find((s) => s.error)?.id ?? false, + ); + + const isLargeStep = (stepName: string) => stepName === 'writePack' || stepName === 'diff'; + + const handleChange = + (panel: string, stepName: string) => + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + (event: React.ChangeEvent<{}>, isExpanded: boolean) => { + if (isLargeStep(stepName)) { + return; + } + setExpanded(isExpanded ? panel : false); + }; + + const getStepIcon = (step: StepData) => { + if (step.error) { + return ; + } + if (step.blocked) { + return ; + } + return ; + }; + + const getStepStatus = (step: StepData) => { + if (step.error) { + return ( + + ); + } + if (step.blocked) { + return ( + + ); + } + return ( + + ); + }; + + const totalSteps = steps.length; + const errorSteps = steps.filter((s) => s.error).length; + const blockedSteps = steps.filter((s) => s.blocked).length; + const successSteps = totalSteps - errorSteps - blockedSteps; + + return ( +
+ +

Push Validation Steps Summary

+
+ } + label={`${successSteps} Successful`} + style={{ backgroundColor: '#388e3c', color: 'white' }} + /> + {errorSteps > 0 && ( + } + label={`${errorSteps} Error${errorSteps > 1 ? 's' : ''}`} + style={{ backgroundColor: '#d32f2f', color: 'white' }} + /> + )} + {blockedSteps > 0 && ( + } + label={`${blockedSteps} Blocked`} + style={{ backgroundColor: '#f57c00', color: 'white' }} + /> + )} + +
+
+ +
+ {steps.map((step) => ( + + } + className={classes.stepSummary} + > +
{getStepIcon(step)}
+
+ {step.stepName} + {getStepStatus(step)} +
+
+ +
+ {step.error && step.errorMessage && ( +
+ Error: {step.errorMessage} +
+ )} + {step.blocked && step.blockedMessage && ( +
+ Blocked: {step.blockedMessage} +
+ )} + {step.content && ( +
+ + Content: + +
+                      {typeof step.content === 'string'
+                        ? step.content
+                        : JSON.stringify(step.content, null, 2)}
+                    
+
+ )} + {step.logs && step.logs.length > 0 && ( +
+ + Logs ({step.logs.length}): + + {step.logs.map((log: string, logIndex: number) => ( +
+ {log} +
+ ))} +
+ )} + {!step.error && + !step.blocked && + !step.content && + (!step.logs || step.logs.length === 0) && ( + + This step completed successfully with no additional details. + + )} +
+
+
+ ))} +
+
+ ); +}; + +export default StepsTimeline;