# Pods Panel, CalVer Versioning & Monitoring Fixes — Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Add CalVer auto-versioning to all builds, a 7th TUI cockpit showing K8s pod status, and fix cluster resource monitoring.

**Architecture:** CI computes `YYYY.MM.PIPELINE_IID` and passes it via env var. Each binary reads it at compile time via `option_env!()`. A new gRPC streaming RPC in api_gateway uses the `kube` crate to list/watch pods. The TUI gets a 7th cockpit (key 7) rendering pod state by service group.

**Tech Stack:** Rust (workspace), tonic/prost (gRPC), ratatui (TUI), kube-rs (K8s client), GitLab CI, Prometheus

---

## Task 1: CalVer Build Version — `common/src/build_info.rs` ✅ (if done) / TODO

**Files:**
- Create: `crates/common/src/build_info.rs`
- Modify: `crates/common/src/lib.rs`

**Step 1: Create `build_info.rs`**

```rust
// crates/common/src/build_info.rs
//! Build-time version information.
//!
//! CI sets `FOXHUNT_BUILD_VERSION` (e.g. `2026.03.408`).
//! Local dev falls back to `CARGO_PKG_VERSION`.

/// Build version string. Set at compile time.
///
/// CI: `FOXHUNT_BUILD_VERSION=2026.03.408`
/// Local: falls back to Cargo.toml `version` field.
pub fn version() -> &'static str {
    option_env!("FOXHUNT_BUILD_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"))
}
```

**Step 2: Register module in `lib.rs`**

In `crates/common/src/lib.rs`, add after the `pub mod tls;` line:

```rust
pub mod build_info;
```

**Step 3: Verify it compiles**

Run: `SQLX_OFFLINE=true cargo check -p common`
Expected: compiles, `version()` returns "1.0.0" (Cargo.toml workspace version)

**Step 4: Commit**

```bash
git add crates/common/src/build_info.rs crates/common/src/lib.rs
git commit -m "feat(common): add build_info module with CalVer version function"
```

---

## Task 2: CI Version Computation — `.gitlab-ci.yml`

**Files:**
- Modify: `.gitlab-ci.yml` (global variables + compile job scripts)

**Step 1: Add version computation to global variables**

In the `variables:` block (after line 59), add:

```yaml
  # CalVer: YYYY.MM.<pipeline_iid> — monotonically increasing per pipeline
  # Computed at job start; override in pipeline vars to pin a version.
  FOXHUNT_BUILD_VERSION: "${CI_COMMIT_TIMESTAMP:0:4}.${CI_COMMIT_TIMESTAMP:5:2}.${CI_PIPELINE_IID}"
```

**Note:** GitLab CE doesn't support bash substring in variables. We must compute it in the script section instead.

**Step 1 (revised): Add version computation to compile scripts**

In `compile-services` script section, add before `cargo build`:

```yaml
    - |
      # CalVer: YYYY.MM.<pipeline_iid>
      YEAR=$(date -d "$CI_COMMIT_TIMESTAMP" +%Y 2>/dev/null || date +%Y)
      MONTH=$(date -d "$CI_COMMIT_TIMESTAMP" +%m 2>/dev/null || date +%m)
      export FOXHUNT_BUILD_VERSION="${YEAR}.${MONTH}.${CI_PIPELINE_IID:-0}"
      echo "Build version: $FOXHUNT_BUILD_VERSION"
```

Add the same block to `compile-training` script section, before `cargo build`.

Also add to `compile-fxt` (if it exists as a separate job, or it's part of compile-services).

**Step 2: Verify locally**

Run: `FOXHUNT_BUILD_VERSION=2026.03.999 SQLX_OFFLINE=true cargo check -p common`
Expected: compiles. `version()` would return "2026.03.999" at runtime.

**Step 3: Commit**

```bash
git add .gitlab-ci.yml
git commit -m "feat(ci): compute CalVer FOXHUNT_BUILD_VERSION in compile jobs"
```

---

## Task 3: Populate `ServiceStatus.version` in api_gateway

**Files:**
- Modify: `services/api_gateway/src/grpc/monitoring_handler.rs`
- Dependency: `common::build_info::version()`

**Step 1: Find `check_backend_health` or `ServiceStatus` construction**

In `monitoring_handler.rs`, find where `ServiceStatus` is built (around line 280-306). The `version` field should be populated.

Look for the pattern:
```rust
version: None, // or ..Default::default()
```

Replace with:
```rust
version: Some(common::build_info::version().to_owned()),
```

This only sets the api_gateway's own version. Each downstream service would need to expose its version in its health response. For now, api_gateway reports its own version for all discovered services (they're all built from the same commit).

