container logs

This commit is contained in:
Hamcha 2023-11-19 15:08:47 +01:00
parent 8c2f9948c7
commit 9777a7ec64
16 changed files with 381 additions and 39 deletions

13
Cargo.lock generated
View file

@ -1079,10 +1079,12 @@ dependencies = [
"bollard",
"clap",
"dotenvy",
"futures-util",
"serde",
"serde_json",
"thiserror",
"tokio",
"tokio-stream",
"tracing",
"tracing-subscriber",
]
@ -1214,6 +1216,17 @@ dependencies = [
"syn",
]
[[package]]
name = "tokio-stream"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "397c988d37662c7dda6d2208364a706264bf3d6138b11d436cbac0ad38832842"
dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.10"

View file

@ -11,9 +11,11 @@ axum = "0.6"
bollard = "0.15"
clap = { version = "4", features = ["env", "derive"] }
dotenvy = "0.15"
futures-util = "0.3"
serde = "1"
serde_json = "1"
thiserror = "1"
tokio = { version = "1", features = ["full"] }
tokio-stream = "0.1"
tracing = "0.1"
tracing-subscriber = "0.3"

27
src/http/accept.rs Normal file
View file

@ -0,0 +1,27 @@
use axum::{
async_trait,
extract::FromRequestParts,
http::{
header::{HeaderValue, ACCEPT},
request::Parts,
StatusCode,
},
};
pub struct ExtractAccept(pub HeaderValue);
#[async_trait]
impl<S> FromRequestParts<S> for ExtractAccept
where
S: Send + Sync,
{
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
if let Some(user_agent) = parts.headers.get(ACCEPT) {
Ok(ExtractAccept(user_agent.clone()))
} else {
Err((StatusCode::BAD_REQUEST, "`User-Agent` header is missing"))
}
}
}

View file

@ -1,2 +1,3 @@
pub mod accept;
pub mod error;
pub mod response;

View file

