UI, and more issue fixes

pull/496/head
hunterlong 2020-04-14 09:08:50 -07:00
parent 6257ecb5cb
commit 3b7df28c47
25 changed files with 739 additions and 328 deletions

1
.github/FUNDING.yml vendored
View File

@ -1,2 +1,3 @@
github: hunterlong github: hunterlong
patreon: statping
custom: ['https://www.buymeacoffee.com/hunterlong'] custom: ['https://www.buymeacoffee.com/hunterlong']

View File

@ -1,3 +1,9 @@
# 0.90.26 (04-13-2020)
- Fixed Delete Failures button/function
- Removed timezone field from Settings (core)
- Modified CDN asset URL
- Fixed single Service view, more complex charts
# 0.90.25 # 0.90.25
- Added string response on OnTest for Notifiers - Added string response on OnTest for Notifiers
- Modified UI to show user the response for a Notifier. - Modified UI to show user the response for a Notifier.

View File

@ -27,8 +27,8 @@ lite: clean
reup: down clean compose-build-full up reup: down clean compose-build-full up
test: clean test: clean compile
go test -v -p=4 -ldflags="-X main.VERSION=testing" -coverprofile=coverage.out ./... go test -v -p=1 -ldflags="-X main.VERSION=testing" -coverprofile=coverage.out ./...
# build all arch's and release Statping # build all arch's and release Statping
release: test-deps release: test-deps

View File

