more stats

This commit is contained in:
Hamcha 2023-11-19 18:09:36 +01:00
parent 2cde2f57f6
commit a3f0ddb99f
4 changed files with 151 additions and 16 deletions

3
Cargo.lock generated
View file

@ -266,6 +266,7 @@ dependencies = [
"serde_repr", "serde_repr",
"serde_urlencoded", "serde_urlencoded",
"thiserror", "thiserror",
"time",
"tokio", "tokio",
"tokio-util", "tokio-util",
"url", "url",
@ -281,6 +282,7 @@ dependencies = [
"serde", "serde",
"serde_repr", "serde_repr",
"serde_with", "serde_with",
"time",
] ]
[[package]] [[package]]
@ -1083,6 +1085,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"thiserror", "thiserror",
"time",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tracing", "tracing",

View file

@ -8,13 +8,14 @@ anyhow = "1"
askama = { version = "0.12", features = ["with-axum"] } askama = { version = "0.12", features = ["with-axum"] }
askama_axum = "0.3" askama_axum = "0.3"
axum = "0.6" axum = "0.6"
bollard = "0.15" bollard = { version = "0.15", features = ["time"] }
clap = { version = "4", features = ["env", "derive"] } clap = { version = "4", features = ["env", "derive"] }
dotenvy = "0.15" dotenvy = "0.15"
futures-util = "0.3" futures-util = "0.3"
serde = "1" serde = "1"
serde_json = "1" serde_json = "1"
thiserror = "1" thiserror = "1"
time = { version = "0.3", features = ["serde"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
tokio-stream = "0.1" tokio-stream = "0.1"
tracing = "0.1" tracing = "0.1"

View file

@ -1,34 +1,59 @@
use crate::http::error::Result; use crate::http::error::Result;
use bollard::{ use bollard::{
container::InspectContainerOptions, container::InspectContainerOptions,
service::{ContainerInspectResponse, ContainerSummary}, service::{ContainerInspectResponse, ContainerSummary, MountPoint},
Docker, Docker,
}; };
use serde::Serialize; use serde::Serialize;
use time::OffsetDateTime;
#[derive(Serialize)] #[derive(Serialize)]
pub struct ContainerInfo { pub struct ContainerInfo {
pub id: String,
pub name: String, pub name: String,
pub state: String, pub state: String,
pub image: String, pub image: String,
pub image_id: String,
pub created_at: String,
pub volumes: Option<Vec<MountPoint>>,
pub env: Option<Vec<String>>,
} }
impl From<ContainerSummary> for ContainerInfo { impl From<ContainerSummary> for ContainerInfo {
fn from(value: ContainerSummary) -> Self { fn from(value: ContainerSummary) -> Self {
let created = OffsetDateTime::from_unix_timestamp(value.created.unwrap())
.unwrap_or(OffsetDateTime::UNIX_EPOCH);
ContainerInfo { ContainerInfo {
name: value.names.unwrap()[0].trim_start_matches('/').to_string(), id: value.id.unwrap_or_default(),
state: value.state.unwrap(), name: value.names.unwrap_or_default()[0]
image: value.image.unwrap(), .trim_start_matches('/')
.to_string(),
state: value.state.unwrap_or_default(),
image: value.image.unwrap_or_default(),
image_id: value.image_id.unwrap_or_default(),
created_at: created.time().to_string(),
volumes: value.mounts,
env: None,
} }
} }
} }
impl From<ContainerInspectResponse> for ContainerInfo { impl From<ContainerInspectResponse> for ContainerInfo {
fn from(value: ContainerInspectResponse) -> Self { fn from(value: ContainerInspectResponse) -> Self {
let config = value.config.unwrap_or_default();
ContainerInfo { ContainerInfo {
name: value.name.unwrap(), id: value.id.unwrap_or_default(),
state: value.state.and_then(|s| s.status).unwrap().to_string(), name: value.name.unwrap_or_default(),
image: value.image.unwrap(), state: value
.state
.and_then(|s| s.status)
.unwrap_or_else(|| bollard::service::ContainerStateStatusEnum::EMPTY)
.to_string(),
image: config.image.unwrap_or_default(),
image_id: value.image.unwrap_or_default(),
created_at: value.created.unwrap_or_default(),
volumes: value.mounts,
env: config.env,
} }
} }
} }

View file

@ -5,11 +5,44 @@
{% block content %} {% block content %}
<main> <main>
<h1>Container <span class="container-name">{{container_name}}</span></h1> <h1>Container <span class="container-name">{{container_name}}</span></h1>
<h2>Status</h2> <section class="status-section">
<div>TODO</div> <h2>Status</h2>
<h2>Logs</h2> <dl>
<code id="log"> <dt>ID</dt>
</code> <dd>{{ info.id }}</dd>
<dt>Status</dt>
<dd>{{ info.state }}</dd>
<dt>Image</dt>
<dd>{{ info.image }}</dd>
<dt>Image ID</dt>
<dd>{{ info.image_id }}</dd>
<dt>Created at</dt>
<dd>{{ info.created_at }}</dd>
{% match info.volumes %}
{% when Some with (volumes) %}
<dt>Volumes</dt>
{% for volume in volumes %}
<dd>
{{ volume.source.clone().unwrap_or_default() }} → {{
volume.destination.clone().unwrap_or_default() }}
</dd>
{% endfor %}
{% when None %}
{% endmatch %}
{% match info.env %}
{% when Some with (env) %}
<dt>Environment</dt>
{% for var in env %}
<dd>{{ var }}</dd>
{% endfor %}
{% when None %}
{% endmatch %}
</dl>
</section>
<section class="log-section">
<h2>Logs</h2>
<code id="log"></code>
</section>
</main> </main>
<style scoped> <style scoped>
@ -23,6 +56,7 @@
text-transform: uppercase; text-transform: uppercase;
margin: 0; margin: 0;
padding: 0; padding: 0;
margin-bottom: 1rem;
} }
pre { pre {
@ -39,7 +73,6 @@
display: flex; display: flex;
display: block; display: block;
height: 800px;
max-height: 80vh; max-height: 80vh;
overflow-y: auto; overflow-y: auto;
@ -59,6 +92,73 @@
} }
} }
& time {
padding-right: 1ch;
color: #B1A9FF;
}
}
dl {
border: 1px solid #3D3E82;
background-color: #202248;
margin: 0;
}
dt,
dd {
box-sizing: border-box;
padding: 4px;
}
dd~dt,
dd~dd {
border-top: 1px solid #262A65;
}
dt {
float: left;
width: 25%;
padding-bottom: 0;
}
dd {
margin-left: 25%;
border-left: 1px dotted #3D3E82;
background-color: #171625;
padding: 4px 8px;
word-break: break-all;
}
dd:after {
content: "";
display: block;
clear: both;
}
main>h1 {
grid-area: head;
}
.status-section {
grid-area: info;
}
.log-section {
grid-area: log;
}
@media (min-width: 1000px) {
main {
display: grid;
grid-template-areas:
"head head head"
"info log log"
"info log log";
grid-template-rows: 80px 1fr;
grid-template-columns: 30vw 1fr;
}
} }
</style> </style>
@ -98,9 +198,15 @@
} }
// Received lines of log // Received lines of log
if ("lines" in data) { if ("lines" in data) {
data.lines.split("\n").map(line => convert.toHtml(line)).filter(line => line).forEach(line => { data.lines.split("\n").forEach(line => {
if (!line) {
return;
}
// Extract timestamp
const firstSpace = line.indexOf(' ')
const [timestamp, logline] = [line.substring(0, firstSpace), line.substring(firstSpace + 1)];
const lineEl = document.createElement("p"); const lineEl = document.createElement("p");
lineEl.innerHTML = line.trim(); lineEl.innerHTML = `<time datetime="${timestamp}">${timestamp}</time>${convert.toHtml(logline)}`.trim();
logEl.appendChild(lineEl); logEl.appendChild(lineEl);
lineEl.scrollIntoView(); lineEl.scrollIntoView();
}); });