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

+2
View File
@@ -0,0 +1,2 @@
ADMIN_USERNAME=alex
ADMIN_PASSWORD=Alex1234
+26
View File
@@ -0,0 +1,26 @@
FROM node:20-alpine as build
WORKDIR /app
COPY ./package.json yarn.lock ./
COPY packages/analytics/package.json ./packages/analytics/package.json
RUN yarn install --frozen-lockfile
COPY tsconfig.json ./tsconfig.json
COPY packages ./packages
RUN yarn workspace analytics build
# Production stage
FROM node:20-alpine as prod
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/packages/analytics ./packages/analytics
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3001
EXPOSE 3001
WORKDIR /app/packages/analytics
CMD [ "yarn", "start" ]
+59
View File
@@ -0,0 +1,59 @@
# Analytics
## Information provided
### 1. **Dashboard Overview**:
- **Total Users**: Display the total number of registered users.
- **Active Users**: Count of users who've been active in the past day, week, or month.
- **Completed Quests**: Total quests or challenges completed by all users.
- **Average Time Usage**: Average time a user spends on the platform or on challenges.
### 2. **User Analytics**:
- **User Growth**: A graph showing user registrations over time (daily, weekly, monthly).
- **Retention Rate**: Percentage of users who return after their first visit.
- **User Segmentation**: Group users based on criteria like their progress level, frequency of usage, or other custom metrics.
### 3. **Quest/Challenge Analytics**:
- **Quest Popularity**: Rank quests by the number of completions or engagements.
- **Average Completion Time**: The average time taken by users to complete each quest.
- **Drop-Off Points**: Identify quests where users abandon or fail frequently.
### 4. **Error Analysis**:
- **Common Errors**: List frequently mistyped or erroneous commands.
- **Quest-specific Errors**: Identify challenges where users make the most mistakes.
### 5. **Feedback & Survey Results**:
- **Feedback Overview**: Display recent feedback or suggestions.
- **Survey Analysis**: If you've integrated a survey, show aggregated results or key insights.
### 6. **System Health & Usage**:
- **Server Load**: Monitor server performance metrics like CPU usage, memory usage, etc.
- **Error Logs**: View any system errors, glitches, or issues.
### 7. **User Details**:
- **Search & View**: Ability to search for specific users and view their profile, progress, and activity logs.
- **User Behavior**: For selected users, see a detailed path of their activity, including the quests they've engaged with, time spent, errors made, etc.
### 8. **Notifications & Alerts**:
- **System Alerts**: Notifications about server downtimes, high error rates, or other critical issues.
- **User Activity Alerts**: Set alerts for specific user activities, e.g., when a user completes all challenges.
### 9. **Engagement Metrics**:
- **Daily Active Users**: Users who engage with the platform daily.
- **Top Users**: Identify power users or those with high engagement rates.
### 10. **Export & Reports**:
- **Data Export**: Option to export user data, analytics, or other metrics.
- **Generate Reports**: Create detailed reports based on selected metrics, useful for team meetings or strategy planning.
When designing this admin dashboard, focus on clarity and ease of use. Utilize visual aids like graphs, charts, and heatmaps to make data interpretation straightforward. Since your backend is built with NodeJS and ExpressJS and you use MongoDB for data storage, you can utilize various NPM packages to aid in data visualization and dashboard creation. Ensure the dashboard is secure and accessible only to authorized personnel.
+29
View File
@@ -0,0 +1,29 @@
{
"name": "analytics",
"version": "1.0.0",
"main": "src/index.js",
"private": true,
"license": "GPL",
"scripts": {
"start": "node dist/analytics/src/index.js",
"build": "tsc",
"type-check": "tsc --noEmit",
"dev": "tsx watch src/index.ts"
},
"dependencies": {
"dotenv": "^16.4.5",
"express": "^4.20.0",
"express-basic-auth": "^1.2.1",
"express-handlebars": "^7.1.2",
"mongoose": "^8.9.5"
},
"devDependencies": {
"@swc/core": "^1.4.2",
"@swc/helpers": "^0.5.6",
"@types/express": "^4.17.21",
"@types/node": "^20.11.20",
"regenerator-runtime": "^0.14.1",
"tsx": "^4.19.3",
"typescript": "^5.3.3"
}
}
+133
View File
@@ -0,0 +1,133 @@
body {
font-family: 'sans';
}
table,
tr,
td,
th {
border-collapse: collapse;
border-spacing: 0;
width: 100%;
border: 2px solid #dcdcdd;
}
th,
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;
}
+9
View File
@@ -0,0 +1,9 @@
<svg width="638" height="732" viewBox="0 0 638 732" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path d="M0 0H638V732H0V0Z" fill="url(#pattern0)"/>
<defs>
<pattern id="pattern0" patternContentUnits="objectBoundingBox" width="1" height="1">
<use xlink:href="#image0_379_14" transform="matrix(0.00126582 0 0 0.00110327 0 -0.000333748)"/>
</pattern>
<image id="image0_379_14" width="790" height="907" xlink:href="data:image/png;base64,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 truncated
</defs>
</svg>

