50 Commits

Author SHA1 Message Date
59705b9903 Merge pull request 'Database Connection Update' (#4) from feature/database-connection into master
Some checks reported errors
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build was killed
Reviewed-on: #4
2021-02-08 03:16:50 +01:00
d0cdb6afb5 [skip ci] Remove logger in backend controller
Some checks failed
continuous-integration/drone/pr Build is failing
2021-02-08 03:08:39 +01:00
d7208b773c Update React, fix null dates, add default route for missing season ID and move blurred area effect to table only
All checks were successful
continuous-integration/drone/push Build is passing
2021-02-08 03:01:53 +01:00
a6ca23dd5d Enable debug and set file name for S3 cache
All checks were successful
continuous-integration/drone/push Build is passing
2021-02-01 14:49:31 +01:00
687067810f Comment out unit tests step
Some checks reported errors
continuous-integration/drone/push Build was killed
It fails because there needs to be one unit test.
2021-02-01 12:44:26 +01:00
9722302343 Set proper environment for frontend and comment out test
Some checks failed
continuous-integration/drone/push Build is failing
2021-02-01 12:40:01 +01:00
1fcbb917b6 Comment out backend E2E tests 2021-02-01 12:39:45 +01:00
9e0c819060 Change mount directory for S3 caching plugin 2021-02-01 12:39:29 +01:00
44c663bfd6 Rebuild and flush cache even on failure
Some checks failed
continuous-integration/drone/push Build is failing
2021-02-01 12:26:24 +01:00
2c73dda857 Add missing package installation step for backend
Some checks failed
continuous-integration/drone/push Build is failing
2021-02-01 12:21:55 +01:00
5673068e1a Change directory in each step
Some checks failed
continuous-integration/drone/push Build is failing
2021-02-01 12:20:04 +01:00
28013d43de Debug print directory content
Some checks failed
continuous-integration/drone/push Build is failing
2021-02-01 12:15:42 +01:00
3169c4716e Change workspace dir for each backend and frontend 2021-02-01 12:07:22 +01:00
14a5913aef Add S3 caching and automated tests
Some checks reported errors
continuous-integration/drone/push Build was killed
2021-02-01 12:02:03 +01:00
fcd9acc5dc Add Swarm migrated compose files
Contains new stack.beta.yml for beta deployment for dev images
2021-02-01 11:27:06 +01:00
721421294f feature(database-connection): Fix error handling
Some checks failed
continuous-integration/drone/push Build is failing
2021-01-28 00:03:56 +01:00
9db53d5354 feature(database-connection): Add tests for timetracking and add fallback value to max season for fetching stats 2021-01-27 22:51:26 +01:00
53710079e8 Add "dev-latest" tag to Docker DEV images
All checks were successful
continuous-integration/drone/push Build is passing
2021-01-18 21:15:11 +01:00
ca6cfd6622 [skip ci] Fix spelling mistake 2021-01-18 21:14:38 +01:00
530e6d1cd1 Remove caching in drone.yml for now
All checks were successful
continuous-integration/drone/push Build is passing
2021-01-18 15:55:13 +01:00
6d60c46426 Improve Docker building
Some checks failed
continuous-integration/drone/push Build is failing
2021-01-18 15:46:57 +01:00
4b1f39042b Add caching option for Docker images
Some checks failed
continuous-integration/drone/push Build is failing
2021-01-17 23:06:15 +01:00
1dac3e6210 Fix wrong contexts in .drone.yml
Some checks failed
continuous-integration/drone/push Build is failing
2021-01-17 22:33:50 +01:00
eaf2bba8d1 feature(database-connection): Update API url
All checks were successful
continuous-integration/drone/push Build is passing
2021-01-17 22:24:09 +01:00
f62bc93932 feature(database-connection): Update docker-compose.yml
All checks were successful
continuous-integration/drone/push Build is passing
2021-01-17 22:12:19 +01:00
92a8b6f551 Change tags for Docker images in .drone.yml
All checks were successful
continuous-integration/drone/push Build is passing
2021-01-17 18:10:34 +01:00
b63391845f Update .drone.yml to build dev releases
Some checks failed
continuous-integration/drone/push Build is failing
2021-01-17 17:35:06 +01:00
fce8a4d095 feature(database-connection): Show next navigation button only when below maxSeasonId 2021-01-12 15:58:58 +01:00
59636415ef feature(database-connection): Fix default redirect on base path 2021-01-12 15:58:33 +01:00
834276f651 feature(database-connection): Overlay error container on top of UserList 2021-01-12 15:58:14 +01:00
bbdbd04cc8 feature(database-connection): Parse dates in response 2021-01-12 15:57:48 +01:00
e3951d4793 Reformat code 2021-01-12 14:15:33 +01:00
c514337fea feature(database-connection): Add TimeTrackerPredicate and PrismaService to providers 2021-01-12 14:15:14 +01:00
4fe3d6a984 feature(database-connection): Update Prisma 2021-01-12 14:14:16 +01:00
83d956a8aa feature(database-connection): Adapt new requests to database 2021-01-12 13:38:03 +01:00
9ea9fa410c feature(database-connection): Add type aliases for responses 2021-01-12 13:37:38 +01:00
2affcd625f feature(database-connection): Add request logging 2021-01-12 13:36:37 +01:00
1d4da03039 feature(database-connection): Use default UserStatsService 2021-01-12 12:43:29 +01:00
10a0254f4e feature(database-connection): Refactor ErrorContainer.tsx to functional components 2021-01-12 12:42:52 +01:00
c62d760570 feature(database-connection): Refactor SeasonDetail.tsx to functional components 2021-01-12 12:42:39 +01:00
a528118178 feature(database-connection): Refactor SeasonSwitch.tsx to functional components 2021-01-12 12:42:27 +01:00
092534e437 feature(database-connection): Refactor UserList.tsx to functional components 2021-01-12 12:38:41 +01:00
eae539e39f feature(database-connection): Clean up MainPage.tsx 2021-01-12 12:38:19 +01:00
4d96bb2674 feature(database-connection): Render spinner on top of UserList component 2021-01-12 12:32:53 +01:00
1b7046788e feature(database-connection): Fix blur flickering 2021-01-12 12:26:29 +01:00
7af903638d feature(database-connection): Add prototype for spinner 2021-01-12 12:14:22 +01:00
816d5df943 feature(database-connection): Fix routing and refactor style sheets 2021-01-12 11:49:08 +01:00
29b6974714 feature(database-connection): Add routing for dynamic seasons 2021-01-11 15:46:09 +01:00
ff1c56c791 feature(database-connection): Restructure frontend 2021-01-11 08:18:22 +01:00
ceea2a7616 feature(database-connection): First implementation 2021-01-08 17:38:49 +01:00
55 changed files with 4989 additions and 4177 deletions

View File

@@ -3,6 +3,34 @@ 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: Run unit tests
# image: node
# environment:
# CI: true
# commands:
# - cd frontend
# - yarn test
- name: Build and push frontend image
image: plugins/docker
settings:
@@ -12,20 +40,185 @@ 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:
branch:
- release/*
ref:
- refs/tags/* # only trigger when tagging
---
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: Run unit tests
# image: node
# environment:
# CI: true
# commands:
# - cd frontend
# - yarn test
- 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:
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-dev.tar
when:
status:
- success
- failure
trigger:
exclude:
branch:
- release/*
---
kind: pipeline
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: Run E2E tests
# image: node
# - cd backend
# - npm run test:e2e
- name: Build and push backend image
image: plugins/docker
settings:
@@ -34,11 +227,146 @@ 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:
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: backend.tar
when:
status:
- success
- failure
trigger:
branch:
- release/*
ref:
- refs/tags/* # only trigger when tagging
---
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: Run E2E tests
# image: node
# - cd backend
# - npm run test:e2e
- 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:
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: backend-dev.tar
when:
status:
- success
- failure
trigger:
exclude:
branch:
- release/*

2
backend/.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules
npm-debug.log

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View 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[]
}

View File

@@ -0,0 +1,3 @@
export interface Predicate<T, O> {
process(input: T): O;
}

13
backend/src/api/sort.ts Normal file
View 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;
};
}

View 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]);
}
});
});

View 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`;
};
}

View File

@@ -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!');
});
});
});

View File

@@ -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,
);
});
}
}

View File

@@ -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('*');
}
}

View 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);
// });
});

View 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);
});
}

View 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);
})
});

View 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));
}
}

View 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();
}
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -1,4 +1,5 @@
export interface TableEntry {
name: string;
onlineTime: string;
name: string;
rank: string;
onlineTime: string;
}

View 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[];

View 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();
}
}

View File

@@ -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);

View File

@@ -4,41 +4,92 @@ 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
labels:
- traefik.enable=true
- traefik.http.services.tsotr-backend.loadbalancer.server.port=3000
deploy:
resources:
limits:
cpus: '0.50'
memory: 1024M
reservations:
cpus: '0.25'
memory: 512M
labels:
traefik.enable: "true"
# 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
traefik.docker.network: traefik-proxy
traefik.constraint-label: traefik-proxy
# 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
# 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
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
View File

@@ -0,0 +1,4 @@
{
"presets": ["@babel/preset-react", "@babel/preset-env"],
"plugins": ["emotion"]
}

View File

@@ -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

View 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
}
}
}

View File

@@ -2,22 +2,35 @@
"name": "ts-onlinetime-ranks-frontend",
"version": "0.0.2",
"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",
"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"
}
}

View File

@@ -7,7 +7,8 @@
text-align: center;
height: 100vh;
color: whitesmoke;
background-color: #282c34; }
background-color: #282c34;
}
.first-place {
font-size: xx-large;

View File

@@ -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,

View File

@@ -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();
// });

View File

@@ -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;

View 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);
}

View 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;

View File

@@ -0,0 +1,5 @@
.Footer {
footer {
padding: 2rem;
}
}

View 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;

View File

@@ -0,0 +1,7 @@
.Header {
.title {
font-size: 4vw;
color: #88c9db;
}
}

View 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;

View File

@@ -0,0 +1,4 @@
.SeasonDetail {
vertical-align: middle;
justify-content: center;
}

View 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;

View 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;
}
}

View 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;

View 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;
}
}

View 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;

View File

@@ -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>

View File

@@ -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]
}
}

View File

@@ -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;
}
}

View File

@@ -1,4 +1,5 @@
export default interface TableEntry {
name: string;
rank: string;
onlineTime: string;
}

View File

@@ -0,0 +1,6 @@
import TableEntry from "./TableEntry";
import {SeasonDetailProperties} from "../components/SeasonDetail/SeasonDetail";
export default interface UserStatsResponse extends SeasonDetailProperties {
stats: TableEntry[]
}

View File

@@ -0,0 +1,14 @@
@import "src/App.scss";
.MainPage {
@include background();
.blurred {
@include blurred();
}
.ErrorContainer {
margin: 0;
z-index: 2;
position: absolute;
}
}

View 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)

View File

@@ -1,30 +1,33 @@
import TableEntry from "../models/TableEntry";
import RequestError from "../models/RequestError";
import UserStatsResponse from "../models/UserStatsResponse";
export default class UserStatsService {
private apiURL = 'https://api.tsotr.humenius.me/stats'
private static apiURL = 'https://api.tsotr.humenius.me/stats'
//private static apiURL = 'http://localhost:3500/stats'
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(`${this.apiURL}/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;
}

File diff suppressed because it is too large Load Diff

48
stack.beta.yml Normal file
View 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