@ -1,3 +1,4 @@
use super::error::{AppError, ErrorInfo};
use askama::Template;
use askama_axum::IntoResponse;
use axum::{
@ -8,8 +9,6 @@ use axum::{
};
use serde_json::json;
use super::error::{AppError, ErrorInfo};
pub type HandlerResponse = Result<askama_axum::Response, AppError>;
struct Response {

View file

@ -7,8 +7,8 @@ use std::{net::SocketAddr, path::PathBuf};
use crate::http::response::response_interceptor;
mod http;
mod node;
mod route;
mod stack;
/// GitOps+WebUI for arion-based stacks
#[derive(Parser, Debug)]

42
src/node/container.rs Normal file
View file

@ -0,0 +1,42 @@
use crate::http::error::Result;
use bollard::{
container::InspectContainerOptions,
service::{ContainerInspectResponse, ContainerSummary},
Docker,
};
use serde::Serialize;
#[derive(Serialize)]
pub struct ContainerInfo {
pub name: String,
pub state: String,
pub image: String,
}
impl From<ContainerSummary> for ContainerInfo {
fn from(value: ContainerSummary) -> Self {
ContainerInfo {
name: value.names.unwrap()[0].trim_start_matches('/').to_string(),
state: value.state.unwrap(),
image: value.image.unwrap(),
}
}
}
impl From<ContainerInspectResponse> for ContainerInfo {
fn from(value: ContainerInspectResponse) -> Self {
ContainerInfo {
name: value.name.unwrap(),
state: value.state.map(|s| s.status).flatten().unwrap().to_string(),
image: value.image.unwrap(),
}
}
}
pub async fn get_info(docker: &Docker, name: &str) -> Result<ContainerInfo> {
let info = docker
.inspect_container(name, Some(InspectContainerOptions { size: true }))
.await?;
Ok(info.into())
}

View file

@ -1,8 +1,7 @@
use crate::http::error::AppError;
use axum::http::StatusCode;
use thiserror::Error;
use crate::http::error::AppError;
#[derive(Error, Debug)]
pub enum StackError {
#[error("stack not found")]

3
src/node/mod.rs Normal file
View file

@ -0,0 +1,3 @@
pub mod container;
pub mod error;
pub mod stack;

View file

@ -1,13 +1,10 @@
use super::error::StackError;
use crate::http::error::Result;
use bollard::{container::ListContainersOptions, service::ContainerSummary, Docker};
use serde::Serialize;
use std::{collections::HashMap, path::PathBuf};
use tokio::fs::{read_dir, try_exists};
use crate::http::error::Result;
use self::error::StackError;
pub mod error;
const COMPOSE_FILE: &str = "arion-compose.nix";
async fn is_stack(dir: &PathBuf) -> Result<bool> {
@ -38,7 +35,22 @@ pub async fn get_compose(base_dir: &PathBuf, stack_name: &str) -> Result<String>
Ok(contents)
}
pub async fn list(base_dir: &PathBuf) -> Result<Vec<String>> {
#[derive(Serialize)]
pub struct StackInfo {
pub name: String,
pub active: bool,
}
pub async fn list(base_dir: &PathBuf, docker: &Docker) -> Result<Vec<StackInfo>> {
let containers = docker
.list_containers(Some(ListContainersOptions {
all: false,
limit: None,
filters: HashMap::from([("status".to_string(), vec!["running".to_string()])]),
..Default::default()
}))
.await?;
let mut dirs = read_dir(base_dir).await?;
let mut stacklist = vec![];
while let Some(dir) = dirs.next_entry().await? {
@ -47,7 +59,18 @@ pub async fn list(base_dir: &PathBuf) -> Result<Vec<String>> {
continue;
}
if is_stack(&dir.path()).await? {
stacklist.push(dir.file_name().to_string_lossy().to_string())
let name = dir.file_name().to_string_lossy().to_string();
// Check status by analyzing containers
let active = containers.iter().any(|cont| {
let project = cont
.clone()
.labels
.map(|lab| lab.get("com.docker.compose.project").cloned())
.flatten()
.unwrap_or_default();
name == project
});
stacklist.push(StackInfo { name, active })
}
}

115
src/route/container.rs Normal file
View file

@ -0,0 +1,115 @@
use std::convert::Infallible;
use crate::{
http::{
accept::ExtractAccept,
response::{reply, HandlerResponse},
},
node::container::{get_info, ContainerInfo},
AppState,
};
use askama::Template;
use askama_axum::IntoResponse;
use axum::{
extract::{Path, State},
response::{
sse::{Event, KeepAlive},
Sse,
},
routing::get,
Router,
};
use bollard::container::LogsOptions;
use futures_util::stream::StreamExt;
use serde_json::json;
#[derive(Template)]
#[template(path = "container/get-one.html")]
struct GetOneTemplate {
container_name: String,
info: ContainerInfo,
}
async fn get_one(
Path(container_name): Path<String>,
State(state): State<AppState>,
) -> HandlerResponse {
let info = get_info(&state.docker, &container_name).await?;
reply(
json!({
"name": container_name,
"info": info,
}),
GetOneTemplate {
container_name,
info,
},
)
}
async fn get_log(
Path(container_name): Path<String>,
State(state): State<AppState>,
ExtractAccept(accept): ExtractAccept,
) -> impl IntoResponse {
if let Ok(accept_type) = accept.to_str() {
if accept_type == "text/event-stream" {
return get_log_stream(container_name, state).await.into_response();
}
}
get_log_string(container_name, state).await.into_response()
}
async fn get_log_string(container_name: String, state: AppState) -> impl IntoResponse {
state
.docker
.logs(
&container_name,
Some(LogsOptions::<String> {
follow: false,
stdout: true,
stderr: true,
..Default::default()
}),
)
.map(|ev| match ev {
Ok(output) => output.to_string(),
Err(error) => error.to_string(),
})
.collect::<Vec<_>>()
.await
.join("\n")
}
async fn get_log_stream(container_name: String, state: AppState) -> impl IntoResponse {
let stream = state
.docker
.logs(
&container_name,
Some(LogsOptions::<String> {
follow: true,
stdout: true,
stderr: true,
..Default::default()
}),
)
.map(|msg| match msg {
Ok(output) => Ok::<_, Infallible>(
Event::default()
.json_data(json!({ "lines": output.to_string() }))
.unwrap(),
),
Err(error) => Ok(Event::default()
.json_data(json!({ "error": error.to_string() }))
.unwrap()),
});
Sse::new(stream).keep_alive(KeepAlive::default())
}
pub(super) fn router() -> Router<AppState> {
Router::new()
.route("/:container", get(get_one))
.route("/:container/log", get(get_log))
}

View file

@ -1,25 +1,26 @@
use askama::Template;
use axum::{extract::State, routing::get, Router};
use serde_json::json;
use crate::{
http::{
error::AppError,
response::{reply, HandlerResponse},
},
node::stack::{list, StackInfo},
AppState,
};
use askama::Template;
use axum::{extract::State, routing::get, Router};
use serde_json::json;
mod container;
mod stack;
#[derive(Template)]
#[template(path = "home.html")]
struct HomeTemplate {
stacks: Vec<String>,
stacks: Vec<StackInfo>,
}
async fn home(State(state): State<AppState>) -> HandlerResponse {
let list = crate::stack::list(&state.stack_dir)
let list = list(&state.stack_dir, &state.docker)
.await
.map_err(AppError::from)?;
reply(json!({ "stacks": list }), HomeTemplate { stacks: list })
@ -29,4 +30,5 @@ pub(crate) fn router() -> Router<AppState> {
Router::new()
.route("/", get(home))
.nest("/stack", stack::router())
.nest("/container", container::router())
}

View file

@ -4,12 +4,14 @@ use axum::{
routing::get,
Router,
};
use bollard::service::ContainerSummary;
use serde_json::json;
use crate::{
http::response::{reply, HandlerResponse},
stack::{get_compose, get_containers},
node::{
container::ContainerInfo,
stack::{get_compose, get_containers},
},
AppState,
};
@ -21,22 +23,6 @@ struct GetOneTemplate {
containers: Vec<ContainerInfo>,
}
struct ContainerInfo {
name: String,
state: String,
image: String,
}
impl From<ContainerSummary> for ContainerInfo {
fn from(value: ContainerSummary) -> Self {
ContainerInfo {
name: value.names.unwrap()[0].trim_start_matches('/').to_string(),
state: value.state.unwrap(),
image: value.image.unwrap(),
}
}
}
async fn get_one(Path(stack_name): Path<String>, State(state): State<AppState>) -> HandlerResponse {
let file_contents = get_compose(&state.stack_dir, &stack_name).await?;
let containers = get_containers(&state.docker, &stack_name).await?;

View file

@ -0,0 +1,110 @@
{% extends "base.html" %}
{% block title %}Container {{container_name}}{% endblock %}
{% block content %}
<main>
<h1>Container <span class="container-name">{{container_name}}</span></h1>
<h2>Status</h2>
<div>TODO</div>
<h2>Logs</h2>
<code id="log">
</code>
</main>
<style scoped>
main {
display: flex;
flex-direction: column;
gap: 1rem;
}
h2 {
text-transform: uppercase;
margin: 0;
padding: 0;
}
pre {
margin: 0;
padding: 0;
}
.container-name {
color: #E796F3;
}
#log {
background-color: #171625;
display: flex;
display: block;
height: 500px;
max-height: 50vh;
overflow-y: auto;
& .error {
padding: 4px 8px;
background-color: #3B1219;
color: #FF9592;
}
& p {
padding: 4px 8px;
margin: 0;
&:nth-child(odd) {
background-color: #202248;
}
}
}
</style>
<script type="module">
import ansiFmt from 'https://esm.run/ansi-to-html';
const convert = new ansiFmt({
fg: '#E0DFFE',
bg: '#171625',
newline: false,
escapeXML: true,
stream: false,
colors: ["#282c34", "#abb2bf", "#e06c75", "#be5046", "#98c379", "#d19a66", "#61afef", "#c678dd", "#56b6c2", "#4b5263", "#5c6370"]
});
const logEl = document.querySelector("#log");
const logSource = new EventSource(`${location.origin}${location.pathname}/log`);
logSource.addEventListener("error", (ev) => {
logSource.close();
const err = document.createElement("div");
err.className = "error";
err.appendChild(document.createTextNode("No logs available after this (container not running)"));
logEl.appendChild(err);
err.scrollIntoView();
return;
});
logSource.addEventListener("message", (ev) => {
const data = JSON.parse(ev.data);
// If an error is received stop listening
if ("error" in data) {
logSource.close();
const err = document.createElement("div");
err.className = "error";
err.appendChild(document.createTextNode(data.error));
logEl.appendChild(err);
err.scrollIntoView();
return;
}
// Received lines of log
if ("lines" in data) {
data.lines.split("\n").map(line => convert.toHtml(line)).filter(line => line).forEach(line => {
const lineEl = document.createElement("p");
lineEl.innerHTML = line;
logEl.appendChild(lineEl);
lineEl.scrollIntoView();
});
}
});
</script>
{% endblock %}

View file

@ -4,13 +4,33 @@
{% block content %}
<main>
<h2>All stacks</h2>
<ul>
{% for stack in stacks %}
<li><a href="/stack/{{stack}}">{{stack}}</a></li>
<li><a href="/stack/{{stack.name}}">{{stack.name}}</a> {% if !stack.active %}<div class="stopped">
INACTIVE</div>{% endif %}</li>
{% endfor %}
</ul>
</main>
<style scoped>
ul {
margin: 0;
padding: 0;
}
li {
display: flex;
align-items: center;
margin-bottom: 0.5rem;
gap: 0.5rem;
.stopped {
background-color: #E5484D;
padding: 2px 4px;
border-radius: 4px;
font-size: 11pt;
}
}
</style>
{% endblock %}

View file

@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Viewing {{stack_name}}{% endblock %}
{% block title %}Stack {{stack_name}}{% endblock %}
{% block content %}
<main>