forked from osmarks/onstat
initial commit
This commit is contained in:
commit
532302ae8e
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
onstat
|
||||||
|
monitoring.sqlite3*
|
14
onstat.nimble
Normal file
14
onstat.nimble
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# Package
|
||||||
|
|
||||||
|
version = "0.1.0"
|
||||||
|
author = "osmarks"
|
||||||
|
description = "Monitor the existence/status of your networked things."
|
||||||
|
license = "MIT"
|
||||||
|
srcDir = "src"
|
||||||
|
bin = @["onstat"]
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
|
||||||
|
requires "nim >= 1.4.2"
|
||||||
|
requires "https://github.com/GULPF/tiny_sqlite#8fe760d9"
|
||||||
|
requires "karax >= 1.2.1"
|
33
src/db.nim
Normal file
33
src/db.nim
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import tiny_sqlite
|
||||||
|
import options
|
||||||
|
import times
|
||||||
|
|
||||||
|
let migrations: seq[string] = @[
|
||||||
|
"""
|
||||||
|
CREATE TABLE sites (
|
||||||
|
sid INTEGER PRIMARY KEY,
|
||||||
|
url TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE reqs (
|
||||||
|
rid INTEGER PRIMARY KEY,
|
||||||
|
site INTEGER NOT NULL REFERENCES sites(sid),
|
||||||
|
timestamp INTEGER NOT NULL,
|
||||||
|
status INTEGER NOT NULL,
|
||||||
|
latency INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX req_ts_idx ON reqs (timestamp);
|
||||||
|
"""
|
||||||
|
]
|
||||||
|
|
||||||
|
proc migrate*(db: DbConn) =
|
||||||
|
let currentVersion = fromDbValue(get db.value("PRAGMA user_version"), int)
|
||||||
|
for mid in (currentVersion + 1) .. migrations.len:
|
||||||
|
db.transaction:
|
||||||
|
echo "Migrating to schema " & $mid
|
||||||
|
db.execScript migrations[mid - 1]
|
||||||
|
# for some reason this pragma does not work using normal parameter binding
|
||||||
|
db.exec("PRAGMA user_version = " & $mid)
|
||||||
|
echo "DB ready"
|
132
src/onstat.nim
Normal file
132
src/onstat.nim
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import options
|
||||||
|
import asyncdispatch
|
||||||
|
import asynchttpserver
|
||||||
|
import tiny_sqlite
|
||||||
|
import macros
|
||||||
|
import times
|
||||||
|
import karax/[karaxdsl, vdom]
|
||||||
|
import httpclient
|
||||||
|
import sugar
|
||||||
|
import net
|
||||||
|
import sequtils
|
||||||
|
import strformat
|
||||||
|
|
||||||
|
import ./db
|
||||||
|
|
||||||
|
macro includeFile(x: string): string = newStrLitNode(readFile(x.strVal))
|
||||||
|
|
||||||
|
const css = includeFile("./src/style.css")
|
||||||
|
let database = openDatabase("./monitoring.sqlite3")
|
||||||
|
migrate(database)
|
||||||
|
|
||||||
|
var threadDB {.threadvar.}: Option[DbConn]
|
||||||
|
proc getDB(): DbConn =
|
||||||
|
if isNone threadDB:
|
||||||
|
let x = openDatabase("./monitoring.sqlite3")
|
||||||
|
x.exec("PRAGMA journal_mode=WAL")
|
||||||
|
threadDB = some x
|
||||||
|
get threadDB
|
||||||
|
|
||||||
|
func timeToTimestamp*(t: Time): int64 = toUnix(t) * 1000000 + (nanosecond(t) div 1000)
|
||||||
|
func timestampToTime*(ts: int64): Time = initTime(ts div 1000000, (ts mod 1000000) * 1000)
|
||||||
|
|
||||||
|
proc toDbValue(t: Time): DbValue = DbValue(kind: sqliteInteger, intVal: timeToTimestamp(t))
|
||||||
|
proc fromDbValue(value: DbValue, T: typedesc[Time]): Time = timestampToTime(value.intVal)
|
||||||
|
|
||||||
|
type
|
||||||
|
ResponseType = enum
|
||||||
|
rtOk = 0
|
||||||
|
rtHttpError = 1
|
||||||
|
rtTimeout = 2
|
||||||
|
rtFetchError = 3
|
||||||
|
Response = object
|
||||||
|
rtype: ResponseType
|
||||||
|
latency: int64 # microseconds
|
||||||
|
SiteStatus = object
|
||||||
|
url: string
|
||||||
|
lastPing: Time
|
||||||
|
lastResponse: ResponseType
|
||||||
|
lastLatency: float
|
||||||
|
uptimePercent: float
|
||||||
|
|
||||||
|
proc uptimeSince(sid: int, time: Time): float =
|
||||||
|
let okPings = fromDbValue(get getDB().value("SELECT COUNT(*) FROM reqs WHERE site = ? AND status = 0", sid), int)
|
||||||
|
let totalPings = fromDbValue(get getDB().value("SELECT COUNT(*) FROM reqs WHERE site = ?", sid), int)
|
||||||
|
okPings / totalPings
|
||||||
|
|
||||||
|
proc fetchLatest(row: ResultRow): Option[SiteStatus] =
|
||||||
|
let weekAgo = getTime() + initTimeInterval(weeks= -1)
|
||||||
|
let (site, url) = row.unpack((int, string))
|
||||||
|
let row = getDB().one("SELECT timestamp, status, latency FROM reqs WHERE site = ? ORDER BY timestamp DESC LIMIT 1", site)
|
||||||
|
if isNone row: return none(SiteStatus)
|
||||||
|
let (ts, status, latency) = (get row).unpack((Time, int, int))
|
||||||
|
some SiteStatus(url: url, lastPing: ts, lastResponse: ResponseType(status), lastLatency: float64(latency) / 1e3, uptimePercent: uptimeSince(site, weekAgo))
|
||||||
|
|
||||||
|
proc mainPage(): string =
|
||||||
|
let sites = getDB().all("SELECT * FROM sites ORDER BY sid").map(fetchLatest).filter(x => isSome x).map(x => get x)
|
||||||
|
let up = sites.filter(x => x.lastResponse == rtOk).len()
|
||||||
|
let vnode = buildHtml(html()):
|
||||||
|
head:
|
||||||
|
meta(charset="utf8")
|
||||||
|
title: text &"{up}/{sites.len} up - OnStat"
|
||||||
|
style: text css
|
||||||
|
body:
|
||||||
|
h1(class="title"): text "OnStat"
|
||||||
|
h2(class="title"): text &"{up}/{sites.len} up"
|
||||||
|
for site in sites:
|
||||||
|
tdiv(class="card " & $site.lastResponse):
|
||||||
|
h2:
|
||||||
|
case site.lastResponse
|
||||||
|
of rtOk: text "✓ "
|
||||||
|
of rtHttpError: text "⚠ "
|
||||||
|
of rtTimeout: text "✕ "
|
||||||
|
of rtFetchError: text "✕ "
|
||||||
|
text site.url
|
||||||
|
tdiv: text("Last pinged " & format(site.lastPing, "HH:mm:ss dd-MM-yyyy"))
|
||||||
|
tdiv:
|
||||||
|
case site.lastResponse
|
||||||
|
of rtOk: text &"Latency {site.lastLatency}ms"
|
||||||
|
of rtHttpError: text "HTTP error"
|
||||||
|
of rtTimeout: text "Timed out"
|
||||||
|
of rtFetchError: text "Fetch failed"
|
||||||
|
tdiv: text &"{site.uptimePercent * 100}% up in last week"
|
||||||
|
$vnode
|
||||||
|
|
||||||
|
proc onRequest(req: Request) {.async.} =
|
||||||
|
if req.reqMethod == HttpGet:
|
||||||
|
case req.url.path
|
||||||
|
of "/": await req.respond(Http200, mainPage(), headers=newHttpHeaders([("Content-Type", "text/html")]))
|
||||||
|
else: await req.respond(Http404, "not found")
|
||||||
|
else:
|
||||||
|
await req.respond(Http404, "not found")
|
||||||
|
|
||||||
|
proc pollTarget(s: string): Future[Response] {.async.} =
|
||||||
|
var client = newAsyncHttpClient()
|
||||||
|
var x = Response(rtype: rtTimeout, latency: 0)
|
||||||
|
proc doFetch() {.async.} =
|
||||||
|
let ts = now().utc
|
||||||
|
let res = await client.get(s)
|
||||||
|
let latency = (now().utc - ts).inMicroseconds
|
||||||
|
if res.code.is4xx or res.code.is5xx: x = Response(rtype: rtHttpError, latency: latency)
|
||||||
|
else: x = Response(rtype: rtOk, latency: latency)
|
||||||
|
try:
|
||||||
|
discard await withTimeout(doFetch(), 10000)
|
||||||
|
except:
|
||||||
|
x = Response(rtype: rtFetchError, latency: 0)
|
||||||
|
return x
|
||||||
|
|
||||||
|
proc pollTargets() {.async.} =
|
||||||
|
for row in getDB().all("SELECT * FROM sites"):
|
||||||
|
let (id, url) = row.unpack((int64, string))
|
||||||
|
let res = await pollTarget(url)
|
||||||
|
getDB().exec("INSERT INTO reqs (site, timestamp, status, latency) VALUES (?, ?, ?, ?)", id, getTime(), int(res.rtype), res.latency)
|
||||||
|
|
||||||
|
proc timerCallback(fd: AsyncFD): bool =
|
||||||
|
asyncCheck pollTargets()
|
||||||
|
false
|
||||||
|
|
||||||
|
echo "Starting up"
|
||||||
|
asyncCheck pollTargets()
|
||||||
|
addTimer(5000, false, timerCallback)
|
||||||
|
var server = newAsyncHttpServer()
|
||||||
|
waitFor server.serve(Port(7800), onRequest)
|
34
src/style.css
Normal file
34
src/style.css
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
body {
|
||||||
|
max-width: 40em;
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2 {
|
||||||
|
font-weight: normal;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
border-bottom: 1px solid black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.rtOk h2 {
|
||||||
|
color: green;
|
||||||
|
}
|
||||||
|
.card.rtHttpError h2 {
|
||||||
|
color: orange;
|
||||||
|
}
|
||||||
|
.card.rtFetchError h2 {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
.card.rtTimeout h2 {
|
||||||
|
color: red;
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user