Compare commits

..

18 Commits

Author SHA1 Message Date
iamBadgers
62ed1badc2 Add volume support to allow loader to pass data to server. 2025-06-03 01:57:06 -07:00
iamBadgers
9801ba4ba6 setup timer for create database. 2025-06-02 21:05:59 -07:00
iamBadgers
f6fcf82eae Move the webapp into its own folder 2025-06-02 17:56:58 -07:00
iamBadgers
cf3abe92f5 remove scripts, fix db copy error 2025-06-02 00:18:52 -07:00
iamBadgers
e1f7404c8d update compose to link memcached correctly 2025-06-01 22:31:51 -07:00
iamBadgers
1dae591ad5 add in port and memcache cl vars 2025-06-01 22:16:05 -07:00
iamBadgers
66eb266dd3 dockerize this shit 2025-06-01 18:33:31 -07:00
iamBadgers
36f5d6b396 build's a bitch 2024-09-02 22:17:46 -07:00
iamBadgers
903117696e update the character list to keep sort and filters in the query params. 2024-09-02 17:00:42 -07:00
iamBadgers
cae3f48063 update the game list to retain filters in the url query params 2024-09-02 16:14:29 -07:00
iamBadgers
cda4aa1b08 git sucks 2024-09-01 20:33:33 -07:00
iamBadgers
e6d0dcaf5f setup memcache 2024-09-01 20:31:35 -07:00
iamBadgers
ba5e91f763 setup memcache 2024-09-01 20:21:03 -07:00
iamBadgers
197cc2ae7f Merge branch 'master' of https://gitlab.badgerbox.co/rush/rush-statistics 2024-08-10 22:16:02 -07:00
youdontneedthis
3d4e4636f7 update scripts 2024-07-21 02:08:25 +00:00
jmosrael@gmail.com
d6de4e55e8 install scripts 2024-07-18 21:07:20 -07:00
jmosrael@gmail.com
bc3455f5d0 Chron script for loading database 2024-07-17 22:50:25 -07:00
iamBadgers
a64a7ae702 tweak installation scripts, add webpack to backend 2024-06-12 23:42:59 -07:00
61 changed files with 596 additions and 284 deletions

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
Dockerfile
.git
.gitignore
dist/**
README.md

4
.gitignore vendored
View File

@@ -5,8 +5,8 @@ __pycache__
# dist folders
dist
frontend/dist
backend/dist
app/frontend/dist
app/backend/dist
# sqlite file
*.db

43
app/Dockerfile Normal file
View File

@@ -0,0 +1,43 @@
FROM node:23 AS frontend
RUN corepack enable
# build the frontend
COPY ./frontend /frontend
WORKDIR /frontend
RUN npm install
CMD ["npm", "run", "build"]
# RUN mkdir -p /srv/cprush-stats/frontend
# RUN cp -rf ./dist/* /srv/cprush-stats/frontend
FROM node:23 AS backend
RUN corepack enable
# build the backend
COPY ./backend /backend
WORKDIR /backend
RUN npm install
CMD ["npm", "run", "build"]
# RUN mkdir -p /srv/cprush-stats
# RUN cp -rf ./dist/* /srv/cprush-stats
# RUN cp package.json /srv/cprush-stats/package.json
FROM node:23 AS server
RUN corepack enable
RUN mkdir -p /srv/cprush-stats/frontend
COPY --from=frontend /frontend/dist /srv/cprush-stats/frontend
COPY --from=backend /backend/dist /srv/cprush-stats
COPY --from=backend /backend/package.json /srv/cprush-stats/package.json
VOLUME /srv/cprush-stats/data
EXPOSE 3001
ENV NODE_ENV=production
ENV MEMCACHE_ADDR="localhost:11211"
WORKDIR /srv/cprush-stats
RUN npm install
CMD ["sh", "-c", "node app.js -m $MEMCACHE_ADDR"]

View File

@@ -13,6 +13,7 @@
"asty-astq": "^1.14.0",
"body-parser": "^1.20.2",
"express": "^4.18.3",
"memcached": "^2.2.2",
"net": "^1.0.2",
"pg-hstore": "^2.3.4",
"sequelize": "^6.37.1",
@@ -1918,6 +1919,12 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/connection-parse": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/connection-parse/-/connection-parse-0.0.7.tgz",
"integrity": "sha512-bTTG28diWg7R7/+qE5NZumwPbCiJOT8uPdZYu674brDjBWQctbaQbYlDKhalS+4i5HxIx+G8dZsnBHKzWpp01A==",
"license": "MIT"
},
"node_modules/console-browserify": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.2.0.tgz",
@@ -3336,6 +3343,16 @@
"minimalistic-assert": "^1.0.1"
}
},
"node_modules/hashring": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/hashring/-/hashring-3.2.0.tgz",
"integrity": "sha512-xCMovURClsQZ+TR30icCZj+34Fq1hs0y6YCASD6ZqdRfYRybb5Iadws2WS+w09mGM/kf9xyA5FCdJQGcgcraSA==",
"license": "MIT",
"dependencies": {
"connection-parse": "0.0.x",
"simple-lru-cache": "0.0.x"
}
},
"node_modules/hasown": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.1.tgz",
@@ -3795,6 +3812,22 @@
"node": ">=0.10.0"
}
},
"node_modules/jackpot": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/jackpot/-/jackpot-0.0.6.tgz",
"integrity": "sha512-rbWXX+A9ooq03/dfavLg9OXQ8YB57Wa7PY5c4LfU3CgFpwEhhl3WyXTQVurkaT7zBM5I9SSOaiLyJ4I0DQmC0g==",
"dependencies": {
"retry": "0.6.0"
}
},
"node_modules/jackpot/node_modules/retry": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.6.0.tgz",
"integrity": "sha512-RgncoxLF1GqwAzTZs/K2YpZkWrdIYbXsmesdomi+iPilSzjUyr/wzNIuteoTVaWokzdwZIJ9NHRNQa/RUiOB2g==",
"engines": {
"node": "*"
}
},
"node_modules/jest-worker": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz",
@@ -4001,6 +4034,16 @@
"node": ">= 0.6"
}
},
"node_modules/memcached": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/memcached/-/memcached-2.2.2.tgz",
"integrity": "sha512-lHwUmqkT9WdUUgRsAvquO4xsKXYaBd644Orz31tuth+w/BIfFNuJMWwsG7sa7H3XXytaNfPTZ5R/yOG3d9zJMA==",
"license": "MIT",
"dependencies": {
"hashring": "3.2.x",
"jackpot": ">=0.0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
@@ -5632,6 +5675,11 @@
"simple-concat": "^1.0.0"
}
},
"node_modules/simple-lru-cache": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/simple-lru-cache/-/simple-lru-cache-0.0.2.tgz",
"integrity": "sha512-uEv/AFO0ADI7d99OHDmh1QfYzQk/izT1vCmu/riQfh7qjBVUUgRT87E5s5h7CxWCA/+YoZerykpEthzVrW3LIw=="
},
"node_modules/slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",

View File

@@ -8,7 +8,7 @@
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/",
"dev": "npx ts-node-dev src/app.ts",
"build": "npx tsc"
"build": "webpack && cp package.json ./dist/package.json"
},
"keywords": [],
"author": "",
@@ -32,6 +32,7 @@
"asty-astq": "^1.14.0",
"body-parser": "^1.20.2",
"express": "^4.18.3",
"memcached": "^2.2.2",
"net": "^1.0.2",
"pg-hstore": "^2.3.4",
"sequelize": "^6.37.1",

View File

@@ -7,15 +7,24 @@ import { addGameApis } from './gameservice'
import { addCharacterApis } from './characterservice'
import { addRushStatsApis } from './rushstatsservice'
import { addDmApis } from './dmservice'
import { Memcache } from './memcache'
var Memcached = require('memcached')
const app = express()
const jsonParser = json()
const port = 3001
const port = process.argv[process.argv.indexOf('-p')]
? process.argv[process.argv.indexOf('-p') + 1]
: 3001
const memcache_addr = process.argv[process.argv.indexOf('-m')]
? process.argv[process.argv.indexOf('-m') + 1]
: 'localhost:11211'
const memcachep = new Memcache(memcache_addr)
addGameApis(app, jsonParser)
addCharacterApis(app, jsonParser)
addRushStatsApis(app, jsonParser)
addDmApis(app, jsonParser)
addGameApis(app, jsonParser, memcachep)
addCharacterApis(app, jsonParser, memcachep)
addRushStatsApis(app, jsonParser, memcachep)
addDmApis(app, jsonParser, memcachep)
app.use(express.static(__dirname + '/frontend'))
app.use('/', (req, res) => {
@@ -24,5 +33,7 @@ app.use('/', (req, res) => {
app.listen(port, async () => {
await database.authenticate()
return console.log(`Express is listening at http://localhost:${port}`)
console.log(`Using memcache at ${memcache_addr}`)
console.log(`Express is listening at http://localhost:${port}`)
return
})

View File

@@ -1,7 +1,7 @@
import { database, Character, Game, Pick, App } from './db'
import { OrderByParser, FilterParser, ParsingError } from './tokenizer'
export function addCharacterApis(app, jsonParser) {
export function addCharacterApis(app, jsonParser, memcace) {
app.get('/api/character/:characterId', async (req, res) => {
try {
const character = await Character.findOne({

View File

@@ -25,7 +25,7 @@ export const roleNames: RoleName[] = [
'Rocker'
]
const databasePath = './testdb.db'
const databasePath = './data/testdb.db'
export const database = new Sequelize({
dialect: 'sqlite',

View File

@@ -2,7 +2,7 @@ import { database, Character, Game, Pick, App } from './db'
import { OrderByParser, FilterParser, ParsingError } from './tokenizer'
import { fn, col } from 'sequelize'
export function addDmApis(app, jsonParser) {
export function addDmApis(app, jsonParser, memcache) {
app.get('/api/dm/:dmName', async (req, res) => {
const dmName = req.params.dmName

View File

@@ -1,7 +1,7 @@
import { database, Character, Game, Pick, App } from './db'
import { OrderByParser, FilterParser, ParsingError } from './tokenizer'
export function addGameApis(app, jsonParser) {
export function addGameApis(app, jsonParser, memcache) {
app.get('/api/game/:gameId', async (req, res) => {
try {
const game = await Game.findOne({ where: { id: req.params['gameId'] } })

View File

@@ -0,0 +1,29 @@
import { promisify } from 'util'
var Memcached = require('memcached')
export class Memcache {
readonly memcached
readonly getCallback
readonly setCallback
readonly replaceCallback
constructor(url: string) {
this.memcached = new Memcached(url, {
timeout: 30
})
this.getCallback = promisify(this.memcached.get).bind(this.memcached)
this.setCallback = promisify(this.memcached.set).bind(this.memcached)
this.replaceCallback = promisify(this.memcached.replace).bind(this.memcached)
}
async get(key: string) {
return this.getCallback(key)
}
async set(key: string, value) {
return this.setCallback(key, value, 200)
}
}

View File

@@ -43,12 +43,21 @@ function monthIdToEndSeconds(monthId: number): number {
return Number.MAX_SAFE_INTEGER
}
export function addRushStatsApis(app, jsonParser) {
export function addRushStatsApis(app, jsonParser, memcache) {
app.post('/api/serverstats/gamestats', jsonParser, async (req, res) => {
const monthId = req.body.monthId
let startSeconds = monthIdToStartSeconds(monthId)
let endSeconds = monthIdToEndSeconds(monthId)
let cachedResponse = await memcache.get('gamestats' + req.body.monthId)
if (cachedResponse) {
console.info('cache hit - ' + 'gamestats' + req.body.monthId)
res.send(cachedResponse)
return
}
console.log('cache miss')
const serverGameStats = await database.query(serverGameStatsQuery, {
type: QueryTypes.SELECT,
replacements: {
@@ -56,6 +65,8 @@ export function addRushStatsApis(app, jsonParser) {
endSeconds
}
})
await memcache.set('gamestats' + req.body.monthId, serverGameStats[0])
res.send(serverGameStats[0])
})
@@ -64,6 +75,13 @@ export function addRushStatsApis(app, jsonParser) {
let startSeconds = monthIdToStartSeconds(monthId)
let endSeconds = monthIdToEndSeconds(monthId)
let cachedResponse = await memcache.get('rolestats' + req.body.monthId)
if (cachedResponse) {
console.info('cache hit - ' + 'rolestats' + req.body.monthId)
res.send(cachedResponse)
return
}
const games = await Game.findAll({
include: [
{ model: Character, as: 'characterPickedForGame' },
@@ -110,6 +128,7 @@ export function addRushStatsApis(app, jsonParser) {
}
})
await memcache.set('rolestats' + req.body.monthId, result)
res.send(result)
})
}

View File

@@ -3,6 +3,7 @@ const NodePolyfillPlugin = require("node-polyfill-webpack-plugin")
const nodeExternals = require('webpack-node-externals');
module.exports = {
mode: 'production',
entry: './src/app.ts',
module: {
rules: [
@@ -12,13 +13,15 @@ module.exports = {
exclude: /node_modules/,
},
],
}, node: {
__dirname: false
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, '../dist'),
filename: 'app.js',
path: path.resolve(__dirname, './dist'),
},
plugins: [
new NodePolyfillPlugin(),

View File

@@ -3,6 +3,6 @@
"semi": false,
"tabWidth": 2,
"singleQuote": true,
"printWidth": 100,
"printWidth": 80,
"trailingComma": "none"
}

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -5,6 +5,7 @@
<v-btn to="/serverstats">Server Stats</v-btn>
<v-btn to="/games">Games</v-btn>
<v-btn to="/characters">Characters</v-btn>
<!-- <v-btn to="/gamemasters">Gamemasters</v-btn> -->
</v-app-bar>
<router-view />
</v-main>

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 526 B

After

Width:  |  Height:  |  Size: 526 B

View File

@@ -5,6 +5,7 @@ import GameDetails from '../vues/GameDetails.vue'
import CharacterList from '../vues/CharacterList.vue'
import CharacterDetails from '../vues/CharacterDetails.vue'
import ServerStats from '../vues/ServerStats.vue'
import DmList from '../vues/DmList.vue'
const root = {
path: '/',
@@ -17,6 +18,7 @@ const gameListRoute = {
}
const gameDetailsRoute = {
name: 'game',
path: '/games/:gameId',
component: GameDetails
}
@@ -27,6 +29,7 @@ const characterListRoute = {
}
const characterDetailsRoute = {
name: 'character',
path: '/characters/:characterId',
component: CharacterDetails
}
@@ -36,13 +39,19 @@ const serverStatsRoute = {
component: ServerStats
}
const gameMasterListRoute = {
path: '/gamemasters',
component: DmList
}
const routes = [
root,
gameListRoute,
gameDetailsRoute,
characterListRoute,
characterDetailsRoute,
serverStatsRoute
serverStatsRoute,
gameMasterListRoute
]
export default createRouter({

View File

@@ -21,6 +21,13 @@ export interface Character {
creationDate: number
}
export interface DmStats {
name: string
gameCount: string
lastGame: number
games: Game
}
export interface GameStats {
Complete: number
Postponed: number

View File

@@ -5,17 +5,21 @@
<h2>Player: {{ character.playerName }}</h2>
<div>Total Game EB: {{ earnedEB }} --- Total Game IP: {{ earnedIP }}</div>
<div>
Games Played: {{ gamesPlayedCount }} --- Games Applied: {{ gamesAppliedCount }} -- Pick Rate:
{{ pickRate.toFixed(2) }}%
Games Played: {{ gamesPlayedCount }} --- Games Applied:
{{ gamesAppliedCount }} -- Pick Rate: {{ pickRate.toFixed(2) }}%
</div>
<div>Last Game: {{ lastPlayedPostDate }}</div>
<div class="d-flex flex-row">
<div class="flex-column game-list">
<h3 class="game-list-title">{{ (games.played || []).length }} Games Played</h3>
<h3 class="game-list-title">
{{ (games.played || []).length }} Games Played
</h3>
<GameTable :gameList="games.played"></GameTable>
</div>
<div class="flex-column game-list">
<h3 class="game-list-title">{{ (games.applied || []).length }} Games Applied</h3>
<h3 class="game-list-title">
{{ (games.applied || []).length }} Games Applied
</h3>
<game-table :gameList="games.applied"></game-table>
</div>
</div>
@@ -65,8 +69,12 @@ const pickRate = ref(0)
const lastPlayedPostDate = ref()
async function loadCharacterDetails() {
const characterResponse = await axios.get(`/api/character/${characterId.value}`)
const gameDetails = await axios.post(`/api/character/${characterId.value}/gameHistory`)
const characterResponse = await axios.get(
`/api/character/${characterId.value}`
)
const gameDetails = await axios.post(
`/api/character/${characterId.value}/gameHistory`
)
character.value = characterResponse.data
games.value = {
@@ -86,7 +94,10 @@ function calculateDerivedEarnings(gamesPlayedList: Game[]) {
earnedIP.value = runningEarnedIp
}
function calculateDerivedGameStats(gamesPlayedList: Game[], gamesAppliedList: Game[]) {
function calculateDerivedGameStats(
gamesPlayedList: Game[],
gamesAppliedList: Game[]
) {
gamesPlayedCount.value = gamesPlayedList.length
gamesAppliedCount.value = gamesAppliedList.length
pickRate.value = (gamesPlayedList.length / gamesAppliedList.length) * 100
@@ -96,7 +107,9 @@ function calculateDerivedLastPlayedPostDate(gamesPlayedList: Game[]) {
if (gamesPlayedList.length === 0) {
lastPlayedPostDate.value = 'N/A'
} else {
lastPlayedPostDate.value = new Date(gamesPlayedList[gamesPlayedList.length - 1].postdate * 1000)
lastPlayedPostDate.value = new Date(
gamesPlayedList[gamesPlayedList.length - 1].postdate * 1000
)
.toISOString()
.split('T')[0]
}

View File

@@ -0,0 +1,171 @@
<template>
<div class="d-flex flex-row">
<v-text-field
class="filter flex-1-0"
placeholder="Filter by Name"
v-model="filterValue"
></v-text-field>
</div>
<v-data-table-server
:headers="headers"
:items="characters"
:items-length="count"
v-model:items-per-page="resultePerPage"
v-model:page="pageValue"
v-model:sort-by="sortValue"
@update:options="loadData"
>
<template v-slot:item.id="{ item }">
<router-link
:to="{ name: 'character', params: { characterId: item.id } }"
>{{ item.id }}</router-link
>
</template>
<template v-slot:item.characterName="{ item }">
<router-link
:to="{ name: 'character', params: { characterId: item.id } }"
>{{ item.characterName }}</router-link
>
</template>
<template v-slot:item.creationDate="{ item }">
{{ new Date(item.creationDate * 1000).toISOString().split('T')[0] }}
</template>
<template #bottom></template>
</v-data-table-server>
<v-pagination
v-model:model-value="pageValue"
:length="pageCount"
></v-pagination>
</template>
<style type="text/css"></style>
<script setup lang="ts">
import { Character } from '../types'
import { onBeforeMount, ref, watch } from 'vue'
import { useRoute, useRouter, LocationQueryValue } from 'vue-router'
import { VDataTable } from 'vuetify/components'
import axios from 'axios'
type ReadonlyHeaders = VDataTable['$props']['headers']
type SortItems = VDataTable['sortBy']
const headers: ReadonlyHeaders = [
{ title: 'ID', align: 'start', sortable: true, key: 'id' },
{ title: 'Character', align: 'start', sortable: true, key: 'characterName' },
{ title: 'Role', align: 'start', sortable: true, key: 'role' },
{ title: 'Player', align: 'start', sortable: true, key: 'playerName' },
{ title: 'Status', align: 'start', sortable: true, key: 'status' },
{
title: 'Creation Date',
align: 'start',
sortable: true,
key: 'creationDate'
}
]
const route = useRoute()
const router = useRouter()
const characters = ref<Character[]>([])
const pageCount = ref(1)
const resultePerPage = ref(10)
const count = ref(10)
const pageValue = ref(1)
const filterValue = ref('')
const sortValue = ref<SortItems>([])
async function loadData({ page, itemsPerPage, sortBy }: any) {
let sortString = 'id asc'
if (sortBy[0]) {
sortString = `${sortBy[0].key} ${sortBy[0].order}`
}
const response = await axios.post('/api/character', {
page: `${page - 1}`,
count: `${itemsPerPage}`,
orderBy: sortString,
filter: filterValue.value ? `characterName:"${filterValue.value}"` : ''
})
characters.value = response.data.characterData
pageCount.value = response.data.pageCount
count.value = response.data.totalCount
router.replace({
query: {
page: pageValue.value,
sort: sortString === 'id asc' ? '' : sortString,
filter: filterValue.value
}
})
}
async function reloadData() {
loadData({
page: pageValue.value,
itemsPerPage: 10,
sortBy: sortValue.value
})
}
function buildSortFromQuery(query: string | LocationQueryValue[]): SortItems {
if (typeof route.query.sort === 'string') {
const key = route.query.sort.split(' ')[0]
const order = route.query.sort.split(' ')[1] === 'asc' ? 'asc' : 'desc'
return [{ key, order }]
}
return []
}
watch(route, (newRoute, oldRoute) => {
if (newRoute.query.page) {
pageValue.value = Number(newRoute.query.page)
}
if (newRoute.query.sort) {
sortValue.value = buildSortFromQuery(newRoute.query.sort)
}
if (newRoute.query.filter) {
filterValue.value = newRoute.query.filter.toString()
}
})
let debounce: ReturnType<typeof setTimeout>
watch(filterValue, (newFilter: string, oldFilter: string) => {
clearTimeout(debounce)
debounce = setTimeout(() => {
router.replace({ query: { page: route.query.page, filter: newFilter } })
}, 500)
loadData({
page: pageValue.value,
itemsPerPage: resultePerPage.value,
sortBy: []
})
})
onBeforeMount(async () => {
// Initialize Filter
if (!route.query.page) {
pageValue.value = 1
} else {
pageValue.value = Number(route.query.page)
count.value = Number(route.query.page) * 10
}
// Initialize Sort
if (!route.query.sort) {
sortValue.value = []
} else {
sortValue.value = buildSortFromQuery(route.query.sort)
}
// Initialize Filter
if (route.query.filter) {
filterValue.value = route.query.filter.toString()
}
})
</script>

View File

@@ -0,0 +1,17 @@
<template>Hello World</template>
<style></style>
<script setup lang="ts">
import { DmStats } from '../types'
import { onMounted, ref, watch } from 'vue'
import { VDataTable } from 'vuetify/components'
type ReadonlyHeaders = VDataTable['$props']['headers']
const headers: ReadonlyHeaders = [
{ title: 'Game Master', align: 'start', sortable: true, key: 'id' },
{ title: 'Game Count', align: 'start', sortable: true, key: 'characterName' },
{ title: 'Last Game', align: 'start', sortable: true, key: 'role' }
]
</script>

View File

@@ -23,23 +23,41 @@
<div class="d-flex flex-row">
<div class="character-list">
<h3>{{ picks.length }} Character Picks</h3>
<v-data-table-virtual :headers="pickHeaders" :items="picks" height="500px" fixed-header>
<v-data-table-virtual
:headers="pickHeaders"
:items="picks"
height="500px"
fixed-header
>
<template v-slot:item.characterId="{ item }">
<a v-bind:href="`/#/characters/${item.characterId}`">{{ item.characterId }}</a>
<a v-bind:href="`/#/characters/${item.characterId}`">{{
item.characterId
}}</a>
</template>
<template v-slot:item.characterName="{ item }">
<a v-bind:href="`/#/characters/${item.characterId}`">{{ item.characterName }}</a>
<a v-bind:href="`/#/characters/${item.characterId}`">{{
item.characterName
}}</a>
</template>
</v-data-table-virtual>
</div>
<div class="character-list">
<h3>{{ apps.length }} Character Apps</h3>
<v-data-table-virtual :headers="appHeaders" :items="apps" height="500px" fixed-header>
<v-data-table-virtual
:headers="appHeaders"
:items="apps"
height="500px"
fixed-header
>
<template v-slot:item.characterId="{ item }">
<a v-bind:href="`/#/characters/${item.characterId}`">{{ item.characterId }}</a>
<a v-bind:href="`/#/characters/${item.characterId}`">{{
item.characterId
}}</a>
</template>
<template v-slot:item.characterName="{ item }">
<a v-bind:href="`/#/characters/${item.characterId}`">{{ item.characterName }}</a>
<a v-bind:href="`/#/characters/${item.characterId}`">{{
item.characterName
}}</a>
</template>
</v-data-table-virtual>
</div>

View File

@@ -3,22 +3,27 @@
<v-text-field
class="filter flex-1-0"
placeholder="Filter by Name"
v-model="filtervalue"
v-model="filterValue"
></v-text-field>
</div>
<v-data-table-server
:headers="headers"
v-model:items-per-page="resultsPerPage"
:items="games"
:items-length="count"
v-model:page="page"
v-model:items-per-page="resultsPerPage"
v-model:page="pageValue"
v-model:sort-by="sortValue"
@update:options="loadData"
>
<template v-slot:item.id="{ item }">
<a v-bind:href="`/#/games/${item.id}`">{{ item.id }}</a>
<router-link :to="{ name: 'game', params: { gameId: item.id } }">{{
item.id
}}</router-link>
</template>
<template v-slot:item.title="{ item }">
<a v-bind:href="`/#/games/${item.id}`">{{ item.title }}</a>
<router-link :to="{ name: 'game', params: { gameId: item.id } }">{{
item.title
}}</router-link>
</template>
<template v-slot:item.postdate="{ item }">
{{ new Date(item.postdate * 1000).toISOString().split('T')[0] }}
@@ -27,9 +32,8 @@
<template #bottom></template>
</v-data-table-server>
<v-pagination
:model-value="page"
v-model:model-value="pageValue"
:length="pageCount"
@update:modelValue="setPage($event)"
></v-pagination>
</template>
@@ -45,12 +49,13 @@
<script setup lang="ts">
import { Game } from '../types'
import GameTable from './GameTable.vue'
import { onMounted, watch, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { onBeforeMount, watch, ref } from 'vue'
import { useRoute, useRouter, LocationQueryValue } from 'vue-router'
import axios from 'axios'
import { VDataTable } from 'vuetify/components'
type ReadonlyHeaders = VDataTable['$props']['headers']
type SortItems = VDataTable['sortBy']
const headers: ReadonlyHeaders = [
{ title: 'ID', align: 'start', sortable: true, key: 'id' },
@@ -64,15 +69,16 @@ const router = useRouter()
const games = ref<Game[]>([])
const pageCount = ref(1)
let page = ref(1)
const resultsPerPage = ref(10)
let count = ref(10)
const count = ref(0)
const filtervalue = ref<string>('')
const pageValue = ref(1)
const filterValue = ref('')
const sortValue = ref<SortItems>([])
async function loadData({ page, itemsPerPage, sortBy }: any) {
let sortString = 'id'
let sortString = 'id asc'
if (sortBy[0]) {
sortString = `${sortBy[0].key} ${sortBy[0].order}`
}
@@ -81,50 +87,80 @@ async function loadData({ page, itemsPerPage, sortBy }: any) {
page: `${page - 1}`,
count: `${itemsPerPage}`,
orderBy: sortString,
filter: filtervalue.value ? `title:"${filtervalue.value}"` : ''
filter: filterValue.value ? `title:"${filterValue.value}"` : ''
})
games.value = response.data.gameData
pageCount.value = response.data.pageCount
count.value = response.data.totalCount
router.replace({
query: {
page: pageValue.value,
sort: sortString === 'id asc' ? '' : sortString,
filter: filterValue.value
}
})
}
function setPage(targetPage: number) {
router.replace({ query: { page: targetPage, filter: route.query.filter } })
async function reloadData() {
loadData({
page: pageValue.value,
itemsPerPage: 10,
sortBy: sortValue.value
})
}
watch(route, (newValue, oldValue) => {
if (!route.query.page) {
router.replace({ query: { page: 1, filter: route.query.filter } })
page.value = 1
} else {
page.value = Number(route.query.page)
function buildSortFromQuery(query: string | LocationQueryValue[]): SortItems {
if (typeof route.query.sort === 'string') {
const key = route.query.sort.split(' ')[0]
const order = route.query.sort.split(' ')[1] === 'asc' ? 'asc' : 'desc'
return [{ key, order }]
}
return []
}
watch(route, (newRoute, oldRoute) => {
if (newRoute.query.page) {
pageValue.value = Number(newRoute.query.page)
}
if (route.query.filter) {
filtervalue.value = route.query.filter.toString()
if (newRoute.query.sort) {
sortValue.value = buildSortFromQuery(newRoute.query.sort)
}
if (newRoute.query.filter) {
filterValue.value = newRoute.query.filter.toString()
}
loadData({ page: page.value, itemsPerPage: resultsPerPage.value, sortBy: [] })
})
let debounce: ReturnType<typeof setTimeout>
watch(filtervalue, (newFilter: string, oldFilter: string) => {
watch(filterValue, (newFilter: string, oldFilter: string) => {
clearTimeout(debounce)
debounce = setTimeout(() => {
router.replace({ query: { page: route.query.page, filter: newFilter } })
reloadData()
}, 500)
loadData({ page: page.value, itemsPerPage: resultsPerPage.value, sortBy: [] })
})
onMounted(async () => {
onBeforeMount(async () => {
// Initialize Filter
if (!route.query.page) {
router.replace({ query: { page: 1 } })
page.value = 1
pageValue.value = 1
} else {
page.value = Number(route.query.page)
pageValue.value = Number(route.query.page)
count.value = Number(route.query.page) * 10
}
// Initialize Sort
if (!route.query.sort) {
sortValue.value = []
} else {
sortValue.value = buildSortFromQuery(route.query.sort)
}
// Initialize Filter
if (route.query.filter) {
filtervalue.value = route.query.filter.toString()
filterValue.value = route.query.filter.toString()
}
})
</script>

View File

@@ -1,5 +1,10 @@
<template>
<v-data-table-virtual :headers="headers" :items="gameList" height="500px" fixed-header>
<v-data-table-virtual
:headers="headers"
:items="gameList"
height="500px"
fixed-header
>
<template v-slot:item.id="{ item }">
<a v-bind:href="`/#/games/${item.id}`">{{ item.id }}</a>
</template>

View File

@@ -1,6 +1,11 @@
<template>
<div class="d-flex flex-column">
<v-select class="date-selector" v-model="dateSelect" variant="underlined" :items="dateItems">
<v-select
class="date-selector"
v-model="dateSelect"
variant="underlined"
:items="dateItems"
>
</v-select>
<div class="stat-bar d-flex flex-row justify-space-around">
<div class="d-flex flex-column">
@@ -18,13 +23,15 @@
<div class="d-flex flex-column">
<v-label class="title">Total / Average EB</v-label>
<v-label
>{{ Math.floor(gameStats.TotalEB) }} / {{ Math.floor(gameStats.AverageEB) }}</v-label
>{{ Math.floor(gameStats.TotalEB) }} /
{{ Math.floor(gameStats.AverageEB) }}</v-label
>
</div>
<div class="d-flex flex-column">
<v-label class="title">Total / Average IP</v-label>
<v-label
>{{ Math.floor(gameStats.TotalIP) }} / {{ Math.floor(gameStats.AverageIP) }}</v-label
>{{ Math.floor(gameStats.TotalIP) }} /
{{ Math.floor(gameStats.AverageIP) }}</v-label
>
</div>
</div>
@@ -33,9 +40,7 @@
<thead>
<tr>
<th class="text-left">Role</th>
<th class="text-left">
Characters
</th>
<th class="text-left">Characters</th>
<th class="text-left">App Count</th>
<th class="text-left">Games Played</th>
<th class="text-left">Pick Rate %</th>
@@ -51,7 +56,9 @@
</td>
<td class="text-left">{{ roleStat.apps }}</td>
<td class="text-left">{{ roleStat.picks }}</td>
<td class="text-left">{{ Math.floor((roleStat.picks / roleStat.apps) * 100) }}%</td>
<td class="text-left">
{{ Math.floor((roleStat.picks / roleStat.apps) * 100) }}%
</td>
<td class="text-left">
{{ Math.floor((roleStat.picks / gameStats.Complete) * 100) }}%
</td>
@@ -231,7 +238,9 @@ function updateChart(stats: RoleStats, tag: ChartType) {
{
label: 'Game Type',
data: [
gameStats.value.Complete - gameStats.value.Events - gameStats.value.Fixes,
gameStats.value.Complete -
gameStats.value.Events -
gameStats.value.Fixes,
gameStats.value.Postponed,
gameStats.value.Pending,
gameStats.value.Events,

35
docker-compose.yaml Normal file
View File

@@ -0,0 +1,35 @@
version: "3"
networks:
cprush-net:
external: false
volumes:
cprush-loader-data:
services:
cprush:
# image: potato
build: ./app
environment:
- MEMCACHE_ADDR=memcache:11211
volumes:
- cprush-loader-data:/srv/cprush-stats/data
networks:
- cprush-net
ports:
- 3001:3001
depends_on:
- memcache
- loader
loader:
build: ./loader
environment:
- REPLAY_TIME=600
volumes:
- cprush-loader-data:/loader/data
memcache:
image: memcached
networks:
- cprush-net

View File

@@ -1,122 +0,0 @@
<template>
<div class="d-flex flex-row">
<v-text-field
class="filter flex-1-0"
placeholder="Filter by Name"
v-model="filtervalue"
></v-text-field>
</div>
<v-data-table-server
:headers="headers"
v-model:items-per-page="resultePerPage"
:items="characters"
:items-length="count"
v-model:page="page"
@update:options="loadData"
>
<template v-slot:item.id="{ item }">
<a v-bind:href="`/#/characters/${item.id}`">{{ item.id }}</a>
</template>
<template v-slot:item.characterName="{ item }">
<a v-bind:href="`/#/characters/${item.id}`">{{ item.characterName }}</a>
</template>
<template v-slot:item.creationDate="{ item }">
{{ new Date(item.creationDate * 1000).toISOString().split('T')[0] }}
</template>
<template #bottom></template>
</v-data-table-server>
<v-pagination
:model-value="page"
:length="pageCount"
@update:modelValue="setPage($event)"
></v-pagination>
</template>
<style type="text/css"></style>
<script setup lang="ts">
import { Character } from '../types'
import { onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { VDataTable } from 'vuetify/components'
import axios from 'axios'
type ReadonlyHeaders = VDataTable['$props']['headers']
const headers: ReadonlyHeaders = [
{ title: 'ID', align: 'start', sortable: true, key: 'id' },
{ title: 'Character', align: 'start', sortable: true, key: 'characterName' },
{ title: 'Role', align: 'start', sortable: true, key: 'role' },
{ title: 'Player', align: 'start', sortable: true, key: 'playerName' },
{ title: 'Status', align: 'start', sortable: true, key: 'status' },
{ title: 'Creation Date', align: 'start', sortable: true, key: 'creationDate' }
]
const route = useRoute()
const router = useRouter()
const characters = ref<Character[]>([])
const pageCount = ref(1)
const page = ref(1)
const resultePerPage = ref(10)
const count = ref(10)
const filtervalue = ref('')
async function loadData({ page, itemsPerPage, sortBy }: any) {
let sortString = 'id'
if (sortBy[0]) {
console.log(sortBy[0].key)
console.log(sortBy[0].order)
sortString = `${sortBy[0].key} ${sortBy[0].order}`
}
const response = await axios.post('/api/character', {
page: `${page - 1}`,
count: `${itemsPerPage}`,
orderBy: sortString,
filter: filtervalue.value ? `characterName:"${filtervalue.value}"` : ''
})
characters.value = response.data.characterData
pageCount.value = response.data.pageCount
count.value = response.data.totalCount
}
function setPage(targetPage: number) {
router.replace({ query: { page: targetPage, filter: route.query.filter } })
}
watch(route, (newValue, oldValue) => {
if (!route.query.page) {
router.replace({ query: { page: 1, filter: route.query.filter } })
page.value = 1
} else {
page.value = Number(route.query.page)
}
page.value = Number(route.query.page)
loadData({ page: page.value, itemsPerPage: resultePerPage.value, sortBy: [] })
})
let debounce: ReturnType<typeof setTimeout>
watch(filtervalue, (newFilter: string, oldFilter: string) => {
clearTimeout(debounce)
debounce = setTimeout(() => {
router.replace({ query: { page: route.query.page, filter: newFilter } })
}, 500)
loadData({ page: page.value, itemsPerPage: resultePerPage.value, sortBy: [] })
})
onMounted(async () => {
if (!route.query.page) {
router.replace({ query: { page: 1 } })
page.value = 1
} else {
page.value = Number(route.query.page)
}
if (route.query.filter) {
filtervalue.value = route.query.filter.toString()
}
})
</script>

View File

@@ -1,10 +0,0 @@
<template>
</template>
<style>
</style>
<script setup lang="ts">
</script>

View File

@@ -1,44 +0,0 @@
#!/bin/bash
parent_path=$( cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P )
echo ""
echo "---"
echo "Moving the distributable folder into /srv/gamestats"
echo "---"
cp -r "$parent_path"/dist /srv/gamestats
echo ""
echo "---"
echo "Creating the systemd services."
echo "---"
SYSTEMD_SERVICE_FILE=/etc/systemd/system/rushstats.service
if [ -f $SYSTEMD_SERVICE_FILE ]; then
echo "Service already exists; removing."
rm $SYSTEMD_SERVICE_FILE
fi
touch $SYSTEMD_SERVICE_FILE
echo "[Unit]" >> $SYSTEMD_SERVICE_FILE
echo "Description=Stats Server for Cyberpunk Rush" >> $SYSTEMD_SERVICE_FILE
echo "After=network.target" >> $SYSTEMD_SERVICE_FILE
echo "" >> $SYSTEMD_SERVICE_FILE
echo "[Service]" >> $SYSTEMD_SERVICE_FILE
echo "Type=simple" >> $SYSTEMD_SERVICE_FILE
echo "WorkingDirectory=/srv/gamestats"
echo "ExecStart=node /srv/gamestats/app.js" >> $SYSTEMD_SERVICE_FILE
echo "Restart=on-failure" >> $SYSTEMD_SERVICE_FILE
echo "" >> $SYSTEMD_SERVICE_FILE
echo "[Install]" >> $SYSTEMD_SERVICE_FILE
echo "WantedBy=multi-user.target" >> $SYSTEMD_SERVICE_FILE
echo "" >> $SYSTEMD_SERVICE_FILE
echo ""
echo "---"
echo "Creating the cron job to refresh the database."
echo "---"
echo ""
echo "---"
echo "Reloading daemons and starting service"
echo "---"
systemctl daeomon-reload
systemctl start rushstats.service

15
loader/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
# Setup the loader
FROM python:3.12 AS loader
COPY . /loader
VOLUME /loader/data
WORKDIR /loader
RUN python -m pip install --upgrade pip
RUN pip install -r requirements.txt
ENV REPLAY_TIME=0
CMD ["sh", "-c", "python3 createrushdatabase.py -r $REPLAY_TIME"]

View File

@@ -1,9 +1,16 @@
import time
import argparse
from databasesync import createDatabase
from sheetloader import downloadGamesCSV, downloadCharactersCSV
parser = argparse.ArgumentParser()
parser.add_argument("-r", "--replay_time", type=int)
args = parser.parse_args()
CHARACTER_DATA_OUT_FILE = "CharacterData.csv"
GAME_DATA_OUT_FILE = "GameData.csv"
DATABASE_NAME = "testdb.db"
DATABASE_NAME = "data/testdb.db"
def execute():
downloadCharactersCSV(CHARACTER_DATA_OUT_FILE)
@@ -11,4 +18,10 @@ def execute():
createDatabase(DATABASE_NAME, GAME_DATA_OUT_FILE, CHARACTER_DATA_OUT_FILE)
if __name__ == "__main__":
print("starting up loader.")
print("replay time: ", args.replay_time)
execute()
while args.replay_time and args.replay_time > 0:
print("re-run in ", args.replay_time, "seconds", flush=True)
time.sleep(args.replay_time)
execute()

View File

@@ -12,6 +12,10 @@ Game = namedtuple('Game',
Link = namedtuple('Link', ['gameId', 'gameTitle', 'characterId', 'characterName'])
SET_WAL_PRAGMA = """
PRAGMA journal_mode=WAL;
"""
APPS_TABLE_CREATE = """
CREATE TABLE IF NOT EXISTS "Apps" (
"gameId" INTEGER,
@@ -170,6 +174,7 @@ def loadAppsAndPicks(characterNameToId, gameTitleToId, gameFileName):
def createTables(dbName):
with sqlite3.connect(dbName) as connection:
cursor = connection.cursor()
cursor.execute(SET_WAL_PRAGMA)
cursor.execute(CHARACTER_TABLE_CREATE)
cursor.execute(GAMES_TABLE_CREATE)
cursor.execute(APPS_TABLE_CREATE)

5
loader/requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
certifi==2024.6.2
charset-normalizer==3.3.2
idna==3.7
requests==2.32.3
urllib3==2.2.1

View File

@@ -1,31 +0,0 @@
#!/bin/bash
parent_path=$( cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P )
mkdir "$parent_path"/dist
# Build and move the front end distribution into the output dist folder
cd "$parent_path"/frontend
npm install
npm run build
cd ../
cp -rf ./frontend/dist ./dist/frontend
# Build and move the back end distribution into the output dist folder
cd "$parent_path"/backend
npm install
npm run build
cd ../
cp -f ./backend/dist/* ./dist
# Build and move the database into the output dist folder
cd "$parent_path"/loader
python3 createrushdatabase.py
cd ../
cp -r ./loader/testdb.db ./dist/testdb.db
# Move the package into the dist folder and install the needed modules
cd "$parent_path"
cp ./backend/package.json ./dist/package.json
cd "$parent_path"/dist
npm install