diff --git a/package.json b/package.json index 85c8836..2d09c3f 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@quixo3/prisma-session-store": "^3.1.13", "@simplewebauthn/browser": "^13.1.0", "@simplewebauthn/server": "^13.1.1", + "apexcharts": "^4.7.0", "axios": "^1.8.4", "better-sqlite3": "^11.9.1", "date-fns": "^4.1.0", @@ -38,9 +39,11 @@ "pino-http": "^10.4.0", "pino-pretty": "^13.0.0", "quasar": "^2.16.0", + "superjson": "^2.2.2", "uuid": "^11.1.0", "vue": "^3.4.18", - "vue-router": "^4.0.0" + "vue-router": "^4.0.0", + "vue3-apexcharts": "^1.8.0" }, "devDependencies": { "@eslint/js": "^9.25.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b64008..e8e3e93 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@simplewebauthn/server': specifier: ^13.1.1 version: 13.1.1 + apexcharts: + specifier: ^4.7.0 + version: 4.7.0 axios: specifier: ^1.8.4 version: 1.8.4 @@ -83,6 +86,9 @@ importers: quasar: specifier: ^2.16.0 version: 2.18.1 + superjson: + specifier: ^2.2.2 + version: 2.2.2 uuid: specifier: ^11.1.0 version: 11.1.0 @@ -92,6 +98,9 @@ importers: vue-router: specifier: ^4.0.0 version: 4.5.0(vue@3.5.13(typescript@5.8.3)) + vue3-apexcharts: + specifier: ^1.8.0 + version: 1.8.0(apexcharts@4.7.0)(vue@3.5.13(typescript@5.8.3)) devDependencies: '@eslint/js': specifier: ^9.25.1 @@ -677,6 +686,31 @@ packages: peerDependencies: eslint: '>=9.0.0' + '@svgdotjs/svg.draggable.js@3.0.6': + resolution: {integrity: sha512-7iJFm9lL3C40HQcqzEfezK2l+dW2CpoVY3b77KQGqc8GXWa6LhhmX5Ckv7alQfUXBuZbjpICZ+Dvq1czlGx7gA==} + peerDependencies: + '@svgdotjs/svg.js': ^3.2.4 + + '@svgdotjs/svg.filter.js@3.0.9': + resolution: {integrity: sha512-/69XMRCDoam2HgC4ldHIaDgeQf1ViHIsa0Ld4uWgiXtZ+E24DWHe/9Ib6kbNiZ7WRIdlVokUDR1Fg0kjIpkfbw==} + engines: {node: '>= 0.8.0'} + + '@svgdotjs/svg.js@3.2.4': + resolution: {integrity: sha512-BjJ/7vWNowlX3Z8O4ywT58DqbNRyYlkk6Yz/D13aB7hGmfQTvGX4Tkgtm/ApYlu9M7lCQi15xUEidqMUmdMYwg==} + + '@svgdotjs/svg.resize.js@2.0.5': + resolution: {integrity: sha512-4heRW4B1QrJeENfi7326lUPYBCevj78FJs8kfeDxn5st0IYPIRXoTtOSYvTzFWgaWWXd3YCDE6ao4fmv91RthA==} + engines: {node: '>= 14.18'} + peerDependencies: + '@svgdotjs/svg.js': ^3.2.4 + '@svgdotjs/svg.select.js': ^4.0.1 + + '@svgdotjs/svg.select.js@4.0.2': + resolution: {integrity: sha512-5gWdrvoQX3keo03SCmgaBbD+kFftq0F/f2bzCbNnpkkvW6tk4rl4MakORzFuNjvXPWwB4az9GwuvVxQVnjaK2g==} + engines: {node: '>= 14.18'} + peerDependencies: + '@svgdotjs/svg.js': ^3.2.4 + '@swc/helpers@0.5.17': resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==} @@ -831,6 +865,9 @@ packages: '@vue/shared@3.5.13': resolution: {integrity: sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==} + '@yr/monotone-cubic-spline@1.0.3': + resolution: {integrity: sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==} + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -880,6 +917,9 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + apexcharts@4.7.0: + resolution: {integrity: sha512-iZSrrBGvVlL+nt2B1NpqfDuBZ9jX61X9I2+XV0hlYXHtTwhwLTHDKGXjNXAgFBDLuvSYCB/rq2nPWVPRv2DrGA==} + archiver-utils@5.0.2: resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==} engines: {node: '>= 14'} @@ -3075,6 +3115,12 @@ packages: peerDependencies: vue: ^3.2.0 + vue3-apexcharts@1.8.0: + resolution: {integrity: sha512-5tSD4mXTBbIJ9ir+58qHE6oNtIe0RNgqIRYMKpcsIaxkKtwUww4JhvPkpUFlmiW4OJbbdklgjleXq1lfcM4gdA==} + peerDependencies: + apexcharts: '>=4.0.0' + vue: '>=3.0.0' + vue@3.5.13: resolution: {integrity: sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==} peerDependencies: @@ -3676,6 +3722,25 @@ snapshots: - supports-color - typescript + '@svgdotjs/svg.draggable.js@3.0.6(@svgdotjs/svg.js@3.2.4)': + dependencies: + '@svgdotjs/svg.js': 3.2.4 + + '@svgdotjs/svg.filter.js@3.0.9': + dependencies: + '@svgdotjs/svg.js': 3.2.4 + + '@svgdotjs/svg.js@3.2.4': {} + + '@svgdotjs/svg.resize.js@2.0.5(@svgdotjs/svg.js@3.2.4)(@svgdotjs/svg.select.js@4.0.2(@svgdotjs/svg.js@3.2.4))': + dependencies: + '@svgdotjs/svg.js': 3.2.4 + '@svgdotjs/svg.select.js': 4.0.2(@svgdotjs/svg.js@3.2.4) + + '@svgdotjs/svg.select.js@4.0.2(@svgdotjs/svg.js@3.2.4)': + dependencies: + '@svgdotjs/svg.js': 3.2.4 + '@swc/helpers@0.5.17': dependencies: tslib: 2.8.1 @@ -3893,6 +3958,8 @@ snapshots: '@vue/shared@3.5.13': {} + '@yr/monotone-cubic-spline@1.0.3': {} + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -3936,6 +4003,15 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 + apexcharts@4.7.0: + dependencies: + '@svgdotjs/svg.draggable.js': 3.0.6(@svgdotjs/svg.js@3.2.4) + '@svgdotjs/svg.filter.js': 3.0.9 + '@svgdotjs/svg.js': 3.2.4 + '@svgdotjs/svg.resize.js': 2.0.5(@svgdotjs/svg.js@3.2.4)(@svgdotjs/svg.select.js@4.0.2(@svgdotjs/svg.js@3.2.4)) + '@svgdotjs/svg.select.js': 4.0.2(@svgdotjs/svg.js@3.2.4) + '@yr/monotone-cubic-spline': 1.0.3 + archiver-utils@5.0.2: dependencies: glob: 10.4.5 @@ -6181,6 +6257,11 @@ snapshots: '@vue/devtools-api': 6.6.4 vue: 3.5.13(typescript@5.8.3) + vue3-apexcharts@1.8.0(apexcharts@4.7.0)(vue@3.5.13(typescript@5.8.3)): + dependencies: + apexcharts: 4.7.0 + vue: 3.5.13(typescript@5.8.3) + vue@3.5.13(typescript@5.8.3): dependencies: '@vue/compiler-dom': 3.5.13 diff --git a/quasar.config.js b/quasar.config.js index 3003be1..ec3f55e 100644 --- a/quasar.config.js +++ b/quasar.config.js @@ -13,6 +13,7 @@ export default defineConfig((/* ctx */) => // --> boot files are part of "main.js" // https://v2.quasar.dev/quasar-cli-vite/boot-files boot: [ + 'apexcharts' ], // https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#css diff --git a/src-server/routes/mantis.js b/src-server/routes/mantis.js index 1d96572..479a0b3 100644 --- a/src-server/routes/mantis.js +++ b/src-server/routes/mantis.js @@ -3,6 +3,7 @@ import { PrismaClient } from '@prisma/client'; // Import Prisma Client import { getMantisSettings, saveTicketToDatabase } from '../services/mantisDownloader.js'; import axios from 'axios'; import reader from '@kenjiuno/msgreader'; +import SuperJSON from 'superjson'; const MsgReader = reader.default; const prisma = new PrismaClient(); // Instantiate Prisma Client @@ -281,4 +282,78 @@ router.get('/msg-extract/:ticketId/:attachmentId/:innerAttachmentId', async(req, } }); +// GET /mantis/stats/issues - Get daily count of new Mantis issues (last 7 days) +router.get('/stats/issues', async(req, res) => +{ + try + { + // Calculate the date range (last 7 days) + const endDate = new Date(); + endDate.setHours(23, 59, 59, 999); // End of today + + const startDate = new Date(); + startDate.setDate(startDate.getDate() - 6); // 7 days ago + startDate.setHours(0, 0, 0, 0); // Start of the day + + // Query for daily counts of issues created in the last 7 days + const dailyIssues = await prisma.$queryRaw` + SELECT + DATE(created_at) as date, + COUNT(*) as count + FROM + "MantisIssue" + WHERE + created_at >= ${startDate} AND created_at <= ${endDate} + GROUP BY + DATE(created_at) + ORDER BY + date ASC + `; + + res.status(200).json(dailyIssues); + } + catch (error) + { + console.error('Error fetching Mantis issue statistics:', error.message); + res.status(500).json({ error: 'Failed to fetch Mantis issue statistics' }); + } +}); + +// GET /mantis/stats/comments - Get daily count of new Mantis comments (last 7 days) +router.get('/stats/comments', async(req, res) => +{ + try + { + // Calculate the date range (last 7 days) + const endDate = new Date(); + endDate.setHours(23, 59, 59, 999); // End of today + + const startDate = new Date(); + startDate.setDate(startDate.getDate() - 6); // 7 days ago + startDate.setHours(0, 0, 0, 0); // Start of the day + + // Query for daily counts of comments created in the last 7 days + const dailyComments = await prisma.$queryRaw` + SELECT + DATE(created_at) as date, + COUNT(*) as count + FROM + "MantisComment" + WHERE + created_at >= ${startDate} AND created_at <= ${endDate} + GROUP BY + DATE(created_at) + ORDER BY + date ASC + `; + + res.status(200).json(dailyComments); + } + catch (error) + { + console.error('Error fetching Mantis comment statistics:', error.message); + res.status(500).json({ error: 'Failed to fetch Mantis comment statistics' }); + } +}); + export default router; \ No newline at end of file diff --git a/src-server/server.js b/src-server/server.js index a7518f8..cc803e9 100644 --- a/src-server/server.js +++ b/src-server/server.js @@ -22,7 +22,7 @@ import authRoutes from './routes/auth.js'; import chatRoutes from './routes/chat.js'; import settingsRoutes from './routes/settings.js'; import userPreferencesRoutes from './routes/userPreferences.js'; -import mantisRoutes from './routes/mantis.js'; // Import Mantis routes +import mantisRoutes from './routes/mantis.js'; import cron from 'node-cron'; import { generateAndStoreMantisSummary } from './services/mantisSummarizer.js'; import { requireAuth } from './middlewares/authMiddleware.js'; @@ -30,6 +30,7 @@ import { requireAuth } from './middlewares/authMiddleware.js'; import { setup as setupMantisDownloader } from './services/mantisDownloader.js'; import { logger } from './utils/logging.js'; +import SuperJSON from 'superjson'; dotenv.config(); @@ -100,12 +101,26 @@ app.disable('x-powered-by'); // Add JSON body parsing middleware app.use(express.json()); +app.use((req, res, next) => +{ + res.json = (data) => + { + if (res.headersSent) + { + return; + } + res.setHeader('Content-Type', 'application/json'); + res.send(SuperJSON.stringify(data)); + }; + next(); +}); + // Add API routes app.use('/api/auth', authRoutes); app.use('/api/chat', requireAuth, chatRoutes); app.use('/api/user-preferences', requireAuth, userPreferencesRoutes); app.use('/api/settings', requireAuth, settingsRoutes); -app.use('/api/mantis', requireAuth, mantisRoutes); // Register Mantis routes +app.use('/api/mantis', requireAuth, mantisRoutes); app.use('/api', requireAuth, apiRoutes); if (process.env.PROD) diff --git a/src/boot/apexcharts.js b/src/boot/apexcharts.js new file mode 100644 index 0000000..592fe40 --- /dev/null +++ b/src/boot/apexcharts.js @@ -0,0 +1,8 @@ +import { defineBoot } from '#q-app/wrappers'; + +import VueApexCharts from 'vue3-apexcharts'; + +export default defineBoot(async({app}) => +{ + app.use(VueApexCharts); +}); \ No newline at end of file diff --git a/src/boot/axios.js b/src/boot/axios.js index e0e6099..afbae11 100644 --- a/src/boot/axios.js +++ b/src/boot/axios.js @@ -1,14 +1,30 @@ -import { boot } from 'quasar/wrappers'; import axios from 'axios'; -// Be careful when using SSR for cross-request state pollution -// due to creating a Singleton instance here; -// If any client changes this (global) instance, it might be a -// good idea to move this instance creation inside of the -// "export default () => {}" function below (which runs individually -// for each client) +import SuperJSON from 'superjson'; axios.defaults.withCredentials = true; // Enable sending cookies with requests -// Export the API instance so you can import it easily elsewhere, e.g. stores +axios.interceptors.response.use( + (response) => + { + //If the response content type is application/json, we want to parse it with SuperJSON + if (response.headers['content-type'] && response.headers['content-type'].includes('application/json')) + { + try + { + response.data = SuperJSON.parse(JSON.stringify(response.data)); + } + catch (error) + { + console.error('Error deserializing response data:', error); + } + } + return response; + }, + (error) => + { + // Handle errors here if needed + return Promise.reject(error); + } +); export default axios; \ No newline at end of file diff --git a/src/css/app.scss b/src/css/app.scss index 09428ab..ed0bd1b 100644 --- a/src/css/app.scss +++ b/src/css/app.scss @@ -21,4 +21,8 @@ body { .bg-blurred { background: rgba(0, 0, 0, 0.5); backdrop-filter: blur(10px); +} + +.drop-shadow { + filter: drop-shadow(0 0 25px rgba(0, 0, 0, 0.5)); } \ No newline at end of file diff --git a/src/pages/DashboardPage.vue b/src/pages/DashboardPage.vue new file mode 100644 index 0000000..6db126e --- /dev/null +++ b/src/pages/DashboardPage.vue @@ -0,0 +1,366 @@ + + + + + \ No newline at end of file diff --git a/src/pages/LandingPage.vue b/src/pages/LandingPage.vue index 79ce8c1..e9e52fb 100644 --- a/src/pages/LandingPage.vue +++ b/src/pages/LandingPage.vue @@ -4,7 +4,7 @@

@@ -63,10 +63,6 @@ const features = ref([ text-shadow: 0 0 10px rgba(0, 0, 0, 0.75); } -.logo { - filter: drop-shadow(0 0 25px rgba(0, 0, 0, 0.5)); -} - .features { background-color: rgba(0, 0, 0, 0.25); border-radius: 10px; diff --git a/src/pages/LoginPage.vue b/src/pages/LoginPage.vue index 0fb20e3..3f09401 100644 --- a/src/pages/LoginPage.vue +++ b/src/pages/LoginPage.vue @@ -3,7 +3,7 @@