commit 532302ae8e88297344e2b2d0e902875286524575 Author: osmarks Date: Thu Jan 28 16:22:33 2021 +0000 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..767c5af --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +onstat +monitoring.sqlite3* \ No newline at end of file diff --git a/nim.cfg b/nim.cfg new file mode 100644 index 0000000..ace4e9c --- /dev/null +++ b/nim.cfg @@ -0,0 +1 @@ +-d:ssl \ No newline at end of file diff --git a/onstat.nimble b/onstat.nimble new file mode 100644 index 0000000..10d0ab4 --- /dev/null +++ b/onstat.nimble @@ -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" \ No newline at end of file diff --git a/src/db.nim b/src/db.nim new file mode 100644 index 0000000..e742fbd --- /dev/null +++ b/src/db.nim @@ -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" \ No newline at end of file diff --git a/src/onstat.nim b/src/onstat.nim new file mode 100644 index 0000000..5ec2e01 --- /dev/null +++ b/src/onstat.nim @@ -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) \ No newline at end of file diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..5dc5899 --- /dev/null +++ b/src/style.css @@ -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; +} \ No newline at end of file