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

@@ -6,7 +6,6 @@
import { notificationAdapterRouter } from './routes/notificationAdapterRouter.js';
import { authInterceptor, cookieSession, adminInterceptor } from './security.js';
import { generalSettingsRouter } from './routes/generalSettingsRoute.js';
import { analyticsRouter } from './routes/analyticsRouter.js';
import { providerRouter } from './routes/providerRouter.js';
import { versionRouter } from './routes/versionRouter.js';
import { loginRouter } from './routes/loginRoute.js';
@@ -22,6 +21,7 @@ import logger from '../services/logger.js';
import { listingsRouter } from './routes/listingsRouter.js';
import { getSettings } from '../services/storage/settingsStorage.js';
import { featureRouter } from './routes/featureRouter.js';
import { dashboardRouter } from './routes/dashboardRouter.js';
const service = restana();
const staticService = files(path.join(getDirName(), '../ui/public'));
const PORT = (await getSettings()).port || 9998;
@@ -33,19 +33,21 @@ service.use('/api/admin', authInterceptor());
service.use('/api/jobs', authInterceptor());
service.use('/api/version', authInterceptor());
service.use('/api/listings', authInterceptor());
service.use('/api/dashboard', authInterceptor());
service.use('/api/features', authInterceptor());
// /admin can only be accessed when user is having admin permissions
service.use('/api/admin', adminInterceptor());
service.use('/api/jobs/notificationAdapter', notificationAdapterRouter);
service.use('/api/admin/generalSettings', generalSettingsRouter);
service.use('/api/jobs/provider', providerRouter);
service.use('/api/jobs/insights', analyticsRouter);
service.use('/api/admin/users', userRouter);
service.use('/api/version', versionRouter);
service.use('/api/jobs', jobRouter);
service.use('/api/login', loginRouter);
service.use('/api/listings', listingsRouter);
service.use('/api/features', featureRouter);
service.use('/api/dashboard', dashboardRouter);
//this route is unsecured intentionally as it is being queried from the login page
service.use('/api/demo', demoRouter);

View File

@@ -1,15 +0,0 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import restana from 'restana';
import * as listingStorage from '../../services/storage/listingsStorage.js';
const service = restana();
const analyticsRouter = service.newRouter();
analyticsRouter.get('/:jobId', async (req, res) => {
const { jobId } = req.params;
res.body = listingStorage.getListingProviderDataForAnalytics(jobId) || {};
res.send();
});
export { analyticsRouter };

View File

@@ -0,0 +1,71 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import restana from 'restana';
import * as jobStorage from '../../services/storage/jobStorage.js';
import * as userStorage from '../../services/storage/userStorage.js';
import { getListingsKpisForJobIds, getProviderDistributionForJobIds } from '../../services/storage/listingsStorage.js';
import { getSettings } from '../../services/storage/settingsStorage.js';
const service = restana();
export const dashboardRouter = service.newRouter();
function isAdmin(req) {
const user = req.session?.currentUser ? userStorage.getUser(req.session.currentUser) : null;
return !!user?.isAdmin;
}
function getAccessibleJobs(req) {
const currentUser = req.session.currentUser;
const admin = isAdmin(req);
return jobStorage
.getJobs()
.filter((job) => admin || job.userId === currentUser || job.shared_with_user.includes(currentUser));
}
function cap(val) {
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
}
dashboardRouter.get('/', async (req, res) => {
const jobs = getAccessibleJobs(req);
const settings = await getSettings();
// KPIs
const totalJobs = jobs.length;
const totalListings = jobs.reduce((sum, j) => sum + (j.numberOfFoundListings || 0), 0);
const jobIds = jobs.map((j) => j.id);
const { numberOfActiveListings, avgPriceOfListings } = getListingsKpisForJobIds(jobIds);
// Build Pie data in a simple shape the frontend can consume directly
// Shape: { labels: string[], values: number[] } with values as percentages
const providerPieRaw = getProviderDistributionForJobIds(jobIds);
const providerPie = Array.isArray(providerPieRaw)
? {
labels: providerPieRaw.map((p) => cap(p.type)),
values: providerPieRaw.map((p) => Number(p.value) || 0),
}
: providerPieRaw && typeof providerPieRaw === 'object'
? {
labels: Array.isArray(providerPieRaw.labels) ? providerPieRaw.labels : [],
values: Array.isArray(providerPieRaw.values) ? providerPieRaw.values : [],
}
: { labels: [], values: [] };
res.body = {
general: {
interval: settings.interval,
lastRun: settings.lastRun || null,
nextRun: settings.lastRun == null ? 0 : settings.lastRun + settings.interval * 60000,
},
kpis: {
totalJobs,
totalListings,
numberOfActiveListings,
avgPriceOfListings,
},
pie: providerPie,
};
res.send();
});

View File

