Compare commits
2 commits
21a549b19f
...
bee2d265d7
Author | SHA1 | Date | |
---|---|---|---|
bee2d265d7 | |||
fd8c865a27 |
24 changed files with 441 additions and 46 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -1,3 +1,4 @@
|
||||||
/target
|
/target
|
||||||
/dist
|
/dist
|
||||||
.env
|
.env
|
||||||
|
.vscode
|
131
src/git/history.rs
Normal file
131
src/git/history.rs
Normal file
|
@ -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<u32>,
|
||||||
|
pub new_line: Option<u32>,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
pub struct CommitInfo {
|
||||||
|
pub oid: String,
|
||||||
|
pub message: String,
|
||||||
|
pub author: String,
|
||||||
|
pub date: String,
|
||||||
|
pub changes: Vec<LineChange>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ThreadSafeRepository {
|
||||||
|
pub fn get_history(&self, path: &Path) -> Result<Vec<CommitInfo>> {
|
||||||
|
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<String> {
|
||||||
|
// 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<DiffLine<'_>> 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<String> {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,6 +3,8 @@ use git2::{ErrorCode, Index, IndexAddOption, Oid, Repository, Signature};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
|
pub mod history;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ThreadSafeRepository {
|
pub struct ThreadSafeRepository {
|
||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
|
@ -1,6 +1,6 @@
|
||||||
use super::{
|
use super::{
|
||||||
accept::{parse_accept, ResponseType},
|
accept::{parse_accept, ResponseType},
|
||||||
error::{AppError, ErrorInfo},
|
error::{ErrorInfo, Result},
|
||||||
};
|
};
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use askama_axum::IntoResponse;
|
use askama_axum::IntoResponse;
|
||||||
|
@ -12,7 +12,7 @@ use axum::{
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
pub type HandlerResponse = Result<askama_axum::Response, AppError>;
|
pub type HandlerResponse = Result<askama_axum::Response>;
|
||||||
|
|
||||||
struct Response {
|
struct Response {
|
||||||
html: String,
|
html: String,
|
||||||
|
|
|
@ -1,16 +1,18 @@
|
||||||
|
use crate::git::{GitConfig, ThreadSafeRepository};
|
||||||
use crate::http::response::response_interceptor;
|
use crate::http::response::response_interceptor;
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use axum::middleware::from_fn;
|
use axum::middleware::from_fn;
|
||||||
use bollard::Docker;
|
use bollard::Docker;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use node::git::{GitConfig, ThreadSafeRepository};
|
|
||||||
use std::{net::SocketAddr, path::PathBuf};
|
use std::{net::SocketAddr, path::PathBuf};
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
|
|
||||||
|
mod git;
|
||||||
mod http;
|
mod http;
|
||||||
mod nix;
|
mod nix;
|
||||||
mod node;
|
mod node;
|
||||||
mod route;
|
mod route;
|
||||||
|
mod stack;
|
||||||
|
|
||||||
/// GitOps+WebUI for arion-based stacks
|
/// GitOps+WebUI for arion-based stacks
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
|
|
|
@ -1,6 +1,4 @@
|
||||||
pub mod container;
|
pub mod container;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod git;
|
|
||||||
pub mod nix;
|
pub mod nix;
|
||||||
pub mod stack;
|
|
||||||
pub mod system;
|
pub mod system;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
use crate::{
|
use crate::{
|
||||||
|
http::error::Result,
|
||||||
node::container::{kill, remove, restart, start, stop},
|
node::container::{kill, remove, restart, start, stop},
|
||||||
route::AppError,
|
|
||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
|
@ -17,7 +17,7 @@ macro_rules! container_command {
|
||||||
($cmd: ident) => {
|
($cmd: ident) => {
|
||||||
move |Path(cont_name): Path<String>, State(state): State<AppState>| async move {
|
move |Path(cont_name): Path<String>, State(state): State<AppState>| async move {
|
||||||
$cmd(&state.docker, &cont_name).await?;
|
$cmd(&state.docker, &cont_name).await?;
|
||||||
Ok(Redirect::to("./")) as Result<Redirect, AppError>
|
Ok(Redirect::to("./")) as Result<Redirect>
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -41,7 +41,7 @@ pub(super) fn router() -> Router<AppState> {
|
||||||
post(
|
post(
|
||||||
move |Path(cont_name): Path<String>, State(state): State<AppState>| async move {
|
move |Path(cont_name): Path<String>, State(state): State<AppState>| async move {
|
||||||
remove(&state.docker, &cont_name).await?;
|
remove(&state.docker, &cont_name).await?;
|
||||||
Ok(Redirect::to("/")) as Result<Redirect, AppError>
|
Ok(Redirect::to("/")) as Result<Redirect>
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,12 +1,8 @@
|
||||||
use crate::{
|
use crate::{
|
||||||
http::{
|
http::error::AppError,
|
||||||
error::AppError,
|
http::response::{reply, HandlerResponse},
|
||||||
response::{reply, HandlerResponse},
|
node::system::{system_info, SystemInfo},
|
||||||
},
|
stack::{list, NodeInfo},
|
||||||
node::{
|
|
||||||
stack::{list, NodeInfo},
|
|
||||||
system::{system_info, SystemInfo},
|
|
||||||
},
|
|
||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
use axum::{extract::State, http::StatusCode};
|
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(
|
pub(super) async fn check_stack_file(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
body: String,
|
body: String,
|
||||||
) -> Result<StatusCode, AppError> {
|
) -> Result<StatusCode> {
|
||||||
compose::check(&state.arion_bin, &body).await?;
|
compose::check(&state.arion_bin, &body).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,8 +4,8 @@ use axum::{extract::State, response::Redirect, Form};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
http::error::AppError,
|
http::error::Result,
|
||||||
node::stack::{compose, operation},
|
stack::{compose, operation},
|
||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -21,7 +21,7 @@ pub(super) struct CreateStackForm {
|
||||||
pub(super) async fn create_stack(
|
pub(super) async fn create_stack(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Form(form): Form<CreateStackForm>,
|
Form(form): Form<CreateStackForm>,
|
||||||
) -> Result<Redirect, AppError> {
|
) -> Result<Redirect> {
|
||||||
// Make sure body is is ok
|
// Make sure body is is ok
|
||||||
let name = compose::check(&state.arion_bin, &form.source).await?;
|
let name = compose::check(&state.arion_bin, &form.source).await?;
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,11 @@ use axum::{
|
||||||
response::Redirect,
|
response::Redirect,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{http::error::AppError, node::stack::operation, AppState};
|
use crate::{
|
||||||
|
http::error::Result,
|
||||||
|
stack::{self, operation},
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "stack/delete-one.html")]
|
#[template(path = "stack/delete-one.html")]
|
||||||
|
@ -13,14 +17,21 @@ struct ConfirmDeleteTemplate {
|
||||||
stack_name: String,
|
stack_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn confirm_deletion_page(Path(stack_name): Path<String>) -> impl IntoResponse {
|
pub(super) async fn confirm_deletion_page(
|
||||||
ConfirmDeleteTemplate { stack_name }
|
Path(stack_name): Path<String>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<impl IntoResponse> {
|
||||||
|
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(
|
pub(super) async fn delete_stack(
|
||||||
Path(stack_name): Path<String>,
|
Path(stack_name): Path<String>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> Result<Redirect, AppError> {
|
) -> Result<Redirect> {
|
||||||
operation::remove(
|
operation::remove(
|
||||||
&state.stack_dir,
|
&state.stack_dir,
|
||||||
&state.arion_bin,
|
&state.arion_bin,
|
||||||
|
|
|
@ -6,7 +6,11 @@ use axum::{
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
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)]
|
#[derive(Deserialize)]
|
||||||
pub(super) struct EditStackForm {
|
pub(super) struct EditStackForm {
|
||||||
|
@ -18,7 +22,7 @@ pub(super) async fn edit_stack(
|
||||||
Path(stack_name): Path<String>,
|
Path(stack_name): Path<String>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Form(form): Form<EditStackForm>,
|
Form(form): Form<EditStackForm>,
|
||||||
) -> Result<Redirect, AppError> {
|
) -> Result<Redirect> {
|
||||||
let commit_message = if form.commit_message.trim().is_empty() {
|
let commit_message = if form.commit_message.trim().is_empty() {
|
||||||
format!("Update {}", stack_name)
|
format!("Update {}", stack_name)
|
||||||
} else {
|
} else {
|
||||||
|
@ -39,20 +43,34 @@ pub(super) async fn edit_stack(
|
||||||
// Make sure file is ok
|
// Make sure file is ok
|
||||||
compose::check(&state.arion_bin, &source).await?;
|
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
|
// Write compose file
|
||||||
compose::write(&state.stack_dir, &stack_name, &source).await?;
|
compose::write(&state.stack_dir, stack_name, source).await?;
|
||||||
|
|
||||||
// Git commit
|
// Git commit
|
||||||
compose::commit(state.repository, &stack_name, &commit_message)?;
|
compose::commit(state.repository, stack_name, commit_message)?;
|
||||||
|
|
||||||
// Update stack
|
// Update stack
|
||||||
arion::command(
|
arion::command(
|
||||||
&state.stack_dir,
|
&state.stack_dir,
|
||||||
&stack_name,
|
stack_name,
|
||||||
&state.arion_bin,
|
&state.arion_bin,
|
||||||
arion::StackCommand::Start,
|
arion::StackCommand::Start,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(Redirect::to("./"))
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
75
src/route/stack/history.rs
Normal file
75
src/route/stack/history.rs
Normal file
|
@ -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<CommitInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(super) struct RestoreForm {
|
||||||
|
oid: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn list(
|
||||||
|
Path(stack_name): Path<String>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> 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<String>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Form(form): Form<RestoreForm>,
|
||||||
|
) -> Result<Redirect> {
|
||||||
|
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(".."))
|
||||||
|
}
|
|
@ -6,8 +6,8 @@ use axum::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
http::error::AppError,
|
http::error::Result,
|
||||||
node::stack::arion::{command, StackCommand},
|
stack::arion::{command, StackCommand},
|
||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -17,13 +17,14 @@ mod check;
|
||||||
mod create;
|
mod create;
|
||||||
mod delete;
|
mod delete;
|
||||||
mod edit;
|
mod edit;
|
||||||
|
mod history;
|
||||||
mod read;
|
mod read;
|
||||||
|
|
||||||
macro_rules! stack_command {
|
macro_rules! stack_command {
|
||||||
($cmd: expr) => {
|
($cmd: expr) => {
|
||||||
move |Path(stack_name): Path<String>, State(state): State<AppState>| async move {
|
move |Path(stack_name): Path<String>, State(state): State<AppState>| async move {
|
||||||
command(&state.stack_dir, &stack_name, &state.arion_bin, $cmd).await?;
|
command(&state.stack_dir, &stack_name, &state.arion_bin, $cmd).await?;
|
||||||
Ok(Redirect::to("./")) as Result<Redirect, AppError>
|
Ok(Redirect::to("./")) as Result<Redirect>
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -54,5 +55,6 @@ pub(super) fn router() -> Router<AppState> {
|
||||||
"/:stack/delete",
|
"/:stack/delete",
|
||||||
get(delete::confirm_deletion_page).post(delete::delete_stack),
|
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))
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,14 +4,14 @@ use serde_json::json;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
http::response::{reply, HandlerResponse},
|
http::response::{reply, HandlerResponse},
|
||||||
node::{container::ContainerInfo, nix::parse_arion_compose, stack},
|
node::container::ContainerInfo,
|
||||||
AppState,
|
node::nix::parse_arion_compose,
|
||||||
|
stack, AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "stack/get-one.html")]
|
#[template(path = "stack/get-one.html")]
|
||||||
struct GetOneTemplate {
|
struct GetOneTemplate {
|
||||||
stack_folder: String,
|
|
||||||
stack_name: String,
|
stack_name: String,
|
||||||
file_contents: String,
|
file_contents: String,
|
||||||
containers: Vec<ContainerInfo>,
|
containers: Vec<ContainerInfo>,
|
||||||
|
@ -33,7 +33,6 @@ pub(super) async fn get_one(
|
||||||
"containers": containers,
|
"containers": containers,
|
||||||
}),
|
}),
|
||||||
GetOneTemplate {
|
GetOneTemplate {
|
||||||
stack_folder: stack_name,
|
|
||||||
stack_name: info.project,
|
stack_name: info.project,
|
||||||
file_contents,
|
file_contents,
|
||||||
containers: containers.iter().map(|c| c.clone().into()).collect(),
|
containers: containers.iter().map(|c| c.clone().into()).collect(),
|
||||||
|
|
|
@ -6,8 +6,9 @@ use tempfile::tempdir;
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
git::ThreadSafeRepository,
|
||||||
http::error::AppError,
|
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};
|
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<()> {
|
pub fn commit(repository: ThreadSafeRepository, stack_name: &str, message: &str) -> Result<()> {
|
||||||
let compose_path = format!("{}/{}", stack_name, COMPOSE_FILE);
|
let compose_path = path(stack_name);
|
||||||
repository.commit_files(&[&PathBuf::from(compose_path)], message)?;
|
repository.commit_files(&[&compose_path], message)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -70,3 +71,8 @@ pub async fn check(arion_bin: &Path, source: &str) -> crate::http::error::Result
|
||||||
Ok(info.project)
|
Ok(info.project)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[inline(always)]
|
||||||
|
pub fn path(stack_name: &str) -> PathBuf {
|
||||||
|
format!("{}/{}", stack_name, COMPOSE_FILE).into()
|
||||||
|
}
|
|
@ -93,3 +93,10 @@ pub async fn containers(docker: &Docker, stack_name: &str) -> Result<Vec<Contain
|
||||||
}))
|
}))
|
||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shortcut function to get a stack name from directory
|
||||||
|
pub async fn stack_name(base_dir: &Path, folder: &str) -> Result<String> {
|
||||||
|
let compose_file = compose::get(base_dir, folder).await?;
|
||||||
|
let info = parse_arion_compose(&compose_file)?;
|
||||||
|
Ok(info.project)
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use super::container::ContainerInfo;
|
use crate::node::container::ContainerInfo;
|
||||||
|
|
||||||
pub mod arion;
|
pub mod arion;
|
||||||
pub mod compose;
|
pub mod compose;
|
|
@ -3,7 +3,7 @@ use futures_util::future::try_join;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
|
|
||||||
use crate::node::git::ThreadSafeRepository;
|
use crate::git::ThreadSafeRepository;
|
||||||
|
|
||||||
use super::{arion, COMPOSE_FILE, PACKAGE_CONTENTS, PACKAGE_FILE};
|
use super::{arion, COMPOSE_FILE, PACKAGE_CONTENTS, PACKAGE_FILE};
|
||||||
|
|
|
@ -100,6 +100,13 @@ button {
|
||||||
color: var(--link-hover);
|
color: var(--link-hover);
|
||||||
border-color: var(--link-hover);
|
border-color: var(--link-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background-color: #323035;
|
||||||
|
border-color: #3C393F;
|
||||||
|
color: #6F6D78;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
table.table {
|
table.table {
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>{% block title %}{{ title }} - staxman{% endblock %}</title>
|
<title>{% block title %}{% endblock %} - staxman</title>
|
||||||
<link rel="preconnect" href="https://rsms.me/">
|
<link rel="preconnect" href="https://rsms.me/">
|
||||||
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
|
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
|
||||||
<link rel="stylesheet" href="https://iosevka-webfonts.github.io/iosevka/iosevka.css">
|
<link rel="stylesheet" href="https://iosevka-webfonts.github.io/iosevka/iosevka.css">
|
||||||
|
|
140
templates/stack/history.html
Normal file
140
templates/stack/history.html
Normal file
|
@ -0,0 +1,140 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Stack history for {{stack_name}}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main>
|
||||||
|
<header>
|
||||||
|
<h1>Stack history for <span class="stack-name">{{stack_name}}</span></h1>
|
||||||
|
<div id="page-actions">
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<section class="versions">
|
||||||
|
{% for (i, commit) in commits.iter().enumerate() %}
|
||||||
|
<article>
|
||||||
|
<p>
|
||||||
|
<time datetime="{{commit.date}}">{{commit.date_human()}}</time>
|
||||||
|
<span class="commit_id">
|
||||||
|
{{commit.oid[..7]}}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<details open>
|
||||||
|
<summary title="{{commit.message}} ({{commit.author}})">
|
||||||
|
{{commit.message}}
|
||||||
|
</summary>
|
||||||
|
<code>
|
||||||
|
{% for line in commit.diff() %}
|
||||||
|
{% match line.chars().next() %}
|
||||||
|
{% when Some with ('+') %}
|
||||||
|
<pre class="add">{{line}}</pre>
|
||||||
|
{% when Some with ('-') %}
|
||||||
|
<pre class="del">{{line}}</pre>
|
||||||
|
{% when _ %}
|
||||||
|
<pre class="context">{{line}}</pre>
|
||||||
|
{% endmatch %}
|
||||||
|
{% endfor %}
|
||||||
|
</code>
|
||||||
|
</details>
|
||||||
|
<div class="actions">
|
||||||
|
{% if i == 0 %}
|
||||||
|
<button disabled>CURRENT</button>
|
||||||
|
{% else %}
|
||||||
|
<form method="POST" action="./history/revert">
|
||||||
|
<input type="hidden" name="oid" value="{{commit.oid}}" />
|
||||||
|
<button type="submit">Revert to this revision</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.stack-name {
|
||||||
|
color: var(--text-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
summary {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.versions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1ch;
|
||||||
|
|
||||||
|
& article {
|
||||||
|
border: 1px solid var(--table-border-color);
|
||||||
|
background-color: var(--table-bg);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5ch;
|
||||||
|
|
||||||
|
& p {
|
||||||
|
font-size: 9pt;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
& .commit_id::before {
|
||||||
|
content: " - ";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& code {
|
||||||
|
font-size: 10pt;
|
||||||
|
background-color: var(--bg-raised);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
margin-top: 0.5ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
& pre {
|
||||||
|
&.context {
|
||||||
|
color: var(--button-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.add {
|
||||||
|
color: var(--success-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.del {
|
||||||
|
color: var(--danger-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
& button {
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 10pt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
const actions = document.getElementById("page-actions");
|
||||||
|
const toggle_text = ["Collapse all", "Expand all"];
|
||||||
|
|
||||||
|
/** @type {HTMLDetailsElement[]} */
|
||||||
|
const versions = [...document.querySelectorAll(".versions details")];
|
||||||
|
|
||||||
|
let toggle = document.createElement("button");
|
||||||
|
toggle.type = "button";
|
||||||
|
toggle.appendChild(document.createTextNode(toggle_text[0]));
|
||||||
|
toggle.addEventListener("click", () => {
|
||||||
|
const index = toggle_text.indexOf(toggle.textContent);
|
||||||
|
versions.forEach(version => { version.open = index == 1 });
|
||||||
|
toggle.textContent = toggle_text[(index + 1) % 2];
|
||||||
|
});
|
||||||
|
actions.appendChild(toggle);
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
Reference in a new issue