convert analytics to TS

This commit is contained in:
chreamy authored and wancat committed 2024-02-28 23:38:33 +08:00
1 parent fb69cb4e10
commit 923de901c4
24 files changed
+11093 -211

No files matched your search

+1 -1
View File
@@ -78,7 +78,7 @@ services:
networks:
- internal
ports:
- '27017:27017'
- '27018:27017'
volumes:
- ./data:/data/db
+10802
View File
File diff suppressed because it is too large. Load diff
+4 -1
View File
@@ -1,3 +1,6 @@
{
"ext": "js,handlebars"
"watch": ["src"],
"ext": "ts,handlebars,js",
"ignore": ["src/**/*.spec.ts"],
"exec": "node --loader ts-node/esm src/index.ts"
}
+7 -3
View File
@@ -6,9 +6,10 @@
"type": "module",
"license": "GPL",
"scripts": {
"start": "node src/index.js",
"start": "node --loader ts-node/esm src/index.ts",
"build": "tsc",
"dev": "nodemon src/index.js"
"watch": "tsc -w",
"dev": "nodemon --loader ts-node/esm src/index.ts"
},
"dependencies": {
"@linux-odyssey/models": "*",
@@ -18,6 +19,9 @@
"mongoose": "^8.0.3"
},
"devDependencies": {
"nodemon": "^3.0.1"
"@types/node": "^20.11.20",
"nodemon": "^3.1.0",
"ts-node": "^10.9.2",
"typescript": "^5.3.3"
}
}
-18
View File
@@ -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
+30
View File
@@ -0,0 +1,30 @@
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) : 3001,
db: process.env.MONGO_URL || 'mongodb://localhost:27017/odyssey-test',
//username: getOrFailed('ADMIN_USERNAME'),
username: 'wancat',
//password: getOrFailed('ADMIN_PASSWORD'),
password: 'InMatrix',
baseUrl:
process.env.BASE_URL ||
`http://${process.env.HOST || 'localhost'}:${process.env.PORT ? parseInt(process.env.PORT) : 3001}`,
}
export default config
File renamed without changes.
@@ -1,10 +1,10 @@
/* eslint-disable object-shorthand */
import Pagination from '../models/pagination.js'
import Pagination from '../models/pagination.ts'
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: any, res: any): Promise<void> {
const { nextKey, order } = req.query as { nextKey?: string; order: string }
const itemsPerPage = 100
try {
@@ -1,8 +1,8 @@
import { userCount } from '../models/users.js'
import { questList } from '../models/quests.js'
import { questList } from '../models/quests.ts'
// eslint-disable-next-line import/prefer-default-export
export async function homeController(req, res) {
export async function homeController(req: any, res: any) {
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)
}
@@ -1,7 +1,6 @@
import { questList } from '../models/quests.js'
import { questList } from '../models/quests.ts'
// eslint-disable-next-line import/prefer-default-export
export async function questListController(req, res) {
export async function questListController(req: any, res: any) {
const quests = await questList()
quests.forEach((quest) => {
quest.failed = quest.started - quest.completed
@@ -1,7 +1,7 @@
import Pagination from '../models/pagination.js'
import { sessionList, sessionDetail } from '../models/sessions.js'
export async function sessionListController(req, res) {
import Pagination from '../models/pagination.ts'
import { sessionList, sessionDetail } from '../models/sessions.ts'
import { Request, Response } from 'express'
export async function sessionListController(req: any, res: any) {
const { nextKey, order } = req.query
const itemsPerPage = 50
@@ -21,7 +21,7 @@ export async function sessionListController(req, res) {
}
}
export async function sessionDetailController(req, res) {
export async function sessionDetailController(req: any, res: any) {
const { id } = req.params
const session = await sessionDetail(id)
res.render('sessionDetail', { session })
@@ -1,7 +1,7 @@
import Pagination from '../models/pagination.js'
import Pagination from '../models/pagination.ts'
import { userList } from '../models/users.js'
export async function userListController(req, res) {
export async function userListController(req: any, res: any) {
const { nextKey, order } = req.query
const itemsPerPage = 50
@@ -2,8 +2,8 @@ import connectDB from '@linux-odyssey/models'
import express from 'express'
import { engine } from 'express-handlebars'
import basicAuth from 'express-basic-auth'
import config from './config.js'
import router from './router.js'
import config from './config.ts'
import router from './router.ts'
async function main() {
await connectDB(config.db)
@@ -1,7 +1,8 @@
import { Command } from '@linux-odyssey/models'
import Pagination from './pagination.ts'
// 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'),
{
@@ -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()
}
}
@@ -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<string, 1 | -1> } {
return { $sort: { [field]: this.order.getOrder() as 1 | -1 } }
}
limit() {
return { $limit: this.itemsPerPage }
}
getOrder() {
return this.order.getName()
}
}
-83
View File
@@ -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,
}
}
+103
View File
@@ -0,0 +1,103 @@
import { Command, Session } from '@linux-odyssey/models'
import Pagination from './pagination.ts'
import { Types } from 'mongoose'
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<any[]> {
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
console.log(finishedAt)
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,
}
})
}
interface User {
username: string
}
interface SessionDocument {
_id: Types.ObjectId
user: User
quest: string
status: string
createdAt: Date
finishedAt: Date
}
export async function sessionDetail(id: string): Promise<any> {
const session = (await Session.findById(id)
.populate('user')
.exec()) as unknown as SessionDocument
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.getTime() - createdAt.getTime())
: '',
commands,
}
}
@@ -1,6 +1,7 @@
import { Session, User } from '@linux-odyssey/models'
import Pagination from './pagination.ts'
function loginMethods(user) {
function loginMethods(user: any) {
const methods = []
if (user.hashedPassword) methods.push('password')
if (user.socialLogins) methods.push(...Object.keys(user.socialLogins))
@@ -8,10 +9,10 @@ function loginMethods(user) {
}
export function userCount() {
return User.find().count()
return User.countDocuments({})
}
export async function userList(pagination) {
export async function userList(pagination: Pagination) {
const users = await Session.aggregate([
pagination.match('user'),
{
@@ -2,12 +2,12 @@ import { Router } from 'express'
import { homeController } from './controllers/index.js'
import { userListController } from './controllers/users.js'
import { questListController } from './controllers/quests.js'
import { commandListController } from './controllers/commands.js'
import { questListController } from './controllers/quests.ts'
import { commandListController } from './controllers/commands.ts'
import {
sessionDetailController,
sessionListController,
} from './controllers/sessions.js'
} from './controllers/sessions.ts'
const router = Router()
+4 -1
View File
@@ -3,7 +3,10 @@
"compilerOptions": {
"allowJs": true,
"rootDir": "src",
"outDir": "./dist"
"outDir": "./dist",
"esModuleInterop": true,
"noEmit": true,
"allowImportingTsExtensions": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
+4 -4
View File
@@ -2,7 +2,7 @@ import Docker from 'dockerode'
import config, { getQuestImage } from '../config.js'
import logger from '../utils/logger.js'
const engine = new Docker()
const engine = new Docker({ socketPath: '//./pipe/docker_engine' })
const containerOptions = {
AttachStdin: true,
@@ -29,8 +29,8 @@ export function createContainer(
if (!config.isProduction && hostPwd && mountQuest === questId) {
logger.info('Mounting quest folder', mountQuest)
option.HostConfig!.Binds = [
`${hostPwd}/quests/${mountQuest}/home:/home/commander`,
`${hostPwd}/packages/container:/usr/local/lib/container`,
`${hostPwd.replace(/\\/g, '/')}/quests/${mountQuest}/home:/home/commander`,
`${hostPwd.replace(/\\/g, '/')}/packages/container:/usr/local/lib/container`,
]
}
return engine.createContainer(option)
@@ -62,7 +62,7 @@ export async function attachContainer(
Tty: true,
Env: [
`TOKEN=${token}`,
`API_ENDPOINT=${config.backendUrl}`,
`API_ENDPOINT=http://backend:3000`,
'ZDOTDIR=/etc/zsh',
],
})
+23
View File
@@ -1176,6 +1176,13 @@
dependencies:
undici-types "~5.26.4"
"@types/node@^20.11.20":
version "20.11.21"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.21.tgz#ad67e65652f7be15686e2df87a38076a81c5e9c5"
integrity sha512-/ySDLGscFPNasfqStUuWWPfL78jompfIoVzLJPVVAHBh6rpG68+pI2Gk+fNLeI8/f1yPYL4s46EleVIc20F1Ow==
dependencies:
undici-types "~5.26.4"
"@types/oauth@*":
version "0.9.4"
resolved "https://registry.yarnpkg.com/@types/oauth/-/oauth-0.9.4.tgz#dcbab5efa2f34f312b915f80685760ccc8111e0a"
@@ -5373,6 +5380,22 @@ nodemon@^3.0.1:
touch "^3.1.0"
undefsafe "^2.0.5"
nodemon@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-3.1.0.tgz#ff7394f2450eb6a5e96fe4180acd5176b29799c9"
integrity sha512-xqlktYlDMCepBJd43ZQhjWwMw2obW/JRvkrLxq5RCNcuDDX1DbcPT+qT1IlIIdf+DhnWs90JpTMe+Y5KxOchvA==
dependencies:
chokidar "^3.5.2"
debug "^4"
ignore-by-default "^1.0.1"
minimatch "^3.1.2"
pstree.remy "^1.1.8"
semver "^7.5.3"
simple-update-notifier "^2.0.0"
supports-color "^5.5.0"
touch "^3.1.0"
undefsafe "^2.0.5"
nopt@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88"