monitoring setup, project dashboard, account page

This commit is contained in:
simon987 2019-02-09 13:16:58 -05:00
parent f577e76afa
commit 4ef4752c14
28 changed files with 629 additions and 160 deletions

View File

@ -67,10 +67,6 @@ func (api *WebAPI) Login(r *Request) {
sess := api.Session.StartFasthttp(r.Ctx) sess := api.Session.StartFasthttp(r.Ctx)
sess.Set("manager", manager) sess.Set("manager", manager)
logrus.Debug("SET")
logrus.Debug(sess.ID())
logrus.Debug(manager)
r.OkJson(LoginResponse{ r.OkJson(LoginResponse{
Manager: manager, Manager: manager,
Ok: true, Ok: true,
@ -135,8 +131,6 @@ func (api *WebAPI) AccountDetails(r *Request) {
sess := api.Session.StartFasthttp(r.Ctx) sess := api.Session.StartFasthttp(r.Ctx)
manager := sess.Get("manager") manager := sess.Get("manager")
logrus.Debug("GET")
logrus.Debug(sess.ID())
if manager == nil { if manager == nil {
r.OkJson(AccountDetails{ r.OkJson(AccountDetails{

View File

@ -5,6 +5,7 @@ import (
"github.com/Sirupsen/logrus" "github.com/Sirupsen/logrus"
"github.com/buaazp/fasthttprouter" "github.com/buaazp/fasthttprouter"
"github.com/kataras/go-sessions" "github.com/kataras/go-sessions"
"github.com/robfig/cron"
"github.com/simon987/task_tracker/config" "github.com/simon987/task_tracker/config"
"github.com/simon987/task_tracker/storage" "github.com/simon987/task_tracker/storage"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
@ -16,6 +17,7 @@ type WebAPI struct {
Database *storage.Database Database *storage.Database
SessionConfig sessions.Config SessionConfig sessions.Config
Session *sessions.Sessions Session *sessions.Sessions
Cron *cron.Cron
} }
type Info struct { type Info struct {
@ -32,10 +34,23 @@ func Index(r *Request) {
r.OkJson(info) 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 { func New() *WebAPI {
api := new(WebAPI) api := new(WebAPI)
api.Database = &storage.Database{} api.Database = &storage.Database{}
api.setupMonitoring()
api.router = &fasthttprouter.Router{} api.router = &fasthttprouter.Router{}
@ -71,6 +86,9 @@ func New() *WebAPI {
api.router.GET("/project/get/:id", LogRequestMiddleware(api.ProjectGet)) api.router.GET("/project/get/:id", LogRequestMiddleware(api.ProjectGet))
api.router.POST("/project/update/:id", LogRequestMiddleware(api.ProjectUpdate)) api.router.POST("/project/update/:id", LogRequestMiddleware(api.ProjectUpdate))
api.router.GET("/project/list", LogRequestMiddleware(api.ProjectGetAllProjects)) 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.POST("/task/create", LogRequestMiddleware(api.TaskCreate))
api.router.GET("/task/get/:project", LogRequestMiddleware(api.TaskGetFromProject)) api.router.GET("/task/get/:project", LogRequestMiddleware(api.TaskGetFromProject))

52
api/monitoring.go Normal file
View 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,
})
}

View File

@ -49,6 +49,12 @@ type GetAllProjectsResponse struct {
Projects *[]storage.Project `json:"projects,omitempty"` 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) { func (api *WebAPI) ProjectCreate(r *Request) {
createReq := &CreateProjectRequest{} createReq := &CreateProjectRequest{}
@ -203,3 +209,16 @@ func (api *WebAPI) ProjectGetAllProjects(r *Request) {
Projects: projects, 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,
})
}

View File

@ -3,7 +3,7 @@ server:
database: database:
conn_str: "user=task_tracker dbname=task_tracker_test sslmode=disable" conn_str: "user=task_tracker dbname=task_tracker_test sslmode=disable"
log_levels: ["debug", "error"] log_levels: ["debug", "error", "trace", "info", "warn"]
git: git:
webhook_secret: "very_secret_secret" webhook_secret: "very_secret_secret"
@ -19,3 +19,7 @@ log:
session: session:
cookie_name: "tt" cookie_name: "tt"
expiration: "25m" expiration: "25m"
monitoring:
snapshot_interval: "10s"
history_length: "3000h"

View File

@ -16,6 +16,8 @@ var Cfg struct {
DbLogLevels []logrus.Level DbLogLevels []logrus.Level
SessionCookieName string SessionCookieName string
SessionCookieExpiration time.Duration SessionCookieExpiration time.Duration
MonitoringInterval time.Duration
MonitoringHistory time.Duration
} }
func SetupConfig() { func SetupConfig() {
@ -40,5 +42,14 @@ func SetupConfig() {
} }
Cfg.SessionCookieName = viper.GetString("session.cookie_name") Cfg.SessionCookieName = viper.GetString("session.cookie_name")
Cfg.SessionCookieExpiration, err = time.ParseDuration(viper.GetString("session.expiration")) 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)
}
} }

View File

@ -1,44 +1,102 @@
package storage package storage
import (
"github.com/Sirupsen/logrus"
"time"
)
type ProjectMonitoringSnapshot struct { type ProjectMonitoringSnapshot struct {
NewTaskCount int64 NewTaskCount int64 `json:"new_task_count"`
FailedTaskCount int64 FailedTaskCount int64 `json:"failed_task_count"`
ClosedTaskCount int64 ClosedTaskCount int64 `json:"closed_task_count"`
WorkerAccessCount int64 WorkerAccessCount int64 `json:"worker_access_count"`
TimeStamp int64 AwaitingVerificationCount int64 `json:"awaiting_verification_count"`
TimeStamp int64 `json:"time_stamp"`
} }
func (database *Database) MakeProjectSnapshots() { func (database *Database) MakeProjectSnapshots() {
startTime := time.Now()
db := database.getDB() db := database.getDB()
_, err := db.Exec(` _, err := db.Exec(`
INSERT INTO project_monitoring_snapshot 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 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), (SELECT COUNT(*) FROM task WHERE task.project = project.id AND status = 2),
closed_task_count, closed_task_count,
(SELECT COUNT(*) FROM worker_has_access_to_project wa WHERE wa.project = project.id), (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') extract(epoch from now() at time zone 'utc')
FROM project`) FROM project`)
handleErr(err) 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() db := database.getDB()
snapshots := make([]ProjectMonitoringSnapshot, 0)
rows, err := db.Query(`SELECT new_task_count, failed_task_count, closed_task_count, rows, err := db.Query(`SELECT new_task_count, failed_task_count, closed_task_count,
worker_access_count, timestamp FROM project_monitoring_snapshot worker_access_count, awaiting_verification_task_count, timestamp FROM project_monitoring_snapshot
WHERE project=$1 AND timestamp BETWEEN $2 AND $3`, pid, from, to) WHERE project=$1 AND timestamp BETWEEN $2 AND $3 ORDER BY TIMESTAMP DESC `, pid, from, to)
handleErr(err) handleErr(err)
for rows.Next() { for rows.Next() {
s := ProjectMonitoringSnapshot{} 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) 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
} }

View File

@ -156,3 +156,31 @@ func (database Database) GetAllProjects() *[]Project {
return &projects 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
}

View File

@ -47,7 +47,7 @@ func (database *Database) SaveTask(task *Task, project int64, hash64 int64) erro
if err != nil { if err != nil {
logrus.WithError(err).WithFields(logrus.Fields{ logrus.WithError(err).WithFields(logrus.Fields{
"task": task, "task": task,
}).Warn("Database.saveTask INSERT task ERROR") }).Trace("Database.saveTask INSERT task ERROR")
return err return err
} }

View File

@ -3,8 +3,7 @@ server:
database: database:
conn_str: "user=task_tracker dbname=task_tracker_test sslmode=disable" conn_str: "user=task_tracker dbname=task_tracker_test sslmode=disable"
log_levels: ["debug", "error"] log_levels: ["debug", "error", "trace", "info", "warn"]
# log_levels: ["debug", "error", "trace", "info", "warning"]
git: git:
webhook_secret: "very_secret_secret" webhook_secret: "very_secret_secret"
@ -17,3 +16,7 @@ log:
session: session:
cookie_name: "tt_test" cookie_name: "tt_test"
expiration: "25m" expiration: "25m"
monitoring:
snapshot_interval: "10h"
history_length: "10h"

View File

@ -1,3 +1,29 @@
<pre> <div class="container">
{{authService.account | json}} <mat-card class="mat-elevation-z8" *ngIf="account">
</pre>
<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}}:&nbsp;
<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>

View File

@ -8,10 +8,13 @@ import {AuthService} from "../auth.service";
}) })
export class AccountDetailsComponent implements OnInit { export class AccountDetailsComponent implements OnInit {
account: Manager;
constructor(private authService: AuthService) { constructor(private authService: AuthService) {
} }
ngOnInit() { ngOnInit() {
this.account = this.authService.account;
} }
} }

View File

@ -18,7 +18,7 @@ export class ApiService {
} }
getLogs() { 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() { getProjects() {
@ -49,5 +49,12 @@ export class ApiService {
return this.http.get(this.url + "/account", this.options) 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)
}
} }

View File

@ -16,6 +16,7 @@ import {
MatFormFieldModule, MatFormFieldModule,
MatIconModule, MatIconModule,
MatInputModule, MatInputModule,
MatListModule,
MatMenuModule, MatMenuModule,
MatPaginatorIntl, MatPaginatorIntl,
MatPaginatorModule, MatPaginatorModule,
@ -98,7 +99,8 @@ export function createTranslateLoader(http: HttpClient) {
), ),
MatSelectModule, MatSelectModule,
MatProgressBarModule, MatProgressBarModule,
MatTabsModule MatTabsModule,
MatListModule
], ],
exports: [], exports: [],

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

View File

@ -28,16 +28,7 @@ export class LoginComponent implements OnInit {
} }
register() { register() {
this.apiService.register(this.credentials) this.authService.register(this.credentials)
.subscribe(
() => {
this.router.navigateByUrl("/account")
},
error => {
console.log(error);
this.messengerService.show(error.error.message);
}
)
} }
canCreate(): boolean { canCreate(): boolean {

View File

@ -10,3 +10,8 @@
.mat-cell { .mat-cell {
text-align: left; text-align: left;
} }
.checkbox-wrapper mat-checkbox {
margin: 3px 5px;
vertical-align: middle;
}

View File

@ -1,22 +1,31 @@
<div class="container"> <div class="container">
<div class="table-container"> <div class="table-container">
<mat-form-field> <div class="checkbox-wrapper">
<input matInput (keyup)="applyFilter($event.target.value)" [placeholder]="'logs.filter' | translate"> <mat-form-field style="margin-right: 10px">
</mat-form-field> <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"> <div class="mat-elevation-z8">
<mat-table [dataSource]="data" matSort matSortActive="timestamp" <mat-table [dataSource]="data" matSort matSortActive="timestamp"
matSortDirection="desc"> matSortDirection="desc">
<ng-container matColumnDef="level"> <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> *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>
<ng-container matColumnDef="timestamp"> <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> *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>
<ng-container matColumnDef="message"> <ng-container matColumnDef="message">
<mat-header-cell mat-sort-header *matHeaderCellDef>{{"logs.message" | translate}}</mat-header-cell> <mat-header-cell mat-sort-header *matHeaderCellDef>{{"logs.message" | translate}}</mat-header-cell>

View File

@ -1,6 +1,6 @@
import {Component, OnInit, ViewChild} from '@angular/core'; import {Component, OnInit, ViewChild} from '@angular/core';
import {ApiService} from "../api.service"; import {ApiService} from "../api.service";
import {LogEntry} from "../models/logentry"; import {getLogLevel, LogEntry} from "../models/logentry";
import _ from "lodash" import _ from "lodash"
import * as moment from "moment"; import * as moment from "moment";
@ -44,9 +44,9 @@ export class LogsComponent implements OnInit {
this.data.data = _.map(data["logs"], (entry) => { this.data.data = _.map(data["logs"], (entry) => {
return <LogEntry>{ return <LogEntry>{
message: entry.message, 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), data: JSON.stringify(JSON.parse(entry.data), null, 2),
level: entry.level level: getLogLevel(entry.level),
} }
}); });
} }

View File

@ -4,3 +4,32 @@ export interface LogEntry {
data: any, data: any,
timestamp: string, 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;
}
}

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

View File

@ -1,5 +1,6 @@
#timeline-wrapper { #timeline-wrapper {
width: 100%; width: 100%;
display: none;
} }
#status-pie-wrapper { #status-pie-wrapper {
@ -12,3 +13,28 @@
height: 50%; height: 50%;
width: 400px; 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;
}
}

