# Bouncer Management Page Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Add a bouncer detail page at `/bouncers/{name}` showing status, connection info, networks, and a delete action. **Architecture:** The detail page reads the SojuBouncer CR for status/hostname, then connects to the per-tenant soju database (`{name}-db` secret) to fetch the username and network list. Delete removes the CR (the operator handles cleanup via finalizer). The bouncer list page is updated to link to detail pages and show ready status. **Tech Stack:** Axum 0.8, kube.rs, tokio-postgres, askama 0.15, existing flash/PageContext pattern --- ### Task 1: Add status to the k8s types and BouncerInfo **Files:** - Modify: `crates/web-api/src/k8s.rs` **Step 1: Add status types to k8s.rs** Add after `SojuBouncerSpec`: ```rust #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] pub struct SojuBouncerStatus { #[serde(default)] pub conditions: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct SojuBouncerCondition { #[serde(rename = "type")] pub type_: String, pub status: String, #[serde(default)] pub reason: Option, #[serde(default)] pub message: Option, } ``` Update the `#[kube(...)]` attribute to include `status = "SojuBouncerStatus"`: ```rust #[kube( group = "irc.now", version = "v1alpha1", kind = "SojuBouncer", namespaced, status = "SojuBouncerStatus" )] ``` **Step 2: Run `cargo check -p irc-now-web-api`** --- ### Task 2: Bouncer detail handler -- CR data **Files:** - Modify: `crates/web-api/src/routes/bouncer.rs` - Modify: `crates/web-api/src/flash.rs` - Modify: `crates/web-api/src/main.rs` **Step 1: Add new flash codes** In `crates/web-api/src/flash.rs`, add to the `resolve` match: ```rust "bouncer_not_found" => (FlashLevel::Error, "bouncer not found."), "bouncer_deleted" => (FlashLevel::Success, "bouncer deleted."), "bouncer_delete_failed" => (FlashLevel::Error, "failed to delete bouncer."), ``` **Step 2: Add the detail handler and template struct** In `crates/web-api/src/routes/bouncer.rs`, add: ```rust use axum::extract::Path; use k8s_openapi::api::core::v1::Secret; use kube::api::DeleteParams; pub struct NetworkInfo { pub name: String, pub addr: String, pub nick: String, pub enabled: bool, } #[derive(Template, WebTemplate)] #[template(path = "bouncer_detail.html")] pub struct BouncerDetailTemplate { pub page: PageContext, pub name: String, pub hostname: String, pub ready: bool, pub status_message: String, pub username: String, pub networks: Vec, } 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 bouncer = api.get(&name).await.map_err(|e| { tracing::error!("failed to get bouncer {name}: {e}"); (AppendHeaders([flash::set_flash("bouncer_not_found")]), Redirect::to("/bouncers")) })?; // Verify ownership let owner = bouncer.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("bouncer_not_found")]), Redirect::to("/bouncers"))); } let (ready, status_message) = bouncer.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())); // Fetch username and networks from soju DB let (username, networks) = match get_bouncer_details(&state, &name).await { Ok((u, n)) => (u, n), Err(e) => { tracing::warn!("failed to read soju db for {name}: {e}"); (String::new(), vec![]) } }; let page = PageContext::from_request(&headers, &state.announcement); let clear = page.flash.is_some(); let template = BouncerDetailTemplate { page, name, hostname: bouncer.spec.hostname, ready, status_message, username, networks, }; if clear { Ok((AppendHeaders([flash::clear_flash()]), template).into_response()) } else { Ok(template.into_response()) } } async fn get_bouncer_details( state: &AppState, bouncer_name: &str, ) -> Result<(String, Vec), Box> { let secret_api: Api = Api::namespaced(state.kube.clone(), &state.namespace); let secret = secret_api.get(&format!("{bouncer_name}-db")).await?; let uri = secret.data.as_ref() .and_then(|d| d.get("uri")) .map(|b| String::from_utf8_lossy(&b.0).to_string()) .ok_or("missing uri in db secret")?; let (client, connection) = tokio_postgres::connect(&uri, tokio_postgres::NoTls).await?; tokio::spawn(async move { if let Err(e) = connection.await { tracing::warn!("soju db connection error: {e}"); } }); let username: String = client .query_one(r#"SELECT username FROM "User" LIMIT 1"#, &[]) .await? .get(0); let rows = client .query( r#"SELECT COALESCE(name, '') as name, addr, COALESCE(nick, '') as nick, enabled FROM "Network" ORDER BY id"#, &[], ) .await?; let networks = rows.iter().map(|r| NetworkInfo { name: r.get(0), addr: r.get(1), nick: r.get(2), enabled: r.get(3), }).collect(); Ok((username, networks)) } ``` **Step 3: Add the route in main.rs** Add after the `/bouncers/create` route: ```rust .route("/bouncers/{name}", get(routes::bouncer::detail)) ``` **Step 4: Run `cargo check -p irc-now-web-api`** --- ### Task 3: Delete handler **Files:** - Modify: `crates/web-api/src/routes/bouncer.rs` - Modify: `crates/web-api/src/main.rs` **Step 1: Add delete handler** In `crates/web-api/src/routes/bouncer.rs`: ```rust pub async fn delete( State(state): State, AuthUser(user): AuthUser, Path(name): Path, ) -> (AppendHeaders<[(axum::http::HeaderName, axum::http::HeaderValue); 1]>, Redirect) { let api: Api = Api::namespaced(state.kube.clone(), &state.namespace); // Verify ownership before deleting match api.get(&name).await { Ok(bouncer) => { let owner = bouncer.metadata.labels.as_ref() .and_then(|l| l.get("irc.now/owner")) .map(|s| s.as_str()); if owner != Some(&user.sub) { return (AppendHeaders([flash::set_flash("bouncer_not_found")]), Redirect::to("/bouncers")); } } Err(_) => { return (AppendHeaders([flash::set_flash("bouncer_not_found")]), Redirect::to("/bouncers")); } } match api.delete(&name, &DeleteParams::default()).await { Ok(_) => (AppendHeaders([flash::set_flash("bouncer_deleted")]), Redirect::to("/bouncers")), Err(e) => { tracing::error!("failed to delete bouncer {name}: {e}"); (AppendHeaders([flash::set_flash("bouncer_delete_failed")]), Redirect::to(&format!("/bouncers/{name}"))) } } } ``` **Step 2: Add the route** In `crates/web-api/src/main.rs`, add after the detail route: ```rust .route("/bouncers/{name}/delete", post(routes::bouncer::delete)) ``` **Step 3: Run `cargo check -p irc-now-web-api`** --- ### Task 4: Bouncer detail template **Files:** - Create: `crates/web-api/templates/bouncer_detail.html` **Step 1: Create the template** ```html {% extends "base.html" %} {% block title %}{{ name }} - irc.now{% endblock %} {% block banner %}{% include "partials/announcement.html" %}{% endblock %} {% block flash %}{% include "partials/flash.html" %}{% endblock %} {% block content %}

