Compare commits

...

3 Commits

Author SHA1 Message Date
ed8ec8ca37 Automatically delete old messages 2024-12-15 00:13:27 +01:00
6d371970f1 dashboard: Group messages by service 2024-11-03 12:11:29 +01:00
af604426c8 Cargo update 2024-11-03 11:54:53 +01:00
8 changed files with 1213 additions and 1293 deletions

2212
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -68,7 +68,7 @@ fn log_lines(opt: &Opt, r: impl BufRead) -> eyre::Result<()> {
let line = line.wrap_err("Failed to read from stdin")?; let line = line.wrap_err("Failed to read from stdin")?;
let line = line.trim(); let line = line.trim();
if !line.is_empty() { if !line.is_empty() {
log_message(&opt, &line)?; log_message(opt, line)?;
} }
} }

View File

@ -4,11 +4,11 @@ use chrono::{DateTime, Local};
use eyre::{eyre, ContextCompat}; use eyre::{eyre, ContextCompat};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity { pub enum Severity {
Fatal,
Error,
Warning, Warning,
Error,
Fatal,
} }
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
@ -32,13 +32,17 @@ pub struct LogMsg {
} }
impl LogMsg { impl LogMsg {
pub fn new(service: String, message: String, hostname: String) -> Self { pub fn new(
service: impl Into<String>,
message: impl Into<String>,
hostname: impl Into<String>,
) -> Self {
Self { Self {
time: Some(Local::now()), time: Some(Local::now()),
severity: Severity::Error, severity: Severity::Error,
service, service: service.into(),
message, message: message.into(),
hostname, hostname: hostname.into(),
file: None, file: None,
line: None, line: None,
} }

View File

@ -17,3 +17,6 @@ eyre = "0.6.12"
color-eyre = "0.6.3" color-eyre = "0.6.3"
redb = { version = "2.1.3", features = ["logging"] } redb = { version = "2.1.3", features = ["logging"] }
rmp-serde = "1.3.0" rmp-serde = "1.3.0"
[dev-dependencies]
rand = "0.8.5"

View File

@ -1,33 +1,49 @@
use std::{cmp::max, collections::BTreeMap};
use rocket::{http::Status, response::content::RawHtml, State}; use rocket::{http::Status, response::content::RawHtml, State};
use rocket_dyn_templates::{context, Template}; use rocket_dyn_templates::{context, Template};
use serde::Serialize; use serde::Serialize;
use snitch_lib::Severity;
use crate::database::Database; use crate::database::Database;
#[get("/")] #[get("/")]
pub async fn index(db: &State<Database>) -> Result<RawHtml<Template>, Status> { pub async fn index(db: &State<Database>) -> Result<RawHtml<Template>, Status> {
db.drop_old_messages().map_err(|e| {
log::error!("failed to query database: {e}");
Status::InternalServerError
})?;
// TODO: // TODO:
// maybe only show most recent error for each service in dashboard view, i.e.: // 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; // 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| { let records = db.get_all_log_messages().map_err(|e| {
log::error!("failed to query database: {e}"); log::error!("failed to query database: {e}");
Status::InternalServerError Status::InternalServerError
})?; })?;
#[derive(Serialize)]
struct ServiceRecords<'a> {
first_record: &'a Record,
last_record: &'a Record,
severity: Severity,
all_records: Vec<&'a Record>,
}
#[derive(Serialize)] #[derive(Serialize)]
struct Record { struct Record {
time: String, time: String,
severity: String, severity: Severity,
service: String, service: String,
message: String, message: String,
location: String, location: String,
} }
let messages: Vec<_> = messages let records: Vec<_> = records
.into_iter() .into_iter()
.map(|(_id, record)| Record { .map(|(_id, record)| Record {
time: record.time.map(|time| time.to_string()).unwrap_or_default(), time: record.time.map(|time| time.to_string()).unwrap_or_default(),
severity: record.severity.as_str().to_string(), severity: record.severity,
service: record.service, service: record.service,
message: record.message, message: record.message,
location: record location: record
@ -38,10 +54,28 @@ pub async fn index(db: &State<Database>) -> Result<RawHtml<Template>, Status> {
}) })
.collect(); .collect();
let mut records_by_service = BTreeMap::new();
for record in &records {
let entry = records_by_service
.entry(record.service.as_str())
.or_insert_with(|| ServiceRecords {
first_record: record,
last_record: record,
all_records: vec![],
severity: record.severity,
});
entry.last_record = record;
entry.all_records.push(record);
entry.severity = max(record.severity, entry.severity);
}
Ok(RawHtml(Template::render( Ok(RawHtml(Template::render(
"index", "index",
context! { context! {
messages: &messages records: &records,
records_by_service: &records_by_service,
}, },
))) )))
} }

