summaryrefslogtreecommitdiff
path: root/opendc-web/opendc-web-server/src/main/webui/components
diff options
context:
space:
mode:
authorvincent van beek <vincent@vlogic.nl>2026-04-15 16:19:02 +0200
committerGitHub <noreply@github.com>2026-04-15 16:19:02 +0200
commit11e355321db20b70c76c35b6e8fc36dbb9d97fc6 (patch)
treef12b8c8c2b6a642b315f2e4a7e54274bbcdb60be /opendc-web/opendc-web-server/src/main/webui/components
parent3e52cd36bed9455105f4a8c3d83ec805c1fb7b70 (diff)
add a job report to the scenario overview with details and time data (#406)
* add a job report to the scenario overview with details and time data * create Report data class
Diffstat (limited to 'opendc-web/opendc-web-server/src/main/webui/components')
-rw-r--r--opendc-web/opendc-web-server/src/main/webui/components/portfolios/JobReportModal.js178
-rw-r--r--opendc-web/opendc-web-server/src/main/webui/components/portfolios/ScenarioTable.js37
2 files changed, 206 insertions, 9 deletions
diff --git a/opendc-web/opendc-web-server/src/main/webui/components/portfolios/JobReportModal.js b/opendc-web/opendc-web-server/src/main/webui/components/portfolios/JobReportModal.js
new file mode 100644
index 00000000..5c518af7
--- /dev/null
+++ b/opendc-web/opendc-web-server/src/main/webui/components/portfolios/JobReportModal.js
@@ -0,0 +1,178 @@
+import PropTypes from 'prop-types'
+import React from 'react'
+import { Button, EmptyState, EmptyStateBody, EmptyStateIcon, Modal, ModalVariant, Title } from '@patternfly/react-core'
+import { CheckCircleIcon } from '@patternfly/react-icons'
+import { Table, Thead, Tr, Th, Tbody, Td } from '@patternfly/react-table'
+import { useJobReport } from '../../data/project'
+
+function formatDuration(seconds) {
+ if (seconds < 60) {
+ return `${seconds}s`
+ }
+ const minutes = Math.floor(seconds / 60)
+ const remainingSeconds = seconds % 60
+ if (minutes < 60) {
+ return `${minutes}m ${remainingSeconds}s`
+ }
+ const hours = Math.floor(minutes / 60)
+ const remainingMinutes = minutes % 60
+ return `${hours}h ${remainingMinutes}m ${remainingSeconds}s`
+}
+
+function JobReportModal({ jobId, isOpen, onClose }) {
+ const { data: report, isLoading } = useJobReport(jobId, { enabled: isOpen })
+
+ const logs = report?.logs || []
+ const error = report?.error
+ const summary = report?.summary
+ const createdAt = report?.createdAt
+ const startedAt = report?.startedAt
+
+ const actions = [
+ <Button variant="primary" onClick={onClose} key="close">
+ Close
+ </Button>,
+ ]
+
+ return (
+ <Modal variant={ModalVariant.large} isOpen={isOpen} onClose={onClose} title="Job Report" actions={actions}>
+ {isLoading && <div>Loading report...</div>}
+
+ {!isLoading && (createdAt || startedAt) && (
+ <div style={{ marginBottom: '20px', fontSize: '14px', color: '#6a6e73' }}>
+ {createdAt && (
+ <div>
+ <strong>Created:</strong> {new Date(createdAt).toLocaleString()}
+ </div>
+ )}
+ {startedAt && (
+ <div>
+ <strong>Started:</strong> {new Date(startedAt).toLocaleString()}
+ </div>
+ )}
+ </div>
+ )}
+
+ {!isLoading && logs.length === 0 && !error && (
+ <>
+ {summary && (summary.runtimeSeconds !== undefined || summary.waitTimeSeconds !== undefined) && (
+ <div style={{ marginBottom: '15px' }}>
+ {summary.runtimeSeconds !== undefined && (
+ <div>
+ <strong>Runtime:</strong> {formatDuration(summary.runtimeSeconds)}
+ </div>
+ )}
+ {summary.waitTimeSeconds !== undefined && (
+ <div>
+ <strong>Queue Wait Time:</strong> {formatDuration(summary.waitTimeSeconds)}
+ </div>
+ )}
+ </div>
+ )}
+ <EmptyState>
+ <EmptyStateIcon icon={CheckCircleIcon} color="green" />
+ <Title headingLevel="h4" size="lg">
+ No warnings or errors
+ </Title>
+ <EmptyStateBody>This simulation completed successfully with no issues.</EmptyStateBody>
+ </EmptyState>
+ </>
+ )}
+
+ {!isLoading && error && (
+ <div style={{ marginBottom: '20px' }}>
+ <Title headingLevel="h3" size="md">
+ Error
+ </Title>
+ <div
+ style={{
+ padding: '10px',
+ backgroundColor: '#fef0f0',
+ border: '1px solid #c9190b',
+ borderRadius: '3px',
+ marginTop: '10px',
+ }}
+ >
+ <div>
+ <strong>Type:</strong> {error.type}
+ </div>
+ <div>
+ <strong>Message:</strong> {error.message}
+ </div>
+ {error.stackTrace && (
+ <details style={{ marginTop: '10px' }}>
+ <summary style={{ cursor: 'pointer' }}>Stack Trace</summary>
+ <pre style={{ fontSize: '12px', overflow: 'auto', maxHeight: '200px' }}>
+ {error.stackTrace}
+ </pre>
+ </details>
+ )}
+ </div>
+ </div>
+ )}
+
+ {!isLoading && logs.length > 0 && (
+ <div>
+ {summary && (
+ <div style={{ marginBottom: '15px' }}>
+ <strong>Summary:</strong> {summary.totalWarnings} warning(s), {summary.totalErrors} error(s)
+ {summary.runtimeSeconds !== undefined && (
+ <>
+ {' | '}
+ <strong>Runtime:</strong> {formatDuration(summary.runtimeSeconds)}
+ </>
+ )}
+ {summary.waitTimeSeconds !== undefined && (
+ <>
+ {' | '}
+ <strong>Queue Wait Time:</strong> {formatDuration(summary.waitTimeSeconds)}
+ </>
+ )}
+ </div>
+ )}
+ <Table variant="compact">
+ <Thead>
+ <Tr>
+ <Th>Time</Th>
+ <Th>Level</Th>
+ <Th>Logger</Th>
+ <Th>Message</Th>
+ </Tr>
+ </Thead>
+ <Tbody>
+ {logs.map((log, index) => (
+ <Tr key={index}>
+ <Td>{new Date(log.timestamp).toLocaleTimeString()}</Td>
+ <Td>
+ <span
+ style={{
+ color: log.level === 'ERROR' ? '#c9190b' : '#f0ab00',
+ fontWeight: 'bold',
+ }}
+ >
+ {log.level}
+ </span>
+ </Td>
+ <Td style={{ fontSize: '12px' }}>{log.logger}</Td>
+ <Td>{log.message}</Td>
+ </Tr>
+ ))}
+ </Tbody>
+ </Table>
+ </div>
+ )}
+ </Modal>
+ )
+}
+
+JobReportModal.propTypes = {
+ jobId: PropTypes.number.isRequired,
+ isOpen: PropTypes.bool,
+ onClose: PropTypes.func.isRequired,
+}
+
+JobReportModal.defaultProps = {
+ isOpen: false,
+}
+
+export default JobReportModal
diff --git a/opendc-web/opendc-web-server/src/main/webui/components/portfolios/ScenarioTable.js b/opendc-web/opendc-web-server/src/main/webui/components/portfolios/ScenarioTable.js
index b068d045..db88456b 100644
--- a/opendc-web/opendc-web-server/src/main/webui/components/portfolios/ScenarioTable.js
+++ b/opendc-web/opendc-web-server/src/main/webui/components/portfolios/ScenarioTable.js
@@ -23,24 +23,36 @@
import { Bullseye } from '@patternfly/react-core'
import Link from 'next/link'
import { TableComposable, Thead, Tr, Th, Tbody, Td, ActionsColumn } from '@patternfly/react-table'
-import React from 'react'
+import React, { useState } from 'react'
import { Portfolio, Status } from '../../shapes'
import TableEmptyState from '../util/TableEmptyState'
import ScenarioState from './ScenarioState'
import { useDeleteScenario } from '../../data/project'
+import JobReportModal from './JobReportModal'
function ScenarioTable({ portfolio, status }) {
const { mutate: deleteScenario } = useDeleteScenario()
const projectId = portfolio?.project?.id
const scenarios = portfolio?.scenarios ?? []
+ const [reportJobId, setReportJobId] = useState(null)
- const actions = ({ number }) => [
- {
- title: 'Delete Scenario',
- onClick: () => deleteScenario({ projectId: projectId, number }),
- isDisabled: number === 0,
- },
- ]
+ const actions = (scenario) => {
+ const latestJob = scenario.jobs[scenario.jobs.length - 1]
+ const canViewReport = latestJob && (latestJob.state === 'FINISHED' || latestJob.state === 'FAILED')
+
+ return [
+ {
+ title: 'View Report',
+ onClick: () => setReportJobId(latestJob.id),
+ isDisabled: !canViewReport,
+ },
+ {
+ title: 'Delete Scenario',
+ onClick: () => deleteScenario({ projectId: projectId, number: scenario.number }),
+ isDisabled: scenario.number === 0,
+ },
+ ]
+ }
return (
<TableComposable aria-label="Scenario List" variant="compact">
@@ -49,6 +61,7 @@ function ScenarioTable({ portfolio, status }) {
<Th>Name</Th>
<Th>Topology</Th>
<Th>Trace</Th>
+ <Th>Created</Th>
<Th>State</Th>
</Tr>
</Thead>
@@ -68,6 +81,11 @@ function ScenarioTable({ portfolio, status }) {
<Td dataLabel="Workload">{`${scenario.workload.trace.name} (${
scenario.workload.samplingFraction * 100
}%)`}</Td>
+ <Td dataLabel="Created">
+ {scenario.jobs && scenario.jobs.length > 0
+ ? new Date(scenario.jobs[0].createdAt).toLocaleString()
+ : '-'}
+ </Td>
<Td dataLabel="State">
<ScenarioState state={scenario.jobs[scenario.jobs.length - 1].state} />
</Td>
@@ -78,7 +96,7 @@ function ScenarioTable({ portfolio, status }) {
))}
{scenarios.length === 0 && (
<Tr>
- <Td colSpan={4}>
+ <Td colSpan={5}>
<Bullseye>
<TableEmptyState
status={status}
@@ -91,6 +109,7 @@ function ScenarioTable({ portfolio, status }) {
</Tr>
)}
</Tbody>
+ {reportJobId && <JobReportModal jobId={reportJobId} isOpen={true} onClose={() => setReportJobId(null)} />}
</TableComposable>
)
}