View File

@ -3,6 +3,13 @@
<mat-card-title *ngIf="project">{{"dashboard.title" | translate}} "{{project.name}}"</mat-card-title> <mat-card-title *ngIf="project">{{"dashboard.title" | translate}} "{{project.name}}"</mat-card-title>
<mat-card-content style="padding: 2em 0 1em"> <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"> <p *ngIf="project">
{{"project.git_repo" | translate}}: {{"project.git_repo" | translate}}:
<a target="_blank" [href]="project['clone_url']">{{project.git_repo}}</a> <a target="_blank" [href]="project['clone_url']">{{project.git_repo}}</a>
@ -10,12 +17,13 @@
<p>{{"project.motd" | translate}}:</p> <p>{{"project.motd" | translate}}:</p>
<pre *ngIf="project">{{project.motd}}</pre> <pre *ngIf="project">{{project.motd}}</pre>
<div style="display: flex; align-items: center; justify-content: center"> <div style="display: flex; align-items: center; justify-content: center">
<div id="timeline-wrapper"> <div id="timeline-wrapper">
<canvas id="timeline"></canvas> <canvas id="timeline"></canvas>
</div> </div>
<div> <div [class.hidden]="noTasks" id="side-charts">
<div id="status-pie-wrapper"> <div id="status-pie-wrapper">
<canvas id="status-pie"></canvas> <canvas id="status-pie"></canvas>
</div> </div>
@ -23,6 +31,18 @@
<canvas id="assignees-pie"></canvas> <canvas id="assignees-pie"></canvas>
</div> </div>
</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> </div>
<mat-expansion-panel *ngIf="project" style="margin-top: 1em"> <mat-expansion-panel *ngIf="project" style="margin-top: 1em">