View File

@ -1,9 +1,13 @@
use std::{ use std::{
fmt::Debug, fmt::Debug,
marker::PhantomData, marker::PhantomData,
sync::atomic::{AtomicU64, Ordering}, sync::{
atomic::{AtomicU64, Ordering},
Mutex,
},
}; };
use chrono::{DateTime, Duration, Utc};
use eyre::{eyre, WrapErr}; use eyre::{eyre, WrapErr};
use redb::ReadableTable; use redb::ReadableTable;
use serde::{de::DeserializeOwned, Serialize}; use serde::{de::DeserializeOwned, Serialize};
@ -22,7 +26,19 @@ struct Serialized<T> {
_type: PhantomData<T>, _type: PhantomData<T>,
} }
pub(crate) fn open(opt: &Opt) -> eyre::Result<Database> { mod table {
use super::Serialized;
use redb::TableDefinition;
use snitch_lib::LogMsg;
pub const LOG_MESSAGE: TableDefinition<u64, Serialized<LogMsg>> =
TableDefinition::new("log_message");
}
pub const MAX_MESSAGE_AGE: Duration = Duration::weeks(8);
impl Database {
pub(crate) fn open(opt: &Opt) -> eyre::Result<Self> {
let inner = redb::Database::create(&opt.db) let inner = redb::Database::create(&opt.db)
.context(eyre!("Failed to open database at {}", opt.db.display()))?; .context(eyre!("Failed to open database at {}", opt.db.display()))?;
@ -42,18 +58,8 @@ pub(crate) fn open(opt: &Opt) -> eyre::Result<Database> {
inner, inner,
next_log_id: next_log_id.into(), 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> { pub fn write_log(&self, log: &LogMsg) -> eyre::Result<u64> {
let txn = self.inner.begin_write()?; let txn = self.inner.begin_write()?;
let id = self.next_log_id.fetch_add(1, Ordering::SeqCst); let id = self.next_log_id.fetch_add(1, Ordering::SeqCst);
@ -77,6 +83,63 @@ impl Database {
}) })
.collect() .collect()
} }
/// Drop any messages older than [MAX_MESSAGE_AGE]
pub fn drop_old_messages(&self) -> eyre::Result<usize> {
static LAST_RUN: Mutex<DateTime<Utc>> = Mutex::new(DateTime::<Utc>::MIN_UTC);
let now = Utc::now();
// Only run this once a day
{
let mut last_run = LAST_RUN.lock().unwrap();
if now > *last_run + Duration::days(1) {
*last_run = now;
} else {
return Ok(0);
}
}
log::info!("cleaning up old messages");
let txn = self.inner.begin_write()?;
let mut n = 0;
txn.open_table(table::LOG_MESSAGE)?
.retain_in(0.., |_id, log_msg| {
let keep = match log_msg.deserialize() {
Ok(log_msg) => match log_msg.time {
Some(time) => {
let too_old = now > time + MAX_MESSAGE_AGE;
if too_old {
log::info!("removing old message posted at {time}");
}
!too_old
}
// Timestamp should always be set.
// TODO: make it not be an option
None => {
log::warn!("removing message with no timestamp");
false
}
},
Err(_) => {
log::warn!("removing message that failed to deserialize");
false
}
};
if !keep {
n += 1;
}
keep
})?;
txn.commit()?;
Ok(n)
}
} }
impl<T> Serialized<T> impl<T> Serialized<T>
@ -100,11 +163,13 @@ impl<T> redb::Value for Serialized<T>
where where
T: Debug + Serialize + DeserializeOwned, T: Debug + Serialize + DeserializeOwned,
{ {
type SelfType<'a> = Self type SelfType<'a>
= Self
where where
Self: 'a; Self: 'a;
type AsBytes<'a> = &'a [u8] type AsBytes<'a>
= &'a [u8]
where where
Self: 'a; Self: 'a;
@ -134,3 +199,40 @@ where
redb::TypeName::new(std::any::type_name::<Serialized<T>>()) redb::TypeName::new(std::any::type_name::<Serialized<T>>())
} }
} }
#[cfg(test)]
mod tests {
use chrono::Local;
use super::*;
#[test]
fn clear_old_messages() {
let tmp: u32 = rand::random();
let tmp = format!("/tmp/{tmp:08x}.db");
let opt = Opt { db: tmp.into() };
let db = Database::open(&opt).unwrap();
let now = Local::now();
let msg_with_time = |time| LogMsg {
time: Some(time),
..LogMsg::new("service", "message", "hostname")
};
// not old mesages
db.write_log(&msg_with_time(now)).unwrap();
db.write_log(&msg_with_time(now - MAX_MESSAGE_AGE + Duration::days(1)))
.unwrap();
// old messages
db.write_log(&msg_with_time(now - MAX_MESSAGE_AGE - Duration::days(1)))
.unwrap();
db.write_log(&msg_with_time(now - MAX_MESSAGE_AGE - Duration::days(365)))
.unwrap();
db.write_log(&msg_with_time(now - MAX_MESSAGE_AGE - Duration::seconds(1)))
.unwrap();
assert_eq!(db.drop_old_messages().unwrap(), 3);
}
}

