checkins, UX, and fixes

pull/490/head
Hunter Long 2020-04-08 18:47:27 -07:00
parent 8f32e89f4e
commit bbfb21ec03
18 changed files with 341 additions and 122 deletions

View File

@ -2,6 +2,9 @@
- Added Incident Reporting
- Added Cypress tests
- Added Github and Google OAuth login (beta)
- Added Delete All Failures
- Added Checkin form
- Added Pushover notifier
# 0.90.22
- Added range input types for integer form fields

View File

@ -68,6 +68,10 @@ class Api {
return axios.post('api/reorder/services', data).then(response => (response.data))
}
async checkins() {
return axios.get('api/checkins').then(response => (response.data))
}
async groups() {
return axios.get('api/groups').then(response => (response.data))
}
@ -129,6 +133,14 @@ class Api {
return axios.delete('api/incidents/'+incident.id).then(response => (response.data))
}
async checkin_create(data) {
return axios.post('api/checkins', data).then(response => (response.data))
}
async checkin_delete(checkin) {
return axios.delete('api/checkins/'+checkin.api_key).then(response => (response.data))
}
async messages() {
return axios.get('api/messages').then(response => (response.data))
}

View File

@ -15,13 +15,27 @@ HTML,BODY {
}
.copy-btn {
position: absolute;
right: 0;
}
.btn-xs {
font-size: 8pt;
padding: 2px 6px;
}
.copy-btn BUTTON {
background-color: white;
margin: 6px;
height: 26px;
font-size: 10pt;
padding: 3px 7px;
border: 1px solid #a7a7a7;
border-radius: 4px;
border-radius: 4px !important;
}
.dim {
background-color: #f3f3f3;
}
.slider-info {

View File

@ -26,10 +26,6 @@
<li v-if="$store.state.admin" @click="navopen = !navopen" class="nav-item navbar-item">
<router-link to="/dashboard/logs" class="nav-link">Logs</router-link>
</li>
<li @click="navopen = !navopen" class="nav-item navbar-item">
<router-link to="/dashboard/help" class="nav-link">Help</router-link>
</li>
</ul>
<span class="navbar-text">
<a href="#" class="nav-link" v-on:click="logout">Logout</a>

View File

@ -59,6 +59,10 @@
<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>
@ -79,6 +83,7 @@
</template>
<script>
import Checkin from '../../forms/Checkin';
import FormIncident from '../../forms/Incident';
import FormMessage from '../../forms/Message';
import ServiceFailures from './ServiceFailures';
@ -89,6 +94,7 @@
export default {
name: 'ServiceInfo',
components: {
Checkin,
ServiceFailures,
FormIncident,
FormMessage,

View File

@ -1,23 +1,41 @@
<template>
<form @submit.prevent="saveCheckin">
<div class="form-group row">
<div class="col-md-3">
<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-3">
<label for="checkin_interval" class="col-form-label">Interval (seconds)</label>
<input v-model="checkin.interval" type="number" name="interval" class="form-control" id="checkin_interval" placeholder="60">
</div>
<div class="col-3">
<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">
<button @click.prevent="saveCheckin" type="submit" id="submit" class="btn btn-success d-block" style="margin-top: 14px;">Save Checkin</button>
</div>
<div>
<div v-for="(checkin, i) in checkins" class="col-12 alert dim" 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 dim">
<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-success d-block mt-2">Save Checkin</button>
</div>
</div>
</form>
</div>
</div>
</form>
</template>
<script>
@ -37,20 +55,41 @@
name: "",
interval: 60,
grace: 60,
service: this.service.id
service_id: this.service.id
}
}
},
mounted() {
},
methods: {
async saveCheckin() {
const data = {name: this.group.name, public: this.group.public}
await Api.group_create(data)
const groups = await Api.groups()
this.$store.commit('setGroups', groups)
computed: {
checkins() {
return this.$store.getters.serviceCheckins(this.service.id)
},
core() {
return this.$store.getters.core
},
},
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>

View File

@ -8,11 +8,9 @@
</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>

View File

@ -5,84 +5,83 @@
</div>
<div class="col-12">
<form @submit.prevent="saveSetup">
<div class="row">
<div class="col-6">
<div class="form-group">
<label>Database Connection</label>
<select @change="canSubmit" v-model="setup.db_connection" id="db_connection" class="form-control">
<option value="sqlite">Sqlite</option>
<option value="postgres">Postgres</option>
<option value="mysql">MySQL</option>
</select>
</div>
<div v-if="setup.db_connection !== 'sqlite'" class="form-group">
<label>Host</label>
<input @keyup="canSubmit" v-model="setup.db_host" id="db_host" type="text" class="form-control" placeholder="localhost">
</div>
<div v-if="setup.db_connection !== 'sqlite'" class="form-group">
<label>Database Port</label>
<input @keyup="canSubmit" v-model="setup.db_port" id="db_port" type="text" class="form-control" placeholder="localhost">
</div>
<div v-if="setup.db_connection !== 'sqlite'" class="form-group">
<label>Username</label>
<input @keyup="canSubmit" v-model="setup.db_user" id="db_user" type="text" class="form-control" placeholder="root">
</div>
<div v-if="setup.db_connection !== 'sqlite'" class="form-group">
<label for="db_password">Password</label>
<input @keyup="canSubmit" v-model="setup.db_password" id="db_password" type="password" class="form-control" placeholder="password123">
</div>
<div v-if="setup.db_connection !== 'sqlite'" class="form-group">
<label for="db_database">Database</label>
<input @keyup="canSubmit" v-model="setup.db_database" id="db_database" type="text" class="form-control" placeholder="Database name">
</div>
<form @submit.prevent="saveSetup">
<div class="row">
<div class="col-6">
<div class="form-group">
<label>Database Connection</label>
<select @change="canSubmit" v-model="setup.db_connection" id="db_connection" class="form-control">
<option value="sqlite">Sqlite</option>
<option value="postgres">Postgres</option>
<option value="mysql">MySQL</option>
</select>
</div>
<div v-if="setup.db_connection !== 'sqlite'" class="form-group">
<label>Host</label>
<input @keyup="canSubmit" v-model="setup.db_host" id="db_host" type="text" class="form-control" placeholder="localhost">
</div>
<div v-if="setup.db_connection !== 'sqlite'" class="form-group">
<label>Database Port</label>
<input @keyup="canSubmit" v-model="setup.db_port" id="db_port" type="text" class="form-control" placeholder="localhost">
</div>
<div v-if="setup.db_connection !== 'sqlite'" class="form-group">
<label>Username</label>
<input @keyup="canSubmit" v-model="setup.db_user" id="db_user" type="text" class="form-control" placeholder="root">
</div>
<div v-if="setup.db_connection !== 'sqlite'" class="form-group">
<label for="db_password">Password</label>
<input @keyup="canSubmit" v-model="setup.db_password" id="db_password" type="password" class="form-control" placeholder="password123">
</div>
<div v-if="setup.db_connection !== 'sqlite'" class="form-group">
<label for="db_database">Database</label>
<input @keyup="canSubmit" v-model="setup.db_database" id="db_database" type="text" class="form-control" placeholder="Database name">
</div>
</div>
</div>
<div class="col-6">
<div class="col-6">
<div class="form-group">
<label>Project Name</label>
<input @keyup="canSubmit" v-model="setup.project" id="project" type="text" class="form-control" placeholder="Great Uptime" required>
</div>
<div class="form-group">
<label>Project Name</label>
<input @keyup="canSubmit" v-model="setup.project" id="project" type="text" class="form-control" placeholder="Great Uptime" required>
<div class="form-group">
<label>Project Description</label>
<input @keyup="canSubmit" v-model="setup.description" id="description" type="text" class="form-control" placeholder="Great Uptime">
</div>
<div class="form-group">
<label for="domain">Domain URL</label>
<input @keyup="canSubmit" v-model="setup.domain" type="text" class="form-control" id="domain" required>
</div>
<div class="form-group">
<label>Admin Username</label>
<input @keyup="canSubmit" v-model="setup.username" id="username" type="text" class="form-control" placeholder="admin" required>
</div>
<div class="form-group">
<label>Admin Password</label>
<input @keyup="canSubmit" v-model="setup.password" id="password" type="password" class="form-control" placeholder="password" required>
</div>
<div class="form-group">
<label>Confirm Admin Password</label>
<input @keyup="canSubmit" v-model="setup.confirm_password" id="password_confirm" type="password" class="form-control" placeholder="password" required>
</div>
</div>
<div v-if="error" class="col-12 alert alert-danger">
{{error}}
</div>
<button @click.prevent="saveSetup" v-bind:disabled="disabled || loading" type="submit" class="btn btn-primary btn-block" :class="{'btn-primary': !loading, 'btn-default': loading}">
{{loading ? "Loading..." : "Save Settings"}}
</button>
</div>
<div class="form-group">
<label>Project Description</label>
<input @keyup="canSubmit" v-model="setup.description" id="description" type="text" class="form-control" placeholder="Great Uptime">
</div>
<div class="form-group">
<label for="domain">Domain URL</label>
<input @keyup="canSubmit" v-model="setup.domain" type="text" class="form-control" id="domain" required>
</div>
<div class="form-group">
<label>Admin Username</label>
<input @keyup="canSubmit" v-model="setup.username" id="username" type="text" class="form-control" placeholder="admin" required>
</div>
<div class="form-group">
<label>Admin Password</label>
<input @keyup="canSubmit" v-model="setup.password" id="password" type="password" class="form-control" placeholder="password" required>
</div>
<div class="form-group">
<label>Confirm Admin Password</label>
<input @keyup="canSubmit" v-model="setup.confirm_password" id="password_confirm" type="password" class="form-control" placeholder="password" required>
</div>
</div>
<div v-if="error" class="col-12 alert alert-danger">
{{error}}
</div>
<button @click.prevent="saveSetup" v-bind:disabled="disabled || loading" type="submit" class="btn btn-primary btn-block" :class="{'btn-primary': !loading, 'btn-default': loading}">
{{loading ? "Loading..." : "Save Settings"}}
</button>
</div>
</form>
</form>
</div>
</div>

View File

@ -73,7 +73,7 @@
<div class="col-sm-9">
<div class="input-group">
<input v-model="core.api_key" type="text" class="form-control" id="api_key" readonly>
<div class="input-group-append">
<div class="input-group-append copy-btn">
<button @click.prevent="copy(core.api_key)" class="btn btn-outline-secondary" type="button">Copy</button>
</div>
</div>
@ -86,7 +86,7 @@
<div class="col-sm-9">
<div class="input-group">
<input v-model="core.api_secret" @focus="$event.target.select()" type="text" class="form-control select-input" id="api_secret" readonly>
<div class="input-group-append">
<div class="input-group-append copy-btn">
<button @click="copy(core.api_secret)" class="btn btn-outline-secondary" type="button">Copy</button>
</div>
</div>

View File

@ -26,6 +26,7 @@ export default new Vuex.Store({
messages: [],
users: [],
notifiers: [],
checkins: [],
admin: false
},
getters: {
@ -39,6 +40,7 @@ export default new Vuex.Store({
incidents: state => state.incidents,
users: state => state.users,
notifiers: state => state.notifiers,
checkins: state => state.checkins,
isAdmin: state => state.admin,
@ -48,6 +50,9 @@ export default new Vuex.Store({
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),
serviceCheckins: (state) => (id) => {
return state.checkins.filter(c => c.service_id === id)
},
serviceByAll: (state) => (element) => {
if (element % 1 === 0) {
return state.services.find(s => s.id == element)
@ -99,6 +104,9 @@ export default new Vuex.Store({
setServices (state, services) {
state.services = services
},
setCheckins (state, checkins) {
state.checkins = checkins
},
setGroups (state, groups) {
state.groups = groups
},
@ -147,6 +155,8 @@ export default new Vuex.Store({
context.commit("setGroups", groups);
const services = await Api.services()
context.commit("setServices", services);
const checkins = await Api.checkins()
context.commit("setCheckins", checkins);
const messages = await Api.messages()
context.commit("setMessages", messages)
context.commit("setHasPublicData", true)

View File

@ -40,8 +40,7 @@ func checkinCreateHandler(w http.ResponseWriter, r *http.Request) {
return
}
checkin.ServiceId = service.Id
err = checkin.Create()
if err != nil {
if err := checkin.Create(); err != nil {
sendErrorJson(err, w, r)
return
}
@ -52,7 +51,7 @@ func checkinHitHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
checkin, err := checkins.FindByAPI(vars["api"])
if err != nil {
sendErrorJson(fmt.Errorf("checkin %v was not found", vars["api"]), w, r)
sendErrorJson(fmt.Errorf("checkin %s was not found", vars["api"]), w, r)
return
}
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
@ -60,15 +59,17 @@ func checkinHitHandler(w http.ResponseWriter, r *http.Request) {
hit := &checkins.CheckinHit{
Checkin: checkin.Id,
From: ip,
CreatedAt: utils.Now().UTC(),
CreatedAt: utils.Now(),
}
log.Infof("Checking %s was requested", checkin.Name)
err = hit.Create()
if err != nil {
sendErrorJson(fmt.Errorf("checkin %v was not found", vars["api"]), w, r)
return
}
checkin.Failing = false
checkin.LastHitTime = utils.Now().UTC()
checkin.LastHitTime = utils.Now()
sendJsonAction(hit.Id, "update", w, r)
}

View File

@ -8,11 +8,6 @@ import (
"net/http"
)
func apiAllIncidentsHandler(w http.ResponseWriter, r *http.Request) {
inc := incidents.All()
returnJson(inc, w, r)
}
func apiServiceIncidentsHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
incids := incidents.FindByService(utils.ToInt(vars["id"]))

View File

@ -146,9 +146,9 @@ func Router() *mux.Router {
// API CHECKIN Routes
api.Handle("/api/checkins", authenticated(apiAllCheckinsHandler, false)).Methods("GET")
api.Handle("/api/checkin/{api}", authenticated(apiCheckinHandler, false)).Methods("GET")
api.Handle("/api/checkin", authenticated(checkinCreateHandler, false)).Methods("POST")
api.Handle("/api/checkin/{api}", authenticated(checkinDeleteHandler, false)).Methods("DELETE")
api.Handle("/api/checkins", authenticated(checkinCreateHandler, false)).Methods("POST")
api.Handle("/api/checkins/{api}", authenticated(apiCheckinHandler, false)).Methods("GET")
api.Handle("/api/checkins/{api}", authenticated(checkinDeleteHandler, false)).Methods("DELETE")
r.Handle("/checkin/{api}", http.HandlerFunc(checkinHitHandler))
// Static Files Routes

View File

@ -20,6 +20,7 @@ func InitNotifiers() {
Twilio,
Webhook,
Mobile,
Pushover,
)
}

85
notifiers/pushover.go Normal file
View File

@ -0,0 +1,85 @@
package notifiers
import (
"fmt"
"github.com/statping/statping/types/failures"
"github.com/statping/statping/types/notifications"
"github.com/statping/statping/types/notifier"
"github.com/statping/statping/types/services"
"github.com/statping/statping/utils"
"net/url"
"strings"
"time"
)
const (
pushoverUrl = "https://api.pushover.net/1/messages.json"
)
var _ notifier.Notifier = (*pushover)(nil)
type pushover struct {
*notifications.Notification
}
func (t *pushover) Select() *notifications.Notification {
return t.Notification
}
var Pushover = &pushover{&notifications.Notification{
Method: "pushover",
Title: "Pushover",
Description: "Use Pushover to receive push notifications. You will need to create a <a href=\"https://pushover.net/apps/build\">New Application</a> on Pushover before using this notifier.",
Author: "Hunter Long",
AuthorUrl: "https://github.com/hunterlong",
Icon: "fa dot-circle",
Delay: time.Duration(10 * time.Second),
Limits: 60,
Form: []notifications.NotificationForm{{
Type: "text",
Title: "User Token",
Placeholder: "Insert your device's Pushover Token",
DbField: "api_key",
Required: true,
}, {
Type: "text",
Title: "Application API Key",
Placeholder: "Create an Application and insert the API Key here",
DbField: "api_secret",
Required: true,
},
}},
}
// Send will send a HTTP Post to the Pushover API. It accepts type: string
func (t *pushover) sendMessage(message string) error {
v := url.Values{}
v.Set("token", t.ApiSecret)
v.Set("user", t.ApiKey)
v.Set("message", message)
rb := strings.NewReader(v.Encode())
_, _, err := utils.HttpRequest(pushoverUrl, "POST", "application/x-www-form-urlencoded", nil, rb, time.Duration(10*time.Second), true)
if err != nil {
return err
}
return err
}
// OnFailure will trigger failing service
func (t *pushover) OnFailure(s *services.Service, f *failures.Failure) error {
msg := fmt.Sprintf("Your service '%v' is currently offline!", s.Name)
return t.sendMessage(msg)
}
// OnSuccess will trigger successful service
func (t *pushover) OnSuccess(s *services.Service) error {
msg := fmt.Sprintf("Your service '%v' is currently online!", s.Name)
return t.sendMessage(msg)
}
// OnTest will test the Pushover SMS messaging
func (t *pushover) OnTest() error {
msg := fmt.Sprintf("Testing the Pushover Notifier")
return t.sendMessage(msg)
}

View File

@ -0,0 +1,60 @@
package notifiers
import (
"github.com/statping/statping/database"
"github.com/statping/statping/types/notifications"
"github.com/statping/statping/types/null"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"os"
"testing"
)
var (
PUSHOVER_TOKEN = os.Getenv("PUSHOVER_TOKEN")
PUSHOVER_API = os.Getenv("PUSHOVER_API")
)
func TestPushoverNotifier(t *testing.T) {
db, err := database.OpenTester()
require.Nil(t, err)
db.AutoMigrate(&notifications.Notification{})
notifications.SetDB(db)
if PUSHOVER_TOKEN == "" || PUSHOVER_API == "" {
t.Log("Pushover notifier testing skipped, missing PUSHOVER_TOKEN and PUSHOVER_API environment variable")
t.SkipNow()
}
t.Run("Load Pushover", func(t *testing.T) {
Pushover.ApiKey = PUSHOVER_TOKEN
Pushover.ApiSecret = PUSHOVER_API
Pushover.Enabled = null.NewNullBool(true)
Add(Pushover)
assert.Nil(t, err)
assert.Equal(t, "Hunter Long", Pushover.Author)
assert.Equal(t, PUSHOVER_TOKEN, Pushover.ApiKey)
})
t.Run("Pushover Within Limits", func(t *testing.T) {
assert.True(t, Pushover.CanSend())
})
t.Run("Pushover OnFailure", func(t *testing.T) {
err := Pushover.OnFailure(exampleService, exampleFailure)
assert.Nil(t, err)
})
t.Run("Pushover OnSuccess", func(t *testing.T) {
err := Pushover.OnSuccess(exampleService)
assert.Nil(t, err)
})
t.Run("Pushover Test", func(t *testing.T) {
err := Pushover.OnTest()
assert.Nil(t, err)
})
}

View File

@ -32,7 +32,7 @@ func All() []*Checkin {
}
func (c *Checkin) Create() error {
c.ApiKey = utils.RandomString(7)
c.ApiKey = utils.RandomString(32)
q := db.Create(c)
c.Start()

View File

@ -57,7 +57,7 @@ func Connect(configs *DbConfig, retry bool) error {
if err != nil {
log.Debugln(fmt.Sprintf("Database connection error %s", err))
if retry {
log.Errorln(fmt.Sprintf("Database %s connection to '%s' is not available, trying again in 5 seconds...", configs.DbConn, configs.DbHost))
log.Warnln(fmt.Sprintf("Database %s connection to '%s' is not available, trying again in 5 seconds...", configs.DbConn, configs.DbHost))
time.Sleep(5 * time.Second)
return Connect(configs, retry)
} else {