diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 0c2f197..7adbef8 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -12,23 +12,6 @@ jobs: uses: ./.github/workflows/ci-checks.yml secrets: inherit - code_review: - permissions: - pull-requests: write - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - - name: Code Review GPT - uses: mattzcarey/code-review-gpt@v0.1.4-alpha - with: - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - MODEL: 'gpt-3.5-turbo' - GITHUB_TOKEN: ${{ github.token }} - build: - needs: ci-checks uses: ./.github/workflows/build.yml secrets: inherit diff --git a/packages/analytics/.env.dev b/packages/analytics/.env.dev new file mode 100644 index 0000000..1a17b26 --- /dev/null +++ b/packages/analytics/.env.dev @@ -0,0 +1,2 @@ +ADMIN_USERNAME=alex +ADMIN_PASSWORD=Alex1234 \ No newline at end of file diff --git a/packages/analytics/Dockerfile b/packages/analytics/Dockerfile index 98f1a53..15d1b22 100644 --- a/packages/analytics/Dockerfile +++ b/packages/analytics/Dockerfile @@ -4,8 +4,7 @@ WORKDIR /app COPY ./package.json yarn.lock tsconfig.json ./ COPY packages/analytics/package.json ./packages/analytics/package.json COPY packages/models/package.json ./packages/models/package.json -RUN yarn set version berry -RUN yarn install +RUN yarn install --frozen-lockfile ENV HOST=0.0.0.0 ENV PORT=3001 EXPOSE 3001 diff --git a/packages/analytics/nodemon.json b/packages/analytics/nodemon.json index d98dcc2..8d55d8a 100644 --- a/packages/analytics/nodemon.json +++ b/packages/analytics/nodemon.json @@ -1,3 +1,6 @@ { - "ext": "js,handlebars" + "watch": ["src", "views"], + "ext": "ts,handlebars,js", + "ignore": ["src/**/*.spec.ts"], + "exec": "node --loader ts-node/esm src/index.ts" } diff --git a/packages/analytics/package.json b/packages/analytics/package.json index 4de7ae2..2121560 100644 --- a/packages/analytics/package.json +++ b/packages/analytics/package.json @@ -6,19 +6,27 @@ "type": "module", "license": "GPL", "scripts": { - "start": "node src/index.js", + "start": "node dist/index.js", "build": "tsc", - "dev": "nodemon src/index.js" + "watch": "tsc -w", + "dev": "nodemon" }, "dependencies": { "@linux-odyssey/models": "*", + "dotenv": "^16.4.5", "express": "^4.18.2", "express-basic-auth": "^1.2.1", "express-handlebars": "^7.1.2", "mongoose": "^8.0.3" }, "devDependencies": { - "nodemon": "^3.0.1", + "@swc/core": "^1.4.2", + "@swc/helpers": "^0.5.6", + "@types/express": "^4.17.21", + "@types/node": "^20.11.20", + "nodemon": "^3.1.0", + "regenerator-runtime": "^0.14.1", + "ts-node": "^10.9.2", "typescript": "^5.3.3" } } diff --git a/packages/analytics/public/css/style.css b/packages/analytics/public/css/style.css index d973b00..7d2e5ef 100644 --- a/packages/analytics/public/css/style.css +++ b/packages/analytics/public/css/style.css @@ -9,7 +9,7 @@ th { border-collapse: collapse; border-spacing: 0; width: 100%; - border: 2px solid black; + border: 2px solid #dcdcdd; } th, @@ -17,3 +17,117 @@ td { width: 1px; white-space: nowrap; } + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background-color: #000000; + color: #333; + margin: 0; + padding: 20px; + box-sizing: border-box; +} + +.title h1 { + color: #00ff00; + text-align: center; + font-size: 50px; + margin-top: 50px; + font-family: + ui-monospace, + SFMono-Regular, + Menlo, + Monaco, + Consolas, + Liberation Mono, + Courier New, + monospace; +} + +.title img { + width: 135px; +} +.title { + display: flex; + flex-direction: row; + justify-content: center; + gap: 30px; + margin-bottom: 30px; +} +.heading { + color: #ffffff; +} + +nav { + display: flex; + flex-direction: row; + justify-content: center; + gap: 30px; +} + +.nav a { + color: white; + text-decoration: none; + font-weight: 600; + font-size: 20px; +} + +.data-table { + width: 100%; + border-collapse: collapse; + margin-top: 20px; + color: #dcdcdd; + + background-color: #24262e; +} + +.data-table td { + padding: 10px; + text-align: left; + font-weight: 400; +} + +.data-table a { + color: #00dd00; + font-weight: 500; +} + +.data-table th { + color: #dcdcdd; + padding: 10px; + border: 1px solid #dcdcdd; + text-align: left; +} + +.container { + background-color: #24262e; + padding: 20px; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); +} + +.sort { + display: flex; + margin-bottom: 25px; + gap: 15px; +} + +.sort a { + background-color: #24262e; + height: 50px; + padding: 0 5 0 5; + border-radius: 10px; + text-decoration: none !important; +} + +.sort .btn h3 { + margin: 15 3 0 3; + font-weight: 600; + color: #dcdcdd; + font-size: 15px; +} + +.pagination { + width: 100%; + justify-content: center; + margin-top: 20px; +} diff --git a/packages/analytics/public/icon_totem.svg b/packages/analytics/public/icon_totem.svg new file mode 100644 index 0000000..f0521e5 --- /dev/null +++ b/packages/analytics/public/icon_totem.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/analytics/src/config.js b/packages/analytics/src/config.js deleted file mode 100644 index 466ea91..0000000 --- a/packages/analytics/src/config.js +++ /dev/null @@ -1,18 +0,0 @@ -function getOrFailed(key) { - if (!process.env[key]) { - throw new Error(`Missing required environment variable: ${key}`) - } - return process.env[key] -} - -const config = { - host: process.env.HOST || 'localhost', - port: process.env.PORT || 3001, - db: process.env.MONGO_URL || 'mongodb://localhost:27017/odyssey-test', - username: getOrFailed('ADMIN_USERNAME'), - password: getOrFailed('ADMIN_PASSWORD'), -} - -config.baseUrl = process.env.BASE_URL || `http://${config.host}:${config.port}` - -export default config diff --git a/packages/analytics/src/config.ts b/packages/analytics/src/config.ts new file mode 100644 index 0000000..850cfa8 --- /dev/null +++ b/packages/analytics/src/config.ts @@ -0,0 +1,34 @@ +import dotenv from 'dotenv' + +if (process.env.NODE_ENV !== 'production') { + dotenv.config({ path: '.env.dev' }) +} + +function getOrFailed(key: string): string { + if (!process.env[key]) { + throw new Error(`Missing required environment variable: ${key}`) + } + return process.env[key] as string +} + +interface Config { + host: string + port: number + db: string + username: string + password: string + baseUrl: string +} + +const config: Config = { + host: process.env.HOST || 'localhost', + port: process.env.PORT ? parseInt(process.env.PORT, 10) : 3001, + db: process.env.MONGO_URL || 'mongodb://localhost:27017/odyssey-test', + username: getOrFailed('ADMIN_USERNAME'), + password: getOrFailed('ADMIN_PASSWORD'), + baseUrl: + process.env.BASE_URL || + `http://${process.env.HOST || 'localhost'}:${process.env.PORT ? parseInt(process.env.PORT, 10) : 3001}`, +} + +export default config diff --git a/packages/analytics/src/constants.js b/packages/analytics/src/constants.ts similarity index 100% rename from packages/analytics/src/constants.js rename to packages/analytics/src/constants.ts diff --git a/packages/analytics/src/controllers/commands.js b/packages/analytics/src/controllers/commands.ts similarity index 70% rename from packages/analytics/src/controllers/commands.js rename to packages/analytics/src/controllers/commands.ts index b570504..aa2f6d3 100644 --- a/packages/analytics/src/controllers/commands.js +++ b/packages/analytics/src/controllers/commands.ts @@ -1,10 +1,12 @@ -/* eslint-disable object-shorthand */ +import type { Request, Response } from 'express' import Pagination from '../models/pagination.js' import { errorCommands } from '../models/commands.js' -// eslint-disable-next-line import/prefer-default-export -export async function commandListController(req, res) { - const { nextKey, order } = req.query +export async function commandListController( + req: Request, + res: Response +): Promise { + const { nextKey, order } = req.query as { nextKey?: string; order: string } const itemsPerPage = 100 try { diff --git a/packages/analytics/src/controllers/index.js b/packages/analytics/src/controllers/index.ts similarity index 69% rename from packages/analytics/src/controllers/index.js rename to packages/analytics/src/controllers/index.ts index 19edb1d..27678fa 100644 --- a/packages/analytics/src/controllers/index.js +++ b/packages/analytics/src/controllers/index.ts @@ -1,8 +1,8 @@ +import { Request, Response } from 'express' import { userCount } from '../models/users.js' import { questList } from '../models/quests.js' -// eslint-disable-next-line import/prefer-default-export -export async function homeController(req, res) { +export async function homeController(req: Request, res: Response) { const quests = await questList() const [totalQuests, totalCompleted] = quests.reduce( ([total, completed], quest) => [ @@ -17,8 +17,7 @@ export async function homeController(req, res) { userCount: await userCount(), totalQuests, totalCompleted, + completedRate: Math.round((totalCompleted / totalQuests) * 10000) / 100, } - payload.completedRate = - Math.round((payload.totalCompleted / payload.totalQuests) * 10000) / 100 res.render('home', payload) } diff --git a/packages/analytics/src/controllers/quests.js b/packages/analytics/src/controllers/quests.ts similarity index 63% rename from packages/analytics/src/controllers/quests.js rename to packages/analytics/src/controllers/quests.ts index 4e5b12a..b6cb836 100644 --- a/packages/analytics/src/controllers/quests.js +++ b/packages/analytics/src/controllers/quests.ts @@ -1,7 +1,7 @@ +import { Request, Response } from 'express' import { questList } from '../models/quests.js' -// eslint-disable-next-line import/prefer-default-export -export async function questListController(req, res) { +export async function questListController(req: Request, res: Response) { const quests = await questList() quests.forEach((quest) => { quest.failed = quest.started - quest.completed diff --git a/packages/analytics/src/controllers/sessions.js b/packages/analytics/src/controllers/sessions.ts similarity index 70% rename from packages/analytics/src/controllers/sessions.js rename to packages/analytics/src/controllers/sessions.ts index a4dbe89..7506301 100644 --- a/packages/analytics/src/controllers/sessions.js +++ b/packages/analytics/src/controllers/sessions.ts @@ -1,8 +1,9 @@ +import { Request, Response } from 'express' import Pagination from '../models/pagination.js' import { sessionList, sessionDetail } from '../models/sessions.js' -export async function sessionListController(req, res) { - const { nextKey, order } = req.query +export async function sessionListController(req: Request, res: Response) { + const { nextKey, order } = req.query as { nextKey: string; order: string } const itemsPerPage = 50 try { @@ -21,7 +22,7 @@ export async function sessionListController(req, res) { } } -export async function sessionDetailController(req, res) { +export async function sessionDetailController(req: Request, res: Response) { const { id } = req.params const session = await sessionDetail(id) res.render('sessionDetail', { session }) diff --git a/packages/analytics/src/controllers/users.js b/packages/analytics/src/controllers/users.js deleted file mode 100644 index 220bb3b..0000000 --- a/packages/analytics/src/controllers/users.js +++ /dev/null @@ -1,22 +0,0 @@ -import Pagination from '../models/pagination.js' -import { userList } from '../models/users.js' - -export async function userListController(req, res) { - const { nextKey, order } = req.query - const itemsPerPage = 50 - - try { - const pagination = new Pagination(order, itemsPerPage, nextKey) - const users = await userList(pagination) - const newNextKey = users[users.length - 1]?._id - - res.render('users', { - users, - nextKey: newNextKey, - order: pagination.getOrder(), - }) - } catch (error) { - console.error(error) - res.status(500).send('Error fetching user data') - } -} diff --git a/packages/analytics/src/controllers/users.ts b/packages/analytics/src/controllers/users.ts new file mode 100644 index 0000000..2d08a40 --- /dev/null +++ b/packages/analytics/src/controllers/users.ts @@ -0,0 +1,36 @@ +import { Request, Response } from 'express' +import mongoose from 'mongoose' +import Pagination from '../models/pagination.js' +import { userList, userDetail, idToUser } from '../models/users.js' + +export async function userListController(req: Request, res: Response) { + const { nextKey, order } = req.query as { nextKey: string; order: string } + const itemsPerPage = 50 + + try { + const pagination = new Pagination(order, itemsPerPage, nextKey) + const users = await userList(pagination) + const newNextKey = users[users.length - 1]?._id + + res.render('users', { + users, + nextKey: newNextKey, + order: pagination.getOrder(), + }) + } catch (error) { + console.error(error) + res.status(500).send('Error fetching user data') + } +} + +export async function userDetailController(req: Request, res: Response) { + const id = new mongoose.Types.ObjectId(req.params.id) + const sessions = await userDetail(id) + try { + const user = await idToUser(id) + res.render('usersDetail', { sessions, user }) + } catch (error) { + console.error(error) + res.status(500).send('Error fetching user data') + } +} diff --git a/packages/analytics/src/index.js b/packages/analytics/src/index.ts similarity index 100% rename from packages/analytics/src/index.js rename to packages/analytics/src/index.ts diff --git a/packages/analytics/src/interface.ts b/packages/analytics/src/interface.ts new file mode 100644 index 0000000..94ed03b --- /dev/null +++ b/packages/analytics/src/interface.ts @@ -0,0 +1,61 @@ +import { Types } from 'mongoose' + +export interface User { + username: string +} +export interface SessionDocument { + _id: Types.ObjectId + user: User + quest: string + status: string + createdAt: Date + finishedAt: Date +} +export interface CommandObject { + command: string + pwd: string + stage?: string | null + output: string | undefined + error: string | undefined + createdAt: string +} +export interface SessionObject { + _id: Types.ObjectId + user: string + quest: string + status: string + createdAt: string + finishedAt: string + lastActivityAt: string + usedTime: string + commandCount: number + commands: CommandObject[] +} +export interface SessionDetail { + _id: Types.ObjectId + user: string + quest: string + status: string + createdAt: string + finishedAt: string + usedTime: string + commands: CommandObject[] +} +export interface IUser extends Document { + hashedPassword?: string + socialLogins?: Record< + string, + { + id: string + provider: string + displayName?: string | null | undefined + } + > +} +export interface UserSessionDetail { + _id: Types.ObjectId + quest: string + status: string + createdAt: string + usedTime: string +} diff --git a/packages/analytics/src/models/commands.js b/packages/analytics/src/models/commands.ts similarity index 92% rename from packages/analytics/src/models/commands.js rename to packages/analytics/src/models/commands.ts index e771c4f..2e2d742 100644 --- a/packages/analytics/src/models/commands.js +++ b/packages/analytics/src/models/commands.ts @@ -1,7 +1,8 @@ import { Command } from '@linux-odyssey/models' +import Pagination from './pagination.js' // eslint-disable-next-line import/prefer-default-export -export async function errorCommands(pagination) { +export async function errorCommands(pagination: Pagination) { const commands = await Command.aggregate([ pagination.match('_id'), { diff --git a/packages/analytics/src/models/pagination.js b/packages/analytics/src/models/pagination.js deleted file mode 100644 index 255d9fd..0000000 --- a/packages/analytics/src/models/pagination.js +++ /dev/null @@ -1,74 +0,0 @@ -/* eslint-disable lines-between-class-members */ -/* eslint-disable class-methods-use-this */ -// eslint-disable-next-line max-classes-per-file -import mongoose from 'mongoose' - -export const DESC = 'desc' -export const ASC = 'asc' - -class Asc { - getOperator() { - return '$gt' - } - - getOrder() { - return 1 - } - - getName() { - return ASC - } -} - -class Desc { - getOperator() { - return '$lt' - } - - getOrder() { - return -1 - } - - getName() { - return DESC - } -} - -export default class Pagination { - nextKey = null - itemsPerPage = 50 - order = null - - constructor(order, itemsPerPage = 50, nextKey = null) { - this.itemsPerPage = itemsPerPage - if (!order || order === DESC) { - this.order = new Desc() - } else if (order === ASC) { - this.order = new Asc() - } else { - throw new Error('order must be asc or desc') - } - if (nextKey) { - this.nextKey = new mongoose.Types.ObjectId(nextKey) - } - } - - match(field) { - const stage = this.nextKey - ? { [field]: { [this.order.getOperator()]: this.nextKey } } - : {} - return { $match: stage } - } - - sort(field) { - return { $sort: { [field]: this.order.getOrder() } } - } - - limit() { - return { $limit: this.itemsPerPage } - } - - getOrder() { - return this.order.getName() - } -} diff --git a/packages/analytics/src/models/pagination.ts b/packages/analytics/src/models/pagination.ts new file mode 100644 index 0000000..799dd87 --- /dev/null +++ b/packages/analytics/src/models/pagination.ts @@ -0,0 +1,89 @@ +/* eslint-disable lines-between-class-members */ +/* eslint-disable class-methods-use-this */ +// eslint-disable-next-line max-classes-per-file +import mongoose, { Types } from 'mongoose' + +export const DESC: string = 'desc' +export const ASC: string = 'asc' + +interface Order { + getOperator(): string + getOrder(): number + getName(): string +} + +class Asc implements Order { + getOperator(): string { + return '$gt' + } + + getOrder(): number { + return 1 + } + + getName(): string { + return ASC + } +} + +class Desc implements Order { + getOperator(): string { + return '$lt' + } + + getOrder(): 1 | -1 { + return -1 + } + + getName(): string { + return DESC + } +} + +export default class Pagination { + nextKey: Types.ObjectId | null = null + itemsPerPage: number = 50 + order: Order + + constructor( + order: string, + itemsPerPage: number = 50, + nextKey: string | null = null + ) { + this.itemsPerPage = itemsPerPage + if (!order || order === DESC) { + this.order = new Desc() + } else if (order === ASC) { + this.order = new Asc() + } else { + throw new Error('order must be asc or desc') + } + if (nextKey) { + this.nextKey = new mongoose.Types.ObjectId(nextKey) + } + } + + match(field: string) { + const stage: { [key: string]: { [key: string]: Types.ObjectId } } = this + .nextKey + ? { + [field]: { + [this.order ? this.order.getOperator() : '$gt']: this.nextKey, + }, + } + : {} + return { $match: stage } + } + + sort(field: string): { $sort: Record } { + return { $sort: { [field]: this.order.getOrder() as 1 | -1 } } + } + + limit() { + return { $limit: this.itemsPerPage } + } + + getOrder() { + return this.order.getName() + } +} diff --git a/packages/analytics/src/models/quests.js b/packages/analytics/src/models/quests.ts similarity index 100% rename from packages/analytics/src/models/quests.js rename to packages/analytics/src/models/quests.ts diff --git a/packages/analytics/src/models/sessions.js b/packages/analytics/src/models/sessions.js deleted file mode 100644 index f7b4485..0000000 --- a/packages/analytics/src/models/sessions.js +++ /dev/null @@ -1,83 +0,0 @@ -import { Command, Session } from '@linux-odyssey/models' - -function formatTime(time) { - const seconds = Math.floor(time / 1000) - const minutes = Math.floor(seconds / 60) - const hours = Math.floor(minutes / 60) - return `${hours}h ${minutes % 60}m ${seconds % 60}s` -} - -export async function sessionList(pagination) { - const sessions = await Session.aggregate([ - pagination.match('_id'), - { - $lookup: { - from: 'users', - localField: 'user', - foreignField: '_id', - as: 'user', - }, - }, - { $unwind: '$user' }, - { - $lookup: { - from: 'commands', - localField: '_id', - foreignField: 'session', - as: 'commands', - }, - }, - pagination.sort('_id'), - pagination.limit(), - ]) - - return sessions.map( - ({ - _id, - user, - quest, - status, - createdAt, - finishedAt, - commands, - lastActivityAt, - }) => { - return { - _id, - user: user.username, - quest, - status, - createdAt: createdAt?.toLocaleString(), - finishedAt: finishedAt?.toLocaleString(), - lastActivityAt: lastActivityAt?.toLocaleString(), - usedTime: formatTime(lastActivityAt - createdAt), - commandCount: commands?.length, - } - } - ) -} - -export async function sessionDetail(id) { - const session = await Session.findById(id).populate('user').exec() - const commands = (await Command.find({ session: id })).map( - ({ command, pwd, output, error, createdAt, stage }) => ({ - command: command?.slice(0, 20), - pwd, - stage, - output: output?.slice(0, 50), - error: error?.slice(0, 50), - createdAt: createdAt?.toLocaleString(), - }) - ) - const { _id, user, quest, status, createdAt, finishedAt } = session - return { - _id, - user: user.username, - quest, - status, - createdAt: createdAt?.toLocaleString(), - finishedAt: finishedAt?.toLocaleString(), - usedTime: finishedAt ? formatTime(finishedAt - createdAt) : '', - commands, - } -} diff --git a/packages/analytics/src/models/sessions.ts b/packages/analytics/src/models/sessions.ts new file mode 100644 index 0000000..ac3794a --- /dev/null +++ b/packages/analytics/src/models/sessions.ts @@ -0,0 +1,99 @@ +import { Command, Session } from '@linux-odyssey/models' +import Pagination from './pagination.js' +import { + CommandObject, + SessionDetail, + SessionDocument, + SessionObject, +} from '../interface.js' + +function formatTime(time: number) { + const seconds = Math.floor(time / 1000) + const minutes = Math.floor(seconds / 60) + const hours = Math.floor(minutes / 60) + return `${hours}h ${minutes % 60}m ${seconds % 60}s` +} + +export async function sessionList( + pagination: Pagination +): Promise { + const sessions = await Session.aggregate([ + pagination.match('_id'), + { + $lookup: { + from: 'users', + localField: 'user', + foreignField: '_id', + as: 'user', + }, + }, + { $unwind: '$user' }, + { + $lookup: { + from: 'commands', + localField: '_id', + foreignField: 'session', + as: 'commands', + }, + }, + pagination.sort('_id'), + pagination.limit(), + ]) + + return sessions.map((session) => { + const { + _id, + user, + quest, + status, + createdAt, + finishedAt, + commands, + lastActivityAt, + } = session + return { + _id, + user: user.username, // Assuming `user` is always populated. + quest, + status, + createdAt: createdAt?.toLocaleString(), + finishedAt: finishedAt?.toLocaleString(), + lastActivityAt: lastActivityAt?.toLocaleString(), + usedTime: + lastActivityAt && createdAt + ? formatTime(lastActivityAt.getTime() - createdAt.getTime()) + : 'N/A', // Ensure both dates are present + commandCount: commands?.length || 0, + commands, + } + }) +} + +export async function sessionDetail(id: string): Promise { + const session = (await Session.findById(id) + .populate('user') + .exec()) as unknown as SessionDocument + const commands: CommandObject[] = (await Command.find({ session: id })).map( + ({ command, pwd, output, error, createdAt, stage }): CommandObject => ({ + command: command?.slice(0, 20), + pwd, + stage, + output: output?.slice(0, 50), + error: error?.slice(0, 50), + createdAt: createdAt?.toLocaleString(), + }) + ) + const { _id, user, quest, status, createdAt, finishedAt } = session + return { + _id, + user: user.username, + quest, + status, + createdAt: createdAt?.toLocaleString(), + finishedAt: finishedAt?.toLocaleString(), + usedTime: finishedAt + ? formatTime(finishedAt.getTime() - createdAt.getTime()) + : '', + commands, + } +} diff --git a/packages/analytics/src/models/users.js b/packages/analytics/src/models/users.js deleted file mode 100644 index 896fe5d..0000000 --- a/packages/analytics/src/models/users.js +++ /dev/null @@ -1,49 +0,0 @@ -import { Session, User } from '@linux-odyssey/models' - -function loginMethods(user) { - const methods = [] - if (user.hashedPassword) methods.push('password') - if (user.socialLogins) methods.push(...Object.keys(user.socialLogins)) - return methods -} - -export function userCount() { - return User.find().count() -} - -export async function userList(pagination) { - const users = await Session.aggregate([ - pagination.match('user'), - { - $group: { - _id: '$user', - count: { $sum: 1 }, - lastActivityAt: { $max: '$lastActivityAt' }, - }, - }, - { - $lookup: { - from: 'users', - localField: '_id', - foreignField: '_id', - as: 'userData', - }, - }, - { $unwind: '$userData' }, - pagination.sort('userData._id'), - pagination.limit(), - ]) - - return users.map(({ userData, count, lastActivityAt }) => { - const { _id, username, email, createdAt } = userData - return { - _id, - username, - email, - createdAt: createdAt?.toLocaleString(), - method: loginMethods(userData), - sessionCount: count, - lastActivityAt: lastActivityAt?.toLocaleString(), - } - }) -} diff --git a/packages/analytics/src/models/users.ts b/packages/analytics/src/models/users.ts new file mode 100644 index 0000000..608a47b --- /dev/null +++ b/packages/analytics/src/models/users.ts @@ -0,0 +1,88 @@ +import { Session, User } from '@linux-odyssey/models' +import { Types } from 'mongoose' +import Pagination from './pagination.js' +import { IUser, UserSessionDetail } from '../interface.js' + +function loginMethods(user: IUser): string[] { + const methods: string[] = [] + if (user.hashedPassword) methods.push('password') + if (user.socialLogins && typeof user.socialLogins === 'object') { + methods.push(...Object.keys(user.socialLogins)) + } + return methods +} + +function formatTime(time: number) { + const seconds = Math.floor(time / 1000) + const minutes = Math.floor(seconds / 60) + const hours = Math.floor(minutes / 60) + return `${hours}h ${minutes % 60}m ${seconds % 60}s` +} +export function userCount() { + return User.countDocuments({}) +} + +export async function userList(pagination: Pagination) { + const users = await Session.aggregate([ + pagination.match('user'), + { + $group: { + _id: '$user', + count: { $sum: 1 }, + lastActivityAt: { $max: '$lastActivityAt' }, + }, + }, + { + $lookup: { + from: 'users', + localField: '_id', + foreignField: '_id', + as: 'userData', + }, + }, + { $unwind: '$userData' }, + pagination.sort('userData._id'), + pagination.limit(), + ]) + + return users.map(({ userData, count, lastActivityAt }) => { + const { _id, username, email, createdAt } = userData + return { + _id, + username, + email, + createdAt: createdAt?.toLocaleString(), + method: loginMethods(userData), + sessionCount: count, + lastActivityAt: lastActivityAt?.toLocaleString(), + } + }) +} + +export async function userDetail( + id: Types.ObjectId +): Promise { + const sessions = await Session.find({ + user: id, + }) + return sessions.map((session): UserSessionDetail => { + const { _id, quest, status, createdAt, lastActivityAt } = session + return { + _id, + quest, + status, + createdAt: createdAt?.toLocaleString(), + usedTime: + lastActivityAt && createdAt + ? formatTime(lastActivityAt.getTime() - createdAt.getTime()) + : 'N/A', // Ensure both dates are present + } + }) +} + +export async function idToUser(id: Types.ObjectId): Promise { + const user = await User.findOne({ + _id: id, + }) + return user?.username || 'User not found' +} diff --git a/packages/analytics/src/router.js b/packages/analytics/src/router.ts similarity index 82% rename from packages/analytics/src/router.js rename to packages/analytics/src/router.ts index 9ef6ba6..9f50043 100644 --- a/packages/analytics/src/router.js +++ b/packages/analytics/src/router.ts @@ -1,7 +1,10 @@ import { Router } from 'express' import { homeController } from './controllers/index.js' -import { userListController } from './controllers/users.js' +import { + userListController, + userDetailController, +} from './controllers/users.js' import { questListController } from './controllers/quests.js' import { commandListController } from './controllers/commands.js' import { @@ -13,6 +16,7 @@ const router = Router() router.get('/', homeController) router.get('/users', userListController) +router.get('/users/:id', userDetailController) router.get('/quests', questListController) router.get('/commands', commandListController) router.get('/sessions', sessionListController) diff --git a/packages/analytics/tsconfig.json b/packages/analytics/tsconfig.json index 4e7db43..a8aa476 100644 --- a/packages/analytics/tsconfig.json +++ b/packages/analytics/tsconfig.json @@ -1,10 +1,11 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "allowJs": true, "rootDir": "src", "outDir": "./dist" }, "include": ["src"], - "exclude": ["node_modules", "dist"] + "ts-node": { + "swc": true + } } diff --git a/packages/analytics/views/commands.handlebars b/packages/analytics/views/commands.handlebars index f2252cd..6ee7acb 100644 --- a/packages/analytics/views/commands.handlebars +++ b/packages/analytics/views/commands.handlebars @@ -1,11 +1,15 @@ -

Commands

+

Commands

-

Error commands

- {{/with}} \ No newline at end of file diff --git a/packages/analytics/views/sessions.handlebars b/packages/analytics/views/sessions.handlebars index 613435f..765ce13 100644 --- a/packages/analytics/views/sessions.handlebars +++ b/packages/analytics/views/sessions.handlebars @@ -1,10 +1,14 @@ -

Sessions

-