**Step 2: Verify it compiles**

Run: `SQLX_OFFLINE=true cargo check -p api_gateway`

**Step 3: Commit**

```bash
git add services/api_gateway/src/grpc/monitoring_handler.rs
git commit -m "feat(api_gateway): populate ServiceStatus.version from build_info"
```

---

## Task 4: Cluster Resources Fix (DONE)

> Already completed during monitoring debugging session.

**Files changed:**
- `services/api_gateway/src/grpc/monitoring_handler.rs`: Added `query_cluster_resources()` method, wired into `build_response()` replacing hardcoded zeros.
- `infra/k8s/network-policies/prometheus.yaml`: Added port 9100 to `172.16.0.0/16` ipBlock, port 8080 to foxhunt podSelector, port 9400 to kube-system namespace.
- `infra/k8s/network-policies/prometheus-cilium.yaml` (NEW): CiliumNetworkPolicy for Prometheus → hostNetwork pods (node-exporter).

**Verify: Already verified** — Prometheus returns real CPU/RAM/GPU values. api_gateway monitoring tests pass (9/9).

**Commit:**

```bash
git add services/api_gateway/src/grpc/monitoring_handler.rs
git add infra/k8s/network-policies/prometheus.yaml
git add infra/k8s/network-policies/prometheus-cilium.yaml
git commit -m "fix(monitoring): wire cluster resources, fix Prometheus networking"
```

---

## Task 5: Proto — `SubscribeClusterPods` RPC

**Files:**
- Modify: `proto/monitoring.proto`

**Step 1: Add messages and RPC**

After the `GetEpochHistory` RPC (line 54), add:

```protobuf
  // Kubernetes pod status — streams every N seconds
  rpc SubscribeClusterPods(SubscribeClusterPodsRequest) returns (stream ClusterPodsResponse);
```

After `GetEpochHistoryResponse` (line 425), add:

```protobuf
// ============================================================================
// Cluster Pod Messages
// ============================================================================

message SubscribeClusterPodsRequest {
  uint32 interval_seconds = 1;  // 0 = server default (5s)
}

message ClusterPodsResponse {
  repeated PodInfo pods = 1;
  int64 timestamp = 2;
}

message PodInfo {
  string name = 1;
  string service = 2;         // app.kubernetes.io/name label
  string namespace = 3;
  string status = 4;          // Running/Pending/CrashLoopBackOff/Completed/etc
  int32 restarts = 5;
  string age = 6;             // human-readable: "2d5h", "3m"
  string node = 7;
  bool ready = 8;
  string version = 9;         // from FOXHUNT_BUILD_VERSION env or empty
}
```

**Step 2: Verify proto compiles**

Run: `SQLX_OFFLINE=true cargo check -p api_gateway`
Expected: compile error for unimplemented `subscribe_cluster_pods` — that's expected, we implement it next.

**Step 3: Commit**

```bash
git add proto/monitoring.proto
git commit -m "feat(proto): add SubscribeClusterPods streaming RPC"
```

---

## Task 6: api_gateway — `kube` crate + pods handler

**Files:**
- Modify: `services/api_gateway/Cargo.toml`
- Create: `services/api_gateway/src/grpc/pods_handler.rs`
- Modify: `services/api_gateway/src/grpc/monitoring_handler.rs` (implement the new RPC)

**Step 1: Add `kube` dependency**

In `services/api_gateway/Cargo.toml`, add after the `redis` dependency:

```toml
# Kubernetes API client for pod listing
kube = { version = "0.99", features = ["client", "runtime", "derive"] }
k8s-openapi = { version = "0.24", features = ["latest"] }
```

**Step 2: Create pods_handler.rs**

