From e8d821ca7fb8a8eab18e9b2b263f3bbede350bec Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 09:03:52 -0700 Subject: [PATCH 01/10] [#993][IMP] Add timeline visualization group routes - Add reusable helpers to build grouped visualization events with consistent title/style formatting. - Extend timeline visualization APIs with , , and endpoints. - Update and handlers to use shared grouping expansion behavior. - Map configured event color codes to readable group labels and fallback to . --- .../rest/case/case_timeline_routes.py | 142 +++++++++++++++--- 1 file changed, 120 insertions(+), 22 deletions(-) diff --git a/source/app/blueprints/rest/case/case_timeline_routes.py b/source/app/blueprints/rest/case/case_timeline_routes.py index 2c8a83cbc..729d49869 100644 --- a/source/app/blueprints/rest/case/case_timeline_routes.py +++ b/source/app/blueprints/rest/case/case_timeline_routes.py @@ -174,25 +174,97 @@ def case_get_timeline_state(caseid): return response_error('No timeline state for this case. Add an event to begin') +def _get_visualization_event(row, group_name): + content = row.event_content.replace('\n', '
') if row.event_content else '' + visualized_event = { + 'date': row.event_date, + 'group': group_name, + 'content': row.event_title, + 'title': f"{row.event_date.strftime('%Y-%m-%dT%H:%M:%S')}
{content}", + 'unique_id': row.event_id + } + + if row.event_color: + visualized_event['style'] = f'background-color: {row.event_color};' + + return visualized_event + + +def _expand_timeline_by_group(timeline, events_groups, fallback_group): + tim = [] + for row in timeline: + grouped_values = events_groups.get(row.event_id, []) or [fallback_group] + for group_name in grouped_values: + tim.append(_get_visualization_event(row, group_name)) + + return tim + + +def _build_events_groups(rows, value_key): + events_groups = {} + for row in rows: + event_id = row.event_id + group_value = getattr(row, value_key, None) + if not group_value: + continue + + events_groups.setdefault(event_id, []) + if group_value not in events_groups[event_id]: + events_groups[event_id].append(group_value) + + return events_groups + + +def _get_event_color_group_name(event_color): + event_color_map = { + '#fff': 'White', + '#1572e899': 'Blue', + '#6861ce99': 'Purple', + '#48abf799': 'Light blue', + '#31ce3699': 'Green', + '#f2596199': 'Red', + '#ffad4699': 'Orange' + } + + if not event_color: + return 'No color' + + return event_color_map.get(event_color.lower(), event_color) + + @case_timeline_rest_blueprint.route('/case/timeline/visualize/data/by-asset', methods=['GET']) @ac_requires_case_identifier(CaseAccessLevel.read_only, CaseAccessLevel.full_access) @ac_api_requires() def case_getgraph_assets(caseid): - assets_cache = get_assets_by_case(caseid) timeline = get_events_by_case(caseid) + assets_cache = get_assets_by_case(caseid) + events_assets = _build_events_groups(assets_cache, 'asset_name') + + tim = _expand_timeline_by_group(timeline, events_assets, 'No linked assets') + + res = { + "events": tim + } + + return response_success("", data=res) - tim = [] - for row in timeline: - for asset in assets_cache: - if asset.event_id == row.event_id: - tmp = {'date': row.event_date, 'group': asset.asset_name, 'content': row.event_title, - 'title': f"{row.event_date.strftime('%Y-%m-%dT%H:%M:%S')} - {row.event_content}"} - if row.event_color: - tmp['style'] = f'background-color: {row.event_color};' +@case_timeline_rest_blueprint.route('/case/timeline/visualize/data/by-ioc', methods=['GET']) +@ac_requires_case_identifier(CaseAccessLevel.read_only, CaseAccessLevel.full_access) +@ac_api_requires() +def case_getgraph_iocs(caseid): + timeline = get_events_by_case(caseid) + iocs_cache = CaseEventsIoc.query.with_entities( + CaseEventsIoc.event_id, + Ioc.ioc_value + ).join( + CaseEventsIoc.ioc + ).filter( + CaseEventsIoc.case_id == caseid + ).all() + events_iocs = _build_events_groups(iocs_cache, 'ioc_value') - tmp['unique_id'] = row.event_id - tim.append(tmp) + tim = _expand_timeline_by_group(timeline, events_iocs, 'No linked IOCs') res = { "events": tim @@ -206,24 +278,50 @@ def case_getgraph_assets(caseid): @ac_api_requires() def case_getgraph(caseid): timeline = get_events_by_case(caseid) + events_categories = {} + for row in timeline: + group_name = row.category[0].name if row.category else 'Uncategorized' + events_categories[row.event_id] = [group_name] - tim = [] + tim = _expand_timeline_by_group(timeline, events_categories, 'Uncategorized') + + res = { + "events": tim + } + + return response_success("", data=res) + + +@case_timeline_rest_blueprint.route('/case/timeline/visualize/data/by-tag', methods=['GET']) +@ac_requires_case_identifier(CaseAccessLevel.read_only, CaseAccessLevel.full_access) +@ac_api_requires() +def case_getgraph_tags(caseid): + timeline = get_events_by_case(caseid) + events_tags = {} for row in timeline: + tags = [tag.strip() for tag in (row.event_tags or '').split(',') if tag.strip()] + events_tags[row.event_id] = list(dict.fromkeys(tags)) + + tim = _expand_timeline_by_group(timeline, events_tags, 'No tags') - tmp = {'date': row.event_date, 'group': row.category[0].name if row.category else 'Uncategorized', 'content': row.event_title} + res = { + "events": tim + } - if row.event_content: - content = row.event_content.replace('\n', '
') - else: - content = '' + return response_success("", data=res) - tmp['title'] = f"{row.event_date.strftime('%Y-%m-%dT%H:%M:%S')}
{content}" - if row.event_color: - tmp['style'] = f'background-color: {row.event_color};' +@case_timeline_rest_blueprint.route('/case/timeline/visualize/data/by-color', methods=['GET']) +@ac_requires_case_identifier(CaseAccessLevel.read_only, CaseAccessLevel.full_access) +@ac_api_requires() +def case_getgraph_colors(caseid): + timeline = get_events_by_case(caseid) + events_colors = {} + for row in timeline: + color_name = _get_event_color_group_name(row.event_color) + events_colors[row.event_id] = [color_name] - tmp['unique_id'] = row.event_id - tim.append(tmp) + tim = _expand_timeline_by_group(timeline, events_colors, 'No color') res = { "events": tim From 6e350f264c48d8104bb88c4eb0d4d3216abb23cb Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 09:04:04 -0700 Subject: [PATCH 02/10] [#993][IMP] Route timeline visualization modes in frontend - Add a mode-to-endpoint map for ioc, asset, color, tag, and category groupings. - Default unknown group-by values to the category visualization endpoint. - Update grouped mode detection to use the new mode list before setting timeline groups. --- ui/src/pages/case.timeline.visu.js | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/ui/src/pages/case.timeline.visu.js b/ui/src/pages/case.timeline.visu.js index c8d1bca85..fe16a13a0 100644 --- a/ui/src/pages/case.timeline.visu.js +++ b/ui/src/pages/case.timeline.visu.js @@ -1,10 +1,13 @@ function visualizeTimeline(group) { - ggr = ['asset', 'category'] - if (group == 'asset') { - src = '/case/timeline/visualize/data/by-asset'; - } else { - src = '/case/timeline/visualize/data/by-category'; - } + const groupedModes = ['ioc', 'asset', 'color', 'tag', 'category']; + const groupToEndpoint = { + ioc: '/case/timeline/visualize/data/by-ioc', + asset: '/case/timeline/visualize/data/by-asset', + color: '/case/timeline/visualize/data/by-color', + tag: '/case/timeline/visualize/data/by-tag', + category: '/case/timeline/visualize/data/by-category' + }; + const src = groupToEndpoint[group] || groupToEndpoint.category; get_request_api(src) .done((data) => { @@ -54,7 +57,7 @@ function visualizeTimeline(group) { container.innerHTML = ''; $('#card_main_load').show(); timeline = new vis.Timeline(container, null, options); - if (ggr.includes(group)) { + if (groupedModes.includes(group)) { timeline.setGroups(groups); } timeline.setItems(items); @@ -70,4 +73,4 @@ function refresh_timeline_graph(){ urlParams = new URLSearchParams(queryString); group = urlParams.get('group-by'); visualizeTimeline(group); -} \ No newline at end of file +} From 31b788c7e2fc4890c0486a7c3fc3ec91e2dc1426 Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 09:04:09 -0700 Subject: [PATCH 03/10] [#993][IMP] Add timeline graph group controls - Add navbar controls for Group by IOC, Group by color, and Group by tag. - Keep existing no-group, asset, and category controls intact. - Preserve existing visualize page layout and behavior while expanding grouping options. --- .../pages/case/templates/case_graph_timeline.html | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/source/app/blueprints/pages/case/templates/case_graph_timeline.html b/source/app/blueprints/pages/case/templates/case_graph_timeline.html index ac1efb38b..205c992fa 100644 --- a/source/app/blueprints/pages/case/templates/case_graph_timeline.html +++ b/source/app/blueprints/pages/case/templates/case_graph_timeline.html @@ -25,9 +25,18 @@ + + + @@ -82,4 +91,4 @@ }); -{% endblock javascripts %} \ No newline at end of file +{% endblock javascripts %} From 8921f7dc600ebf5fb32eed05698c2bda2149ec4d Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 09:04:13 -0700 Subject: [PATCH 04/10] [#993][IMP] Expand timeline visualize actions in main toolbar - Replace the top Toggle view button with a direct Visualize button linking to timeline visualization. - Add dropdown shortcuts for Visualize by IOC, Visualize by color, and Visualize by tag. - Keep existing tree/compact toggles and export/upload actions unchanged. --- .../pages/case/templates/case_timeline.html | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/source/app/blueprints/pages/case/templates/case_timeline.html b/source/app/blueprints/pages/case/templates/case_timeline.html index 1caca57b8..78d4f861f 100644 --- a/source/app/blueprints/pages/case/templates/case_timeline.html +++ b/source/app/blueprints/pages/case/templates/case_timeline.html @@ -53,9 +53,9 @@