From 6cd4319fcf540ef70f74cc2f10d0d4297ee7b788 Mon Sep 17 00:00:00 2001 From: Ellie Huxtable Date: Thu, 11 Apr 2024 16:59:01 +0100 Subject: feat(gui): add base structure (#1935) * initial * ui things * cargo * update, add history refresh button * history page a bit better, add initial dotfiles page * re-org layout * bye squigglies * add dotfiles ui, show aliases * add default shell detection * put stats in a little drawer, alias import changes * use new table for aliases, add alias deleting * support adding aliases * close drawer when added, no alias autocomplete * clippy, format * attempt to ensure gdk is installed ok * sudo * no linux things on mac ffs * I forgot we build for windows too... end of day * remove tauri backend from workspace --- ui/backend/src/db.rs | 245 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 ui/backend/src/db.rs (limited to 'ui/backend/src/db.rs') diff --git a/ui/backend/src/db.rs b/ui/backend/src/db.rs new file mode 100644 index 00000000..c1aa4de9 --- /dev/null +++ b/ui/backend/src/db.rs @@ -0,0 +1,245 @@ +// Some wrappers around the Atuin history DB +// I'll probably use this to inform changes to the "upstream" client crate +// We also use Strings a bunch for errors. They're passed to the Tauri frontend, +// which requires that they be serializable. +// Can rework that in the future too, but my main concern is avoiding tauri limitations/reqs +// ending up in the main crate. + +use serde::Serialize; +use sqlx::{sqlite::SqliteRow, Row}; +use std::collections::HashMap; +use std::path::PathBuf; + +use atuin_client::settings::{FilterMode, SearchMode}; +use atuin_client::{ + database::{Context, Database, OptFilters, Sqlite}, + history::History, +}; + +// useful for preprocessing data for the frontend +#[derive(Serialize, Debug)] +pub struct NameValue { + pub name: String, + pub value: T, +} + +#[derive(Serialize, Debug)] +pub struct GlobalStats { + pub total_history: u64, + + pub daily: Vec>, + + pub last_1d: u64, + pub last_7d: u64, + pub last_30d: u64, +} + +#[derive(Serialize)] +pub struct UIHistory { + pub id: String, + /// When the command was run. + pub timestamp: i128, + /// How long the command took to run. + pub duration: i64, + /// The exit code of the command. + pub exit: i64, + /// The command that was run. + pub command: String, + /// The current working directory when the command was run. + pub cwd: String, + /// The session ID, associated with a terminal session. + pub session: String, + /// The hostname of the machine the command was run on. + pub user: String, + + pub host: String, +} + +pub fn to_ui_history(history: History) -> UIHistory { + let parts: Vec = history.hostname.split(':').map(str::to_string).collect(); + + let (host, user) = if parts.len() == 2 { + (parts[0].clone(), parts[1].clone()) + } else { + ("no-host".to_string(), "no-user".to_string()) + }; + + let mac = format!("/Users/{}", user); + let linux = format!("/home/{}", user); + + let cwd = history.cwd.replace(mac.as_str(), "~"); + let cwd = cwd.replace(linux.as_str(), "~"); + + UIHistory { + id: history.id.0, + timestamp: history.timestamp.unix_timestamp_nanos(), + duration: history.duration, + exit: history.exit, + command: history.command, + session: history.session, + host, + user, + cwd, + } +} + +pub struct HistoryDB(Sqlite); + +impl HistoryDB { + pub async fn new(path: PathBuf, timeout: f64) -> Result { + let sqlite = Sqlite::new(path, timeout) + .await + .map_err(|e| e.to_string())?; + + Ok(Self(sqlite)) + } + + pub async fn list(&self, limit: Option, unique: bool) -> Result, String> { + let filters = vec![]; + + // bit of a hack but provide an empty context + // shell context makes _no sense_ in a GUI + let context = Context { + session: "".to_string(), + cwd: "".to_string(), + host_id: "".to_string(), + hostname: "".to_string(), + git_root: None, + }; + + let history = self + .0 + .list(&filters, &context, limit, unique, false) + .await + .map_err(|e| e.to_string())?; + + let history = history + .into_iter() + .filter(|h| h.duration > 0) + .map(to_ui_history) + .collect(); + + Ok(history) + } + + pub async fn search(&self, query: &str) -> Result, String> { + let context = Context { + session: "".to_string(), + cwd: "".to_string(), + host_id: "".to_string(), + hostname: "".to_string(), + git_root: None, + }; + + let filters = OptFilters { + limit: Some(200), + ..OptFilters::default() + }; + + let history = self + .0 + .search( + SearchMode::Fuzzy, + FilterMode::Global, + &context, + query, + filters, + ) + .await + .map_err(|e| e.to_string())?; + + let history = history + .into_iter() + .filter(|h| h.duration > 0) + .map(to_ui_history) + .collect(); + + Ok(history) + } + + pub async fn global_stats(&self) -> Result { + let day_ago = time::OffsetDateTime::now_utc() - time::Duration::days(1); + let day_ago = day_ago.unix_timestamp_nanos(); + + let week_ago = time::OffsetDateTime::now_utc() - time::Duration::days(7); + let week_ago = week_ago.unix_timestamp_nanos(); + + let month_ago = time::OffsetDateTime::now_utc() - time::Duration::days(30); + let month_ago = month_ago.unix_timestamp_nanos(); + + // get the last 30 days of shell history + let history: Vec = sqlx::query("SELECT * FROM history WHERE timestamp > ?") + .bind(month_ago as i64) + .map(|row: SqliteRow| { + History::from_db() + .id(row.get("id")) + .timestamp( + time::OffsetDateTime::from_unix_timestamp_nanos( + row.get::("timestamp") as i128, + ) + .unwrap(), + ) + .duration(row.get("duration")) + .exit(row.get("exit")) + .command(row.get("command")) + .cwd(row.get("cwd")) + .session(row.get("session")) + .hostname(row.get("hostname")) + .deleted_at(None) + .build() + .into() + }) + .map(to_ui_history) + .fetch_all(&self.0.pool) + .await + .map_err(|e| e.to_string())?; + + let total: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM history") + .fetch_one(&self.0.pool) + .await + .map_err(|e| e.to_string())?; + + let mut day = 0; + let mut week = 0; + let mut month = 0; + + let mut daily = HashMap::new(); + let ymd = time::format_description::parse("[year]-[month]-[day]").unwrap(); + + for i in history { + if i.timestamp > day_ago { + day += 1; + } + + if i.timestamp > week_ago { + week += 1; + } + + if i.timestamp > month_ago { + month += 1; + + // get the start of the day, as a unix timestamp + let date = time::OffsetDateTime::from_unix_timestamp_nanos(i.timestamp) + .unwrap() + .format(&ymd) + .unwrap(); + + daily.entry(date).and_modify(|v| *v += 1).or_insert(1); + } + } + + let mut daily: Vec> = daily + .into_iter() + .map(|(k, v)| NameValue { name: k, value: v }) + .collect(); + daily.sort_by(|a, b| a.name.cmp(&b.name)); + + Ok(GlobalStats { + total_history: total.0 as u64, + last_30d: month, + last_7d: week, + last_1d: day, + daily, + }) + } +} -- cgit v1.3.1