```rust
// services/api_gateway/src/grpc/pods_handler.rs
//! Kubernetes pod listing for the Pods cockpit.

use anyhow::Result;
use k8s_openapi::api::core::v1::Pod;
use kube::{Api, Client};

/// Fetch all pods in a namespace and convert to proto `PodInfo`.
pub async fn list_pods(namespace: &str) -> Result<Vec<super::monitoring::PodInfo>> {
    let client = Client::try_default().await?;
    let pods: Api<Pod> = Api::namespaced(client, namespace);
    let pod_list = pods.list(&Default::default()).await?;

    let mut infos = Vec::with_capacity(pod_list.items.len());
    for pod in pod_list.items {
        let meta = &pod.metadata;
        let spec = pod.spec.as_ref();
        let status = pod.status.as_ref();

        let name = meta.name.clone().unwrap_or_default();
        let labels = meta.labels.as_ref();
        let service = labels
            .and_then(|l| l.get("app.kubernetes.io/name"))
            .cloned()
            .unwrap_or_default();

        let phase = status
            .and_then(|s| s.phase.clone())
            .unwrap_or_else(|| "Unknown".into());

        let ready = status
            .and_then(|s| s.conditions.as_ref())
            .map(|conds| conds.iter().any(|c| c.type_ == "Ready" && c.status == "True"))
            .unwrap_or(false);

        let restarts: i32 = status
            .and_then(|s| s.container_statuses.as_ref())
            .map(|cs| cs.iter().map(|c| c.restart_count).sum())
            .unwrap_or(0);

        let node = spec
            .and_then(|s| s.node_name.clone())
            .unwrap_or_default();

        let creation = meta.creation_timestamp.as_ref().map(|t| t.0);
        let age = creation
            .map(|t| format_age(t))
            .unwrap_or_else(|| "?".into());

        // Try to get version from FOXHUNT_BUILD_VERSION env var in first container
        let version = spec
            .and_then(|s| s.containers.first())
            .and_then(|c| c.env.as_ref())
            .and_then(|envs| {
                envs.iter()
                    .find(|e| e.name == "FOXHUNT_BUILD_VERSION")
                    .and_then(|e| e.value.clone())
            })
            .unwrap_or_default();

        infos.push(super::monitoring::PodInfo {
            name,
            service,
            namespace: namespace.into(),
            status: phase,
            restarts,
            age,
            node: short_node_name(&node),
            ready,
            version,
        });
    }

    // Sort by service name, then pod name
    infos.sort_by(|a, b| (&a.service, &a.name).cmp(&(&b.service, &b.name)));
    Ok(infos)
}

fn short_node_name(full: &str) -> String {
    // scw-foxhunt-foxhunt-e4027b8e... → foxhunt
    // scw-foxhunt-ci-training-h100-... → ci-training-h100
    full.strip_prefix("scw-foxhunt-")
        .unwrap_or(full)
        .split('-')
        .take(3)
        .collect::<Vec<_>>()
        .join("-")
        .chars()
        .take(20)
        .collect()
}

fn format_age(created: chrono::DateTime<chrono::Utc>) -> String {
    let dur = chrono::Utc::now() - created;
    let secs = dur.num_seconds();
    if secs < 60 {
        format!("{secs}s")
    } else if secs < 3600 {
        format!("{}m", secs / 60)
    } else if secs < 86400 {
        format!("{}h{}m", secs / 3600, (secs % 3600) / 60)
    } else {
        format!("{}d{}h", secs / 86400, (secs % 86400) / 3600)
    }
}
```

**Step 3: Wire into monitoring_handler.rs**

Add the `subscribe_cluster_pods` implementation to the `MonitoringService` impl block. This follows the same streaming pattern as `stream_training_metrics`:

