use askama::Template; use askama_web::WebTemplate; use axum::extract::{Path, State}; use axum::http::HeaderMap; use axum::response::{AppendHeaders, IntoResponse, Redirect, Response}; use axum::Form; use kube::api::{DeleteParams, ListParams, PostParams}; use kube::Api; use serde::Deserialize; use crate::auth_guard::AuthUser; use crate::flash; use crate::htmx; use crate::k8s::{ErgoNetwork, default_network_spec, sanitize_name}; use crate::page::PageContext; use crate::state::AppState; #[derive(Template, WebTemplate)] #[template(path = "networks.html")] pub struct NetworksTemplate { pub page: PageContext, pub networks: Vec, } pub struct NetworkInfo { pub name: String, pub hostname: String, pub network_name: String, pub ready: bool, } #[derive(Template, WebTemplate)] #[template(path = "network_detail.html")] pub struct NetworkDetailTemplate { pub page: PageContext, pub name: String, pub hostname: String, pub network_name: String, pub ready: bool, pub status_message: String, } #[derive(Deserialize)] pub struct CreateForm { pub name: String, #[serde(default)] pub title: Option, } #[derive(Template)] #[template(path = "partials/network_creating.html")] struct NetworkCreatingFragment { name: String, } #[derive(Template)] #[template(path = "partials/flash_fragment.html")] struct FlashFragment { css_class: &'static str, text: &'static str, } #[derive(Template)] #[template(path = "partials/network_status_fragment.html")] struct NetworkStatusFragment { ready: bool, } pub async fn list( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, ) -> impl IntoResponse { let api: Api = Api::namespaced(state.kube.clone(), &state.namespace); let lp = ListParams::default().labels(&format!("irc.now/owner={}", user.sub)); let networks = api .list(&lp) .await .map(|list| { list.items .into_iter() .map(|n| { let ready = n.status.as_ref() .and_then(|s| s.conditions.first()) .map(|c| c.status == "True") .unwrap_or(false); NetworkInfo { name: n.metadata.name.unwrap_or_default(), hostname: n.spec.hostname, network_name: n.spec.network_name.unwrap_or_default(), ready, } }) .collect() }) .unwrap_or_default(); let page = PageContext::from_request(&headers, &state.announcement); let clear = page.flash.is_some(); let template = NetworksTemplate { page, networks }; if clear { (AppendHeaders([flash::clear_flash()]), template).into_response() } else { template.into_response() } } pub async fn create( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Form(form): Form, ) -> Response { if !user.is_pro() { if htmx::is_htmx(&headers) { return axum::http::Response::builder() .status(403) .body(axum::body::Body::from( FlashFragment { css_class: "flash-error", text: "upgrade to pro to create a network" } .render().unwrap_or_default() )) .unwrap() .into_response(); } return axum::http::StatusCode::FORBIDDEN.into_response(); } let name = sanitize_name(&form.name); if name.is_empty() { if htmx::is_htmx(&headers) { return axum::http::Response::builder() .status(200) .body(axum::body::Body::from( FlashFragment { css_class: "flash-error", text: "failed to create network." } .render().unwrap_or_default() )) .unwrap() .into_response(); } return (AppendHeaders([flash::set_flash("network_create_failed")]), Redirect::to("/networks")).into_response(); } let user_sub = user.sub.clone(); let mut spec = default_network_spec(&name); if let Some(title) = form.title.filter(|t| !t.trim().is_empty()) { spec.network_name = Some(title.trim().to_string()); } let mut network = ErgoNetwork::new(&name, spec); network .metadata .labels .get_or_insert_with(Default::default) .insert("irc.now/owner".to_string(), user.sub); let api: Api = Api::namespaced(state.kube.clone(), &state.namespace); if let Err(e) = api.create(&PostParams::default(), &network).await { let flash_code = match &e { kube::Error::Api(status) if status.is_already_exists() => "network_name_taken", _ => { tracing::error!("failed to create network: {e}"); "network_create_failed" } }; if htmx::is_htmx(&headers) { let msg = flash::resolve(flash_code).unwrap(); return axum::http::Response::builder() .status(200) .body(axum::body::Body::from( FlashFragment { css_class: msg.css_class(), text: msg.text } .render().unwrap_or_default() )) .unwrap() .into_response(); } return (AppendHeaders([flash::set_flash(flash_code)]), Redirect::to("/networks")).into_response(); } crate::events::record( &state.db, &user_sub, "network_create", Some(serde_json::json!({"network": name})), ).await; if htmx::is_htmx(&headers) { let fragment = NetworkCreatingFragment { name }; axum::http::Response::builder() .status(200) .body(axum::body::Body::from(fragment.render().unwrap_or_default())) .unwrap() .into_response() } else { (AppendHeaders([flash::set_flash("network_created")]), Redirect::to("/networks")).into_response() } } pub async fn detail( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(name): Path, ) -> Result { let api: Api = Api::namespaced(state.kube.clone(), &state.namespace); let network = api.get(&name).await.map_err(|e| { tracing::error!("failed to get network {name}: {e}"); (AppendHeaders([flash::set_flash("network_not_found")]), Redirect::to("/networks")) })?; let owner = network.metadata.labels.as_ref() .and_then(|l| l.get("irc.now/owner")) .map(|s| s.as_str()); if owner != Some(&user.sub) { return Err((AppendHeaders([flash::set_flash("network_not_found")]), Redirect::to("/networks"))); } let (ready, status_message) = network.status.as_ref() .and_then(|s| s.conditions.first()) .map(|c| ( c.status == "True", c.message.clone().unwrap_or_default(), )) .unwrap_or((false, "pending".to_string())); let page = PageContext::from_request(&headers, &state.announcement); let clear = page.flash.is_some(); let template = NetworkDetailTemplate { page, name, hostname: network.spec.hostname, network_name: network.spec.network_name.unwrap_or_default(), ready, status_message, }; if clear { Ok((AppendHeaders([flash::clear_flash()]), template).into_response()) } else { Ok(template.into_response()) } } pub async fn delete( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(name): Path, ) -> Response { let api: Api = Api::namespaced(state.kube.clone(), &state.namespace); match api.get(&name).await { Ok(network) => { let owner = network.metadata.labels.as_ref() .and_then(|l| l.get("irc.now/owner")) .map(|s| s.as_str()); if owner != Some(&user.sub) { if htmx::is_htmx(&headers) { return ([htmx::hx_redirect("/networks")], "").into_response(); } return (AppendHeaders([flash::set_flash("network_not_found")]), Redirect::to("/networks")).into_response(); } } Err(_) => { if htmx::is_htmx(&headers) { return ([htmx::hx_redirect("/networks")], "").into_response(); } return (AppendHeaders([flash::set_flash("network_not_found")]), Redirect::to("/networks")).into_response(); } } match api.delete(&name, &DeleteParams::default()).await { Ok(_) => { crate::events::record( &state.db, &user.sub, "network_delete", Some(serde_json::json!({"network": name})), ).await; if htmx::is_htmx(&headers) { ([htmx::hx_redirect("/networks")], "").into_response() } else { (AppendHeaders([flash::set_flash("network_deleted")]), Redirect::to("/networks")).into_response() } } Err(e) => { tracing::error!("failed to delete network {name}: {e}"); if htmx::is_htmx(&headers) { ([htmx::hx_redirect(&format!("/networks/{name}"))], "").into_response() } else { (AppendHeaders([flash::set_flash("network_delete_failed")]), Redirect::to(&format!("/networks/{name}"))).into_response() } } } } pub async fn status( State(state): State, AuthUser(user): AuthUser, Path(name): Path, ) -> Response { let api: Api = Api::namespaced(state.kube.clone(), &state.namespace); let network = match api.get(&name).await { Ok(n) => n, Err(_) => return axum::http::StatusCode::NOT_FOUND.into_response(), }; let owner = network.metadata.labels.as_ref() .and_then(|l| l.get("irc.now/owner")) .map(|s| s.as_str()); if owner != Some(&user.sub) { return axum::http::StatusCode::NOT_FOUND.into_response(); } let ready = network.status.as_ref() .and_then(|s| s.conditions.first()) .map(|c| c.status == "True") .unwrap_or(false); let fragment = NetworkStatusFragment { ready }; let html = fragment.render().unwrap_or_default(); if ready { ([("hx-trigger", "network-ready")], axum::response::Html(html)).into_response() } else { axum::response::Html(html).into_response() } }