mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 16:09:05 +00:00
## Alert Library A curated catalog at `/alert-library`: browse packs of ready-made alerts, see which ones can actually run against the streams this org already receives, preview a verdict against real data, and install one or many. The library is a **peer page** of Alerts / Notification Destinations / Templates — flat routing, presented as tabs via a shared `AlertSectionTabs` in `OPageLayout #header-tabs` (the pattern `PipelineSectionTabs` already ships). ### What landed, by phase | Phase | What | | --- | --- | | Data layer | S3 manifest fetch, cache, readiness resolution (`useAlertLibrary`, `constants/alertLibrary`, `types/alertLibrary`) | | Gallery | Rail (packs / categories, multi-select, segmented switch), stat-strip availability facets, cards | | Drawer | Detail view with query preview and customizable tunables | | Install | Bulk-install wizard — destination selection, per-alert tunables, stale-session hardening | | Prefill | Carry a library alert (query, trigger, threshold, severity) into the alert editor | ### Design decisions worth knowing - **Rail navigates, strip filters.** Availability is deliberately not in the rail — carrying both overflowed it and pushed a facet group below the fold. - **Colour marks the exception only.** No "Ready" pill (it would describe ~75 of 86 cards and signal nothing); only unusable cards carry a label. - **A failed `/streams` call leaves readiness unknown**, rather than claiming "none of your data matches" — that claim would be about the request, not the org, and it is the most alarming thing this page can say. Counts read `—` until there is an answer, and a failed *refresh* keeps the verdict it already had. - **No "Installed" tile** — installed detection doesn't exist yet, and a tile reading a permanent zero is dishonest UI. - **`severity: warning` renders as `medium`** — the badge group's scale is critical/high/medium/low/info, and medium is the amber it also gives P3, which is exactly the priority a warning alert installs as. - Pack and category names come from the manifest, so they render as `raw()` data — a pack added upstream needs no frontend release. ### Design-system changes (used by the library, applied app-wide) - **`warning-quiet` badge tone** — one variant whose values differ by theme: the soft amber wash in light, a neutral chip in dark. Neither the filled amber chip nor the neutral one was right in both, because the two ramps aren't symmetric. - **Border tokens now meet WCAG 2.1 SC 1.4.11.** Every border token measured under 3:1 (`border-strong` was 2.52:1). `border-default` firms to grey-300 and controls get a token that clears 3:1. - Nav: Reliability's flyout groups its four alerting entries under one **Alerts** header, declared in the same order as the tab strip those pages carry. Flyout levels are sized by depth. - `sensors-off` icon added to the `OIcon` registry. ### Fixes that rode along - **metrics**: a shared URL whose `metrics_data` blob omits panel query fields no longer blanks the whole metrics page — blob queries are filled out to the panel schema before being laid over. - **dashboards**: a PromQL series with no legend template was named `JSON.stringify(metric)`, so fifty series read identically for the width of the chip. Series are now named by the labels that actually differ, preferring the ones that say *which workload* it is (namespace / service / pod / host). Correlation's semantic groups refine that when loaded, but nothing is fetched to render a chart — that endpoint is enterprise-only and a chart must render the same on OSS. - **alerts**: preview evaluates against the alert's real trigger; deleting a row no longer reloads the list behind it; peer tabs no longer shift when switching alerting pages. - **ui**: badge-only select options no longer render bold. ### Tests - `test(search)`: pins the streaming-aggregate **wire contract** at the HTTP boundary. Under streaming aggregates each partition carries the cumulative merged state (last frame is the answer) while ordinary partitions are disjoint and must be summed. No pytest had ever received `streaming_aggs: true` — the emitter could have flipped to per-partition deltas with the whole suite green while dashboards, Logs and the alert preview all silently over-counted. - `test(e2e)`: four helpers that passed only while an org stayed small — an assertion reading past a 20-row page, an unfiltered `OSelect` that virtualises past 50 entries, a tab click landing on the rename pencil, and two builder tests asserting on whichever metric sorted first. - New unit specs across the library components, composable, facets, tunables and install path. ### Notes - **i18n**: en-US only; CI generates the other 14 locales. - **No backend changes** — this branch is entirely `web/` plus test helpers.
133 lines
4.9 KiB
Python
133 lines
4.9 KiB
Python
"""Server-Sent Events (SSE) response parser for OO's `/_search_stream` and
|
|
`/_values_stream` endpoints.
|
|
|
|
OO returns SSE-formatted responses for streaming queries:
|
|
event: search_response_metadata
|
|
data: {"results": {"total": N, ...}}
|
|
|
|
event: search_response_hits
|
|
data: {"hits": [...]}
|
|
|
|
The endpoint may emit MULTIPLE metadata + hits frames per response (one
|
|
per partition for aggregations, etc.). This helper merges them into a
|
|
single `{"results": {"total": int, "hits": [...]}}` dict that callers
|
|
can treat uniformly.
|
|
|
|
Replaces the `read_response()` function previously inlined in
|
|
test_streaming.py.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from typing import Any
|
|
|
|
import requests
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def read_sse_frames(response: requests.Response) -> list[tuple[str, dict[str, Any]]]:
|
|
"""Parse an SSE-streamed response into its raw frames, in wire order.
|
|
|
|
Returns one `(event_name, data)` pair per `event:` / `data:` line pair,
|
|
with NO merging and NO interpretation — e.g.::
|
|
|
|
[("search_response_metadata", {"results": {...}, "streaming_aggs": True}),
|
|
("search_response_hits", {"hits": [...]}),
|
|
("search_response_metadata", {...}),
|
|
...]
|
|
|
|
Use this (instead of `read_sse_response()`) whenever the PER-FRAME
|
|
semantics are the thing under test. `read_sse_response()` deliberately
|
|
collapses the frames — it keeps `max(totals)` and concatenates every hits
|
|
frame — so it cannot tell a disjoint page from a cumulative aggregation
|
|
state, and concatenating hits across cumulative frames duplicates rows.
|
|
|
|
Frames whose `data:` payload is not valid JSON are skipped (logged at
|
|
debug), matching `read_sse_response()`'s tolerance.
|
|
"""
|
|
content = response.content.decode("utf-8")
|
|
lines = content.split("\n")
|
|
frames: list[tuple[str, dict[str, Any]]] = []
|
|
|
|
for i, line in enumerate(lines):
|
|
text = line.strip()
|
|
if not text.startswith("event: "):
|
|
continue
|
|
event = text[len("event: "):].strip()
|
|
if i + 1 >= len(lines):
|
|
continue
|
|
data_line = lines[i + 1].strip()
|
|
if not data_line.startswith("data: "):
|
|
continue
|
|
try:
|
|
frames.append((event, json.loads(data_line[6:])))
|
|
except json.JSONDecodeError as e:
|
|
logger.debug("SSE %s frame parse error: %s", event, e)
|
|
|
|
return frames
|
|
|
|
|
|
def read_sse_response(response: requests.Response) -> dict[str, Any]:
|
|
"""Parse an SSE-streamed response into one merged `{results: {total, hits}}` dict.
|
|
|
|
Multiple metadata frames: we pick the MAX total (for aggregation queries,
|
|
each partition reports a partial; max is the right global). Hits are
|
|
concatenated across all hits frames.
|
|
|
|
Returns `{"results": {"total": 0, "hits": []}}` if no valid frames found.
|
|
"""
|
|
content = response.content.decode("utf-8")
|
|
lines = content.split("\n")
|
|
metadata_frames: list[dict[str, Any]] = []
|
|
hits_frames: list[dict[str, Any]] = []
|
|
|
|
for i, line in enumerate(lines):
|
|
text = line.strip()
|
|
|
|
if text.startswith("event: search_response_metadata"):
|
|
if i + 1 < len(lines):
|
|
data_line = lines[i + 1].strip()
|
|
if data_line.startswith("data: "):
|
|
try:
|
|
metadata_frames.append(json.loads(data_line[6:]))
|
|
except json.JSONDecodeError as e:
|
|
logger.debug("SSE metadata frame parse error: %s", e)
|
|
|
|
elif text.startswith("event: search_response_hits"):
|
|
if i + 1 < len(lines):
|
|
data_line = lines[i + 1].strip()
|
|
if data_line.startswith("data: "):
|
|
try:
|
|
hits_frames.append(json.loads(data_line[6:]))
|
|
except json.JSONDecodeError as e:
|
|
logger.debug("SSE hits frame parse error: %s", e)
|
|
|
|
if not metadata_frames:
|
|
logger.debug("read_sse_response: no metadata frames found")
|
|
return {"results": {"total": 0, "hits": []}}
|
|
|
|
# Concatenate all hits across hits frames
|
|
all_hits: list[dict[str, Any]] = []
|
|
for frame in hits_frames:
|
|
if "hits" in frame and isinstance(frame["hits"], list):
|
|
all_hits.extend(frame["hits"])
|
|
|
|
# Determine combined total. For single-frame responses use the reported
|
|
# total directly. For multi-frame, pick the MAX (each partition reports
|
|
# its partial; for aggregation queries the max is the global total).
|
|
combined = metadata_frames[0].copy()
|
|
if len(metadata_frames) == 1:
|
|
total = metadata_frames[0].get("results", {}).get("total", len(all_hits))
|
|
else:
|
|
totals = [
|
|
f.get("results", {}).get("total", 0) for f in metadata_frames
|
|
]
|
|
total = max(totals) if any(t for t in totals) else len(all_hits)
|
|
|
|
combined.setdefault("results", {})
|
|
combined["results"]["total"] = total
|
|
combined["results"]["hits"] = all_hits
|
|
return combined
|