```rust
type SubscribeClusterPodsStream =
    Pin<Box<dyn Stream<Item = Result<ClusterPodsResponse, Status>> + Send>>;

async fn subscribe_cluster_pods(
    &self,
    request: Request<SubscribeClusterPodsRequest>,
) -> Result<Response<Self::SubscribeClusterPodsStream>, Status> {
    let req = request.into_inner();
    let interval_secs = if req.interval_seconds == 0 { 5 } else { req.interval_seconds.clamp(1, 60) };

    let stream = async_stream::try_stream! {
        let mut interval = tokio::time::interval(Duration::from_secs(u64::from(interval_secs)));
        loop {
            interval.tick().await;
            match pods_handler::list_pods("foxhunt").await {
                Ok(pods) => {
                    yield ClusterPodsResponse {
                        pods,
                        timestamp: chrono::Utc::now().timestamp(),
                    };
                }
                Err(e) => {
                    tracing::warn!("Pod listing failed: {e}");
                    yield ClusterPodsResponse {
                        pods: vec![],
                        timestamp: chrono::Utc::now().timestamp(),
                    };
                }
            }
        }
    };

    Ok(Response::new(Box::pin(stream)))
}
```

**Step 4: Verify it compiles**

Run: `SQLX_OFFLINE=true cargo check -p api_gateway`

**Step 5: Commit**

```bash
git add services/api_gateway/Cargo.toml
git add services/api_gateway/src/grpc/pods_handler.rs
git add services/api_gateway/src/grpc/monitoring_handler.rs
git commit -m "feat(api_gateway): implement SubscribeClusterPods with kube crate"
```

---

## Task 7: RBAC for api_gateway pod listing

**Files:**
- Modify: `infra/k8s/services/api-gateway.yaml` (or create separate RBAC file)

**Step 1: Add Role + RoleBinding**

Create or append to the api_gateway K8s resources:

```yaml
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: api-gateway-pod-reader
  namespace: foxhunt
rules:
  - apiGroups: [""]
    resources: [pods]
    verbs: [get, list, watch]
  - apiGroups: [metrics.k8s.io]
    resources: [pods]
    verbs: [get, list]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: api-gateway-pod-reader
  namespace: foxhunt
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: api-gateway-pod-reader
subjects:
  - kind: ServiceAccount
    name: api-gateway
    namespace: foxhunt
```

**Step 2: Apply**

Run: `kubectl apply -f infra/k8s/services/api-gateway.yaml`

**Step 3: Verify**

Run: `kubectl auth can-i list pods -n foxhunt --as=system:serviceaccount:foxhunt:api-gateway`
Expected: `yes`

**Step 4: Commit**

```bash
git add infra/k8s/services/api-gateway.yaml
git commit -m "feat(infra): RBAC for api_gateway pod listing"
```

---

## Task 8: api_gateway NetworkPolicy — K8s API egress

**Files:**
- Modify: `infra/k8s/network-policies/api-gateway.yaml`

**Step 1: Add K8s API egress**

Add egress rule to allow api_gateway → K8s API for pod listing:

```yaml
    # Kubernetes API (pod listing for Pods cockpit)
    - to:
        - ipBlock:
            cidr: 10.32.0.0/12
      ports:
        - protocol: TCP
          port: 443
    - to:
        - ipBlock:
            cidr: 172.16.0.0/16
      ports:
        - protocol: TCP
          port: 6443
```

**Step 2: Apply and verify**

Run: `kubectl apply -f infra/k8s/network-policies/api-gateway.yaml`
Then: `kubectl exec deploy/api-gateway -n foxhunt -- wget -qO- --timeout=3 https://kubernetes.default.svc/api/v1/namespaces/foxhunt/pods 2>&1 | head -5`

**Step 3: Commit**

```bash
git add infra/k8s/network-policies/api-gateway.yaml
git commit -m "feat(infra): api-gateway K8s API egress for pod listing"
```

---

## Task 9: TUI — PodData structs + StateUpdate variant

**Files:**
- Modify: `bin/fxt/src/tui/state.rs`
- Modify: `bin/fxt/src/tui/data_fetcher.rs`
- Modify: `bin/fxt/src/tui/event_loop.rs`

**Step 1: Add pod data structs to `state.rs`**

After `AlertData` struct (around line 312):

```rust
/// A single K8s pod.
pub struct PodData {
    pub name: String,
    pub service: String,
    pub status: String,
    pub restarts: i32,
    pub age: String,
    pub node: String,
    pub ready: bool,
    pub version: String,
}

/// Pods grouped by service.
pub struct PodGroupData {
    pub service: String,
    pub pods: Vec<PodData>,
    pub ready_count: usize,
    pub total_count: usize,
}
```