View File

@ -8,6 +8,7 @@ mod database;
use std::path::PathBuf; use std::path::PathBuf;
use clap::Parser; use clap::Parser;
use database::Database;
use rocket_dyn_templates::Template; use rocket_dyn_templates::Template;
#[derive(Parser)] #[derive(Parser)]
@ -24,7 +25,7 @@ async fn rocket() -> _ {
let opt = Opt::parse(); let opt = Opt::parse();
color_eyre::install().unwrap(); color_eyre::install().unwrap();
let db = database::open(&opt).expect("open database"); let db = Database::open(&opt).expect("open database");
rocket::build() rocket::build()
.manage(db) .manage(db)

View File

@ -26,26 +26,66 @@
padding: 0.5em; padding: 0.5em;
font-family: 'Ubuntu Mono', mono; font-family: 'Ubuntu Mono', mono;
} }
.service-error-list {
max-height: 20em;
font-size: small;
overflow: scroll;
transition: max-height ease-in-out 0.4s;
}
.collapsed {
max-height: 0;
}
</style> </style>
<script>
function collapse(id) {
document.getElementById(id).classList.toggle("collapsed")
}
</script>
</head> </head>
<body> <body>
{{#if records}}
<table> <table>
<tr> <tr>
<th>Service</th>
<th>Severity</th> <th>Severity</th>
<th>Time</th> <th>Service</th>
<th>Message</th> <th>Count</th>
<th>Location</th> <th>Last message at</th>
</tr> </tr>
{{#each messages}} {{#each records_by_service}}
<tr> <tr onclick="collapse('{{@key}}-error-list')">
<td>{{this.service}}</td> <td>{{this.severity}}</td>
<td>{{@key}}</td>
<td>{{len this.all_records}}</td>
<td>{{this.last_record.time}}</td>
</tr>
<tr><td colspan="4">
<div
id="{{@key}}-error-list"
class="service-error-list collapsed"
>
<table>
<tr>
<th>Severity</th>
<th>Message</th>
<th>Time</th>
</tr>
{{#each this.all_records}}
<tr>
<td>{{this.severity}}</td> <td>{{this.severity}}</td>
<td>{{this.time}}</td>
<td>{{this.message}}</td> <td>{{this.message}}</td>
<td>{{this.location}}</td> <td>{{this.time}}</td>
</tr> </tr>
{{/each}} {{/each}}
</table> </table>
</div></td></tr>
{{/each}}
</table>
{{else}}
<p>
No entries! All is well.
</p>
{{/if}}
</body> </body>
</html> </html>