Skip to content

Commit

Permalink
Showing 11 changed files with 1,079 additions and 165 deletions.
51 changes: 50 additions & 1 deletion api/projects/tasks.go
Original file line number Diff line number Diff line change
@@ -2,14 +2,15 @@ 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"
"time"
)

// AddTask inserts a task into the database and returns a header or returns error
@@ -218,3 +219,51 @@ 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)

var tplID *int
if tpl := context.Get(r, "template"); tpl != nil {
id := tpl.(db.Template).ID
tplID = &id
}

filter := db.TaskFilter{}

if start := r.URL.Query().Get("start"); start != "" {
d, err := time.Parse("2006-01-02", start)
if err != nil {
helpers.WriteErrorStatus(w, "Invalid start date", http.StatusBadRequest)
return
}
filter.Start = &d
}

if end := r.URL.Query().Get("end"); end != "" {
d, err := time.Parse("2006-01-02", end)
if err != nil {
helpers.WriteErrorStatus(w, "Invalid end date", http.StatusBadRequest)
return
}
filter.End = &d
}

if userId := r.URL.Query().Get("user_id"); userId != "" {
u, err := strconv.Atoi(userId)
if err != nil {
helpers.WriteErrorStatus(w, "Invalid user_id", http.StatusBadRequest)
return
}
filter.UserID = &u
}