Add `pods: Vec<PodGroupData>` field to `AppState` struct, and `pods: Vec::new()` to its Default impl. Also add `selected_pod_group: Option<usize>` and `pod_group_expanded: bool` for navigation.

**Step 2: Add `StateUpdate::Pods` variant to `data_fetcher.rs`**

```rust
/// Cluster pod statuses (from SubscribeClusterPods).
Pods(Vec<PodGroupData>),
```

**Step 3: Handle in `apply_update` in `event_loop.rs`**

```rust
StateUpdate::Pods(pods) => {
    state.pods = pods;
}
```

**Step 4: Verify it compiles**

Run: `SQLX_OFFLINE=true cargo check -p fxt`

**Step 5: Commit**

```bash
git add bin/fxt/src/tui/state.rs bin/fxt/src/tui/data_fetcher.rs bin/fxt/src/tui/event_loop.rs
git commit -m "feat(fxt): add PodData/PodGroupData structs and StateUpdate::Pods"
```

---

## Task 10: TUI — `spawn_pods_stream` in data_fetcher

**Files:**
- Modify: `bin/fxt/src/tui/data_fetcher.rs`

**Step 1: Add `spawn_pods_stream` method**

Follow the same pattern as `spawn_training_metrics`. The stream subscribes to `SubscribeClusterPods` and groups pods by service:

```rust
fn spawn_pods_stream(&self, client: &FoxhuntClient) {
    let mut mon = client.monitoring();
    let tx = self.tx.clone();
    let cancel = self.cancel.clone();

    tokio::spawn(async move {
        let mut backoff = Backoff::new();
        loop {
            let req = Request::new(monitoring::SubscribeClusterPodsRequest {
                interval_seconds: 5,
            });
            match mon.subscribe_cluster_pods(req).await {
                Ok(resp) => {
                    backoff.reset();
                    let mut stream = resp.into_inner();
                    loop {
                        tokio::select! {
                            _ = cancel.cancelled() => return,
                            msg = stream.message() => {
                                match msg {
                                    Ok(Some(m)) => {
                                        let groups = group_pods(m.pods);
                                        let _ = tx.send(StateUpdate::Pods(groups));
                                    }
                                    Ok(None) => break,
                                    Err(e) => {
                                        warn!("Pods stream error: {e}");
                                        break;
                                    }
                                }
                            }
                        }
                    }
                }
                Err(e) => {
                    warn!("Pods stream connect failed: {e}");
                }
            }
            if backoff.attempts >= MAX_RECONNECT_ATTEMPTS { return; }
            tokio::select! {
                _ = cancel.cancelled() => return,
                _ = tokio::time::sleep(backoff.next_delay()) => {}
            }
        }
    });
}
```

Add a `group_pods` helper:

```rust
fn group_pods(pods: Vec<monitoring::PodInfo>) -> Vec<PodGroupData> {
    use std::collections::BTreeMap;
    let mut groups: BTreeMap<String, Vec<PodData>> = BTreeMap::new();
    for p in pods {
        let svc = if p.service.is_empty() { "(other)".into() } else { p.service.clone() };
        groups.entry(svc).or_default().push(PodData {
            name: p.name,
            service: p.service,
            status: p.status,
            restarts: p.restarts,
            age: p.age,
            node: p.node,
            ready: p.ready,
            version: p.version,
        });
    }
    groups.into_iter().map(|(service, pods)| {
        let ready_count = pods.iter().filter(|p| p.ready).count();
        let total_count = pods.len();
        PodGroupData { service, pods, ready_count, total_count }
    }).collect()
}
```

**Step 2: Register in `start()` method**

Add `self.spawn_pods_stream(client);` to the `start()` method.

**Step 3: Verify it compiles**

Run: `SQLX_OFFLINE=true cargo check -p fxt`

**Step 4: Commit**

```bash
git add bin/fxt/src/tui/data_fetcher.rs
git commit -m "feat(fxt): add spawn_pods_stream with service grouping"
```

---

