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
-