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:
@@ -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>
|
||||
|
||||
@@ -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
BIN
ui/src/assets/heart.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
23
ui/src/components/cards/ChartCard.less
Normal file
23
ui/src/components/cards/ChartCard.less
Normal 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;
|
||||
}
|
||||
}
|
||||
92
ui/src/components/cards/DashboardCard.less
Normal file
92
ui/src/components/cards/DashboardCard.less
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
19
ui/src/components/cards/DashboardCardColors.less
Normal file
19
ui/src/components/cards/DashboardCardColors.less
Normal 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;
|
||||
40
ui/src/components/cards/KpiCard.jsx
Normal file
40
ui/src/components/cards/KpiCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
ui/src/components/cards/PieChartCard.jsx
Normal file
97
ui/src/components/cards/PieChartCard.jsx
Normal 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} />}</>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
.navigate {
|
||||
&__logout_Button {
|
||||
&__footer {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 },
|
||||
|
||||
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