View File

@ -3,7 +3,8 @@ import {ApiService} from "../api.service";
import {Project} from "../models/project"; import {Project} from "../models/project";
import {ActivatedRoute} from "@angular/router"; import {ActivatedRoute} from "@angular/router";
import {Chart, ChartData, Point} from "chart.js"; import {Chart} from "chart.js";
import {AssignedTasks, MonitoringSnapshot} from "../models/monitoring";
@Component({ @Component({
@ -15,22 +16,30 @@ export class ProjectDashboardComponent implements OnInit {
private projectId; private projectId;
project: Project; project: Project;
noTasks = false;
private timeline: Chart; private timeline: Chart;
private statusPie: Chart; private statusPie: Chart;
private assigneesPir: Chart; private assigneesPie: Chart;
private colors = { private colors = {
new: "#76FF03", new: "#76FF03",
failed: "#FF3D00", failed: "#FF3D00",
closed: "#E0E0E0", closed: "#E0E0E0",
awaiting: "#FFB74D" awaiting: "#FFB74D",
random: [
"#3D5AFE", "#2979FF", "#2196F3",
"#7C4DFF", "#673AB7", "#7C4DFF",
"#FFC400", "#FFD740", "#FFC107",
"#FF3D00", "#FF6E40", "#FF5722",
"#76FF03", "#B2FF59", "#8BC34A"
]
}; };
tmpLabels = []; snapshots: MonitoringSnapshot[] = [];
tmpNew = []; lastSnapshot: MonitoringSnapshot;
tmpFailed = []; assignees: AssignedTasks[];
tmpClosed = [];
tmpAwaiting = [];
constructor(private apiService: ApiService, private route: ActivatedRoute) { constructor(private apiService: ApiService, private route: ActivatedRoute) {
} }
@ -42,19 +51,110 @@ export class ProjectDashboardComponent implements OnInit {
this.getProject(); this.getProject();
}); });
}
let n = 40; public refresh() {
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))
}
this.setupTimeline(); this.apiService.getMonitoringSnapshots(60, this.projectId)
this.setupStatusPie(); .subscribe((data: any) => {
this.setupAssigneesPie(); 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() { private setupTimeline() {
@ -64,49 +164,8 @@ export class ProjectDashboardComponent implements OnInit {
this.timeline = new Chart(ctx, { this.timeline = new Chart(ctx, {
type: "bar", type: "bar",
data: { data: {
labels: this.tmpLabels, labels: this.snapshots.map(s => s.time_stamp as any),
datasets: [ datasets: this.makeTimelineDataset(this.snapshots),
{
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,
},
],
}, },
options: { options: {
title: { title: {
@ -124,10 +183,6 @@ export class ProjectDashboardComponent implements OnInit {
ticks: { ticks: {
source: "auto" source: "auto"
}, },
time: {
unit: "minute",
unitStepSize: 10,
}
}] }]
}, },
tooltips: { tooltips: {
@ -136,12 +191,20 @@ export class ProjectDashboardComponent implements OnInit {
mode: "index", mode: "index",
position: "nearest", position: "nearest",
}, },
responsive: true
} }
}) })
} }
private setupStatusPie() { 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 elem = document.getElementById("status-pie") as any;
let ctx = elem.getContext("2d"); let ctx = elem.getContext("2d");
@ -158,10 +221,10 @@ export class ProjectDashboardComponent implements OnInit {
{ {
label: "Task status", label: "Task status",
data: [ data: [
10, this.lastSnapshot.new_task_count,
24, this.lastSnapshot.failed_task_count,
301, this.lastSnapshot.closed_task_count,
90, this.lastSnapshot.awaiting_verification_count,
], ],
backgroundColor: [ backgroundColor: [
this.colors.new, this.colors.new,
@ -186,7 +249,7 @@ export class ProjectDashboardComponent implements OnInit {
animation: { animation: {
animateScale: true, animateScale: true,
animateRotate: true animateRotate: true
} },
} }
}); });
} }
@ -196,30 +259,19 @@ export class ProjectDashboardComponent implements OnInit {
let elem = document.getElementById("assignees-pie") as any; let elem = document.getElementById("assignees-pie") as any;
let ctx = elem.getContext("2d"); 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", type: "doughnut",
data: { data: {
labels: [ labels: this.assignees.map(x => x.assignee),
"marc",
"simon",
"bernie",
"natasha",
],
datasets: [ datasets: [
{ {
label: "Task status", label: "Task status",
data: [ data: this.assignees.map(x => x.task_count),
10, backgroundColor: colors,
24,
1,
23,
],
backgroundColor: [
this.colors.new,
this.colors.failed,
this.colors.closed,
this.colors.awaiting
],
} }
], ],
}, },
@ -237,23 +289,35 @@ export class ProjectDashboardComponent implements OnInit {
animation: { animation: {
animateScale: true, animateScale: true,
animateRotate: true animateRotate: true
} },
} }
}); });
} }
private getProject() { private getProject() {
this.apiService.getProject(this.projectId).subscribe(data => { this.apiService.getProject(this.projectId).subscribe((data: any) => {
this.project = <Project>{ this.project = data.project;
id: data["project"]["id"],
name: data["project"]["name"], this.apiService.getMonitoringSnapshots(60, this.projectId)
clone_url: data["project"]["clone_url"], .subscribe((data: any) => {
git_repo: data["project"]["git_repo"], this.snapshots = data.snapshots;
motd: data["project"]["motd"], this.lastSnapshot = this.snapshots ? this.snapshots.sort((a, b) => {
priority: data["project"]["priority"], return b.time_stamp - a.time_stamp
version: data["project"]["version"], })[0] : null;
public: data["project"]["public"],
} this.setupTimeline();
this.setupStatusPie();
if (!this.snapshots) {
return
}
this.apiService.getAssigneeStats(this.projectId)
.subscribe((data: any) => {
this.assignees = data.assignees;
this.setupAssigneesPie();
});
})
}) })
} }
} }

