refactor: move analytics into services directory

This commit is contained in:
Justin Lin committed 2025-05-05 19:55:40 +08:00
1 parent e43cbe26ce
commit 740cbcecb4
31 files changed
+6 -5

No files matched your search

+36
View File
@@ -0,0 +1,36 @@
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.ADMIN_HOST || 'localhost',
port: process.env.ADMIN_PORT ? parseInt(process.env.ADMIN_PORT, 10) : 3001,
db: process.env.MONGO_URL || 'mongodb://localhost:27017/odyssey-test',
username: getOrFailed('ADMIN_USERNAME'),
password: getOrFailed('ADMIN_PASSWORD'),
baseUrl:
process.env.ADMIN_BASE_URL ||
`http://${process.env.ADMIN_HOST || 'localhost'}:${
process.env.ADMIN_PORT ? parseInt(process.env.ADMIN_PORT, 10) : 3001
}`,
}
export default config
@@ -0,0 +1,26 @@
import type { Request, Response } from 'express'
import Pagination from '../models/pagination'
import { errorCommands } from '../models/commands'
export async function commandListController(
req: Request,
res: Response
): Promise<void> {
const { nextKey, order } = req.query as { nextKey?: string; order: string }
const itemsPerPage = 100
try {
const pagination = new Pagination(order, itemsPerPage, nextKey)
const payload = await errorCommands(pagination)
const newNextKey = payload[payload.length - 1]?._id
res.render('commands', {
errorCommands: payload,
nextKey: newNextKey,
order: pagination.getOrder(),
})
} catch (error) {
console.error(error)
res.status(500).send('Error fetching data')
}
}
@@ -0,0 +1,23 @@
import { Request, Response } from 'express'
import { userCount } from '../models/users'
import { questList } from '../models/quests'
export async function homeController(req: Request, res: Response) {
const quests = await questList()
const [totalQuests, totalCompleted] = quests.reduce(
([total, completed], quest) => [
total + quest.started,
completed + quest.completed,
],
[0, 0]
)
const payload = {
title: 'Linux Odyssey Admin',
userCount: await userCount(),
totalQuests,
totalCompleted,
completedRate: Math.round((totalCompleted / totalQuests) * 10000) / 100,
}
res.render('home', payload)
}
@@ -0,0 +1,10 @@
import { Request, Response } from 'express'
import { questList } from '../models/quests'
export async function questListController(req: Request, res: Response) {
const quests = await questList()
quests.forEach((quest) => {
quest.failed = quest.started - quest.completed
})
res.render('quests', { quests })
}
@@ -0,0 +1,29 @@
import { Request, Response } from 'express'
import Pagination from '../models/pagination'
import { sessionList, sessionDetail } from '../models/sessions'
export async function sessionListController(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 sessions = await sessionList(pagination)
const newNextKey = sessions[sessions.length - 1]?._id
res.render('sessions', {
sessions,
nextKey: newNextKey,
order: pagination.getOrder(),
})
} catch (error) {
console.log(error)
res.status(500).send('Error fetching session data')
}
}
export async function sessionDetailController(req: Request, res: Response) {
const { id } = req.params
const session = await sessionDetail(id)
res.render('sessionDetail', { session })
}
@@ -0,0 +1,36 @@
import { Request, Response } from 'express'
import mongoose from 'mongoose'
import Pagination from '../models/pagination'
import { userList, userDetail, idToUser } from '../models/users'
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')
}
}
+32
View File
@@ -0,0 +1,32 @@
import express from 'express'
import { engine } from 'express-handlebars'
import basicAuth from 'express-basic-auth'
import { connectDB } from '../../../packages/models'
import config from './config'
import router from './router'
async function main() {
await connectDB(config.db)
const app = express()
// Setting up Handlebars as templating engine
app.engine('handlebars', engine())
app.set('view engine', 'handlebars')
app.set('views', './views')
app.use(
basicAuth({
users: { [config.username]: config.password },
challenge: true,
realm: 'Linux Odyssey Admin Dashboard',
})
)
app.use(router)
app.use(express.static('./public'))
app.listen(config.port, config.host, () => {
console.log(`Server started on ${config.baseUrl}`)
})
}
main().catch(console.error)
+61
View File
@@ -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
}
+51
View File
@@ -0,0 +1,51 @@
import { Command } from '../../../../packages/models'
import Pagination from './pagination'
// eslint-disable-next-line import/prefer-default-export
export async function errorCommands(pagination: Pagination) {
const commands = await Command.aggregate([
pagination.match('_id'),
{
$match: {
$and: [
{ error: { $exists: true } },
{ error: { $ne: null } },
{ error: { $ne: '' } },
],
},
},
{
$lookup: {
from: 'sessions', // 替換成 'session' 對應的集合名稱
localField: 'session', // 替換成 'session' 對應的字段名稱
foreignField: '_id',
as: 'session',
},
},
{ $unwind: '$session' },
{
$lookup: {
from: 'users', // 替換成 'user' 對應的集合名稱
localField: 'session.user', // 替換成 'user' 對應的字段名稱
foreignField: '_id',
as: 'session.user',
},
},
{ $unwind: '$session.user' },
pagination.sort('_id'),
pagination.limit(),
])
return commands.map(({ _id, command, error, createdAt, session }) => {
const { user, quest } = session
return {
_id,
command: command.slice(0, 20),
error: error.slice(0, 50),
createdAt: createdAt?.toLocaleString(),
user: user?.username,
quest,
session: session._id,
}
})
}
@@ -0,0 +1,88 @@
/* eslint-disable class-methods-use-this */
/* eslint-disable 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<string, 1 | -1> } {
return { $sort: { [field]: this.order.getOrder() as 1 | -1 } }
}
limit() {
return { $limit: this.itemsPerPage }
}
getOrder() {
return this.order.getName()
}
}
+65
View File
@@ -0,0 +1,65 @@
import { UserProfile } from '../../../../packages/models'
export interface QuestProgress {
_id: string
started: number
completed: number
completedRate: number
failed: number
}
export function questList(): Promise<QuestProgress[]> {
return UserProfile.aggregate([
{
$project: {
progress: {
$objectToArray: '$progress',
},
},
},
{
$unwind: '$progress',
},
{
$group: {
_id: '$progress.v.quest',
started: {
$sum: 1,
},
completed: {
$sum: {
$cond: {
if: { $eq: ['$progress.v.completed', true] },
then: 1,
else: 0,
},
},
},
},
},
{
$project: {
_id: 1,
started: 1,
completed: 1,
completedRate: {
$cond: {
if: { $eq: ['$started', 0] },
then: 0,
else: {
$round: [
{ $multiply: [{ $divide: ['$completed', '$started'] }, 100] },
0,
],
},
},
},
},
},
{
$sort: {
completed: -1,
},
},
])
}
+99
View File
@@ -0,0 +1,99 @@
import { Command, Session } from '../../../../packages/models'
import Pagination from './pagination'
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<SessionObject[]> {
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<SessionDetail> {
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,
}
}
+92
View File
@@ -0,0 +1,92 @@
import { HydratedDocument, Types } from 'mongoose'
import { Session, User, IUser, ISession } from '../../../../packages/models'
import Pagination from './pagination'
import { UserSessionDetail } from '../interface'
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: {
userData: HydratedDocument<IUser>
count: number
lastActivityAt: Date
}[] = 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<UserSessionDetail[]> {
const sessions: HydratedDocument<ISession>[] = 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<String> {
const user = await User.findOne({
_id: id,
})
return user?.username || 'User not found'
}
+22
View File
@@ -0,0 +1,22 @@
import { Router } from 'express'
import { homeController } from './controllers/index'
import { userListController, userDetailController } from './controllers/users'
import { questListController } from './controllers/quests'
import { commandListController } from './controllers/commands'
import {
sessionDetailController,
sessionListController,
} from './controllers/sessions'
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)
router.get('/sessions/:id', sessionDetailController)
export default router