feat(stats): add chart to ui

This commit is contained in:
Denis Gukov 2024-12-09 01:19:26 +05:00
parent 830ada9377
commit 5263bb5bcf
9 changed files with 815 additions and 155 deletions

View File

@ -2,11 +2,11 @@ package projects
import (
"errors"
"github.com/gorilla/context"
"github.com/semaphoreui/semaphore/api/helpers"
"github.com/semaphoreui/semaphore/db"
"github.com/semaphoreui/semaphore/services/tasks"
"github.com/semaphoreui/semaphore/util"
"github.com/gorilla/context"
log "github.com/sirupsen/logrus"
"net/http"
"strconv"
@ -218,3 +218,16 @@ func RemoveTask(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
func GetTaskStats(w http.ResponseWriter, r *http.Request) {
project := context.Get(r, "project").(db.Project)
stats, err := helpers.Store(r).GetTaskStats(project.ID, nil, db.TaskStatUnitDay, db.TaskFilter{})
if err != nil {
util.LogErrorWithFields(err, log.Fields{"error": "Bad request. Cannot get task stats from database"})
w.WriteHeader(http.StatusBadRequest)
return
}
helpers.WriteJSON(w, http.StatusOK, stats)
}

View File

@ -317,6 +317,7 @@ func Route() *mux.Router {
projectTmplManagement.HandleFunc("/{template_id}/tasks", projects.GetAllTasks).Methods("GET")
projectTmplManagement.HandleFunc("/{template_id}/tasks/last", projects.GetLastTasks).Methods("GET")
projectTmplManagement.HandleFunc("/{template_id}/schedules", projects.GetTemplateSchedules).Methods("GET")
projectTmplManagement.HandleFunc("/{template_id}/stats", projects.GetTaskStats).Methods("GET")
projectTaskManagement := projectUserAPI.PathPrefix("/tasks").Subrouter()
projectTaskManagement.Use(projects.GetTaskMiddleware)

View File

@ -92,6 +92,24 @@ func (e *ValidationError) Error() string {
return e.Message
}
type TaskStatUnit string
const TaskStatUnitDay TaskStatUnit = "day"
const TaskStatUnitWeek TaskStatUnit = "week"
const TaskStatUnitMonth TaskStatUnit = "month"
type TaskFilter struct {
From *time.Time `json:"from"`
To *time.Time `json:"to"`
UserID *int `json:"user_id"`
}
type TaskStat struct {
Date string `json:"date"`
CountByStatus map[string]int `json:"count_by_status"`
AvgDuration int `json:"avg_duration"`
}
type Store interface {
// Connect connects to the database.
// Token parameter used if PermanentConnection returns false.
@ -271,6 +289,8 @@ type Store interface {
GetTemplateVaults(projectID int, templateID int) ([]TemplateVault, error)
CreateTemplateVault(vault TemplateVault) (TemplateVault, error)
UpdateTemplateVaults(projectID int, templateID int, vaults []TemplateVault) error
GetTaskStats(projectID int, templateID *int, unit TaskStatUnit, filter TaskFilter) ([]TaskStat, error)
}
var AccessKeyProps = ObjectProps{

View File

@ -820,6 +820,11 @@ func (d *BoltDb) isObjectInUse(bucketID int, objProps db.ObjectProps, objID obje
return
}
func (d *BoltDb) GetTaskStats(projectID int, templateID *int, unit db.TaskStatUnit, filter db.TaskFilter) (stats []db.TaskStat, err error) {
err = fmt.Errorf("not implmenented")
return
}
func CreateTestStore() *BoltDb {
util.Config = &util.ConfigType{
BoltDb: &util.DbConfig{},

View File

@ -4,11 +4,6 @@ import (
"database/sql"
"embed"
"fmt"
"reflect"
"regexp"
"strconv"
"strings"
"github.com/Masterminds/squirrel"
"github.com/go-gorp/gorp/v3"
_ "github.com/go-sql-driver/mysql" // imports mysql driver
@ -16,6 +11,10 @@ import (
"github.com/semaphoreui/semaphore/db"
"github.com/semaphoreui/semaphore/util"
log "github.com/sirupsen/logrus"
"reflect"
"regexp"
"strconv"
"strings"
)
type SqlDb struct {
@ -761,3 +760,58 @@ func (d *SqlDb) GetObjectReferences(objectProps db.ObjectProps, referringObjectP
return
}
func (d *SqlDb) GetTaskStats(projectID int, templateID *int, unit db.TaskStatUnit, filter db.TaskFilter) (stats []db.TaskStat, err error) {
if unit != db.TaskStatUnitDay {
err = fmt.Errorf("only day unit is supported")
return
}
var res []struct {
Date string `db:"date"`
Status string `db:"status"`
Count int `db:"count"`
}
q := squirrel.Select("DATE(created) AS date, status, COUNT(*) AS count").
From("task").
Where("project_id=?", projectID).
GroupBy("DATE(created), status").
OrderBy("DATE(created) DESC")
if templateID != nil {
q = q.Where("template_id=?", *templateID)
}
if filter.UserID != nil {
q = q.Where("user_id=?", *filter.UserID)
}
query, args, err := q.ToSql()
if err != nil {
return
}
_, err = d.selectAll(&res, query, args...)
var date string
var stat *db.TaskStat
for _, r := range res {
if date != r.Date {
date = r.Date
stat = &db.TaskStat{
Date: date,
CountByStatus: make(map[string]int),
}
stats = append(stats, *stat)
}
stat.CountByStatus[r.Status] = r.Count
}
return
}

738
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,108 @@
<template>
<BarChartGenerator
:chart-id="chartId"
:dataset-id-key="chartId"
:chart-options="chartOptions"
:chart-data="chartData"
/>
</template>
<script>
import {
BarElement,
CategoryScale,
Chart as ChartJS,
Legend,
LinearScale,
LineElement,
PointElement,
TimeScale,
Title,
Tooltip,
} from 'chart.js';
import 'chartjs-adapter-moment';
ChartJS.register(
Title,
Tooltip,
Legend,
BarElement,
LineElement,
LinearScale,
CategoryScale,
PointElement,
TimeScale,
);
export default {
name: 'LineChart',
props: {
sourceData: Array,
chartId: String,
},
computed: {
chartData() {
return {
labels: (this.sourceData || []).map((row) => new Date(row.date)),
datasets: [
{
label: 'Success',
borderColor: '#4caf50',
backgroundColor: '#4caf50',
data: (this.sourceData || []).map((row) => row.count_by_status.success),
cubicInterpolationMode: 'monotone',
},
{
label: 'Failed',
borderColor: '#ff5252',
backgroundColor: '#ff5252',
data: (this.sourceData || []).map((row) => row.count_by_status.error),
cubicInterpolationMode: 'monotone',
},
{
label: 'Stopped',
borderColor: '#555',
backgroundColor: '#555',
data: (this.sourceData || []).map((row) => row.count_by_status.stopped),
cubicInterpolationMode: 'monotone',
},
],
};
},
},
data() {
return {
chartOptions: {
scales: {
x: {
stacked: true,
type: 'time',
time: {
unit: 'day',
},
},
y: {
stacked: true,
},
},
responsive: true,
maintainAspectRatio: false,
animation: {
duration: 0,
},
},
};
},
};
</script>

View File

@ -2,6 +2,8 @@ import Vue from 'vue';
import moment from 'moment';
import axios from 'axios';
import { AnsiUp } from 'ansi_up';
import { Line, Bar } from 'vue-chartjs/legacy';
import App from './App.vue';
import router from './router';
import vuetify from './plugins/vuetify';
@ -82,6 +84,9 @@ Vue.filter('formatMilliseconds', (value) => {
return moment.duration(duration, 'milliseconds').humanize();
});
Vue.component('LineChartGenerator', Line);
Vue.component('BarChartGenerator', Bar);
new Vue({
router,
vuetify,

View File

@ -85,6 +85,9 @@
</v-list>
</v-col>
</v-row>
<LineChart :source-data="stats"/>
</v-container>
</template>
@ -94,8 +97,11 @@ import {
TEMPLATE_TYPE_ICONS,
TEMPLATE_TYPE_TITLES,
} from '@/lib/constants';
import axios from 'axios';
import LineChart from '@/components/LineChart.vue';
export default {
components: { LineChart },
props: {
template: Object,
repositories: Array,
@ -107,7 +113,15 @@ export default {
TEMPLATE_TYPE_ICONS,
TEMPLATE_TYPE_TITLES,
TEMPLATE_TYPE_ACTION_TITLES,
stats: null,
};
},
async created() {
this.stats = (await axios({
method: 'get',
url: `/api/project/${this.template.project_id}/templates/${this.template.id}/stats`,
responseType: 'json',
})).data;
},
};
</script>