stats, err := helpers.Store(r).GetTaskStats(project.ID, tplID, db.TaskStatUnitDay, filter)
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)
}
1 change: 1 addition & 0 deletions api/router.go
Original file line number Diff line number Diff line change
@@ -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)
21 changes: 21 additions & 0 deletions db/Store.go
Original file line number Diff line number Diff line change
@@ -4,6 +4,7 @@ import (
"database/sql/driver"
"encoding/json"
"errors"
"github.com/semaphoreui/semaphore/pkg/task_logger"
"reflect"
"strings"
"time"
@@ -92,6 +93,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 {
Start *time.Time `json:"start"`
End *time.Time `json:"end"`
UserID *int `json:"user_id"`
}

type TaskStat struct {
Date string `json:"date"`
CountByStatus map[task_logger.TaskStatus]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 +290,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{
84 changes: 84 additions & 0 deletions db/bolt/BoltDb.go
Original file line number Diff line number Diff line change
@@ -3,7 +3,9 @@ package bolt
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"github.com/semaphoreui/semaphore/pkg/task_logger"
"reflect"
"sort"
"strings"
@@ -820,6 +822,88 @@ func (d *BoltDb) isObjectInUse(bucketID int, objProps db.ObjectProps, objID obje
return
}

var ErrEndOfRange = errors.New("end of range")

func (d *BoltDb) 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
}

stats = make([]db.TaskStat, 0)

err = d.db.View(func(tx *bbolt.Tx) error {

b := tx.Bucket(makeBucketId(db.TaskProps, 0))
var c enumerable
if b == nil {
c = emptyEnumerable{}
} else {
c = b.Cursor()
}

var date string
var stat *db.TaskStat

err2 := apply(c, db.TaskProps, db.RetrieveQueryParams{}, func(i interface{}) bool {
task := i.(db.Task)

if task.ProjectID != projectID {
return false
}

if templateID != nil && task.TemplateID != *templateID {
return false
}

if filter.End != nil && task.Created.After(*filter.End) {
return false
}

if filter.UserID != nil && (task.UserID == nil || *task.UserID != *filter.UserID) {
return false
}

return true
}, func(i interface{}) error {

task := i.(db.Task)

created := task.Created.Format("2006-01-02")

if created < filter.Start.Format("2006-01-02") {
return ErrEndOfRange
}

if date != created {
date = created
stat = &db.TaskStat{
Date: date,
CountByStatus: make(map[task_logger.TaskStatus]int),
}
stats = append(stats, *stat)
}

if _, ok := stat.CountByStatus[task.Status]; !ok {
stat.CountByStatus[task.Status] = 0
}

stat.CountByStatus[task.Status]++

return nil
})

if errors.Is(err2, ErrEndOfRange) {
return nil
}

return err2
})

return
}

func CreateTestStore() *BoltDb {
util.Config = &util.ConfigType{
BoltDb: &util.DbConfig{},
71 changes: 66 additions & 5 deletions db/sql/SqlDb.go
Original file line number Diff line number Diff line change
@@ -4,18 +4,18 @@ 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
_ "github.com/lib/pq"
"github.com/semaphoreui/semaphore/db"
"github.com/semaphoreui/semaphore/pkg/task_logger"
"github.com/semaphoreui/semaphore/util"
log "github.com/sirupsen/logrus"
"reflect"
"regexp"
"strconv"
"strings"
)

type SqlDb struct {
@@ -761,3 +761,64 @@ 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) {

stats = make([]db.TaskStat, 0)

if unit != db.TaskStatUnitDay {
err = fmt.Errorf("only day unit is supported")
return
}

var res []struct {
Date string `db:"date"`
Status task_logger.TaskStatus `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.Start != nil {
q = q.Where("start>=?", *filter.Start)
}

if filter.End != nil {
q = q.Where("end<?", *filter.End)
}

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[task_logger.TaskStatus]int),
}
stats = append(stats, *stat)
}

stat.CountByStatus[r.Status] = r.Count
}

return
}
781 changes: 632 additions & 149 deletions web/package-lock.json

Large diffs are not rendered by default.

7 changes: 5 additions & 2 deletions web/package.json
Original file line number Diff line number Diff line change
@@ -14,16 +14,19 @@
"axios": "^0.29.0",
"core-js": "^3.39.0",
"cron-parser": "^4.9.0",
"dredd": "^13.1.2",
"moment": "^2.29.4",
"vue": "^2.6.14",
"vue-codemirror": "^4.0.6",
"vue-i18n": "^8.18.2",
"vue-router": "^3.5.4",
"vuedraggable": "^2.24.3",
"vuetify": "^2.6.10"
"vuetify": "^2.6.10",
"chart.js": "^3.8.0",
"chartjs-adapter-moment": "^1.0.1",
"vue-chartjs": "^4.0.0"
},
"devDependencies": {
"dredd": "^13.1.2",
"@vue/cli-plugin-babel": "^5.0.6",
"@vue/cli-plugin-eslint": "^5.0.6",
"@vue/cli-plugin-router": "^5.0.6",
108 changes: 108 additions & 0 deletions web/src/components/LineChart.vue
Original file line number Diff line number Diff line change
@@ -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>
5 changes: 5 additions & 0 deletions web/src/main.js
Original file line number Diff line number Diff line change
@@ -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,
6 changes: 0 additions & 6 deletions web/src/views/project/TemplateView.vue
Original file line number Diff line number Diff line change
@@ -79,12 +79,6 @@
</v-btn>
</v-toolbar>

<v-container fluid>
<v-alert text type="info" class="mb-0 ml-4 mr-4 mb-2" v-if="item.description">
{{ item.description }}
</v-alert>
</v-container>

<v-tabs class="mb-4 ml-4">
<v-tab :to="`/project/${item.project_id}/templates/${item.id}/tasks`">Tasks</v-tab>
<v-tab :to="`/project/${item.project_id}/templates/${item.id}/details`">Details</v-tab>
109 changes: 107 additions & 2 deletions web/src/views/project/template/TemplateDetails.vue
Original file line number Diff line number Diff line change
@@ -3,12 +3,12 @@
<v-alert
text
type="info"
class="mb-0 ml-4 mr-4 mb-2"
class="mb-0 ml-4 mr-4 mb-6"
v-if="template.description"
>{{ template.description }}
</v-alert>

<v-row>
<v-row class="mb-2">
<v-col>
<v-list subheader>
<v-list-item>
@@ -85,6 +85,33 @@
</v-list>
</v-col>
</v-row>

<v-card style="background: rgba(133, 133, 133, 0.06)" class="mx-4">
<v-card-title>
Task Status
<v-spacer />
<v-select
hide-details
dense
:items="dateRanges"
class="mr-6"
style="max-width: 200px"
v-model="dateRange"
/>

<v-select
hide-details
dense
:items="users"
style="max-width: 200px"
v-model="user"
/>
</v-card-title>
<v-card-text>
<LineChart :source-data="stats"/>
</v-card-text>
</v-card>

</v-container>

</template>
@@ -94,20 +121,98 @@ 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,
inventory: Array,
environment: Array,
},
data() {
return {
dateRanges: [{
text: 'Last Week',
value: 'last_week',
}, {
text: 'Last Month',
value: 'last_month',
}],
users: [{
text: 'All users',
value: null,
}],
user: null,
TEMPLATE_TYPE_ICONS,
TEMPLATE_TYPE_TITLES,
TEMPLATE_TYPE_ACTION_TITLES,
stats: null,
dateRange: 'last_week',
};
},
computed: {
startDate() {
const date = new Date();
switch (this.dateRange) {
case 'last_month':
date.setDate(date.getDate() - 7);
break;
case 'last_week':
default:
date.setDate(date.getDate() - 30);
break;
}
return date.toISOString().split('T')[0];
},
},
watch: {
async startDate() {
await this.refreshData();
},
async user() {
await this.refreshData();
},
},
async created() {
await this.refreshData();
this.users = [{
text: 'All users',
value: null,
}, ...(await axios({
method: 'get',
url: `/api/project/${this.template.project_id}/users`,
responseType: 'json',
})).data.map((x) => ({
value: x.id,
text: x.name,
}))];
},
methods: {
async refreshData() {
let url = `/api/project/${this.template.project_id}/templates/${this.template.id}/stats?start=${this.startDate}`;
if (this.user) {
url += `&user_id=${this.user}`;
}
this.stats = (await axios({
method: 'get',
url,
responseType: 'json',
})).data;
},
},
};
</script>

0 comments on commit a93f56f

Please sign in to comment.