mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
adding new dashboard view. Muchas wow
This commit is contained in:
156
ui/src/views/dashboard/Dashboard.jsx
Normal file
156
ui/src/views/dashboard/Dashboard.jsx
Normal file
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button, Col, Row, Toast } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconTerminal,
|
||||
IconStar,
|
||||
IconClock,
|
||||
IconDoubleChevronLeft,
|
||||
IconDoubleChevronRight,
|
||||
IconStarStroked,
|
||||
IconNoteMoney,
|
||||
IconSearch,
|
||||
IconPlayCircle,
|
||||
} from '@douyinfe/semi-icons';
|
||||
|
||||
import { useSelector, useActions } from '../../services/state/store';
|
||||
import KpiCard from '../../components/cards/KpiCard.jsx';
|
||||
import PieChartCard from '../../components/cards/PieChartCard.jsx';
|
||||
import Headline from '../../components/headline/Headline.jsx';
|
||||
|
||||
import './Dashboard.less';
|
||||
import { SegmentPart } from '../../components/segment/SegmentPart.jsx';
|
||||
import { xhrPost } from '../../services/xhr.js';
|
||||
import { format } from '../../services/time/timeService.js';
|
||||
|
||||
export default function Dashboard() {
|
||||
const actions = useActions();
|
||||
const dashboard = useSelector((state) => state.dashboard.data);
|
||||
React.useEffect(() => {
|
||||
actions.dashboard.getDashboard();
|
||||
}, []);
|
||||
|
||||
const kpis = dashboard?.kpis || { totalJobs: 0, totalListings: 0, providersUsed: 0 };
|
||||
const pieData = dashboard?.pie || [];
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<Headline text="Dashboard" size={3} />
|
||||
|
||||
<Row gutter={16} className="dashboard__row">
|
||||
<Col span={12} xs={24} sm={24} md={24} lg={24} xl={12}>
|
||||
<SegmentPart name="General" Icon={IconTerminal}>
|
||||
<Row gutter={16} className="dashboard__row">
|
||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
||||
<KpiCard
|
||||
title="Search Interval"
|
||||
value={`${dashboard?.general?.interval} min`}
|
||||
icon={<IconClock />}
|
||||
description="Time interval for job execution"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
||||
<KpiCard
|
||||
title="Last Search"
|
||||
valueFontSize="14px"
|
||||
value={
|
||||
dashboard?.general?.lastRun == null || dashboard?.general?.lastRun === 0
|
||||
? '---'
|
||||
: format(dashboard?.general?.lastRun)
|
||||
}
|
||||
icon={<IconDoubleChevronLeft />}
|
||||
description="Last execution timestamp"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
||||
<KpiCard
|
||||
title="Next Search"
|
||||
value={
|
||||
dashboard?.general?.nextRun == null || dashboard?.general?.nextRun === 0
|
||||
? '---'
|
||||
: format(dashboard?.general?.nextRun)
|
||||
}
|
||||
valueFontSize="14px"
|
||||
icon={<IconDoubleChevronRight />}
|
||||
description="Next execution timestamp"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
||||
<KpiCard title="Search Now" icon={<IconSearch />} description="Run a search now">
|
||||
<Button
|
||||
size="small"
|
||||
style={{ marginTop: '.2rem' }}
|
||||
icon={<IconPlayCircle />}
|
||||
aria-label="Start now"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await xhrPost('/api/jobs/startAll', null);
|
||||
Toast.success('Successfully triggered Fredy search.');
|
||||
} catch {
|
||||
Toast.error('Failed to trigger search');
|
||||
}
|
||||
}}
|
||||
>
|
||||
Search now
|
||||
</Button>
|
||||
</KpiCard>
|
||||
</Col>
|
||||
</Row>
|
||||
</SegmentPart>
|
||||
</Col>
|
||||
<Col span={12} xs={24} sm={24} md={24} lg={24} xl={12}>
|
||||
<SegmentPart name="Overview" Icon={IconStar}>
|
||||
<Row gutter={16} className="dashboard__row">
|
||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
||||
<KpiCard
|
||||
title="Jobs"
|
||||
color="blue"
|
||||
value={!kpis.totalJobs ? '---' : kpis.totalJobs}
|
||||
icon={<IconTerminal />}
|
||||
description="Total number of jobs"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
||||
<KpiCard
|
||||
title="Listings"
|
||||
color="orange"
|
||||
value={!kpis.totalListings ? '---' : kpis.totalListings}
|
||||
icon={<IconStarStroked />}
|
||||
description="Total listings found"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
||||
<KpiCard
|
||||
title="Active Listings"
|
||||
color="green"
|
||||
value={!kpis.numberOfActiveListings ? '---' : kpis.numberOfActiveListings}
|
||||
icon={<IconStar />}
|
||||
description="Total active listings"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
||||
<KpiCard
|
||||
title="Avg. Price"
|
||||
color="purple"
|
||||
value={`${!kpis.avgPriceOfListings ? '---' : kpis.avgPriceOfListings} EUR`}
|
||||
icon={<IconNoteMoney />}
|
||||
description="Avg. Price of listings"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</SegmentPart>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<SegmentPart name="Provider Insights" Icon={IconStar} helpText="Percentage of found listings over all providers">
|
||||
<PieChartCard title="Jobs per Provider" data={pieData} isLoading={false} />
|
||||
</SegmentPart>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Dashboard.displayName = 'Dashboard';
|
||||
11
ui/src/views/dashboard/Dashboard.less
Normal file
11
ui/src/views/dashboard/Dashboard.less
Normal file
@@ -0,0 +1,11 @@
|
||||
.dashboard {
|
||||
&__row {
|
||||
margin-bottom: 1rem;
|
||||
/* Ensure grid items wrap to next line on narrow screens */
|
||||
flex-wrap: wrap;
|
||||
/* Vertical gap of 1rem between wrapped grid items (no px) */
|
||||
.semi-col {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,6 @@ export default function Jobs() {
|
||||
onJobRemoval={onJobRemoval}
|
||||
onListingRemoval={onListingRemoval}
|
||||
onJobStatusChanged={onJobStatusChanged}
|
||||
onJobInsight={(jobId) => navigate(`/jobs/insights/${jobId}`)}
|
||||
onJobEdit={(jobId) => navigate(`/jobs/edit/${jobId}`)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { format } from '../../services/time/timeService';
|
||||
import { Button, Card, Col, Row, Toast } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconClock,
|
||||
IconDoubleChevronLeft,
|
||||
IconDoubleChevronRight,
|
||||
IconPlayCircle,
|
||||
IconSearch,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { xhrPost } from '../../services/xhr.js';
|
||||
|
||||
import './ProsessingTimes.less';
|
||||
import { useScreenWidth } from '../../hooks/screenWidth.js';
|
||||
|
||||
function InfoCard({ title, value, icon }) {
|
||||
const { Meta } = Card;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
margin: '1rem',
|
||||
background: 'rgb(53, 54, 60)',
|
||||
borderRadius: '.3rem',
|
||||
padding: '1rem',
|
||||
minHeight: '3rem',
|
||||
}}
|
||||
>
|
||||
<Meta title={title} description={value} avatar={icon} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProcessingTimes({ processingTimes = {} }) {
|
||||
if (Object.keys(processingTimes).length === 0) {
|
||||
return null;
|
||||
}
|
||||
const width = useScreenWidth();
|
||||
const invisible = width <= 1180;
|
||||
|
||||
if (invisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<Col span={6}>
|
||||
<InfoCard
|
||||
title="Search Interval"
|
||||
value={`${processingTimes.interval} min`}
|
||||
icon={<IconClock style={{ color: 'rgba(var(--semi-grey-4), 1)' }} />}
|
||||
/>
|
||||
</Col>
|
||||
{processingTimes.lastRun && (
|
||||
<>
|
||||
<Col span={6}>
|
||||
<InfoCard
|
||||
title="Last search"
|
||||
icon={<IconDoubleChevronLeft style={{ color: 'rgba(var(--semi-grey-4), 1)' }} />}
|
||||
value={format(processingTimes.lastRun)}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<InfoCard
|
||||
title="Next search"
|
||||
icon={<IconDoubleChevronRight style={{ color: 'rgba(var(--semi-grey-4), 1)' }} />}
|
||||
value={format(processingTimes.lastRun + processingTimes.interval * 60000)}
|
||||
/>
|
||||
</Col>
|
||||
</>
|
||||
)}
|
||||
<Col span={6}>
|
||||
<InfoCard
|
||||
title="Search Now"
|
||||
icon={<IconSearch style={{ color: 'rgba(var(--semi-grey-4), 1)' }} />}
|
||||
value={
|
||||
<Button
|
||||
size="small"
|
||||
style={{ marginTop: '.2rem' }}
|
||||
icon={<IconPlayCircle />}
|
||||
aria-label="Start now"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await xhrPost('/api/jobs/startAll', null);
|
||||
Toast.success('Successfully triggered Fredy search.');
|
||||
} catch {
|
||||
Toast.error('Failed to trigger search');
|
||||
}
|
||||
}}
|
||||
>
|
||||
Search now
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
.processingTimes {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { roundToHour } from '../../../services/time/timeService';
|
||||
import Headline from '../../../components/headline/Headline';
|
||||
import { useActions, useSelector } from '../../../services/state/store';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import Linechart from './Linechart';
|
||||
|
||||
const JobInsight = function JobInsight() {
|
||||
const actions = useActions();
|
||||
|
||||
const insights = useSelector((state) => state.jobs.insights);
|
||||
const jobs = useSelector((state) => state.jobs.jobs);
|
||||
const params = useParams();
|
||||
|
||||
React.useEffect(() => {
|
||||
actions.jobs.getInsightDataForJob(params.jobId);
|
||||
actions.jobs.getJobs();
|
||||
}, []);
|
||||
|
||||
const getData = () => {
|
||||
const data = insights[params.jobId] || {};
|
||||
const providers = Object.keys(data);
|
||||
|
||||
const countsByProvider = {};
|
||||
const allTimes = new Set();
|
||||
|
||||
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : 'Unknown');
|
||||
|
||||
providers.forEach((key) => {
|
||||
const providerName = cap(key);
|
||||
const tmpTimeObj = {};
|
||||
|
||||
Object.values(data[key] || {}).forEach((listingTs) => {
|
||||
const time = roundToHour(listingTs);
|
||||
tmpTimeObj[time] = tmpTimeObj[time] == null ? 1 : tmpTimeObj[time] + 1;
|
||||
allTimes.add(time);
|
||||
});
|
||||
|
||||
countsByProvider[providerName] = tmpTimeObj;
|
||||
});
|
||||
|
||||
const sortedTimes = Array.from(allTimes).sort((a, b) => a - b);
|
||||
|
||||
const result = [];
|
||||
providers.forEach((key) => {
|
||||
const providerName = cap(key);
|
||||
const bucket = countsByProvider[providerName] || {};
|
||||
|
||||
sortedTimes.forEach((t) => {
|
||||
result.push({
|
||||
listings: new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(new Date(parseInt(t))),
|
||||
listingsNumber: bucket[t] || 0, // y value
|
||||
provider: providerName, // series key
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const getJobName = () => {
|
||||
const job = jobs.find((job) => job.id === params.jobId);
|
||||
if (job == null) {
|
||||
return 'unknown';
|
||||
} else {
|
||||
return job.name;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Headline text={`Insights into Job: ${getJobName()}`} />
|
||||
<Linechart isLoading={false} series={getData()} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default JobInsight;
|
||||
@@ -1,57 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import Placeholder from '../../../components/placeholder/Placeholder';
|
||||
import { VChart } from '@visactor/react-vchart';
|
||||
|
||||
import './Linechart.less';
|
||||
|
||||
const commonSpec = {
|
||||
type: 'line',
|
||||
xField: 'listings',
|
||||
yField: 'listingsNumber',
|
||||
seriesField: 'provider',
|
||||
legends: { visible: true },
|
||||
line: {
|
||||
style: {
|
||||
lineWidth: 2,
|
||||
},
|
||||
},
|
||||
point: {
|
||||
visible: false,
|
||||
},
|
||||
axes: [
|
||||
{
|
||||
orient: 'bottom',
|
||||
field: 'listings',
|
||||
zero: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const Linechart = function Linechart({ title, series, isLoading = false }) {
|
||||
return (
|
||||
<Placeholder ready={!isLoading} rows={6}>
|
||||
{series == null || series.length === 0 ? (
|
||||
<div className="linechart__no__data">No Data for selected timeframe :-/</div>
|
||||
) : (
|
||||
<VChart
|
||||
spec={{
|
||||
...commonSpec,
|
||||
title: {
|
||||
visible: true,
|
||||
text: title,
|
||||
},
|
||||
data: { values: series },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Placeholder>
|
||||
);
|
||||
};
|
||||
|
||||
export default Linechart;
|
||||
@@ -1,15 +0,0 @@
|
||||
.linechart {
|
||||
&__no__data {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #06dcfff2;
|
||||
flex-direction: column;
|
||||
|
||||
&__height {
|
||||
height: 30.7rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ export default function Login() {
|
||||
Toast.success('Login successful!');
|
||||
|
||||
await actions.user.getCurrentUser();
|
||||
navigate('/jobs');
|
||||
navigate('/dashboard');
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user