worker stats dashboard, fixed logs page, mobile support for navbar & project dashboard

This commit is contained in:
simon987 2019-02-09 16:34:15 -05:00
parent 4ef4752c14
commit a6802c7109
19 changed files with 259 additions and 58 deletions

View File

@ -78,6 +78,7 @@ func New() *WebAPI {
api.router.POST("/worker/create", LogRequestMiddleware(api.WorkerCreate)) api.router.POST("/worker/create", LogRequestMiddleware(api.WorkerCreate))
api.router.POST("/worker/update", LogRequestMiddleware(api.WorkerUpdate)) api.router.POST("/worker/update", LogRequestMiddleware(api.WorkerUpdate))
api.router.GET("/worker/get/:id", LogRequestMiddleware(api.WorkerGet)) api.router.GET("/worker/get/:id", LogRequestMiddleware(api.WorkerGet))
api.router.GET("/worker/stats", LogRequestMiddleware(api.GetAllWorkerStats))
api.router.POST("/access/grant", LogRequestMiddleware(api.WorkerGrantAccess)) api.router.POST("/access/grant", LogRequestMiddleware(api.WorkerGrantAccess))
api.router.POST("/access/remove", LogRequestMiddleware(api.WorkerRemoveAccess)) api.router.POST("/access/remove", LogRequestMiddleware(api.WorkerRemoveAccess))

View File

@ -44,6 +44,12 @@ type WorkerAccessResponse struct {
Message string `json:"message"` Message string `json:"message"`
} }
type GetAllWorkerStatsResponse struct {
Ok bool `json:"ok"`
Message string `json:"message,omitempty"`
Stats *[]storage.WorkerStats `json:"stats"`
}
func (api *WebAPI) WorkerCreate(r *Request) { func (api *WebAPI) WorkerCreate(r *Request) {
workerReq := &CreateWorkerRequest{} workerReq := &CreateWorkerRequest{}
@ -208,6 +214,16 @@ func (api *WebAPI) WorkerUpdate(r *Request) {
} }
} }
func (api *WebAPI) GetAllWorkerStats(r *Request) {
stats := api.Database.GetAllWorkerStats()
r.OkJson(GetAllWorkerStatsResponse{
Ok: true,
Stats: stats,
})
}
func (api *WebAPI) workerCreate(request *CreateWorkerRequest, identity *storage.Identity) (*storage.Worker, error) { func (api *WebAPI) workerCreate(request *CreateWorkerRequest, identity *storage.Identity) (*storage.Worker, error) {
if request.Alias == "" { if request.Alias == "" {

View File

@ -19,7 +19,8 @@ CREATE TABLE worker
alias TEXT, alias TEXT,
created INTEGER, created INTEGER,
identity INTEGER REFERENCES worker_identity (id), identity INTEGER REFERENCES worker_identity (id),
secret BYTEA secret BYTEA,
closed_task_count INTEGER DEFAULT 0
); );
CREATE TABLE project CREATE TABLE project
@ -103,6 +104,7 @@ CREATE OR REPLACE FUNCTION on_task_delete_proc() RETURNS TRIGGER AS
$$ $$
BEGIN BEGIN
UPDATE project SET closed_task_count=closed_task_count + 1 WHERE id = OLD.project; UPDATE project SET closed_task_count=closed_task_count + 1 WHERE id = OLD.project;
UPDATE worker SET closed_task_count=closed_task_count + 1 WHERE id = OLD.assignee;
RETURN OLD; RETURN OLD;
END; END;
$$ LANGUAGE 'plpgsql'; $$ LANGUAGE 'plpgsql';

View File

@ -19,6 +19,11 @@ type Worker struct {
Secret []byte `json:"secret"` Secret []byte `json:"secret"`
} }
type WorkerStats struct {
Alias string `json:"alias"`
ClosedTaskCount int64 `json:"closed_task_count"`
}
func (database *Database) SaveWorker(worker *Worker) { func (database *Database) SaveWorker(worker *Worker) {
db := database.getDB() db := database.getDB()
@ -163,3 +168,19 @@ func (database *Database) UpdateWorker(worker *Worker) bool {
return rowsAffected == 1 return rowsAffected == 1
} }
func (database *Database) GetAllWorkerStats() *[]WorkerStats {
db := database.getDB()
rows, err := db.Query(`SELECT alias, closed_task_count FROM worker WHERE closed_task_count>0 LIMIT 50`)
handleErr(err)
stats := make([]WorkerStats, 0)
for rows.Next() {
s := WorkerStats{}
_ = rows.Scan(&s.Alias, &s.ClosedTaskCount)
stats = append(stats, s)
}
return &stats
}

View File

@ -19,7 +19,8 @@ CREATE TABLE worker
alias TEXT, alias TEXT,
created INTEGER, created INTEGER,
identity INTEGER REFERENCES worker_identity (id), identity INTEGER REFERENCES worker_identity (id),
secret BYTEA secret BYTEA,
closed_task_count INTEGER DEFAULT 0
); );
CREATE TABLE project CREATE TABLE project
@ -76,8 +77,8 @@ CREATE TABLE log_entry
CREATE TABLE manager CREATE TABLE manager
( (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
username TEXT, username TEXT UNIQUE,
password TEXT, password BYTEA,
website_admin BOOLEAN website_admin BOOLEAN
); );
@ -103,6 +104,7 @@ CREATE OR REPLACE FUNCTION on_task_delete_proc() RETURNS TRIGGER AS
$$ $$
BEGIN BEGIN
UPDATE project SET closed_task_count=closed_task_count + 1 WHERE id = OLD.project; UPDATE project SET closed_task_count=closed_task_count + 1 WHERE id = OLD.project;
UPDATE worker SET closed_task_count=closed_task_count + 1 WHERE id = OLD.assignee;
RETURN OLD; RETURN OLD;
END; END;
$$ LANGUAGE 'plpgsql'; $$ LANGUAGE 'plpgsql';

