Skip to content

Commit 2ff9817

Browse files
authored
Add a tree view to search UI (#1595)
1 parent 554e3f1 commit 2ff9817

20 files changed

Lines changed: 1330 additions & 202 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file.
44

55
# Unreleased
66

7+
- Add a tree view to the `serve` UI's search page, grouping results by namespace with expand/collapse controls, and a "Hide deprecated" toggle (on by default) for both the list and tree views. ([#1595](https://github.com/open-telemetry/weaver/pull/1595) by @jerbly)
78
- Support signal refinements over a published dependency. ([#1587](https://github.com/open-telemetry/weaver/pull/1587) by @lmolkova)
89
- 💥 BREAKING CHANGE 💥 Preserve per-attribute `requirement_level` on attribute refs of public attribute groups in the v2 resolved and materialized schemas. Each entry in an attribute group's `attributes` is now an object (`{ base, requirement_level }`) instead of a bare `attribute_catalog` index. ([#1584](https://github.com/open-telemetry/weaver/pull/1584) by @lmolkova)
910
- Use the OS-native certificate store (via ureq's `platform-verifier` feature) to validate TLS connections for remote registry downloads, instead of a fixed bundled root CA list. ([#1583](https://github.com/open-telemetry/weaver/pull/1583) by @jerbly)

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/weaver_mcp/src/service.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,7 @@ impl WeaverMcpService {
378378
params.query.as_deref(),
379379
search_type,
380380
stability,
381+
false, // hide_deprecated: not exposed via the MCP search tool
381382
limit,
382383
0, // offset
383384
);

crates/weaver_search/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,6 @@ weaver_semconv = { path = "../weaver_semconv", features = ["openapi"] }
1717
serde.workspace = true
1818
utoipa.workspace = true
1919
schemars.workspace = true
20+
21+
[dev-dependencies]
22+
serde_json.workspace = true

crates/weaver_search/src/lib.rs

Lines changed: 131 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ use weaver_semconv::stability::Stability;
2525
//TODO: Consider using a fuzzy matching crate for improved search capabilities.
2626
// e.g. Tantivy - https://github.com/open-telemetry/weaver/pull/1076#discussion_r2640681775
2727

28+
/// Maximum number of results a single `search` call returns. Requested limits
29+
/// above this are clamped. Kept in sync with the documented maximum of the
30+
/// `/api/v1/registry/search` endpoint (`SearchParams::limit`).
31+
pub const MAX_SEARCH_LIMIT: usize = 1000;
32+
2833
/// Search context for performing fuzzy searches and O(1) lookups across the registry.
2934
pub struct SearchContext {
3035
/// All searchable items for fuzzy search.
@@ -150,6 +155,7 @@ impl SearchContext {
150155
/// * `query` - Optional search query string (None = browse mode).
151156
/// * `search_type` - Filter by item type.
152157
/// * `stability` - Optional stability filter.
158+
/// * `hide_deprecated` - When true, excludes deprecated items regardless of stability.
153159
/// * `limit` - Maximum number of results.
154160
/// * `offset` - Pagination offset.
155161
///
@@ -162,10 +168,11 @@ impl SearchContext {
162168
query: Option<&str>,
163169
search_type: SearchType,
164170
stability: Option<Stability>,
171+
hide_deprecated: bool,
165172
limit: usize,
166173
offset: usize,
167174
) -> (Vec<SearchResult>, usize) {
168-
let limit = limit.min(200); // Cap at 200
175+
let limit = limit.min(MAX_SEARCH_LIMIT);
169176

170177
// Filter by type
171178
let mut items: Vec<&SearchableItem> = self
@@ -179,6 +186,12 @@ impl SearchContext {
179186
items.retain(|item| item.stability() == &stability_filter);
180187
}
181188

189+
// `deprecated` is independent of `stability` - a deprecated item can carry any
190+
// stability level - so this is a separate retain rather than folded into the above.
191+
if hide_deprecated {
192+
items.retain(|item| !item.is_deprecated());
193+
}
194+
182195
// Branch based on whether we have a search query
183196
let (results, total) = if let Some(q) = query {
184197
if q.is_empty() {
@@ -188,7 +201,7 @@ impl SearchContext {
188201
(results, total)
189202
} else {
190203
// Non-empty query - search mode with scoring
191-
search_mode_with_total(items, q, limit, &self.separator)
204+
search_mode_with_total(items, q, limit, offset, &self.separator)
192205
}
193206
} else {
194207
// No query - browse mode
@@ -331,6 +344,7 @@ fn search_mode_with_total(
331344
items: Vec<&SearchableItem>,
332345
query: &str,
333346
limit: usize,
347+
offset: usize,
334348
separator: &str,
335349
) -> (Vec<SearchResult>, usize) {
336350
let mut scored_items: Vec<(u32, &SearchableItem)> = items
@@ -348,12 +362,13 @@ fn search_mode_with_total(
348362
// Sort by score descending
349363
scored_items.sort_by_key(|b| std::cmp::Reverse(b.0));
350364

351-
// Calculate total before taking limit
365+
// Calculate total before paginating
352366
let total = scored_items.len();
353367

354-
// Take top N and convert to results
368+
// Page through the ranked matches and convert to results
355369
let results = scored_items
356370
.into_iter()
371+
.skip(offset)
357372
.take(limit)
358373
.map(|(score, item)| item.to_search_result(score))
359374
.collect();
@@ -823,7 +838,7 @@ mod tests {
823838
let registry = make_test_registry();
824839
let ctx = SearchContext::from_registry(&registry);
825840

826-
let (results, total) = ctx.search(Some("http"), SearchType::All, None, 10, 0);
841+
let (results, total) = ctx.search(Some("http"), SearchType::All, None, false, 10, 0);
827842

828843
// Should find http.request.method, http.response.status_code,
829844
// http.server.request.duration, http.client
@@ -837,7 +852,7 @@ mod tests {
837852
let ctx = SearchContext::from_registry(&registry);
838853

839854
// None query = browse mode
840-
let (results, total) = ctx.search(None, SearchType::All, None, 100, 0);
855+
let (results, total) = ctx.search(None, SearchType::All, None, false, 100, 0);
841856

842857
// Should return all items: 5 attributes + 1 metric + 1 span + 1 event + 1 entity = 9
843858
assert_eq!(total, 9);
@@ -850,25 +865,25 @@ mod tests {
850865
let ctx = SearchContext::from_registry(&registry);
851866

852867
// Filter by Attribute only
853-
let (results, total) = ctx.search(None, SearchType::Attribute, None, 100, 0);
868+
let (results, total) = ctx.search(None, SearchType::Attribute, None, false, 100, 0);
854869
assert_eq!(total, 5); // 5 attributes (3 regular + 1 template + 1 development)
855870
assert_eq!(results.len(), 5);
856871

857872
// Filter by Metric only
858-
let (results, total) = ctx.search(None, SearchType::Metric, None, 100, 0);
873+
let (results, total) = ctx.search(None, SearchType::Metric, None, false, 100, 0);
859874
assert_eq!(total, 1);
860875
assert_eq!(results.len(), 1);
861876

862877
// Filter by Span only
863-
let (_, total) = ctx.search(None, SearchType::Span, None, 100, 0);
878+
let (_, total) = ctx.search(None, SearchType::Span, None, false, 100, 0);
864879
assert_eq!(total, 1);
865880

866881
// Filter by Event only
867-
let (_, total) = ctx.search(None, SearchType::Event, None, 100, 0);
882+
let (_, total) = ctx.search(None, SearchType::Event, None, false, 100, 0);
868883
assert_eq!(total, 1);
869884

870885
// Filter by Entity only
871-
let (_, total) = ctx.search(None, SearchType::Entity, None, 100, 0);
886+
let (_, total) = ctx.search(None, SearchType::Entity, None, false, 100, 0);
872887
assert_eq!(total, 1);
873888
}
874889

@@ -878,39 +893,70 @@ mod tests {
878893
let ctx = SearchContext::from_registry(&registry);
879894

880895
// Get first 2 items
881-
let (results1, total1) = ctx.search(None, SearchType::All, None, 2, 0);
896+
let (results1, total1) = ctx.search(None, SearchType::All, None, false, 2, 0);
882897
assert_eq!(total1, 9);
883898
assert_eq!(results1.len(), 2);
884899

885900
// Get next 2 items with offset
886-
let (results2, total2) = ctx.search(None, SearchType::All, None, 2, 2);
901+
let (results2, total2) = ctx.search(None, SearchType::All, None, false, 2, 2);
887902
assert_eq!(total2, 9);
888903
assert_eq!(results2.len(), 2);
889904

890905
// Get remaining items
891-
let (results3, _) = ctx.search(None, SearchType::All, None, 100, 4);
906+
let (results3, _) = ctx.search(None, SearchType::All, None, false, 100, 4);
892907
assert_eq!(results3.len(), 5);
893908
}
894909

895910
#[test]
896-
fn test_search_limit_capped_at_200() {
911+
fn test_search_limit_capped_at_max() {
897912
let registry = make_test_registry();
898913
let ctx = SearchContext::from_registry(&registry);
899914

900-
// Request limit > 200 should be capped
901-
let (results, _) = ctx.search(None, SearchType::All, None, 500, 0);
915+
// Request limit > MAX_SEARCH_LIMIT should be capped
916+
let (results, _) = ctx.search(None, SearchType::All, None, false, MAX_SEARCH_LIMIT + 1, 0);
902917

903918
// We only have 9 items, so we get 9 (not testing the cap directly,
904919
// but ensuring it doesn't crash with large limit)
905920
assert_eq!(results.len(), 9);
906921
}
907922

923+
#[test]
924+
fn test_search_query_mode_pagination() {
925+
let registry = make_test_registry();
926+
let ctx = SearchContext::from_registry(&registry);
927+
928+
// Collect all matches in one call, then re-fetch them one page at a time.
929+
let (all_results, total) = ctx.search(Some("http"), SearchType::All, None, false, 100, 0);
930+
assert_eq!(all_results.len(), total);
931+
assert!(total >= 4);
932+
933+
let (page1, _) = ctx.search(Some("http"), SearchType::All, None, false, 2, 0);
934+
let (page2, _) = ctx.search(Some("http"), SearchType::All, None, false, 2, 2);
935+
assert_eq!(page1.len(), 2);
936+
937+
// Pages must continue the ranked list, not repeat the top results.
938+
let paged: Vec<_> = page1.iter().chain(page2.iter()).collect();
939+
for (paged_item, full_item) in paged.iter().zip(all_results.iter()) {
940+
assert_eq!(
941+
serde_json::to_value(paged_item).unwrap(),
942+
serde_json::to_value(full_item).unwrap()
943+
);
944+
}
945+
946+
// Offset past the end returns an empty page but the same total.
947+
let (past_end, past_end_total) =
948+
ctx.search(Some("http"), SearchType::All, None, false, 10, total);
949+
assert!(past_end.is_empty());
950+
assert_eq!(past_end_total, total);
951+
}
952+
908953
#[test]
909954
fn test_search_no_results() {
910955
let registry = make_test_registry();
911956
let ctx = SearchContext::from_registry(&registry);
912957

913-
let (results, total) = ctx.search(Some("zzzznonexistent"), SearchType::All, None, 10, 0);
958+
let (results, total) =
959+
ctx.search(Some("zzzznonexistent"), SearchType::All, None, false, 10, 0);
914960

915961
assert_eq!(total, 0);
916962
assert!(results.is_empty());
@@ -980,8 +1026,14 @@ mod tests {
9801026
let ctx = SearchContext::from_registry(&registry);
9811027

9821028
// Filter by Stable only
983-
let (results, total) =
984-
ctx.search(None, SearchType::Attribute, Some(Stability::Stable), 100, 0);
1029+
let (results, total) = ctx.search(
1030+
None,
1031+
SearchType::Attribute,
1032+
Some(Stability::Stable),
1033+
false,
1034+
100,
1035+
0,
1036+
);
9851037

9861038
// Should return only stable attributes (4: http.request.method, http.response.status_code, db.system, test.template)
9871039
assert_eq!(total, 4);
@@ -998,6 +1050,7 @@ mod tests {
9981050
None,
9991051
SearchType::Attribute,
10001052
Some(Stability::Development),
1053+
false,
10011054
100,
10021055
0,
10031056
);
@@ -1020,6 +1073,64 @@ mod tests {
10201073
assert_eq!(dev_item.stability(), &Stability::Development);
10211074
}
10221075

1076+
// =========================================================================
1077+
// Deprecated Filtering Tests
1078+
// =========================================================================
1079+
1080+
fn make_registry_with_attributes(attributes: Vec<Attribute>) -> ForgeResolvedRegistry {
1081+
ForgeResolvedRegistry {
1082+
schema_url: "https://example.com/schemas/1.2.3".try_into().unwrap(),
1083+
registry: Registry {
1084+
attributes,
1085+
attribute_groups: vec![],
1086+
metrics: vec![],
1087+
spans: vec![],
1088+
events: vec![],
1089+
entities: vec![],
1090+
},
1091+
refinements: Refinements {
1092+
metrics: vec![],
1093+
spans: vec![],
1094+
events: vec![],
1095+
},
1096+
}
1097+
}
1098+
1099+
#[test]
1100+
fn test_search_hide_deprecated() {
1101+
let registry = make_registry_with_attributes(vec![
1102+
make_attribute("service.name", "Service name", "", false),
1103+
make_attribute("service.namespace", "Deprecated namespace", "", true),
1104+
]);
1105+
let ctx = SearchContext::from_registry(&registry);
1106+
1107+
let (results, total) = ctx.search(None, SearchType::All, None, false, 100, 0);
1108+
assert_eq!(total, 2);
1109+
assert_eq!(results.len(), 2);
1110+
1111+
let (results, total) = ctx.search(None, SearchType::All, None, true, 100, 0);
1112+
assert_eq!(total, 1);
1113+
assert_eq!(results.len(), 1);
1114+
}
1115+
1116+
#[test]
1117+
fn test_search_hide_deprecated_independent_of_stability() {
1118+
// `make_attribute` always sets stability: Stable regardless of the
1119+
// deprecated flag - a deprecated item can carry any stability level,
1120+
// so `hide_deprecated` must filter independently of the stability
1121+
// filter rather than only affecting items with `stability: deprecated`.
1122+
let registry = make_registry_with_attributes(vec![make_attribute(
1123+
"service.name",
1124+
"Deprecated but stable",
1125+
"",
1126+
true,
1127+
)]);
1128+
let ctx = SearchContext::from_registry(&registry);
1129+
1130+
let (_, total) = ctx.search(None, SearchType::All, Some(Stability::Stable), true, 100, 0);
1131+
assert_eq!(total, 0);
1132+
}
1133+
10231134
// =========================================================================
10241135
// Namespace Browsing Tests
10251136
// =========================================================================

src/serve/handlers.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,7 @@ pub async fn search_registry(
277277
query,
278278
params.search_type,
279279
params.stability,
280+
params.hide_deprecated,
280281
params.limit,
281282
params.offset,
282283
);

src/serve/types.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,11 @@ pub struct SearchParams {
3535
pub search_type: SearchType,
3636
/// Filter by stability level.
3737
pub stability: Option<Stability>,
38+
/// Exclude deprecated items, independent of the stability filter (default: false).
39+
#[serde(default)]
40+
pub hide_deprecated: bool,
3841
/// Maximum number of results (default: 50).
42+
// The maximum must match weaver_search::MAX_SEARCH_LIMIT (enforced there).
3943
#[serde(default = "default_search_limit")]
4044
#[param(maximum = 1000)]
4145
pub limit: usize,

ui/e2e/fixtures/render-registry/registry.yaml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,25 @@ events:
298298
renamed_to: render.event.thing
299299
note: Replaced by `render.event.thing`.
300300

301+
- name: render.attr
302+
requirement_level: recommended
303+
brief: An event whose full name coincides with the `render.attr` attribute namespace.
304+
note: |
305+
Regression fixture for the tree view nesting this event inside the
306+
`render.attr` folder as if it were the folder's self-describing item -
307+
a namespace collision is just a naming coincidence, not ownership, so
308+
the event must stay a sibling of the folder.
309+
stability: stable
310+
311+
- name: render.coordinator
312+
requirement_level: recommended
313+
brief: An event alphabetically between the `render.container` and `render.entity` namespace folders.
314+
note: |
315+
Regression fixture for tree view ordering - folders and items are
316+
siblings in the same namespace and must sort together into one
317+
alphabetical list, not as a folders-then-items grouping.
318+
stability: stable
319+
301320
entities:
302321
- type: render.entity.host
303322
requirement_level: recommended

0 commit comments

Comments
 (0)