{{ name }}

status

{% if ready %}

ready

{% else %}

not ready

{% if !status_message.is_empty() %}

{{ status_message }}

{% endif %} {% endif %}

connection

host {{ hostname }}
port (tls) 6697
port (plain) 6667
{% if !username.is_empty() %}
username {{ username }}
{% endif %}
{% if !networks.is_empty() %}

networks

{% for net in networks %}
{% if net.name.is_empty() %}{{ net.addr }}{% else %}{{ net.name }}{% endif %} {% if !net.nick.is_empty() %}{{ net.nick }} @ {% endif %}{{ net.addr }}{% if !net.enabled %} (disabled){% endif %}
{% endfor %}
{% endif %}

danger zone

deleting a bouncer removes all its data, networks, and message history.

{% endblock %} ``` --- ### Task 5: Update bouncer list to link to detail and show status **Files:** - Modify: `crates/web-api/src/routes/bouncer.rs` - Modify: `crates/web-api/templates/bouncers.html` **Step 1: Add ready status to BouncerInfo** In `bouncer.rs`, update `BouncerInfo`: ```rust pub struct BouncerInfo { pub name: String, pub hostname: String, pub ready: bool, } ``` Update the list handler's map to extract ready status: ```rust .map(|b| { let ready = b.status.as_ref() .and_then(|s| s.conditions.first()) .map(|c| c.status == "True") .unwrap_or(false); BouncerInfo { name: b.metadata.name.unwrap_or_default(), hostname: b.spec.hostname, ready, } }) ``` **Step 2: Update bouncers.html** Replace the bouncer card in the `{% for bouncer in bouncers %}` loop: ```html {% for bouncer in bouncers %}

{{ bouncer.name }}

{{ bouncer.hostname }} -- {% if bouncer.ready %}ready{% else %}not ready{% endif %}

{% endfor %} ``` **Step 3: Run `cargo check -p irc-now-web-api`** --- ### Task 6: Build and verify **Step 1: Run `cargo check -p irc-now-web-api`** **Step 2: Run `cargo test -p irc-now-web-api`** **Step 3: Fix any issues**