adding new dashboard view. Muchas wow

This commit is contained in:
orangecoding
2025-12-14 12:23:59 +01:00
parent 87b5673bf0
commit 87771655a8
31 changed files with 688 additions and 1047 deletions

View 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';

View 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;
}
}
}

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -1,5 +0,0 @@
.processingTimes {
display: flex;
gap: 1rem;
justify-content: space-between;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}
}
}

View File

@@ -52,7 +52,7 @@ export default function Login() {
Toast.success('Login successful!');
await actions.user.getCurrentUser();
navigate('/jobs');
navigate('/dashboard');
};
return (