View File

@ -9,7 +9,7 @@
}, },
"logs": { "logs": {
"filter": "Filter", "filter": "Filter",
"time": "Time", "time": "Time (UTC)",
"level": "Level", "level": "Level",
"message": "Message", "message": "Message",
"data": "Details", "data": "Details",
@ -18,7 +18,14 @@
"of": "of", "of": "of",
"items_per_page": "Items per page", "items_per_page": "Items per page",
"next_page": "Next 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": "Projects", "projects": "Projects",
@ -47,11 +54,13 @@
"create": "Create", "create": "Create",
"git_repo": "Git repository name", "git_repo": "Git repository name",
"motd": "Message of the day", "motd": "Message of the day",
"update": "Update" "update": "Edit"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard for", "title": "Dashboard for",
"metadata": "Project metadata" "metadata": "Project metadata",
"empty": "No tasks",
"refresh": "Refresh"
}, },
"login": { "login": {
"title": "Login", "title": "Login",
@ -64,5 +73,11 @@
"create_account": { "create_account": {
"title": "Register", "title": "Register",
"create": "Create account" "create": "Create account"
},
"account": {
"metadata": "Account metadata",
"title": "Account details",
"subtitle": "toto: subtitle",
"username": "Username"
} }
} }

View File

@ -9,7 +9,7 @@
}, },
"logs": { "logs": {
"filter": "Filtrer", "filter": "Filtrer",
"time": "Date", "time": "Date (UTC)",
"level": "Niveau", "level": "Niveau",
"message": "Message", "message": "Message",
"data": "Details", "data": "Details",
@ -18,7 +18,14 @@
"of": "de", "of": "de",
"items_per_page": "Items par page", "items_per_page": "Items par page",
"next_page": "Page suivante", "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": {
"projects": "Projets", "projects": "Projets",
@ -52,7 +59,9 @@
}, },
"dashboard": { "dashboard": {
"title": "Tableau de bord pour ", "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": { "login": {
"title": "Ouvrir un session", "title": "Ouvrir un session",
@ -65,6 +74,12 @@
"create_account": { "create_account": {
"title": "Créer un compte", "title": "Créer un compte",
"create": "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"
} }
} }

View File

@ -6,7 +6,7 @@
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<base href="/"> <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"> <link rel="icon" type="image/x-icon" href="favicon.ico">
</head> </head>
<body> <body>

View File

@ -78,3 +78,11 @@ body {
.mat-tab-body { .mat-tab-body {
padding-top: 1em; padding-top: 1em;
} }
pre {
color: #616161;
}
.hidden {
display: none !important;
}