# 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<SojuBouncerCondition>,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SojuBouncerCondition {
    #[serde(rename = "type")]
    pub type_: String,
    pub status: String,
    #[serde(default)]
    pub reason: Option<String>,
    #[serde(default)]
    pub message: Option<String>,
}
```

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<NetworkInfo>,
}

pub async fn detail(
    State(state): State<AppState>,
    headers: HeaderMap,
    AuthUser(user): AuthUser,
    Path(name): Path<String>,
) -> Result<impl IntoResponse, impl IntoResponse> {
    let api: Api<SojuBouncer> = 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<NetworkInfo>), Box<dyn std::error::Error>> {
    let secret_api: Api<Secret> = 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<AppState>,
    AuthUser(user): AuthUser,
    Path(name): Path<String>,
) -> (AppendHeaders<[(axum::http::HeaderName, axum::http::HeaderValue); 1]>, Redirect) {
    let api: Api<SojuBouncer> = 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 %}
<div class="content">
  <h1>{{ name }}</h1>

  <div class="card">
    <h2>status</h2>
    {% if ready %}
    <p style="color: var(--g);">ready</p>
    {% else %}
    <p style="color: #E08080;">not ready</p>
    {% if !status_message.is_empty() %}
    <p style="font-size: 12px; font-family: var(--mono); color: var(--text-3);">{{ status_message }}</p>
    {% endif %}
    {% endif %}
  </div>

  <div class="card">
    <h2>connection</h2>
    <div class="profile-field">
      <span class="profile-label">host</span>
      <span>{{ hostname }}</span>
    </div>
    <div class="profile-field">
      <span class="profile-label">port (tls)</span>
      <span>6697</span>
    </div>
    <div class="profile-field">
      <span class="profile-label">port (plain)</span>
      <span>6667</span>
    </div>
    {% if !username.is_empty() %}
    <div class="profile-field">
      <span class="profile-label">username</span>
      <span>{{ username }}</span>
    </div>
    {% endif %}
  </div>

  {% if !networks.is_empty() %}
  <div class="card">
    <h2>networks</h2>
    {% for net in networks %}
    <div class="profile-field">
      <span class="profile-label">{% if net.name.is_empty() %}{{ net.addr }}{% else %}{{ net.name }}{% endif %}</span>
      <span>{% if !net.nick.is_empty() %}{{ net.nick }} @ {% endif %}{{ net.addr }}{% if !net.enabled %} (disabled){% endif %}</span>
    </div>
    {% endfor %}
  </div>
  {% endif %}

  <div class="card">
    <h2>danger zone</h2>
    <p>deleting a bouncer removes all its data, networks, and message history.</p>
    <form method="post" action="/bouncers/{{ name }}/delete" onsubmit="return confirm('delete bouncer {{ name }}? this cannot be undone.');">
      <button type="submit" class="btn-fill" style="background: #AA5555;">delete bouncer</button>
    </form>
  </div>
</div>
{% 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 %}
  <a href="/bouncers/{{ bouncer.name }}" style="text-decoration: none; color: inherit;">
    <div class="card">
      <h2>{{ bouncer.name }}</h2>
      <p>{{ bouncer.hostname }} -- {% if bouncer.ready %}<span style="color: var(--g);">ready</span>{% else %}<span style="color: #E08080;">not ready</span>{% endif %}</p>
    </div>
  </a>
  {% 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**
