Files
foxhunt/docs/plans/2026-03-04-pods-panel-versioning.md
2026-03-04 13:17:41 +01:00

27 KiB

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

// 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:

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

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:

  # 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:

    - |
      # 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

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:

version: None, // or ..Default::default()

Replace with:

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

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:

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:

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

After GetEpochHistoryResponse (line 425), add:

// ============================================================================
// 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

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:

# 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

// 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:

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

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:

---
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

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:

    # 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

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):

/// 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

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

Step 3: Handle in apply_update in event_loop.rs

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

Step 4: Verify it compiles

Run: SQLX_OFFLINE=true cargo check -p fxt

Step 5: Commit

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:

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:

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

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

//! 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

pub mod pods;

Step 3: Verify it compiles

Run: SQLX_OFFLINE=true cargo check -p fxt

Step 4: Commit

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

const COCKPIT_COUNT: usize = 7;

Step 2: Add PodsCockpit to cockpits vec

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:

KeyCode::Char(c @ '1'..='6') => {

To:

KeyCode::Char(c @ '1'..='7') => {

Step 4: Update status bar

In render_status_bar, change "1-6" to "1-7":

Span::styled("1-7", Style::default().fg(theme::SECONDARY)),

Step 5: Update help overlay

Update the cockpit list text:

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

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

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)