View File

@ -17,8 +17,8 @@ export class ApiService {
) { ) {
} }
getLogs() { getLogs(level: number) {
return this.http.post(this.url + "/logs", "{\"level\":4, \"since\":1}", this.options); return this.http.post(this.url + "/logs", {level: level, since: 1}, this.options);
} }
getProjects() { getProjects() {
@ -57,4 +57,8 @@ export class ApiService {
return this.http.get(this.url + `/project/assignees/${project}`, this.options) return this.http.get(this.url + `/project/assignees/${project}`, this.options)
} }
getWorkerStats() {
return this.http.get(this.url + `/worker/stats`, this.options)
}
} }

View File

@ -10,6 +10,7 @@ import {filter} from "rxjs/operators";
import {TranslateService} from "@ngx-translate/core"; import {TranslateService} from "@ngx-translate/core";
import {LoginComponent} from "./login/login.component"; import {LoginComponent} from "./login/login.component";
import {AccountDetailsComponent} from "./account-details/account-details.component"; import {AccountDetailsComponent} from "./account-details/account-details.component";
import {WorkerDashboardComponent} from "./worker-dashboard/worker-dashboard.component";
const routes: Routes = [ const routes: Routes = [
{path: "log", component: LogsComponent}, {path: "log", component: LogsComponent},
@ -18,7 +19,8 @@ const routes: Routes = [
{path: "projects", component: ProjectListComponent}, {path: "projects", component: ProjectListComponent},
{path: "project/:id", component: ProjectDashboardComponent}, {path: "project/:id", component: ProjectDashboardComponent},
{path: "project/:id/update", component: UpdateProjectComponent}, {path: "project/:id/update", component: UpdateProjectComponent},
{path: "new_project", component: CreateProjectComponent} {path: "new_project", component: CreateProjectComponent},
{path: "workers", component: WorkerDashboardComponent}
]; ];
@NgModule({ @NgModule({

View File

@ -8,3 +8,17 @@
.nav-link { .nav-link {
} }
.large-nav {
display: none;
}
@media (min-width: 768px) {
.large-nav {
display: initial;
}
.small-nav {
display: none;
}
}

View File

@ -1,18 +1,47 @@
<mat-toolbar color="primary"> <mat-toolbar color="primary">
<button mat-button [class.mat-accent]="router.url == '/'" class="nav-title" [routerLink]="''">{{"nav.title" | translate}}</button> <div class="large-nav">
<button mat-button [class.mat-accent]="router.url == '/log'" class="nav-link" [routerLink]="'log'">{{"nav.logs" | translate}}</button> <button mat-button [class.mat-accent]="router.url == '/'" class="nav-title"
<button mat-button [class.mat-accent]="router.url == '/projects'" class="nav-link" [routerLink]="'projects'">{{"nav.project_list" | translate}}</button> [routerLink]="''">{{"nav.title" | translate}}</button>
<button mat-button [class.mat-accent]="router.url == '/new_project'" class="nav-link" [routerLink]="'new_project'">{{"nav.new_project" | translate}}</button> <button mat-button [class.mat-accent]="router.url == '/log'" class="nav-link"
[routerLink]="'log'">{{"nav.logs" | translate}}</button>
<button mat-button [class.mat-accent]="router.url == '/projects'" class="nav-link"
[routerLink]="'projects'">{{"nav.project_list" | translate}}</button>
<button mat-button [class.mat-accent]="router.url == '/new_project'" class="nav-link"
[routerLink]="'new_project'">{{"nav.new_project" | translate}}</button>
<button mat-button [class.mat-accent]="router.url == '/workers'" class="nav-link"
[routerLink]="'workers'">{{"nav.worker_dashboard" | translate}}</button>
</div>
<div class="small-nav">
<button mat-button [matMenuTriggerFor]="smallNav">
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #smallNav>
<button mat-menu-item [class.mat-accent]="router.url == '/'" class="nav-title"
[routerLink]="''">{{"nav.title" | translate}}</button>
<button mat-menu-item [class.mat-accent]="router.url == '/log'" class="nav-link"
[routerLink]="'log'">{{"nav.logs" | translate}}</button>
<button mat-menu-item [class.mat-accent]="router.url == '/projects'" class="nav-link"
[routerLink]="'projects'">{{"nav.project_list" | translate}}</button>
<button mat-menu-item [class.mat-accent]="router.url == '/new_project'" class="nav-link"
[routerLink]="'new_project'">{{"nav.new_project" | translate}}</button>
<button mat-menu-item [class.mat-accent]="router.url == '/workers'" class="nav-link"
[routerLink]="'workers'">{{"nav.worker_dashboard" | translate}}</button>
</mat-menu>
</div>
<span class="nav-spacer"></span> <span class="nav-spacer"></span>
<button mat-button [class.mat-accent]="router.url == '/login'" class="nav-link" <button mat-button [class.mat-accent]="router.url == '/login'" class="nav-link"
[routerLink]="'login'">{{"nav.login" | translate}}</button> [routerLink]="'login'">{{"nav.login" | translate}}</button>
<mat-form-field [floatLabel]="'never'">
<mat-select [placeholder]="'nav.langSelect' | translate" (selectionChange)="langChange($event)"> <button mat-button [matMenuTriggerFor]="langMenu">
<mat-option *ngFor="let lang of langList" [value]="lang.lang"> {{"nav.lang_select" | translate}}
<mat-icon>arrow_drop_down</mat-icon>
</button>
<mat-menu #langMenu>
<button mat-menu-item *ngFor="let lang of langList" (click)="langChange(lang)">
{{lang.display}} {{lang.display}}
</mat-option> </button>
</mat-select> </mat-menu>
</mat-form-field>
</mat-toolbar> </mat-toolbar>

View File

@ -1,7 +1,6 @@
import {Component} from '@angular/core'; import {Component} from '@angular/core';
import {Router} from '@angular/router'; import {Router} from '@angular/router';
import {TranslateService} from "@ngx-translate/core"; import {TranslateService} from "@ngx-translate/core";
import {MatSelectChange} from "@angular/material";
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@ -10,8 +9,8 @@ import {MatSelectChange} from "@angular/material";
}) })
export class AppComponent { export class AppComponent {
langChange(event: MatSelectChange) { langChange(lang: any) {
this.translate.use(event.value) this.translate.use(lang.lang)
} }
langList: any[] = [ langList: any[] = [

View File

@ -9,6 +9,7 @@ import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import { import {
MatAutocompleteModule, MatAutocompleteModule,
MatButtonModule, MatButtonModule,
MatButtonToggleModule,
MatCardModule, MatCardModule,
MatCheckboxModule, MatCheckboxModule,
MatDividerModule, MatDividerModule,
@ -45,6 +46,7 @@ import {TranslateHttpLoader} from "@ngx-translate/http-loader";
import {TranslatedPaginator} from "./TranslatedPaginatorConfiguration"; import {TranslatedPaginator} from "./TranslatedPaginatorConfiguration";
import {LoginComponent} from './login/login.component'; import {LoginComponent} from './login/login.component';
import {AccountDetailsComponent} from './account-details/account-details.component'; import {AccountDetailsComponent} from './account-details/account-details.component';
import {WorkerDashboardComponent} from './worker-dashboard/worker-dashboard.component';
export function createTranslateLoader(http: HttpClient) { export function createTranslateLoader(http: HttpClient) {
@ -63,6 +65,7 @@ export function createTranslateLoader(http: HttpClient) {
SnackBarComponent, SnackBarComponent,
LoginComponent, LoginComponent,
AccountDetailsComponent, AccountDetailsComponent,
WorkerDashboardComponent,
], ],
imports: [ imports: [
BrowserModule, BrowserModule,
@ -100,7 +103,8 @@ export function createTranslateLoader(http: HttpClient) {
MatSelectModule, MatSelectModule,
MatProgressBarModule, MatProgressBarModule,
MatTabsModule, MatTabsModule,
MatListModule MatListModule,
MatButtonToggleModule
], ],
exports: [], exports: [],

View File

@ -11,7 +11,7 @@
text-align: left; text-align: left;
} }
.checkbox-wrapper mat-checkbox { mat-button-toggle-group {
margin: 3px 5px; margin: 3px 5px;
vertical-align: middle; vertical-align: middle;
} }

View File

@ -1,16 +1,23 @@
<div class="container"> <div class="container">
<div class="table-container"> <div class="table-container">
<div class="checkbox-wrapper">
<mat-form-field style="margin-right: 10px"> <mat-form-field style="margin-right: 10px">
<input matInput (keyup)="applyFilter($event.target.value)" [placeholder]="'logs.filter' | translate"> <input matInput (keyup)="applyFilter($event.target.value)" [placeholder]="'logs.filter' | translate">
</mat-form-field> </mat-form-field>
<mat-checkbox>{{"logs.fatal" | translate}}</mat-checkbox> <mat-button-toggle-group name="level" aria-label="Font Style" (change)="filterLevelChange($event)">
<mat-checkbox>{{"logs.panic" | translate}}</mat-checkbox> <mat-button-toggle value="1">{{"logs.fatal" | translate}}</mat-button-toggle>
<mat-checkbox>{{"logs.error" | translate}}</mat-checkbox> <mat-button-toggle value="2">{{"logs.panic" | translate}}</mat-button-toggle>
<mat-checkbox>{{"logs.warn" | translate}}</mat-checkbox> <mat-button-toggle value="3">{{"logs.error" | translate}}</mat-button-toggle>
<mat-checkbox>{{"logs.info" | translate}}</mat-checkbox> <mat-button-toggle value="4">{{"logs.warn" | translate}}</mat-button-toggle>
<mat-checkbox>{{"logs.debug" | translate}}</mat-checkbox> <mat-button-toggle value="5">{{"logs.info" | translate}}</mat-button-toggle>
</div> <mat-button-toggle value="6">{{"logs.debug" | translate}}</mat-button-toggle>
<mat-button-toggle value="7">{{"logs.trace" | translate}}</mat-button-toggle>
</mat-button-toggle-group>
<button mat-raised-button style="float: right"
[title]="'dashboard.refresh' | translate"
(click)="refresh()">
<mat-icon>refresh</mat-icon>
</button>
<div class="mat-elevation-z8"> <div class="mat-elevation-z8">
<mat-table [dataSource]="data" matSort matSortActive="timestamp" <mat-table [dataSource]="data" matSort matSortActive="timestamp"

View File

@ -4,7 +4,7 @@ import {getLogLevel, LogEntry} from "../models/logentry";
import _ from "lodash" import _ from "lodash"
import * as moment from "moment"; import * as moment from "moment";
import {MatPaginator, MatSort, MatTableDataSource} from "@angular/material"; import {MatButtonToggleChange, MatPaginator, MatSort, MatTableDataSource} from "@angular/material";
@Component({ @Component({
selector: 'app-logs', selector: 'app-logs',
@ -15,6 +15,7 @@ export class LogsComponent implements OnInit {
logs: LogEntry[] = []; logs: LogEntry[] = [];
data: MatTableDataSource<LogEntry>; data: MatTableDataSource<LogEntry>;
filterLevel: number = 1;
logsCols: string[] = ["level", "timestamp", "message", "data"]; logsCols: string[] = ["level", "timestamp", "message", "data"];
@ViewChild(MatPaginator) paginator: MatPaginator; @ViewChild(MatPaginator) paginator: MatPaginator;
@ -25,21 +26,25 @@ export class LogsComponent implements OnInit {
} }
ngOnInit() { ngOnInit() {
this.getLogs();
this.data.paginator = this.paginator; this.data.paginator = this.paginator;
this.data.sort = this.sort; this.data.sort = this.sort;
// interval(5000).subscribe(() => {
// this.getLogs();
// })
} }
applyFilter(filter: string) { applyFilter(filter: string) {
this.data.filter = filter.trim().toLowerCase(); this.data.filter = filter.trim().toLowerCase();
} }
private getLogs() { filterLevelChange(event: MatButtonToggleChange) {
this.apiService.getLogs().subscribe( this.filterLevel = Number(event.value);
this.getLogs(Number(event.value))
}
private refresh() {
this.getLogs(this.filterLevel)
}
private getLogs(level: number) {
this.apiService.getLogs(level).subscribe(
data => { data => {
this.data.data = _.map(data["logs"], (entry) => { this.data.data = _.map(data["logs"], (entry) => {
return <LogEntry>{ return <LogEntry>{

View File

@ -0,0 +1,18 @@
<div class="container">
<mat-card class="mat-elevation-z8">
<mat-card-header style="float:left">
<mat-card-title>{{"workers.title" | translate}}</mat-card-title>
<mat-card-subtitle>{{"workers.subtitle" | translate}}</mat-card-subtitle>
</mat-card-header>
<button mat-raised-button style="float: right"
[title]="'dashboard.refresh' | translate"
(click)="refresh()"
>
<mat-icon>refresh</mat-icon>
</button>
<mat-card-content>
<canvas id="worker-stats"></canvas>
</mat-card-content>
</mat-card>
</div>

View File

@ -0,0 +1,66 @@
import {Component, OnInit} from '@angular/core';
import {ApiService} from "../api.service";
import {Chart} from "chart.js";
@Component({
selector: 'app-worker-dashboard',
templateUrl: './worker-dashboard.component.html',
styleUrls: ['./worker-dashboard.component.css']
})
export class WorkerDashboardComponent implements OnInit {
private chart: Chart;
constructor(private apiService: ApiService) {
}
ngOnInit() {
this.setupChart();
this.refresh()
}
private refresh() {
this.apiService.getWorkerStats()
.subscribe((data: any) => {
this.updateChart(data.stats)
}
)
}
private setupChart() {
let elem = document.getElementById("worker-stats") as any;
let ctx = elem.getContext("2d");
this.chart = new Chart(ctx, {
type: "bar",
data: {
labels: [],
datasets: [],
},
options: {
title: {
display: false,
},
legend: {
display: false
},
tooltips: {
enabled: true,
},
responsive: true
}
})
}
private updateChart(data) {
this.chart.data.labels = data.map(w => w.alias);
this.chart.data.datasets = [{
data: data.map(w => w.closed_task_count),
backgroundColor: "#FF3D00"
}];
this.chart.update();
}
}

View File

@ -1,11 +1,12 @@
{ {
"nav": { "nav": {
"title": "task_tracker", "title": "task_tracker",
"langSelect": "Language", "lang_select": "Language",
"logs": "Logs", "logs": "Logs",
"project_list": "Projects", "project_list": "Projects",
"new_project": "New Project", "new_project": "New Project",
"login": "Login" "login": "Login",
"worker_dashboard": "Workers"
}, },
"logs": { "logs": {
"filter": "Filter", "filter": "Filter",
@ -40,7 +41,8 @@
"new_project": "New project", "new_project": "New project",
"login": "Login", "login": "Login",
"new_account": "Create account", "new_account": "Create account",
"account": "Account details" "account": "Account details",
"workers": "Workers"
}, },
"project": { "project": {
"name": "Project name", "name": "Project name",
@ -67,8 +69,7 @@
"login": "Login", "login": "Login",
"username": "Username", "username": "Username",
"password": "Password", "password": "Password",
"repeat_password": "Repeat password", "repeat_password": "Repeat password"
"create_account": ""
}, },
"create_account": { "create_account": {
"title": "Register", "title": "Register",
@ -79,5 +80,9 @@
"title": "Account details", "title": "Account details",
"subtitle": "toto: subtitle", "subtitle": "toto: subtitle",
"username": "Username" "username": "Username"
},
"workers": {
"title": "Completed tasks per worker",
"subtitle": "Real-time data for all projects"
} }
} }

View File

@ -1,11 +1,12 @@
{ {
"nav": { "nav": {
"title": "task_tracker (fr)", "title": "task_tracker (fr)",
"langSelect": "Langue", "lang_select": "Langue",
"logs": "Journal", "logs": "Journal",
"project_list": "Projets", "project_list": "Projets",
"new_project": "Nouveau projet", "new_project": "Nouveau projet",
"login": "Ouvrir un session" "login": "Ouvrir un session",
"worker_dashboard": "Workers"
}, },
"logs": { "logs": {
"filter": "Filtrer", "filter": "Filtrer",
@ -41,7 +42,8 @@
"update": "Modifier", "update": "Modifier",
"login": "Ouverture de session", "login": "Ouverture de session",
"new_account": "Création de compte", "new_account": "Création de compte",
"account": "Compte" "account": "Compte",
"workers": "Workers"
}, },
"project": { "project": {
"name": "Nom du projet", "name": "Nom du projet",
@ -80,6 +82,10 @@
"title": "Détails du compte", "title": "Détails du compte",
"subtitle": "toto: sous-titre", "subtitle": "toto: sous-titre",
"username": "Nom d'utilisateur" "username": "Nom d'utilisateur"
},
"workers": {
"title": "Tâches complétés par worker",
"subtitle": "Données en temps réél pour tous les projets"
} }
} }