1use serde::{Deserialize, Serialize};
15
16use crate::render::{Cell, Row, RowId};
17use crate::surface::{Column, ColumnKind};
18
19pub const LENS_SCHEMA_VERSION: u32 = 1;
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct GroupVersionKind {
27 #[serde(default)]
29 pub group: String,
30 pub version: String,
32 pub kind: String,
34}
35
36impl GroupVersionKind {
37 pub fn display(&self) -> String {
39 if self.group.is_empty() {
40 format!("{}/{}", self.version, self.kind)
41 } else {
42 format!("{}/{}/{}", self.group, self.version, self.kind)
43 }
44 }
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "snake_case")]
50pub enum RuleOp {
51 Eq,
53 Ne,
55 Gt,
57 Gte,
59 Lt,
61 Lte,
63 Contains,
65}
66
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74pub struct StatusRule {
75 pub field: String,
77 pub op: RuleOp,
79 pub value: serde_json::Value,
81 pub level: crate::render::StatusLevel,
83}
84
85#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95pub struct ConditionRule {
96 pub condition_type: String,
98 pub status: String,
101 pub level: crate::render::StatusLevel,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub struct LensAction {
110 pub id: String,
112 pub label_key: String,
114 #[serde(rename = "state", default = "default_action_state")]
117 pub state: String,
118}
119
120fn default_action_state() -> String {
121 "allowed".into()
122}
123
124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
126pub struct ViewDefinition {
127 pub id: String,
129 pub api_version: u32,
132 pub target: GroupVersionKind,
134 #[serde(default)]
136 pub columns: Vec<Column>,
137 #[serde(default)]
139 pub status: Vec<StatusRule>,
140 #[serde(default)]
143 pub conditions: Vec<ConditionRule>,
144 #[serde(default)]
146 pub actions: Vec<LensAction>,
147}
148
149pub fn validate_viewdef(vd: &ViewDefinition) -> Vec<String> {
154 let mut problems = Vec::new();
155
156 if vd.id.trim().is_empty() {
157 problems.push("id: must not be empty".into());
158 } else if !vd.id.contains('.') {
159 problems.push(format!(
160 "id {:?}: must be reverse-DNS (e.g. \"com.example.cnpg-lens\")",
161 vd.id
162 ));
163 }
164
165 if vd.api_version != LENS_SCHEMA_VERSION {
166 problems.push(format!(
167 "api_version: this release supports lens schema v{LENS_SCHEMA_VERSION}, but the \
168 lens declares v{} — a migration is required (docs/versioning.md)",
169 vd.api_version
170 ));
171 }
172
173 if vd.target.version.trim().is_empty() {
174 problems.push("target.version: must not be empty".into());
175 }
176 if vd.target.kind.trim().is_empty() {
177 problems.push("target.kind: must not be empty".into());
178 }
179
180 let mut seen = std::collections::HashSet::new();
182 for col in &vd.columns {
183 if col.id.trim().is_empty() {
184 problems.push("columns: a column has an empty id".into());
185 } else if !seen.insert(col.id.as_str()) {
186 problems.push(format!("columns: duplicate column id {:?}", col.id));
187 }
188 if !valid_header_key(&col.header_key) {
189 problems.push(format!(
190 "columns.{:?}: header_key must be a dotted i18n key (e.g. \"col.name\")",
191 col.id
192 ));
193 }
194 if col.kind != ColumnKind::Status && col.field.as_deref().is_none_or(str::is_empty) {
197 problems.push(format!(
198 "columns.{:?}: a non-status column needs a `field` (dotted JSON path) so \
199 its value is data-bound, not implicit (ADR-0012)",
200 col.id
201 ));
202 } else if let Some(field) = col.field.as_deref()
203 && !field.is_empty()
204 && !valid_field_path(field)
205 {
206 problems.push(format!(
207 "columns.{:?}.field {:?}: not a dotted JSON path",
208 col.id, field
209 ));
210 }
211 }
212
213 for (i, rule) in vd.status.iter().enumerate() {
215 if !valid_field_path(&rule.field) {
216 problems.push(format!(
217 "status[{i}].field {:?}: not a dotted JSON path",
218 rule.field
219 ));
220 }
221 match rule.op {
222 RuleOp::Gt | RuleOp::Gte | RuleOp::Lt | RuleOp::Lte => {
223 if !rule.value.is_number() {
224 problems.push(format!(
225 "status[{i}].value: a numeric operator ({:?}) needs a numeric value",
226 rule.op
227 ));
228 }
229 }
230 RuleOp::Contains => {
231 if !rule.value.is_string() {
232 problems.push(format!(
233 "status[{i}].value: `contains` needs a string value"
234 ));
235 }
236 }
237 RuleOp::Eq | RuleOp::Ne => {}
238 }
239 }
240
241 let mut seen_actions = std::collections::HashSet::new();
243 for action in &vd.actions {
244 if action.id.trim().is_empty() || !seen_actions.insert(action.id.as_str()) {
245 problems.push(format!(
246 "actions: duplicate or empty action id {:?}",
247 action.id
248 ));
249 }
250 }
251
252 for (i, rule) in vd.conditions.iter().enumerate() {
255 if rule.condition_type.trim().is_empty() {
256 problems.push(format!("conditions[{i}].condition_type: must not be empty"));
257 }
258 if !is_condition_status(&rule.status) {
259 problems.push(format!(
260 "conditions[{i}].status {:?}: must be one of \"True\", \"False\", \"Unknown\"",
261 rule.status
262 ));
263 }
264 }
265
266 problems
267}
268
269fn is_condition_status(status: &str) -> bool {
271 matches!(status, "True" | "False" | "Unknown")
272}
273
274fn valid_field_path(field: &str) -> bool {
277 let mut parts = field.split('.');
278 let Some(first) = parts.next() else {
279 return false;
280 };
281 if !is_identifier(first) {
282 return false;
283 }
284 parts.all(is_segment)
285}
286
287fn resolve_field<'a>(root: &'a serde_json::Value, field: &str) -> Option<&'a serde_json::Value> {
291 let mut cur = root;
292 for segment in field.split('.') {
293 let (ident, subscripts) = split_subscripts(segment);
295 cur = cur.get(ident)?;
296 for sub in subscripts {
297 cur = cur.get(sub)?;
298 }
299 }
300 Some(cur)
301}
302
303fn split_subscripts(segment: &str) -> (&str, Vec<usize>) {
306 let mut idx = segment.len();
307 let mut subs = Vec::new();
308 while idx > 0 && segment[..idx].ends_with(']') {
309 if let Some(open) = segment[..idx].rfind('[') {
310 let inside = &segment[open + 1..idx - 1];
311 if let Ok(n) = inside.parse::<usize>() {
312 subs.push(n);
313 }
314 idx = open;
315 } else {
316 break;
317 }
318 }
319 subs.reverse();
320 (&segment[..idx], subs)
321}
322
323pub fn evaluate_status(
331 vd: &ViewDefinition,
332 resource: &serde_json::Value,
333) -> Option<crate::render::StatusLevel> {
334 for rule in &vd.status {
335 if rule_matches(rule, resource) {
336 return Some(rule.level);
337 }
338 }
339 for rule in &vd.conditions {
340 if condition_matches(rule, resource) {
341 return Some(rule.level);
342 }
343 }
344 None
345}
346
347pub fn render_row(vd: &ViewDefinition, resource: &serde_json::Value) -> Row {
363 let id = resource
364 .get("metadata")
365 .and_then(|m| m.get("uid"))
366 .and_then(|u| u.as_str())
367 .map(|uid| RowId(uid.to_string()))
368 .unwrap_or_else(|| {
369 let name = resource
370 .get("metadata")
371 .and_then(|m| m.get("name"))
372 .and_then(|n| n.as_str())
373 .unwrap_or_default();
374 let ns = resource
375 .get("metadata")
376 .and_then(|m| m.get("namespace"))
377 .and_then(|n| n.as_str())
378 .unwrap_or_default();
379 RowId(if ns.is_empty() {
380 name.to_string()
381 } else {
382 format!("{ns}/{name}")
383 })
384 });
385
386 let cells = vd
387 .columns
388 .iter()
389 .map(|col| cell_for_column(col, resource, vd))
390 .collect();
391
392 Row { id, cells }
393}
394
395fn cell_for_column(col: &Column, resource: &serde_json::Value, vd: &ViewDefinition) -> Cell {
397 if col.kind == ColumnKind::Status {
398 let (level, label) = match evaluate_status(vd, resource) {
401 Some(level) => (level, level_label(level)),
402 None => (crate::render::StatusLevel::Info, "unknown".to_string()),
403 };
404 return Cell::Status {
405 level,
406 label_key: label,
407 };
408 }
409
410 let Some(field) = col.field.as_deref() else {
412 return empty_cell_for_kind(col.kind);
413 };
414 match resolve_field(resource, field) {
415 Some(serde_json::Value::Number(n)) if n.is_i64() => Cell::Number {
416 value: n.as_i64().unwrap_or(0),
417 },
418 Some(serde_json::Value::Number(n)) => Cell::Text {
419 value: n.to_string(),
420 },
421 Some(serde_json::Value::String(s)) => Cell::Text { value: s.clone() },
422 Some(serde_json::Value::Bool(b)) => Cell::Text {
423 value: b.to_string(),
424 },
425 Some(serde_json::Value::Null) | None => empty_cell_for_kind(col.kind),
426 Some(other) => Cell::Text {
427 value: other.to_string(),
428 },
429 }
430}
431
432fn empty_cell_for_kind(kind: ColumnKind) -> Cell {
434 match kind {
435 ColumnKind::Number => Cell::Number { value: 0 },
436 _ => Cell::Text {
437 value: String::new(),
438 },
439 }
440}
441
442fn level_label(level: crate::render::StatusLevel) -> String {
444 match level {
445 crate::render::StatusLevel::Ok => "status.ok".into(),
446 crate::render::StatusLevel::Info => "status.info".into(),
447 crate::render::StatusLevel::Warning => "status.warning".into(),
448 crate::render::StatusLevel::Error => "status.error".into(),
449 crate::render::StatusLevel::Pending => "status.pending".into(),
450 }
451}
452
453fn condition_matches(rule: &ConditionRule, resource: &serde_json::Value) -> bool {
456 let Some(conditions) = resource.get("status").and_then(|s| s.get("conditions")) else {
457 return false;
458 };
459 let Some(list) = conditions.as_array() else {
460 return false;
461 };
462 list.iter().any(|cond| {
463 cond.get("type").and_then(|t| t.as_str()) == Some(rule.condition_type.as_str())
464 && cond.get("status").and_then(|s| s.as_str()) == Some(rule.status.as_str())
465 })
466}
467
468fn rule_matches(rule: &StatusRule, resource: &serde_json::Value) -> bool {
469 let Some(actual) = resolve_field(resource, &rule.field) else {
470 return false;
471 };
472 match rule.op {
473 RuleOp::Eq => actual == &rule.value,
474 RuleOp::Ne => actual != &rule.value,
475 RuleOp::Gt | RuleOp::Gte | RuleOp::Lt | RuleOp::Lte => {
476 let (Some(a), Some(b)) = (actual.as_i64(), rule.value.as_i64()) else {
478 return false;
479 };
480 match rule.op {
481 RuleOp::Gt => a > b,
482 RuleOp::Gte => a >= b,
483 RuleOp::Lt => a < b,
484 RuleOp::Lte => a <= b,
485 _ => unreachable!(),
486 }
487 }
488 RuleOp::Contains => match (actual.as_str(), rule.value.as_str()) {
489 (Some(a), Some(b)) => a.contains(b),
490 _ => false,
491 },
492 }
493}
494
495fn is_segment(seg: &str) -> bool {
496 let mut idx = seg.len();
499 while idx > 0 && seg[..idx].ends_with(']') {
500 let Some(open) = seg[..idx].rfind('[') else {
501 return false;
502 };
503 let inside = &seg[open + 1..idx - 1];
504 if inside.is_empty() || !inside.chars().all(|c| c.is_ascii_digit()) {
505 return false;
506 }
507 idx = open;
508 }
509 is_identifier(&seg[..idx])
510}
511
512fn is_identifier(s: &str) -> bool {
513 !s.is_empty()
514 && s.chars()
515 .enumerate()
516 .all(|(i, c)| c.is_alphanumeric() || c == '_' || (i > 0 && c == '-'))
517}
518
519fn valid_header_key(key: &str) -> bool {
522 key.split('.').all(is_identifier) && key.contains('.')
523}
524
525pub fn example_cnpg_columns() -> Vec<Column> {
529 vec![
530 Column {
531 id: "name".into(),
532 header_key: "col.name".into(),
533 kind: ColumnKind::Text,
534 sortable: true,
535 field: Some("metadata.name".into()),
536 },
537 Column {
538 id: "instances".into(),
539 header_key: "col.instances".into(),
540 kind: ColumnKind::Number,
541 sortable: true,
542 field: Some("spec.instances".into()),
543 },
544 Column {
545 id: "status".into(),
546 header_key: "col.status".into(),
547 kind: ColumnKind::Status,
548 sortable: true,
549 field: None,
550 },
551 ]
552}
553
554pub fn example_status_rule() -> StatusRule {
556 StatusRule {
557 field: "status.phase".into(),
558 op: RuleOp::Eq,
559 value: serde_json::json!("ClusterIsReady"),
560 level: crate::render::StatusLevel::Ok,
561 }
562}
563
564#[cfg(test)]
565mod tests {
566 use super::*;
567
568 fn col(id: &str) -> Column {
569 Column {
570 id: id.into(),
571 header_key: format!("col.{id}"),
572 kind: ColumnKind::Text,
573 sortable: true,
574 field: Some(format!("metadata.{id}")),
575 }
576 }
577
578 fn status_col(id: &str) -> Column {
579 Column {
580 id: id.into(),
581 header_key: format!("col.{id}"),
582 kind: ColumnKind::Status,
583 sortable: true,
584 field: None,
585 }
586 }
587
588 fn action(id: &str) -> LensAction {
589 LensAction {
590 id: id.into(),
591 label_key: format!("action.{id}"),
592 state: "allowed".into(),
593 }
594 }
595
596 fn valid() -> ViewDefinition {
597 ViewDefinition {
598 id: "com.example.cnpg-lens".into(),
599 api_version: LENS_SCHEMA_VERSION,
600 target: GroupVersionKind {
601 group: "postgresql.cnpg.io".into(),
602 version: "v1".into(),
603 kind: "Cluster".into(),
604 },
605 columns: vec![col("name"), status_col("status")],
606 status: vec![example_status_rule()],
607 conditions: vec![],
608 actions: vec![action("describe")],
609 }
610 }
611
612 #[test]
613 fn valid_lens_has_no_problems() {
614 assert!(validate_viewdef(&valid()).is_empty());
615 }
616
617 #[test]
618 fn missing_reverse_dns_id_is_flagged() {
619 let mut vd = valid();
620 vd.id = "no-dot-here".into();
621 let problems = validate_viewdef(&vd);
622 assert!(problems.iter().any(|p| p.contains("reverse-DNS")));
623 }
624
625 #[test]
626 fn wrong_api_version_is_flagged() {
627 let mut vd = valid();
628 vd.api_version = 999;
629 let problems = validate_viewdef(&vd);
630 assert!(problems.iter().any(|p| p.contains("api_version")));
631 }
632
633 #[test]
634 fn duplicate_column_id_is_flagged() {
635 let mut vd = valid();
636 vd.columns = vec![col("name"), col("name")];
637 let problems = validate_viewdef(&vd);
638 assert!(problems.iter().any(|p| p.contains("duplicate column")));
639 }
640
641 #[test]
642 fn numeric_op_with_string_value_is_flagged() {
643 let mut vd = valid();
644 vd.status = vec![StatusRule {
645 field: "spec.replicas".into(),
646 op: RuleOp::Gt,
647 value: serde_json::json!("many"),
648 level: crate::render::StatusLevel::Warning,
649 }];
650 let problems = validate_viewdef(&vd);
651 assert!(problems.iter().any(|p| p.contains("numeric")));
652 }
653
654 #[test]
655 fn contains_op_with_numeric_value_is_flagged() {
656 let mut vd = valid();
657 vd.status = vec![StatusRule {
658 field: "status.phase".into(),
659 op: RuleOp::Contains,
660 value: serde_json::json!(3),
661 level: crate::render::StatusLevel::Warning,
662 }];
663 let problems = validate_viewdef(&vd);
664 assert!(problems.iter().any(|p| p.contains("contains")));
665 }
666
667 #[test]
668 fn malformed_field_path_is_flagged() {
669 let mut vd = valid();
670 vd.status = vec![StatusRule {
671 field: ".bad.path".into(),
672 op: RuleOp::Eq,
673 value: serde_json::json!("x"),
674 level: crate::render::StatusLevel::Ok,
675 }];
676 let problems = validate_viewdef(&vd);
677 assert!(problems.iter().any(|p| p.contains("field")));
678 }
679
680 #[test]
681 fn duplicate_action_id_is_flagged() {
682 let mut vd = valid();
683 vd.actions = vec![action("x"), action("x")];
684 let problems = validate_viewdef(&vd);
685 assert!(problems.iter().any(|p| p.contains("action")));
686 }
687
688 #[test]
689 fn field_path_validator_accepts_indexes() {
690 assert!(valid_field_path("status.phase"));
691 assert!(valid_field_path("spec.containers[0].name"));
692 assert!(valid_field_path("metadata.labels.app"));
693 assert!(!valid_field_path(""));
694 assert!(!valid_field_path(".phase"));
695 assert!(!valid_field_path("status..phase"));
696 }
697
698 #[test]
699 fn resolve_field_reads_nested_and_indexed_paths() {
700 let v = serde_json::json!({
701 "status": {"phase": "Running"},
702 "spec": {"containers": [{"name": "app"}]}
703 });
704 assert_eq!(
705 resolve_field(&v, "status.phase"),
706 Some(&serde_json::json!("Running"))
707 );
708 assert_eq!(
709 resolve_field(&v, "spec.containers[0].name"),
710 Some(&serde_json::json!("app"))
711 );
712 assert_eq!(resolve_field(&v, "status.nope"), None);
713 }
714
715 #[test]
716 fn evaluate_status_first_match_wins() {
717 let mut vd = valid();
718 vd.status = vec![
719 StatusRule {
720 field: "status.phase".into(),
721 op: RuleOp::Eq,
722 value: serde_json::json!("Running"),
723 level: crate::render::StatusLevel::Ok,
724 },
725 StatusRule {
726 field: "status.phase".into(),
727 op: RuleOp::Ne,
728 value: serde_json::json!("Running"),
729 level: crate::render::StatusLevel::Warning,
730 },
731 ];
732 let running = serde_json::json!({"status": {"phase": "Running"}});
733 assert_eq!(
734 evaluate_status(&vd, &running),
735 Some(crate::render::StatusLevel::Ok)
736 );
737 let pending = serde_json::json!({"status": {"phase": "Pending"}});
738 assert_eq!(
739 evaluate_status(&vd, &pending),
740 Some(crate::render::StatusLevel::Warning)
741 );
742 let empty = serde_json::json!({});
743 assert_eq!(evaluate_status(&vd, &empty), None);
744 }
745
746 #[test]
747 fn numeric_rule_compares_numerically() {
748 let mut vd = valid();
749 vd.status = vec![StatusRule {
750 field: "spec.replicas".into(),
751 op: RuleOp::Gt,
752 value: serde_json::json!(1),
753 level: crate::render::StatusLevel::Warning,
754 }];
755 let three = serde_json::json!({"spec": {"replicas": 3}});
756 assert_eq!(
757 evaluate_status(&vd, &three),
758 Some(crate::render::StatusLevel::Warning)
759 );
760 let one = serde_json::json!({"spec": {"replicas": 1}});
761 assert_eq!(evaluate_status(&vd, &one), None);
762 }
763
764 #[test]
765 fn contains_rule_matches_substring() {
766 let mut vd = valid();
767 vd.status = vec![StatusRule {
768 field: "status.message".into(),
769 op: RuleOp::Contains,
770 value: serde_json::json!("back-off"),
771 level: crate::render::StatusLevel::Error,
772 }];
773 let msg = serde_json::json!({"status": {"message": "back-off pulling image"}});
774 assert_eq!(
775 evaluate_status(&vd, &msg),
776 Some(crate::render::StatusLevel::Error)
777 );
778 }
779
780 #[test]
781 fn condition_rule_matches_ready_true() {
782 let mut vd = valid();
783 vd.status = vec![];
784 vd.conditions = vec![
785 ConditionRule {
786 condition_type: "Ready".into(),
787 status: "True".into(),
788 level: crate::render::StatusLevel::Ok,
789 },
790 ConditionRule {
791 condition_type: "Ready".into(),
792 status: "False".into(),
793 level: crate::render::StatusLevel::Error,
794 },
795 ];
796 let ready = serde_json::json!({
797 "status": {"conditions": [{"type": "Ready", "status": "True"}]}
798 });
799 assert_eq!(
800 evaluate_status(&vd, &ready),
801 Some(crate::render::StatusLevel::Ok)
802 );
803 let not_ready = serde_json::json!({
804 "status": {"conditions": [{"type": "Ready", "status": "False"}]}
805 });
806 assert_eq!(
807 evaluate_status(&vd, ¬_ready),
808 Some(crate::render::StatusLevel::Error)
809 );
810 let other = serde_json::json!({
812 "status": {"conditions": [{"type": "Progressing", "status": "True"}]}
813 });
814 assert_eq!(evaluate_status(&vd, &other), None);
815 assert_eq!(
817 evaluate_status(&vd, &serde_json::json!({"status": {}})),
818 None
819 );
820 }
821
822 #[test]
823 fn invalid_condition_status_is_flagged() {
824 let mut vd = valid();
825 vd.conditions = vec![ConditionRule {
826 condition_type: "Ready".into(),
827 status: "Yes".into(),
828 level: crate::render::StatusLevel::Ok,
829 }];
830 let problems = validate_viewdef(&vd);
831 assert!(problems.iter().any(|p| p.contains("conditions[0].status")));
832 }
833
834 #[test]
835 fn empty_condition_type_is_flagged() {
836 let mut vd = valid();
837 vd.conditions = vec![ConditionRule {
838 condition_type: "".into(),
839 status: "True".into(),
840 level: crate::render::StatusLevel::Ok,
841 }];
842 let problems = validate_viewdef(&vd);
843 assert!(
844 problems
845 .iter()
846 .any(|p| p.contains("conditions[0].condition_type"))
847 );
848 }
849
850 #[test]
851 fn non_status_column_without_field_is_flagged() {
852 let mut vd = valid();
853 vd.columns = vec![Column {
855 id: "name".into(),
856 header_key: "col.name".into(),
857 kind: ColumnKind::Text,
858 sortable: true,
859 field: None,
860 }];
861 let problems = validate_viewdef(&vd);
862 assert!(problems.iter().any(|p| p.contains("field")));
863 }
864
865 #[test]
866 fn malformed_column_field_is_flagged() {
867 let mut vd = valid();
868 vd.columns = vec![Column {
869 id: "name".into(),
870 header_key: "col.name".into(),
871 kind: ColumnKind::Text,
872 sortable: true,
873 field: Some(".bad.path".into()),
874 }];
875 let problems = validate_viewdef(&vd);
876 assert!(
877 problems
878 .iter()
879 .any(|p| p.contains("not a dotted JSON path"))
880 );
881 }
882
883 #[test]
884 fn render_row_maps_fields_and_infers_status() {
885 let vd = ViewDefinition {
887 id: "com.example.cnpg-lens".into(),
888 api_version: LENS_SCHEMA_VERSION,
889 target: GroupVersionKind {
890 group: "postgresql.cnpg.io".into(),
891 version: "v1".into(),
892 kind: "Cluster".into(),
893 },
894 columns: vec![
895 Column {
896 id: "name".into(),
897 header_key: "col.name".into(),
898 kind: ColumnKind::Text,
899 sortable: true,
900 field: Some("metadata.name".into()),
901 },
902 Column {
903 id: "instances".into(),
904 header_key: "col.instances".into(),
905 kind: ColumnKind::Number,
906 sortable: true,
907 field: Some("spec.instances".into()),
908 },
909 Column {
910 id: "status".into(),
911 header_key: "col.status".into(),
912 kind: ColumnKind::Status,
913 sortable: true,
914 field: None,
915 },
916 ],
917 status: vec![StatusRule {
918 field: "status.phase".into(),
919 op: RuleOp::Eq,
920 value: serde_json::json!("ClusterIsReady"),
921 level: crate::render::StatusLevel::Ok,
922 }],
923 conditions: vec![],
924 actions: vec![],
925 };
926
927 let resource = serde_json::json!({
928 "metadata": {"uid": "abc-123", "name": "pg", "namespace": "db"},
929 "spec": {"instances": 3},
930 "status": {"phase": "ClusterIsReady"}
931 });
932
933 let row = render_row(&vd, &resource);
934 assert_eq!(row.id, RowId("abc-123".into()));
936 assert_eq!(row.cells.len(), 3);
937 assert_eq!(row.cells[0], Cell::Text { value: "pg".into() });
939 assert_eq!(row.cells[1], Cell::Number { value: 3 });
941 assert_eq!(
943 row.cells[2],
944 Cell::Status {
945 level: crate::render::StatusLevel::Ok,
946 label_key: "status.ok".into(),
947 }
948 );
949 }
950
951 #[test]
952 fn render_row_falls_back_to_ns_name_identity_and_info_status() {
953 let vd = ViewDefinition {
954 id: "com.example.t".into(),
955 api_version: LENS_SCHEMA_VERSION,
956 target: GroupVersionKind {
957 group: "example.io".into(),
958 version: "v1".into(),
959 kind: "Thing".into(),
960 },
961 columns: vec![Column {
962 id: "status".into(),
963 header_key: "col.status".into(),
964 kind: ColumnKind::Status,
965 sortable: true,
966 field: None,
967 }],
968 status: vec![],
969 conditions: vec![],
970 actions: vec![],
971 };
972 let resource = serde_json::json!({
974 "metadata": {"name": "x", "namespace": "n"}
975 });
976 let row = render_row(&vd, &resource);
977 assert_eq!(row.id, RowId("n/x".into()));
978 assert_eq!(
979 row.cells[0],
980 Cell::Status {
981 level: crate::render::StatusLevel::Info,
982 label_key: "unknown".into(),
983 }
984 );
985 }
986}