incidents, UX

pull/492/head
hunterlong 2020-04-13 04:17:09 -07:00
parent fe2cdb915a
commit b3b41f58fa
26 changed files with 827 additions and 197 deletions

View File

@ -3,6 +3,9 @@
- Modified UI to show user the response for a Notifier. - Modified UI to show user the response for a Notifier.
- Modified some Notifiers title's - Modified some Notifiers title's
- Added more Cypress e2e testing - Added more Cypress e2e testing
- Modified Incidents form and UX.
- Added /api/services/{id}/uptime_data API endpoint to show online/offline durations as a series for charts.
- Modified index page to automatically refresh Service details on interval
# 0.90.24 # 0.90.24
- Fixed login form from not showing - Fixed login form from not showing

View File

@ -56,6 +56,10 @@ 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) {
return axios.get('api/services/' + id + '/uptime_data').then(response => (response.data))
}
async service_heatmap(id, start, end, group) { async service_heatmap(id, start, end, group) {
return axios.get('api/services/' + id + '/heatmap').then(response => (response.data)) return axios.get('api/services/' + id + '/heatmap').then(response => (response.data))
} }
@ -118,7 +122,7 @@ class Api {
} }
async incident_updates(incident) { async incident_updates(incident) {
return axios.post('api/incidents/'+incident.id+'/updates', data).then(response => (response.data)) return axios.get('api/incidents/'+incident.id+'/updates').then(response => (response.data))
} }
async incident_update_create(update) { async incident_update_create(update) {
@ -129,12 +133,12 @@ class Api {
return axios.delete('api/incidents/'+update.incident+'/updates/'+update.id).then(response => (response.data)) return axios.delete('api/incidents/'+update.incident+'/updates/'+update.id).then(response => (response.data))
} }
async incidents_service(service) { async incidents_service(id) {
return axios.get('api/services/'+service.id+'/incidents').then(response => (response.data)) return axios.get('api/services/'+id+'/incidents').then(response => (response.data))
} }
async incident_create(service, data) { async incident_create(service_id, data) {
return axios.post('api/services/'+service.id+'/incidents', data).then(response => (response.data)) return axios.post('api/services/'+service_id+'/incidents', data).then(response => (response.data))
} }
async incident_delete(incident) { async incident_delete(incident) {

View File

@ -28,8 +28,8 @@ HTML,BODY {
background-color: white; background-color: white;
margin: 6px; margin: 6px;
height: 26px; height: 26px;
font-size: 10pt; font-size: 8pt;
padding: 3px 7px; padding: 5px 7px;
border: 1px solid #a7a7a7; border: 1px solid #a7a7a7;
border-radius: 4px !important; border-radius: 4px !important;
} }
@ -344,10 +344,33 @@ HTML,BODY {
background-color: #ffbbbb; background-color: #ffbbbb;
} }
.btn-white {
background-color: white;
border: 1px solid #d8d8d8;
color: #767676;
}
.btn-white:hover {
background-color: #fcfcfc;
border: 1px solid #bdbdbd;
color: #767676;
}
.braker {
border-top: 1px solid rgba(0, 0, 0, 0.10);
width: 98%;
display: block;
margin: 0 auto;
}
.text-dim {
color: #a0a0a0;
}
.card { .card {
background-color: $service-background; background-color: $service-background;
border: $service-border; border: $service-border;
//box-shadow: 0px 2px 11px 1px rgba(0, 0, 0, 0.13); box-shadow: 0px 3px 6px 1px rgba(0, 0, 0, 0.08);
} }
.card-body { .card-body {

View File

@ -2,7 +2,7 @@
<div class="col-12"> <div class="col-12">
<div class="card contain-card text-black-50 bg-white mb-4"> <div class="card contain-card text-black-50 bg-white mb-4">
<div class="card-header">Annoucements</div> <div class="card-header">Annoucements</div>
<div class="card-body"> <div class="card-body pt-0">
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
<tr> <tr>

View File

@ -4,15 +4,16 @@
<div class="card-header">Services <div class="card-header">Services
<router-link v-if="$store.state.admin" to="/dashboard/create_service" class="btn btn-sm btn-outline-success float-right"> <router-link v-if="$store.state.admin" to="/dashboard/create_service" class="btn btn-sm btn-outline-success float-right">
<font-awesome-icon icon="plus"/> Create <font-awesome-icon icon="plus"/> Create
</router-link></div> </router-link>
<div class="card-body"> </div>
<div class="card-body pt-0">
<ServicesList/> <ServicesList/>
</div> </div>
</div> </div>
<div class="card contain-card text-black-50 bg-white mb-4"> <div class="card contain-card text-black-50 bg-white mb-4">
<div class="card-header">Groups</div> <div class="card-header">Groups</div>
<div class="card-body"> <div class="card-body pt-0">
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>

View File

@ -2,7 +2,7 @@
<div class="col-12"> <div class="col-12">
<div class="card contain-card text-black-50 bg-white mb-4"> <div class="card contain-card text-black-50 bg-white mb-4">
<div class="card-header">Users</div> <div class="card-header">Users</div>
<div class="card-body"> <div class="card-body pt-0">
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
<tr> <tr>

View File

@ -9,7 +9,7 @@
</tr> </tr>
</thead> </thead>
<draggable id="services_list" tag="tbody" v-model="servicesList" handle=".drag_icon"> <draggable id="services_list" tag="tbody" v-model="servicesList" handle=".drag_icon">
<tr v-for="(service, index) in $store.getters.servicesInOrder" :key="service.id"> <tr v-for="(service, index) in servicesList" :key="service.id">
<td> <td>
<span v-if="$store.state.admin" class="drag_icon d-none d-md-inline"> <span v-if="$store.state.admin" class="drag_icon d-none d-md-inline">
<font-awesome-icon icon="bars" class="mr-3"/> <font-awesome-icon icon="bars" class="mr-3"/>

View File

@ -4,8 +4,11 @@
<div class="flex-fill service_day" v-for="(d, index) in failureData" :class="{'mini_error': d.amount > 0, 'mini_success': d.amount === 0}"></div> <div class="flex-fill service_day" v-for="(d, index) in failureData" :class="{'mini_error': d.amount > 0, 'mini_success': d.amount === 0}"></div>
</div> </div>
<div class="row mt-2"> <div class="row mt-2">
<div class="col-6 text-left font-2 text-muted">30 Days Ago</div> <div class="col-4 text-left font-2 text-muted">30 Days Ago</div>
<div class="col-6 text-right font-2 text-muted">Today</div> <div class="col-4 text-center font-2" :class="{'text-muted': service.online, 'text-danger': !service.online}">
{{service_txt}}
</div>
<div class="col-4 text-right font-2 text-muted">Today</div>
</div> </div>
</div> </div>
</template> </template>
@ -28,6 +31,14 @@ export default {
type: Object, type: Object,
required: true required: true
} }
},
computed: {
service_txt() {
if (!this.service.online) {
return `Offline for ${this.ago(this.service.last_success)}`
}
return `${this.service.online_24_hours}% Uptime`
}
}, },
mounted () { mounted () {
this.lastDaysFailures() this.lastDaysFailures()

View File

@ -1,27 +1,27 @@
<template> <template>
<div class="row"> <div class="row">
<div v-for="(incident, i) in incidents" class="col-12 mt-4"> <div v-for="(incident, i) in incidents" class="col-12 mt-4 mb-3">
<h5>Incident: {{incident.title}}<span class="font-2 float-right">{{niceDate(incident.created_at)}}</span></h5> <span class="braker mt-1 mb-3"></span>
{{incident.description}} <h6>Incident: {{incident.title}}<span class="font-2 float-right">{{niceDate(incident.created_at)}}</span></h6>
<div class="row"> <span class="font-2" v-html="incident.description"></span>
<div v-for="(update, i) in incident.updates.reverse()" v-bind:key="update.id" class="col-12 mt-3">
<span class="col-2 badge text-uppercase" :class="badgeClass(update.type)">{{update.type}}</span> <UpdatesBlock :incident="incident"/>
<span class="col-10">{{update.message}}</span>
<span class="col-12 font-1 float-right text-black-50">{{ago(update.created_at)}} ago</span>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import Api from '../../API'; import Api from '../../API';
import UpdatesBlock from "@/components/Index/UpdatesBlock";
export default { export default {
name: 'IncidentsBlock', name: 'IncidentsBlock',
components: {UpdatesBlock},
props: { props: {
service: { service: {
type: Object type: Object,
required: true
} }
}, },
data() { data() {
@ -44,7 +44,11 @@ export default {
} }
}, },
async getIncidents() { async getIncidents() {
this.incidents = await Api.incidents_service(this.service) this.incidents = await Api.incidents_service(this.service.id)
},
async incident_updates(incident) {
await Api.incident_updates(incident).then((d) => {return d})
return o
} }
} }
} }

