pull/429/head
hunterlong 2020-02-20 21:36:23 -08:00
parent eb5ecdad16
commit da34fc179c
19 changed files with 387 additions and 103 deletions

View File

@ -10,7 +10,7 @@ ADD Makefile go.mod /go/src/github.com/hunterlong/statping/
RUN go mod vendor && \
make dev-deps
ADD . /go/src/github.com/hunterlong/statping
RUN cd frontend && yarn install
RUN cd frontend && yarn install --network-timeout 1000000
RUN make compile install
# Statping :latest Docker Image

View File

@ -86,6 +86,16 @@ func (s *Service) HitsBetween(t1, t2 time.Time, group string, column string) typ
}
}
// FailuresBetween returns the gorm database query for a collection of service hits between a time range
func (s *Service) FailuresBetween(t1, t2 time.Time, group string, column string) types.Database {
selector := Dbtimestamp(group, column)
if CoreApp.Config.DbConn == "postgres" {
return Database(&Failure{}).Select(selector).Where("service = ? AND created_at BETWEEN ? AND ?", s.Id, t1.UTC().Format(types.TIME), t2.UTC().Format(types.TIME))
} else {
return Database(&Failure{}).Select(selector).Where("service = ? AND created_at BETWEEN ? AND ?", s.Id, t1.UTC().Format(types.TIME_DAY), t2.UTC().Format(types.TIME_DAY))
}
}
// CloseDB will close the database connection if available
func CloseDB() {
if DbSession != nil {

View File

@ -272,7 +272,7 @@ type DateScanObj struct {
}
// GraphDataRaw will return all the hits between 2 times for a Service
func GraphDataRaw(service types.ServiceInterface, start, end time.Time, group string, column string) *DateScanObj {
func GraphHitsDataRaw(service types.ServiceInterface, start, end time.Time, group string, column string) *DateScanObj {
model := service.(*Service).HitsBetween(start, end, group, column)
model = model.Order("timeframe asc", false).Group("timeframe")
outgoing, err := model.ToChart()
@ -282,6 +282,23 @@ func GraphDataRaw(service types.ServiceInterface, start, end time.Time, group st
return &DateScanObj{outgoing}
}
// GraphDataRaw will return all the hits between 2 times for a Service
func GraphFailuresDataRaw(service types.ServiceInterface, start, end time.Time, group string) []types.TimeValue {
srv := service.(*Service)
query := Database(&types.Failure{}).
Where("service = ?", srv.Id).
Between(start, end).
MultipleSelects(types.SelectByTime(group), types.CountAmount()).
GroupByTimeframe().Debug()
outgoing, err := query.ToTimeValue()
if err != nil {
log.Error(err)
}
return outgoing
}
// ToString will convert the DateScanObj into a JSON string for the charts to render
func (d *DateScanObj) ToString() string {
data, err := json.Marshal(d.Array)

View File

@ -23,7 +23,7 @@ func (s *Service) SparklineHourResponse(hours int, method string) string {
var arr []string
end := time.Now().UTC()
start := end.Add(time.Duration(-hours) * time.Hour)
obj := GraphDataRaw(s, start, end, "hour", method)
obj := GraphHitsDataRaw(s, start, end, "hour", method)
for _, v := range obj.Array {
arr = append(arr, utils.ToString(v.Value))
}

View File

@ -26,6 +26,7 @@
"vue-codemirror": "^4.0.6",
"vue-flatpickr-component": "^8.1.5",
"vue-moment": "^4.1.0",
"vue-observe-visibility": "^0.4.6",
"vue-router": "~3.0",
"vuedraggable": "^2.23.2",
"vuex": "^3.1.2"

View File

@ -40,6 +40,10 @@ class Api {
return axios.get('/api/services/' + id + '/data?start=' + start + '&end=' + end + '&group=' + group).then(response => (response.data))
}
async service_failures_data(id, start, end, group) {
return axios.get('/api/services/' + id + '/failure_data?start=' + start + '&end=' + end + '&group=' + group).then(response => (response.data))
}
async service_heatmap(id, start, end, group) {
return axios.get('/api/services/' + id + '/heatmap').then(response => (response.data))
}
@ -61,6 +65,7 @@ class Api {
}
async groups_reorder(data) {
window.console.log('/api/reorder/groups', data)
return axios.post('/api/reorder/groups', data).then(response => (response.data))
}

View File

@ -3,57 +3,11 @@
<div class="col-12">
<h1 class="text-black-50">Services
<router-link to="/dashboard/create_service" class="btn btn-outline-success mt-1 float-right">
<i class="fas fa-plus"></i> Create
<font-awesome-icon icon="plus"/> Create
</router-link>
</h1>
<table class="table">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col" class="d-none d-md-table-cell">Status</th>
<th scope="col" class="d-none d-md-table-cell">Visibility</th>
<th scope="col" class="d-none d-md-table-cell">Group</th>
<th scope="col"></th>
</tr>
</thead>
<draggable tag="tbody" v-model="servicesList" :key="$store.getters.servicesInOrder.length" class="sortable" handle=".drag_icon">
<tr v-for="(service, index) in $store.getters.servicesInOrder" :key="index">
<td>
<span class="drag_icon d-none d-md-inline">
<font-awesome-icon icon="bars" />
</span> {{service.name}}
</td>
<td class="d-none d-md-table-cell">
<span class="badge" :class="{'animate-fader': !service.online, 'badge-success': service.online, 'badge-danger': !service.online}">
{{service.online ? "ONLINE" : "OFFLINE"}}
</span>
<ToggleSwitch v-if="service.online" :service="service"/>
</td>
<td class="d-none d-md-table-cell">
<span class="badge" :class="{'badge-primary': service.public, 'badge-secondary': !service.public}">
{{service.public ? "PUBLIC" : "PRIVATE"}}
</span>
</td>
<td class="d-none d-md-table-cell">
<div v-if="service.group_id !== 0"><span class="badge badge-secondary">{{serviceGroup(service)}}</span></div>
</td>
<td class="text-right">
<div class="btn-group">
<router-link :to="{path: `/dashboard/edit_service/${service.id}`, params: {service: service} }" class="btn btn-outline-secondary">
<i class="fas fa-chart-area"></i> Edit
</router-link>
<router-link :to="{path: serviceLink(service), params: {service: service} }" class="btn btn-outline-secondary">
<i class="fas fa-chart-area"></i> View
</router-link>
<a @click.prevent="deleteService(service)" href="#" class="btn btn-danger">
<font-awesome-icon icon="times" />
</a>
</div>
</td>
</tr>
</draggable>
</table>
<ServicesList/>
</div>
@ -71,8 +25,10 @@
</thead>
<draggable tag="tbody" v-model="groupsList" class="sortable_groups" handle=".drag_icon">
<tr v-for="(group, index) in $store.getters.groupsClean" v-bind:key="index">
<td><span class="drag_icon d-none d-md-inline"><font-awesome-icon icon="bars" /></span> {{group.name}}</td>
<tr v-for="(group, index) in $store.getters.groupsCleanInOrder" v-bind:key="group.id">
<td><span class="drag_icon d-none d-md-inline">
<font-awesome-icon icon="bars" /></span> {{group.name}}
</td>
<td>{{$store.getters.servicesInGroup(group.id).length}}</td>
<td>
<span v-if="group.public" class="badge badge-primary">PUBLIC</span>
@ -102,10 +58,12 @@
import Api from "../../API";
import ToggleSwitch from "../../forms/ToggleSwitch";
import draggable from 'vuedraggable'
import ServicesList from './ServicesList';
export default {
name: 'DashboardServices',
components: {
ServicesList,
ToggleSwitch,
FormGroup,
draggable
@ -117,23 +75,9 @@
}
},
computed: {
servicesList: {
get() {
return this.$store.state.servicesInOrder
},
async set(value) {
let data = [];
value.forEach((s, k) => {
data.push({service: s.id, order: k + 1})
});
await Api.services_reorder(data)
const services = await Api.services()
this.$store.commit('setServices', services)
}
},
groupsList: {
get() {
return this.$store.state.groupsInOrder
return this.$store.getters.groupsCleanInOrder
},
async set(value) {
let data = [];
@ -165,13 +109,6 @@
window.console.log("saving...");
window.console.log(this.myViews.array()); // this.myViews.array is not a function
},
serviceGroup(s) {
let group = this.$store.getters.groupById(s.group_id)
if (group) {
return group.name
}
return ""
},
async deleteGroup(g) {
let c = confirm(`Are you sure you want to delete '${g.name}'?`)
if (c) {
@ -179,14 +116,6 @@
const groups = await Api.groups()
this.$store.commit('setGroups', groups)
}
},
async deleteService(s) {
let c = confirm(`Are you sure you want to delete '${s.name}'?`)
if (c) {
await Api.service_delete(s.id)
const services = await Api.services()
this.$store.commit('setServices', services)
}
}
}
}

View File

@ -0,0 +1,109 @@
<template>
<table class="table">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col" class="d-none d-md-table-cell">Status</th>
<th scope="col" class="d-none d-md-table-cell">Visibility</th>
<th scope="col" class="d-none d-md-table-cell">Group</th>
<th scope="col"></th>
</tr>
</thead>
<draggable tag="tbody" v-model="servicesList" handle=".drag_icon">
<tr v-for="(service, index) in $store.getters.servicesInOrder" :key="service.id">
<td>
<span class="drag_icon d-none d-md-inline">
<font-awesome-icon icon="bars" />
</span> {{service.name}}
</td>
<td class="d-none d-md-table-cell">
<span class="badge" :class="{'animate-fader': !service.online, 'badge-success': service.online, 'badge-danger': !service.online}">
{{service.online ? "ONLINE" : "OFFLINE"}}
</span>
<ToggleSwitch v-if="service.online" :service="service"/>
</td>
<td class="d-none d-md-table-cell">
<span class="badge" :class="{'badge-primary': service.public, 'badge-secondary': !service.public}">
{{service.public ? "PUBLIC" : "PRIVATE"}}
</span>
</td>
<td class="d-none d-md-table-cell">
<div v-if="service.group_id !== 0"><span class="badge badge-secondary">{{serviceGroup(service)}}</span></div>
</td>
<td class="text-right">
<div class="btn-group">
<router-link :to="{path: `/dashboard/edit_service/${service.id}`, params: {service: service} }" class="btn btn-outline-secondary">
<i class="fas fa-chart-area"></i> Edit
</router-link>
<router-link :to="{path: serviceLink(service), params: {service: service} }" class="btn btn-outline-secondary">
<i class="fas fa-chart-area"></i> View
</router-link>
<a @click.prevent="deleteService(service)" href="#" class="btn btn-danger">
<font-awesome-icon icon="times" />
</a>
</div>
</td>
</tr>
</draggable>
</table>
</template>
<script>
import Api from "../../API";
import draggable from 'vuedraggable'
import ToggleSwitch from '../../forms/ToggleSwitch';
export default {
name: 'ServicesList',
components: {
ToggleSwitch,
draggable
},
computed: {
servicesList: {
get () {
return this.$store.getters.servicesInOrder
},
set (value) {
this.updateOrder(value)
}
}
},
data() {
return {
}
},
methods: {
async updateOrder(value) {
let data = [];
value.forEach((s, k) => {
data.push({ service: s.id, order: k + 1 })
});
const reorder = await Api.services_reorder(data)
window.console.log('reorder', reorder)
const services = await Api.services()
this.$store.commit('setServices', services)
},
async deleteService(s) {
let c = confirm(`Are you sure you want to delete '${s.name}'?`)
if (c) {
await Api.service_delete(s.id)
const services = await Api.services()
this.$store.commit('setServices', services)
}
},
serviceGroup(s) {
let group = this.$store.getters.groupById(s.group_id)
if (group) {
return group.name
}
return ""
},
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

View File

@ -6,6 +6,8 @@
<a v-for="(service, index) in $store.getters.servicesInGroup(group.id)" v-bind:key="index" class="service_li list-group-item list-group-item-action">
{{service.name}}
<span class="badge bg-success float-right pulse-glow">{{service.online ? "ONLINE" : "OFFLINE"}}</span>
<GroupServiceFailures :service="service"/>
</a>
</div>
@ -13,20 +15,18 @@
</template>
<script>
import Api from '../../API';
import GroupServiceFailures from './GroupServiceFailures';
export default {
name: 'Group',
components: {
GroupServiceFailures
},
props: {
group: Object
},
methods: {
serviceBadge (s) {
},
}
}
</script>

View File

@ -0,0 +1,95 @@
<template>
<div class="row">
<span class="bg-danger">o</span>
</div>
</template>
<script>
import Api from '../../API';
export default {
name: 'GroupServiceFailures',
components: {
},
data() {
return {
failureData: null
}
},
props: {
service: {
type: Object,
required: true
}
},
mounted () {
this.lastDaysFailures()
},
methods: {
async lastDaysFailures() {
const start = this.nowSubtract((3600 * 24) * 30)
this.failureData = await Api.service_failures_data(this.service.id, this.toUnix(start), this.toUnix(this.now()), "day")
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
@keyframes pulse_animation {
0% { transform: scale(1); }
30% { transform: scale(1); }
40% { transform: scale(1.02); }
50% { transform: scale(1); }
60% { transform: scale(1); }
70% { transform: scale(1.05); }
80% { transform: scale(1); }
100% { transform: scale(1); }
}
.pulse {
animation-name: pulse_animation;
animation-duration: 1500ms;
transform-origin:70% 70%;
animation-iteration-count: infinite;
animation-timing-function: linear;
}
@keyframes glow-grow {
0% {
opacity: 0;
transform: scale(1);
}
80% {
opacity: 1;
}
100% {
transform: scale(2);
opacity: 0;
}
}
.pulse-glow {
animation-name: glow-grown;
animation-duration: 100ms;
transform-origin: 70% 30%;
animation-iteration-count: infinite;
animation-timing-function: linear;
}
.pulse-glow:before,
.pulse-glow:after {
position: absolute;
content: "";
height: 0.4rem;
width: 1.7rem;
top: 1.3rem;
right: 2.15rem;
border-radius: 0;
box-shadow: 0 0 6px #47d337;
animation: glow-grow 2s ease-out infinite;
}
</style>

View File

@ -13,8 +13,8 @@
</div>
</div>
<div class="chart-container">
<ServiceChart :service="service"/>
<div v-observe-visibility="visibleChart" class="chart-container">
<ServiceChart :service="service" :visible="visible"/>
</div>
<div class="row lower_canvas full-col-12 text-white" :class="{'bg-success': service.online, 'bg-danger': !service.online}">
@ -46,6 +46,11 @@ export default {
required: true
},
},
data() {
return {
visible: false,
}
},
methods: {
smallText(s) {
if (s.online) {
@ -53,6 +58,11 @@ export default {
} else {
return `Offline, last error: ${s.last_failure.issue} ${this.ago(this.parseTime(s.last_failure.created_at))}`
}
},
visibleChart(isVisible, entry) {
if (isVisible && !this.visible) {
this.visible = true
}
}
}
}

View File

@ -1,5 +1,5 @@
<template>
<apexchart v-if="ready" width="100%" height="225" type="area" :options="chartOptions" :series="series"></apexchart>
<template v-show="showing">
<apexchart v-if="ready" width="100%" height="235" type="area" :options="chartOptions" :series="series"/>
</template>
<script>
@ -35,14 +35,16 @@
service: {
type: Object,
required: true
},
visible: {
type: Boolean,
required: true
}
},
async created() {
await this.chartHits()
},
data() {
return {
ready: false,
showing: false,
data: [],
chartOptions: {
chart: {
@ -120,6 +122,14 @@
}]
}
},
watch: {
visible: function(newVal, oldVal) {
if (newVal && !this.showing) {
this.showing = true
this.chartHits()
}
}
},
methods: {
async chartHits() {
const start = this.nowSubtract((3600 * 24) * 7)

View File

@ -1,6 +1,7 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import VueApexCharts from 'vue-apexcharts'
import VueObserveVisibility from 'vue-observe-visibility'
import App from '@/App.vue'
import store from './store'
@ -12,6 +13,7 @@ import "./icons"
Vue.component('apexchart', VueApexCharts)
Vue.use(VueRouter);
Vue.use(VueObserveVisibility);
Vue.config.productionTip = false
new Vue({

View File

@ -1,5 +1,5 @@
<template>
<div class="container col-md-7 col-sm-12 mt-2 sm-container">
<div class="container col-md-7 col-sm-12 mt-4 sm-container">
<Header/>
@ -12,12 +12,11 @@
</div>
<div class="col-12 full-col-12">
<div v-for="(service, index) in $store.getters.services" :ref="service.id" v-bind:key="index">
<div v-for="(service, index) in $store.getters.servicesInOrder" :ref="service.id" v-bind:key="index">
<ServiceBlock :service=service />
</div>
</div>
</div>
</template>

View File

@ -19,7 +19,7 @@
<a v-for="(notifier, index) in $store.getters.notifiers" v-bind:key="`${notifier.method}_${index}`" @click.prevent="changeTab" class="nav-link text-capitalize" v-bind:class="{active: liClass(`v-pills-${notifier.method.toLowerCase()}-tab`)}" v-bind:id="`v-pills-${notifier.method.toLowerCase()}-tab`" data-toggle="pill" v-bind:href="`#v-pills-${notifier.method.toLowerCase()}`" role="tab" v-bind:aria-controls="`v-pills-${notifier.method.toLowerCase()}`" aria-selected="false">
<font-awesome-icon :icon="iconName(notifier.icon)" class="mr-2"/> {{notifier.method}}
<span v-if="notifier.enabled" class="badge badge-pill float-right mt-1" :class="{'badge-success': !liClass(`v-pills-${notifier.method.toLowerCase()}-tab`), 'badge-light': liClass(`v-pills-${notifier.method.toLowerCase()}-tab`)}">ON</span>
<span v-if="notifier.enabled" class="badge badge-pill float-right mt-1" :class="{'badge-success': !liClass(`v-pills-${notifier.method.toLowerCase()}-tab`), 'badge-light': liClass(`v-pills-${notifier.method.toLowerCase()}-tab`), 'text-dark': liClass(`v-pills-${notifier.method.toLowerCase()}-tab`)}">ON</span>
</a>
<h6 class="mt-4 text-muted">Integrations <span class="badge badge-secondary float-right">BETA</span></h6>

View File

@ -43,6 +43,7 @@ export default new Vuex.Store({
servicesInOrder: state => state.services.sort((a, b) => a.order_id - b.order_id),
groupsInOrder: state => state.groups.sort((a, b) => a.order_id - b.order_id),
groupsClean: state => state.groups.filter(g => g.name !== '').sort((a, b) => a.order_id - b.order_id),
groupsCleanInOrder: state => state.groups.filter(g => g.name !== '').sort((a, b) => a.order_id - b.order_id).sort((a, b) => a.order_id - b.order_id),
serviceById: (state) => (id) => {
return state.services.find(s => s.id == id)

View File

@ -120,6 +120,7 @@ func Router() *mux.Router {
api.Handle("/api/reorder/services", authenticated(reorderServiceHandler, false)).Methods("POST")
api.Handle("/api/services/{id}/running", authenticated(apiServiceRunningHandler, false)).Methods("POST")
api.Handle("/api/services/{id}/data", cached("30s", "application/json", apiServiceDataHandler)).Methods("GET")
api.Handle("/api/services/{id}/failure_data", cached("30s", "application/json", apiServiceFailureDataHandler)).Methods("GET")
api.Handle("/api/services/{id}/ping", cached("30s", "application/json", apiServicePingDataHandler)).Methods("GET")
api.Handle("/api/services/{id}/heatmap", cached("30s", "application/json", apiServiceHeatmapHandler)).Methods("GET")
api.Handle("/api/services/{id}", authenticated(apiServiceUpdateHandler, false)).Methods("POST")

View File

@ -107,7 +107,7 @@ func servicesViewHandler(w http.ResponseWriter, r *http.Request) {
group = "hour"
}
data := core.GraphDataRaw(serv, start, end, group, "latency")
data := core.GraphHitsDataRaw(serv, start, end, group, "latency")
out := struct {
Service *core.Service
@ -216,7 +216,29 @@ func apiServiceDataHandler(w http.ResponseWriter, r *http.Request) {
start := time.Unix(startField, 0)
end := time.Unix(endField, 0)
obj := core.GraphDataRaw(service, start, end, grouping, "latency")
obj := core.GraphHitsDataRaw(service, start, end, grouping, "latency")
returnJson(obj, w, r)
}
func apiServiceFailureDataHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
service := core.SelectService(utils.ToInt(vars["id"]))
if service == nil {
sendErrorJson(errors.New("service data not found"), w, r)
return
}
fields := parseGet(r)
grouping := fields.Get("group")
if grouping == "" {
grouping = "hour"
}
startField := utils.ToInt(fields.Get("start"))
endField := utils.ToInt(fields.Get("end"))
start := time.Unix(startField, 0)
end := time.Unix(endField, 0)
obj := core.GraphFailuresDataRaw(service, start, end, grouping)
returnJson(obj, w, r)
}
@ -235,7 +257,7 @@ func apiServicePingDataHandler(w http.ResponseWriter, r *http.Request) {
start := time.Unix(startField, 0)
end := time.Unix(endField, 0)
obj := core.GraphDataRaw(service, start, end, grouping, "ping_time")
obj := core.GraphHitsDataRaw(service, start, end, grouping, "ping_time")
returnJson(obj, w, r)
}

View File

@ -17,9 +17,11 @@ package types
import (
"database/sql"
"fmt"
"github.com/jinzhu/gorm"
"net/http"
"strconv"
"strings"
"time"
)
@ -108,6 +110,11 @@ type Database interface {
Hits() ([]*Hit, error)
ToChart() ([]*DateScan, error)
GroupByTimeframe() Database
ToTimeValue() ([]TimeValue, error)
MultipleSelects(args ...string) Database
Failurer
}
@ -116,6 +123,42 @@ type Failurer interface {
Fails() ([]*Failure, error)
}
func sqlTimeframes(increment string) string {
switch increment {
case "second":
return "%Y-%m-%d %H:%M:%S"
case "minute":
return "%Y-%m-%d %H:%M:00"
case "hour":
return "%Y-%m-%d %H:00:00"
case "day":
return "%Y-%m-%d 00:00:00"
case "month":
return "%Y-%m 00:00:00"
case "year":
return "%Y"
default:
return "%Y-%m-%d 00:00:00"
}
}
func (it *Db) MultipleSelects(args ...string) Database {
joined := strings.Join(args, ", ")
return it.Select(joined)
}
func CountAmount() string {
return fmt.Sprintf("COUNT(id) as amount")
}
func SelectByTime(increment string) string {
return fmt.Sprintf("strftime('%s', created_at, 'utc') as timeframe", sqlTimeframes(increment))
}
func (it *Db) GroupByTimeframe() Database {
return it.Group("timeframe")
}
func (it *Db) Failures(id int64) Database {
return it.Model(&Failure{}).Where("service = ?", id).Not("method = 'checkin'").Order("id desc")
}
@ -128,6 +171,7 @@ func (it *Db) Fails() ([]*Failure, error) {
type Db struct {
Database *gorm.DB
Type string
}
// Openw is a drop-in replacement for Open()
@ -138,7 +182,10 @@ func Openw(dialect string, args ...interface{}) (db Database, err error) {
// Wrap wraps gorm.DB in an interface
func Wrap(db *gorm.DB) Database {
return &Db{db}
return &Db{
Database: db,
Type: db.Dialect().GetName(),
}
}
func (it *Db) Close() error {
@ -457,6 +504,32 @@ type DateScan struct {
Value int64 `json:"y"`
}
type TimeValue struct {
Timeframe time.Time `json:"timeframe"`
Amount int64 `json:"amount"`
}
func (it *Db) ToTimeValue() ([]TimeValue, error) {
rows, err := it.Database.Rows()
if err != nil {
return nil, err
}
var data []TimeValue
for rows.Next() {
var timeframe string
var amount int64
if err := rows.Scan(&timeframe, &amount); err != nil {
return nil, err
}
createdTime, _ := time.Parse(TIME, timeframe)
data = append(data, TimeValue{
Timeframe: createdTime,
Amount: amount,
})
}
return data, nil
}
func (it *Db) ToChart() ([]*DateScan, error) {
rows, err := it.Database.Rows()
if err != nil {