After

Width:  |  Height:  |  Size: 630 KiB

+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
@@ -0,0 +1,48 @@
import mongoose from 'mongoose'
import Pagination, { ASC, DESC } from '../src/models/pagination.js'
describe('Pagination', () => {
it('should failed when not provide order', () => {
expect(new Pagination('').getOrder()).toBe(DESC)
expect(() => new Pagination('Hahaha')).toThrow()
})
it('should return correct limit', () => {
const pagination = new Pagination(DESC, 10)
expect(pagination.limit()).toMatchObject({ $limit: 10 })
})
it('desc should return correct order', async () => {
const pagination = new Pagination(DESC, 10)
expect(pagination.sort('_id')).toMatchObject({ $sort: { _id: -1 } })
expect(pagination.sort('time')).toMatchObject({ $sort: { time: -1 } })
})
it('asc should return correct order', async () => {
const pagination = new Pagination(ASC, 10)
expect(pagination.sort('_id')).toMatchObject({ $sort: { _id: 1 } })
expect(pagination.sort('time')).toMatchObject({ $sort: { time: 1 } })
})
it('should return correct match stage for empty nextKey', async () => {
const pagination = new Pagination(DESC, 10)
expect(pagination.match('_id')).toMatchObject({ $match: {} })
})
it('should return correct match stage for empty nextKey', async () => {
const nextKeyStr = '656c7fea28a4c087bfcefe12'
const pagination = new Pagination(DESC, 10, nextKeyStr)
const nextKey = new mongoose.Types.ObjectId(nextKeyStr)
expect(pagination.match('_id')).toMatchObject({
$match: {
_id: {
$lt: nextKey,
},
},
})
})
})
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist"
},
"include": ["src", "../models"],
"ts-node": {
"swc": true
}
}
@@ -0,0 +1,37 @@
<h1 class='heading'>Commands</h1>
<div>
<h2 class='heading'>Error commands</h2>
<div class='sort'>
<a href='/commands?order=asc'><div class='btn'>
<h3>Ascending</h3>
</div></a>
<a href='/commands?order=desc'><div class='btn'>
<h3>Descending</h3>
</div></a>
</div>
<table class='data-table'>
<tr>
<th>Command</th>
<th>User</th>
<th>Quest</th>
<th>Session</th>
<th>Error</th>
<th>Created At</th>
</tr>
{{#each errorCommands}}
<tr>
<td>{{command}}</td>
<td>{{user}}</td>
<td>{{quest}}</td>
<td><a href='/sessions/{{session}}'>Detail</a></td>
<td>{{error}}</td>
<td>{{createdAt}}</td>
</tr>
{{/each}}
</table>
<div class='pagination sort'>
<a href='/commands?nextKey={{nextKey}}&order={{order}}'><div class='btn'>
<h3>Next</h3>
</div></a>
</div>
</div>
+27
View File
@@ -0,0 +1,27 @@
<body>
<div class='title'>
<img src='icon_totem.svg' class='styled-image' />
<h1>Welcome to {{title}}</h1>
</div>
<div class='container'>
<h2 class='heading'>Overview</h2>
<table class='data-table'>
<tr>
<th>Registered Users</th>
<td>{{userCount}}</td>
</tr>
<tr>
<th>Total Started Quests</th>
<td>{{totalQuests}}</td>
</tr>
<tr>
<th>Completed Quests</th>
<td>{{totalCompleted}}</td>
</tr>
<tr>
<th>Completed Rate</th>
<td>{{completedRate}}%</td>
</tr>
</table>
</div>
</body>
@@ -0,0 +1,16 @@
<html>
<head>
<title>{{title}}</title>
<link rel='stylesheet' href='/css/style.css' />
</head>
<body>
<nav class='nav'>
<div><a href='/'>Home</a></div>
<div><a href='/users'>Users</a></div>
<div><a href='/quests'>Quests</a></div>
<div><a href='/commands'>Commands</a></div>
<div><a href='/sessions'>Sessions</a></div>
</nav>
{{{body}}}
</body>
</html>
@@ -0,0 +1,20 @@
<h1 class='heading'>Quests</h1>
<div>
<table class='data-table'>
<tr>
<th>Id</th>
<th>Started</th>
<th>Completed</th>
<th>Failed</th>
<th>Completed Rate</th>
</tr>
{{#each quests}}
<tr>
<td>{{_id}}</td>
<td>{{started}}</td>
<td>{{completed}}</td>
<td>{{failed}}</td>
<td>{{completedRate}} %</td>
</tr>
{{/each}}
</table>
@@ -0,0 +1,58 @@
{{#with session}}<h1 class='heading'>Session {{_id}}</h1>
<div>
<table class='data-table'>
<tr>
<th>Quest</th>
<td>{{quest}}</td>
</tr>
<tr>
<th>User</th>
<td>{{user}}</td>
</tr>
<tr>
<th>Status</th>
<td>{{status}}</td>
</tr>
<tr>
<th>Created At</th>
<td>{{createdAt}}</td>
</tr>
<tr>
<th>Finished At</th>
<td>{{finishedAt}}</td>
</tr>
<tr>
<th>Used Time</th>
<td>{{usedTime}}</td>
</tr>
<tr>
<th>Command Count</th>
<td>{{commands.length}}</td>
</tr>
</table>
<h2 class='heading'>Commands</h2>
<table class='data-table'>
<tr>
<th>Stage</th>
<th>PWD</th>
<th>Input</th>
<th>Output</th>
<th>Error</th>
<th>Input Time</th>
</tr>
{{#each commands}}
<tr>
<td>{{stage}}</td>
<td>{{pwd}}</td>
<td>{{command}}</td>
<td>{{output}}</td>
<td>{{error}}</td>
<td>{{createdAt}}</td>
</tr>
{{/each}}
</table>
</div>{{/with}}
@@ -0,0 +1,40 @@
<h1 class='heading'>Sessions</h1>
<div class='sort'>
<a href='/sessions?order=asc'><div class='btn'>
<h3>Ascending</h3>
</div></a>
<a href='/sessions?order=desc'><div class='btn'>
<h3>Descending</h3>
</div></a>
</div>
<div>
<table class='data-table'>
<tr>
<th>User</th>
<th>Quest</th>
<th>Status</th>
<th>Created At</th>
<th>Finished At</th>
<th>Used Time</th>
<th>Command Count</th>
<th>Session</th>
</tr>
{{#each sessions}}
<tr>
<td>{{user}}</td>
<td>{{quest}}</td>
<td>{{status}}</td>
<td>{{createdAt}}</td>
<td>{{finishedAt}}</td>
<td>{{usedTime}}</td>
<td>{{commandCount}}</td>
<td><a href='/sessions/{{_id}}'>Detail</a></td>
</tr>
{{/each}}
</table>
</div>
<div class='pagination sort'>
<a href='/sessions?nextKey={{nextKey}}&order={{order}}'><div class='btn'>
<h3>Next</h3>
</div></a>
</div>
+36
View File
@@ -0,0 +1,36 @@
<h1 class='heading'>Users List</h1>
<div class='sort'>
<a href='/users?order=asc'><div class='btn'>
<h3>Ascending</h3>
</div></a>
<a href='/users?order=desc'><div class='btn'>
<h3>Descending</h3>
</div></a>
</div>
<div>
<table class='data-table'>
<tr>
<th>Username</th>
<th>Last Activity</th>
<th>Sessions Count</th>
<th>Email</th>
<th>Registration Time</th>
<th>Registration Method</th>
</tr>
{{#each users}}
<tr>
<td><a href='/users/{{_id}}'>{{username}}</a></td>
<td>{{lastActivityAt}}</td>
<td>{{sessionCount}}</td>
<td>{{email}}</td>
<td>{{createdAt}}</td>
<td>{{method}}</td>
</tr>
{{/each}}
</table>
</div>
<div class='pagination sort'>
<a href='/users?nextKey={{nextKey}}&order={{order}}'><div class='btn'>
<h3>Next</h3>
</div></a>
</div>
@@ -0,0 +1,21 @@
<h1 class='heading'>{{user}}'s Sessions History</h1>
<div>
<table class='data-table'>
<tr>
<th>Quest</th>
<th>Status</th>
<th>Created At</th>
<th>Used Time</th>
<th>Session</th>
</tr>
{{#each sessions}}
<tr>
<td>{{quest}}</td>
<td>{{status}}</td>
<td>{{createdAt}}</td>
<td>{{usedTime}}</td>
<td><a href='/sessions/{{_id}}'>Detail</a></td>
</tr>
{{/each}}
</table>
</div>