Compare commits
61 Commits
v0.0.2
...
release/0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
9a3094ae6a
|
|||
|
d72bf5d0fe
|
|||
|
f566f83398
|
|||
|
668551bc04
|
|||
|
79e95cecfa
|
|||
|
c82a7f0fa1
|
|||
| d635c9b0c0 | |||
| 96754c5999 | |||
| bcd1189f30 | |||
| 8730766c66 | |||
|
67c877385f
|
|||
| 59705b9903 | |||
|
d0cdb6afb5
|
|||
|
d7208b773c
|
|||
|
a6ca23dd5d
|
|||
|
687067810f
|
|||
|
9722302343
|
|||
|
1fcbb917b6
|
|||
|
9e0c819060
|
|||
|
44c663bfd6
|
|||
|
2c73dda857
|
|||
|
5673068e1a
|
|||
|
28013d43de
|
|||
|
3169c4716e
|
|||
|
14a5913aef
|
|||
|
fcd9acc5dc
|
|||
|
721421294f
|
|||
|
9db53d5354
|
|||
|
53710079e8
|
|||
|
ca6cfd6622
|
|||
|
530e6d1cd1
|
|||
|
6d60c46426
|
|||
|
4b1f39042b
|
|||
|
1dac3e6210
|
|||
|
eaf2bba8d1
|
|||
|
f62bc93932
|
|||
|
92a8b6f551
|
|||
|
b63391845f
|
|||
|
fce8a4d095
|
|||
|
59636415ef
|
|||
|
834276f651
|
|||
|
bbdbd04cc8
|
|||
|
e3951d4793
|
|||
|
c514337fea
|
|||
|
4fe3d6a984
|
|||
|
83d956a8aa
|
|||
|
9ea9fa410c
|
|||
|
2affcd625f
|
|||
|
1d4da03039
|
|||
|
10a0254f4e
|
|||
|
c62d760570
|
|||
|
a528118178
|
|||
|
092534e437
|
|||
|
eae539e39f
|
|||
|
4d96bb2674
|
|||
|
1b7046788e
|
|||
|
7af903638d
|
|||
|
816d5df943
|
|||
|
29b6974714
|
|||
|
ff1c56c791
|
|||
|
ceea2a7616
|
286
.drone.yml
286
.drone.yml
@@ -1,8 +1,29 @@
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: frontend
|
||||
|
||||
steps:
|
||||
- name: Restore cache
|
||||
image: plugins/s3-cache
|
||||
settings:
|
||||
pull: true
|
||||
endpoint: https://storage.humenius.me
|
||||
access_key:
|
||||
from_secret: aws_access_key_id
|
||||
secret_key:
|
||||
from_secret: aws_secret_access_key
|
||||
restore: true
|
||||
debug: true
|
||||
filename: frontend.tar
|
||||
workdir: frontend/
|
||||
|
||||
- name: Install packages
|
||||
image: node
|
||||
commands:
|
||||
- cd frontend
|
||||
- yarn
|
||||
|
||||
- name: Build and push frontend image
|
||||
image: plugins/docker
|
||||
settings:
|
||||
@@ -12,13 +33,126 @@ steps:
|
||||
from_secret: docker_password
|
||||
dockerfile: frontend/Dockerfile
|
||||
context: frontend/
|
||||
use_cache: true
|
||||
repo: docker.humenius.me/humenius/ts-onlinetime-ranks-frontend
|
||||
registry: docker.humenius.me
|
||||
tags: ["latest", "${DRONE_SEMVER}"]
|
||||
|
||||
- name: Rebuild cache
|
||||
image: plugins/s3-cache
|
||||
settings:
|
||||
pull: true
|
||||
endpoint: https://storage.humenius.me
|
||||
access_key:
|
||||
from_secret: aws_access_key_id
|
||||
secret_key:
|
||||
from_secret: aws_secret_access_key
|
||||
rebuild: true
|
||||
debug: true
|
||||
filename: frontend.tar
|
||||
mount:
|
||||
- frontend/node_modules
|
||||
when:
|
||||
event: push
|
||||
status: [ success, failure ]
|
||||
|
||||
- name: Flush cache
|
||||
image: plugins/s3-cache:1
|
||||
settings:
|
||||
pull: true
|
||||
endpoint: https://storage.humenius.me
|
||||
access_key:
|
||||
from_secret: aws_access_key_id
|
||||
secret_key:
|
||||
from_secret: aws_secret_access_key
|
||||
flush: true
|
||||
flush_age: 14
|
||||
debug: true
|
||||
filename: frontend.tar
|
||||
when:
|
||||
status: [ success, failure ]
|
||||
|
||||
trigger:
|
||||
ref:
|
||||
- refs/tags/* # only trigger when tagging
|
||||
event: [ tag, promote ]
|
||||
refs: [ refs/heads/release/*, refs/tags/* ]
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: frontend-dev
|
||||
|
||||
steps:
|
||||
- name: Restore cache
|
||||
image: plugins/s3-cache
|
||||
settings:
|
||||
pull: true
|
||||
endpoint: https://storage.humenius.me
|
||||
access_key:
|
||||
from_secret: aws_access_key_id
|
||||
secret_key:
|
||||
from_secret: aws_secret_access_key
|
||||
restore: true
|
||||
debug: true
|
||||
filename: frontend-dev.tar
|
||||
workdir: frontend/
|
||||
|
||||
- name: Install packages
|
||||
image: node
|
||||
commands:
|
||||
- cd frontend
|
||||
- ls -la
|
||||
- yarn
|
||||
|
||||
- name: Build and push frontend dev image
|
||||
image: plugins/docker
|
||||
settings:
|
||||
username:
|
||||
from_secret: docker_username
|
||||
password:
|
||||
from_secret: docker_password
|
||||
dockerfile: frontend/Dockerfile
|
||||
context: frontend/
|
||||
use_cache: true
|
||||
repo: docker.humenius.me/humenius/ts-onlinetime-ranks-frontend
|
||||
registry: docker.humenius.me
|
||||
tags: ["dev-${DRONE_COMMIT_SHA}", "dev-latest"]
|
||||
|
||||
- name: Rebuild cache
|
||||
image: plugins/s3-cache
|
||||
settings:
|
||||
pull: true
|
||||
endpoint: https://storage.humenius.me
|
||||
access_key:
|
||||
from_secret: aws_access_key_id
|
||||
secret_key:
|
||||
from_secret: aws_secret_access_key
|
||||
rebuild: true
|
||||
debug: true
|
||||
filename: frontend-dev.tar
|
||||
mount:
|
||||
- frontend/node_modules
|
||||
when:
|
||||
status: [ success, failure ]
|
||||
|
||||
- name: Flush cache
|
||||
image: plugins/s3-cache:1
|
||||
settings:
|
||||
pull: true
|
||||
endpoint: https://storage.humenius.me
|
||||
access_key:
|
||||
from_secret: aws_access_key_id
|
||||
secret_key:
|
||||
from_secret: aws_secret_access_key
|
||||
flush: true
|
||||
flush_age: 14
|
||||
debug: true
|
||||
filename: frontend-dev.tar
|
||||
when:
|
||||
status: [ success, failure ]
|
||||
|
||||
trigger:
|
||||
event: [ push, pull_request, promote ]
|
||||
# refs: [ refs/heads/release/*, refs/heads/master, refs/heads/feature/*, refs/heads/hotfix/* ]
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
@@ -26,6 +160,33 @@ type: docker
|
||||
name: backend
|
||||
|
||||
steps:
|
||||
- name: Restore cache
|
||||
image: plugins/s3-cache
|
||||
settings:
|
||||
pull: true
|
||||
endpoint: https://storage.humenius.me
|
||||
access_key:
|
||||
from_secret: aws_access_key_id
|
||||
secret_key:
|
||||
from_secret: aws_secret_access_key
|
||||
restore: true
|
||||
debug: true
|
||||
filename: backend.tar
|
||||
workdir: backend/
|
||||
|
||||
- name: Install packages
|
||||
image: node
|
||||
commands:
|
||||
- cd backend
|
||||
- npm install
|
||||
|
||||
- name: Run unit tests
|
||||
image: node
|
||||
commands:
|
||||
- cd backend
|
||||
- ls -la
|
||||
- npm run test:cov
|
||||
|
||||
- name: Build and push backend image
|
||||
image: plugins/docker
|
||||
settings:
|
||||
@@ -34,11 +195,128 @@ steps:
|
||||
password:
|
||||
from_secret: docker_password
|
||||
dockerfile: backend/Dockerfile
|
||||
use_cache: true
|
||||
context: backend/
|
||||
repo: docker.humenius.me/humenius/ts-onlinetime-ranks-backend
|
||||
registry: docker.humenius.me
|
||||
tags: ["latest", "${DRONE_SEMVER}"]
|
||||
|
||||
- name: Rebuild cache
|
||||
image: plugins/s3-cache
|
||||
settings:
|
||||
pull: true
|
||||
endpoint: https://storage.humenius.me
|
||||
access_key:
|
||||
from_secret: aws_access_key_id
|
||||
secret_key:
|
||||
from_secret: aws_secret_access_key
|
||||
rebuild: true
|
||||
debug: true
|
||||
filename: backend.tar
|
||||
mount:
|
||||
- backend/node_modules
|
||||
when:
|
||||
status: [ success, failure ]
|
||||
|
||||
- name: Flush cache
|
||||
image: plugins/s3-cache:1
|
||||
settings:
|
||||
pull: true
|
||||
endpoint: https://storage.humenius.me
|
||||
access_key:
|
||||
from_secret: aws_access_key_id
|
||||
secret_key:
|
||||
from_secret: aws_secret_access_key
|
||||
flush: true
|
||||
flush_age: 14
|
||||
debug: true
|
||||
filename: backend.tar
|
||||
when:
|
||||
status: [ success, failure ]
|
||||
|
||||
trigger:
|
||||
ref:
|
||||
- refs/tags/* # only trigger when tagging
|
||||
event: [ tag, promote ]
|
||||
refs: [ refs/heads/release/*, refs/tags/* ]
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: backend-dev
|
||||
|
||||
steps:
|
||||
- name: Restore cache
|
||||
image: plugins/s3-cache
|
||||
settings:
|
||||
pull: true
|
||||
endpoint: https://storage.humenius.me
|
||||
access_key:
|
||||
from_secret: aws_access_key_id
|
||||
secret_key:
|
||||
from_secret: aws_secret_access_key
|
||||
restore: true
|
||||
debug: true
|
||||
filename: backend-dev.tar
|
||||
workdir: backend/
|
||||
|
||||
- name: Install packages
|
||||
image: node
|
||||
commands:
|
||||
- cd backend
|
||||
- npm install
|
||||
|
||||
- name: Run unit tests
|
||||
image: node
|
||||
commands:
|
||||
- cd backend
|
||||
- npm run test:cov
|
||||
|
||||
- name: Build and push backend dev image
|
||||
image: plugins/docker
|
||||
settings:
|
||||
username:
|
||||
from_secret: docker_username
|
||||
password:
|
||||
from_secret: docker_password
|
||||
dockerfile: backend/Dockerfile
|
||||
context: backend/
|
||||
use_cache: true
|
||||
repo: docker.humenius.me/humenius/ts-onlinetime-ranks-backend
|
||||
registry: docker.humenius.me
|
||||
tags: ["dev-${DRONE_COMMIT_SHA}", "dev-latest"]
|
||||
|
||||
- name: Rebuild cache
|
||||
image: plugins/s3-cache
|
||||
settings:
|
||||
pull: true
|
||||
endpoint: https://storage.humenius.me
|
||||
access_key:
|
||||
from_secret: aws_access_key_id
|
||||
secret_key:
|
||||
from_secret: aws_secret_access_key
|
||||
rebuild: true
|
||||
debug: true
|
||||
filename: backend-dev.tar
|
||||
mount:
|
||||
- backend/node_modules
|
||||
when:
|
||||
status: [ success, failure ]
|
||||
|
||||
- name: Flush cache
|
||||
image: plugins/s3-cache:1
|
||||
settings:
|
||||
pull: true
|
||||
endpoint: https://storage.humenius.me
|
||||
access_key:
|
||||
from_secret: aws_access_key_id
|
||||
secret_key:
|
||||
from_secret: aws_secret_access_key
|
||||
flush: true
|
||||
flush_age: 14
|
||||
debug: true
|
||||
filename: backend-dev.tar
|
||||
when:
|
||||
status: [ success, failure ]
|
||||
|
||||
trigger:
|
||||
event: [ push, pull_request, promote ]
|
||||
# refs: [ refs/heads/master, refs/heads/feature/*, refs/heads/hotfix/* ]
|
||||
|
||||
2
backend/.dockerignore
Normal file
2
backend/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
@@ -1,15 +1,37 @@
|
||||
FROM node:14.3.0-alpine AS builder
|
||||
# FROM node:14.3.0-alpine AS builder
|
||||
# WORKDIR /app
|
||||
# COPY package.json .
|
||||
# COPY package-lock.json .
|
||||
# RUN npm install
|
||||
# COPY . .
|
||||
# RUN npm run build
|
||||
|
||||
# FROM node:14.3.0-alpine
|
||||
# WORKDIR /app
|
||||
# COPY --from=builder /app/dist .
|
||||
|
||||
# # ENV DATABASE_URL
|
||||
|
||||
# EXPOSE 3500
|
||||
# CMD ["node", "main.js"]
|
||||
|
||||
FROM node:14.3.0-alpine as builder
|
||||
WORKDIR /app
|
||||
COPY package.json .
|
||||
COPY package-lock.json .
|
||||
RUN npm install
|
||||
|
||||
ENV DATABASE_URL="mysql://dummy:user@localhost:1234/db"
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm install
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
|
||||
|
||||
FROM node:14.3.0-alpine
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app .
|
||||
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package*.json ./
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
EXPOSE 3500
|
||||
CMD ["npm", "run", "start:prod"]
|
||||
|
||||
CMD [ "npm", "run", "start:prod" ]
|
||||
|
||||
3434
backend/package-lock.json
generated
3434
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ts-onlinetime-ranks-backend",
|
||||
"version": "0.0.2",
|
||||
"version": "0.1.0",
|
||||
"description": "Backend microservice for TeamSpeak 3 Online Time Ranking",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
@@ -20,19 +20,21 @@
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^7.0.0",
|
||||
"@nestjs/core": "^7.0.0",
|
||||
"@nestjs/platform-express": "^7.0.0",
|
||||
"node-fetch": "^2.6.0",
|
||||
"@nestjs/common": "^7.6.5",
|
||||
"@nestjs/core": "^7.6.5",
|
||||
"@nestjs/platform-express": "^7.6.5",
|
||||
"@prisma/client": "^2.14.0",
|
||||
"node-fetch": "^2.6.1",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^3.0.2",
|
||||
"rxjs": "^6.5.4",
|
||||
"winston": "^3.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^7.0.0",
|
||||
"@nestjs/schematics": "^7.0.0",
|
||||
"@nestjs/testing": "^7.0.0",
|
||||
"@nestjs/cli": "^7.5.4",
|
||||
"@nestjs/schematics": "^7.2.6",
|
||||
"@nestjs/testing": "^7.6.5",
|
||||
"@prisma/cli": "^2.14.0",
|
||||
"@types/express": "^4.17.3",
|
||||
"@types/jest": "25.1.4",
|
||||
"@types/node": "^13.9.1",
|
||||
@@ -43,6 +45,7 @@
|
||||
"eslint-config-prettier": "^6.10.0",
|
||||
"eslint-plugin-import": "^2.20.1",
|
||||
"jest": "^25.1.0",
|
||||
"jest-mock-extended": "^1.0.10",
|
||||
"prettier": "^1.19.1",
|
||||
"supertest": "^4.0.2",
|
||||
"ts-jest": "^26.1.1",
|
||||
|
||||
48
backend/prisma/schema.prisma
Normal file
48
backend/prisma/schema.prisma
Normal file
@@ -0,0 +1,48 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "mysql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model ranks {
|
||||
entry_id Int @id @default(autoincrement())
|
||||
season_id Int
|
||||
rank_id Int @unique
|
||||
rank_name String
|
||||
seasons seasons @relation(fields: [season_id], references: [season_id])
|
||||
timetracker timetracker[]
|
||||
|
||||
@@unique([season_id, rank_id], name: "ranks_season_id_rank_id_uindex")
|
||||
}
|
||||
|
||||
model seasons {
|
||||
season_id Int @id @default(autoincrement())
|
||||
start_date DateTime @default(now())
|
||||
end_date DateTime?
|
||||
ranks ranks[]
|
||||
timetracker timetracker[]
|
||||
}
|
||||
|
||||
model timetracker {
|
||||
entry_id Int @id @default(autoincrement())
|
||||
user_uid String
|
||||
season_id Int
|
||||
rank_id Int?
|
||||
time Int
|
||||
ranks ranks? @relation(fields: [rank_id], references: [rank_id])
|
||||
seasons seasons @relation(fields: [season_id], references: [season_id])
|
||||
user user @relation(fields: [user_uid], references: [uid])
|
||||
|
||||
@@unique([user_uid, season_id], name: "timetracker_user_uid_season_id_uindex")
|
||||
@@index([rank_id], name: "timetracker_ranks_rank_id_fk")
|
||||
@@index([season_id], name: "timetracker_seasons_season_id_fk")
|
||||
}
|
||||
|
||||
model user {
|
||||
uid String @id
|
||||
name String?
|
||||
timetracker timetracker[]
|
||||
}
|
||||
3
backend/src/api/predicate.ts
Normal file
3
backend/src/api/predicate.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export interface Predicate<T, O> {
|
||||
process(input: T): O;
|
||||
}
|
||||
13
backend/src/api/sort.ts
Normal file
13
backend/src/api/sort.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export class Sort {
|
||||
public static descending = (a: number, b: number) => {
|
||||
if (a < b) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (a > b) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
}
|
||||
25
backend/src/api/timeutil.spec.ts
Normal file
25
backend/src/api/timeutil.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { TimeUtil } from './timeutil';
|
||||
|
||||
describe('TimeUtil', () => {
|
||||
it('should humanize raw seconds', () => {
|
||||
// const rawTime = 12345;
|
||||
// const expected = "0d 3h 25m 45s";
|
||||
const input = {
|
||||
rawTime: [
|
||||
12345,
|
||||
123,
|
||||
4
|
||||
],
|
||||
expected: [
|
||||
"0d 3h 25m 45s",
|
||||
"0d 0h 2m 3s",
|
||||
"0d 0h 0m 4s"
|
||||
]
|
||||
};
|
||||
|
||||
for (let i = 0; i < input.rawTime.length; i++) {
|
||||
const actual = TimeUtil.humanize(input.rawTime[i]);
|
||||
expect(actual).toEqual(input.expected[i]);
|
||||
}
|
||||
});
|
||||
});
|
||||
10
backend/src/api/timeutil.ts
Normal file
10
backend/src/api/timeutil.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export class TimeUtil {
|
||||
public static humanize = (seconds: number) => {
|
||||
const d = Math.floor(seconds / (3600 * 24));
|
||||
const h = Math.floor((seconds % (3600 * 24)) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
|
||||
return `${d}d ${h}h ${m}m ${s}s`;
|
||||
};
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
describe('AppController', () => {
|
||||
let appController: AppController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
|
||||
appController = app.get<AppController>(AppController);
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(appController.getHello()).toBe('Hello World!');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,25 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HostParam,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
import { TableEntry } from "./models/TableEntry";
|
||||
import { SinusBotService } from "./services/sinusbot.service";
|
||||
import { DatabaseService } from './database/database.service';
|
||||
import logger from './logger/Logger';
|
||||
import { TableEntry } from './models/TableEntry';
|
||||
import { SinusBotService } from './services/sinusbot.service';
|
||||
import { UserStatsResponse } from './models/aliases';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(
|
||||
private readonly appService: AppService,
|
||||
private readonly sinusBotService: SinusBotService
|
||||
private readonly sinusBotService: SinusBotService,
|
||||
private readonly databaseService: DatabaseService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@@ -15,8 +27,21 @@ export class AppController {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
|
||||
@Get('/stats')
|
||||
async getStats(): Promise<TableEntry[]> {
|
||||
@Get('/stats-old')
|
||||
async getStatsOld(): Promise<TableEntry[]> {
|
||||
return await this.sinusBotService.fetchStats();
|
||||
}
|
||||
|
||||
@Get('/stats/season/:id?')
|
||||
async getStats(@Param('id') id?: string): Promise<UserStatsResponse> {
|
||||
return await this.databaseService
|
||||
.fetchStats(id)
|
||||
.catch(err => {
|
||||
logger.error(`Error occured when fetching stats.`, err);
|
||||
throw new HttpException(
|
||||
'Error when fetching stats. Contact administrator or try again later!',
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { SinusBotService } from "./services/sinusbot.service";
|
||||
import { SinusBotService } from './services/sinusbot.service';
|
||||
import { DatabaseService } from './database/database.service';
|
||||
import { LoggerMiddleware } from './logger.middleware';
|
||||
import { PrismaService } from './prisma/prisma.service';
|
||||
import { TimeTrackerPredicate } from './database/timetracking.predicate';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
controllers: [AppController],
|
||||
providers: [AppService, SinusBotService],
|
||||
providers: [
|
||||
AppService,
|
||||
SinusBotService,
|
||||
DatabaseService,
|
||||
PrismaService,
|
||||
TimeTrackerPredicate,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer): any {
|
||||
consumer.apply(LoggerMiddleware).forRoutes('*');
|
||||
}
|
||||
}
|
||||
|
||||
69
backend/src/database/database.service.spec.ts
Normal file
69
backend/src/database/database.service.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { seasons } from '@prisma/client';
|
||||
import { DatabaseService } from './database.service';
|
||||
import { mockReset, MockProxy, mockDeep } from 'jest-mock-extended';
|
||||
import { SeasonInfo } from '../models/aliases';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { TimeTrackerPredicate } from './timetracking.predicate';
|
||||
import { mocked } from 'ts-jest';
|
||||
|
||||
describe('DatabaseService', () => {
|
||||
let service: DatabaseService;
|
||||
const mockedPrismaService: MockProxy<PrismaService> = mockDeep<PrismaService>();
|
||||
|
||||
beforeEach(async () => {
|
||||
mockReset(mockedPrismaService);
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [DatabaseService, PrismaService, TimeTrackerPredicate],
|
||||
})
|
||||
.overrideProvider(PrismaService)
|
||||
.useValue(mockedPrismaService)
|
||||
.compile();
|
||||
|
||||
service = module.get<DatabaseService>(DatabaseService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
// TODO This will be skipped as Prisma__#client can barely be mocked.
|
||||
// it('fetch season info with season ID given', async () => {
|
||||
// const seasonIdToBeTested = 1;
|
||||
// const mockedValue: seasons = {
|
||||
// season_id: 2,
|
||||
// start_date: new Date('1995-12-17T03:24:00'),
|
||||
// end_date: null
|
||||
// };
|
||||
// const mockedMaxSeasonId = {
|
||||
// max: {
|
||||
// season_id: 2
|
||||
// }
|
||||
// };
|
||||
//
|
||||
// mockedPrismaService.seasons.findUnique.calledWith({
|
||||
// where: {
|
||||
// season_id: Number(seasonIdToBeTested)
|
||||
// }
|
||||
// }).mockImplementation((subset) => {
|
||||
// return new Promise(resolve => resolve(mockedValue));
|
||||
// });
|
||||
//
|
||||
// mockedPrismaService.seasons.aggregate.calledWith({
|
||||
// max: {
|
||||
// season_id: true
|
||||
// }
|
||||
// }).mockImplementation(() => Promise.resolve(mockedMaxSeasonId));
|
||||
//
|
||||
// const expected: SeasonInfo = {
|
||||
// season_id: 2,
|
||||
// maxSeasonId: 2,
|
||||
// start_date: new Date('1995-12-17T03:24:00'),
|
||||
// end_date: null
|
||||
// }
|
||||
// const actual = service.fetchSeasonInfos();
|
||||
//
|
||||
// await expect(actual).resolves.toEqual(expected);
|
||||
// });
|
||||
});
|
||||
87
backend/src/database/database.service.ts
Normal file
87
backend/src/database/database.service.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { TimeTrackerPredicate } from './timetracking.predicate';
|
||||
import {
|
||||
SeasonInfo,
|
||||
TimeTrackerStats,
|
||||
UserStatsResponse,
|
||||
} from '../models/aliases';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { seasons } from '@prisma/client';
|
||||
import logger from 'src/logger/Logger';
|
||||
|
||||
@Injectable()
|
||||
export class DatabaseService {
|
||||
|
||||
constructor(
|
||||
private readonly prismaClient: PrismaService,
|
||||
private readonly timetrackerPredicate: TimeTrackerPredicate,
|
||||
) {}
|
||||
|
||||
public fetchSeasonInfos = async (seasonId?: string): Promise<SeasonInfo> => {
|
||||
let maxSeasonId: number;
|
||||
|
||||
return this.fetchMaxSeasonId().then(value => {
|
||||
maxSeasonId = value;
|
||||
return value;
|
||||
})
|
||||
.then(value => this.prismaClient.seasons.findUnique({
|
||||
where: {
|
||||
season_id: Number(seasonId ?? value)
|
||||
}
|
||||
}) as Promise<seasons>)
|
||||
.then((result: SeasonInfo) => {
|
||||
result.maxSeasonId = maxSeasonId;
|
||||
return result;
|
||||
});
|
||||
};
|
||||
|
||||
public fetchStats = async (seasonId?: string): Promise<UserStatsResponse> => {
|
||||
let response;
|
||||
return this.fetchSeasonInfos(seasonId)
|
||||
.then(result => {
|
||||
response = {
|
||||
seasonId: result.season_id,
|
||||
maxSeasonId: result.maxSeasonId,
|
||||
dates: {
|
||||
start: result.start_date,
|
||||
end: result.end_date,
|
||||
},
|
||||
};
|
||||
return result.season_id.toString();
|
||||
})
|
||||
.then(this.fetchTimeTrackerStats)
|
||||
.then(this.timetrackerPredicate.process)
|
||||
.then(result => {
|
||||
response.stats = result;
|
||||
return response;
|
||||
});
|
||||
};
|
||||
|
||||
fetchTimeTrackerStats: (string) => Promise<TimeTrackerStats[]> = (
|
||||
seasonId: string,
|
||||
) =>
|
||||
this.prismaClient.timetracker.findMany({
|
||||
include: {
|
||||
user: true,
|
||||
ranks: true,
|
||||
},
|
||||
where: {
|
||||
// eslint-disable-next-line @typescript-eslint/camelcase
|
||||
season_id: Number(seasonId),
|
||||
},
|
||||
});
|
||||
|
||||
fetchMaxSeasonId: () => Promise<number> = () =>
|
||||
new Promise((resolve, reject) => {
|
||||
this.prismaClient.seasons
|
||||
.aggregate({
|
||||
max: {
|
||||
// eslint-disable-next-line @typescript-eslint/camelcase
|
||||
season_id: true,
|
||||
},
|
||||
})
|
||||
.then(result => result.max.season_id)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
71
backend/src/database/timetracking.predicate.spec.ts
Normal file
71
backend/src/database/timetracking.predicate.spec.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { TimeTrackerPredicate } from './timetracking.predicate';
|
||||
import { TimeTrackerStats } from '../models/aliases';
|
||||
|
||||
describe('TimeTrackerPredicate', () => {
|
||||
let timeTrackerPredicate: TimeTrackerPredicate;
|
||||
|
||||
beforeEach(() => {
|
||||
timeTrackerPredicate = new TimeTrackerPredicate();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(timeTrackerPredicate).toBeDefined();
|
||||
});
|
||||
|
||||
it('should process UserStats properly to TableEntry', () => {
|
||||
const input: TimeTrackerStats[] = [
|
||||
{
|
||||
entry_id: 1,
|
||||
user_uid: 'TEST_UUID1',
|
||||
season_id: 1,
|
||||
rank_id: 1,
|
||||
time: 12345,
|
||||
user: {
|
||||
uid: 'TEST_UUID1',
|
||||
name: 'Test User 1'
|
||||
},
|
||||
ranks: {
|
||||
entry_id: 1,
|
||||
season_id: 1,
|
||||
rank_id: 1,
|
||||
rank_name: 'Test Rank 1'
|
||||
}
|
||||
},
|
||||
{
|
||||
entry_id: 2,
|
||||
user_uid: 'TEST_UUID2',
|
||||
season_id: 1,
|
||||
rank_id: 2,
|
||||
time: 123455,
|
||||
user: {
|
||||
uid: 'TEST_UUID2',
|
||||
name: 'Test User 2'
|
||||
},
|
||||
ranks: {
|
||||
entry_id: 1,
|
||||
season_id: 1,
|
||||
rank_id: 2,
|
||||
rank_name: 'Test Rank 2'
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
const expected = [
|
||||
{
|
||||
name: 'Test User 2',
|
||||
onlineTime: "1d 10h 17m 35s",
|
||||
rank: 'Test Rank 2',
|
||||
rawTime: 123455
|
||||
},
|
||||
{
|
||||
name: 'Test User 1',
|
||||
onlineTime: "0d 3h 25m 45s",
|
||||
rank: 'Test Rank 1',
|
||||
rawTime: 12345
|
||||
}
|
||||
];
|
||||
|
||||
const actual = timeTrackerPredicate.process(input);
|
||||
expect(actual).toEqual(expected);
|
||||
})
|
||||
});
|
||||
24
backend/src/database/timetracking.predicate.ts
Normal file
24
backend/src/database/timetracking.predicate.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { TimeTrackerStats } from '../models/aliases';
|
||||
import { Predicate } from '../api/predicate';
|
||||
import { TableEntry } from '../models/TableEntry';
|
||||
import { TimeUtil } from '../api/timeutil';
|
||||
import { Sort } from '../api/sort';
|
||||
|
||||
@Injectable()
|
||||
export class TimeTrackerPredicate
|
||||
implements Predicate<TimeTrackerStats[], TableEntry[]> {
|
||||
process(input: TimeTrackerStats[]): TableEntry[] {
|
||||
return input
|
||||
.filter((userStats: TimeTrackerStats) => userStats.time != null)
|
||||
.map((userStats: TimeTrackerStats) => {
|
||||
return {
|
||||
name: userStats.user.name,
|
||||
rawTime: userStats.time,
|
||||
onlineTime: TimeUtil.humanize(userStats.time),
|
||||
rank: userStats.ranks.rank_name,
|
||||
};
|
||||
})
|
||||
.sort((lhs, rhs) => Sort.descending(lhs.rawTime, rhs.rawTime));
|
||||
}
|
||||
}
|
||||
22
backend/src/logger.middleware.ts
Normal file
22
backend/src/logger.middleware.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import logger from 'src/logger/Logger';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
@Injectable()
|
||||
export class LoggerMiddleware implements NestMiddleware {
|
||||
use(request: Request, response: Response, next: NextFunction): void {
|
||||
const { ip, method, originalUrl: url } = request;
|
||||
const userAgent = request.get('user-agent') || '';
|
||||
|
||||
response.on('finish', () => {
|
||||
const { statusCode } = response;
|
||||
const contentLength = response.get('content-length');
|
||||
|
||||
logger.info(
|
||||
`${method} ${url} ${statusCode} ${contentLength} - ${userAgent} ${ip}`,
|
||||
);
|
||||
});
|
||||
|
||||
next();
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import * as winston from 'winston';
|
||||
|
||||
const transports = {
|
||||
console: new winston.transports.Console()
|
||||
console: new winston.transports.Console(),
|
||||
};
|
||||
|
||||
const logger = winston.createLogger({
|
||||
transports: [transports.console]
|
||||
})
|
||||
transports: [transports.console],
|
||||
});
|
||||
|
||||
export default logger;
|
||||
|
||||
@@ -4,7 +4,7 @@ interface RequestError extends Error {
|
||||
}
|
||||
|
||||
interface RequestErrorConstructor extends ErrorConstructor {
|
||||
new(message?: string): RequestError;
|
||||
new (message?: string): RequestError;
|
||||
(message?: string): RequestError;
|
||||
readonly prototype: RequestError;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export interface TableEntry {
|
||||
name: string;
|
||||
onlineTime: string;
|
||||
name: string;
|
||||
rank: string;
|
||||
onlineTime: string;
|
||||
}
|
||||
|
||||
6
backend/src/models/aliases.ts
Normal file
6
backend/src/models/aliases.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { ranks, seasons, timetracker, user } from '@prisma/client';
|
||||
import { TableEntry } from './TableEntry';
|
||||
|
||||
export type TimeTrackerStats = timetracker & { user: user; ranks: ranks };
|
||||
export type SeasonInfo = seasons & { maxSeasonId: number };
|
||||
export type UserStatsResponse = SeasonInfo & TableEntry[];
|
||||
14
backend/src/prisma/prisma.service.ts
Normal file
14
backend/src/prisma/prisma.service.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy {
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,22 @@
|
||||
import { TableEntry } from "../models/TableEntry";
|
||||
import { TunakillUser } from "../models/TunakillUser";
|
||||
import {HttpException, HttpStatus, Injectable} from "@nestjs/common";
|
||||
import {TunakillLogin} from "../models/TunakillLogin";
|
||||
import { TableEntry } from '../models/TableEntry';
|
||||
import { TunakillUser } from '../models/TunakillUser';
|
||||
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { TunakillLogin } from '../models/TunakillLogin';
|
||||
import fetch from 'node-fetch';
|
||||
import logger from "../logger/Logger";
|
||||
import logger from '../logger/Logger';
|
||||
|
||||
@Injectable()
|
||||
export class SinusBotService {
|
||||
private host = process.env.HOST
|
||||
private host = process.env.HOST;
|
||||
private credentials = {
|
||||
username: process.env.SINUSBOT_USER,
|
||||
password: process.env.SINUSBOT_PASSWORD
|
||||
}
|
||||
password: process.env.SINUSBOT_PASSWORD,
|
||||
};
|
||||
|
||||
private botInfo = {
|
||||
id: undefined,
|
||||
instanceId: process.env.SINUSBOT_INSTANCEID
|
||||
}
|
||||
instanceId: process.env.SINUSBOT_INSTANCEID,
|
||||
};
|
||||
|
||||
private botIdURL = `${this.host}/api/v1/botId`;
|
||||
private tunaKillURL = `${this.host}/api/v1/bot/i/${this.botInfo.instanceId}/event/tunakill_rank_all_user`;
|
||||
@@ -34,23 +34,30 @@ export class SinusBotService {
|
||||
// if (this.bearer == null) {
|
||||
logger.info(`Hey! I'm trying to get my Bearer token!`);
|
||||
await this.login()
|
||||
.then(token => this.bearer = token)
|
||||
.then(token => (this.bearer = token))
|
||||
.then(() => {
|
||||
logger.debug(
|
||||
`Seems like I have my Bearer token!
|
||||
Looks like it's ${this.bearer == null ? 'undefined' : 'not undefined'}`
|
||||
Looks like it's ${
|
||||
this.bearer == null ? 'undefined' : 'not undefined'
|
||||
}`,
|
||||
);
|
||||
});
|
||||
// }
|
||||
|
||||
logger.info(`I try to fetch user data now! The URL is called ${this.tunaKillURL}`);
|
||||
logger.info(
|
||||
`I try to fetch user data now! The URL is called ${this.tunaKillURL}`,
|
||||
);
|
||||
return await fetch(this.tunaKillURL, this.requestConfig(null, this.bearer))
|
||||
.then(res => this.checkStatus(res))
|
||||
.then(res => res.json())
|
||||
.then(data => this.consumeTunakillResponse(data))
|
||||
.catch(error => {
|
||||
logger.error(`I couldn't fetch user data.`, error);
|
||||
throw this.createHttpException(HttpStatus.INTERNAL_SERVER_ERROR, error.message);
|
||||
throw this.createHttpException(
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
error.message,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -61,12 +68,15 @@ export class SinusBotService {
|
||||
if (this.botInfo.id == null) {
|
||||
logger.debug(`I have to fetch a bot ID before I can continue!`);
|
||||
await this.fetchDefaultBotId()
|
||||
.then(defaultBotId => this.botInfo.id = defaultBotId)
|
||||
.then(defaultBotId => (this.botInfo.id = defaultBotId))
|
||||
.catch(error => {
|
||||
logger.warn(`I couldn't retrieve SinusBot bot information. Login is likely to fail!`, error);
|
||||
logger.warn(
|
||||
`I couldn't retrieve SinusBot bot information. Login is likely to fail!`,
|
||||
error,
|
||||
);
|
||||
throw this.createHttpException(
|
||||
HttpStatus.NOT_FOUND,
|
||||
`Could not fetch enough bot information for further requests.`
|
||||
`Could not fetch enough bot information for further requests.`,
|
||||
);
|
||||
});
|
||||
logger.info(`The bot ID now is ${this.botInfo.id}`);
|
||||
@@ -75,20 +85,23 @@ export class SinusBotService {
|
||||
const body: TunakillLogin = {
|
||||
username: this.credentials.username,
|
||||
password: this.credentials.password,
|
||||
botId: this.botInfo.id
|
||||
}
|
||||
botId: this.botInfo.id,
|
||||
};
|
||||
|
||||
logger.info(`Logging in for Bearer token!`)
|
||||
logger.info(`Logging in for Bearer token!`);
|
||||
return await fetch(this.loginURL, this.requestConfig(JSON.stringify(body)))
|
||||
.then(res => this.checkStatus(res))
|
||||
.then(res => res.json())
|
||||
.then(data => data.token)
|
||||
.catch(error => {
|
||||
logger.error(`Oh oh! Something went wrong while fetching Bearer token.`, error);
|
||||
logger.error(
|
||||
`Oh oh! Something went wrong while fetching Bearer token.`,
|
||||
error,
|
||||
);
|
||||
throw this.createHttpException(
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
`Fetching Bearer token for Sinusbot failed.
|
||||
Please refresh page or try again later!`
|
||||
Please refresh page or try again later!`,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -110,39 +123,56 @@ export class SinusBotService {
|
||||
throw Error('Response from SinusBot does not have any data to parse.');
|
||||
}
|
||||
|
||||
return response
|
||||
// TODO: Remove hardcoded username filter for bots.
|
||||
.filter((user: TunakillUser) => user.name !== "Server Query Admin" && user.name !== "DJ Inshalla")
|
||||
return (
|
||||
response
|
||||
// TODO: Remove hardcoded username filter for bots.
|
||||
.filter(
|
||||
(user: TunakillUser) =>
|
||||
user.name !== 'Server Query Admin' && user.name !== 'DJ Inshalla',
|
||||
)
|
||||
|
||||
.filter((user: TunakillUser) => user.time != null)
|
||||
.map((user: TunakillUser) => {
|
||||
return {
|
||||
name: user.name,
|
||||
rawTime: user.time,
|
||||
onlineTime: this.humanizeTime(user.time)
|
||||
};
|
||||
})
|
||||
.sort((a: any, b: any) => this.sortByDescendingTime(a.rawTime, b.rawTime));
|
||||
.filter((user: TunakillUser) => user.time != null)
|
||||
.map((user: TunakillUser) => {
|
||||
return {
|
||||
name: user.name,
|
||||
rawTime: user.time,
|
||||
onlineTime: this.humanizeTime(user.time),
|
||||
};
|
||||
})
|
||||
.sort((a: any, b: any) =>
|
||||
this.sortByDescendingTime(a.rawTime, b.rawTime),
|
||||
)
|
||||
);
|
||||
}
|
||||
private requestConfig = (body?: string, bearerToken?: string, requestType: string = 'POST'): RequestInit => {
|
||||
private requestConfig = (
|
||||
body?: string,
|
||||
bearerToken?: string,
|
||||
requestType: string = 'POST',
|
||||
): RequestInit => {
|
||||
return {
|
||||
method: requestType,
|
||||
body: body,
|
||||
headers: {
|
||||
'Accept': '*/*',
|
||||
Accept: '*/*',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'HumeniusTSRankingBackend/0.0.2',
|
||||
'Authorization': `Bearer ${bearerToken}`
|
||||
}
|
||||
Authorization: `Bearer ${bearerToken}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
private createHttpException = (statusCode: HttpStatus, message?: string): HttpException => {
|
||||
return new HttpException({
|
||||
status: statusCode,
|
||||
error: message
|
||||
}, statusCode);
|
||||
}
|
||||
private createHttpException = (
|
||||
statusCode: HttpStatus,
|
||||
message?: string,
|
||||
): HttpException => {
|
||||
return new HttpException(
|
||||
{
|
||||
status: statusCode,
|
||||
error: message,
|
||||
},
|
||||
statusCode,
|
||||
);
|
||||
};
|
||||
|
||||
private checkStatus = response => {
|
||||
if (!response.ok) {
|
||||
@@ -151,7 +181,7 @@ export class SinusBotService {
|
||||
throw err;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
};
|
||||
|
||||
private sortByDescendingTime = (a: number, b: number) => {
|
||||
if (a < b) {
|
||||
@@ -163,16 +193,16 @@ export class SinusBotService {
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
private humanizeTime = (seconds: number) => {
|
||||
const d = Math.floor(seconds / (3600 * 24));
|
||||
const h = Math.floor(seconds % (3600 * 24) / 3600);
|
||||
const m = Math.floor(seconds % 3600 / 60);
|
||||
const h = Math.floor((seconds % (3600 * 24)) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
|
||||
return `${d}d ${h}h ${m}m ${s}s`;
|
||||
}
|
||||
};
|
||||
|
||||
private static logResponse(res: any) {
|
||||
logger.debug(res);
|
||||
|
||||
@@ -4,41 +4,171 @@ services:
|
||||
frontend:
|
||||
image: docker.humenius.me/humenius/ts-onlinetime-ranks-frontend:latest
|
||||
networks:
|
||||
- proxy
|
||||
- traefik-proxy
|
||||
env_file: .env
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.services.tsotr-frontend.loadbalancer.server.port=5000
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.50'
|
||||
memory: 1024M
|
||||
reservations:
|
||||
cpus: '0.25'
|
||||
memory: 512M
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
|
||||
# HTTP + Redirect
|
||||
- traefik.http.routers.tsotr-frontend.entrypoints=web
|
||||
- traefik.http.routers.tsotr-frontend.rule=Host(`tsotr.humenius.me`)
|
||||
- traefik.http.routers.tsotr-frontend.middlewares=redirect@file
|
||||
traefik.docker.network: traefik-proxy
|
||||
traefik.constraint-label: traefik-proxy
|
||||
|
||||
# HTTPS
|
||||
- traefik.http.routers.tsotr-frontend-secure.entrypoints=web-secure
|
||||
- traefik.http.routers.tsotr-frontend-secure.rule=Host(`tsotr.humenius.me`)
|
||||
- traefik.http.routers.tsotr-frontend-secure.tls.certresolver=letsencrypt
|
||||
# Watchtower update
|
||||
com.centurylinklabs.watchtower.enable: "true"
|
||||
|
||||
# HTTPS
|
||||
traefik.http.routers.tsotr-frontend-secure.entrypoints: web-secure
|
||||
traefik.http.routers.tsotr-frontend-secure.rule: Host(`tsotr.humenius.me`)
|
||||
traefik.http.routers.tsotr-frontend-secure.tls.certresolver: letsencrypt
|
||||
traefik.http.services.tsotr-frontend.loadbalancer.server.port: 5000
|
||||
|
||||
backend:
|
||||
image: docker.humenius.me/humenius/ts-onlinetime-ranks-backend:latest
|
||||
networks:
|
||||
- proxy
|
||||
- traefik-proxy
|
||||
- ts-onlinetime-ranks
|
||||
env_file: .env
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.50'
|
||||
memory: 1024M
|
||||
reservations:
|
||||
cpus: '0.25'
|
||||
memory: 512M
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
|
||||
traefik.docker.network: traefik-proxy
|
||||
traefik.constraint-label: traefik-proxy
|
||||
|
||||
# Watchtower update
|
||||
com.centurylinklabs.watchtower.enable: "true"
|
||||
|
||||
# HTTPS
|
||||
traefik.http.routers.tsotr-backend-secure.entrypoints: web-secure
|
||||
traefik.http.routers.tsotr-backend-secure.rule: Host(`api.tsotr.humenius.me`)
|
||||
traefik.http.routers.tsotr-backend-secure.tls.certresolver: letsencrypt
|
||||
traefik.http.services.tsotr-backend.loadbalancer.server.port: 3500
|
||||
|
||||
frontend-dev:
|
||||
image: docker.humenius.me/humenius/ts-onlinetime-ranks-frontend:dev-latest
|
||||
networks:
|
||||
- proxy
|
||||
env_file: .env_beta
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.50'
|
||||
memory: 1024M
|
||||
reservations:
|
||||
cpus: '0.25'
|
||||
memory: 512M
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
|
||||
traefik.docker.network: traefik-proxy
|
||||
traefik.constraint-label: traefik-proxy
|
||||
|
||||
# Watchtower update
|
||||
com.centurylinklabs.watchtower.enable: "true"
|
||||
|
||||
# HTTPS
|
||||
traefik.http.routers.tsotr-backend-secure.entrypoints: web-secure
|
||||
traefik.http.routers.tsotr-backend-secure.rule: Host(`beta.tsotr.humenius.me`)
|
||||
traefik.http.routers.tsotr-backend-secure.tls.certresolver: letsencrypt
|
||||
traefik.http.services.tsotr-backend.loadbalancer.server.port: 5000
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.services.tsotr-backend.loadbalancer.server.port=3000
|
||||
- traefik.http.services.tsotr-frontend.loadbalancer.server.port=5000
|
||||
|
||||
# HTTP + Redirect
|
||||
- traefik.http.routers.tsotr-backend.entrypoints=web
|
||||
- traefik.http.routers.tsotr-backend.rule=Host(`api.tsotr.humenius.me`)
|
||||
- traefik.http.routers.tsotr-backend.middlewares=redirect@file
|
||||
# Watchtower update
|
||||
- com.centurylinklabs.watchtower.enable=true
|
||||
|
||||
# HTTPS
|
||||
- traefik.http.routers.tsotr-frontend-secure.entrypoints=web-secure
|
||||
- traefik.http.routers.tsotr-frontend-secure.rule=Host(`beta.tsotr.humenius.me`)
|
||||
- traefik.http.routers.tsotr-frontend-secure.tls.certresolver=letsencrypt
|
||||
|
||||
backend-dev:
|
||||
image: docker.humenius.me/humenius/ts-onlinetime-ranks-backend:dev-latest
|
||||
networks:
|
||||
- proxy
|
||||
- ts-onlinetime-ranks
|
||||
env_file: .env_beta
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.50'
|
||||
memory: 1024M
|
||||
reservations:
|
||||
cpus: '0.25'
|
||||
memory: 512M
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
|
||||
traefik.docker.network: traefik-proxy
|
||||
traefik.constraint-label: traefik-proxy
|
||||
|
||||
# Watchtower update
|
||||
com.centurylinklabs.watchtower.enable: "true"
|
||||
|
||||
# HTTPS
|
||||
traefik.http.routers.tsotr-backend-secure.entrypoints: web-secure
|
||||
traefik.http.routers.tsotr-backend-secure.rule: Host(`api.beta.tsotr.humenius.me`)
|
||||
traefik.http.routers.tsotr-backend-secure.tls.certresolver: letsencrypt
|
||||
traefik.http.services.tsotr-backend.loadbalancer.server.port: 3500
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.services.tsotr-backend.loadbalancer.server.port=3500
|
||||
|
||||
# Watchtower update
|
||||
- com.centurylinklabs.watchtower.enable=true
|
||||
|
||||
# HTTPS
|
||||
- traefik.http.routers.tsotr-backend-secure.entrypoints=web-secure
|
||||
- traefik.http.routers.tsotr-backend-secure.rule=Host(`api.tsotr.humenius.me`)
|
||||
- traefik.http.routers.tsotr-backend-secure.rule=Host(`api.beta.tsotr.humenius.me`)
|
||||
- traefik.http.routers.tsotr-backend-secure.tls.certresolver=letsencrypt
|
||||
|
||||
db:
|
||||
hostname: ts-onlinetime-ranks-db
|
||||
image: docker.io/bitnami/mariadb:10.5-debian-10
|
||||
env_file: .env
|
||||
ports:
|
||||
- target: 3306
|
||||
published: 13307
|
||||
mode: host
|
||||
networks:
|
||||
- ts-onlinetime-ranks
|
||||
healthcheck:
|
||||
test: ['CMD', '/opt/bitnami/scripts/mariadb/healthcheck.sh']
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 6
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.50'
|
||||
memory: 1024M
|
||||
reservations:
|
||||
cpus: '0.25'
|
||||
memory: 512M
|
||||
volumes:
|
||||
- ts-onlinetime-ranks-db:/bitnami/mariadb
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
traefik-proxy:
|
||||
external: true
|
||||
ts-onlinetime-ranks:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
ts-onlinetime-ranks-db:
|
||||
driver: local
|
||||
|
||||
4
frontend/.babelrc
Normal file
4
frontend/.babelrc
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"presets": ["@babel/preset-react", "@babel/preset-env"],
|
||||
"plugins": ["emotion"]
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
FROM node:14.3.0-alpine as builder
|
||||
WORKDIR /app
|
||||
ENV PATH /app/node_modules/.bin:$PATH
|
||||
COPY ./package.json ./
|
||||
RUN yarn
|
||||
COPY . .
|
||||
RUN yarn
|
||||
RUN yarn build
|
||||
|
||||
FROM node:14.3.0-alpine
|
||||
|
||||
15
frontend/generate-react-cli.json
Normal file
15
frontend/generate-react-cli.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"usesTypeScript": true,
|
||||
"usesCssModule": false,
|
||||
"cssPreprocessor": "scss",
|
||||
"testLibrary": "Testing Library",
|
||||
"component": {
|
||||
"default": {
|
||||
"path": "src/components",
|
||||
"withStyle": true,
|
||||
"withTest": true,
|
||||
"withStory": false,
|
||||
"withLazy": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,36 @@
|
||||
{
|
||||
"name": "ts-onlinetime-ranks-frontend",
|
||||
"version": "0.0.2",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"proxy": "https://api.tsotr.humenius.me",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.32",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.15.1",
|
||||
"@fortawesome/react-fontawesome": "^0.1.14",
|
||||
"@testing-library/jest-dom": "^4.2.4",
|
||||
"@testing-library/react": "^9.3.2",
|
||||
"@testing-library/user-event": "^7.1.2",
|
||||
"@types/jest": "^24.0.0",
|
||||
"@types/node": "^12.0.0",
|
||||
"@types/react": "^16.9.0",
|
||||
"@types/react-dom": "^16.9.0",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"@types/node": "^14.14.25",
|
||||
"husky": "^4.3.7",
|
||||
"lint-staged": "^10.5.3",
|
||||
"prettier": "^2.2.1",
|
||||
"react": "17.0.0",
|
||||
"react-dom": "17.0.0",
|
||||
"react-router": "^5.2.0",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-scripts": "3.4.1",
|
||||
"react-spinners": "^0.10.4",
|
||||
"sass": "^1.26.5",
|
||||
"typescript": "~3.7.2"
|
||||
},
|
||||
"lint-staged": {
|
||||
"/src/**/*.{js,jsx,ts,tsx,json,css,scss}": [
|
||||
"prettier --single-quote --write",
|
||||
"git add"
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"precommit": "lint-staged",
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
@@ -37,5 +50,10 @@
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^16.14.2",
|
||||
"@types/react-dom": "^16.9.10",
|
||||
"@types/react-router-dom": "^5.1.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
text-align: center;
|
||||
height: 100vh;
|
||||
color: whitesmoke;
|
||||
background-color: #282c34; }
|
||||
background-color: #282c34;
|
||||
}
|
||||
|
||||
.first-place {
|
||||
font-size: xx-large;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.App {
|
||||
@mixin background() {
|
||||
width: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -10,43 +10,19 @@
|
||||
background-color: #282c34;
|
||||
}
|
||||
|
||||
.blurred {
|
||||
-webkit-filter: blur(5px);
|
||||
-moz-filter: blur(5px);
|
||||
-o-filter: blur(5px);
|
||||
-ms-filter: blur(5px);
|
||||
filter: blur(5px);
|
||||
}
|
||||
|
||||
.first-place {
|
||||
font-size: xx-large;
|
||||
font-weight: bolder;
|
||||
color: gold;
|
||||
}
|
||||
|
||||
.second-place {
|
||||
font-size: x-large;
|
||||
font-weight: bold;
|
||||
color: silver;
|
||||
}
|
||||
|
||||
.third-place {
|
||||
font-size: larger;
|
||||
color: saddlebrown;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 4vw;
|
||||
color: #88c9db;
|
||||
}
|
||||
|
||||
|
||||
.error-message {
|
||||
color: red;
|
||||
}
|
||||
|
||||
footer {
|
||||
padding: 2rem;
|
||||
@mixin blurred() {
|
||||
-webkit-filter: blur(10px);
|
||||
-moz-filter: blur(10px);
|
||||
-o-filter: blur(10px);
|
||||
-ms-filter: blur(10px);
|
||||
filter: blur(10px);
|
||||
-webkit-touch-callout: none; /* iOS Safari */
|
||||
-webkit-user-select: none; /* Safari */
|
||||
-khtml-user-select: none; /* Konqueror HTML */
|
||||
-moz-user-select: none; /* Old versions of Firefox */
|
||||
-ms-user-select: none; /* Internet Explorer/Edge */
|
||||
user-select: none; /* Non-prefixed version, currently
|
||||
supported by Chrome, Edge, Opera and Firefox */
|
||||
}
|
||||
|
||||
a:link,
|
||||
|
||||
@@ -2,8 +2,8 @@ import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
const { getByText } = render(<App />);
|
||||
const linkElement = getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
||||
// test('renders learn react link', () => {
|
||||
// const { getByText } = render(<App />);
|
||||
// const linkElement = getByText(/learn react/i);
|
||||
// expect(linkElement).toBeInTheDocument();
|
||||
// });
|
||||
|
||||
@@ -1,115 +1,20 @@
|
||||
import React from 'react';
|
||||
import React, {FunctionComponent} from 'react';
|
||||
import './App.scss';
|
||||
import UserStatsMockService from "./mock/UserStatsMockService";
|
||||
import UserStatsService from "./services/UserStatsService";
|
||||
import {findDOMNode} from "react-dom";
|
||||
import TableEntry from "./models/TableEntry";
|
||||
import {BrowserRouter as Router, Route, Switch, Redirect} from 'react-router-dom';
|
||||
|
||||
interface State {
|
||||
error?: string,
|
||||
isLoaded: boolean,
|
||||
users?: TableEntry[],
|
||||
mock?: TableEntry[]
|
||||
}
|
||||
import {library} from "@fortawesome/fontawesome-svg-core";
|
||||
import {faArrowCircleLeft, faArrowCircleRight} from "@fortawesome/free-solid-svg-icons";
|
||||
import MainPage, {IMainPageProps} from "./pages/MainPage/MainPage";
|
||||
|
||||
export default class App extends React.Component {
|
||||
library.add(faArrowCircleLeft, faArrowCircleRight)
|
||||
|
||||
private apiService: UserStatsService = new UserStatsService();
|
||||
private mockService = new UserStatsMockService();
|
||||
const App: FunctionComponent = () => (
|
||||
<Router>
|
||||
<Switch>
|
||||
<Route path={'/season/:id?'} component={(props: IMainPageProps) => (<MainPage {...props}/>)} />
|
||||
<Redirect to={'/season'} />
|
||||
</Switch>
|
||||
</Router>
|
||||
);
|
||||
|
||||
state: State = {
|
||||
error: undefined,
|
||||
isLoaded: false,
|
||||
users: undefined,
|
||||
mock: undefined
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.setState({isLoaded: false, mock: this.mockService.getStatsWithoutPromise()});
|
||||
// this.mockService.getStats()
|
||||
// .then(data => this.setState({
|
||||
// data: data,
|
||||
// mock: data
|
||||
// }))
|
||||
// .finally(() => {
|
||||
// });
|
||||
this.apiService.getStats()
|
||||
.then(data => this.setState({
|
||||
isLoaded: true,
|
||||
users: data
|
||||
}))
|
||||
.catch(error => {
|
||||
error.response.json()
|
||||
.then((err: any) => this.setState({
|
||||
isLoaded: true,
|
||||
error: err.error
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
const element = findDOMNode(this);
|
||||
if (element != null) {
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
createTableEntries(entries: TableEntry[]) {
|
||||
return entries.map((entry, index) => {
|
||||
const placement = index + 1;
|
||||
const placementClassName = placement === 1 ? "first-place"
|
||||
: (placement === 2 ? "second-place"
|
||||
: (placement === 3 ? "third-place"
|
||||
: undefined))
|
||||
return (
|
||||
<tr key={index} className={placementClassName}>
|
||||
<td>{placement}</td>
|
||||
<td>{entry.name}</td>
|
||||
<td>{entry.onlineTime}</td>
|
||||
</tr>
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
renderTableData() {
|
||||
const { error, isLoaded, users, mock } = this.state;
|
||||
if (users != null && isLoaded && error == null) {
|
||||
return this.createTableEntries(users);
|
||||
} else if (isLoaded && error != null && mock != null) {
|
||||
return this.createTableEntries(mock);
|
||||
} else if (mock != null) {
|
||||
return this.createTableEntries(mock);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="App">
|
||||
<p className="title">Humenius' TeamSpeak 3-Ranking</p>
|
||||
{ this.state.error != null ? <p className="error-message"> { this.state.error } Please try again later!</p> : null}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Placement</th>
|
||||
<th>Name</th>
|
||||
<th>Online time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={this.state.error != null || !this.state.isLoaded ? "blurred" : undefined}>
|
||||
{this.renderTableData()}
|
||||
</tbody>
|
||||
</table>
|
||||
<footer>
|
||||
Made by <a href="https://humenius.me"
|
||||
>Humenius</a>.
|
||||
Powered by <a href="https://reactjs.org/"
|
||||
>React</a>.
|
||||
<br/>
|
||||
<a href="ts3server://ts.humenius.me">
|
||||
Click here to join my TeamSpeak server
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
export default App;
|
||||
11
frontend/src/components/ErrorContainer/ErrorContainer.scss
Normal file
11
frontend/src/components/ErrorContainer/ErrorContainer.scss
Normal file
@@ -0,0 +1,11 @@
|
||||
.ErrorContainer {
|
||||
color: #f33c3c;
|
||||
font-size: 32px;
|
||||
//box-shadow:
|
||||
// 0 2.8px 2.2px rgba(0, 0, 0, 0.034),
|
||||
// 0 6.7px 5.3px rgba(0, 0, 0, 0.048),
|
||||
// 0 12.5px 10px rgba(0, 0, 0, 0.06),
|
||||
// 0 22.3px 17.9px rgba(0, 0, 0, 0.072),
|
||||
// 0 41.8px 33.4px rgba(0, 0, 0, 0.086),
|
||||
// 0 100px 80px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
14
frontend/src/components/ErrorContainer/ErrorContainer.tsx
Normal file
14
frontend/src/components/ErrorContainer/ErrorContainer.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import './ErrorContainer.scss';
|
||||
|
||||
const ErrorContainer: React.FC<IErrorContainerProps> = (props: IErrorContainerProps) => (
|
||||
<div className="ErrorContainer" data-testid="ErrorContainer">
|
||||
{props.message}
|
||||
</div>
|
||||
)
|
||||
|
||||
export interface IErrorContainerProps {
|
||||
message: string
|
||||
}
|
||||
|
||||
export default ErrorContainer;
|
||||
5
frontend/src/components/Footer/Footer.scss
Normal file
5
frontend/src/components/Footer/Footer.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
.Footer {
|
||||
footer {
|
||||
padding: 2rem;
|
||||
}
|
||||
}
|
||||
17
frontend/src/components/Footer/Footer.tsx
Normal file
17
frontend/src/components/Footer/Footer.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import './Footer.scss';
|
||||
|
||||
const Footer: React.FC = () => (
|
||||
<div className="Footer" data-testid="Footer">
|
||||
<footer>
|
||||
Made by <a href="https://humenius.me">Humenius</a>.
|
||||
Powered by <a href="https://reactjs.org/">React</a>.
|
||||
<br/>
|
||||
<a href="ts3server://ts.humenius.me">
|
||||
Click here to join my TeamSpeak server
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Footer;
|
||||
7
frontend/src/components/Header/Header.scss
Normal file
7
frontend/src/components/Header/Header.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
|
||||
.Header {
|
||||
.title {
|
||||
font-size: 4vw;
|
||||
color: #88c9db;
|
||||
}
|
||||
}
|
||||
10
frontend/src/components/Header/Header.tsx
Normal file
10
frontend/src/components/Header/Header.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import './Header.scss';
|
||||
|
||||
const Header: React.FC = () => (
|
||||
<div className="Header" data-testid="Header">
|
||||
<p className="title">Humenius' TeamSpeak 3-Ranking</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Header;
|
||||
4
frontend/src/components/SeasonDetail/SeasonDetail.scss
Normal file
4
frontend/src/components/SeasonDetail/SeasonDetail.scss
Normal file
@@ -0,0 +1,4 @@
|
||||
.SeasonDetail {
|
||||
vertical-align: middle;
|
||||
justify-content: center;
|
||||
}
|
||||
28
frontend/src/components/SeasonDetail/SeasonDetail.tsx
Normal file
28
frontend/src/components/SeasonDetail/SeasonDetail.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import './SeasonDetail.scss';
|
||||
import {IUserListProperties} from "../UserList/UserList";
|
||||
|
||||
const SeasonDetail: React.FC<IUserListProperties> = (props: IUserListProperties) => {
|
||||
const { seasonId, dates } = props.userStats
|
||||
|
||||
return (
|
||||
<span className="SeasonDetail" data-testid="SeasonDetail">
|
||||
Season {seasonId} - Duration: {dateToString(dates.start)} - {dateToString(dates.end)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const dateToString = (date?: Date): string => {
|
||||
return date ? date.toLocaleDateString('en-GB') : 'TBD';
|
||||
}
|
||||
|
||||
export interface SeasonDetailProperties {
|
||||
seasonId?: string,
|
||||
maxSeasonId: string
|
||||
dates: {
|
||||
start?: Date,
|
||||
end?: Date
|
||||
}
|
||||
}
|
||||
|
||||
export default SeasonDetail;
|
||||
17
frontend/src/components/SeasonSwitch/SeasonSwitch.scss
Normal file
17
frontend/src/components/SeasonSwitch/SeasonSwitch.scss
Normal file
@@ -0,0 +1,17 @@
|
||||
.SeasonSwitch {
|
||||
|
||||
|
||||
a {
|
||||
font-size: 3vw;
|
||||
max-font-size: 4vw;
|
||||
padding-left: 1em;
|
||||
padding-right: 1em;
|
||||
vertical-align: middle;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.no-click {
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
}
|
||||
45
frontend/src/components/SeasonSwitch/SeasonSwitch.tsx
Normal file
45
frontend/src/components/SeasonSwitch/SeasonSwitch.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import './SeasonSwitch.scss';
|
||||
import SeasonDetail from '../SeasonDetail/SeasonDetail';
|
||||
import {IUserListProperties} from "../UserList/UserList";
|
||||
|
||||
const SeasonSwitch: React.FC<IUserListProperties> = (props: IUserListProperties) => {
|
||||
const seasonId = Number(props.userStats.seasonId)
|
||||
const maxSeasonId = Number(props.userStats.maxSeasonId)
|
||||
|
||||
return (
|
||||
<div className={"SeasonSwitch"} data-testid="SeasonSwitch">
|
||||
{
|
||||
seasonId > 1
|
||||
&& (props.enabled
|
||||
&& <Link to={"/season/" + (seasonId - 1)} onClick={() => {
|
||||
console.log("nothing happens")
|
||||
props.onSeasonIdChange('' + (seasonId - 1))}}>
|
||||
<FontAwesomeIcon icon="arrow-circle-left"/>
|
||||
</Link>
|
||||
|| <Link to={""} onClick={(e) => {
|
||||
console.log("nothing happens2")
|
||||
e.preventDefault()}}>
|
||||
<FontAwesomeIcon icon="arrow-circle-left"/>
|
||||
</Link>)
|
||||
}
|
||||
|
||||
<SeasonDetail {...props} />
|
||||
|
||||
{
|
||||
seasonId < maxSeasonId
|
||||
&& (props.enabled
|
||||
? <Link to={"/season/" + (seasonId + 1)} onClick={() => props.onSeasonIdChange('' + (seasonId + 1))}>
|
||||
<FontAwesomeIcon icon="arrow-circle-right"/>
|
||||
</Link>
|
||||
: <Link to={""} onClick={(e) => e.preventDefault()}>
|
||||
<FontAwesomeIcon icon="arrow-circle-right"/>
|
||||
</Link>)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SeasonSwitch;
|
||||
23
frontend/src/components/UserList/UserList.scss
Normal file
23
frontend/src/components/UserList/UserList.scss
Normal file
@@ -0,0 +1,23 @@
|
||||
.UserList {
|
||||
.first-place {
|
||||
font-size: xx-large;
|
||||
font-weight: bolder;
|
||||
color: gold;
|
||||
}
|
||||
|
||||
.second-place {
|
||||
font-size: x-large;
|
||||
font-weight: bold;
|
||||
color: silver;
|
||||
}
|
||||
|
||||
.third-place {
|
||||
font-size: larger;
|
||||
color: saddlebrown;
|
||||
}
|
||||
|
||||
.SeasonSwitch {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
50
frontend/src/components/UserList/UserList.tsx
Normal file
50
frontend/src/components/UserList/UserList.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import TableEntry from '../../models/TableEntry';
|
||||
import './UserList.scss';
|
||||
import SeasonSwitch from "../SeasonSwitch/SeasonSwitch";
|
||||
import UserStatsResponse from "../../models/UserStatsResponse";
|
||||
|
||||
const createTableEntries = (entries: TableEntry[]) =>
|
||||
entries.map((entry, index) => {
|
||||
const placement = index + 1;
|
||||
const placementClassName = placement === 1 ? "first-place"
|
||||
: (placement === 2 ? "second-place"
|
||||
: (placement === 3 ? "third-place"
|
||||
: undefined))
|
||||
return (
|
||||
<tr key={index} className={placementClassName}>
|
||||
<td>{placement}</td>
|
||||
<td>{entry.name}</td>
|
||||
<td>{entry.rank}</td>
|
||||
<td>{entry.onlineTime}</td>
|
||||
</tr>
|
||||
)
|
||||
});
|
||||
|
||||
const UserList: React.FC<IUserListProperties> = (props: IUserListProperties) => (
|
||||
<div className="UserList" data-testid="UserList">
|
||||
<SeasonSwitch {...props} />
|
||||
<table className={!props.enabled ? "blurred" : ""}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Placement</th>
|
||||
<th>Name</th>
|
||||
<th>Rank</th>
|
||||
<th>Online time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{createTableEntries(props.userStats.stats)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
|
||||
export interface IUserListProperties {
|
||||
userStats: UserStatsResponse
|
||||
mocked?: boolean
|
||||
onSeasonIdChange: React.Dispatch<React.SetStateAction<string | undefined>>
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export default UserList;
|
||||
16
frontend/src/configs/api.config.ts
Normal file
16
frontend/src/configs/api.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export default class APIConfiguration {
|
||||
private static API_DEV = "https://api.beta.tsotr.humenius.me"
|
||||
private static API_PROD = "https://api.tsotr.humenius.me"
|
||||
private static API_LOCAL = "http://localhost:3500"
|
||||
|
||||
public static getUrl = (): string => {
|
||||
switch (process.env.NODE_ENV) {
|
||||
case 'production':
|
||||
return APIConfiguration.API_PROD;
|
||||
case 'development':
|
||||
return APIConfiguration.API_DEV;
|
||||
default:
|
||||
return APIConfiguration.API_LOCAL;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import * as serviceWorker from './serviceWorker';
|
||||
import App from "./App";
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -1,26 +1,63 @@
|
||||
import TableEntry from "../models/TableEntry";
|
||||
import UserStatsResponse from "../models/UserStatsResponse";
|
||||
import UserStatsService from "../services/UserStatsService";
|
||||
|
||||
export default class UserStatsMockService extends UserStatsService {
|
||||
private readonly entries: TableEntry[];
|
||||
private static readonly mocks: UserStatsResponse[] = [
|
||||
{
|
||||
seasonId: "1",
|
||||
maxSeasonId: "2",
|
||||
dates: {
|
||||
start: new Date(2019, 12, 5),
|
||||
end: new Date()
|
||||
},
|
||||
stats: [
|
||||
{ name: "Humen", rank: "Overwatch Noob 2", onlineTime: "0d 1h 0m 0s" },
|
||||
{ name: "Humen", rank: "Random Rank 3 4", onlineTime: "0d 1h 0m 0s" },
|
||||
{ name: "Humen", rank: "Kas is cool 5", onlineTime: "0d 1h 0m 0s" },
|
||||
{ name: "Humen", rank: "Bremsspu 6r", onlineTime: "0d 1h 0m 0s" },
|
||||
{ name: "Humen", rank: "127 3", onlineTime: "0d 1h 0m 0s" },
|
||||
{ name: "Humen", rank: "ok343", onlineTime: "0d 1h 0m 0s" }
|
||||
]
|
||||
},
|
||||
{
|
||||
seasonId: "2",
|
||||
maxSeasonId: "2",
|
||||
dates: {
|
||||
start: new Date(new Date().getFullYear(), 0, 1),
|
||||
end: new Date()
|
||||
},
|
||||
stats: [
|
||||
{ name: "Humen", rank: "Overwatch Noob", onlineTime: "0d 1h 0m 0s" },
|
||||
{ name: "Humen", rank: "Random Rank 3", onlineTime: "0d 1h 0m 0s" },
|
||||
{ name: "Humen", rank: "Kas is cool", onlineTime: "0d 1h 0m 0s" },
|
||||
{ name: "Humen", rank: "Bremsspur", onlineTime: "0d 1h 0m 0s" },
|
||||
{ name: "Humen", rank: "12", onlineTime: "0d 1h 0m 0s" },
|
||||
{ name: "Humen", rank: "ok", onlineTime: "0d 1h 0m 0s" }
|
||||
]
|
||||
},
|
||||
{
|
||||
seasonId: 'undefined',
|
||||
maxSeasonId: "2",
|
||||
dates: {
|
||||
start: undefined,
|
||||
end: undefined
|
||||
},
|
||||
stats: [
|
||||
{ name: "Humen", rank: "Overwatch Noob", onlineTime: "0d 1h 0m 0s" },
|
||||
{ name: "Humen", rank: "Random Rank 3", onlineTime: "0d 1h 0m 0s" },
|
||||
{ name: "Humen", rank: "Kas is cool", onlineTime: "0d 1h 0m 0s" },
|
||||
{ name: "Humen", rank: "Bremsspur", onlineTime: "0d 1h 0m 0s" },
|
||||
{ name: "Humen", rank: "12", onlineTime: "0d 1h 0m 0s" },
|
||||
{ name: "Humen", rank: "ok", onlineTime: "0d 1h 0m 0s" }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.entries = [
|
||||
{ name: "Humen", onlineTime: "0d 1h 0m 0s" },
|
||||
{ name: "Humen", onlineTime: "0d 1h 0m 0s" },
|
||||
{ name: "Humen", onlineTime: "0d 1h 0m 0s" },
|
||||
{ name: "Humen", onlineTime: "0d 1h 0m 0s" },
|
||||
{ name: "Humen", onlineTime: "0d 1h 0m 0s" },
|
||||
{ name: "Humen", onlineTime: "0d 1h 0m 0s" }
|
||||
];
|
||||
public static async getStats(seasonId?: string): Promise<UserStatsResponse> {
|
||||
return Promise.resolve(this.mocks[Number((seasonId ? seasonId : 2)) - 1])
|
||||
}
|
||||
|
||||
async getStats(): Promise<TableEntry[]> {
|
||||
return Promise.resolve(this.entries);
|
||||
}
|
||||
|
||||
getStatsWithoutPromise(): TableEntry[] {
|
||||
return this.entries;
|
||||
static getStatsWithoutPromise(seasonId?: string): UserStatsResponse {
|
||||
return this.mocks[Number((seasonId ? seasonId : 2)) - 1]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
export default class RequestError extends Error {
|
||||
response?: any;
|
||||
//response?: any = "Unknown error. Please try again later.";
|
||||
statusCode: number
|
||||
message: string
|
||||
|
||||
|
||||
constructor(statusCode: number, message: string) {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export default interface TableEntry {
|
||||
name: string;
|
||||
rank: string;
|
||||
onlineTime: string;
|
||||
}
|
||||
|
||||
6
frontend/src/models/UserStatsResponse.tsx
Normal file
6
frontend/src/models/UserStatsResponse.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import TableEntry from "./TableEntry";
|
||||
import {SeasonDetailProperties} from "../components/SeasonDetail/SeasonDetail";
|
||||
|
||||
export default interface UserStatsResponse extends SeasonDetailProperties {
|
||||
stats: TableEntry[]
|
||||
}
|
||||
14
frontend/src/pages/MainPage/MainPage.scss
Normal file
14
frontend/src/pages/MainPage/MainPage.scss
Normal file
@@ -0,0 +1,14 @@
|
||||
@import "src/App.scss";
|
||||
|
||||
.MainPage {
|
||||
@include background();
|
||||
|
||||
.blurred {
|
||||
@include blurred();
|
||||
}
|
||||
.ErrorContainer {
|
||||
margin: 0;
|
||||
z-index: 2;
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
62
frontend/src/pages/MainPage/MainPage.tsx
Normal file
62
frontend/src/pages/MainPage/MainPage.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React, {FC, useEffect, useState} from "react";
|
||||
import UserStatsMockService from "../../mock/UserStatsMockService";
|
||||
import Header from "../../components/Header/Header";
|
||||
import UserList from "../../components/UserList/UserList";
|
||||
import ErrorContainer from "../../components/ErrorContainer/ErrorContainer";
|
||||
import Footer from "../../components/Footer/Footer";
|
||||
import {RouteComponentProps} from "react-router/index";
|
||||
import UserStatsResponse from "../../models/UserStatsResponse";
|
||||
import {withRouter} from "react-router";
|
||||
import RequestError from "../../models/RequestError";
|
||||
import './MainPage.scss';
|
||||
import {ClipLoader} from "react-spinners";
|
||||
import UserStatsService from "../../services/UserStatsService";
|
||||
|
||||
const MainPage: FC<IMainPageProps> = (props: IMainPageProps) => {
|
||||
const [seasonId, setSeasonId] = useState(props.match.params.id)
|
||||
const [error, setError] = useState<RequestError | undefined>(undefined)
|
||||
const [loading, setLoadingState] = useState(true)
|
||||
const [seasonStats, setSeasonStats] = useState<UserStatsResponse>(UserStatsMockService.getStatsWithoutPromise(seasonId))
|
||||
const [spinnerColor] = useState('#61dafb')
|
||||
|
||||
useEffect(() => {
|
||||
setLoadingState(true)
|
||||
setSeasonStats(UserStatsMockService.getStatsWithoutPromise('3'))
|
||||
UserStatsService.getStats(seasonId)
|
||||
.then(res => {
|
||||
setSeasonStats(res)
|
||||
setLoadingState(false)
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err.message)
|
||||
setLoadingState(false)
|
||||
setError(new RequestError(0, "Could not retrieve stats. Try again later."))
|
||||
})
|
||||
}, [seasonId])
|
||||
|
||||
const spinnerCss = `
|
||||
margin: 0;
|
||||
z-index: 2;
|
||||
position: absolute;
|
||||
`
|
||||
|
||||
return (
|
||||
<div className="MainPage">
|
||||
<Header />
|
||||
{
|
||||
error && <ErrorContainer message={error.message} />
|
||||
}
|
||||
|
||||
<ClipLoader color={spinnerColor} loading={loading} size={150} css={spinnerCss} />
|
||||
<UserList enabled={!loading && (error == null)} onSeasonIdChange={setSeasonId} userStats={seasonStats} />
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface IMainPageProps extends RouteComponentProps<{ id?: string }> {
|
||||
|
||||
}
|
||||
|
||||
export default withRouter(MainPage)
|
||||
@@ -1,31 +1,34 @@
|
||||
import TableEntry from "../models/TableEntry";
|
||||
import APIConfiguration from "../configs/api.config";
|
||||
import RequestError from "../models/RequestError";
|
||||
|
||||
import UserStatsResponse from "../models/UserStatsResponse";
|
||||
|
||||
export default class UserStatsService {
|
||||
|
||||
private apiURL = 'https://api.tsotr.humenius.me/stats'
|
||||
private static readonly API_URL = APIConfiguration.getUrl();
|
||||
|
||||
private requestInit: RequestInit = {
|
||||
private static requestInit: RequestInit = {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
async getStats(): Promise<TableEntry[]> {
|
||||
return fetch(this.apiURL, this.requestInit)
|
||||
.then(res => UserStatsService.checkResponse(res))
|
||||
.then(data => data.json());
|
||||
public static async getStats(seasonId?: string): Promise<UserStatsResponse> {
|
||||
return fetch(`${UserStatsService.API_URL}/stats/season/${seasonId ?? ''}`, this.requestInit)
|
||||
.then(res => UserStatsService.checkResponse(res))
|
||||
.then(data => data.json())
|
||||
.then(data => {
|
||||
data.dates.start = data.dates.start ? new Date(data.dates.start) : undefined
|
||||
data.dates.end = data.dates.end ? new Date(data.dates.end) : undefined
|
||||
return data
|
||||
})
|
||||
}
|
||||
|
||||
private static checkResponse(response: any): any {
|
||||
if (!response.ok) {
|
||||
console.log(response);
|
||||
let error = new RequestError(response.statusText);
|
||||
error.response = response;
|
||||
throw error;
|
||||
throw new RequestError(response.status, response.body);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
3911
frontend/yarn.lock
3911
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
48
stack.beta.yml
Normal file
48
stack.beta.yml
Normal file
@@ -0,0 +1,48 @@
|
||||
version: '3.7'
|
||||
|
||||
services:
|
||||
frontend:
|
||||
image: docker.humenius.me/humenius/ts-onlinetime-ranks-frontend:dev-latest
|
||||
networks:
|
||||
- proxy
|
||||
env_file: .env_beta
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.services.tsotr-frontend.loadbalancer.server.port=5000
|
||||
|
||||
# Watchtower update
|
||||
- com.centurylinklabs.watchtower.enable=true
|
||||
|
||||
# HTTPS
|
||||
- traefik.http.routers.tsotr-frontend-secure.entrypoints=web-secure
|
||||
- traefik.http.routers.tsotr-frontend-secure.rule=Host(`beta.tsotr.humenius.me`)
|
||||
- traefik.http.routers.tsotr-frontend-secure.tls.certresolver=letsencrypt
|
||||
|
||||
backend:
|
||||
image: docker.humenius.me/humenius/ts-onlinetime-ranks-backend:dev-latest
|
||||
networks:
|
||||
- proxy
|
||||
- ts-onlinetime-ranks
|
||||
env_file: .env_beta
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.services.tsotr-backend.loadbalancer.server.port=3500
|
||||
|
||||
# Watchtower update
|
||||
- com.centurylinklabs.watchtower.enable=true
|
||||
|
||||
# HTTPS
|
||||
- traefik.http.routers.tsotr-backend-secure.entrypoints=web-secure
|
||||
- traefik.http.routers.tsotr-backend-secure.rule=Host(`api.beta.tsotr.humenius.me`)
|
||||
- traefik.http.routers.tsotr-backend-secure.tls.certresolver=letsencrypt
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
external: true
|
||||
ts-onlinetime-ranks:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
ts-onlinetime-ranks-db:
|
||||
driver: local
|
||||
|
||||
Reference in New Issue
Block a user