-{% endblock javascripts %}
\ No newline at end of file
+{% endblock javascripts %}
diff --git a/source/app/blueprints/pages/case/templates/modal_add_case_event.html b/source/app/blueprints/pages/case/templates/modal_add_case_event.html
index 2a6d4fa21..2ea86eb45 100644
--- a/source/app/blueprints/pages/case/templates/modal_add_case_event.html
+++ b/source/app/blueprints/pages/case/templates/modal_add_case_event.html
@@ -172,7 +172,7 @@
{% if event.event_id %} Event ID #{{ event.event_i
@@ -326,4 +326,4 @@
{% if event.event_id %} Event ID #{{ event.event_i
]);
$('#event_iocs').trigger('change');
-{% endif %}
\ No newline at end of file
+{% endif %}
diff --git a/source/app/blueprints/rest/case/case_timeline_routes.py b/source/app/blueprints/rest/case/case_timeline_routes.py
index 2c8a83cbc..da289c60c 100644
--- a/source/app/blueprints/rest/case/case_timeline_routes.py
+++ b/source/app/blueprints/rest/case/case_timeline_routes.py
@@ -174,25 +174,129 @@ 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 ''
+ styles = []
+
+ if row.event_color:
+ styles.append(f'background-color: {row.event_color};')
+
+ # Highlight child events in visualization with a dashed outline.
+ if row.parent_event_id is not None:
+ styles.append('border: 1px dashed #6c757d;')
+ styles.append('box-sizing: border-box;')
+
+ 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 styles:
+ visualized_event['style'] = ' '.join(styles)
+
+ 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)
+
+
+def _is_include_children():
+ include_children = request.args.get('include-children')
+ if include_children is None:
+ return True
+
+ normalized_value = str(include_children).strip().lower()
+ if '?' in normalized_value:
+ normalized_value = normalized_value.split('?', maxsplit=1)[0]
+ if '&' in normalized_value:
+ normalized_value = normalized_value.split('&', maxsplit=1)[0]
+
+ return normalized_value in ('1', 'true', 'yes', 'on')
+
+
+def _get_visualization_timeline(caseid):
+ timeline = get_events_by_case(caseid)
+ if _is_include_children():
+ return timeline
+
+ return [row for row in timeline if row.parent_event_id is None]
+
+
@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):
+ timeline = _get_visualization_timeline(caseid)
assets_cache = get_assets_by_case(caseid)
- timeline = get_events_by_case(caseid)
+ events_assets = _build_events_groups(assets_cache, 'asset_name')
- 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}"}
+ tim = _expand_timeline_by_group(timeline, events_assets, 'No linked assets')
- if row.event_color:
- tmp['style'] = f'background-color: {row.event_color};'
+ res = {
+ "events": tim
+ }
+
+ return response_success("", data=res)
- tmp['unique_id'] = row.event_id
- tim.append(tmp)
+
+@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_visualization_timeline(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')
+
+ tim = _expand_timeline_by_group(timeline, events_iocs, 'No linked IOCs')
res = {
"events": tim
@@ -205,25 +309,51 @@ def case_getgraph_assets(caseid):
@ac_requires_case_identifier(CaseAccessLevel.read_only, CaseAccessLevel.full_access)
@ac_api_requires()
def case_getgraph(caseid):
- timeline = get_events_by_case(caseid)
+ timeline = _get_visualization_timeline(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_visualization_timeline(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))
- tmp = {'date': row.event_date, 'group': row.category[0].name if row.category else 'Uncategorized', 'content': row.event_title}
+ tim = _expand_timeline_by_group(timeline, events_tags, 'No tags')
- if row.event_content:
- content = row.event_content.replace('\n', '
')
- else:
- content = ''
+ res = {
+ "events": tim
+ }
+
+ 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_visualization_timeline(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
diff --git a/source/app/forms.py b/source/app/forms.py
index 386630878..e73345e77 100644
--- a/source/app/forms.py
+++ b/source/app/forms.py
@@ -183,7 +183,7 @@ class CaseEventForm(FlaskForm):
event_assets = SelectField(u'Event Asset')
event_category_id = SelectField(u'Event Category')
event_tz = StringField(u'Event Timezone', validators=[DataRequired()])
- event_in_summary = BooleanField(u'Add to summary')
+ event_in_summary = BooleanField(u'Add to visualization')
event_tags = StringField(u'Event Tags')
event_in_graph = BooleanField(u'Display in graph')
diff --git a/ui/src/pages/case.timeline.visu.js b/ui/src/pages/case.timeline.visu.js
index c8d1bca85..615eb6d73 100644
--- a/ui/src/pages/case.timeline.visu.js
+++ b/ui/src/pages/case.timeline.visu.js
@@ -1,12 +1,343 @@
-function visualizeTimeline(group) {
- ggr = ['asset', 'category']
- if (group == 'asset') {
- src = '/case/timeline/visualize/data/by-asset';
+var timeline = null;
+var timeline_items = null;
+var g_event_id = null;
+var g_event_desc_editor = null;
+var current_timeline = [];
+var current_visualization_group = null;
+var current_visualization_include_children = true;
+
+function get_visualization_state_key() {
+ return `timeline.visualization.state:${get_caseid()}`;
+}
+
+function get_visualization_state() {
+ try {
+ const raw_state = localStorage.getItem(get_visualization_state_key());
+ if (!raw_state) {
+ return null;
+ }
+
+ return JSON.parse(raw_state);
+ } catch (error) {
+ return null;
+ }
+}
+
+function save_visualization_state() {
+ if (!timeline) {
+ return;
+ }
+
+ try {
+ const window_data = timeline.getWindow();
+ const state = {
+ start: window_data.start.toISOString(),
+ end: window_data.end.toISOString(),
+ group: current_visualization_group,
+ include_children: current_visualization_include_children
+ };
+ localStorage.setItem(get_visualization_state_key(), JSON.stringify(state));
+ } catch (error) {
+ // Ignore storage failures (private mode/quota), timeline still works without persistence.
+ }
+}
+
+function query_bool(value, default_value) {
+ if (value === null || value === undefined) {
+ return default_value;
+ }
+
+ return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase());
+}
+
+function clear_visualization_state() {
+ try {
+ localStorage.removeItem(get_visualization_state_key());
+ } catch (error) {
+ // Ignore storage failures.
+ }
+}
+
+function get_timeline_events_cache(callback) {
+ get_request_api('/case/timeline/events/list')
+ .done((data) => {
+ if (data.status === 'success' && data.data && data.data.timeline) {
+ current_timeline = data.data.timeline;
+ } else {
+ current_timeline = [];
+ }
+
+ if (callback) {
+ callback();
+ }
+ });
+}
+
+function edit_in_event_desc() {
+ if ($('#container_event_desc_content').is(':visible')) {
+ $('#container_event_description').show(100);
+ $('#container_event_desc_content').hide(100);
+ $('#event_edition_btn').hide(100);
+ $('#event_preview_button').hide(100);
} else {
- src = '/case/timeline/visualize/data/by-category';
+ $('#event_preview_button').show(100);
+ $('#event_edition_btn').show(100);
+ $('#container_event_desc_content').show(100);
+ $('#container_event_description').hide(100);
+ }
+}
+
+function preview_event_description(no_btn_update) {
+ if (!$('#container_event_description').is(':visible')) {
+ event_desc = g_event_desc_editor.getValue();
+ converter = get_showdown_convert();
+ html = converter.makeHtml(do_md_filter_xss(event_desc));
+ event_desc_html = do_md_filter_xss(html);
+ $('#target_event_desc').html(event_desc_html);
+ $('#container_event_description').show();
+ if (!no_btn_update) {
+ $('#event_preview_button').html('');
+ }
+ $('#container_event_desc_content').hide();
+ } else {
+ $('#container_event_description').hide();
+ if (!no_btn_update) {
+ $('#event_preview_button').html('');
+ }
+
+ $('#event_preview_button').html('');
+ $('#container_event_desc_content').show();
+ }
+}
+
+function show_time_converter() {
+ $('#event_date_convert').show();
+ $('#event_date_convert_input').focus();
+ $('#event_date_inputs').hide();
+}
+
+function hide_time_converter() {
+ $('#event_date_convert').hide();
+ $('#event_date_inputs').show();
+ $('#event_date').focus();
+}
+
+function time_converter() {
+ let date_val = $('#event_date_convert_input').val();
+
+ var data_sent = Object();
+ data_sent['date_value'] = date_val;
+ data_sent['csrf_token'] = $('#csrf_token').val();
+
+ post_request_api('/case/timeline/events/convert-date', JSON.stringify(data_sent))
+ .done(function(data) {
+ if (notify_auto_api(data)) {
+ $('#event_date').val(data.data.date);
+ $('#event_time').val(data.data.time);
+ $('#event_tz').val(data.data.tz);
+ hide_time_converter();
+ $('#convert_bad_feedback').text('');
+ }
+ })
+ .fail(function() {
+ $('#convert_bad_feedback').text('Unable to find a matching pattern for the date');
+ });
+}
+
+function duplicate_event(id) {
+ window.location.hash = id;
+ clear_api_error();
+
+ get_request_api(`/case/timeline/events/duplicate/${id}`)
+ .done((data) => {
+ if (notify_auto_api(data)) {
+ refresh_timeline_graph();
+ }
+ });
+}
+
+function delete_event(id) {
+ window.location.hash = id;
+ do_deletion_prompt("You are about to delete event #" + id)
+ .then((doDelete) => {
+ if (doDelete) {
+ post_request_api(`/case/timeline/events/delete/${id}`)
+ .done(function(data) {
+ if (notify_auto_api(data)) {
+ refresh_timeline_graph();
+ $('#modal_add_event').modal('hide');
+ }
+ });
+ }
+ });
+}
+
+function update_event(event_id) {
+ update_event_ext(event_id, true);
+}
+
+function update_event_ext(event_id, do_close) {
+ if (event_id === undefined || event_id === null) {
+ event_id = g_event_id;
+ }
+
+ window.location.hash = event_id;
+ clear_api_error();
+ var data_sent = $('#form_new_event').serializeObject();
+ data_sent['event_date'] = `${$('#event_date').val()}T${$('#event_time').val()}`;
+ data_sent['event_in_summary'] = $('#event_in_summary').is(':checked');
+ data_sent['event_in_graph'] = $('#event_in_graph').is(':checked');
+ data_sent['event_sync_iocs_assets'] = $('#event_sync_iocs_assets').is(':checked');
+ data_sent['event_tags'] = $('#event_tags').val();
+ data_sent['event_assets'] = $('#event_assets').val();
+ data_sent['event_iocs'] = $('#event_iocs').val();
+ data_sent['event_tz'] = $('#event_tz').val();
+ data_sent['event_content'] = g_event_desc_editor.getValue();
+ data_sent['parent_event_id'] = $('#parent_event_id').val() || null;
+
+ ret = get_custom_attributes_fields();
+ has_error = ret[0].length > 0;
+ attributes = ret[1];
+
+ if (has_error) {
+ return false;
}
- get_request_api(src)
+ data_sent['custom_attributes'] = attributes;
+
+ post_request_api(`/case/timeline/events/update/${event_id}`, JSON.stringify(data_sent), true)
+ .done(function(data) {
+ if (notify_auto_api(data)) {
+ refresh_timeline_graph();
+
+ if (do_close !== undefined && do_close === true) {
+ $('#modal_add_event').modal('hide');
+ }
+
+ $('#submit_new_event').text("Saved").addClass('btn-outline-success').removeClass('btn-outline-danger').removeClass('btn-outline-warning');
+ $('#last_saved').removeClass('btn-danger').addClass('btn-success');
+ $('#last_saved > i').attr('class', "fa-solid fa-file-circle-check");
+ }
+ });
+}
+
+function edit_event(id) {
+ const url = '/case/timeline/events/' + id + '/modal' + case_param();
+ window.location.hash = id;
+
+ $('#modal_add_event_content').load(url, function(response, status, xhr) {
+ hide_minimized_modal_box();
+ if (status !== "success") {
+ ajax_notify_error(xhr, url);
+ return false;
+ }
+
+ g_event_id = id;
+ g_event_desc_editor = get_new_ace_editor('event_description', 'event_desc_content', 'target_event_desc',
+ function() {
+ $('#last_saved').addClass('btn-danger').removeClass('btn-success');
+ $('#last_saved > i').attr('class', "fa-solid fa-file-circle-exclamation");
+ }, null);
+
+ g_event_desc_editor.setOption("minLines", "6");
+ preview_event_description(true);
+ headers = get_editor_headers('g_event_desc_editor', null, 'event_edition_btn');
+ $('#event_edition_btn').append(headers);
+ edit_in_event_desc();
+
+ get_timeline_events_cache(function() {
+ let parent_selector = $('#parent_event_id');
+
+ // Add empty option
+ let option = $('