@ -146,6 +146,45 @@ type isObject interface {
Db() Database Db() Database
} }
func ParseRequest(r *http.Request) (*GroupQuery, error) {
fields := parseGet(r)
grouping := fields.Get("group")
startField := utils.ToInt(fields.Get("start"))
endField := utils.ToInt(fields.Get("end"))
limit := utils.ToInt(fields.Get("limit"))
offset := utils.ToInt(fields.Get("offset"))
fill, _ := strconv.ParseBool(fields.Get("fill"))
orderBy := fields.Get("order")
if limit == 0 {
limit = 10000
}
if grouping == "" {
grouping = "1h"
}
groupDur, err := time.ParseDuration(grouping)
if err != nil {
log.Errorln(err)
groupDur = 1 * time.Hour
}
query := &GroupQuery{
Start: time.Unix(startField, 0).UTC(),
End: time.Unix(endField, 0).UTC(),
Group: groupDur,
Order: orderBy,
Limit: int(limit),
Offset: int(offset),
FillEmpty: fill,
}
if query.Start.After(query.End) {
return nil, errors.New("start time is after ending time")
}
return query, nil
}
func ParseQueries(r *http.Request, o isObject) (*GroupQuery, error) { func ParseQueries(r *http.Request, o isObject) (*GroupQuery, error) {
fields := parseGet(r) fields := parseGet(r)
grouping := fields.Get("group") grouping := fields.Get("group")
@ -169,6 +208,9 @@ func ParseQueries(r *http.Request, o isObject) (*GroupQuery, error) {
log.Errorln(err) log.Errorln(err)
groupDur = 1 * time.Hour groupDur = 1 * time.Hour
} }
if endField == 0 {
endField = utils.Now().Unix()
}
query := &GroupQuery{ query := &GroupQuery{
Start: time.Unix(startField, 0).UTC(), Start: time.Unix(startField, 0).UTC(),

View File

@ -10,9 +10,9 @@
<base href="{{BasePath}}"> <base href="{{BasePath}}">
{{if USE_CDN}} {{if USE_CDN}}
<link rel="shortcut icon" type="image/x-icon" href="https://assets.statping.com/favicon.ico"> <link rel="shortcut icon" type="image/x-icon" href="https://assets.statping.com/favicon.ico">
<link rel="stylesheet" href="https://assets.statping.com/css/bootstrap.min.css"> <link rel="stylesheet" href="https://assets.statping.com/vendor.css">
<link rel="stylesheet" href="https://assets.statping.com/css/base.css"> <link rel="stylesheet" href="https://assets.statping.com/style.css">
<link rel="stylesheet" href="https://assets.statping.com/font/all.css"> <link rel="stylesheet" href="https://assets.statping.com/main.css">
{{else}} {{else}}
<link rel="shortcut icon" type="image/x-icon" href="favicon.ico"> <link rel="shortcut icon" type="image/x-icon" href="favicon.ico">
{{if USING_ASSETS}} {{if USING_ASSETS}}
@ -33,11 +33,11 @@
<div id="app" class="statping_container"></div> <div id="app" class="statping_container"></div>
{{if USE_CDN}} {{if USE_CDN}}
<script src="https://assets.statping.com/js/bundle.js"></script> <script src="https://assets.statping.com/bundle.js"></script>
<script src="https://assets.statping.com/js/vendor.chunk.js"></script> <script src="https://assets.statping.com/vendor.chunk.js"></script>
<script src="https://assets.statping.com/js/polyfill.chunk.js"></script> <script src="https://assets.statping.com/polyfill.chunk.js"></script>
<script src="https://assets.statping.com/js/style.chunk.js"></script> <script src="https://assets.statping.com/style.chunk.js"></script>
<script src="https://assets.statping.com/js/main.chunk.js"></script> <script src="https://assets.statping.com/main.chunk.js"></script>
{{else}} {{else}}
<% _.each(htmlWebpackPlugin.tags.bodyTags, function(bodyTag) { %> <% _.each(htmlWebpackPlugin.tags.bodyTags, function(bodyTag) { %>
<%= bodyTag %> <% }) %> <%= bodyTag %> <% }) %>

View File

@ -56,8 +56,8 @@ class Api {
return axios.get('api/services/' + id + '/failure_data?start=' + start + '&end=' + end + '&group=' + group + '&fill=' + fill).then(response => (response.data)) return axios.get('api/services/' + id + '/failure_data?start=' + start + '&end=' + end + '&group=' + group + '&fill=' + fill).then(response => (response.data))
} }
async service_uptime(id) { async service_uptime(id, start, end) {
return axios.get('api/services/' + id + '/uptime_data').then(response => (response.data)) return axios.get('api/services/' + id + '/uptime_data?start=' + start + '&end=' + end).then(response => (response.data))
} }
async service_heatmap(id, start, end, group) { async service_heatmap(id, start, end, group) {

View File

@ -41,7 +41,7 @@
</template> </template>
<script> <script>
import Api from "../API"; import Api from "../../API";
export default { export default {
name: 'Checkins', name: 'Checkins',

View File

@ -36,10 +36,12 @@
</td> </td>
<td class="text-right"> <td class="text-right">
<div v-if="$store.state.admin" class="btn-group"> <div v-if="$store.state.admin" class="btn-group">
<a @click.prevent="editGroup(group, edit)" href="#" class="btn btn-outline-secondary"><font-awesome-icon icon="chart-area" /> Edit</a> <button @click.prevent="editGroup(group, edit)" href="#" class="btn btn-sm btn-outline-secondary">
<a @click.prevent="deleteGroup(group)" href="#" class="btn btn-danger"> <font-awesome-icon icon="edit" />
</button>
<button @click.prevent="deleteGroup(group)" href="#" class="btn btn-sm btn-danger">
<font-awesome-icon icon="times" /> <font-awesome-icon icon="times" />
</a> </button>
</div> </div>
</td> </td>
</tr> </tr>

View File

@ -1,9 +1,13 @@
<template> <template>
<div class="col-12"> <div class="col-12">
<h2>{{service.name}} Failures <h2>{{service.name}} Failures
<span class="btn btn-outline-danger float-right">Delete All</span></h2> <button v-if="failures.length>0" @click="deleteFailures" class="btn btn-outline-danger float-right">Delete All</button></h2>
<div class="list-group mt-3 mb-4"> <div class="list-group mt-3 mb-4">
<div class="alert alert-info" v-if="failures.length===0">
You don't have any failures for {{service.name}}. Way to go!
</div>
<div v-for="(failure, index) in failures" :key="index" class="mb-2 list-group-item list-group-item-action flex-column align-items-start"> <div v-for="(failure, index) in failures" :key="index" class="mb-2 list-group-item list-group-item-action flex-column align-items-start">
<div class="d-flex w-100 justify-content-between"> <div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">{{failure.issue}}</h5> <h5 class="mb-1">{{failure.issue}}</h5>
@ -39,7 +43,7 @@
</template> </template>
<script> <script>
import Api from "../API"; import Api from "../../API";
export default { export default {
name: 'Failures', name: 'Failures',
@ -72,13 +76,21 @@ export default {
await this.gotoPage(1) await this.gotoPage(1)
}, },
methods: { methods: {
async deleteFailures() {
const c = confirm('Are you sure you want to delete all failures?')
if (c) {
await Api.service_failures_delete(this.service)
this.service = await Api.service(this.service.id)
this.total = 0
await this.load()
}
},
async gotoPage(page) { async gotoPage(page) {
this.page = page; this.page = page;
this.offset = (page-1) * this.limit; this.offset = (page-1) * this.limit;
await this.load()
window.console.log('page', this.page, this.limit, this.offset); },
async load() {
this.failures = await Api.service_failures(this.service.id, 0, 9999999999, this.limit, this.offset) this.failures = await Api.service_failures(this.service.id, 0, 9999999999, this.limit, this.offset)
} }
} }

View File

@ -48,7 +48,7 @@
</template> </template>
<script> <script>
import Api from "../API"; import Api from "../../API";
import FormIncidentUpdates from "@/forms/IncidentUpdates"; import FormIncidentUpdates from "@/forms/IncidentUpdates";
export default { export default {

View File

@ -2,7 +2,9 @@
<div class="row"> <div class="row">
<div v-for="(incident, i) in incidents" class="col-12 mt-4 mb-3"> <div v-for="(incident, i) in incidents" class="col-12 mt-4 mb-3">
<span class="braker mt-1 mb-3"></span> <span class="braker mt-1 mb-3"></span>
<h6>Incident: {{incident.title}}<span class="font-2 float-right">{{niceDate(incident.created_at)}}</span></h6> <h6>Incident: {{incident.title}}
<span class="font-2 float-right">{{niceDate(incident.created_at)}}</span>
</h6>
<span class="font-2" v-html="incident.description"></span> <span class="font-2" v-html="incident.description"></span>
<UpdatesBlock :incident="incident"/> <UpdatesBlock :incident="incident"/>

View File

@ -1,9 +1,11 @@
<template> <template>
<div class="row"> <div class="row">
<div v-for="(update, i) in updates" v-bind:key="i" class="col-12 mt-3"> <div v-for="(update, i) in updates" v-bind:key="i" class="col-12 mt-3">
<span class="col-2 badge text-uppercase" :class="badgeClass(update.type)">{{update.type}}</span> <div class="col-md-2 col-12">
<span class="col-10">{{update.message}}</span> <span class="badge text-uppercase" :class="badgeClass(update.type)">{{update.type}}</span>
<span class="col-12 font-1 float-right text-black-50">{{ago(update.created_at)}} ago</span> </div>
<div class="col-md-12 col-12 mt-2 font-3">{{update.message}}</div>
<div class="col-12 font-1 float-right text-black-50 mt-2">{{ago(update.created_at)}} ago</div>
</div> </div>
</div> </div>
</template> </template>

View File

@ -0,0 +1,276 @@
<template>
<div>
<div class="service-chart-container">
<apexchart width="100%" height="420" type="area" :options="main_chart_options" :series="main_chart"></apexchart>
</div>
</div>
</template>
<script>
import Api from "../../API";
const timeoptions = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' };
export default {
name: 'AdvancedChart',
props: {
service: {
type: Object,
required: true
},
start: {
type: String,
required: true
},
end: {
type: String,
required: true
},
group: {
type: String,
required: true
},
updated: {
type: Function,
required: true
},
},
data() {
return {
loading: true,
main_data: null,
expanded_data: null,
main_chart_options: {
noData: {
text: "Loading...",
align: 'center',
verticalAlign: 'middle',
offsetX: 0,
offsetY: -20,
style: {
color: "#bababa",
fontSize: '27px'
}
},
chart: {
id: 'mainchart',
events: {
dataPointSelection: (event, chartContext, config) => {
window.console.log('slect')
window.console.log(event)
},
updated: (chartContext, config) => {
window.console.log('updated')
},
beforeZoom: (chartContext, { xaxis }) => {
const start = (xaxis.min / 1000).toFixed(0)
const end = (xaxis.max / 1000).toFixed(0)
window.console.log(start, end)
this.updated(this.fromUnix(start), this.fromUnix(end))
return {
xaxis: {
min: this.fromUnix(start),
max: this.fromUnix(end)
}
}
},
scrolled: (chartContext, { xaxis }) => {
window.console.log(xaxis)
},
},
height: 500,
width: "100%",
type: "area",
animations: {
enabled: false,
initialAnimation: {
enabled: true
}
},
selection: {
enabled: true
},
zoom: {
enabled: true
},
toolbar: {
show: true
},
stroke: {
show: false,
curve: 'stepline',
lineCap: 'butt',
},
},
xaxis: {
type: "datetime",
labels: {
show: true
},
tooltip: {
enabled: false
}
},
yaxis: {
labels: {
show: true
},
},
markers: {
size: 0,
strokeWidth: 0,
hover: {
size: undefined,
sizeOffset: 0
}
},
tooltip: {
theme: false,
enabled: true,
custom: function ({ series, seriesIndex, dataPointIndex, w }) {
let ts = w.globals.seriesX[seriesIndex][dataPointIndex];
const dt = new Date(ts).toLocaleDateString("en-us", timeoptions)
let val = series[seriesIndex][dataPointIndex];
if (val >= 1000) {
val = (val * 0.1).toFixed(0) + " milliseconds"
} else {
val = (val * 0.01).toFixed(0) + " microseconds"
}
return `<div class="chartmarker"><span>Response Time: </span><span class="font-3">${val}</span><span>${dt}</span></div>`
},
fixed: {
enabled: true,
position: 'topRight',
offsetX: -30,
offsetY: 40,
},
x: {
show: true,
},
y: {
formatter: undefined,
title: {
formatter: (seriesName) => seriesName,
},
},
},
legend: {
show: false,
},
dataLabels: {
enabled: false
},
floating: true,
axisTicks: {
show: true
},
axisBorder: {
show: false
},
fill: {
colors: ["#48d338"],
opacity: 1,
type: 'solid'
},
stroke: {
show: true,
curve: 'smooth',
lineCap: 'butt',
colors: ["#3aa82d"],
width: 2,
}
},
expanded_chart_options: {
chart: {
id: "chart1",
height: 130,
type: "bar",
foreColor: "#ccc",
brush: {
target: "chart2",
enabled: true
},
selection: {
enabled: true,
fill: {
color: "#fff",
opacity: 0.4
},
xaxis: {
min: new Date("27 Jul 2017 10:00:00").getTime(),
max: new Date("14 Aug 2999 10:00:00").getTime()
}
}
},
colors: ["#FF0080"],
stroke: {
width: 2
},
grid: {
borderColor: "#444"
},
markers: {
size: 0
},
xaxis: {
type: "datetime",
tooltip: {
enabled: false
}
},
yaxis: {
tickAmount: 2
}
}
}
},
async mounted() {
await this.update_data();
},
computed: {
main_chart () {
return [{
name: this.service.name,
...this.convertToChartData(this.main_data)
}]
},
expanded_chart () {
return this.toBarData(this.expanded_data)
},
params () {
return {start: this.toUnix(new Date(this.start)), end: this.toUnix(new Date(this.end))}
},
},
watch: {
start: function(n, o) {
this.update_data()
},
end: function(n, o) {
this.update_data()
},
group: function(n, o) {
this.update_data()
},
},
methods: {
async update_data() {
this.loading = true
await this.chartHits()
// await this.expanded_hits()
this.loading = false
},
async expanded_hits() {
this.expanded_data = await this.load_hits(0, 99999999999, "24h")
},
async chartHits() {
this.main_data = await this.load_hits()
},
async load_hits(start=this.params.start, end=this.params.end, group=this.group) {
return await Api.service_hits(this.service.id, start, end, group, false)
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

View File

@ -49,12 +49,9 @@
</div> </div>
<div class="col-md-4 col-6 float-right"> <div class="col-md-4 col-6 float-right">
<button v-if="!expanded" @click="showMoreStats" class="btn btn-sm float-right dyn-dark text-white" :class="{'bg-success': service.online, 'bg-danger': !service.online}">View Service</button> <button v-if="!expanded" @click="setService" class="btn btn-sm float-right dyn-dark text-white" :class="{'bg-success': service.online, 'bg-danger': !service.online}">
<button v-if="expanded" @click="expanded = false" class="btn btn-sm float-right dyn-dark text-white" :class="{'btn-outline-success': service.online, 'bg-danger': !service.online}">Hide</button> View Service
</div> </button>
<div v-if="expanded" class="row">
<Analytics title="Last Failure" value="417 Days ago"/>
</div> </div>
</div> </div>
@ -133,6 +130,10 @@ export default {
this.track_service = this.in_service this.track_service = this.in_service
}, },
methods: { methods: {
async setService() {
await this.$store.commit('setService', this.service)
this.$router.push('/service/'+this.service.id, {props: {in_service: this.service}})
},
async showMoreStats() { async showMoreStats() {
this.expanded = !this.expanded; this.expanded = !this.expanded;
@ -174,7 +175,6 @@ export default {
if (!this.timer_func) { if (!this.timer_func) {
this.timer_func = setInterval(async () => { this.timer_func = setInterval(async () => {
this.track_service = await Api.service(this.service.id) this.track_service = await Api.service(this.service.id)
window.console.log(this.track_service.name)
}, this.track_service.check_interval * 1000) }, this.track_service.check_interval * 1000)
} }
} }

View File

@ -67,23 +67,23 @@
<div class="card-footer"> <div class="card-footer">
<div class="row"> <div class="row">
<div class="col-3"> <div class="col-12 col-md-3 mb-2 mb-md-0">
<router-link :to="{path: `/dashboard/service/${service.id}/incidents`, params: {id: service.id} }" class="btn btn-block btn-white incident"> <router-link :to="{path: `/dashboard/service/${service.id}/incidents`, params: {id: service.id} }" class="btn btn-block btn-white incident">
Incidents Incidents
</router-link> </router-link>
</div> </div>
<div class="col-3"> <div class="col-12 col-md-3 mb-2 mb-md-0">
<router-link :to="{path: `/dashboard/service/${service.id}/checkins`, params: {id: service.id} }" class="btn btn-block btn-white checkins"> <router-link :to="{path: `/dashboard/service/${service.id}/checkins`, params: {id: service.id} }" class="btn btn-block btn-white checkins">
Checkins Checkins
</router-link> </router-link>
</div> </div>
<div class="col-3"> <div class="col-12 col-md-3 mb-2 mb-md-0">
<router-link :to="{path: `/dashboard/service/${service.id}/failures`, params: {id: service.id} }" class="btn btn-block btn-white failures"> <router-link :to="{path: `/dashboard/service/${service.id}/failures`, params: {id: service.id} }" class="btn btn-block btn-white failures">
Failures <span class="badge badge-danger float-right mt-1">{{service.stats.failures}}</span> Failures <span class="badge badge-danger float-right mt-1">{{service.stats.failures}}</span>
</router-link> </router-link>
</div> </div>
<div class="col-3 pt-2"> <div class="col-12 col-md-3 mb-2 mb-md-0 mt-0 mt-md-1">
<span class="text-black-50 float-right">{{service.online_7_days}}% Uptime</span> <span class="text-black-50 float-md-right">{{service.online_7_days}}% Uptime</span>
</div> </div>
</div> </div>
@ -155,13 +155,6 @@
this.set2 = await this.getHits(24, "1h") this.set2 = await this.getHits(24, "1h")
this.set2_name = this.calc(this.set2) this.set2_name = this.calc(this.set2)
this.loaded = true this.loaded = true
},
async deleteFailures() {
const c = confirm('Are you sure you want to delete all failures?')
if (c) {
await Api.service_failures_delete(this.service)
this.service = await Api.service(this.service.id)
}
}, },
Tab(name) { Tab(name) {
if (this.openTab === name) { if (this.openTab === name) {

View File

@ -42,6 +42,9 @@ export default Vue.mixin({
dur(t1, t2) { dur(t1, t2) {
return formatDistance(t1, t2) return formatDistance(t1, t2)
}, },
format(val, type="EEEE, MMM do h:mma") {
return format(val, type)
},
niceDate(val) { niceDate(val) {
return format(parseISO(val), "EEEE, MMM do h:mma") return format(parseISO(val), "EEEE, MMM do h:mma")
}, },
@ -119,6 +122,13 @@ export default Vue.mixin({
return "bars" return "bars"
} }
}, },
toBarData(data = []) {
let newSet = [];
data.forEach((f) => {
newSet.push([this.toUnix(this.parseISO(f.timeframe)), f.amount])
})
return newSet
},
convertToChartData(data = [], multiplier=1, asInt=false) { convertToChartData(data = [], multiplier=1, asInt=false) {
if (!data) { if (!data) {
return {data: []} return {data: []}

View File

@ -21,69 +21,42 @@
</div> </div>
<div class="row mt-5 mb-4"> <div class="row mt-5 mb-4">
<span class="col-6 font-2"> <div class="col-12 col-md-5 font-2 mb-3 mb-md-0">
<flatPickr v-model="start_time" type="text" name="start_time" class="form-control form-control-plaintext" id="start_time" value="0001-01-01T00:00:00Z" required /> <flatPickr :disabled="loading" @on-change="onnn" v-model="start_time" :config="{ enableTime: true, altInput: true, altFormat: 'Y-m-d h:i K', maxDate: new Date() }" type="text" class="btn btn-white text-left" required />
</span> <small class="d-block">From {{this.format(new Date(start_time))}}</small>
<span class="col-6 font-2"> </div>
<flatPickr v-model="end_time" type="text" name="end_time" class="form-control form-control-plaintext" id="end_time" value="0001-01-01T00:00:00Z" required /> <div class="col-12 col-md-5 font-2 mb-3 mb-md-0">
</span> <flatPickr :disabled="loading" @on-change="onnn" v-model="end_time" :config="{ enableTime: true, altInput: true, altFormat: 'Y-m-d h:i K', maxDate: new Date()}" type="text" class="btn btn-white text-left" required />
<small class="d-block">To {{this.format(new Date(end_time))}}</small>
</div>
<div class="col-12 col-md-2">
<select :disabled="loading" @change="chartHits" v-model="group" class="form-control">
<option value="1m">1 Minute</option>
<option value="5m">5 Minutes</option>
<option value="15m">15 Minute</option>
<option value="30m">30 Minutes</option>
<option value="1h">1 Hour</option>
<option value="3h">3 Hours</option>
<option value="6h">6 Hours</option>
<option value="12h">12 Hours</option>
<option value="24h">1 Day</option>
<option value="168h">7 Days</option>
<option value="360h">15 Days</option>
</select>
<small class="d-block d-md-none d-block">Increment Timeframe</small>
</div>
</div> </div>
<div v-if="series" class="service-chart-container"> <AdvancedChart :group="group" :updated="updated_chart" :start="start_time.toString()" :end="end_time.toString()" :service="service"/>
<apexchart width="100%" height="420" type="area" :options="chartOptions" :series="series"></apexchart>
<div v-if="!loading" class="col-12">
<apexchart width="100%" height="120" type="rangeBar" :options="timeRangeOptions" :series="uptime_data"></apexchart>
</div> </div>
<div class="service-chart-heatmap mt-5 mb-4"> <div class="service-chart-heatmap mt-5 mb-4">
<ServiceHeatmap :service="service"/> <ServiceHeatmap :service="service"/>
</div> </div>
<div v-if="load_timedata" class="col-12">
<apexchart width="100%" height="420" type="rangeBar" :options="timeRangeOptions" :series="rangeSeries"></apexchart>
</div>
<nav v-if="service.failures" class="nav nav-pills flex-column flex-sm-row mt-3" id="service_tabs">
<a @click="tab='failures'" class="flex-sm-fill text-sm-center nav-link active">Failures</a>
<a @click="tab='incidents'" class="flex-sm-fill text-sm-center nav-link">Incidents</a>
<a @click="tab='checkins'" v-if="$store.getters.token" class="flex-sm-fill text-sm-center nav-link">Checkins</a>
<a @click="tab='response'" v-if="$store.getters.token" class="flex-sm-fill text-sm-center nav-link">Response</a>
</nav>
<div v-if="service.failures" class="tab-content">
<div class="tab-pane fade active show">
<ServiceFailures :service="service"/>
</div>
<div class="tab-pane fade" :class="{active: tab === 'incidents'}" id="incidents">
</div>
<div class="tab-pane fade" :class="{show: tab === 'checkins'}" id="checkins">
<div class="card">
<div class="card-body">
<Checkin :service="service"/>
</div>
</div>
</div>
<div class="tab-pane fade" :class="{show: tab === 'response'}" id="response">
<div class="col-12 mt-4">
<h3>Last Response</h3>
<textarea rows="8" class="form-control" readonly>invalid route</textarea>
<div class="form-group row mt-2">
<label for="last_status_code" class="col-sm-3 col-form-label">HTTP Status Code</label>
<div class="col-sm-2">
<input type="text" id="last_status_code" class="form-control" value="200" readonly>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
@ -99,6 +72,7 @@
import store from '../store' import store from '../store'
import flatPickr from 'vue-flatpickr-component'; import flatPickr from 'vue-flatpickr-component';
import 'flatpickr/dist/flatpickr.css'; import 'flatpickr/dist/flatpickr.css';
import AdvancedChart from "@/components/Service/AdvancedChart";
const timeoptions = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' }; const timeoptions = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' };
const axisOptions = { const axisOptions = {
@ -128,6 +102,7 @@
export default { export default {
name: 'Service', name: 'Service',
components: { components: {
AdvancedChart,
ServiceTopStats, ServiceTopStats,
ServiceHeatmap, ServiceHeatmap,
ServiceFailures, ServiceFailures,
@ -137,16 +112,18 @@ export default {
}, },
data() { data() {
return { return {
id: 0,
tab: "failures", tab: "failures",
authenticated: false, authenticated: false,
ready: true, ready: true,
group: "1h",
data: null, data: null,
uptime_data: null,
loading: true,
messages: [], messages: [],
failures: [], failures: [],
start_time: this.nowSubtract(84600 * 30), start_time: this.nowSubtract(84600 * 30),
end_time: new Date(), end_time: this.nowSubtract(0),
timedata: [], timedata: null,
load_timedata: false, load_timedata: false,
dailyRangeOpts: { dailyRangeOpts: {
chart: { chart: {
@ -157,8 +134,21 @@ export default {
}, },
timeRangeOptions: { timeRangeOptions: {
chart: { chart: {
height: 200, id: 'uptime',
type: 'rangeBar' height: 120,
type: 'rangeBar',
toolbar: {
show: false
},
zoom: {
enabled: false
}
},
selection: {
enabled: true
},
zoom: {
enabled: true
}, },
plotOptions: { plotOptions: {
bar: { bar: {
@ -170,16 +160,10 @@ export default {
} }
}, },
dataLabels: { dataLabels: {
enabled: true, enabled: false
formatter: (val, opts) => { },
var label = opts.w.globals.labels[opts.dataPointIndex] tooltip: {
var a = this.parseISO(val[0]) enabled: false,
var b = this.parseISO(val[1])
return label
},
style: {
colors: ['#f3f4f5', '#fff']
}
}, },
xaxis: { xaxis: {
type: 'datetime' type: 'datetime'
@ -195,12 +179,32 @@ export default {
} }
}, },
chartOptions: { chartOptions: {
noData: {
text: "Loading...",
align: 'center',
verticalAlign: 'middle',
offsetX: 0,
offsetY: -20,
style: {
color: "#bababa",
fontSize: '27px'
}
},
chart: { chart: {
id: 'mainchart',
events: { events: {
beforeZoom: async (chartContext, { xaxis }) => { dataPointSelection: (event, chartContext, config) => {
window.console.log('slect')
window.console.log(event)
},
updated: (chartContext, config) => {
window.console.log('updated')
},
beforeZoom: (chartContext, { xaxis }) => {
const start = (xaxis.min / 1000).toFixed(0) const start = (xaxis.min / 1000).toFixed(0)
const end = (xaxis.max / 1000).toFixed(0) const end = (xaxis.max / 1000).toFixed(0)
await this.chartHits(start, end, "10m") this.start_time = this.fromUnix(start)
this.end_time = this.fromUnix(end)
return { return {
xaxis: { xaxis: {
min: this.fromUnix(start), min: this.fromUnix(start),
@ -208,6 +212,9 @@ export default {
} }
} }
}, },
scrolled: (chartContext, { xaxis }) => {
window.console.log(xaxis)
},
}, },
height: 500, height: 500,
width: "100%", width: "100%",
@ -233,20 +240,28 @@ export default {
lineCap: 'butt', lineCap: 'butt',
}, },
}, },
xaxis: { xaxis: {
type: "datetime", type: "datetime",
labels: { labels: {
show: true show: true
},
tooltip: {
enabled: true
}
}, },
yaxis: { tooltip: {
labels: { enabled: false
show: true }
}, },
yaxis: {
labels: {
show: true
}, },
},
markers: {
size: 0,
strokeWidth: 0,
hover: {
size: undefined,
sizeOffset: 0
}
},
tooltip: { tooltip: {
theme: false, theme: false,
enabled: true, enabled: true,
@ -259,7 +274,7 @@ export default {
} else { } else {
val = (val * 0.01).toFixed(0) + " microseconds" val = (val * 0.01).toFixed(0) + " microseconds"
} }
return `<div class="chartmarker"><span>Average Response Time: </span><span class="font-3">${val}</span><span>${dt}</span></div>` return `<div class="chartmarker"><span>Response Time: </span><span class="font-3">${val}</span><span>${dt}</span></div>`
}, },
fixed: { fixed: {
enabled: true, enabled: true,
@ -268,9 +283,8 @@ export default {
offsetY: 40, offsetY: 40,
}, },
x: { x: {
show: false, show: true,
format: 'dd MMM',
formatter: undefined,
}, },
y: { y: {
formatter: undefined, formatter: undefined,
@ -320,63 +334,84 @@ export default {
core () { core () {
return this.$store.getters.core return this.$store.getters.core
}, },
uptime_data() { params () {
const data = this.timedata.series.filter(g => g.online) return {start: this.toUnix(new Date(this.start_time)), end: this.toUnix(new Date(this.end_time))}
const offData = this.timedata.series.filter(g => !g.online)
let arr = [];
data.forEach((d) => {
arr.push({
name: "Online", data: {
x: 'Online',
y: [
new Date(d.start).getTime(),
new Date(d.end).getTime()
],
fillColor: '#0db407'
}
})
})
offData.forEach((d) => {
arr.push({
name: "offline", data: {
x: 'Offline',
y: [
new Date(d.start).getTime(),
new Date(d.end).getTime()
],
fillColor: '#b40707'
}
})
})
return arr
}, },
rangeSeries() { id () {
return [{data: this.time_chart_data}] return this.$route.params.id;
}, },
uptimeSeries () {
return this.timedata.series
},
mainChart () {
return [{
name: this.service.name,
...this.convertToChartData(this.data)
}]
}
}, },
watch: { watch: {
service: function(n, o) { service: function(n, o) {
this.chartHits() this.onnn()
this.fetchUptime()
}, },
load_timedata: function(n, o) { load_timedata: function(n, o) {
this.chartHits() this.onnn()
} }
}, },
async created() { async mounted() {
this.id = this.$route.params.id; if (!this.$store.getters.service) {
}, const s = await Api.service(this.id)
this.$store.commit('setService', s)
}
},
methods: { methods: {
async updated_chart(start, end) {
this.start_time = start
this.end_time = end
this.loading = false
},
async onnn() {
this.loading = true
await this.chartHits()
await this.fetchUptime()
this.loading = false
},
async fetchUptime() { async fetchUptime() {
this.timedata = await Api.service_uptime(this.id) const uptime = await Api.service_uptime(this.id, this.params.start, this.params.end)
this.load_timedata = true window.console.log(uptime)
}, this.uptime_data = this.parse_uptime(uptime)
async get() { },
const s = this.$store.getters.serviceByAll(this.id) parse_uptime(timedata) {
window.console.log("service: ", s) const data = timedata.series.filter((g) => g.online) || []
this.getService(this.service) const offData = timedata.series.filter((g) => !g.online) || []
this.messages = this.$store.getters.serviceMessages(this.service.id) let arr = [];
}, window.console.log(data)
if (data) {
data.forEach((d) => {
arr.push({
x: 'Online',
y: [
new Date(d.start).getTime(),
new Date(d.end).getTime()
],
fillColor: '#0db407'
})
})
}
if (offData) {
offData.forEach((d) => {
arr.push({
x: 'Offline',
y: [
new Date(d.start).getTime(),
new Date(d.end).getTime()
],
fillColor: '#b40707'
})
})
}
return [{data: arr}]
},
messageInRange(message) { messageInRange(message) {
const start = this.isBetween(new Date(), message.start_on) const start = this.isBetween(new Date(), message.start_on)
const end = this.isBetween(message.end_on, new Date()) const end = this.isBetween(message.end_on, new Date())
@ -387,31 +422,15 @@ export default {
await this.serviceFailures() await this.serviceFailures()
}, },
async serviceFailures() { async serviceFailures() {
let tt = this.startEndTimes() this.failures = await Api.service_failures(this.service.id, this.params.start, this.params.end)
this.failures = await Api.service_failures(this.service.id, tt.start, tt.end)
}, },
async chartHits(start=0, end=99999999999, group="30m") { async chartHits(start=0, end=99999999999) {
let tt = {}; this.data = await Api.service_hits(this.service.id, this.params.start, this.params.end, this.group, false)
if (start === 0) { if (this.data.length === 0 && this.group !== "1h") {
tt = this.startEndTimes() this.group = "1h"
} else {
tt = {start, end}
}
this.data = await Api.service_hits(this.service.id, tt.start, tt.end, group, false)
if (this.data.length === 0 && group !== "1h") {
await this.chartHits("1h") await this.chartHits("1h")
} }
this.series = [{
name: this.service.name,
...this.convertToChartData(this.data)
}]
this.ready = true this.ready = true
},
startEndTimes() {
const start = this.toUnix(this.service.stats.first_hit)
const end = this.toUnix(new Date())
return {start, end}
} }
} }
} }

View File

@ -13,9 +13,9 @@ import VueRouter from "vue-router";
import Setup from "./forms/Setup"; import Setup from "./forms/Setup";
import Api from "./API"; import Api from "./API";
import Incidents from "@/pages/Incidents"; import Incidents from "@/components/Dashboard/Incidents";
import Checkins from "@/pages/Checkins"; import Checkins from "@/components/Dashboard/Checkins";
import Failures from "@/pages/Failures"; import Failures from "@/components/Dashboard/Failures";
const routes = [ const routes = [
{ {

View File

@ -22,6 +22,7 @@ export default new Vuex.Store({
core: {}, core: {},
token: null, token: null,
services: [], services: [],
service: null,
groups: [], groups: [],
messages: [], messages: [],
users: [], users: [],
@ -36,6 +37,7 @@ export default new Vuex.Store({
core: state => state.core, core: state => state.core,
token: state => state.token, token: state => state.token,
services: state => state.services, services: state => state.services,
service: state => state.service,
groups: state => state.groups, groups: state => state.groups,
messages: state => state.messages, messages: state => state.messages,
incidents: state => state.incidents, incidents: state => state.incidents,
@ -104,6 +106,9 @@ export default new Vuex.Store({
setToken (state, token) { setToken (state, token) {
state.token = token state.token = token
}, },
setService (state, service) {
state.service = service
},
setServices (state, services) { setServices (state, services) {
state.services = services state.services = services
}, },

View File

@ -76,9 +76,6 @@ func apiCoreHandler(w http.ResponseWriter, r *http.Request) {
if c.Domain != app.Domain { if c.Domain != app.Domain {
app.Domain = c.Domain app.Domain = c.Domain
} }
if c.Timezone != app.Timezone {
app.Timezone = c.Timezone
}
app.OAuth = c.OAuth app.OAuth = c.OAuth
app.UseCdn = null.NewNullBool(c.UseCdn.Bool) app.UseCdn = null.NewNullBool(c.UseCdn.Bool)
app.AllowReports = null.NewNullBool(c.AllowReports.Bool) app.AllowReports = null.NewNullBool(c.AllowReports.Bool)

View File

@ -88,8 +88,6 @@ func (s Storage) List() map[string]Item {
//Get a cached content by key //Get a cached content by key
func (s Storage) Get(key string) []byte { func (s Storage) Get(key string) []byte {
s.mu.Lock()
defer s.mu.Unlock()
item := s.items[key] item := s.items[key]
if item.Expired() { if item.Expired() {
CacheStorage.Delete(key) CacheStorage.Delete(key)

View File

@ -9,8 +9,6 @@ import (
"github.com/statping/statping/types/services" "github.com/statping/statping/types/services"
"github.com/statping/statping/utils" "github.com/statping/statping/utils"
"net/http" "net/http"
"sort"
"time"
) )
type serviceOrder struct { type serviceOrder struct {
@ -185,131 +183,37 @@ func apiServiceTimeDataHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
allFailures := service.AllFailures() groupHits, err := database.ParseQueries(r, service.AllHits())
allHits := service.AllHits() if err != nil {
sendErrorJson(err, w, r)
tMap := make(map[time.Time]bool) return
for _, v := range allHits.List() {
tMap[v.CreatedAt] = true
}
for _, v := range allFailures.List() {
tMap[v.CreatedAt] = false
} }
var servs []ser groupFailures, err := database.ParseQueries(r, service.AllFailures())
for t, v := range tMap { if err != nil {
s := ser{ sendErrorJson(err, w, r)
Time: t, return
Online: v,
}
servs = append(servs, s)
} }
sort.Sort(ByTime(servs)) var allFailures []*failures.Failure
var allHits []*hits.Hit
var allTimes []series if err := groupHits.Find(&allHits); err != nil {
online := servs[0].Online sendErrorJson(err, w, r)
thisTime := servs[0].Time return
for i := 0; i < len(servs); i++ {
v := servs[i]
if v.Online != online {
s := series{
Start: thisTime,
End: v.Time,
Duration: v.Time.Sub(thisTime).Milliseconds(),
Online: online,
}
allTimes = append(allTimes, s)
thisTime = v.Time
online = v.Online
}
} }
first := servs[0].Time if err := groupFailures.Find(&allFailures); err != nil {
last := servs[len(servs)-1].Time sendErrorJson(err, w, r)
return
if !service.Online {
s := series{
Start: allTimes[len(allTimes)-1].End,
End: utils.Now(),
Duration: utils.Now().Sub(last).Milliseconds(),
Online: service.Online,
}
allTimes = append(allTimes, s)
} else {
l := allTimes[len(allTimes)-1]
allTimes[len(allTimes)-1] = series{
Start: l.Start,
End: utils.Now(),
Duration: utils.Now().Sub(l.Start).Milliseconds(),
Online: true,
}
} }
jj := uptimeSeries{ uptimeData, err := service.UptimeData(allHits, allFailures)
Start: first, if err != nil {
End: last, sendErrorJson(err, w, r)
Uptime: addDurations(allTimes, true), return
Downtime: addDurations(allTimes, false),
Series: allTimes,
} }
returnJson(uptimeData, w, r)
returnJson(jj, w, r)
}
type ByTime []ser
func (a ByTime) Len() int { return len(a) }
func (a ByTime) Less(i, j int) bool { return a[i].Time.Before(a[j].Time) }
func (a ByTime) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func addDurations(s []series, on bool) int64 {
var dur int64
for _, v := range s {
if v.Online == on {
dur += v.Duration
}
}
return dur
}
type ser struct {
Time time.Time
Online bool
}
func findNextFailure(m map[time.Time]bool, after time.Time, online bool) time.Time {
for k, v := range m {
if k.After(after) && v == online {
return k
}
}
return time.Time{}
}
//func calculateDuration(m map[time.Time]bool, on bool) time.Duration {
// var t time.Duration
// for t, v := range m {
// if v == on {
// t.
// }
// }
//}
type uptimeSeries struct {
Start time.Time `json:"start"`
End time.Time `json:"end"`
Uptime int64 `json:"uptime"`
Downtime int64 `json:"downtime"`
Series []series `json:"series"`
}
type series struct {
Start time.Time `json:"start"`
End time.Time `json:"end"`
Duration int64 `json:"duration"`
Online bool `json:"online"`
} }
func apiServiceDeleteHandler(w http.ResponseWriter, r *http.Request) { func apiServiceDeleteHandler(w http.ResponseWriter, r *http.Request) {

View File

@ -32,7 +32,6 @@ type Core struct {
Setup bool `gorm:"-" json:"setup"` Setup bool `gorm:"-" json:"setup"`
MigrationId int64 `gorm:"column:migration_id" json:"migration_id,omitempty"` MigrationId int64 `gorm:"column:migration_id" json:"migration_id,omitempty"`
UseCdn null.NullBool `gorm:"column:use_cdn;default:false" json:"using_cdn,omitempty"` UseCdn null.NullBool `gorm:"column:use_cdn;default:false" json:"using_cdn,omitempty"`
Timezone float32 `gorm:"column:timezone;default:-8.0" json:"timezone,omitempty"`
LoggedIn bool `gorm:"-" json:"logged_in"` LoggedIn bool `gorm:"-" json:"logged_in"`
IsAdmin bool `gorm:"-" json:"admin"` IsAdmin bool `gorm:"-" json:"admin"`
AllowReports null.NullBool `gorm:"column:allow_reports;default:false" json:"allow_reports"` AllowReports null.NullBool `gorm:"column:allow_reports;default:false" json:"allow_reports"`

View File

@ -3,11 +3,15 @@ package services
import ( import (
"crypto/sha1" "crypto/sha1"
"encoding/hex" "encoding/hex"
"errors"
"fmt" "fmt"
"github.com/statping/statping/types" "github.com/statping/statping/types"
"github.com/statping/statping/types/failures"
"github.com/statping/statping/types/hits"
"github.com/statping/statping/types/null" "github.com/statping/statping/types/null"
"github.com/statping/statping/utils" "github.com/statping/statping/utils"
"net/url" "net/url"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -19,6 +23,145 @@ func (s *Service) Duration() time.Duration {
return time.Duration(s.Interval) * time.Second return time.Duration(s.Interval) * time.Second
} }
// Start will create a channel for the service checking go routine
func (s *Service) UptimeData(hits []*hits.Hit, fails []*failures.Failure) (*UptimeSeries, error) {
if len(hits) == 0 {
return nil, errors.New("service does not have any successful hits")
}
// if theres no failures, then its been online 100%,
// return a series from created time, to current.
if len(fails) == 0 {
fistHit := hits[0]
duration := utils.Now().Sub(fistHit.CreatedAt).Milliseconds()
set := []series{
{
Start: fistHit.CreatedAt,
End: utils.Now(),
Duration: duration,
Online: true,
},
}
out := &UptimeSeries{
Start: fistHit.CreatedAt,
End: utils.Now(),
Uptime: duration,
Downtime: 0,
Series: set,
}
return out, nil
}
tMap := make(map[time.Time]bool)
for _, v := range hits {
tMap[v.CreatedAt] = true
}
for _, v := range fails {
tMap[v.CreatedAt] = false
}
var servs []ser
for t, v := range tMap {
s := ser{
Time: t,
Online: v,
}
servs = append(servs, s)
}
if len(servs) == 0 {
return nil, errors.New("error generating uptime data structure")
}
sort.Sort(ByTime(servs))
var allTimes []series
online := servs[0].Online
thisTime := servs[0].Time
for i := 0; i < len(servs); i++ {
v := servs[i]
if v.Online != online {
s := series{
Start: thisTime,
End: v.Time,
Duration: v.Time.Sub(thisTime).Milliseconds(),
Online: online,
}
allTimes = append(allTimes, s)
thisTime = v.Time
online = v.Online
}
}
if len(allTimes) == 0 {
return nil, errors.New("error generating uptime series structure")
}
first := servs[0].Time
last := servs[len(servs)-1].Time
if !s.Online {
s := series{
Start: allTimes[len(allTimes)-1].End,
End: utils.Now(),
Duration: utils.Now().Sub(last).Milliseconds(),
Online: s.Online,
}
allTimes = append(allTimes, s)
} else {
l := allTimes[len(allTimes)-1]
s := series{
Start: l.Start,
End: utils.Now(),
Duration: utils.Now().Sub(l.Start).Milliseconds(),
Online: true,
}
allTimes = append(allTimes, s)
}
response := &UptimeSeries{
Start: first,
End: last,
Uptime: addDurations(allTimes, true),
Downtime: addDurations(allTimes, false),
Series: allTimes,
}
return response, nil
}
func addDurations(s []series, on bool) int64 {
var dur int64
for _, v := range s {
if v.Online == on {
dur += v.Duration
}
}
return dur
}
type ser struct {
Time time.Time
Online bool
}
type UptimeSeries struct {
Start time.Time `json:"start"`
End time.Time `json:"end"`
Uptime int64 `json:"uptime"`
Downtime int64 `json:"downtime"`
Series []series `json:"series"`
}
type ByTime []ser
func (a ByTime) Len() int { return len(a) }
func (a ByTime) Less(i, j int) bool { return a[i].Time.Before(a[j].Time) }
func (a ByTime) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
type series struct {
Start time.Time `json:"start"`
End time.Time `json:"end"`
Duration int64 `json:"duration"`
Online bool `json:"online"`
}
// Start will create a channel for the service checking go routine // Start will create a channel for the service checking go routine
func (s *Service) Start() { func (s *Service) Start() {
if s.IsRunning() { if s.IsRunning() {

View File

@ -1 +1 @@
0.90.25 0.90.26