mirror of
https://github.com/linux-odyssey/linux-odyssey.git
synced 2026-11-07 19:10:12 +08:00
refactor: move analytics into services directory
This commit is contained in:
1 parent
e43cbe26ce
commit
740cbcecb4
31 files changed
+6
-5
No files matched your search
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user