//! Web UI: an axum + minijinja + htmx app for viewing and editing the
//! flat-file desired state, publishing it into the document, reporting agent
//! status, and handing out join tickets. Structured after ../dms.
use std::time::{SystemTime, UNIX_EPOCH};
use axum::{
Form, Router,
extract::{Path, State},
http::{StatusCode, header},
response::{Html, IntoResponse, Redirect, Response},
routing::{get, post},
};
use minijinja::context;
use serde::{Deserialize, Serialize};
use devendra_proto::{Status, keys};
use crate::{AppState, adopt, agent_ticket, publish, store};
// Fonts are embedded in the binary (B612 Mono + FontAwesome solid, from ../dms)
// and served from `/assets/fonts/*`, so the controller stays a single
// self-contained binary.
const FONT_B612_400: &[u8] = include_bytes!("../assets/fonts/b612-mono-400.woff2");
const FONT_B612_700: &[u8] = include_bytes!("../assets/fonts/b612-mono-700.woff2");
const FONT_FA_SOLID: &[u8] = include_bytes!("../assets/fonts/fa-solid-900.woff2");
/// Build the router for the controller web app.
pub fn router(state: AppState) -> Router {
Router::new()
.route("/", get(index))
.route("/hosts", post(create_host))
.route("/hosts/{id}", get(show_host).post(save_host))
.route("/hosts/{id}/edit", get(edit_host))
.route("/hosts/{id}/includes", post(update_includes))
.route("/hosts/{id}/adopt", get(adopt_page).post(accept_adopt))
.route("/fragments", get(fragments_index).post(create_fragment))
.route("/fragments/{id}", get(show_fragment).post(save_fragment))
.route("/fragments/{id}/edit", get(edit_fragment))
.route("/publish", post(publish_all))
.route("/ticket", get(ticket))
.route("/assets/fonts/{name}", get(font_asset))
.with_state(state)
}
/// Serve an embedded font with a long immutable cache.
async fn font_asset(Path(name): Path<String>) -> Response {
let bytes: &'static [u8] = match name.as_str() {
"b612-mono-400.woff2" => FONT_B612_400,
"b612-mono-700.woff2" => FONT_B612_700,
"fa-solid-900.woff2" => FONT_FA_SOLID,
_ => return StatusCode::NOT_FOUND.into_response(),
};
(
[
(header::CONTENT_TYPE, "font/woff2"),
(header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
],
bytes,
)
.into_response()
}
// ---- views passed to templates ---------------------------------------------
#[derive(Serialize)]
struct HostRow {
id: String,
has_adoption: bool,
status: Option<StatusView>,
}
/// One proposed file in an adoption report, rendered for review.
#[derive(Serialize)]
struct AdoptFileView {
path: String,
rel_path: String,
mode: String,
size: u64,
/// A text preview of the body, truncated. `None` if it looks binary.
preview: Option<String>,
truncated: bool,
}
/// A file resource with fields pre-rendered for display.
#[derive(Serialize)]
struct FileView {
path: String,
state: String,
mode: String,
owner: String,
}
/// A package resource with fields pre-rendered for display.
#[derive(Serialize)]
struct PackageView {
name: String,
state: String,
}
/// A directory resource with fields pre-rendered for display.
#[derive(Serialize)]
struct DirView {
path: String,
state: String,
mode: String,
owner: String,
}
/// A service resource with fields pre-rendered for display.
#[derive(Serialize)]
struct ServiceView {
name: String,
enabled: String,
state: String,
/// Live traffic-light health from the agent, or `None` when the service
/// isn't monitored / no report yet. Serializes to `ok`/`pending`/`bad`/
/// `unknown` for use as a CSS class.
health: Option<devendra_proto::ServiceHealth>,
/// Raw systemd state string, shown as a tooltip.
health_detail: String,
}
/// Host system metrics, pre-formatted for the title badge.
#[derive(Serialize)]
struct MetricsView {
uptime: String,
load: String,
age: String,
}
impl MetricsView {
fn from_metrics(m: &devendra_proto::HostMetrics) -> Self {
MetricsView {
uptime: humanize_uptime(m.uptime_secs),
load: format!("{:.2} {:.2} {:.2}", m.load1, m.load5, m.load15),
age: humanize_age(m.reported_at_ms),
}
}
}
#[derive(Serialize)]
struct StatusView {
generation: u64,
result: String,
message: String,
age: String,
changed: usize,
failed: usize,
}
impl StatusView {
fn from_status(s: &Status) -> Self {
let changed = s.changes.iter().filter(|c| c.action.is_change()).count();
let failed = s
.changes
.iter()
.filter(|c| c.action == devendra_proto::ChangeAction::Failed)
.count();
StatusView {
generation: s.generation,
result: format!("{:?}", s.result).to_lowercase(),
message: s.message.clone(),
age: humanize_age(s.applied_at_ms),
changed,
failed,
}
}
}
// ---- handlers --------------------------------------------------------------
async fn index(State(state): State<AppState>) -> Result<Html<String>, AppError> {
let ids = store::list_host_ids(&state.config.hosts_dir())?;
let pending = adopt::list_pending(&state.node, &state.doc).await?;
let pending_set: std::collections::HashSet<&str> = pending.iter().map(String::as_str).collect();
let mut rows = Vec::with_capacity(ids.len());
for id in &ids {
let status = read_status(&state, id).await?;
rows.push(HostRow {
id: id.clone(),
has_adoption: pending_set.contains(id.as_str()),
status: status.as_ref().map(StatusView::from_status),
});
}
// Hosts that have a proposal but no config file yet — pure adoption targets.
let unmanaged: Vec<String> = pending
.iter()
.filter(|p| !ids.contains(p))
.cloned()
.collect();
// Offer the available fragments as checkboxes in the "new host" form.
let fragments = store::list_fragment_ids(&state.config.include_dir())?;
render(
&state,
"index.html",
context! { hosts => rows, unmanaged => unmanaged, fragments => fragments },
)
}
async fn show_host(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Html<String>, AppError> {
store::validate_host_id(&id)?;
let raw = store::read_raw(&state.config.hosts_dir(), &id)?;
// Best-effort compile so the page can show a parsed summary and surface
// errors, without blocking the view of the raw text.
let (packages, directories, files, services, compile_error) =
match store::load_host(&state.config.hosts_dir(), &id)
.and_then(|cfg| cfg.resolve(&state.config.config_dir))
.and_then(|cfg| cfg.compile(&state.config.config_dir))
{
Ok(compiled) => {
let (packages, dirs, files, mut services) = manifest_views(&compiled.manifest);
// Overlay the agent's live service health onto the desired-state
// services, so each row gets a green/yellow/red indicator.
if let Some(report) = read_service_report(&state, &id).await? {
let live: std::collections::HashMap<&str, &devendra_proto::ServiceRuntime> =
report
.services
.iter()
.map(|s| (s.name.as_str(), s))
.collect();
for sv in &mut services {
if let Some(rt) = live.get(sv.name.as_str()) {
sv.health = Some(rt.health);
sv.health_detail = rt.detail.clone();
}
}
}
(
minijinja::Value::from_serialize(&packages),
minijinja::Value::from_serialize(&dirs),
minijinja::Value::from_serialize(&files),
minijinja::Value::from_serialize(&services),
None,
)
}
Err(e) => (
minijinja::Value::UNDEFINED,
minijinja::Value::UNDEFINED,
minijinja::Value::UNDEFINED,
minijinja::Value::UNDEFINED,
Some(format!("{e:#}")),
),
};
// Includes management: current includes (best-effort if the file parses)
// and the fragments not yet included, offered in the "add include" dropdown.
let current_includes = store::load_host(&state.config.hosts_dir(), &id)
.map(|c| c.include)
.unwrap_or_default();
let includes: Vec<_> = current_includes
.iter()
.map(|reference| {
context! {
reference => reference,
fragment => store::fragment_id_from_ref(reference),
}
})
.collect();
let addable: Vec<String> = store::list_fragment_ids(&state.config.include_dir())?
.into_iter()
.filter(|frag| !current_includes.contains(&store::fragment_include_ref(frag)))
.collect();
let status = read_status(&state, &id).await?;
let metrics = read_metrics(&state, &id).await?;
let has_adoption = adopt::read_report(&state.node, &state.doc, &id)
.await?
.is_some();
render(
&state,
"host.html",
context! {
id => id,
raw => raw,
includes => includes,
addable => addable,
packages => packages,
directories => directories,
files => files,
services => services,
compile_error => compile_error,
status => status.as_ref().map(StatusView::from_status),
metrics => metrics.as_ref().map(MetricsView::from_metrics),
has_adoption => has_adoption,
},
)
}
/// Add or remove a host's fragment includes, then republish.
#[derive(Deserialize)]
struct IncludesForm {
/// Fragment id to add.
add: Option<String>,
/// Include reference (`include/<id>.toml`) to remove.
remove: Option<String>,
}
async fn update_includes(
State(state): State<AppState>,
Path(id): Path<String>,
Form(form): Form<IncludesForm>,
) -> Result<Response, AppError> {
store::validate_host_id(&id)?;
if let Some(fragment) = form.add.as_deref().filter(|s| !s.is_empty()) {
store::add_include(&state.config.hosts_dir(), &id, fragment)?;
} else if let Some(reference) = form.remove.as_deref().filter(|s| !s.is_empty()) {
store::remove_include(&state.config.hosts_dir(), &id, reference)?;
}
// Republish so the include change takes effect on the agent.
let host = store::LoadedHost {
id: id.clone(),
config: store::load_host(&state.config.hosts_dir(), &id)?,
};
publish::publish_host(&state.node, &state.doc, &state.config.config_dir, &host).await?;
Ok(Redirect::to(&format!("/hosts/{id}")).into_response())
}
/// Show a host's pending adoption proposal for review.
async fn adopt_page(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Html<String>, AppError> {
store::validate_host_id(&id)?;
let report = adopt::read_report(&state.node, &state.doc, &id).await?;
let Some(report) = report else {
return render(
&state,
"adopt.html",
context! { id => id, has_report => false },
);
};
// Build a reviewable view of each file, fetching a small text preview.
let mut files = Vec::with_capacity(report.files.len());
for f in &report.files {
let (preview, truncated) = match adopt::read_body(
&state.node,
&state.doc,
&id,
&f.path,
&f.content_blake3,
)
.await
{
Ok(body) => text_preview(&body),
Err(_) => (None, false), // not synced yet / mismatch — no preview
};
files.push(AdoptFileView {
path: f.path.clone(),
rel_path: f.rel_path.clone(),
mode: format!("{:04o}", f.mode),
size: f.size,
preview,
truncated,
});
}
render(
&state,
"adopt.html",
context! {
id => id,
has_report => true,
scan_dir => report.scan_dir,
age => humanize_age(report.discovered_at_ms),
files => files,
},
)
}
#[derive(Deserialize)]
struct AcceptForm {
/// A single file path to adopt, or absent when `all` is set.
path: Option<String>,
/// Present (any value) to adopt every proposed file.
all: Option<String>,
}
async fn accept_adopt(
State(state): State<AppState>,
Path(id): Path<String>,
Form(form): Form<AcceptForm>,
) -> Result<Response, AppError> {
store::validate_host_id(&id)?;
let selected: Vec<String> = if form.all.is_some() {
match adopt::read_report(&state.node, &state.doc, &id).await? {
Some(r) => r.files.iter().map(|f| f.path.clone()).collect(),
None => Vec::new(),
}
} else {
form.path.into_iter().collect()
};
if selected.is_empty() {
return Ok(Redirect::to(&format!("/hosts/{id}/adopt")).into_response());
}
adopt::accept(&state.node, &state.doc, &state.config, &id, &selected).await?;
// After accepting, the host is managed — send the operator to its page.
Ok(Redirect::to(&format!("/hosts/{id}")).into_response())
}
async fn edit_host(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Html<String>, AppError> {
store::validate_host_id(&id)?;
let raw = store::read_raw(&state.config.hosts_dir(), &id)?;
render(
&state,
"edit.html",
context! { id => id, raw => raw, is_new => false },
)
}
#[derive(Deserialize)]
struct SaveForm {
toml: String,
}
async fn save_host(
State(state): State<AppState>,
Path(id): Path<String>,
Form(form): Form<SaveForm>,
) -> Result<Response, AppError> {
store::validate_host_id(&id)?;
// write_raw validates the TOML parses before persisting.
store::write_raw(&state.config.hosts_dir(), &id, &form.toml)?;
// Publish just this host immediately so a save takes effect.
let host = store::LoadedHost {
id: id.clone(),
config: store::load_host(&state.config.hosts_dir(), &id)?,
};
publish::publish_host(&state.node, &state.doc, &state.config.config_dir, &host).await?;
Ok(Redirect::to(&format!("/hosts/{id}")).into_response())
}
#[derive(Deserialize)]
struct CreateForm {
id: String,
toml: String,
}
/// The new-host form has repeated `fragments` checkboxes, which don't map to a
/// struct field, so we take the raw key/value pairs. Fields: `id`, zero or more
/// `fragments`, and an optional `extra` TOML block.
async fn create_host(
State(state): State<AppState>,
Form(pairs): Form<Vec<(String, String)>>,
) -> Result<Response, AppError> {
let mut id = String::new();
let mut fragments = Vec::new();
let mut extra = String::new();
for (key, value) in pairs {
match key.as_str() {
"id" => id = value.trim().to_string(),
"fragments" => fragments.push(value),
"extra" => extra = value,
_ => {}
}
}
store::validate_host_id(&id)?;
// Validate each checked fragment exists before writing anything.
let known = store::list_fragment_ids(&state.config.include_dir())?;
for f in &fragments {
if !known.contains(f) {
return Err(anyhow::anyhow!("unknown fragment {f:?}").into());
}
}
let toml = build_host_toml(&fragments, &extra);
store::write_raw(&state.config.hosts_dir(), &id, &toml)?;
// Publish immediately so the new host goes live.
let host = store::LoadedHost {
id: id.clone(),
config: store::load_host(&state.config.hosts_dir(), &id)?,
};
publish::publish_host(&state.node, &state.doc, &state.config.config_dir, &host).await?;
Ok(Redirect::to(&format!("/hosts/{id}")).into_response())
}
/// Assemble a host TOML from selected fragment ids and an optional extra block.
fn build_host_toml(fragments: &[String], extra: &str) -> String {
let mut out = String::new();
if !fragments.is_empty() {
let refs: Vec<String> = fragments
.iter()
.map(|f| format!("\"{}\"", store::fragment_include_ref(f)))
.collect();
out.push_str(&format!("include = [{}]\n", refs.join(", ")));
}
let extra = extra.trim();
if !extra.is_empty() {
if !out.is_empty() {
out.push('\n');
}
out.push_str(extra);
out.push('\n');
}
out
}
// ---- fragments -------------------------------------------------------------
/// Starter content for a new fragment.
const SAMPLE_FRAGMENT_TOML: &str = "\
# A reusable fragment. Hosts pull it in with:
# include = [\"include/<this-id>.toml\"]
[[files]]
path = \"/etc/containers/systemd/app.container\"
mode = \"0644\"
content = \"\"\"
[Container]
Image=docker.io/library/nginx:latest
\"\"\"
[[services]]
name = \"app.service\"
enabled = true
state = \"started\"
";
async fn fragments_index(State(state): State<AppState>) -> Result<Html<String>, AppError> {
let ids = store::list_fragment_ids(&state.config.include_dir())?;
render(
&state,
"fragments.html",
context! { fragments => ids, sample_toml => SAMPLE_FRAGMENT_TOML },
)
}
async fn show_fragment(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Html<String>, AppError> {
store::validate_fragment_id(&id)?;
let raw = store::read_fragment_raw(&state.config.include_dir(), &id)?;
// Best-effort compile (fragments may themselves include others) for a
// parsed summary; surface errors without hiding the raw text.
let (packages, directories, files, services, compile_error) =
match store::read_fragment_raw(&state.config.include_dir(), &id)
.and_then(|raw| toml::from_str::<store::HostConfig>(&raw).map_err(anyhow::Error::from))
.and_then(|cfg| cfg.resolve(&state.config.config_dir))
.and_then(|cfg| cfg.compile(&state.config.config_dir))
{
Ok(compiled) => {
let (packages, dirs, files, services) = manifest_views(&compiled.manifest);
(
minijinja::Value::from_serialize(&packages),
minijinja::Value::from_serialize(&dirs),
minijinja::Value::from_serialize(&files),
minijinja::Value::from_serialize(&services),
None,
)
}
Err(e) => (
minijinja::Value::UNDEFINED,
minijinja::Value::UNDEFINED,
minijinja::Value::UNDEFINED,
minijinja::Value::UNDEFINED,
Some(format!("{e:#}")),
),
};
let used_by = store::hosts_using_fragment(&state.config.hosts_dir(), &id)?;
render(
&state,
"fragment.html",
context! {
id => id,
raw => raw,
include_ref => store::fragment_include_ref(&id),
packages => packages,
directories => directories,
files => files,
services => services,
compile_error => compile_error,
used_by => used_by,
},
)
}
async fn edit_fragment(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Html<String>, AppError> {
store::validate_fragment_id(&id)?;
let raw = store::read_fragment_raw(&state.config.include_dir(), &id)?;
render(
&state,
"fragment_edit.html",
context! { id => id, raw => raw },
)
}
async fn save_fragment(
State(state): State<AppState>,
Path(id): Path<String>,
Form(form): Form<SaveForm>,
) -> Result<Response, AppError> {
store::validate_fragment_id(&id)?;
store::write_fragment_raw(&state.config.include_dir(), &id, &form.toml)?;
// A fragment change affects every host that includes it (transitively), so
// republish all — publishing only bumps a host's generation if it changed.
publish::publish_all(&state.node, &state.doc, &state.config.config_dir).await?;
Ok(Redirect::to(&format!("/fragments/{id}")).into_response())
}
async fn create_fragment(
State(state): State<AppState>,
Form(form): Form<CreateForm>,
) -> Result<Response, AppError> {
let id = form.id.trim().to_string();
store::validate_fragment_id(&id)?;
store::write_fragment_raw(&state.config.include_dir(), &id, &form.toml)?;
Ok(Redirect::to(&format!("/fragments/{id}")).into_response())
}
async fn publish_all(State(state): State<AppState>) -> Result<Html<String>, AppError> {
let outcomes = publish::publish_all(&state.node, &state.doc, &state.config.config_dir).await?;
let rows: Vec<_> = outcomes
.iter()
.map(|o| {
context! {
host_id => o.host_id,
generation => o.generation,
changed => o.changed,
}
})
.collect();
render(&state, "published.html", context! { outcomes => rows })
}
async fn ticket(State(state): State<AppState>) -> Result<Html<String>, AppError> {
let ticket = agent_ticket(&state.node, &state.doc).await?;
render(
&state,
"ticket.html",
context! {
ticket => ticket.to_string(),
node_id => state.node.node_id().to_string(),
doc => state.doc.id().to_string(),
},
)
}
// ---- helpers ---------------------------------------------------------------
async fn read_status(state: &AppState, host_id: &str) -> Result<Option<Status>, AppError> {
let Some(bytes) = state
.node
.read(&state.doc, &keys::status(&host_id.to_string()))
.await?
else {
return Ok(None);
};
Ok(serde_json::from_slice(&bytes).ok())
}
/// Read the host's live service-state report, if the agent has published one.
async fn read_service_report(
state: &AppState,
host_id: &str,
) -> Result<Option<devendra_proto::ServiceReport>, AppError> {
let Some(bytes) = state
.node
.read(&state.doc, &keys::service_report(&host_id.to_string()))
.await?
else {
return Ok(None);
};
Ok(serde_json::from_slice(&bytes).ok())
}
/// Read the host's latest system metrics, if the agent has published any.
async fn read_metrics(
state: &AppState,
host_id: &str,
) -> Result<Option<devendra_proto::HostMetrics>, AppError> {
let Some(bytes) = state
.node
.read(&state.doc, &keys::metrics(&host_id.to_string()))
.await?
else {
return Ok(None);
};
Ok(serde_json::from_slice(&bytes).ok())
}
fn render(
state: &AppState,
template: &str,
ctx: minijinja::Value,
) -> Result<Html<String>, AppError> {
let tmpl = state.env.get_template(template)?;
Ok(Html(tmpl.render(ctx)?))
}
/// Produce a truncated UTF-8 preview of a file body for review, or `None` if it
/// contains NUL bytes (looks binary). Returns `(preview, truncated)`.
fn text_preview(body: &[u8]) -> (Option<String>, bool) {
const MAX: usize = 4000;
if body.contains(&0) {
return (None, false);
}
let text = String::from_utf8_lossy(body);
if text.len() > MAX {
let mut end = MAX;
while !text.is_char_boundary(end) {
end -= 1;
}
(Some(text[..end].to_string()), true)
} else {
(Some(text.into_owned()), false)
}
}
/// Build display views of a compiled manifest's packages, directories, files,
/// and services, shared by the host and fragment pages.
fn manifest_views(
manifest: &devendra_proto::Manifest,
) -> (
Vec<PackageView>,
Vec<DirView>,
Vec<FileView>,
Vec<ServiceView>,
) {
let packages = manifest
.packages
.iter()
.map(|p| PackageView {
name: p.name.clone(),
state: format!("{:?}", p.state).to_lowercase(),
})
.collect();
let directories = manifest
.directories
.iter()
.map(|d| DirView {
path: d.path.clone(),
state: format!("{:?}", d.state).to_lowercase(),
mode: d
.mode
.map(|m| format!("{m:04o}"))
.unwrap_or_else(|| "—".into()),
owner: match (&d.owner, &d.group) {
(Some(o), Some(g)) => format!("{o}:{g}"),
(Some(o), None) => o.clone(),
_ => "—".into(),
},
})
.collect();
let files = manifest
.files
.iter()
.map(|f| FileView {
path: f.path.clone(),
state: format!("{:?}", f.state).to_lowercase(),
mode: f
.mode
.map(|m| format!("{m:04o}"))
.unwrap_or_else(|| "—".into()),
owner: match (&f.owner, &f.group) {
(Some(o), Some(g)) => format!("{o}:{g}"),
(Some(o), None) => o.clone(),
_ => "—".into(),
},
})
.collect();
let services = manifest
.services
.iter()
.map(|s| ServiceView {
name: s.name.clone(),
enabled: match s.enabled {
Some(true) => "yes".into(),
Some(false) => "no".into(),
None => "—".into(),
},
state: s
.state
.map(|st| format!("{st:?}").to_lowercase())
.unwrap_or_else(|| "—".into()),
// Overlaid later from the agent's live report, if any.
health: None,
health_detail: String::new(),
})
.collect();
(packages, directories, files, services)
}
fn humanize_age(applied_at_ms: u64) -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let secs = now.saturating_sub(applied_at_ms) / 1000;
match secs {
0..=59 => format!("{secs}s ago"),
60..=3599 => format!("{}m ago", secs / 60),
3600..=86399 => format!("{}h ago", secs / 3600),
_ => format!("{}d ago", secs / 86400),
}
}
/// Compact uptime, e.g. `3d 4h`, `5h 12m`, `42m`, `30s`.
fn humanize_uptime(secs: u64) -> String {
let (d, h, m) = (secs / 86400, (secs % 86400) / 3600, (secs % 3600) / 60);
if d > 0 {
format!("{d}d {h}h")
} else if h > 0 {
format!("{h}h {m}m")
} else if m > 0 {
format!("{m}m")
} else {
format!("{secs}s")
}
}
/// Wraps any error as a 500 response with the message in the body — adequate
/// for a small operator-facing tool.
pub struct AppError(anyhow::Error);
impl<E: Into<anyhow::Error>> From<E> for AppError {
fn from(err: E) -> Self {
AppError(err.into())
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
tracing::error!("request error: {:#}", self.0);
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("error: {:#}", self.0),
)
.into_response()
}
}
neiam
/devendra
public · Issues · Pulls · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
27.0 KiB · text
History
e832e77