STUFUFUFUFF
This commit is contained in:
19
snitch-web/Cargo.toml
Normal file
19
snitch-web/Cargo.toml
Normal file
@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "snitch-web"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
snitch-lib = { path = "../snitch-lib" }
|
||||
|
||||
log = "0.4"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
rocket_dyn_templates = { version = "0.1.0-rc.2", features = ["handlebars"] }
|
||||
rocket_db_pools = { version = "0.1.0-rc.2", features = ["sqlx_postgres"] }
|
||||
rocket = { version = "0.5.0-rc.2", features = ["json"] }
|
||||
clap = { version = "4.0.23", features = ["derive", "env"] }
|
||||
eyre = "0.6.12"
|
||||
color-eyre = "0.6.3"
|
||||
redb = { version = "2.1.3", features = ["logging"] }
|
||||
rmp-serde = "1.3.0"
|
||||
3
snitch-web/build.rs
Normal file
3
snitch-web/build.rs
Normal file
@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=migrations");
|
||||
}
|
||||
19
snitch-web/src/api.rs
Normal file
19
snitch-web/src/api.rs
Normal file
@ -0,0 +1,19 @@
|
||||
use chrono::Local;
|
||||
use rocket::{http::Status, serde::json::Json, State};
|
||||
use snitch_lib::LogMsg;
|
||||
|
||||
use crate::database::Database;
|
||||
|
||||
#[post("/", data = "<record>")]
|
||||
pub async fn post_record(db: &State<Database>, record: Json<LogMsg>) -> Status {
|
||||
let mut record = record.into_inner();
|
||||
record.time.get_or_insert_with(Local::now);
|
||||
|
||||
let result = db.write_log(&record);
|
||||
if let Err(e) = result {
|
||||
log::error!("failed to insert record: {e:?}");
|
||||
return Status::InternalServerError;
|
||||
}
|
||||
|
||||
Status::Ok
|
||||
}
|
||||
47
snitch-web/src/dashboard.rs
Normal file
47
snitch-web/src/dashboard.rs
Normal file
@ -0,0 +1,47 @@
|
||||
use rocket::{http::Status, response::content::RawHtml, State};
|
||||
use rocket_dyn_templates::{context, Template};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::database::Database;
|
||||
|
||||
#[get("/")]
|
||||
pub async fn index(db: &State<Database>) -> Result<RawHtml<Template>, Status> {
|
||||
// TODO:
|
||||
// maybe only show most recent error for each service in dashboard view, i.e.:
|
||||
// select distinct on (service) time, message from (select * from record order by time desc) as foo;
|
||||
let messages = db.get_all_log_messages().map_err(|e| {
|
||||
log::error!("failed to query database: {e}");
|
||||
Status::InternalServerError
|
||||
})?;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Record {
|
||||
time: String,
|
||||
severity: String,
|
||||
service: String,
|
||||
message: String,
|
||||
location: String,
|
||||
}
|
||||
|
||||
let messages: Vec<_> = messages
|
||||
.into_iter()
|
||||
.map(|(_id, record)| Record {
|
||||
time: record.time.map(|time| time.to_string()).unwrap_or_default(),
|
||||
severity: record.severity.as_str().to_string(),
|
||||
service: record.service,
|
||||
message: record.message,
|
||||
location: record
|
||||
.file
|
||||
.zip(record.line)
|
||||
.map(|(path, line)| format!("{path}:{line}"))
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(RawHtml(Template::render(
|
||||
"index",
|
||||
context! {
|
||||
messages: &messages
|
||||
},
|
||||
)))
|
||||
}
|
||||
136
snitch-web/src/database.rs
Normal file
136
snitch-web/src/database.rs
Normal file
@ -0,0 +1,136 @@
|
||||
use std::{
|
||||
fmt::Debug,
|
||||
marker::PhantomData,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
use eyre::{eyre, WrapErr};
|
||||
use redb::ReadableTable;
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use snitch_lib::LogMsg;
|
||||
|
||||
use crate::Opt;
|
||||
|
||||
pub struct Database {
|
||||
inner: redb::Database,
|
||||
next_log_id: AtomicU64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Serialized<T> {
|
||||
packed: Vec<u8>,
|
||||
_type: PhantomData<T>,
|
||||
}
|
||||
|
||||
pub(crate) fn open(opt: &Opt) -> eyre::Result<Database> {
|
||||
let inner = redb::Database::create(&opt.db)
|
||||
.context(eyre!("Failed to open database at {}", opt.db.display()))?;
|
||||
|
||||
let txn = inner.begin_write()?;
|
||||
let next_log_id = txn
|
||||
.open_table(table::LOG_MESSAGE)?
|
||||
.range::<u64>(..)?
|
||||
.next_back()
|
||||
.transpose()
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|(id, _)| id.value() + 1)
|
||||
.unwrap_or_default();
|
||||
txn.commit()?;
|
||||
|
||||
Ok(Database {
|
||||
inner,
|
||||
next_log_id: next_log_id.into(),
|
||||
})
|
||||
}
|
||||
|
||||
mod table {
|
||||
use super::Serialized;
|
||||
use redb::TableDefinition;
|
||||
use snitch_lib::LogMsg;
|
||||
|
||||
pub const LOG_MESSAGE: TableDefinition<u64, Serialized<LogMsg>> =
|
||||
TableDefinition::new("log_message");
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub fn write_log(&self, log: &LogMsg) -> eyre::Result<u64> {
|
||||
let txn = self.inner.begin_write()?;
|
||||
let id = self.next_log_id.fetch_add(1, Ordering::SeqCst);
|
||||
txn.open_table(table::LOG_MESSAGE)?
|
||||
.insert(id, Serialized::serialize(log))?;
|
||||
txn.commit()?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub fn get_all_log_messages(&self) -> eyre::Result<Vec<(u64, LogMsg)>> {
|
||||
let txn = self.inner.begin_read()?;
|
||||
txn.open_table(table::LOG_MESSAGE)?
|
||||
.range::<u64>(..)?
|
||||
.map(|result| {
|
||||
let (id, msg) = result?;
|
||||
let id = id.value();
|
||||
let msg = msg.value();
|
||||
|
||||
msg.deserialize().map(|msg| (id, msg))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Serialized<T>
|
||||
where
|
||||
T: Debug + Serialize + DeserializeOwned,
|
||||
{
|
||||
pub fn serialize(value: &T) -> Self {
|
||||
let packed = rmp_serde::to_vec(&value).unwrap();
|
||||
Self {
|
||||
packed,
|
||||
_type: PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deserialize(&self) -> eyre::Result<T> {
|
||||
rmp_serde::from_slice(&self.packed).wrap_err("Failed to deserialize")
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> redb::Value for Serialized<T>
|
||||
where
|
||||
T: Debug + Serialize + DeserializeOwned,
|
||||
{
|
||||
type SelfType<'a> = Self
|
||||
where
|
||||
Self: 'a;
|
||||
|
||||
type AsBytes<'a> = &'a [u8]
|
||||
where
|
||||
Self: 'a;
|
||||
|
||||
fn fixed_width() -> Option<usize> {
|
||||
None
|
||||
}
|
||||
|
||||
fn from_bytes<'a>(data: &'a [u8]) -> Self::SelfType<'a>
|
||||
where
|
||||
Self: 'a,
|
||||
{
|
||||
Serialized {
|
||||
packed: data.to_vec(),
|
||||
_type: PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
fn as_bytes<'a, 'b: 'a>(value: &'a Self::SelfType<'b>) -> Self::AsBytes<'a>
|
||||
where
|
||||
Self: 'a,
|
||||
Self: 'b,
|
||||
{
|
||||
&value.packed
|
||||
}
|
||||
|
||||
fn type_name() -> redb::TypeName {
|
||||
redb::TypeName::new(std::any::type_name::<Serialized<T>>())
|
||||
}
|
||||
}
|
||||
33
snitch-web/src/main.rs
Normal file
33
snitch-web/src/main.rs
Normal file
@ -0,0 +1,33 @@
|
||||
#[macro_use]
|
||||
extern crate rocket;
|
||||
|
||||
mod api;
|
||||
mod dashboard;
|
||||
mod database;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Parser;
|
||||
use rocket_dyn_templates::Template;
|
||||
|
||||
#[derive(Parser)]
|
||||
struct Opt {
|
||||
/// Filepath where snitch will store its database.
|
||||
///
|
||||
/// e.g. postgresql://user:pass@localhost:5432/database
|
||||
#[clap(long, env = "SNITCH_DB", default_value = "./snitch.db")]
|
||||
db: PathBuf,
|
||||
}
|
||||
|
||||
#[launch]
|
||||
async fn rocket() -> _ {
|
||||
let opt = Opt::parse();
|
||||
color_eyre::install().unwrap();
|
||||
|
||||
let db = database::open(&opt).expect("open database");
|
||||
|
||||
rocket::build()
|
||||
.manage(db)
|
||||
.mount("/", routes![dashboard::index, api::post_record])
|
||||
.attach(Template::fairing())
|
||||
}
|
||||
51
snitch-web/templates/index.hbs
Normal file
51
snitch-web/templates/index.hbs
Normal file
@ -0,0 +1,51 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta name="description" content="">
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="/static/icon.svg">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Ubuntu|Ubuntu+Mono&display=swap">
|
||||
|
||||
<title>snitch</title>
|
||||
|
||||
<style>
|
||||
body {
|
||||
color: white;
|
||||
background-color: #302f3b;
|
||||
font-family: 'Ubuntu', sans-serif;
|
||||
}
|
||||
|
||||
th {
|
||||
color: wheat;
|
||||
}
|
||||
|
||||
td {
|
||||
border: 0.1em solid #5b3f63;
|
||||
padding: 0.5em;
|
||||
font-family: 'Ubuntu Mono', mono;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Service</th>
|
||||
<th>Severity</th>
|
||||
<th>Time</th>
|
||||
<th>Message</th>
|
||||
<th>Location</th>
|
||||
</tr>
|
||||
{{#each messages}}
|
||||
<tr>
|
||||
<td>{{this.service}}</td>
|
||||
<td>{{this.severity}}</td>
|
||||
<td>{{this.time}}</td>
|
||||
<td>{{this.message}}</td>
|
||||
<td>{{this.location}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user