tui: tighten panel dashboard readiness

This commit is contained in:
2026-06-18 23:39:52 +09:00
parent fc1ee5bb55
commit 5870251bdf
4 changed files with 709 additions and 65 deletions
+154 -35
View File
@@ -44,15 +44,19 @@ use crate::pod_list::{
use crate::role_session_registry::{
PanelRegistryStore, RelatedTicketRef, RoleSessionOrigin, TicketClaimResult,
};
#[cfg(not(feature = "e2e-test"))]
use crate::workspace_panel::build_workspace_panel;
#[cfg(feature = "e2e-test")]
use crate::workspace_panel::build_workspace_panel_with_e2e_timings;
use crate::workspace_panel::{
ActionPriority, CompanionLifecyclePlan, CompanionPanelState, CompanionPanelStatus,
CompanionPodPresence, ComposerTarget, NextUserAction, OrchestratorLifecyclePlan,
OrchestratorPanelState, OrchestratorPanelStatus, OrchestratorPodPresence, PanelRow,
PanelRowKey, PanelRowKind, TicketConfigAvailability, TicketLocalClaimStatus,
WorkspacePanelViewModel, bounded_panel_diagnostic, build_current_ticket_row,
build_workspace_panel, companion_pod_presence, decide_companion_lifecycle,
decide_orchestrator_lifecycle, local_claim_status_for_pod, orchestrator_pod_presence,
ticket_config_availability, workspace_companion_pod_name, workspace_orchestrator_pod_name,
companion_pod_presence, decide_companion_lifecycle, decide_orchestrator_lifecycle,
local_claim_status_for_pod, orchestrator_pod_presence, ticket_config_availability,
workspace_companion_pod_name, workspace_orchestrator_pod_name,
};
const MAX_ENTRIES: usize = 50;
@@ -947,6 +951,10 @@ struct PanelE2eRenderedRow {
title: String,
status: Option<String>,
action: Option<&'static str>,
disabled_reason: Option<String>,
local_state: Option<String>,
overlay_state: Option<String>,
overlay_detail: Option<String>,
rect: PanelE2eRect,
}
@@ -954,16 +962,45 @@ struct PanelE2eRenderedRow {
#[derive(Debug, Clone, Serialize)]
struct PanelE2eRowsRendered {
selected: Option<PanelE2eRowKey>,
header: PanelE2eDashboardHeader,
rows: Vec<PanelE2eRenderedRow>,
}
#[cfg(feature = "e2e-test")]
#[derive(Debug, Clone, Serialize)]
struct PanelE2eDashboardHeader {
ticket_configured: bool,
companion: Option<PanelE2eCompanionState>,
orchestrator: Option<PanelE2eOrchestratorState>,
diagnostics: Vec<String>,
}
#[cfg(feature = "e2e-test")]
#[derive(Debug, Clone, Serialize)]
struct PanelE2eCompanionState {
pod_name: String,
status: &'static str,
}
#[cfg(feature = "e2e-test")]
#[derive(Debug, Clone, Serialize)]
struct PanelE2eOrchestratorState {
pod_name: String,
status: &'static str,
detail: Option<String>,
}
#[cfg(feature = "e2e-test")]
#[derive(Debug, Serialize)]
struct PanelE2eDashboardContentReady {
ticket_configured: bool,
selected: Option<PanelE2eRowKey>,
snapshot: PanelE2eDashboardSnapshot,
categories: PanelE2eDashboardCategories,
diagnostics: Vec<String>,
}
#[cfg(feature = "e2e-test")]
#[derive(Debug, Clone, Serialize)]
struct PanelE2eDashboardSnapshot {
header: PanelE2eDashboardHeader,
rows: Vec<PanelE2eRenderedRow>,
}
@@ -1035,26 +1072,68 @@ fn panel_e2e_dashboard_categories(rows: &[PanelE2eRenderedRow]) -> PanelE2eDashb
ticket_rows: rows.iter().filter(|row| row.key.kind == "ticket").count(),
ready_ticket_rows: rows
.iter()
.filter(|row| row.key.kind == "ticket" && row.status.as_deref() == Some("ready"))
.filter(|row| row.key.kind == "ticket" && row.local_state.as_deref() == Some("ready"))
.count(),
planning_ticket_rows: rows
.iter()
.filter(|row| row.key.kind == "ticket" && row.status.as_deref() == Some("planning"))
.filter(|row| {
row.key.kind == "ticket" && row.local_state.as_deref() == Some("planning")
})
.count(),
pod_rows: rows.iter().filter(|row| row.key.kind == "pod").count(),
actionable_rows: rows.iter().filter(|row| row.action.is_some()).count(),
}
}
#[cfg(feature = "e2e-test")]
fn panel_e2e_dashboard_header(panel: &WorkspacePanelViewModel) -> PanelE2eDashboardHeader {
PanelE2eDashboardHeader {
ticket_configured: panel.header.ticket_configured,
companion: panel
.header
.companion
.as_ref()
.map(|state| PanelE2eCompanionState {
pod_name: state.pod_name.clone(),
status: state.status.label(),
}),
orchestrator: panel
.header
.orchestrator
.as_ref()
.map(|state| PanelE2eOrchestratorState {
pod_name: state.pod_name.clone(),
status: state.status.label(),
detail: state.detail.clone(),
}),
diagnostics: panel.header.diagnostics.clone(),
}
}
#[cfg(feature = "e2e-test")]
fn panel_e2e_dashboard_content_is_ready(
ticket_configured: bool,
snapshot: &PanelE2eDashboardSnapshot,
categories: &PanelE2eDashboardCategories,
) -> bool {
ticket_configured
snapshot.header.ticket_configured
&& snapshot.header.companion.is_some()
&& snapshot.header.orchestrator.is_some()
&& categories.ready_ticket_rows > 0
&& categories.planning_ticket_rows > 0
&& categories.pod_rows > 0
&& snapshot.rows.iter().any(|row| {
row.key.kind == "ticket"
&& row.local_state.as_deref() == Some("ready")
&& row.overlay_state.is_some()
&& row.action.is_some()
&& row.disabled_reason.is_some()
})
&& snapshot.rows.iter().any(|row| {
row.key.kind == "ticket"
&& row.local_state.as_deref() == Some("planning")
&& row.action.is_some()
&& row.disabled_reason.is_some()
})
}
pub(crate) struct MultiPodApp {
@@ -1430,19 +1509,46 @@ impl MultiPodApp {
.iter()
.map(|hit| {
let panel_row = self.panel.row(&hit.key);
let (title, status, action) = match panel_row {
Some(row) => (
row.title.clone(),
Some(row.status.clone()),
row.next_action.map(NextUserAction::label),
),
let (
title,
status,
action,
disabled_reason,
local_state,
overlay_state,
overlay_detail,
) = match panel_row {
Some(row) => {
let ticket = row.ticket.as_ref();
(
row.title.clone(),
Some(row.status.clone()),
row.next_action.map(NextUserAction::label),
row.disabled_reason.clone(),
ticket.map(|ticket| ticket.workflow_state.as_str().to_string()),
ticket
.and_then(|ticket| ticket.orchestration_overlay.as_ref())
.map(|overlay| overlay.workflow_state.as_str().to_string()),
ticket
.and_then(|ticket| ticket.orchestration_overlay.as_ref())
.map(|overlay| {
format!(
"{}:{}",
overlay.source,
overlay.workflow_state.as_str()
)
}),
)
}
None => match &hit.key {
PanelRowKey::Pod(name) => (name.clone(), None, None),
PanelRowKey::Pod(name) => {
(name.clone(), None, None, None, None, None, None)
}
PanelRowKey::Ticket(id) | PanelRowKey::InvalidTicket(id) => {
(id.clone(), None, None)
(id.clone(), None, None, None, None, None, None)
}
PanelRowKey::TicketIntakePod { pod_name, .. } => {
(pod_name.clone(), None, None)
(pod_name.clone(), None, None, None, None, None, None)
}
},
};
@@ -1451,34 +1557,35 @@ impl MultiPodApp {
title,
status,
action,
disabled_reason,
local_state,
overlay_state,
overlay_detail,
rect: panel_e2e_rect(hit.rect),
}
})
.collect();
let selected = self.selected_row.as_ref().map(panel_e2e_row_key);
let header = panel_e2e_dashboard_header(&self.panel);
crate::e2e_observer::emit(
"panel",
"rows_rendered",
PanelE2eRowsRendered {
selected: selected.clone(),
header: header.clone(),
rows: rows.clone(),
},
);
if !self.emitted_dashboard_content_ready {
let categories = panel_e2e_dashboard_categories(&rows);
if panel_e2e_dashboard_content_is_ready(
self.panel.header.ticket_configured,
&categories,
) {
let snapshot = PanelE2eDashboardSnapshot { header, rows };
if panel_e2e_dashboard_content_is_ready(&snapshot, &categories) {
crate::e2e_observer::emit(
"panel",
"dashboard_content_ready",
PanelE2eDashboardContentReady {
ticket_configured: self.panel.header.ticket_configured,
selected,
snapshot,
categories,
diagnostics: self.panel.header.diagnostics.clone(),
rows,
},
);
self.emitted_dashboard_content_ready = true;
@@ -2390,7 +2497,7 @@ async fn load_multi_pod_snapshot(
let mut list = load_pod_list(list_selected_name.clone(), MAX_ENTRIES).await?;
#[cfg(feature = "e2e-test")]
source_timings.push(PanelE2eSourceTiming {
source: "pod_list.initial",
source: "pod_metadata_status_probe.initial",
elapsed_ms: source_started.elapsed().as_millis(),
});
@@ -2430,7 +2537,7 @@ async fn load_multi_pod_snapshot(
list = load_pod_list(list_selected_name.clone(), MAX_ENTRIES).await?;
#[cfg(feature = "e2e-test")]
source_timings.push(PanelE2eSourceTiming {
source: "pod_list.after_companion_reload",
source: "pod_metadata_status_probe.after_companion_reload",
elapsed_ms: source_started.elapsed().as_millis(),
});
}
@@ -2440,7 +2547,7 @@ async fn load_multi_pod_snapshot(
let config = ticket_config_availability(&workspace_root);
#[cfg(feature = "e2e-test")]
source_timings.push(PanelE2eSourceTiming {
source: "ticket_config",
source: "ticket_config_probe",
elapsed_ms: source_started.elapsed().as_millis(),
});
@@ -2487,23 +2594,35 @@ async fn load_multi_pod_snapshot(
list = load_pod_list(list_selected_name, MAX_ENTRIES).await?;
#[cfg(feature = "e2e-test")]
source_timings.push(PanelE2eSourceTiming {
source: "pod_list.after_orchestrator_reload",
source: "pod_metadata_status_probe.after_orchestrator_reload",
elapsed_ms: source_started.elapsed().as_millis(),
});
}
#[cfg(feature = "e2e-test")]
let source_started = Instant::now();
#[cfg(feature = "e2e-test")]
let (mut panel, panel_source_timings) =
build_workspace_panel_with_e2e_timings(&workspace_root, &list);
#[cfg(not(feature = "e2e-test"))]
let mut panel = build_workspace_panel(&workspace_root, &list);
panel.header.companion = companion.state;
panel.header.diagnostics.extend(companion.diagnostics);
panel.header.orchestrator = orchestrator.state;
panel.header.diagnostics.extend(orchestrator.diagnostics);
#[cfg(feature = "e2e-test")]
source_timings.push(PanelE2eSourceTiming {
source: "workspace_panel.build",
elapsed_ms: source_started.elapsed().as_millis(),
});
{
source_timings.push(PanelE2eSourceTiming {
source: "workspace_panel.build.total",
elapsed_ms: source_started.elapsed().as_millis(),
});
source_timings.extend(panel_source_timings.into_iter().map(|timing| {
PanelE2eSourceTiming {
source: timing.source,
elapsed_ms: timing.elapsed_ms,
}
}));
}
#[cfg(feature = "e2e-test")]
crate::e2e_observer::emit(
+145
View File
@@ -1,6 +1,8 @@
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
#[cfg(feature = "e2e-test")]
use std::time::Instant;
use protocol::PodStatus;
use ticket::config::{
@@ -731,6 +733,7 @@ fn git_output(worktree_root: &Path, args: &[&str]) -> Result<String, String> {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
#[cfg_attr(feature = "e2e-test", allow(dead_code))]
pub(crate) fn build_workspace_panel(
workspace_root: &Path,
pods: &PodList,
@@ -758,6 +761,148 @@ pub(crate) fn build_workspace_panel(
build_workspace_panel_with_registry(workspace_root, pods, &registry)
}
#[cfg(feature = "e2e-test")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct WorkspacePanelE2eSourceTiming {
pub(crate) source: &'static str,
pub(crate) elapsed_ms: u128,
}
#[cfg(feature = "e2e-test")]
pub(crate) fn build_workspace_panel_with_e2e_timings(
workspace_root: &Path,
pods: &PodList,
) -> (WorkspacePanelViewModel, Vec<WorkspacePanelE2eSourceTiming>) {
let mut timings = Vec::new();
let started = Instant::now();
let registry = match PanelRegistryStore::default_for_workspace(workspace_root)
.and_then(|store| store.snapshot())
{
Ok(snapshot) => snapshot,
Err(error) => {
timings.push(WorkspacePanelE2eSourceTiming {
source: "local_claim_scan",
elapsed_ms: started.elapsed().as_millis(),
});
let mut model = WorkspacePanelViewModel::empty(workspace_root);
model
.header
.diagnostics
.push(bounded_panel_diagnostic(format!(
"Panel local role registry unavailable: {error}"
)));
return (
build_workspace_panel_with_registry_model(
model,
workspace_root,
pods,
&PanelRegistrySnapshot::empty(),
),
timings,
);
}
};
timings.push(WorkspacePanelE2eSourceTiming {
source: "local_claim_scan",
elapsed_ms: started.elapsed().as_millis(),
});
let mut model = WorkspacePanelViewModel::empty(workspace_root);
let started = Instant::now();
let availability = ticket_config_availability(workspace_root);
timings.push(WorkspacePanelE2eSourceTiming {
source: "ticket_config_probe",
elapsed_ms: started.elapsed().as_millis(),
});
match availability {
TicketConfigAvailability::Absent => {}
TicketConfigAvailability::Usable => {
model.header.ticket_configured = true;
model.composer = WorkspacePanelComposer::ticket_enabled();
let started = Instant::now();
match TicketConfig::load_workspace(workspace_root) {
Ok(config) => {
timings.push(WorkspacePanelE2eSourceTiming {
source: "ticket_config_parse",
elapsed_ms: started.elapsed().as_millis(),
});
model.header.ticket_root = config.backend_root().to_path_buf();
let backend = LocalTicketBackend::new(config.backend_root().to_path_buf())
.with_record_language(config.ticket_record_language());
let started = Instant::now();
let orchestration_overlay =
load_orchestration_ticket_overlay(workspace_root, &config);
timings.push(WorkspacePanelE2eSourceTiming {
source: "orchestration_overlay_validation_read_git",
elapsed_ms: started.elapsed().as_millis(),
});
let started = Instant::now();
match build_ticket_rows(
&backend,
pods,
&registry,
&orchestration_overlay.states,
) {
Ok(ticket_rows) => {
timings.push(WorkspacePanelE2eSourceTiming {
source: "ticket_scan_parse",
elapsed_ms: started.elapsed().as_millis(),
});
model.rows.extend(ticket_rows.rows);
model.header.diagnostics.extend(ticket_rows.diagnostics);
model
.header
.diagnostics
.extend(orchestration_overlay.diagnostics);
}
Err(error) => {
timings.push(WorkspacePanelE2eSourceTiming {
source: "ticket_scan_parse",
elapsed_ms: started.elapsed().as_millis(),
});
model
.header
.diagnostics
.push(bounded_panel_diagnostic(format!(
"Ticket rows unavailable: {error}"
)))
}
}
}
Err(error) => {
timings.push(WorkspacePanelE2eSourceTiming {
source: "ticket_config_parse",
elapsed_ms: started.elapsed().as_millis(),
});
model
.header
.diagnostics
.push(bounded_panel_diagnostic(format!(
"Ticket config is unusable: {error}"
)))
}
}
}
TicketConfigAvailability::Unusable(message) => {
model.header.ticket_configured = true;
model
.header
.diagnostics
.push(bounded_panel_diagnostic(format!(
"Ticket config is unusable: {message}"
)));
}
}
let started = Instant::now();
model.rows.extend(pod_rows(pods));
timings.push(WorkspacePanelE2eSourceTiming {
source: "pod_row_materialization",
elapsed_ms: started.elapsed().as_millis(),
});
(model, timings)
}
fn build_workspace_panel_with_registry(
workspace_root: &Path,
pods: &PodList,