diff --git a/taegis_magic/core/chunked_search.py b/taegis_magic/core/chunked_search.py new file mode 100644 index 0000000..c78e0fe --- /dev/null +++ b/taegis_magic/core/chunked_search.py @@ -0,0 +1,395 @@ +"""Chunked time-range search execution for Taegis QL queries. + +Wraps the normal search flow so that, when a query uses the | earliest +or | latest Jinja2 filters, the query is automatically retried across +progressively smaller time-range windows if the full range fails. +""" + +import logging +import re +import warnings +from datetime import timedelta +from typing import Any, Callable, List, Optional, Tuple + +from gql.transport.exceptions import TransportQueryError + +log = logging.getLogger(__name__) + +# Regex patterns to locate EARLIEST= / LATEST= in a rendered query +EARLIEST_PATTERN = re.compile(r"EARLIEST\s*=\s*[^\s]+", re.IGNORECASE) +LATEST_PATTERN = re.compile(r"LATEST\s*=\s*[^\s]+", re.IGNORECASE) + + +# chunked search helpers +def _replace_time_range( + query: str, earliest: str, latest: Optional[str] +) -> str: + """Replace earliest=… and latest=… in *query* with new values. + + Parameters + ---------- + query : str + The rendered Taegis QL query containing an earliest=… clause + and optionally a latest=… clause. + earliest : str + New earliest clause, e.g. 'earliest=-15d'. + latest : Optional[str] + New latest clause, e.g. 'latest=-7d'. + If None, any existing latest=… in the query is **preserved** + unchanged (the window extends to whatever the base query specifies, + or "now" if no latest clause exists). + + Returns + ------- + str + The modified query string. + """ + # Replace EARLIEST + new_query = EARLIEST_PATTERN.sub(earliest, query, count=1) + + if latest is not None: + if LATEST_PATTERN.search(new_query): + # Replace existing LATEST + new_query = LATEST_PATTERN.sub(latest, new_query, count=1) + else: + # Append LATEST on a new line after EARLIEST + new_query = new_query.replace(earliest, f"{earliest}\n{latest}", 1) + # else: latest is None → preserve any existing LATEST in the query as-is. + # This respects user-written LATEST clauses (e.g. "LATEST = -7d") and + # ensures chunk windows that extend "to now" don't accidentally strip + # a LATEST that the user explicitly set in their query. + + return new_query + + +# Core chunked execution algorigthm +""" + Algorithm + --------- + 1. Try Tier 0 (full range). On success, return immediately. + 2. On failure -> keep any successful results, identify failed windows. + 3. Subdivide only the failed windows using the next tier's window size. + 4. Retry those subdivisions - successes are appended to the running total. + 5. Repeat until all tiers are exhausted or all windows succeed. + 6. Return aggregate_fn(all_results) or None. +""" + +def execute_chunked_search( + base_query: str, + chunk_tiers: List[List[Tuple[str, Optional[str]]]], + search_fn: Callable[[str], Any], + aggregate_fn: Callable[[List[Any]], Any], +) -> Any: + """Execute a search with progressive time-range chunking. + + Parameters + ---------- + base_query : str + The rendered Taegis QL query with earliest=… (and optionally + latest=…). + chunk_tiers : List[List[Tuple[str, Optional[str]]]] + Tiers of (earliest_clause, latest_clause) pairs produced by + :func:`~taegis_magic.core.time_range.generate_chunk_windows`. + search_fn : Callable[[str], Any] + Executes a single query string and returns a normalizer result. + Must raise on failure (timeout, transport error, etc.). + aggregate_fn : Callable[[List[Any]], Any] + Combines multiple normalizer results into one. + + Returns + ------- + Any + Aggregated normalizer result, or None if every window failed. + + """ + all_results: List[Any] = [] + + # Windows that still need to be retried. Start with Tier 0. + pending_windows: List[Tuple[str, Optional[str]]] = list(chunk_tiers[0]) + + for tier_idx in range(len(chunk_tiers)): + failed_windows: List[Tuple[str, Optional[str]]] = [] + log.info( + "Chunked search: starting tier %d with %d window(s)", + tier_idx, + len(pending_windows), + ) + + for win_idx, (earliest, latest) in enumerate(pending_windows): + modified_query = _replace_time_range(base_query, earliest, latest) + latest_display = latest or "now" + try: + log.info( + "Chunked search tier %d window %d/%d: %s to %s", + tier_idx, + win_idx + 1, + len(pending_windows), + earliest, + latest_display, + ) + result = search_fn(modified_query) + all_results.append(result) + except Exception as exc: + msg_suffix = ( + "Trying smaller windows in next tier..." + if tier_idx < len(chunk_tiers) - 1 + else "No more tiers to retry." + ) + warnings.warn( + f"Chunk failed ({earliest} to {latest_display}): {exc}. " + f"{msg_suffix}", + stacklevel=2, + ) + failed_windows.append((earliest, latest)) + + if not failed_windows: + break + + if tier_idx == len(chunk_tiers) - 1: + # Last tier. Exchausted all retries. + warnings.warn( + f"{len(failed_windows)} chunk(s) failed after all retry tiers " + f"exhausted. Partial results returned.", + stacklevel=2, + ) + break + + # More tiers available. Subdivide only the failed windows + # using the next tier's granularity. + next_tier_windows = chunk_tiers[tier_idx + 1] + retry_windows = _subdivide_failed_windows( + failed_windows, next_tier_windows, + ) + warnings.warn( + f"Tier {tier_idx} had {len(failed_windows)} failure(s) — " + f"retrying those ranges as {len(retry_windows)} smaller " + f"window(s) from tier {tier_idx + 1}...", + stacklevel=2, + ) + pending_windows = retry_windows + + return aggregate_fn(all_results) if all_results else None + + +def _subdivide_failed_windows( + failed_windows: List[Tuple[str, Optional[str]]], + next_tier_windows: List[Tuple[str, Optional[str]]], +) -> List[Tuple[str, Optional[str]]]: + """Find the next-tier windows that cover the failed time ranges. + + For each failed (earliest, latest) pair, select every window from + next_tier_windows whose range overlaps. Two ranges overlap when + neither ends before the other starts. + + The comparison is done on the absolute timedelta of each clause. + latest=None is treated as offset 0 (now). + + Parameters + ---------- + failed_windows : List[Tuple[str, Optional[str]]] + Windows that failed at the current tier. + next_tier_windows : List[Tuple[str, Optional[str]]] + Complete set of windows from the next finer tier. + + Returns + ------- + List[Tuple[str, Optional[str]]] + De-duplicated list of next-tier windows that cover the failed ranges, + preserving the order from *next_tier_windows*. + """ + from taegis_magic.core.time_range import parse_relative_timestamp + + def _offset(clause: Optional[str]) -> timedelta: + """Extract timedelta from 'earliest=-30d' or 'latest=-7d'.""" + if clause is None: + return timedelta(0) # "now" + # Strip 'earliest=' or 'latest=' prefix + value = clause.split("=", 1)[1] + return parse_relative_timestamp(value) + + # Collect indices of next-tier windows that overlap any failed window. + selected_indices: set = set() + + for f_earliest, f_latest in failed_windows: + # Failed range: from f_earliest (large offset) to f_latest (small offset / now) + f_start = _offset(f_earliest) # e.g. timedelta(days=30) + f_end = _offset(f_latest) # e.g. timedelta(days=15) or 0 + + for idx, (n_earliest, n_latest) in enumerate(next_tier_windows): + n_start = _offset(n_earliest) # e.g. timedelta(days=30) + n_end = _offset(n_latest) # e.g. timedelta(days=23) + + # Ranges overlap if neither is entirely before/after the other. + # "Before" means a *larger* offset (further in the past). + # Range A: [f_start .. f_end] (f_start >= f_end) + # Range B: [n_start .. n_end] (n_start >= n_end) + if n_end >= f_start or f_end >= n_start: + continue # no overlap + selected_indices.add(idx) + + # Return in the original order from next_tier_windows. + return [next_tier_windows[i] for i in sorted(selected_indices)] + + +# Aggregation helper +def aggregate_normalizer_results(results: List[Any]) -> Optional[Any]: + """Merge multiple normalizer results into one. + + Per-chunk display metadata (query ID, result count, share link) is + snapshotted before merging so that the display template can show + accurate per-chunk rows even though raw_results on the combined + normalizer is mutated in place. + + Parameters + ---------- + results : List[Any] + Normalizer results from individual chunk searches. + + Returns + ------- + Optional[Any] + The merged normalizer, or None if *results* is empty. + """ + if not results: + return None + + # Snapshot per-chunk display properties *before* mutating raw_results. + chunk_snapshots: List[_ChunkSnapshot] = [] + if len(results) > 1: + for r in results: + snapshot = _ChunkSnapshot( + region=getattr(r, "region", ""), + tenant_id=getattr(r, "tenant_id", ""), + service=getattr(r, "service", ""), + status=getattr(r, "status", ""), + results_returned=getattr(r, "results_returned", -1), + query_identifier=getattr(r, "query_identifier", None), + ) + # Trigger shareable_url creation while raw_results is intact. + try: + snapshot.shareable_url = r.shareable_url + except Exception: + snapshot.shareable_url = "Unable to create shareable link" + chunk_snapshots.append(snapshot) + + # Now merge raw_results into the first normalizer. + combined = results[0] + for r in results[1:]: + if hasattr(r, "raw_results") and hasattr(combined, "raw_results"): + combined.raw_results.extend(r.raw_results) + else: + log.warning( + "Cannot merge result of type %s — missing raw_results attribute.", + type(r).__name__, + ) + + # Attach frozen snapshots for the display template. + if chunk_snapshots: + combined._chunk_results = chunk_snapshots + + return combined + + +class _ChunkSnapshot: + """Frozen snapshot of a single chunk's display properties. + + Created *before* raw_results are merged so that each chunk's + results_returned, query_identifier, and shareable_url + reflect only that chunk's data. + """ + + __slots__ = ( + "region", + "tenant_id", + "service", + "status", + "results_returned", + "query_identifier", + "shareable_url", + ) + + def __init__( + self, + region: str, + tenant_id: str, + service: str, + status: str, + results_returned: int, + query_identifier: Optional[str], + shareable_url: str = "", + ): + self.region = region + self.tenant_id = tenant_id + self.service = service + self.status = status + self.results_returned = results_returned + self.query_identifier = query_identifier + self.shareable_url = shareable_url + + +# High-level wrapper for magics.py +def execute_chunked_search_from_magic( + cell: str, + command_args: list, + chunking_schedule: List[List[Tuple[str, Optional[str]]]], + app_fn: Callable, +) -> Optional[Any]: + """Execute a chunked search from the magics command flow. + + For each time window in the chunking schedule: + + 1. Modifies *cell* to replace earliest= / latest= with the + window's values. + 2. Rebuilds *command_args* with the modified cell. + 3. Calls app_fn(modified_args, …) exactly as the existing flow does. + 4. Collects successful results. + 5. Warns on failures but continues. + 6. Merges results via aggregate_normalizer_results. + + Parameters + ---------- + cell : str + The rendered Taegis QL query (with earliest=…). + command_args : list + The CLI argument list (may contain --cell, which will be replaced). + chunking_schedule : List[List[Tuple[str, Optional[str]]]] + Chunking tiers from :func:`~taegis_magic.core.filters.get_chunking_schedule`. + app_fn : Callable + The Typer app callable (taegis_magic.cli.app). + + Returns + ------- + Optional[Any] + Aggregated normalizer result, or None if all windows failed. + """ + + def _search_fn(modified_cell: str) -> Any: + """Run a single search via the CLI app.""" + # Build fresh args list with the modified cell + args = list(command_args) + + # Remove any existing --cell argument pair + clean_args: list = [] + skip_next = False + for i, arg in enumerate(args): + if skip_next: + skip_next = False + continue + if arg == "--cell": + skip_next = True + continue + clean_args.append(arg) + + clean_args.extend(["--cell", modified_cell]) + + result = app_fn(clean_args, prog_name="taegis", standalone_mode=False) + if result is None: + raise RuntimeError("Search returned None (possible empty result or error)") + return result + + return execute_chunked_search( + base_query=cell, + chunk_tiers=chunking_schedule, + search_fn=_search_fn, + aggregate_fn=aggregate_normalizer_results, + ) diff --git a/taegis_magic/core/filters.py b/taegis_magic/core/filters.py new file mode 100644 index 0000000..67d9bfa --- /dev/null +++ b/taegis_magic/core/filters.py @@ -0,0 +1,180 @@ +"""Custom Jinja2 filters for Taegis QL earliest and latest time-range clauses. + +Provides earliest_filter and latest_filter for use with +jinja2.Environment.filters. Each filter accepts a list of relative +timestamp strings and returns a rendered earliest= / latest= clause. +""" + +import logging +from typing import Dict, List, Optional, Tuple + +from taegis_magic.core.time_range import ( + generate_chunk_windows, + sort_timestamps_descending, +) + +log = logging.getLogger(__name__) + +# TaegisTimeRange — str subclass that carries chunk metadata +class TaegisTimeRange(str): + """String subclass that also carries chunk metadata. + + When cast to str (which Jinja2 does), it produces a valid + earliest=… or latest=… clause. + + Attributes + ---------- + chunks : List[List[Tuple[str, Optional[str]]]] + The full chunking schedule (list of tiers). + timestamps : List[str] + The original sorted timestamps. + direction : str + 'earliest' or 'latest'. + """ + + chunks: List[List[Tuple[str, Optional[str]]]] + timestamps: List[str] + direction: str + + def __new__( + cls, + value: str, + chunks: Optional[List[List[Tuple[str, Optional[str]]]]] = None, + timestamps: Optional[List[str]] = None, + direction: str = "earliest", + ): + instance = super().__new__(cls, value) + instance.chunks = chunks or [] + instance.timestamps = timestamps or [] + instance.direction = direction + return instance + + +# Module-level chunking registry +_CHUNKING_REGISTRY: Dict[str, List[List[Tuple[str, Optional[str]]]]] = {} + + +def get_chunking_schedule( + rendered_cell: str, +) -> Optional[List[List[Tuple[str, Optional[str]]]]]: + """Look up chunking metadata for a rendered cell string. + + Checks whether any registered earliest=… clause key appears in + *rendered_cell* and returns the corresponding chunking schedule. + + Parameters + ---------- + rendered_cell : str + The fully rendered Taegis QL query string. + + Returns + ------- + Optional[List[List[Tuple[str, Optional[str]]]]] + The chunking schedule if found, else None. + """ + if not rendered_cell: + return None + + for key, schedule in _CHUNKING_REGISTRY.items(): + if key in rendered_cell: + return schedule + + return None + + +def clear_chunking_registry() -> None: + """Clear the chunking registry.""" + _CHUNKING_REGISTRY.clear() + +# Jinja2 filter functions + +def earliest_filter(timestamps: List[str]) -> TaegisTimeRange: + """Jinja2 filter: {{ [] | earliest }}. + + 1. Sorts *timestamps* descending by duration. + 2. Generates the chunking schedule via :func:`generate_chunk_windows`. + 3. Returns a :class:`TaegisTimeRange` whose string value is + earliest= and whose .chunks carries the full schedule. + 4. Registers the schedule in :data:`_CHUNKING_REGISTRY`. + + Parameters + ---------- + timestamps : List[str] + Relative timestamp strings, e.g. ['-30d', '-15d', '-7d', '-1d']. + + Returns + ------- + TaegisTimeRange + String rendering to earliest=-30d (using the largest timestamp). + """ + sorted_ts = sort_timestamps_descending(timestamps) + chunks = generate_chunk_windows(sorted_ts) + + rendered_value = f"earliest={sorted_ts[0]}" + + result = TaegisTimeRange( + value=rendered_value, + chunks=chunks, + timestamps=sorted_ts, + direction="earliest", + ) + + # Register in module-level registry so execution layer can find it + _CHUNKING_REGISTRY[rendered_value] = chunks + log.debug( + "Registered earliest chunking schedule: %s (%d tiers)", + rendered_value, + len(chunks), + ) + + return result + + +def latest_filter(timestamps: Optional[List[str]] = None) -> TaegisTimeRange: + """Jinja2 filter: {{ [] | latest }}. + + - If *timestamps* is None or empty, returns TaegisTimeRange('') + (defaults to now) with empty .chunks. + - Otherwise returns TaegisTimeRange('latest=') with + .chunks populated. + + Parameters + ---------- + timestamps : Optional[List[str]] + Relative timestamp strings, or None. + + Returns + ------- + TaegisTimeRange + String rendering to latest=-7d (the smallest / most recent + timestamp) or '' when defaulting to now. + """ + if not timestamps: + return TaegisTimeRange( + value="", + chunks=[], + timestamps=[], + direction="latest", + ) + + sorted_ts = sort_timestamps_descending(timestamps) + chunks = generate_chunk_windows(sorted_ts) + + # The smallest (most recent) timestamp is the last after descending sort + rendered_value = f"latest={sorted_ts[-1]}" + + result = TaegisTimeRange( + value=rendered_value, + chunks=chunks, + timestamps=sorted_ts, + direction="latest", + ) + + _CHUNKING_REGISTRY[rendered_value] = chunks + log.debug( + "Registered latest chunking schedule: %s (%d tiers)", + rendered_value, + len(chunks), + ) + + return result diff --git a/taegis_magic/core/time_range.py b/taegis_magic/core/time_range.py new file mode 100644 index 0000000..f3ee0b0 --- /dev/null +++ b/taegis_magic/core/time_range.py @@ -0,0 +1,195 @@ +"""Taegis QL relative timestamp parsing and time-range window generation. +Time range utility module for converting relative timestamps into chunking schedules for Taegis QL queries. +""" + +import math +import re +from datetime import timedelta +from typing import List, Optional, Tuple + +import pandas as pd + +# Pandas-supported units for simple relative timestamps +UNIT_MAP = { + "s": "s", + "m": "min", + "h": "h", + "d": "d", + "w": "W", +} + +_APPROXIMATE_UNIT_MAP = { + "mo": (30, "d"), + "y": (365, "d"), +} + +# Pattern to parse relative timestamps like '-30d', '-3mo', '-1y', '7d', etc. +_RELATIVE_TS_PATTERN = re.compile( + r"^[+-]?(\d+)(s|m|h|d|w|mo|y)$", re.IGNORECASE +) + + +def parse_relative_timestamp(ts: str) -> timedelta: + """Parse a Taegis QL relative timestamp into a timedelta. + + Supports: s, m, h, d, w, mo, y. According to : https://docs.taegis.secureworks.com/search/querylanguage/advanced_search/#time-ranges + Parameters + ---------- + ts : str + Relative timestamp string, e.g. '-30d', '-3mo', '1y'. + + Returns + ------- + timedelta + Positive timedelta representing the duration. + + Raises + ------ + ValueError + If the timestamp string cannot be parsed. + """ + ts = ts.strip() + match = _RELATIVE_TS_PATTERN.match(ts) + if not match: + raise ValueError( + f"Cannot parse relative timestamp: {ts!r}. " + f"Expected format like '-30d', '-3mo', '-1y'." + ) + + value = int(match.group(1)) + unit = match.group(2).lower() + + if unit in UNIT_MAP: + return pd.to_timedelta(value, unit=UNIT_MAP[unit]).to_pytimedelta() + + if unit in _APPROXIMATE_UNIT_MAP: + multiplier, pandas_unit = _APPROXIMATE_UNIT_MAP[unit] + return pd.to_timedelta(value * multiplier, unit=pandas_unit).to_pytimedelta() + + raise ValueError(f"Unknown unit: {unit!r} in timestamp {ts!r}.") + + +def sort_timestamps_descending(timestamps: List[str]) -> List[str]: + """Sort timestamps by absolute duration, largest first. + + Parameters + ---------- + timestamps : List[str] + List of relative timestamp strings, e.g.['-7d', '-30d', '-1d', '-15d'] + + Returns + ------- + List[str] + Sorted list with the largest duration first. + """ + return sorted(timestamps, key=lambda ts: parse_relative_timestamp(ts), reverse=True) + + +def generate_chunk_windows( + timestamps: List[str], +) -> List[List[Tuple[str, Optional[str]]]]: + """Given a list of relative timestamps, produce chunking tiers. + + The timestamps are first sorted descending by duration. + - TIEr 0 is the full range window from the largest timestamp to NOW. + - Tier 1 is a fallback tier of tier 0's range, chunked into windows of the second-largest timestamp. + - Tier N is a fallback tier of tier 0's range, chunked into windows of the N-th largest timestamp. + + Parameters + ---------- + timestamps : List[str] + List of relative timestamp strings, e.g. ['-30d', '-15d', '-7d', '-1d']. Sorted descending by duration for chunking best effort. + + Returns + ------- + List[List[Tuple[str, Optional[str]]]] + A list of tiers. Each tier is a list of (earliest_clause, latest_clause) tuples. + + Raises + ------ + ValueError + If fewer than 1 timestamp is provided. + """ + if not timestamps: + raise ValueError("At least one timestamp is required.") + + sorted_ts = sort_timestamps_descending(timestamps) + + # Parse all durations for arithmetic + durations = [pd.Timedelta(parse_relative_timestamp(ts)) for ts in sorted_ts] + + full_range = durations[0] + full_range_ts = sorted_ts[0] + + # Tier 0: single full-range window + tiers: List[List[Tuple[str, Optional[str]]]] = [ + [(f"earliest={full_range_ts}", None)] + ] + + # Tier 1..N: progressively smaller windows + for tier_idx in range(1, len(sorted_ts)): + window_size = durations[tier_idx] + window_ts = sorted_ts[tier_idx] + + # Calculate how many windows for coverage + num_windows = math.ceil(full_range / window_size) + + tier_windows: List[Tuple[str, Optional[str]]] = [] + for i in range(num_windows): + + earliest_offset = full_range - (window_size * i) + latest_offset = full_range - (window_size * (i + 1)) + + # Convert offsets back to relative timestamp strings + latest_seconds = latest_offset.total_seconds() + + earliest_clause = f"earliest=-{_offset_to_relative_str(earliest_offset)}" + + if latest_seconds <= 0: + # This is the most recent window — LATEST defaults to now + latest_clause = None + else: + latest_clause = f"latest=-{_offset_to_relative_str(latest_offset)}" + + tier_windows.append((earliest_clause, latest_clause)) + + tiers.append(tier_windows) + + return tiers + + +def _offset_to_relative_str(offset: timedelta) -> str: + """Convert a timedelta offset back to a compact relative timestamp string. + + Uses the largest exact unit that divides evenly, falling back to seconds. + + Parameters + ---------- + offset : timedelta + The time offset (positive). + + Returns + ------- + str + Compact string like '30d', '2w', '3600s', etc. + """ + total_seconds = int(pd.Timedelta(offset).total_seconds()) + + if total_seconds <= 0: + return "0s" + + # Try largest units first + if total_seconds % (7 * 86400) == 0: + weeks = total_seconds // (7 * 86400) + return f"{weeks}w" + if total_seconds % 86400 == 0: + days = total_seconds // 86400 + return f"{days}d" + if total_seconds % 3600 == 0: + hours = total_seconds // 3600 + return f"{hours}h" + if total_seconds % 60 == 0: + minutes = total_seconds // 60 + return f"{minutes}m" + + return f"{total_seconds}s" diff --git a/taegis_magic/magics.py b/taegis_magic/magics.py index 444422c..e2f3ba4 100644 --- a/taegis_magic/magics.py +++ b/taegis_magic/magics.py @@ -149,6 +149,12 @@ def taegis(self, line: str, cell: Optional[str] = None): if magic_args and magic_args.cell_template: template_environment = load_jinja2_template_environment() + + from taegis_magic.core.filters import earliest_filter, latest_filter, clear_chunking_registry + clear_chunking_registry() + template_environment.filters["earliest"] = earliest_filter + template_environment.filters["latest"] = latest_filter + try: if magic_args.cell_template_file: template = template_environment.get_template( @@ -216,10 +222,24 @@ def taegis(self, line: str, cell: Optional[str] = None): command_args.extend(["--cell", cell]) # os.environ["TAEGIS_MAGIC_OUTPUT"] = "True" - try: - result = app(command_args, prog_name="taegis", standalone_mode=False) - except (SystemExit, TransportQueryError): - result = None + from taegis_magic.core.filters import get_chunking_schedule + chunking_schedule = get_chunking_schedule(cell) if cell else None + + if chunking_schedule: + self.shell.user_ns["_taegis_magic_chunking_schedule"] = chunking_schedule + + from taegis_magic.core.chunked_search import execute_chunked_search_from_magic + result = execute_chunked_search_from_magic( + cell=cell, + command_args=command_args, + chunking_schedule=chunking_schedule, + app_fn=app, + ) + else: + try: + result = app(command_args, prog_name="taegis", standalone_mode=False) + except (SystemExit, TransportQueryError): + result = None # os.environ["TAEGIS_MAGIC_OUTPUT"] = "False" if not result: diff --git a/taegis_magic/templates/taegis_search_results.md.jinja b/taegis_magic/templates/taegis_search_results.md.jinja index 9b9e843..fb5dba4 100644 --- a/taegis_magic/templates/taegis_search_results.md.jinja +++ b/taegis_magic/templates/taegis_search_results.md.jinja @@ -8,6 +8,13 @@ ID: *{{ obj.query_identifier }}* |Region |Tenant |Service |Status |Num. Total |Num. Aggregates | |----------------|-------------------|-----------------|----------------|--------------------------------|--------------------------| |{{ obj.region }}|{{ obj.tenant_id }}|{{ obj.service }}|{{ obj.status }}|{{ obj.total_results|validate_int }}|{{ obj.aggregate|length }}| +{% elif obj._chunk_results is defined and obj._chunk_results -%} +|Chunk|Region |Tenant |Service |Status |Num. Returned |Query ID |Link | +|-----|----------------|-------------------|-----------------|----------------|---------------------------------------|:-----------------------|:----------------------| +{% for chunk in obj._chunk_results -%} +|{{ loop.index }}|{{ chunk.region }}|{{ chunk.tenant_id }}|{{ chunk.service }}|{{ chunk.status }}|{{ chunk.results_returned|validate_int }}|{{ chunk.query_identifier or 'N/A' }}|{{ chunk.shareable_url }}| +{% endfor -%} +|**Total**| | | | |**{{ obj.results_returned|validate_int }}**| | | {% else %} |Region |Tenant |Service |Status |Num. Total |Num. Returned |Link | |----------------|-------------------|-----------------|----------------|------------------------------------|---------------------------------------|:----------------------|