diff --git a/src/git/history.rs b/src/git/history.rs new file mode 100644 index 0000000..db3706a --- /dev/null +++ b/src/git/history.rs @@ -0,0 +1,131 @@ +use anyhow::Result; +use git2::DiffLine; +use serde::Serialize; +use std::path::Path; +use time::{ + format_description::well_known::{Iso8601, Rfc2822}, + OffsetDateTime, +}; + +use super::ThreadSafeRepository; + +#[derive(Serialize, Clone)] +pub struct LineChange { + pub op: char, + pub old_line: Option, + pub new_line: Option, + pub content: String, +} + +#[derive(Serialize, Clone)] +pub struct CommitInfo { + pub oid: String, + pub message: String, + pub author: String, + pub date: String, + pub changes: Vec, +} + +impl ThreadSafeRepository { + pub fn get_history(&self, path: &Path) -> Result> { + let repository = self.repository()?; + + let mut revwalk = repository.revwalk()?; + revwalk.push_head()?; + revwalk.set_sorting(git2::Sort::TIME | git2::Sort::REVERSE)?; + + let mut history = Vec::new(); + let mut last_blob = None; + for oid in revwalk { + // Get commit + let oid = oid?; + let commit = repository.find_commit(oid)?; + let tree = commit.tree()?; + + // Check if file changed in this commit + let entry = tree.get_path(path)?; + if last_blob == Some(entry.id()) { + continue; + } + + // Get changes + let current = repository.find_blob(entry.id())?; + let old = last_blob.and_then(|id| repository.find_blob(id).ok()); + + let mut changes = vec![]; + repository.diff_blobs( + old.as_ref(), + None, + Some(¤t), + None, + None, + None, + None, + None, + Some(&mut |_, _, line| { + changes.push(line.into()); + true + }), + )?; + + // Write new blob id to compare against + last_blob = Some(entry.id()); + + history.push(CommitInfo { + oid: commit.id().to_string(), + message: commit.message().unwrap_or_default().to_string(), + author: commit.author().to_string(), + date: OffsetDateTime::from_unix_timestamp(commit.time().seconds()) + .unwrap_or_else(|_| OffsetDateTime::now_utc()) + .format(&Iso8601::DEFAULT) + .unwrap_or_default(), + changes, + }); + } + + Ok(history) + } + + pub fn get_file_at_commit(&self, path: &Path, oid: &str) -> Result { + // lol codewhisperer autocompleted all this, have fun + let repository = self.repository()?; + let commit = repository.find_commit(oid.parse()?)?; + let tree = commit.tree()?; + let entry = tree.get_path(path)?; + let blob = repository.find_blob(entry.id())?; + Ok(String::from_utf8_lossy(blob.content()).into_owned()) + } +} + +impl From> for LineChange { + fn from(value: DiffLine<'_>) -> Self { + Self { + op: value.origin(), + old_line: value.old_lineno(), + new_line: value.new_lineno(), + content: String::from_utf8_lossy(value.content()).into_owned(), + } + } +} + +impl CommitInfo { + pub fn diff(&self) -> Vec { + let mut ordered = self.changes.clone(); + ordered.sort_by(|a, b| { + let line_a = a.old_line.or_else(|| a.new_line).unwrap_or_default(); + let line_b = b.old_line.or_else(|| b.new_line).unwrap_or_default(); + line_a.cmp(&line_b) + }); + ordered + .iter() + .map(|change| format!("{} {}", change.op, change.content)) + .collect() + } + + pub fn date_human(&self) -> String { + OffsetDateTime::parse(&self.date, &Iso8601::DEFAULT) + .ok() + .and_then(|date| date.format(&Rfc2822).ok()) + .unwrap_or_else(|| self.date.clone()) + } +} diff --git a/src/node/git.rs b/src/git/mod.rs similarity index 99% rename from src/node/git.rs rename to src/git/mod.rs index 51eaab8..12a660e 100644 --- a/src/node/git.rs +++ b/src/git/mod.rs @@ -3,6 +3,8 @@ use git2::{ErrorCode, Index, IndexAddOption, Oid, Repository, Signature}; use std::path::{Path, PathBuf}; use tracing::info; +pub mod history; + #[derive(Clone)] pub struct ThreadSafeRepository { pub path: PathBuf, diff --git a/src/http/response.rs b/src/http/response.rs index 4d3c265..6e948a3 100644 --- a/src/http/response.rs +++ b/src/http/response.rs @@ -1,6 +1,6 @@ use super::{ accept::{parse_accept, ResponseType}, - error::{AppError, ErrorInfo}, + error::{ErrorInfo, Result}, }; use askama::Template; use askama_axum::IntoResponse; @@ -12,7 +12,7 @@ use axum::{ }; use serde_json::json; -pub type HandlerResponse = Result; +pub type HandlerResponse = Result; struct Response { html: String, diff --git a/src/main.rs b/src/main.rs index e164893..4374d16 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,16 +1,18 @@ +use crate::git::{GitConfig, ThreadSafeRepository}; use crate::http::response::response_interceptor; use anyhow::{anyhow, Result}; use axum::middleware::from_fn; use bollard::Docker; use clap::Parser; -use node::git::{GitConfig, ThreadSafeRepository}; use std::{net::SocketAddr, path::PathBuf}; use tokio::fs; +mod git; mod http; mod nix; mod node; mod route; +mod stack; /// GitOps+WebUI for arion-based stacks #[derive(Parser, Debug)] diff --git a/src/node/mod.rs b/src/node/mod.rs index 7791dbb..1ba5035 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -1,6 +1,4 @@ pub mod container; pub mod error; -pub mod git; pub mod nix; -pub mod stack; pub mod system; diff --git a/src/route/container/mod.rs b/src/route/container/mod.rs index 2cc4246..5bcd010 100644 --- a/src/route/container/mod.rs +++ b/src/route/container/mod.rs @@ -1,6 +1,6 @@ use crate::{ + http::error::Result, node::container::{kill, remove, restart, start, stop}, - route::AppError, AppState, }; use axum::{ @@ -17,7 +17,7 @@ macro_rules! container_command { ($cmd: ident) => { move |Path(cont_name): Path, State(state): State| async move { $cmd(&state.docker, &cont_name).await?; - Ok(Redirect::to("./")) as Result + Ok(Redirect::to("./")) as Result } }; } @@ -41,7 +41,7 @@ pub(super) fn router() -> Router { post( move |Path(cont_name): Path, State(state): State| async move { remove(&state.docker, &cont_name).await?; - Ok(Redirect::to("/")) as Result + Ok(Redirect::to("/")) as Result }, ), ) diff --git a/src/route/mod.rs b/src/route/mod.rs index 1324443..2d1781d 100644 --- a/src/route/mod.rs +++ b/src/route/mod.rs @@ -1,12 +1,8 @@ use crate::{ - http::{ - error::AppError, - response::{reply, HandlerResponse}, - }, - node::{ - stack::{list, NodeInfo}, - system::{system_info, SystemInfo}, - }, + http::error::AppError, + http::response::{reply, HandlerResponse}, + node::system::{system_info, SystemInfo}, + stack::{list, NodeInfo}, AppState, }; use askama::Template; diff --git a/src/route/stack/check.rs b/src/route/stack/check.rs index f218ef2..129a28b 100644 --- a/src/route/stack/check.rs +++ b/src/route/stack/check.rs @@ -1,11 +1,11 @@ use axum::{extract::State, http::StatusCode}; -use crate::{http::error::AppError, node::stack::compose, AppState}; +use crate::{http::error::Result, stack::compose, AppState}; pub(super) async fn check_stack_file( State(state): State, body: String, -) -> Result { +) -> Result { compose::check(&state.arion_bin, &body).await?; Ok(StatusCode::NO_CONTENT) } diff --git a/src/route/stack/create.rs b/src/route/stack/create.rs index b14453e..dffbad6 100644 --- a/src/route/stack/create.rs +++ b/src/route/stack/create.rs @@ -4,8 +4,8 @@ use axum::{extract::State, response::Redirect, Form}; use serde::Deserialize; use crate::{ - http::error::AppError, - node::stack::{compose, operation}, + http::error::Result, + stack::{compose, operation}, AppState, }; @@ -21,7 +21,7 @@ pub(super) struct CreateStackForm { pub(super) async fn create_stack( State(state): State, Form(form): Form, -) -> Result { +) -> Result { // Make sure body is is ok let name = compose::check(&state.arion_bin, &form.source).await?; diff --git a/src/route/stack/delete.rs b/src/route/stack/delete.rs index 8d1eb9c..7f7d343 100644 --- a/src/route/stack/delete.rs +++ b/src/route/stack/delete.rs @@ -5,7 +5,11 @@ use axum::{ response::Redirect, }; -use crate::{http::error::AppError, node::stack::operation, AppState}; +use crate::{ + http::error::Result, + stack::{self, operation}, + AppState, +}; #[derive(Template)] #[template(path = "stack/delete-one.html")] @@ -13,14 +17,21 @@ struct ConfirmDeleteTemplate { stack_name: String, } -pub(super) async fn confirm_deletion_page(Path(stack_name): Path) -> impl IntoResponse { - ConfirmDeleteTemplate { stack_name } +pub(super) async fn confirm_deletion_page( + Path(stack_name): Path, + State(state): State, +) -> Result { + let project_id = stack::list::stack_name(&state.stack_dir, &stack_name).await?; + + Ok(ConfirmDeleteTemplate { + stack_name: project_id, + }) } pub(super) async fn delete_stack( Path(stack_name): Path, State(state): State, -) -> Result { +) -> Result { operation::remove( &state.stack_dir, &state.arion_bin, diff --git a/src/route/stack/edit.rs b/src/route/stack/edit.rs index 36c6ab6..07f6dee 100644 --- a/src/route/stack/edit.rs +++ b/src/route/stack/edit.rs @@ -6,7 +6,11 @@ use axum::{ }; use serde::Deserialize; -use crate::{http::error::AppError, node::stack::arion, node::stack::compose, AppState}; +use crate::{ + http::error::{AppError, Result}, + stack::{arion, compose}, + AppState, +}; #[derive(Deserialize)] pub(super) struct EditStackForm { @@ -18,7 +22,7 @@ pub(super) async fn edit_stack( Path(stack_name): Path, State(state): State, Form(form): Form, -) -> Result { +) -> Result { let commit_message = if form.commit_message.trim().is_empty() { format!("Update {}", stack_name) } else { @@ -39,20 +43,34 @@ pub(super) async fn edit_stack( // Make sure file is ok compose::check(&state.arion_bin, &source).await?; + edit_stack_int(state, &stack_name, &source, &commit_message).await?; + + Ok(Redirect::to("./")) +} + +pub(super) async fn edit_stack_int( + state: AppState, + stack_name: &str, + source: &str, + commit_message: &str, +) -> Result<()> { + // Make sure file is ok + compose::check(&state.arion_bin, &source).await?; + // Write compose file - compose::write(&state.stack_dir, &stack_name, &source).await?; + compose::write(&state.stack_dir, stack_name, source).await?; // Git commit - compose::commit(state.repository, &stack_name, &commit_message)?; + compose::commit(state.repository, stack_name, commit_message)?; // Update stack arion::command( &state.stack_dir, - &stack_name, + stack_name, &state.arion_bin, arion::StackCommand::Start, ) .await?; - Ok(Redirect::to("./")) + Ok(()) } diff --git a/src/route/stack/history.rs b/src/route/stack/history.rs new file mode 100644 index 0000000..cca0523 --- /dev/null +++ b/src/route/stack/history.rs @@ -0,0 +1,75 @@ +use askama::Template; +use axum::{ + extract::{Path, State}, + response::Redirect, + Form, +}; +use serde::Deserialize; +use serde_json::json; + +use crate::{ + git::history::CommitInfo, + http::error::Result, + http::response::{reply, HandlerResponse}, + node::nix::parse_arion_compose, + stack::compose, + AppState, +}; + +use super::edit::edit_stack_int; + +#[derive(Template)] +#[template(path = "stack/history.html")] +struct HistoryTemplate { + stack_name: String, + commits: Vec, +} + +#[derive(Deserialize)] +pub(super) struct RestoreForm { + oid: String, +} + +pub(super) async fn list( + Path(stack_name): Path, + State(state): State, +) -> HandlerResponse { + let compose_file = compose::get(&state.stack_dir, &stack_name).await?; + let info = parse_arion_compose(&compose_file)?; + + let git_compose_path = compose::path(&stack_name); + let commits = state.repository.get_history(&git_compose_path)?; + + let mut newest_first = commits.clone(); + newest_first.reverse(); + + reply( + json!({ + "commits": commits, + }), + HistoryTemplate { + stack_name: info.project, + commits: newest_first, + }, + ) +} + +pub(super) async fn restore( + Path(stack_name): Path, + State(state): State, + Form(form): Form, +) -> Result { + let path = compose::path(&stack_name); + let source = state.repository.get_file_at_commit(&path, &form.oid)?; + let short_id = form.oid[..6].to_string(); + + edit_stack_int( + state, + &stack_name, + &source, + format!("Revert to {}", short_id).as_str(), + ) + .await?; + + Ok(Redirect::to("..")) +} diff --git a/src/route/stack/mod.rs b/src/route/stack/mod.rs index 6b60e02..d464544 100644 --- a/src/route/stack/mod.rs +++ b/src/route/stack/mod.rs @@ -6,8 +6,8 @@ use axum::{ }; use crate::{ - http::error::AppError, - node::stack::arion::{command, StackCommand}, + http::error::Result, + stack::arion::{command, StackCommand}, AppState, }; @@ -17,13 +17,14 @@ mod check; mod create; mod delete; mod edit; +mod history; mod read; macro_rules! stack_command { ($cmd: expr) => { move |Path(stack_name): Path, State(state): State| async move { command(&state.stack_dir, &stack_name, &state.arion_bin, $cmd).await?; - Ok(Redirect::to("./")) as Result + Ok(Redirect::to("./")) as Result } }; } @@ -54,5 +55,6 @@ pub(super) fn router() -> Router { "/:stack/delete", get(delete::confirm_deletion_page).post(delete::delete_stack), ) - //.route("/:stack/history", get(get_stack_history)) + .route("/:stack/history", get(history::list)) + .route("/:stack/history/revert", post(history::restore)) } diff --git a/src/route/stack/read.rs b/src/route/stack/read.rs index d88433b..8c835d7 100644 --- a/src/route/stack/read.rs +++ b/src/route/stack/read.rs @@ -4,14 +4,14 @@ use serde_json::json; use crate::{ http::response::{reply, HandlerResponse}, - node::{container::ContainerInfo, nix::parse_arion_compose, stack}, - AppState, + node::container::ContainerInfo, + node::nix::parse_arion_compose, + stack, AppState, }; #[derive(Template)] #[template(path = "stack/get-one.html")] struct GetOneTemplate { - stack_folder: String, stack_name: String, file_contents: String, containers: Vec, @@ -33,7 +33,6 @@ pub(super) async fn get_one( "containers": containers, }), GetOneTemplate { - stack_folder: stack_name, stack_name: info.project, file_contents, containers: containers.iter().map(|c| c.clone().into()).collect(), diff --git a/src/node/stack/arion.rs b/src/stack/arion.rs similarity index 100% rename from src/node/stack/arion.rs rename to src/stack/arion.rs diff --git a/src/node/stack/compose.rs b/src/stack/compose.rs similarity index 86% rename from src/node/stack/compose.rs rename to src/stack/compose.rs index 9071c18..87569b5 100644 --- a/src/node/stack/compose.rs +++ b/src/stack/compose.rs @@ -6,8 +6,9 @@ use tempfile::tempdir; use tokio::fs; use crate::{ + git::ThreadSafeRepository, http::error::AppError, - node::{error::StackError, git::ThreadSafeRepository, nix::parse_arion_compose}, + node::{error::StackError, nix::parse_arion_compose}, }; use super::{arion, utils, COMPOSE_FILE, PACKAGE_CONTENTS, PACKAGE_FILE}; @@ -32,8 +33,8 @@ pub async fn write(base_dir: &Path, stack_name: &str, contents: &str) -> Result< } pub fn commit(repository: ThreadSafeRepository, stack_name: &str, message: &str) -> Result<()> { - let compose_path = format!("{}/{}", stack_name, COMPOSE_FILE); - repository.commit_files(&[&PathBuf::from(compose_path)], message)?; + let compose_path = path(stack_name); + repository.commit_files(&[&compose_path], message)?; Ok(()) } @@ -70,3 +71,8 @@ pub async fn check(arion_bin: &Path, source: &str) -> crate::http::error::Result Ok(info.project) } } + +#[inline(always)] +pub fn path(stack_name: &str) -> PathBuf { + format!("{}/{}", stack_name, COMPOSE_FILE).into() +} diff --git a/src/node/stack/list.rs b/src/stack/list.rs similarity index 92% rename from src/node/stack/list.rs rename to src/stack/list.rs index 4b06f11..db3697d 100644 --- a/src/node/stack/list.rs +++ b/src/stack/list.rs @@ -93,3 +93,10 @@ pub async fn containers(docker: &Docker, stack_name: &str) -> Result Result { + let compose_file = compose::get(base_dir, folder).await?; + let info = parse_arion_compose(&compose_file)?; + Ok(info.project) +} diff --git a/src/node/stack/mod.rs b/src/stack/mod.rs similarity index 93% rename from src/node/stack/mod.rs rename to src/stack/mod.rs index fda1b1d..8b6c71d 100644 --- a/src/node/stack/mod.rs +++ b/src/stack/mod.rs @@ -1,6 +1,6 @@ use serde::Serialize; -use super::container::ContainerInfo; +use crate::node::container::ContainerInfo; pub mod arion; pub mod compose; diff --git a/src/node/stack/operation.rs b/src/stack/operation.rs similarity index 97% rename from src/node/stack/operation.rs rename to src/stack/operation.rs index 9e70111..f29ab42 100644 --- a/src/node/stack/operation.rs +++ b/src/stack/operation.rs @@ -3,7 +3,7 @@ use futures_util::future::try_join; use std::path::{Path, PathBuf}; use tokio::fs; -use crate::node::git::ThreadSafeRepository; +use crate::git::ThreadSafeRepository; use super::{arion, COMPOSE_FILE, PACKAGE_CONTENTS, PACKAGE_FILE}; diff --git a/src/node/stack/utils.rs b/src/stack/utils.rs similarity index 100% rename from src/node/stack/utils.rs rename to src/stack/utils.rs diff --git a/static/css/screen.css b/static/css/screen.css index f369f5a..5ac0e7a 100644 --- a/static/css/screen.css +++ b/static/css/screen.css @@ -100,6 +100,13 @@ button { color: var(--link-hover); border-color: var(--link-hover); } + + &:disabled { + background-color: #323035; + border-color: #3C393F; + color: #6F6D78; + cursor: not-allowed; + } } table.table { diff --git a/templates/base.html b/templates/base.html index b594146..040e403 100644 --- a/templates/base.html +++ b/templates/base.html @@ -4,7 +4,7 @@ - {% block title %}{{ title }} - staxman{% endblock %} + {% block title %}{% endblock %} - staxman diff --git a/templates/stack/history.html b/templates/stack/history.html new file mode 100644 index 0000000..21634d6 --- /dev/null +++ b/templates/stack/history.html @@ -0,0 +1,140 @@ +{% extends "base.html" %} + +{% block title %}Stack history for {{stack_name}}{% endblock %} + +{% block content %} +
+
+

Stack history for {{stack_name}}

+
+
+
+
+ {% for (i, commit) in commits.iter().enumerate() %} +
+

+ + + {{commit.oid[..7]}} + +

+
+ + {{commit.message}} + + + {% for line in commit.diff() %} + {% match line.chars().next() %} + {% when Some with ('+') %} +
{{line}}
+ {% when Some with ('-') %} +
{{line}}
+ {% when _ %} +
{{line}}
+ {% endmatch %} + {% endfor %} +
+
+
+ {% if i == 0 %} + + {% else %} +
+ + +
+ {% endif %} +
+
+ {% endfor %} +
+
+ + + + +{% endblock %} \ No newline at end of file