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

@@ -10,7 +10,6 @@ import PermissionAwareRoute from './components/permission/PermissionAwareRoute';
import GeneralSettings from './views/generalSettings/GeneralSettings';
import JobMutation from './views/jobs/mutation/JobMutation';
import UserMutator from './views/user/mutation/UserMutator';
import JobInsight from './views/jobs/insights/JobInsight.jsx';
import { useActions, useSelector } from './services/state/store';
import { Routes, Route, Navigate } from 'react-router-dom';
import Login from './views/login/Login';
@@ -25,8 +24,8 @@ import Listings from './views/listings/Listings.jsx';
import Navigation from './components/navigation/Navigation.jsx';
import { Layout } from '@douyinfe/semi-ui';
import FredyFooter from './components/footer/FredyFooter.jsx';
import ProcessingTimes from './views/jobs/ProcessingTimes.jsx';
import WatchlistManagement from './views/listings/management/WatchlistManagement.jsx';
import Dashboard from './views/dashboard/Dashboard.jsx';
export default function FredyApp() {
const actions = useActions();
@@ -34,7 +33,6 @@ export default function FredyApp() {
const currentUser = useSelector((state) => state.user.currentUser);
const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate);
const settings = useSelector((state) => state.generalSettings.settings);
const processingTimes = useSelector((state) => state.jobs.processingTimes);
useEffect(() => {
async function init() {
@@ -43,7 +41,6 @@ export default function FredyApp() {
await actions.features.getFeatures();
await actions.provider.getProvider();
await actions.jobs.getJobs();
await actions.jobs.getProcessingTimes();
await actions.jobs.getSharableUserList();
await actions.notificationAdapter.getAdapter();
await actions.generalSettings.getGeneralSettings();
@@ -88,14 +85,13 @@ export default function FredyApp() {
</>
)}
{settings.analyticsEnabled === null && !settings.demoMode && <TrackingModal />}
{processingTimes != null && <ProcessingTimes processingTimes={processingTimes} />}
<Divider />
<div className="app__content">
<Routes>
<Route path="/403" element={<InsufficientPermission />} />
<Route path="/jobs/new" element={<JobMutation />} />
<Route path="/jobs/edit/:jobId" element={<JobMutation />} />
<Route path="/jobs/insights/:jobId" element={<JobInsight />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/jobs" element={<Jobs />} />
<Route path="/listings" element={<Listings />} />
<Route path="/watchlistManagement" element={<WatchlistManagement />} />
@@ -134,7 +130,7 @@ export default function FredyApp() {
}
/>
<Route path="/" element={<Navigate to="/jobs" replace />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes>
</div>
</Content>

View File

@@ -9,17 +9,12 @@ import { HashRouter } from 'react-router-dom';
import { createRoot } from 'react-dom/client';
import en_US from '@douyinfe/semi-ui/lib/es/locale/source/en_US';
import { LocaleProvider } from '@douyinfe/semi-ui';
import { initVChartSemiTheme } from '@visactor/vchart-semi-theme';
import App from './App';
import './Index.less';
const container = document.getElementById('fredy');
const root = createRoot(container);
initVChartSemiTheme({
defaultMode: 'dark',
});
root.render(
<HashRouter>
<LocaleProvider locale={en_US}>

BIN
ui/src/assets/heart.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -0,0 +1,23 @@
.chartCard {
/* Use provided background with slight transparency and a brighter mix */
background: color-mix(in oklab, var(--card-bg, rgb(70 72 78)) 20%, white 80%);
border-radius: .6rem;
border: 1px solid color-mix(in oklab, var(--card-bg, rgb(70 72 78)) 35%, white 65%);
box-shadow: 0 6px 20px rgba(0,0,0,0.08);
/* Ensure base text has strong contrast */
color: var(--semi-color-text-0);
/* Semi Card header/title styling */
.semi-card-header .semi-card-header-title {
/* Derive a tinted title color with stronger contrast towards black */
color: color-mix(in oklab, var(--card-bg, rgb(70 72 78)) 60%, black 40%);
font-weight: 600;
}
&__no__data {
display: grid;
place-items: center;
height: 14rem;
opacity: .7;
}
}

View File

@@ -0,0 +1,92 @@
@import "DashboardCardColors.less";
.color-variant(@bg, @border, @text) {
background-color: @bg;
border: 1px solid @border;
color: @text;
}
.dashboard-card {
box-sizing: border-box;
padding: .8rem;
border-radius: .5rem;
border-width: 1px;
font-weight: 600;
box-shadow: 0 6px 20px rgba(0,0,0,0.08);
/* Make all KPI boxes the same size regardless of content/font */
width: 100%;
max-width: none;
height: 10rem;
display: flex;
flex-direction: column;
&.blue {
.color-variant(@color-blue-bg, @color-blue-border, @color-blue-text);
}
&.orange {
.color-variant(@color-orange-bg, @color-orange-border, @color-orange-text);
}
&.green {
.color-variant(@color-green-bg, @color-green-border, @color-green-text);
}
&.purple {
.color-variant(@color-purple-bg, @color-purple-border, @color-purple-text);
}
&.gray {
.color-variant(@color-gray-bg, @color-gray-border, @color-gray-text);
}
&__header {
display: flex;
align-items: center;
gap: .6rem;
/* Keep header from growing content height */
min-height: 2rem;
overflow: hidden;
}
&__icon {
border-radius: .6rem;
display: grid;
place-items: center;
}
&__title {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__content {
margin-top: .4rem;
font-size: .7rem;
flex: 1 1 auto;
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
}
&__value {
margin: 0;
font-size: 1.5rem;
line-height: 1.1;
color: #fff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__desc {
opacity: .8;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}

View File

@@ -0,0 +1,19 @@
@color-blue-bg: rgba(0, 123, 255, 0.24);
@color-blue-border: #1E40AFFF;
@color-blue-text: #60a5fa;
@color-orange-bg: rgba(250, 91, 5, 0.12);
@color-orange-border: #d33601;
@color-orange-text: #FB923CFF;
@color-green-bg: rgba(38, 250, 5, 0.12);
@color-green-border: #00c316;
@color-green-text: #33f308;
@color-purple-bg: rgba(91, 3, 218, 0.38);
@color-purple-border: #7500c3;
@color-purple-text: #b15fff;
@color-gray-bg: rgba(110, 110, 110, 0.38);
@color-gray-border: #807f7f;
@color-gray-text: #bab9b9;

View File

@@ -0,0 +1,40 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
/*
* Copyright (c) 2025 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import './DashboardCard.less';
export default function KpiCard({
title,
icon,
value,
valueFontSize = '1.5rem',
description,
color = 'gray',
children,
}) {
return (
<div className={`dashboard-card ${color}`}>
<div className="dashboard-card__header">
<div className="dashboard-card__icon">{icon}</div>
<div className="dashboard-card__title">
<span>{title}</span>
</div>
</div>
<div className="dashboard-card__content">
<p className="dashboard-card__value" style={{ fontSize: valueFontSize }}>
{value}
{children}
</p>
{description && <span className="dashboard-card__desc">{description}</span>}
</div>
</div>
);
}

View File

@@ -0,0 +1,97 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import { Pie } from 'react-chartjs-2';
import { Chart as ChartJS, ArcElement, Tooltip, Legend, Title as ChartTitle } from 'chart.js';
import './ChartCard.less';
ChartJS.register(ArcElement, Tooltip, Legend, ChartTitle);
export default function PieChartCard({ data = [] }) {
const { labels, values } = React.useMemo(() => {
if (data && typeof data === 'object' && !Array.isArray(data)) {
const lbls = Array.isArray(data.labels) ? data.labels : [];
const vals = Array.isArray(data.values)
? data.values.map((v) => (Number.isFinite(Number(v)) ? Number(v) : 0))
: [];
return { labels: lbls, values: vals };
}
if (Array.isArray(data)) {
const lbls = data.map((d) => d?.type ?? 'Unknown');
const vals = data.map((d) => {
const v = Number(d?.value);
return Number.isFinite(v) ? v : 0;
});
return { labels: lbls, values: vals };
}
return { labels: [], values: [] };
}, [data]);
const palette = React.useMemo(
() => [
'#4e79a7',
'#f28e2b',
'#e15759',
'#76b7b2',
'#59a14f',
'#edc948',
'#b07aa1',
'#ff9da7',
'#9c755f',
'#bab0ab',
],
[],
);
const chartData = React.useMemo(
() => ({
labels,
datasets: [
{
data: values,
backgroundColor: labels.map((_, i) => palette[i % palette.length]),
borderColor: labels.map((_, i) => palette[i % palette.length]),
borderWidth: 1,
},
],
}),
[labels, values, palette],
);
const options = React.useMemo(
() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'right',
labels: {
color: () => '#fff',
},
},
title: { display: false },
tooltip: {
callbacks: {
label: (ctx) => {
const label = ctx.label || '';
const val = ctx.parsed !== undefined ? ctx.parsed : ctx.raw;
return `${label}: ${val}%`;
},
},
},
},
}),
[],
);
const isEmpty = !labels || labels.length === 0 || !values || values.length === 0;
return (
<>{isEmpty ? <div className="chartCard__no__data">No Data</div> : <Pie data={chartData} options={options} />}</>
);
}

View File

@@ -1,9 +1,10 @@
.navigate {
&__logout_Button {
&__footer {
align-items: center;
justify-content: center;
flex-direction: column;
gap: 0.5rem;
width: 100%;
display: flex;
}
}

View File

@@ -3,26 +3,34 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import { Nav } from '@douyinfe/semi-ui';
import { IconStar, IconSetting, IconTerminal } from '@douyinfe/semi-icons';
import React, { useEffect, useState } from 'react';
import { Button, Nav } from '@douyinfe/semi-ui';
import { IconStar, IconSetting, IconTerminal, IconHistogram, IconSidebar } from '@douyinfe/semi-icons';
import logoWhite from '../../assets/logo_white.png';
import heart from '../../assets/heart.png';
import Logout from '../logout/Logout.jsx';
import { useLocation, useNavigate } from 'react-router-dom';
import './Navigate.less';
import { useScreenWidth } from '../../hooks/screenWidth.js';
import { useFeature } from '../../hooks/featureHook.js';
import { useScreenWidth } from '../../hooks/screenWidth.js';
export default function Navigation({ isAdmin }) {
const navigate = useNavigate();
const location = useLocation();
const width = useScreenWidth();
const collapsed = width <= 850;
const [collapsed, setCollapsed] = useState(width <= 850);
const watchlistFeature = useFeature('WATCHLIST_MANAGEMENT') || false;
useEffect(() => {
if (width <= 850) {
setCollapsed(true);
}
}, [width]);
const items = [
{ itemKey: '/dashboard', text: 'Dashboard', icon: <IconHistogram /> },
{ itemKey: '/jobs', text: 'Jobs', icon: <IconTerminal /> },
{ itemKey: '/listings', text: 'Listings', icon: <IconStar /> },
];
@@ -51,18 +59,21 @@ export default function Navigation({ isAdmin }) {
return (
<Nav
style={{ height: '100%', width: collapsed ? '' : '13.2rem' }}
style={{ height: '100%' }}
items={items}
isCollapsed={collapsed}
selectedKeys={[parsePathName(location.pathname)]}
onSelect={(key) => {
navigate(key.itemKey);
}}
header={<img src={logoWhite} width="180" alt="Fredy Logo" />}
header={<img src={collapsed ? heart : logoWhite} width={collapsed ? '80' : '160'} alt="Fredy Logo" />}
footer={
<div className="navigate__logout_Button">
<Nav.Footer className="navigate__footer">
<Logout text={!collapsed} />
</div>
<Button icon={<IconSidebar />} onClick={() => setCollapsed(!collapsed)}>
{!collapsed && 'Collapse'}
</Button>
</Nav.Footer>
}
/>
);

View File

@@ -8,14 +8,16 @@ import { Card } from '@douyinfe/semi-ui';
import './SegmentParts.less';
export const SegmentPart = ({ name, Icon = null, children, helpText }) => {
export const SegmentPart = ({ name, Icon = null, children, helpText = null }) => {
const { Meta } = Card;
return (
<Card
className="segmentParts"
title={
<Meta title={name} description={helpText} avatar={Icon == null ? null : <Icon size="extra-extra-small" />} />
(helpText || name) && (
<Meta title={name} description={helpText} avatar={Icon == null ? null : <Icon size="extra-extra-small" />} />
)
}
>
{children}

View File

@@ -1,6 +1,6 @@
.segmentParts {
border: 1px solid #323232 !important;
border-radius: 5px !important;
border-radius: .9rem !important;
color: rgba(var(--semi-grey-8), 1);
background: rgb(53, 54, 60);
margin: 2rem;

View File

@@ -6,7 +6,7 @@
import React from 'react';
import { Button, Empty, Table, Switch, Popover } from '@douyinfe/semi-ui';
import { IconAlertTriangle, IconDelete, IconDescend2, IconEdit, IconHistogram } from '@douyinfe/semi-icons';
import { IconAlertTriangle, IconDelete, IconDescend2, IconEdit } from '@douyinfe/semi-icons';
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
import './JobTable.less';
@@ -21,14 +21,7 @@ const empty = (
const getPopoverContent = (text) => <article className="jobPopoverContent">{text}</article>;
export default function JobTable({
jobs = {},
onJobRemoval,
onJobStatusChanged,
onJobEdit,
onJobInsight,
onListingRemoval,
} = {}) {
export default function JobTable({ jobs = {}, onJobRemoval, onJobStatusChanged, onJobEdit, onListingRemoval } = {}) {
return (
<Table
pagination={false}
@@ -98,14 +91,6 @@ export default function JobTable({
render: (_, job) => {
return (
<div className="interactions">
<Popover content={getPopoverContent('Job Insights')}>
<Button
type="primary"
icon={<IconHistogram />}
disabled={job.isOnlyShared}
onClick={() => onJobInsight(job.id)}
/>
</Popover>
<Popover content={getPopoverContent('Edit a Job')}>
<Button
type="secondary"

View File

@@ -33,6 +33,16 @@ export const useFredyState = create(
(set) => {
// Async actions that directly set state (no separate reducer concept)
const effects = {
dashboard: {
async getDashboard() {
try {
const response = await xhrGet('/api/dashboard');
set((state) => ({ dashboard: { ...state.dashboard, data: response.json } }));
} catch (Exception) {
console.error('Error while trying to get resource for /api/dashboard. Error:', Exception);
}
},
},
notificationAdapter: {
async getAdapter() {
try {
@@ -90,27 +100,6 @@ export const useFredyState = create(
console.error(`Error while trying to get resource for api/jobs. Error:`, Exception);
}
},
async getProcessingTimes() {
try {
const response = await xhrGet('/api/jobs/processingTimes');
set((state) => ({ jobs: { ...state.jobs, processingTimes: Object.freeze(response.json) } }));
} catch (Exception) {
console.error(`Error while trying to get resource for api/processingTimes. Error:`, Exception);
}
},
async getInsightDataForJob(jobId) {
try {
const response = await xhrGet(`/api/jobs/insights/${jobId}`);
set((state) => ({
jobs: {
...state.jobs,
insights: { ...state.jobs.insights, [jobId]: Object.freeze(response.json) },
},
}));
} catch (Exception) {
console.error(`Error while trying to get resource for api/jobs/insights. Error:`, Exception);
}
},
},
user: {
async getUsers() {
@@ -185,6 +174,7 @@ export const useFredyState = create(
// Initial state
const initial = {
dashboard: { data: null },
notificationAdapter: [],
listingsTable: {
totalNumber: 0,
@@ -196,12 +186,13 @@ export const useFredyState = create(
demoMode: { demoMode: false },
versionUpdate: {},
provider: [],
jobs: { jobs: [], insights: {}, processingTimes: {}, shareableUserList: [] },
jobs: { jobs: [], shareableUserList: [] },
user: { users: [], currentUser: null },
};
// Expose actions by grouping them per slice
const actions = {
dashboard: { ...effects.dashboard },
notificationAdapter: { ...effects.notificationAdapter },
generalSettings: { ...effects.generalSettings },
demoMode: { ...effects.demoMode },

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 (