mirror of
https://github.com/simon987/task_tracker.git
synced 2025-04-18 18:06:41 +00:00
monitoring setup, project dashboard, account page
This commit is contained in:
parent
f577e76afa
commit
4ef4752c14
@ -67,10 +67,6 @@ func (api *WebAPI) Login(r *Request) {
|
||||
sess := api.Session.StartFasthttp(r.Ctx)
|
||||
sess.Set("manager", manager)
|
||||
|
||||
logrus.Debug("SET")
|
||||
logrus.Debug(sess.ID())
|
||||
logrus.Debug(manager)
|
||||
|
||||
r.OkJson(LoginResponse{
|
||||
Manager: manager,
|
||||
Ok: true,
|
||||
@ -135,8 +131,6 @@ func (api *WebAPI) AccountDetails(r *Request) {
|
||||
|
||||
sess := api.Session.StartFasthttp(r.Ctx)
|
||||
manager := sess.Get("manager")
|
||||
logrus.Debug("GET")
|
||||
logrus.Debug(sess.ID())
|
||||
|
||||
if manager == nil {
|
||||
r.OkJson(AccountDetails{
|
||||
|
18
api/main.go
18
api/main.go
@ -5,6 +5,7 @@ import (
|
||||
"github.com/Sirupsen/logrus"
|
||||
"github.com/buaazp/fasthttprouter"
|
||||
"github.com/kataras/go-sessions"
|
||||
"github.com/robfig/cron"
|
||||
"github.com/simon987/task_tracker/config"
|
||||
"github.com/simon987/task_tracker/storage"
|
||||
"github.com/valyala/fasthttp"
|
||||
@ -16,6 +17,7 @@ type WebAPI struct {
|
||||
Database *storage.Database
|
||||
SessionConfig sessions.Config
|
||||
Session *sessions.Sessions
|
||||
Cron *cron.Cron
|
||||
}
|
||||
|
||||
type Info struct {
|
||||
@ -32,10 +34,23 @@ func Index(r *Request) {
|
||||
r.OkJson(info)
|
||||
}
|
||||
|
||||
func (api *WebAPI) setupMonitoring() {
|
||||
|
||||
api.Cron = cron.New()
|
||||
schedule := cron.Every(config.Cfg.MonitoringInterval)
|
||||
api.Cron.Schedule(schedule, cron.FuncJob(api.Database.MakeProjectSnapshots))
|
||||
api.Cron.Start()
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"every": config.Cfg.MonitoringInterval.String(),
|
||||
}).Info("Started monitoring")
|
||||
}
|
||||
|
||||
func New() *WebAPI {
|
||||
|
||||
api := new(WebAPI)
|
||||
api.Database = &storage.Database{}
|
||||
api.setupMonitoring()
|
||||
|
||||
api.router = &fasthttprouter.Router{}
|
||||
|
||||
@ -71,6 +86,9 @@ func New() *WebAPI {
|
||||
api.router.GET("/project/get/:id", LogRequestMiddleware(api.ProjectGet))
|
||||
api.router.POST("/project/update/:id", LogRequestMiddleware(api.ProjectUpdate))
|
||||
api.router.GET("/project/list", LogRequestMiddleware(api.ProjectGetAllProjects))
|
||||
api.router.GET("/project/monitoring-between/:id", LogRequestMiddleware(api.GetSnapshotsBetween))
|
||||
api.router.GET("/project/monitoring/:id", LogRequestMiddleware(api.GetNSnapshots))
|
||||
api.router.GET("/project/assignees/:id", LogRequestMiddleware(api.ProjectGetAssigneeStats))
|
||||
|
||||
api.router.POST("/task/create", LogRequestMiddleware(api.TaskCreate))
|
||||
api.router.GET("/task/get/:project", LogRequestMiddleware(api.TaskGetFromProject))
|
||||
|
52
api/monitoring.go
Normal file
52
api/monitoring.go
Normal file
@ -0,0 +1,52 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/simon987/task_tracker/storage"
|
||||
"math"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type MonitoringSnapshotResponse struct {
|
||||
Ok bool `json:"ok"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Snapshots *[]storage.ProjectMonitoringSnapshot `json:"snapshots,omitempty"`
|
||||
}
|
||||
|
||||
func (api *WebAPI) GetSnapshotsBetween(r *Request) {
|
||||
|
||||
id, err := strconv.ParseInt(r.Ctx.UserValue("id").(string), 10, 64)
|
||||
from := r.Ctx.Request.URI().QueryArgs().GetUintOrZero("from")
|
||||
to := r.Ctx.Request.URI().QueryArgs().GetUintOrZero("to")
|
||||
if err != nil || id <= 0 || from <= 0 || to <= 0 || from >= math.MaxInt32 || to >= math.MaxInt32 {
|
||||
r.Json(MonitoringSnapshotResponse{
|
||||
Ok: false,
|
||||
Message: "Invalid request",
|
||||
}, 400)
|
||||
return
|
||||
}
|
||||
|
||||
snapshots := api.Database.GetMonitoringSnapshotsBetween(id, from, to)
|
||||
r.OkJson(MonitoringSnapshotResponse{
|
||||
Ok: true,
|
||||
Snapshots: snapshots,
|
||||
})
|
||||
}
|
||||
|
||||
func (api *WebAPI) GetNSnapshots(r *Request) {
|
||||
|
||||
id, err := strconv.ParseInt(r.Ctx.UserValue("id").(string), 10, 64)
|
||||
count := r.Ctx.Request.URI().QueryArgs().GetUintOrZero("count")
|
||||
if err != nil || id <= 0 || count <= 0 || count >= 1000 {
|
||||
r.Json(MonitoringSnapshotResponse{
|
||||
Ok: false,
|
||||
Message: "Invalid request",
|
||||
}, 400)
|
||||
return
|
||||
}
|
||||
|
||||
snapshots := api.Database.GetNMonitoringSnapshots(id, count)
|
||||
r.OkJson(MonitoringSnapshotResponse{
|
||||
Ok: true,
|
||||
Snapshots: snapshots,
|
||||
})
|
||||
}
|
@ -49,6 +49,12 @@ type GetAllProjectsResponse struct {
|
||||
Projects *[]storage.Project `json:"projects,omitempty"`
|
||||
}
|
||||
|
||||
type GetAssigneeStatsResponse struct {
|
||||
Ok bool `json:"ok"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Assignees *[]storage.AssignedTasks `json:"assignees"`
|
||||
}
|
||||
|
||||
func (api *WebAPI) ProjectCreate(r *Request) {
|
||||
|
||||
createReq := &CreateProjectRequest{}
|
||||
@ -203,3 +209,16 @@ func (api *WebAPI) ProjectGetAllProjects(r *Request) {
|
||||
Projects: projects,
|
||||
})
|
||||
}
|
||||
|
||||
func (api *WebAPI) ProjectGetAssigneeStats(r *Request) {
|
||||
|
||||
id, err := strconv.ParseInt(r.Ctx.UserValue("id").(string), 10, 64)
|
||||
handleErr(err, r) //todo handle invalid id
|
||||
|
||||
stats := api.Database.GetAssigneeStats(id, 16)
|
||||
|
||||
r.OkJson(GetAssigneeStatsResponse{
|
||||
Ok: true,
|
||||
Assignees: stats,
|
||||
})
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ server:
|
||||
|
||||
database:
|
||||
conn_str: "user=task_tracker dbname=task_tracker_test sslmode=disable"
|
||||
log_levels: ["debug", "error"]
|
||||
log_levels: ["debug", "error", "trace", "info", "warn"]
|
||||
|
||||
git:
|
||||
webhook_secret: "very_secret_secret"
|
||||
@ -19,3 +19,7 @@ log:
|
||||
session:
|
||||
cookie_name: "tt"
|
||||
expiration: "25m"
|
||||
|
||||
monitoring:
|
||||
snapshot_interval: "10s"
|
||||
history_length: "3000h"
|
||||
|
@ -16,6 +16,8 @@ var Cfg struct {
|
||||
DbLogLevels []logrus.Level
|
||||
SessionCookieName string
|
||||
SessionCookieExpiration time.Duration
|
||||
MonitoringInterval time.Duration
|
||||
MonitoringHistory time.Duration
|
||||
}
|
||||
|
||||
func SetupConfig() {
|
||||
@ -40,5 +42,14 @@ func SetupConfig() {
|
||||
}
|
||||
Cfg.SessionCookieName = viper.GetString("session.cookie_name")
|
||||
Cfg.SessionCookieExpiration, err = time.ParseDuration(viper.GetString("session.expiration"))
|
||||
|
||||
Cfg.MonitoringInterval, err = time.ParseDuration(viper.GetString("monitoring.snapshot_interval"))
|
||||
handleErr(err)
|
||||
Cfg.MonitoringHistory, err = time.ParseDuration(viper.GetString("monitoring.history_length"))
|
||||
handleErr(err)
|
||||
}
|
||||
|
||||
func handleErr(err error) {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
@ -1,44 +1,102 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"github.com/Sirupsen/logrus"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ProjectMonitoringSnapshot struct {
|
||||
NewTaskCount int64
|
||||
FailedTaskCount int64
|
||||
ClosedTaskCount int64
|
||||
WorkerAccessCount int64
|
||||
TimeStamp int64
|
||||
NewTaskCount int64 `json:"new_task_count"`
|
||||
FailedTaskCount int64 `json:"failed_task_count"`
|
||||
ClosedTaskCount int64 `json:"closed_task_count"`
|
||||
WorkerAccessCount int64 `json:"worker_access_count"`
|
||||
AwaitingVerificationCount int64 `json:"awaiting_verification_count"`
|
||||
TimeStamp int64 `json:"time_stamp"`
|
||||
}
|
||||
|
||||
func (database *Database) MakeProjectSnapshots() {
|
||||
|
||||
startTime := time.Now()
|
||||
db := database.getDB()
|
||||
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO project_monitoring_snapshot
|
||||
(project, new_task_count, failed_task_count, closed_task_count, worker_access_count, timestamp)
|
||||
(project, new_task_count, failed_task_count, closed_task_count, worker_access_count,
|
||||
awaiting_verification_task_count, timestamp)
|
||||
SELECT id,
|
||||
(SELECT COUNT(*) FROM task WHERE task.project = project.id AND status = 1),
|
||||
(SELECT COUNT(*) FROM task
|
||||
LEFT JOIN worker_verifies_task wvt on task.id = wvt.task
|
||||
WHERE task.project = project.id AND status = 1 AND wvt.task IS NULL),
|
||||
(SELECT COUNT(*) FROM task WHERE task.project = project.id AND status = 2),
|
||||
closed_task_count,
|
||||
(SELECT COUNT(*) FROM worker_has_access_to_project wa WHERE wa.project = project.id),
|
||||
(SELECT COUNT(*) FROM worker_verifies_task INNER JOIN task t on worker_verifies_task.task = t.id
|
||||
WHERE t.project = project.id),
|
||||
extract(epoch from now() at time zone 'utc')
|
||||
FROM project`)
|
||||
handleErr(err)
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"took": time.Now().Sub(startTime),
|
||||
}).Trace("Took monitoring snapshot")
|
||||
}
|
||||
|
||||
func (database *Database) GetMonitoringSnapshots(pid int64, from int64, to int64) (ss *[]ProjectMonitoringSnapshot) {
|
||||
func (database *Database) GetMonitoringSnapshotsBetween(pid int64, from int, to int) (ss *[]ProjectMonitoringSnapshot) {
|
||||
|
||||
db := database.getDB()
|
||||
|
||||
snapshots := make([]ProjectMonitoringSnapshot, 0)
|
||||
|
||||
rows, err := db.Query(`SELECT new_task_count, failed_task_count, closed_task_count,
|
||||
worker_access_count, timestamp FROM project_monitoring_snapshot
|
||||
WHERE project=$1 AND timestamp BETWEEN $2 AND $3`, pid, from, to)
|
||||
worker_access_count, awaiting_verification_task_count, timestamp FROM project_monitoring_snapshot
|
||||
WHERE project=$1 AND timestamp BETWEEN $2 AND $3 ORDER BY TIMESTAMP DESC `, pid, from, to)
|
||||
handleErr(err)
|
||||
|
||||
for rows.Next() {
|
||||
|
||||
s := ProjectMonitoringSnapshot{}
|
||||
err := rows.Scan(&s.NewTaskCount, &s.FailedTaskCount, &s.ClosedTaskCount, &s.WorkerAccessCount, &s.TimeStamp)
|
||||
err := rows.Scan(&s.NewTaskCount, &s.FailedTaskCount, &s.ClosedTaskCount, &s.WorkerAccessCount,
|
||||
&s.AwaitingVerificationCount, &s.TimeStamp)
|
||||
handleErr(err)
|
||||
|
||||
snapshots = append(snapshots, s)
|
||||
}
|
||||
return nil
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"snapshotCount": len(snapshots),
|
||||
"projectId": pid,
|
||||
"from": from,
|
||||
"to": to,
|
||||
}).Trace("Database.GetMonitoringSnapshotsBetween SELECT")
|
||||
|
||||
return &snapshots
|
||||
}
|
||||
|
||||
func (database *Database) GetNMonitoringSnapshots(pid int64, count int) (ss *[]ProjectMonitoringSnapshot) {
|
||||
|
||||
db := database.getDB()
|
||||
|
||||
snapshots := make([]ProjectMonitoringSnapshot, 0)
|
||||
|
||||
rows, err := db.Query(`SELECT new_task_count, failed_task_count, closed_task_count,
|
||||
worker_access_count, awaiting_verification_task_count, timestamp FROM project_monitoring_snapshot
|
||||
WHERE project=$1 ORDER BY TIMESTAMP DESC LIMIT $2`, pid, count)
|
||||
handleErr(err)
|
||||
|
||||
for rows.Next() {
|
||||
s := ProjectMonitoringSnapshot{}
|
||||
err := rows.Scan(&s.NewTaskCount, &s.FailedTaskCount, &s.ClosedTaskCount, &s.WorkerAccessCount,
|
||||
&s.AwaitingVerificationCount, &s.TimeStamp)
|
||||
handleErr(err)
|
||||
|
||||
snapshots = append(snapshots, s)
|
||||
}
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"snapshotCount": len(snapshots),
|
||||
"projectId": pid,
|
||||
"count": count,
|
||||
}).Trace("Database.GetNMonitoringSnapshots SELECT")
|
||||
|
||||
return &snapshots
|
||||
}
|
||||
|
@ -156,3 +156,31 @@ func (database Database) GetAllProjects() *[]Project {
|
||||
|
||||
return &projects
|
||||
}
|
||||
|
||||
func (database *Database) GetAssigneeStats(pid int64, count int64) *[]AssignedTasks {
|
||||
|
||||
db := database.getDB()
|
||||
assignees := make([]AssignedTasks, 0)
|
||||
|
||||
rows, err := db.Query(`SELECT worker.alias, COUNT(*) as wc FROM TASK
|
||||
LEFT JOIN worker ON TASK.assignee = worker.id WHERE project=$1
|
||||
GROUP BY worker.id ORDER BY wc LIMIT $2`, pid, count)
|
||||
handleErr(err)
|
||||
|
||||
for rows.Next() {
|
||||
assignee := AssignedTasks{}
|
||||
var assigneeAlias sql.NullString
|
||||
err = rows.Scan(&assigneeAlias, &assignee.TaskCount)
|
||||
handleErr(err)
|
||||
|
||||
if assigneeAlias.Valid {
|
||||
assignee.Assignee = assigneeAlias.String
|
||||
} else {
|
||||
assignee.Assignee = "unassigned"
|
||||
}
|
||||
|
||||
assignees = append(assignees, assignee)
|
||||
}
|
||||
|
||||
return &assignees
|
||||
}
|
||||
|
@ -47,7 +47,7 @@ func (database *Database) SaveTask(task *Task, project int64, hash64 int64) erro
|
||||
if err != nil {
|
||||
logrus.WithError(err).WithFields(logrus.Fields{
|
||||
"task": task,
|
||||
}).Warn("Database.saveTask INSERT task ERROR")
|
||||
}).Trace("Database.saveTask INSERT task ERROR")
|
||||
return err
|
||||
}
|
||||
|
||||
|
@ -3,8 +3,7 @@ server:
|
||||
|
||||
database:
|
||||
conn_str: "user=task_tracker dbname=task_tracker_test sslmode=disable"
|
||||
log_levels: ["debug", "error"]
|
||||
# log_levels: ["debug", "error", "trace", "info", "warning"]
|
||||
log_levels: ["debug", "error", "trace", "info", "warn"]
|
||||
|
||||
git:
|
||||
webhook_secret: "very_secret_secret"
|
||||
@ -16,4 +15,8 @@ log:
|
||||
|
||||
session:
|
||||
cookie_name: "tt_test"
|
||||
expiration: "25m"
|
||||
expiration: "25m"
|
||||
|
||||
monitoring:
|
||||
snapshot_interval: "10h"
|
||||
history_length: "10h"
|
||||
|
@ -1,3 +1,29 @@
|
||||
<pre>
|
||||
{{authService.account | json}}
|
||||
</pre>
|
||||
<div class="container">
|
||||
<mat-card class="mat-elevation-z8" *ngIf="account">
|
||||
|
||||
<mat-card-header>
|
||||
<mat-card-title>{{"account.title" | translate}}</mat-card-title>
|
||||
<mat-card-subtitle>{{"account.subtitle" | translate}}</mat-card-subtitle>
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-content>
|
||||
|
||||
<mat-list>
|
||||
<mat-list-item>
|
||||
{{"account.username" | translate}}:
|
||||
<pre>{{account.username}}</pre>
|
||||
</mat-list-item>
|
||||
</mat-list>
|
||||
|
||||
<mat-expansion-panel>
|
||||
<mat-expansion-panel-header>{{"account.metadata" | translate}}</mat-expansion-panel-header>
|
||||
<pre> {{account | json}}</pre>
|
||||
</mat-expansion-panel>
|
||||
</mat-card-content>
|
||||
|
||||
<mat-card-actions>
|
||||
|
||||
|
||||
</mat-card-actions>
|
||||
</mat-card>
|
||||
</div>
|
||||
|
@ -8,10 +8,13 @@ import {AuthService} from "../auth.service";
|
||||
})
|
||||
export class AccountDetailsComponent implements OnInit {
|
||||
|
||||
account: Manager;
|
||||
|
||||
constructor(private authService: AuthService) {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.account = this.authService.account;
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -18,7 +18,7 @@ export class ApiService {
|
||||
}
|
||||
|
||||
getLogs() {
|
||||
return this.http.post(this.url + "/logs", "{\"level\":6, \"since\":1}", this.options);
|
||||
return this.http.post(this.url + "/logs", "{\"level\":4, \"since\":1}", this.options);
|
||||
}
|
||||
|
||||
getProjects() {
|
||||
@ -49,5 +49,12 @@ export class ApiService {
|
||||
return this.http.get(this.url + "/account", this.options)
|
||||
}
|
||||
|
||||
getMonitoringSnapshots(count: number, project: number) {
|
||||
return this.http.get(this.url + `/project/monitoring/${project}?count=${count}`, this.options)
|
||||
}
|
||||
|
||||
getAssigneeStats(project: number) {
|
||||
return this.http.get(this.url + `/project/assignees/${project}`, this.options)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -16,6 +16,7 @@ import {
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatListModule,
|
||||
MatMenuModule,
|
||||
MatPaginatorIntl,
|
||||
MatPaginatorModule,
|
||||
@ -98,7 +99,8 @@ export function createTranslateLoader(http: HttpClient) {
|
||||
),
|
||||
MatSelectModule,
|
||||
MatProgressBarModule,
|
||||
MatTabsModule
|
||||
MatTabsModule,
|
||||
MatListModule
|
||||
|
||||
],
|
||||
exports: [],
|
||||
|
50
web/angular/src/app/auth.service.ts
Normal file
50
web/angular/src/app/auth.service.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {ApiService} from "./api.service";
|
||||
import {Credentials} from "./models/credentials";
|
||||
import {MessengerService} from "./messenger.service";
|
||||
import {Router} from "@angular/router";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AuthService {
|
||||
|
||||
account: Manager;
|
||||
|
||||
constructor(private apiService: ApiService,
|
||||
private messengerService: MessengerService,
|
||||
private router: Router) {
|
||||
}
|
||||
|
||||
public login(credentials: Credentials) {
|
||||
return this.apiService.login(credentials)
|
||||
.subscribe(
|
||||
() => {
|
||||
this.apiService.getAccountDetails()
|
||||
.subscribe((data: any) => {
|
||||
this.account = data.manager;
|
||||
this.router.navigateByUrl("/account");
|
||||
})
|
||||
},
|
||||
error => {
|
||||
console.log(error);
|
||||
this.messengerService.show(error.error.message);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
public register(credentials: Credentials) {
|
||||
return this.apiService.register(credentials)
|
||||
.subscribe(() =>
|
||||
this.apiService.getAccountDetails()
|
||||
.subscribe((data: any) => {
|
||||
this.account = data.manager;
|
||||
this.router.navigateByUrl("/account");
|
||||
}),
|
||||
error => {
|
||||
console.log(error);
|
||||
this.messengerService.show(error.error.message);
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
@ -28,16 +28,7 @@ export class LoginComponent implements OnInit {
|
||||
}
|
||||
|
||||
register() {
|
||||
this.apiService.register(this.credentials)
|
||||
.subscribe(
|
||||
() => {
|
||||
this.router.navigateByUrl("/account")
|
||||
},
|
||||
error => {
|
||||
console.log(error);
|
||||
this.messengerService.show(error.error.message);
|
||||
}
|
||||
)
|
||||
this.authService.register(this.credentials)
|
||||
}
|
||||
|
||||
canCreate(): boolean {
|
||||
|
@ -10,3 +10,8 @@
|
||||
.mat-cell {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.checkbox-wrapper mat-checkbox {
|
||||
margin: 3px 5px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
@ -1,22 +1,31 @@
|
||||
<div class="container">
|
||||
<div class="table-container">
|
||||
<mat-form-field>
|
||||
<input matInput (keyup)="applyFilter($event.target.value)" [placeholder]="'logs.filter' | translate">
|
||||
</mat-form-field>
|
||||
<div class="checkbox-wrapper">
|
||||
<mat-form-field style="margin-right: 10px">
|
||||
<input matInput (keyup)="applyFilter($event.target.value)" [placeholder]="'logs.filter' | translate">
|
||||
</mat-form-field>
|
||||
<mat-checkbox>{{"logs.fatal" | translate}}</mat-checkbox>
|
||||
<mat-checkbox>{{"logs.panic" | translate}}</mat-checkbox>
|
||||
<mat-checkbox>{{"logs.error" | translate}}</mat-checkbox>
|
||||
<mat-checkbox>{{"logs.warn" | translate}}</mat-checkbox>
|
||||
<mat-checkbox>{{"logs.info" | translate}}</mat-checkbox>
|
||||
<mat-checkbox>{{"logs.debug" | translate}}</mat-checkbox>
|
||||
</div>
|
||||
<div class="mat-elevation-z8">
|
||||
|
||||
<mat-table [dataSource]="data" matSort matSortActive="timestamp"
|
||||
matSortDirection="desc">
|
||||
|
||||
<ng-container matColumnDef="level">
|
||||
<mat-header-cell style="flex: 0 0 6em" mat-sort-header
|
||||
<mat-header-cell style="flex: 0 0 9em" mat-sort-header
|
||||
*matHeaderCellDef>{{"logs.level" | translate}}</mat-header-cell>
|
||||
<mat-cell style="flex: 0 0 6em" *matCellDef="let entry"> {{entry.level}} </mat-cell>
|
||||
<mat-cell style="flex: 0 0 8em"
|
||||
*matCellDef="let entry"> {{("logs." + entry.level) | translate}} </mat-cell>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="timestamp">
|
||||
<mat-header-cell style="flex: 0 0 21em" mat-sort-header
|
||||
<mat-header-cell style="flex: 0 0 15em" mat-sort-header
|
||||
*matHeaderCellDef>{{"logs.time" | translate}}</mat-header-cell>
|
||||
<mat-cell style="flex: 0 0 17em" *matCellDef="let entry"> {{entry.timestamp}} </mat-cell>
|
||||
<mat-cell style="flex: 0 0 12em" *matCellDef="let entry"> {{entry.timestamp}} </mat-cell>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="message">
|
||||
<mat-header-cell mat-sort-header *matHeaderCellDef>{{"logs.message" | translate}}</mat-header-cell>
|
||||
|
@ -1,6 +1,6 @@
|
||||
import {Component, OnInit, ViewChild} from '@angular/core';
|
||||
import {ApiService} from "../api.service";
|
||||
import {LogEntry} from "../models/logentry";
|
||||
import {getLogLevel, LogEntry} from "../models/logentry";
|
||||
|
||||
import _ from "lodash"
|
||||
import * as moment from "moment";
|
||||
@ -44,9 +44,9 @@ export class LogsComponent implements OnInit {
|
||||
this.data.data = _.map(data["logs"], (entry) => {
|
||||
return <LogEntry>{
|
||||
message: entry.message,
|
||||
timestamp: moment.unix(entry.timestamp).toISOString(),
|
||||
timestamp: moment.unix(entry.timestamp).format("YYYY-MM-DD HH:mm:ss"),
|
||||
data: JSON.stringify(JSON.parse(entry.data), null, 2),
|
||||
level: entry.level
|
||||
level: getLogLevel(entry.level),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -4,3 +4,32 @@ export interface LogEntry {
|
||||
data: any,
|
||||
timestamp: string,
|
||||
}
|
||||
|
||||
export enum LogLevel {
|
||||
FATAL = "fatal",
|
||||
PANIC = "panic",
|
||||
ERROR = "error",
|
||||
WARN = "warn",
|
||||
INFO = "info",
|
||||
DEBUG = "debug",
|
||||
TRACE = "trace",
|
||||
}
|
||||
|
||||
export function getLogLevel(level: number): string {
|
||||
switch (level) {
|
||||
case 1:
|
||||
return LogLevel.FATAL;
|
||||
case 2:
|
||||
return LogLevel.PANIC;
|
||||
case 3:
|
||||
return LogLevel.ERROR;
|
||||
case 4:
|
||||
return LogLevel.WARN;
|
||||
case 5:
|
||||
return LogLevel.INFO;
|
||||
case 6:
|
||||
return LogLevel.DEBUG;
|
||||
case 7:
|
||||
return LogLevel.TRACE;
|
||||
}
|
||||
}
|
||||
|
12
web/angular/src/app/models/monitoring.ts
Normal file
12
web/angular/src/app/models/monitoring.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export interface MonitoringSnapshot {
|
||||
new_task_count: number
|
||||
failed_task_count: number
|
||||
closed_task_count: number
|
||||
awaiting_verification_count: number
|
||||
time_stamp: number
|
||||
}
|
||||
|
||||
export interface AssignedTasks {
|
||||
assignee: string
|
||||
task_count: number
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
#timeline-wrapper {
|
||||
width: 100%;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#status-pie-wrapper {
|
||||
@ -12,3 +13,28 @@
|
||||
height: 50%;
|
||||
width: 400px;
|
||||
}
|
||||
|
||||
#no-tasks {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#side-charts {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
#side-charts {
|
||||
display: initial;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 576px) {
|
||||
#timeline-wrapper {
|
||||
display: initial;
|
||||
}
|
||||
|
||||
#small-screen-stats {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
@ -3,6 +3,13 @@
|
||||
<mat-card-title *ngIf="project">{{"dashboard.title" | translate}} "{{project.name}}"</mat-card-title>
|
||||
<mat-card-content style="padding: 2em 0 1em">
|
||||
|
||||
<button mat-raised-button style="float: right"
|
||||
[title]="'dashboard.refresh' | translate"
|
||||
(click)="refresh()"
|
||||
>
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
|
||||
<p *ngIf="project">
|
||||
{{"project.git_repo" | translate}}:
|
||||
<a target="_blank" [href]="project['clone_url']">{{project.git_repo}}</a>
|
||||
@ -10,12 +17,13 @@
|
||||
<p>{{"project.motd" | translate}}:</p>
|
||||
<pre *ngIf="project">{{project.motd}}</pre>
|
||||
|
||||
|
||||
<div style="display: flex; align-items: center; justify-content: center">
|
||||
<div id="timeline-wrapper">
|
||||
<canvas id="timeline"></canvas>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div [class.hidden]="noTasks" id="side-charts">
|
||||
<div id="status-pie-wrapper">
|
||||
<canvas id="status-pie"></canvas>
|
||||
</div>
|
||||
@ -23,6 +31,18 @@
|
||||
<canvas id="assignees-pie"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="noTasks" id="no-tasks">
|
||||
<mat-icon>priority_high</mat-icon>
|
||||
<p>{{"dashboard.empty" | translate}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="small-screen-stats">
|
||||
<p>Small screen stats</p>
|
||||
<p>Latest monitoring snapshot:</p>
|
||||
<pre>{{ lastSnapshot | json }}</pre>
|
||||
<p>Assignees</p>
|
||||
<pre>{{ assignees | json }}</pre>
|
||||
</div>
|
||||
|
||||
<mat-expansion-panel *ngIf="project" style="margin-top: 1em">
|
||||
|
@ -3,7 +3,8 @@ import {ApiService} from "../api.service";
|
||||
import {Project} from "../models/project";
|
||||
import {ActivatedRoute} from "@angular/router";
|
||||
|
||||
import {Chart, ChartData, Point} from "chart.js";
|
||||
import {Chart} from "chart.js";
|
||||
import {AssignedTasks, MonitoringSnapshot} from "../models/monitoring";
|
||||
|
||||
|
||||
@Component({
|
||||
@ -15,22 +16,30 @@ export class ProjectDashboardComponent implements OnInit {
|
||||
|
||||
private projectId;
|
||||
project: Project;
|
||||
noTasks = false;
|
||||
|
||||
private timeline: Chart;
|
||||
private statusPie: Chart;
|
||||
private assigneesPir: Chart;
|
||||
private assigneesPie: Chart;
|
||||
|
||||
|
||||
private colors = {
|
||||
new: "#76FF03",
|
||||
failed: "#FF3D00",
|
||||
closed: "#E0E0E0",
|
||||
awaiting: "#FFB74D"
|
||||
awaiting: "#FFB74D",
|
||||
random: [
|
||||
"#3D5AFE", "#2979FF", "#2196F3",
|
||||
"#7C4DFF", "#673AB7", "#7C4DFF",
|
||||
"#FFC400", "#FFD740", "#FFC107",
|
||||
"#FF3D00", "#FF6E40", "#FF5722",
|
||||
"#76FF03", "#B2FF59", "#8BC34A"
|
||||
]
|
||||
};
|
||||
|
||||
tmpLabels = [];
|
||||
tmpNew = [];
|
||||
tmpFailed = [];
|
||||
tmpClosed = [];
|
||||
tmpAwaiting = [];
|
||||
snapshots: MonitoringSnapshot[] = [];
|
||||
lastSnapshot: MonitoringSnapshot;
|
||||
assignees: AssignedTasks[];
|
||||
|
||||
constructor(private apiService: ApiService, private route: ActivatedRoute) {
|
||||
}
|
||||
@ -42,19 +51,110 @@ export class ProjectDashboardComponent implements OnInit {
|
||||
this.getProject();
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
let n = 40;
|
||||
for (let i = 0; i < n; i++) {
|
||||
this.tmpLabels.push((1549501926 + 600 * i) * 1000);
|
||||
this.tmpNew.push(Math.ceil(Math.random() * 30))
|
||||
this.tmpClosed.push(Math.ceil(Math.random() * 100))
|
||||
this.tmpFailed.push(Math.ceil(Math.random() * 13))
|
||||
this.tmpAwaiting.push(Math.ceil(Math.random() * 40))
|
||||
}
|
||||
public refresh() {
|
||||
|
||||
this.setupTimeline();
|
||||
this.setupStatusPie();
|
||||
this.setupAssigneesPie();
|
||||
this.apiService.getMonitoringSnapshots(60, this.projectId)
|
||||
.subscribe((data: any) => {
|
||||
this.snapshots = data.snapshots;
|
||||
this.lastSnapshot = this.snapshots ? this.snapshots.sort((a, b) => {
|
||||
return b.time_stamp - a.time_stamp
|
||||
})[0] : null;
|
||||
|
||||
if (this.lastSnapshot == null || (this.lastSnapshot.awaiting_verification_count == 0 &&
|
||||
this.lastSnapshot.closed_task_count == 0 &&
|
||||
this.lastSnapshot.new_task_count == 0 &&
|
||||
this.lastSnapshot.failed_task_count == 0)) {
|
||||
this.noTasks = true;
|
||||
return
|
||||
}
|
||||
this.noTasks = false;
|
||||
|
||||
this.timeline.data.labels = this.snapshots.map(s => s.time_stamp as any);
|
||||
this.timeline.data.datasets = this.makeTimelineDataset(this.snapshots);
|
||||
this.timeline.update();
|
||||
this.statusPie.data.datasets = [
|
||||
{
|
||||
label: "Task status",
|
||||
data: [
|
||||
this.lastSnapshot.new_task_count,
|
||||
this.lastSnapshot.failed_task_count,
|
||||
this.lastSnapshot.closed_task_count,
|
||||
this.lastSnapshot.awaiting_verification_count,
|
||||
],
|
||||
backgroundColor: [
|
||||
this.colors.new,
|
||||
this.colors.failed,
|
||||
this.colors.closed,
|
||||
this.colors.awaiting
|
||||
],
|
||||
}
|
||||
];
|
||||
this.statusPie.update();
|
||||
|
||||
this.apiService.getAssigneeStats(this.projectId)
|
||||
.subscribe((data: any) => {
|
||||
this.assignees = data.assignees;
|
||||
let colors = this.assignees.map(() => {
|
||||
return this.colors.random[Math.floor(Math.random() * this.colors.random.length)]
|
||||
});
|
||||
this.assigneesPie.data.labels = this.assignees.map(x => x.assignee);
|
||||
this.assigneesPie.data.datasets = [
|
||||
{
|
||||
label: "Task status",
|
||||
data: this.assignees.map(x => x.task_count),
|
||||
backgroundColor: colors,
|
||||
}
|
||||
];
|
||||
this.assigneesPie.update();
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
private makeTimelineDataset(snapshots: MonitoringSnapshot[]) {
|
||||
return [
|
||||
{
|
||||
label: "New",
|
||||
type: "line",
|
||||
fill: false,
|
||||
borderColor: this.colors.new,
|
||||
backgroundColor: this.colors.new,
|
||||
data: snapshots.map(s => s.new_task_count),
|
||||
pointRadius: 0,
|
||||
lineTension: 0.2,
|
||||
},
|
||||
{
|
||||
label: "Failed",
|
||||
type: "line",
|
||||
fill: false,
|
||||
borderColor: this.colors.failed,
|
||||
backgroundColor: this.colors.failed,
|
||||
data: snapshots.map(s => s.failed_task_count),
|
||||
pointRadius: 0,
|
||||
lineTension: 0.2,
|
||||
},
|
||||
{
|
||||
label: "Closed",
|
||||
type: "line",
|
||||
fill: false,
|
||||
borderColor: this.colors.closed,
|
||||
backgroundColor: this.colors.closed,
|
||||
pointRadius: 0,
|
||||
data: snapshots.map(s => s.closed_task_count),
|
||||
lineTension: 0.2,
|
||||
},
|
||||
{
|
||||
label: "Awaiting verification",
|
||||
type: "line",
|
||||
fill: false,
|
||||
borderColor: this.colors.awaiting,
|
||||
backgroundColor: this.colors.awaiting,
|
||||
data: snapshots.map(s => s.awaiting_verification_count),
|
||||
pointRadius: 0,
|
||||
lineTension: 0.2,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
private setupTimeline() {
|
||||
@ -64,49 +164,8 @@ export class ProjectDashboardComponent implements OnInit {
|
||||
this.timeline = new Chart(ctx, {
|
||||
type: "bar",
|
||||
data: {
|
||||
labels: this.tmpLabels,
|
||||
datasets: [
|
||||
{
|
||||
label: "New",
|
||||
type: "line",
|
||||
fill: false,
|
||||
borderColor: this.colors.new,
|
||||
backgroundColor: this.colors.new,
|
||||
data: this.tmpNew,
|
||||
pointRadius: 0,
|
||||
lineTension: 0.2,
|
||||
},
|
||||
{
|
||||
label: "Failed",
|
||||
type: "line",
|
||||
fill: false,
|
||||
borderColor: this.colors.failed,
|
||||
backgroundColor: this.colors.failed,
|
||||
data: this.tmpFailed,
|
||||
pointRadius: 0,
|
||||
lineTension: 0.2,
|
||||
},
|
||||
{
|
||||
label: "Closed",
|
||||
type: "line",
|
||||
fill: false,
|
||||
borderColor: this.colors.closed,
|
||||
backgroundColor: this.colors.closed,
|
||||
pointRadius: 0,
|
||||
data: this.tmpClosed,
|
||||
lineTension: 0.2,
|
||||
},
|
||||
{
|
||||
label: "Awaiting verification",
|
||||
type: "line",
|
||||
fill: false,
|
||||
borderColor: this.colors.awaiting,
|
||||
backgroundColor: this.colors.awaiting,
|
||||
data: this.tmpAwaiting,
|
||||
pointRadius: 0,
|
||||
lineTension: 0.2,
|
||||
},
|
||||
],
|
||||
labels: this.snapshots.map(s => s.time_stamp as any),
|
||||
datasets: this.makeTimelineDataset(this.snapshots),
|
||||
},
|
||||
options: {
|
||||
title: {
|
||||
@ -124,10 +183,6 @@ export class ProjectDashboardComponent implements OnInit {
|
||||
ticks: {
|
||||
source: "auto"
|
||||
},
|
||||
time: {
|
||||
unit: "minute",
|
||||
unitStepSize: 10,
|
||||
}
|
||||
}]
|
||||
},
|
||||
tooltips: {
|
||||
@ -136,12 +191,20 @@ export class ProjectDashboardComponent implements OnInit {
|
||||
mode: "index",
|
||||
position: "nearest",
|
||||
},
|
||||
responsive: true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private setupStatusPie() {
|
||||
|
||||
if (this.lastSnapshot == null || (this.lastSnapshot.awaiting_verification_count == 0 &&
|
||||
this.lastSnapshot.closed_task_count == 0 &&
|
||||
this.lastSnapshot.new_task_count == 0 &&
|
||||
this.lastSnapshot.failed_task_count == 0)) {
|
||||
this.noTasks = true;
|
||||
}
|
||||
|
||||
let elem = document.getElementById("status-pie") as any;
|
||||
let ctx = elem.getContext("2d");
|
||||
|
||||
@ -158,10 +221,10 @@ export class ProjectDashboardComponent implements OnInit {
|
||||
{
|
||||
label: "Task status",
|
||||
data: [
|
||||
10,
|
||||
24,
|
||||
301,
|
||||
90,
|
||||
this.lastSnapshot.new_task_count,
|
||||
this.lastSnapshot.failed_task_count,
|
||||
this.lastSnapshot.closed_task_count,
|
||||
this.lastSnapshot.awaiting_verification_count,
|
||||
],
|
||||
backgroundColor: [
|
||||
this.colors.new,
|
||||
@ -186,7 +249,7 @@ export class ProjectDashboardComponent implements OnInit {
|
||||
animation: {
|
||||
animateScale: true,
|
||||
animateRotate: true
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -196,30 +259,19 @@ export class ProjectDashboardComponent implements OnInit {
|
||||
let elem = document.getElementById("assignees-pie") as any;
|
||||
let ctx = elem.getContext("2d");
|
||||
|
||||
this.statusPie = new Chart(ctx, {
|
||||
let colors = this.assignees.map(() => {
|
||||
return this.colors.random[Math.floor(Math.random() * this.colors.random.length)]
|
||||
});
|
||||
|
||||
this.assigneesPie = new Chart(ctx, {
|
||||
type: "doughnut",
|
||||
data: {
|
||||
labels: [
|
||||
"marc",
|
||||
"simon",
|
||||
"bernie",
|
||||
"natasha",
|
||||
],
|
||||
labels: this.assignees.map(x => x.assignee),
|
||||
datasets: [
|
||||
{
|
||||
label: "Task status",
|
||||
data: [
|
||||
10,
|
||||
24,
|
||||
1,
|
||||
23,
|
||||
],
|
||||
backgroundColor: [
|
||||
this.colors.new,
|
||||
this.colors.failed,
|
||||
this.colors.closed,
|
||||
this.colors.awaiting
|
||||
],
|
||||
data: this.assignees.map(x => x.task_count),
|
||||
backgroundColor: colors,
|
||||
}
|
||||
],
|
||||
},
|
||||
@ -237,23 +289,35 @@ export class ProjectDashboardComponent implements OnInit {
|
||||
animation: {
|
||||
animateScale: true,
|
||||
animateRotate: true
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private getProject() {
|
||||
this.apiService.getProject(this.projectId).subscribe(data => {
|
||||
this.project = <Project>{
|
||||
id: data["project"]["id"],
|
||||
name: data["project"]["name"],
|
||||
clone_url: data["project"]["clone_url"],
|
||||
git_repo: data["project"]["git_repo"],
|
||||
motd: data["project"]["motd"],
|
||||
priority: data["project"]["priority"],
|
||||
version: data["project"]["version"],
|
||||
public: data["project"]["public"],
|
||||
}
|
||||
this.apiService.getProject(this.projectId).subscribe((data: any) => {
|
||||
this.project = data.project;
|
||||
|
||||
this.apiService.getMonitoringSnapshots(60, this.projectId)
|
||||
.subscribe((data: any) => {
|
||||
this.snapshots = data.snapshots;
|
||||
this.lastSnapshot = this.snapshots ? this.snapshots.sort((a, b) => {
|
||||
return b.time_stamp - a.time_stamp
|
||||
})[0] : null;
|
||||
|
||||
this.setupTimeline();
|
||||
this.setupStatusPie();
|
||||
|
||||
if (!this.snapshots) {
|
||||
return
|
||||
}
|
||||
|
||||
this.apiService.getAssigneeStats(this.projectId)
|
||||
.subscribe((data: any) => {
|
||||
this.assignees = data.assignees;
|
||||
this.setupAssigneesPie();
|
||||
});
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -9,7 +9,7 @@
|
||||
},
|
||||
"logs": {
|
||||
"filter": "Filter",
|
||||
"time": "Time",
|
||||
"time": "Time (UTC)",
|
||||
"level": "Level",
|
||||
"message": "Message",
|
||||
"data": "Details",
|
||||
@ -18,7 +18,14 @@
|
||||
"of": "of",
|
||||
"items_per_page": "Items per page",
|
||||
"next_page": "Next page",
|
||||
"prev_page": "Previous page"
|
||||
"prev_page": "Previous page",
|
||||
"fatal": "Fatal",
|
||||
"panic": "Panic",
|
||||
"error": "Error",
|
||||
"warn": "Warning",
|
||||
"info": "Info",
|
||||
"debug": "Debug",
|
||||
"trace": "Trace"
|
||||
},
|
||||
"projects": {
|
||||
"projects": "Projects",
|
||||
@ -47,11 +54,13 @@
|
||||
"create": "Create",
|
||||
"git_repo": "Git repository name",
|
||||
"motd": "Message of the day",
|
||||
"update": "Update"
|
||||
"update": "Edit"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard for",
|
||||
"metadata": "Project metadata"
|
||||
"metadata": "Project metadata",
|
||||
"empty": "No tasks",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"login": {
|
||||
"title": "Login",
|
||||
@ -64,5 +73,11 @@
|
||||
"create_account": {
|
||||
"title": "Register",
|
||||
"create": "Create account"
|
||||
},
|
||||
"account": {
|
||||
"metadata": "Account metadata",
|
||||
"title": "Account details",
|
||||
"subtitle": "toto: subtitle",
|
||||
"username": "Username"
|
||||
}
|
||||
}
|
||||
|
@ -9,7 +9,7 @@
|
||||
},
|
||||
"logs": {
|
||||
"filter": "Filtrer",
|
||||
"time": "Date",
|
||||
"time": "Date (UTC)",
|
||||
"level": "Niveau",
|
||||
"message": "Message",
|
||||
"data": "Details",
|
||||
@ -18,7 +18,14 @@
|
||||
"of": "de",
|
||||
"items_per_page": "Items par page",
|
||||
"next_page": "Page suivante",
|
||||
"prev_page": "Page précédante"
|
||||
"prev_page": "Page précédante",
|
||||
"fatal": "Fatal",
|
||||
"panic": "Panique",
|
||||
"error": "Erreur",
|
||||
"warn": "Avertissement",
|
||||
"info": "Information",
|
||||
"debug": "Débugage",
|
||||
"trace": "Trace"
|
||||
},
|
||||
"projects": {
|
||||
"projects": "Projets",
|
||||
@ -52,7 +59,9 @@
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord pour ",
|
||||
"metadata": "Métadonnés du projet"
|
||||
"metadata": "Métadonnés du projet",
|
||||
"empty": "Aucune tâche",
|
||||
"refresh": "Rafraîchir"
|
||||
},
|
||||
"login": {
|
||||
"title": "Ouvrir un session",
|
||||
@ -65,6 +74,12 @@
|
||||
"create_account": {
|
||||
"title": "Créer un compte",
|
||||
"create": "Créer un compte"
|
||||
},
|
||||
"account": {
|
||||
"metadata": "Métadonnés du compte",
|
||||
"title": "Détails du compte",
|
||||
"subtitle": "toto: sous-titre",
|
||||
"username": "Nom d'utilisateur"
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -6,7 +6,7 @@
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||
<base href="/">
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
</head>
|
||||
<body>
|
||||
|
@ -78,3 +78,11 @@ body {
|
||||
.mat-tab-body {
|
||||
padding-top: 1em;
|
||||
}
|
||||
|
||||
pre {
|
||||
color: #616161;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user