initial commit
This commit is contained in:
		
							
								
								
									
										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; | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user