container logs
This commit is contained in:
parent
8c2f9948c7
commit
9777a7ec64
16 changed files with 381 additions and 39 deletions
13
Cargo.lock
generated
13
Cargo.lock
generated
|
@ -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"
|
||||
|
|
|
@ -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
27
src/http/accept.rs
Normal 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"))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,2 +1,3 @@
|
|||
pub mod accept;
|
||||
pub mod error;
|
||||
pub mod response;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
42
src/node/container.rs
Normal 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())
|
||||
}
|
|
@ -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
3
src/node/mod.rs
Normal file
|
@ -0,0 +1,3 @@
|
|||
pub mod container;
|
||||
pub mod error;
|
||||
pub mod stack;
|
|
@ -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
115
src/route/container.rs
Normal 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))
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
|
|
|
@ -4,12 +4,14 @@ use axum::{
|
|||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use bollard::service::ContainerSummary;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
http::response::{reply, HandlerResponse},
|
||||
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?;
|
||||
|
|
110
templates/container/get-one.html
Normal file
110
templates/container/get-one.html
Normal 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 %}
|
|
@ -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 %}
|
|
@ -1,6 +1,6 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Viewing {{stack_name}}{% endblock %}
|
||||
{% block title %}Stack {{stack_name}}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main>
|
||||
|
|
Reference in a new issue