## Task 11: TUI — Pods Cockpit

**Files:**
- Create: `bin/fxt/src/tui/cockpits/pods.rs`
- Modify: `bin/fxt/src/tui/cockpits/mod.rs`

**Step 1: Create `pods.rs`**

```rust
//! Pods cockpit — Kubernetes pod status grouped by service.

use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Row, Table};

use super::super::state::AppState;
use super::super::theme;
use super::Cockpit;

pub struct PodsCockpit;

impl Cockpit for PodsCockpit {
    fn name(&self) -> &str {
        "Pods"
    }

    fn render(&self, frame: &mut ratatui::Frame, area: Rect, state: &AppState) {
        let block = Block::default()
            .borders(Borders::ALL)
            .title(" Cluster Pods ")
            .title_style(theme::title_style())
            .border_style(Style::default().fg(theme::BORDER));

        if state.pods.is_empty() {
            let msg = Paragraph::new("Waiting for pod data...")
                .style(theme::muted_style())
                .block(block);
            frame.render_widget(msg, area);
            return;
        }

        let header = Row::new(vec![
            "Service", "Ready", "Status", "Version", "Restarts", "Age", "Node",
        ])
        .style(Style::default().fg(theme::SECONDARY).add_modifier(Modifier::BOLD));

        let mut rows = Vec::new();
        for group in &state.pods {
            // Summary row for the service group
            let ready_str = format!("{}/{}", group.ready_count, group.total_count);
            let status = if group.ready_count == group.total_count {
                Span::styled("Running", Style::default().fg(theme::SUCCESS))
            } else if group.ready_count == 0 {
                Span::styled("Down", Style::default().fg(theme::ERROR))
            } else {
                Span::styled("Degraded", Style::default().fg(theme::WARNING))
            };

            let version = group.pods.first()
                .map(|p| p.version.as_str())
                .unwrap_or("");

            let total_restarts: i32 = group.pods.iter().map(|p| p.restarts).sum();
            let restart_style = if total_restarts > 0 {
                Style::default().fg(theme::WARNING)
            } else {
                theme::muted_style()
            };

            rows.push(Row::new(vec![
                Line::from(Span::styled(
                    &group.service,
                    Style::default().fg(theme::PRIMARY).add_modifier(Modifier::BOLD),
                )),
                Line::from(ready_str),
                Line::from(status),
                Line::from(Span::styled(version, theme::muted_style())),
                Line::from(Span::styled(
                    format!("{total_restarts}"),
                    restart_style,
                )),
                Line::from(Span::styled(
                    group.pods.first().map(|p| p.age.as_str()).unwrap_or(""),
                    theme::muted_style(),
                )),
                Line::from(Span::styled(
                    group.pods.first().map(|p| p.node.as_str()).unwrap_or(""),
                    theme::muted_style(),
                )),
            ]));

            // Expanded: show individual pods if more than 1 in group
            if group.pods.len() > 1 {
                for pod in &group.pods {
                    let pod_status_style = if pod.ready {
                        Style::default().fg(theme::SUCCESS)
                    } else {
                        Style::default().fg(theme::ERROR)
                    };
                    let pod_restart_style = if pod.restarts > 0 {
                        Style::default().fg(theme::WARNING)
                    } else {
                        theme::muted_style()
                    };
                    rows.push(Row::new(vec![
                        Line::from(Span::styled(
                            format!("  {}", short_pod_name(&pod.name)),
                            theme::muted_style(),
                        )),
                        Line::from(if pod.ready { "✓" } else { "✗" }),
                        Line::from(Span::styled(&pod.status, pod_status_style)),
                        Line::from(""),
                        Line::from(Span::styled(
                            format!("{}", pod.restarts),
                            pod_restart_style,
                        )),
                        Line::from(Span::styled(&pod.age, theme::muted_style())),
                        Line::from(Span::styled(&pod.node, theme::muted_style())),
                    ]));
                }
            }
        }

        let widths = [
            Constraint::Length(28), // Service
            Constraint::Length(7),  // Ready
            Constraint::Length(12), // Status
            Constraint::Length(14), // Version
            Constraint::Length(9),  // Restarts
            Constraint::Length(8),  // Age
            Constraint::Fill(1),   // Node
        ];

        let table = Table::new(rows, widths)
            .header(header)
            .block(block);

        frame.render_widget(table, area);
    }
}

/// Shorten pod names: `api-gateway-7686d84bdd-zl4mn` → `zl4mn`
fn short_pod_name(name: &str) -> &str {
    name.rsplit('-').next().unwrap_or(name)
}
```

