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",
|
"bollard",
|
||||||
"clap",
|
"clap",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
|
"futures-util",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-stream",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
|
@ -1214,6 +1216,17 @@ dependencies = [
|
||||||
"syn",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.10"
|
version = "0.7.10"
|
||||||
|
|
|
@ -11,9 +11,11 @@ axum = "0.6"
|
||||||
bollard = "0.15"
|
bollard = "0.15"
|
||||||
clap = { version = "4", features = ["env", "derive"] }
|
clap = { version = "4", features = ["env", "derive"] }
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
|
futures-util = "0.3"
|
||||||
serde = "1"
|
serde = "1"
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
thiserror = "1"
|
thiserror = "1"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tokio-stream = "0.1"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = "0.3"
|
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 error;
|
||||||
pub mod response;
|
pub mod response;
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
use super::error::{AppError, ErrorInfo};
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use askama_axum::IntoResponse;
|
use askama_axum::IntoResponse;
|
||||||
use axum::{
|
use axum::{
|
||||||
|
@ -8,8 +9,6 @@ use axum::{
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use super::error::{AppError, ErrorInfo};
|
|
||||||
|
|
||||||
pub type HandlerResponse = Result<askama_axum::Response, AppError>;
|
pub type HandlerResponse = Result<askama_axum::Response, AppError>;
|
||||||
|
|
||||||
struct Response {
|
struct Response {
|
||||||
|
|
|
@ -7,8 +7,8 @@ use std::{net::SocketAddr, path::PathBuf};
|
||||||
use crate::http::response::response_interceptor;
|
use crate::http::response::response_interceptor;
|
||||||
|
|
||||||
mod http;
|
mod http;
|
||||||
|
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)]
|
||||||
|
|
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 axum::http::StatusCode;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::http::error::AppError;
|
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum StackError {
|
pub enum StackError {
|
||||||
#[error("stack not found")]
|
#[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 bollard::{container::ListContainersOptions, service::ContainerSummary, Docker};
|
||||||
|
use serde::Serialize;
|
||||||
use std::{collections::HashMap, path::PathBuf};
|
use std::{collections::HashMap, path::PathBuf};
|
||||||
use tokio::fs::{read_dir, try_exists};
|
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";
|
const COMPOSE_FILE: &str = "arion-compose.nix";
|
||||||
|
|
||||||
async fn is_stack(dir: &PathBuf) -> Result<bool> {
|
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)
|
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 dirs = read_dir(base_dir).await?;
|
||||||
let mut stacklist = vec![];
|
let mut stacklist = vec![];
|
||||||
while let Some(dir) = dirs.next_entry().await? {
|
while let Some(dir) = dirs.next_entry().await? {
|
||||||
|
@ -47,7 +59,18 @@ pub async fn list(base_dir: &PathBuf) -> Result<Vec<String>> {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if is_stack(&dir.path()).await? {
|
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::{
|
use crate::{
|
||||||
http::{
|
http::{
|
||||||
error::AppError,
|
error::AppError,
|
||||||
response::{reply, HandlerResponse},
|
response::{reply, HandlerResponse},
|
||||||
},
|
},
|
||||||
|
node::stack::{list, StackInfo},
|
||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
|
use askama::Template;
|
||||||
|
use axum::{extract::State, routing::get, Router};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
mod container;
|
||||||
mod stack;
|
mod stack;
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "home.html")]
|
#[template(path = "home.html")]
|
||||||
struct HomeTemplate {
|
struct HomeTemplate {
|
||||||
stacks: Vec<String>,
|
stacks: Vec<StackInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn home(State(state): State<AppState>) -> HandlerResponse {
|
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
|
.await
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
reply(json!({ "stacks": list }), HomeTemplate { stacks: list })
|
reply(json!({ "stacks": list }), HomeTemplate { stacks: list })
|
||||||
|
@ -29,4 +30,5 @@ pub(crate) fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(home))
|
.route("/", get(home))
|
||||||
.nest("/stack", stack::router())
|
.nest("/stack", stack::router())
|
||||||
|
.nest("/container", container::router())
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,12 +4,14 @@ use axum::{
|
||||||
routing::get,
|
routing::get,
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use bollard::service::ContainerSummary;
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
http::response::{reply, HandlerResponse},
|
http::response::{reply, HandlerResponse},
|
||||||
stack::{get_compose, get_containers},
|
node::{
|
||||||
|
container::ContainerInfo,
|
||||||
|
stack::{get_compose, get_containers},
|
||||||
|
},
|
||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -21,22 +23,6 @@ struct GetOneTemplate {
|
||||||
containers: Vec<ContainerInfo>,
|
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 {
|
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 file_contents = get_compose(&state.stack_dir, &stack_name).await?;
|
||||||
let containers = get_containers(&state.docker, &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 %}
|
{% block content %}
|
||||||
<main>
|
<main>
|
||||||
|
<h2>All stacks</h2>
|
||||||
<ul>
|
<ul>
|
||||||
{% for stack in stacks %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style scoped>
|
<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>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
|
@ -1,6 +1,6 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Viewing {{stack_name}}{% endblock %}
|
{% block title %}Stack {{stack_name}}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<main>
|
<main>
|
||||||
|
|
Reference in a new issue