View File

@ -0,0 +1,50 @@
<template>
<div class="row">
<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>
<span class="col-10">{{update.message}}</span>
<span class="col-12 font-1 float-right text-black-50">{{ago(update.created_at)}} ago</span>
</div>
</div>
</template>
<script>
import Api from '../../API';
export default {
name: 'UpdatesBlock',
props: {
incident: {
type: Object,
required: true
}
},
data() {
return {
updates: null,
}
},
mounted () {
this.getIncidentUpdates()
},
methods: {
badgeClass(val) {
switch (val.toLowerCase()) {
case "resolved":
return "badge-success"
case "update":
return "badge-info"
case "investigating":
return "badge-danger"
}
},
async getIncidentUpdates() {
this.updates = await Api.incident_updates(this.incident)
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

View File

@ -63,6 +63,7 @@
</template> </template>
<script> <script>
import Api from '../../API';
import Analytics from './Analytics'; import Analytics from './Analytics';
import ServiceChart from "./ServiceChart"; import ServiceChart from "./ServiceChart";
import ServiceTopStats from "@/components/Service/ServiceTopStats"; import ServiceTopStats from "@/components/Service/ServiceTopStats";
@ -72,13 +73,14 @@ export default {
name: 'ServiceBlock', name: 'ServiceBlock',
components: { Analytics, ServiceTopStats, ServiceChart}, components: { Analytics, ServiceTopStats, ServiceChart},
props: { props: {
service: { in_service: {
type: Object, type: Object,
required: true required: true
}, },
}, },
data() { data() {
return { return {
timer_func: null,
expanded: false, expanded: false,
visible: false, visible: false,
dropDownMenu: false, dropDownMenu: false,
@ -115,8 +117,20 @@ export default {
subtitle: "Last 7 Days", subtitle: "Last 7 Days",
value: 0, value: 0,
} }
},
track_service: null,
} }
},
beforeDestroy() {
clearInterval(this.timer_func)
},
computed: {
service() {
return this.track_service
} }
},
async created() {
this.track_service = this.in_service
}, },
methods: { methods: {
async showMoreStats() { async showMoreStats() {
@ -142,6 +156,7 @@ export default {
this.stats.low_ping.value = this.humanTime(pingData.low); this.stats.low_ping.value = this.humanTime(pingData.low);
}, },
smallText(s) { smallText(s) {
const incidents = s.incidents
if (s.online) { if (s.online) {
return `Online, last checked ${this.ago(s.last_success)}` return `Online, last checked ${this.ago(s.last_success)}`
} else { } else {
@ -155,6 +170,13 @@ export default {
visibleChart(isVisible, entry) { visibleChart(isVisible, entry) {
if (isVisible && !this.visible) { if (isVisible && !this.visible) {
this.visible = true this.visible = true
if (!this.timer_func) {
this.timer_func = setInterval(async () => {
this.track_service = await Api.service(this.service.id)
window.console.log(this.track_service.name)
}, this.track_service.check_interval * 1000)
}
} }
} }
} }

View File

@ -9,10 +9,48 @@
</h4> </h4>
</div> </div>
<div class="card-body p-3 p-md-1 pt-md-3 pb-md-1"> <div class="card-body p-3 p-md-1 pt-md-1 pb-md-1">
<transition name="fade"> <transition name="fade">
<div v-if="loaded && service.online" class="col-12 pb-2"> <div v-if="loaded" class="col-12 pb-2">
<div v-if="false" class="row mb-4 align-content-center">
<div v-if="!service.online" class="col-3 text-left">
<span class="text-danger font-5 font-weight-bold">okko</span>
<span class="font-2 d-block">Current Downtime</span>
</div>
<div v-if="service.online" class="col-3 text-left">
<span class="text-success font-5 font-weight-bold">
{{service.online_24_hours.toString()}}%
</span>
<span class="font-2 d-block">Total Uptime</span>
</div>
<div v-if="service.online" class="col-3 text-left">
<span class="text-success font-5 font-weight-bold">
0
</span>
<span class="font-2 d-block">Downtime Today</span>
</div>
<div v-if="service.online" class="col-3 text-left">
<span class="text-success font-5 font-weight-bold">
{{(uptime.uptime / 10000).toFixed(0).toString()}}
</span>
<span class="font-2 d-block">Uptime Duration</span>
</div>
<div class="col-3 text-left">
<span class="text-danger font-5 font-weight-bold">
{{service.failures_24_hours}}
</span>
<span class="font-2 d-block">Failures last 24 hours</span>
</div>
</div>
<div class="row"> <div class="row">
<div class="col-md-6 col-sm-12 mt-2 mt-md-0 mb-3"> <div class="col-md-6 col-sm-12 mt-2 mt-md-0 mb-3">
<ServiceSparkLine :title="set2_name" subtitle="Latency Last 24 Hours" :series="set2"/> <ServiceSparkLine :title="set2_name" subtitle="Latency Last 24 Hours" :series="set2"/>
@ -22,33 +60,6 @@
</div> </div>
</div> </div>
<div v-if="false" class="row mt-4 pt-1 mb-3 align-content-center">
<StatsGen :service="service"
title="Since Yesterday"
:start="this.toUnix(this.nowSubtract(86400 * 2))"
:end="this.toUnix(this.nowSubtract(86400))"
group="24h" expression="latencyPercent"/>
<StatsGen :service="service"
title="7 Day Change"
:start="this.toUnix(this.nowSubtract(86400 * 7))"
:end="this.toUnix(this.now())"
group="24h" expression="latencyPercent"/>
<StatsGen :service="service"
title="Max Latency"
:start="this.toUnix(this.nowSubtract(86400 * 2))"
:end="this.toUnix(this.nowSubtract(86400))"
group="24h" expression="latencyPercent"/>
<StatsGen :service="service"
title="Uptime"
:start="this.toUnix(this.nowSubtract(86400 * 2))"
:end="this.toUnix(this.nowSubtract(86400))"
group="24h" expression="latencyPercent"/>
</div>
</div> </div>
</transition> </transition>
</div> </div>
@ -57,33 +68,24 @@
<div class="row"> <div class="row">
<div class="col-3"> <div class="col-3">
<button @click.prevent="Tab('incident')" class="btn btn-block btn-outline-secondary incident" :class="{'text-white btn-secondary': openTab==='incident'}" >Incidents</button> <router-link :to="{path: `/dashboard/service/${service.id}/incidents`, params: {id: service.id} }" class="btn btn-block btn-white incident">
Incidents
</router-link>
</div> </div>
<div class="col-3"> <div class="col-3">
<button @click.prevent="Tab('checkin')" class="btn btn-block btn-outline-secondary checkin" :class="{'text-white btn-secondary': openTab==='checkin'}" >Checkins</button> <router-link :to="{path: `/dashboard/service/${service.id}/checkins`, params: {id: service.id} }" class="btn btn-block btn-white checkins">
Checkins
</router-link>
</div> </div>
<div class="col-3"> <div class="col-3">
<button @click.prevent="Tab('failures')" class="btn btn-block btn-outline-secondary failures" :disabled="service.stats.failures === 0" :class="{'text-white btn-secondary': openTab==='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></button> Failures <span class="badge badge-danger float-right mt-1">{{service.stats.failures}}</span>
</router-link>
</div> </div>
<div class="col-3 pt-2"> <div class="col-3 pt-2">
<span class="text-black-50 float-right">{{service.online_7_days}}% Uptime</span> <span class="text-black-50 float-right">{{service.online_7_days}}% Uptime</span>
</div> </div>
<div v-if="openTab === 'incident'" class="col-12 mt-4">
<FormIncident :service="service" />
</div>
<div v-if="openTab === 'checkin'" class="col-12 mt-4">
<Checkin :service="service" />
</div>
<div v-if="openTab === 'failures'" class="col-12 mt-4">
<button @click.prevent="deleteFailures" class="btn btn-block btn-outline-secondary delete_failures" :disabled="service.stats.failures === 0">Delete Failures</button>
<ServiceFailures :service="service"/>
</div>
</div> </div>
</div> </div>
@ -122,6 +124,7 @@
}, },
data() { data() {
return { return {
uptime: null,
openTab: "", openTab: "",
set1: [], set1: [],
set2: [], set2: [],
@ -139,9 +142,13 @@
async setVisible(isVisible, entry) { async setVisible(isVisible, entry) {
if (isVisible && !this.visible) { if (isVisible && !this.visible) {
await this.loadInfo() await this.loadInfo()
await this.getUptime()
this.visible = true this.visible = true
} }
}, },
async getUptime() {
this.uptime = await Api.service_uptime(this.service.id)
},
async loadInfo() { async loadInfo() {
this.set1 = await this.getHits(24 * 7, "6h") this.set1 = await this.getHits(24 * 7, "6h")
this.set1_name = this.calc(this.set1) this.set1_name = this.calc(this.set1)

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="col-3 text-center"> <div class="col-3 text-left">
<span class="text-success font-5 font-weight-bold">{{value}}</span> <span class="text-success font-5 font-weight-bold">{{value}}</span>
<span class="font-2 d-block">{{title}}</span> <span class="font-2 d-block">{{title}}</span>
</div> </div>
@ -34,6 +34,9 @@
expression: { expression: {
type: String, type: String,
required: true required: true
},
in_value: {
required: false
} }
}, },
data() { data() {
@ -42,6 +45,9 @@
} }
}, },
async mounted() { async mounted() {
if (this.in_value) {
this.value = this.in_value
}
await this.latencyYesterday(); await this.latencyYesterday();
}, },
methods: { methods: {

View File

@ -1,61 +1,6 @@
<template> <template>
<div> <div>
<div v-for="(incident, i) in incidents" class="card contain-card text-black-50 bg-white mb-4">
<div class="card-header">Incident: {{incident.title}}
<button @click="deleteIncident(incident)" class="btn btn-sm btn-danger float-right">
<font-awesome-icon icon="times" /> Delete
</button>
</div>
<div class="card-body bg-light pt-3">
<div v-for="(update, i) in incident.updates" class="alert alert-light" role="alert">
<span class="badge badge-pill badge-info text-uppercase">{{update.type}}</span>
<span class="float-right font-2">{{ago(update.created_at)}} ago</span>
<span class="d-block mt-2">{{update.message}}
<button @click="delete_update(update)" type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</span>
</div>
<FormIncidentUpdates :incident="incident"/>
<span class="font-2 mt-3">Created: {{niceDate(incident.created_at)}} | Last Update: {{niceDate(incident.updated_at)}}</span>
</div>
</div>
<div class="card contain-card text-black-50 bg-white mb-5">
<div class="card-header">Create Incident for {{service.name}}</div>
<div class="card-body">
<form @submit.prevent="createIncident">
<div class="form-group row">
<label class="col-sm-4 col-form-label">Title</label>
<div class="col-sm-8">
<input v-model="incident.title" type="text" name="title" class="form-control" id="title" placeholder="Incident Title" required>
</div>
</div>
<div class="form-group row">
<label class="col-sm-4 col-form-label">Description</label>
<div class="col-sm-8">
<textarea v-model="incident.description" rows="5" name="description" class="form-control" id="description" required></textarea>
</div>
</div>
<div class="form-group row">
<div class="col-sm-12">
<button @click.prevent="createIncident"
:disabled="!incident.title || !incident.description"
type="submit" class="btn btn-block btn-primary">
Create Incident
</button>
</div>
</div>
<div class="alert alert-danger d-none" id="alerter" role="alert"></div>
</form>
</div>
</div>
</div> </div>
</template> </template>
@ -89,29 +34,7 @@
await this.loadIncidents() await this.loadIncidents()
}, },
methods: { methods: {
async delete_update(update) {
await Api.incident_update_delete(update)
this.incidents = await Api.incidents_service(this.service)
},
async loadIncidents() {
this.incidents = await Api.incidents_service(this.service)
},
async createIncident() {
await Api.incident_create(this.service, this.incident)
await this.loadIncidents()
this.incident = {
title: "",
description: "",
service: this.service.id,
}
},
async deleteIncident(incident) {
let c = confirm(`Are you sure you want to delete '${incident.title}'?`)
if (c) {
await Api.incident_delete(incident)
await this.loadIncidents()
}
}
} }
} }
</script> </script>

View File

@ -1,6 +1,22 @@
<template> <template>
<form class="row" @submit.prevent="createIncidentUpdate"> <div class="card-body bg-light pt-3">
<div v-if="updates.length===0" class="alert alert-link text-danger">
No updates found, create a new Incident Update below.
</div>
<div v-for="(update, i) in updates">
<div class="alert alert-light" role="alert">
<span class="badge badge-pill badge-info text-uppercase">{{update.type}}</span>
<span class="float-right font-2">{{ago(update.created_at)}} ago</span>
<span class="d-block mt-2">{{update.message}}
<button @click="delete_update(update)" type="button" class="close">
<span aria-hidden="true">&times;</span>
</button>
</span>
</div>
</div>
<form class="row" @submit.prevent="createIncidentUpdate">
<div class="col-3"> <div class="col-3">
<select v-model="incident_update.type" class="form-control"> <select v-model="incident_update.type" class="form-control">
<option value="Investigating">Investigating</option> <option value="Investigating">Investigating</option>
@ -21,6 +37,7 @@
</button> </button>
</div> </div>
</form> </form>
</div>
</template> </template>
<script> <script>
@ -35,29 +52,37 @@
}, },
props: { props: {
incident: { incident: {
type: Object type: Object,
required: true
} }
}, },
data () { data () {
return { return {
updates: [], updates: this.incident.updates,
incident_update: { incident_update: {
incident: this.incident.id, incident: this.incident.id,
message: "", message: "",
type: "" type: ""
} }
} }
},
beforeRouteUpdate (to, from, next) {
}, },
async mounted() { async mounted() {
await this.loadUpdates() await this.loadUpdates()
}, },
methods: { methods: {
async loadUpdates() { async delete_update(update) {
this.updates = await Api.incident_updates(this.incident) await Api.incident_update_delete(update)
await this.loadUpdates()
}, },
async createIncidentUpdate() { async createIncidentUpdate() {
await Api.incident_update_create(this.incident_update) await Api.incident_update_create(this.incident_update)
await this.loadUpdates() await this.loadUpdates()
},
async loadUpdates() {
this.updates = await Api.incident_updates(this.incident)
} }
} }
} }

View File

@ -11,7 +11,7 @@
<div class="form-group"> <div class="form-group">
<label>Database Connection</label> <label>Database Connection</label>
<select @change="canSubmit" v-model="setup.db_connection" id="db_connection" class="form-control"> <select @change="canSubmit" v-model="setup.db_connection" id="db_connection" class="form-control">
<option value="sqlite">Sqlite</option> <option value="sqlite">SQLite</option>
<option value="postgres">Postgres</option> <option value="postgres">Postgres</option>
<option value="mysql">MySQL</option> <option value="mysql">MySQL</option>
</select> </select>

View File

@ -0,0 +1,105 @@
<template>
<div class="col-12">
<h2>{{service.name}} Checkins</h2>
<p class="mb-3">Tell your service to send a routine HTTP request to a Statping Checkin.</p>
<div v-for="(checkin, i) in checkins" class="col-12 alert alert-light" role="alert">
<span class="badge badge-pill badge-info text-uppercase">{{checkin.name}}</span>
<span class="float-right font-2">Last checkin {{ago(checkin.last_hit)}}</span>
<span class="float-right font-2 mr-3">Check Every {{checkin.interval}} seconds</span>
<span class="float-right font-2 mr-3">Grace Period {{checkin.grace}} seconds</span>
<span class="d-block mt-2">
<input type="text" class="form-control" :value="`${core.domain}/checkin/${checkin.api_key}`" readonly>
<span class="small">Send a GET request to this URL every {{checkin.interval}} seconds
<button @click="deleteCheckin(checkin)" type="button" class="btn btn-danger btn-xs float-right mt-1">Delete</button>
</span>
</span>
</div>
<div class="col-12 alert alert-light">
<form @submit.prevent="saveCheckin">
<div class="form-group row">
<div class="col-5">
<label for="checkin_interval" class="col-form-label">Checkin Name</label>
<input v-model="checkin.name" type="text" name="name" class="form-control" id="checkin_name" placeholder="New Checkin">
</div>
<div class="col-2">
<label for="checkin_interval" class="col-form-label">Interval</label>
<input v-model="checkin.interval" type="number" name="interval" class="form-control" id="checkin_interval" placeholder="60">
</div>
<div class="col-2">
<label for="grace_period" class="col-form-label">Grace Period</label>
<input v-model="checkin.grace" type="number" name="grace" class="form-control" id="grace_period" placeholder="10">
</div>
<div class="col-3">
<label class="col-form-label"></label>
<button @click.prevent="saveCheckin" type="submit" id="submit" class="btn btn-primary d-block mt-2">Save Checkin</button>
</div>
</div>
</form>
</div>
</div>
</template>
<script>
import Api from "../API";
export default {
name: 'Checkins',
data() {
return {
service: {},
ready: false,
checkin: {
name: "",
interval: 60,
grace: 60,
service_id: 0
}
}
},
computed: {
checkins() {
return this.$store.getters.serviceCheckins(this.service.id)
},
core() {
return this.$store.getters.core
},
},
async created() {
if (this.$route.params) {
const id = this.$route.params.id
this.service = await Api.service(id)
this.checkin.service_id = this.service.id
this.ready = true
}
},
methods: {
fixInts() {
const c = this.checkin
this.checkin.interval = parseInt(c.interval)
this.checkin.grace = parseInt(c.grace)
return this.checkin
},
async saveCheckin() {
const c = this.fixInts()
await Api.checkin_create(c)
await this.updateCheckins()
},
async deleteCheckin(checkin) {
await Api.checkin_delete(checkin)
await this.updateCheckins()
},
async updateCheckins() {
const checkins = await Api.checkins()
this.$store.commit('setCheckins', checkins)
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
.sm {
font-size: 8pt;
}
</style>

View File

@ -0,0 +1,93 @@
<template>
<div class="col-12">
<h2>{{service.name}} Failures
<span class="btn btn-outline-danger float-right">Delete All</span></h2>
<div class="list-group mt-3 mb-4">
<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">
<h5 class="mb-1">{{failure.issue}}</h5>
<small>{{niceDate(failure.created_at)}}</small>
</div>
<p class="mb-1">{{failure.issue}}</p>
</div>
<nav v-if="total > 4" class="mt-3">
<ul class="pagination justify-content-center">
<li class="page-item" :class="{'disabled': page===1}">
<a @click.prevent="gotoPage(page-1)" :disabled="page===1" class="page-link" href="#" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
<span class="sr-only">Previous</span>
</a>
</li>
<li v-for="n in maxPages" class="page-item" :class="{'active': page === n}">
<a @click.prevent="gotoPage(n)" class="page-link" href="#">{{n}}</a>
</li>
<li class="page-item" :class="{'disabled': page===Math.floor(total / limit)}">
<a @click.prevent="gotoPage(page+1)" :disabled="page===Math.floor(total / limit)" class="page-link" href="#" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
<span class="sr-only">Next</span>
</a>
</li>
</ul>
<div class="text-center">
<span class="text-black-50">{{total}} Total</span>
</div>
</nav>
</div>
</div>
</template>
<script>
import Api from "../API";
export default {
name: 'Failures',
data() {
return {
service: {},
failures: [],
limit: 10,
offset: 0,
total: 0,
page: 1
}
},
computed: {
pages() {
return Math.floor(this.total / this.limit)
},
maxPages() {
const p = Math.floor(this.total / this.limit)
if (p > 16) {
return 16
} else {
return p
}
}
},
async created() {
this.service = await Api.service(this.$route.params.id)
this.total = this.service.stats.failures
await this.gotoPage(1)
},
methods: {
async gotoPage(page) {
this.page = page;
this.offset = (page-1) * this.limit;
window.console.log('page', this.page, this.limit, this.offset);
this.failures = await Api.service_failures(this.service.id, 0, 9999999999, this.limit, this.offset)
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
.sm {
font-size: 8pt;
}
</style>

View File

@ -0,0 +1,109 @@
<template>
<div class="col-12">
<div v-for="(incident, i) in incidents" class="card contain-card text-black-50 bg-white mb-4">
<div class="card-header">Incident: {{incident.title}}
<button @click="deleteIncident(incident)" class="btn btn-sm btn-danger float-right">
<font-awesome-icon icon="times" /> Delete
</button>
</div>
<FormIncidentUpdates :incident="incident"/>
<span class="font-2 p-2 pl-3">Created: {{niceDate(incident.created_at)}} | Last Update: {{niceDate(incident.updated_at)}}</span>
</div>
<div class="card contain-card text-black-50 bg-white">
<div class="card-header">Create Incident</div>
<div class="card-body">
<form @submit.prevent="createIncident">
<div class="form-group row">
<label class="col-sm-4 col-form-label">Title</label>
<div class="col-sm-8">
<input v-model="incident.title" type="text" name="title" class="form-control" id="title" placeholder="Incident Title" required>
</div>
</div>
<div class="form-group row">
<label class="col-sm-4 col-form-label">Description</label>
<div class="col-sm-8">
<textarea v-model="incident.description" rows="5" name="description" class="form-control" id="description" required></textarea>
</div>
</div>
<div class="form-group row">
<div class="col-sm-12">
<button @click.prevent="createIncident"
:disabled="!incident.title || !incident.description"
type="submit" class="btn btn-block btn-primary">
Create Incident
</button>
</div>
</div>
<div class="alert alert-danger d-none" id="alerter" role="alert"></div>
</form>
</div>
</div>
</div>
</template>
<script>
import Api from "../API";
import FormIncidentUpdates from "@/forms/IncidentUpdates";
export default {
name: 'Incidents',
components: {FormIncidentUpdates},
data() {
return {
service_id: 0,
ready: false,
incidents: [],
incident: {
title: "",
description: "",
service: 0,
}
}
},
computed:{
theID() {
return this.$route.params.id
}
},
async mounted() {
await this.loadIncidents()
},
methods: {
async getIncidents() {
return await Api.incidents_service(this.theID)
},
async deleteIncident(incident) {
let c = confirm(`Are you sure you want to delete '${incident.title}'?`)
if (c) {
await Api.incident_delete(incident)
await this.loadIncidents()
}
},
async loadIncidents() {
this.incidents = await Api.incidents_service(this.service_id)
},
async createIncident() {
await Api.incident_create(this.theID, this.incident)
await this.loadIncidents()
this.incident = {
title: "",
description: "",
service: this.service_id,
}
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
.sm {
font-size: 8pt;
}
</style>

View File

@ -26,7 +26,7 @@
<div class="col-12 full-col-12"> <div class="col-12 full-col-12">
<div v-for="(service, index) in services" :ref="service.id" v-bind:key="index"> <div v-for="(service, index) in services" :ref="service.id" v-bind:key="index">
<ServiceBlock :service=service /> <ServiceBlock :in_service=service />
</div> </div>
</div> </div>

View File

@ -1,5 +1,5 @@
<template> <template>
<div v-if="service" class="container col-md-7 col-sm-12 mt-md-5 bg-light"> <div class="container col-md-7 col-sm-12 mt-md-5 bg-light">
<div class="col-12 mb-4"> <div class="col-12 mb-4">
@ -8,7 +8,7 @@
</span> </span>
<h4 class="mt-2"> <h4 class="mt-2">
<router-link to="/" class="text-black-50 text-decoration-none">{{$store.getters.core.name}}</router-link> - <span class="text-muted">{{service.name}}</span> <router-link to="/" class="text-black-50 text-decoration-none">{{core.name}}</router-link> - <span class="text-muted">{{service.name}}</span>
<span class="badge float-right d-none d-md-block" :class="{'bg-success': service.online, 'bg-danger': !service.online}"> <span class="badge float-right d-none d-md-block" :class="{'bg-success': service.online, 'bg-danger': !service.online}">
{{service.online ? "ONLINE" : "OFFLINE"}} {{service.online ? "ONLINE" : "OFFLINE"}}
</span> </span>
@ -37,6 +37,11 @@
<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"> <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='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='incidents'" class="flex-sm-fill text-sm-center nav-link">Incidents</a>
@ -132,7 +137,7 @@ export default {
}, },
data() { data() {
return { return {
id: this.$route.params.id, id: 0,
tab: "failures", tab: "failures",
authenticated: false, authenticated: false,
ready: true, ready: true,
@ -141,6 +146,8 @@ export default {
failures: [], failures: [],
start_time: this.nowSubtract(84600 * 30), start_time: this.nowSubtract(84600 * 30),
end_time: new Date(), end_time: new Date(),
timedata: [],
load_timedata: false,
dailyRangeOpts: { dailyRangeOpts: {
chart: { chart: {
height: 500, height: 500,
@ -148,6 +155,45 @@ export default {
type: "area", type: "area",
} }
}, },
timeRangeOptions: {
chart: {
height: 200,
type: 'rangeBar'
},
plotOptions: {
bar: {
horizontal: true,
distributed: true,
dataLabels: {
hideOverflowingLabels: false
}
}
},
dataLabels: {
enabled: true,
formatter: (val, opts) => {
var label = opts.w.globals.labels[opts.dataPointIndex]
var a = this.parseISO(val[0])
var b = this.parseISO(val[1])
return label
},
style: {
colors: ['#f3f4f5', '#fff']
}
},
xaxis: {
type: 'datetime'
},
yaxis: {
show: false
},
grid: {
row: {
colors: ['#f3f4f5', '#fff'],
opacity: 1
}
}
},
chartOptions: { chartOptions: {
chart: { chart: {
events: { events: {
@ -270,22 +316,63 @@ export default {
computed: { computed: {
service () { service () {
return this.$store.getters.serviceByAll(this.id) return this.$store.getters.serviceByAll(this.id)
},
core () {
return this.$store.getters.core
},
uptime_data() {
const data = this.timedata.series.filter(g => g.online)
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() {
return [{data: this.time_chart_data}]
},
}, },
watch: { watch: {
service: function(n, o) { service: function(n, o) {
this.chartHits() this.chartHits()
this.fetchUptime()
},
load_timedata: function(n, o) {
this.chartHits()
} }
}, },
created() { async created() {
this.id = this.$route.params.id;
},
mounted() {
}, },
methods: { methods: {
async fetchUptime() {
this.timedata = await Api.service_uptime(this.id)
this.load_timedata = true
},
async get() { async get() {
const s = store.getters.serviceByAll(this.id) const s = this.$store.getters.serviceByAll(this.id)
window.console.log("service: ", s) window.console.log("service: ", s)
this.getService(this.service) this.getService(this.service)
this.messages = this.$store.getters.serviceMessages(this.service.id) this.messages = this.$store.getters.serviceMessages(this.service.id)
@ -295,13 +382,12 @@ export default {
const end = this.isBetween(message.end_on, new Date()) const end = this.isBetween(message.end_on, new Date())
return start && end return start && end
}, },
async getService(s) { async getService() {
await this.chartHits() await this.chartHits()
await this.serviceFailures() await this.serviceFailures()
}, },
async serviceFailures() { async serviceFailures() {
let tt = this.startEndTimes() let tt = this.startEndTimes()
this.failures = await Api.service_failures(this.service.id, tt.start, tt.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, group="30m") {

View File

@ -13,6 +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 Checkins from "@/pages/Checkins";
import Failures from "@/pages/Failures";
const routes = [ const routes = [
{ {
@ -62,6 +65,24 @@ const routes = [
meta: { meta: {
requiresAuth: true requiresAuth: true
} }
},{
path: 'service/:id/incidents',
component: Incidents,
meta: {
requiresAuth: true
}
},{
path: 'service/:id/checkins',
component: Checkins,
meta: {
requiresAuth: true
}
},{
path: 'service/:id/failures',
component: Failures,
meta: {
requiresAuth: true
}
},{ },{
path: 'messages', path: 'messages',
component: DashboardMessages, component: DashboardMessages,

View File

@ -111,6 +111,7 @@ func Router() *mux.Router {
api.Handle("/api/services/{id}/hits_data", cached("30s", "application/json", apiServiceDataHandler)).Methods("GET") api.Handle("/api/services/{id}/hits_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}/failure_data", cached("30s", "application/json", apiServiceFailureDataHandler)).Methods("GET")
api.Handle("/api/services/{id}/ping_data", cached("30s", "application/json", apiServicePingDataHandler)).Methods("GET") api.Handle("/api/services/{id}/ping_data", cached("30s", "application/json", apiServicePingDataHandler)).Methods("GET")
api.Handle("/api/services/{id}/uptime_data", http.HandlerFunc(apiServiceTimeDataHandler)).Methods("GET")
//api.Handle("/api/services/{id}/heatmap", cached("30s", "application/json", apiServiceHeatmapHandler)).Methods("GET") //api.Handle("/api/services/{id}/heatmap", cached("30s", "application/json", apiServiceHeatmapHandler)).Methods("GET")
// API INCIDENTS Routes // API INCIDENTS Routes
@ -120,7 +121,7 @@ func Router() *mux.Router {
api.Handle("/api/incidents/{id}", authenticated(apiDeleteIncidentHandler, false)).Methods("DELETE") api.Handle("/api/incidents/{id}", authenticated(apiDeleteIncidentHandler, false)).Methods("DELETE")
// API INCIDENTS UPDATES Routes // API INCIDENTS UPDATES Routes
api.Handle("/api/incidents/{id}/updates", authenticated(apiIncidentUpdatesHandler, false)).Methods("GET") api.Handle("/api/incidents/{id}/updates", http.HandlerFunc(apiIncidentUpdatesHandler)).Methods("GET")
api.Handle("/api/incidents/{id}/updates", authenticated(apiCreateIncidentUpdateHandler, false)).Methods("POST") api.Handle("/api/incidents/{id}/updates", authenticated(apiCreateIncidentUpdateHandler, false)).Methods("POST")
api.Handle("/api/incidents/{id}/updates/{uid}", authenticated(apiDeleteIncidentUpdateHandler, false)).Methods("DELETE") api.Handle("/api/incidents/{id}/updates/{uid}", authenticated(apiDeleteIncidentUpdateHandler, false)).Methods("DELETE")

View File

@ -9,6 +9,8 @@ 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 {
@ -176,6 +178,140 @@ func apiServicePingDataHandler(w http.ResponseWriter, r *http.Request) {
returnJson(objs, w, r) returnJson(objs, w, r)
} }
func apiServiceTimeDataHandler(w http.ResponseWriter, r *http.Request) {
service, err := serviceByID(r)
if err != nil {
sendErrorJson(errors.New("service data not found"), w, r)
return
}
allFailures := service.AllFailures()
allHits := service.AllHits()
tMap := make(map[time.Time]bool)
for _, v := range allHits.List() {
tMap[v.CreatedAt] = true
}
for _, v := range allFailures.List() {
tMap[v.CreatedAt] = false
}
var servs []ser
for t, v := range tMap {
s := ser{
Time: t,
Online: v,
}
servs = append(servs, s)
}
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
}
}
first := servs[0].Time
last := servs[len(servs)-1].Time
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{
Start: first,
End: last,
Uptime: addDurations(allTimes, true),
Downtime: addDurations(allTimes, false),
Series: allTimes,
}
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) {
service, err := serviceByID(r) service, err := serviceByID(r)
if err != nil { if err != nil {

View File

@ -26,7 +26,7 @@ type Service struct {
Domain string `gorm:"column:domain" json:"domain" private:"true" scope:"user,admin"` Domain string `gorm:"column:domain" json:"domain" private:"true" scope:"user,admin"`
Expected null.NullString `gorm:"column:expected" json:"expected" scope:"user,admin"` Expected null.NullString `gorm:"column:expected" json:"expected" scope:"user,admin"`
ExpectedStatus int `gorm:"default:200;column:expected_status" json:"expected_status" scope:"user,admin"` ExpectedStatus int `gorm:"default:200;column:expected_status" json:"expected_status" scope:"user,admin"`
Interval int `gorm:"default:30;column:check_interval" json:"check_interval" scope:"user,admin"` Interval int `gorm:"default:30;column:check_interval" json:"check_interval"`
Type string `gorm:"column:check_type" json:"type" scope:"user,admin"` Type string `gorm:"column:check_type" json:"type" scope:"user,admin"`
Method string `gorm:"column:method" json:"method" scope:"user,admin"` Method string `gorm:"column:method" json:"method" scope:"user,admin"`
PostData null.NullString `gorm:"column:post_data" json:"post_data" scope:"user,admin"` PostData null.NullString `gorm:"column:post_data" json:"post_data" scope:"user,admin"`