**Step 2: Register in `cockpits/mod.rs`**

```rust
pub mod pods;
```

**Step 3: Verify it compiles**

Run: `SQLX_OFFLINE=true cargo check -p fxt`

**Step 4: Commit**

```bash
git add bin/fxt/src/tui/cockpits/pods.rs bin/fxt/src/tui/cockpits/mod.rs
git commit -m "feat(fxt): add Pods cockpit with service-grouped table"
```

---

## Task 12: TUI — Wire Cockpit 7 into Event Loop

**Files:**
- Modify: `bin/fxt/src/tui/event_loop.rs`

**Step 1: Update COCKPIT_COUNT**

```rust
const COCKPIT_COUNT: usize = 7;
```

**Step 2: Add PodsCockpit to cockpits vec**

```rust
use super::cockpits::pods::PodsCockpit;

// In event_loop():
let cockpits: Vec<Box<dyn Cockpit>> = vec![
    Box::new(OverviewCockpit),
    Box::new(TrainingCockpit),
    Box::new(TradingCockpit),
    Box::new(ServicesCockpit),
    Box::new(RiskCockpit),
    Box::new(DataCockpit),
    Box::new(PodsCockpit),
];
```

**Step 3: Update key handler range**

Change:
```rust
KeyCode::Char(c @ '1'..='6') => {
```
To:
```rust
KeyCode::Char(c @ '1'..='7') => {
```

**Step 4: Update status bar**

In `render_status_bar`, change `"1-6"` to `"1-7"`:
```rust
Span::styled("1-7", Style::default().fg(theme::SECONDARY)),
```

**Step 5: Update help overlay**

Update the cockpit list text:
```rust
Line::from(vec![Span::styled(
    "  Cockpits: Overview | Training | Trading",
    theme::muted_style(),
)]),
Line::from(vec![Span::styled(
    "            Services | Risk     | Data | Pods",
    theme::muted_style(),
)]),
```

**Step 6: Update AppState doc comment**

In `state.rs`, change `Active cockpit index (0..=5)` to `Active cockpit index (0..=6)` and `six cockpit views` to `seven cockpit views`.

**Step 7: Verify it compiles**

Run: `SQLX_OFFLINE=true cargo check -p fxt`

**Step 8: Run tests**

Run: `SQLX_OFFLINE=true cargo test -p fxt --lib`

**Step 9: Commit**

```bash
git add bin/fxt/src/tui/event_loop.rs bin/fxt/src/tui/state.rs
git commit -m "feat(fxt): wire Pods cockpit as key 7, update event loop"
```

---

## Task 13: Final Integration Build + Deploy

**Step 1: Full workspace check**

Run: `SQLX_OFFLINE=true cargo check --workspace`

**Step 2: Run api_gateway + fxt tests**

Run: `SQLX_OFFLINE=true cargo test -p api_gateway --lib && SQLX_OFFLINE=true cargo test -p fxt --lib`

**Step 3: Commit any remaining changes**

```bash
git status
git add -A
git commit -m "chore: final integration for pods panel + CalVer + monitoring fixes"
```

**Step 4: Push and trigger CI**

Push branch `worktree-pods-versioning` to GitLab to trigger a CI pipeline.

---

## Dependencies Graph

```
Task 1 (build_info) ─┬─→ Task 3 (version in ServiceStatus)
                      └─→ Task 2 (CI version)
Task 4 (cluster resources) ── already done
Task 5 (proto) ──→ Task 6 (pods handler) ──→ Task 7 (RBAC) + Task 8 (NetworkPolicy)
Task 9 (state structs) ──→ Task 10 (data fetcher) ──→ Task 11 (cockpit) ──→ Task 12 (event loop)
All ──→ Task 13 (integration)
```