@@ -9,7 +9,6 @@ import * as userStorage from '../../services/storage/userStorage.js';
import { isAdmin } from '../security.js';
import logger from '../../services/logger.js';
import { bus } from '../../services/events/event-bus.js';
import { getSettings } from '../../services/storage/settingsStorage.js';
const service = restana();
const jobRouter = service.newRouter();
@@ -48,15 +47,6 @@ jobRouter.get('/', async (req, res) => {
res.send();
});
jobRouter.get('/processingTimes', async (req, res) => {
const settings = await getSettings();
res.body = {
interval: settings.interval,
lastRun: settings.lastRun || null,
};
res.send();
});
jobRouter.post('/startAll', async (req, res) => {
bus.emit('jobs:runAll');
res.send();

View File

@@ -7,40 +7,6 @@ import { nullOrEmpty } from '../../utils.js';
import SqliteConnection from './SqliteConnection.js';
import { nanoid } from 'nanoid';
/**
* Build analytics data for a given job by grouping all listings by provider and
* mapping each listing hash to its creation timestamp.
*
* SQL shape:
* SELECT json_group_object(provider, json_object(hash, created_at)) AS result
* FROM listings WHERE job_id = @jobId;
*
* The resulting object has the shape:
* {
* providerA: { "<hash1>": <created_at_ms>, "<hash2>": <created_at_ms>, ... },
* providerB: { ... }
* }
*
* @param {string} jobId - ID of the job whose listings should be aggregated.
* @returns {Record<string, Record<string, number>>} Object grouped by provider mapping listing-hash -> created_at epoch ms.
*/
export const getListingProviderDataForAnalytics = (jobId) => {
const row = SqliteConnection.query(
`SELECT COALESCE(
json_group_object(provider, json(provider_map)),
json('{}')
) AS result
FROM (SELECT provider,
json_group_object(hash, created_at) AS provider_map
FROM listings
WHERE job_id = @jobId
GROUP BY provider);`,
{ jobId },
);
return row?.length > 0 ? JSON.parse(row[0].result) : {};
};
/**
* Return a list of known listing hashes for a given job and provider.
* Useful to de-duplicate before inserting new listings.
@@ -59,6 +25,89 @@ export const getKnownListingHashesForJobAndProvider = (jobId, providerId) => {
).map((r) => r.hash);
};
/**
* Compute KPI aggregates for a given set of job IDs from the listings table.
*
* - numberOfActiveListings: count of listings where is_active = 1
* - avgPriceOfListings: average of numeric price, rounded to nearest integer
*
* When no jobIds are provided, returns zeros.
*
* @param {string[]} jobIds
* @returns {{ numberOfActiveListings: number, avgPriceOfListings: number }}
*/
export const getListingsKpisForJobIds = (jobIds = []) => {
if (!Array.isArray(jobIds) || jobIds.length === 0) {
return { numberOfActiveListings: 0, avgPriceOfListings: 0 };
}
const placeholders = jobIds.map(() => '?').join(',');
const row =
SqliteConnection.query(
`SELECT
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) AS activeCount,
AVG(price) AS avgPrice
FROM listings
WHERE job_id IN (${placeholders})`,
jobIds,
)[0] || {};
return {
numberOfActiveListings: Number(row.activeCount || 0),
avgPriceOfListings: row?.avgPrice == null ? 0 : Math.round(Number(row.avgPrice)),
};
};
/**
* Compute distribution of listings by provider for the given set of job IDs.
* Returns data ready for the pie chart component with fields `type` and `value` (percentage).
*
* Example return:
* [ { type: 'immoscout', value: 62 }, { type: 'immowelt', value: 38 } ]
*
* When no jobIds are provided or no listings exist, returns empty array.
*
* @param {string[]} jobIds
* @returns {{ type: string, value: number }[]}
*/
export const getProviderDistributionForJobIds = (jobIds = []) => {
if (!Array.isArray(jobIds) || jobIds.length === 0) {
return [];
}
const placeholders = jobIds.map(() => '?').join(',');
const rows = SqliteConnection.query(
`SELECT provider, COUNT(*) AS cnt
FROM listings
WHERE job_id IN (${placeholders})
GROUP BY provider
ORDER BY cnt DESC`,
jobIds,
);
const total = rows.reduce((acc, r) => acc + Number(r.cnt || 0), 0);
if (total === 0) return [];
// Map counts to integer percentage values (0-100). Ensure sum is ~100 by rounding.
const percentages = rows.map((r) => ({
type: r.provider,
value: Math.round((Number(r.cnt) / total) * 100),
}));
// Adjust rounding drift to keep sum at 100 (optional minor correction)
const drift = 100 - percentages.reduce((s, p) => s + p.value, 0);
if (drift !== 0 && percentages.length > 0) {
// apply drift to the largest slice to keep UX simple
let maxIdx = 0;
for (let i = 1; i < percentages.length; i++) {
if (percentages[i].value > percentages[maxIdx].value) maxIdx = i;
}
percentages[maxIdx].value = Math.max(0, percentages[maxIdx].value + drift);
}
return percentages;
};
/**
* Return a list of listing that either are active or have an unknown status
* to constantly check if they are still online