diff --git a/web/api/serializers.py b/web/api/serializers.py index 1fd0b7e91..1acbfacf6 100644 --- a/web/api/serializers.py +++ b/web/api/serializers.py @@ -1,6 +1,5 @@ from dashboard.models import * -from django.contrib.humanize.templatetags.humanize import (naturalday, - naturaltime) +from django.contrib.humanize.templatetags.humanize import (naturalday, naturaltime) from django.db.models import F, JSONField, Value from recon_note.models import * from reNgine.common_func import * @@ -8,6 +7,29 @@ from scanEngine.models import * from startScan.models import * from targetApp.models import * +from dashboard.models import InAppNotification + + +class InAppNotificationSerializer(serializers.ModelSerializer): + class Meta: + model = InAppNotification + fields = [ + 'id', + 'title', + 'description', + 'icon', + 'is_read', + 'created_at', + 'notification_type', + 'status', + 'redirect_link', + 'open_in_new_tab', + 'project' + ] + read_only_fields = ['id', 'created_at'] + + def get_project_name(self, obj): + return obj.project.name if obj.project else None class SearchHistorySerializer(serializers.ModelSerializer): diff --git a/web/api/urls.py b/web/api/urls.py index cfdd8f265..39ceb84a7 100644 --- a/web/api/urls.py +++ b/web/api/urls.py @@ -19,6 +19,7 @@ router.register(r'listIps', IpAddressViewSet) router.register(r'listActivityLogs', ListActivityLogsViewSet) router.register(r'listScanLogs', ListScanLogsViewSet) +router.register(r'notifications', InAppNotificationManagerViewSet, basename='notification') urlpatterns = [ url('^', include(router.urls)), diff --git a/web/api/views.py b/web/api/views.py index 8f23e1e18..740967753 100644 --- a/web/api/views.py +++ b/web/api/views.py @@ -14,7 +14,8 @@ from rest_framework import viewsets from rest_framework.response import Response from rest_framework.views import APIView -from rest_framework.status import HTTP_400_BAD_REQUEST +from rest_framework.status import HTTP_400_BAD_REQUEST, HTTP_204_NO_CONTENT +from rest_framework.decorators import action from recon_note.models import * from reNgine.celery import app @@ -33,6 +34,71 @@ logger = logging.getLogger(__name__) +class InAppNotificationManagerViewSet(viewsets.ModelViewSet): + """ + This class manages the notification model, provided CRUD operation on notif model + such as read notif, clear all, fetch all notifications etc + """ + serializer_class = InAppNotificationSerializer + pagination_class = None + + def get_queryset(self): + # we will see later if user based notif is needed + # return InAppNotification.objects.filter(user=self.request.user) + project_slug = self.request.query_params.get('project_slug') + queryset = InAppNotification.objects.all() + if project_slug: + queryset = queryset.filter( + Q(project__slug=project_slug) | Q(notification_type='system') + ) + return queryset.order_by('-created_at') + + @action(detail=False, methods=['post']) + def mark_all_read(self, request): + # marks all notification read + project_slug = self.request.query_params.get('project_slug') + queryset = self.get_queryset() + + if project_slug: + queryset = queryset.filter( + Q(project__slug=project_slug) | Q(notification_type='system') + ) + queryset.update(is_read=True) + return Response(status=HTTP_204_NO_CONTENT) + + @action(detail=True, methods=['post']) + def mark_read(self, request, pk=None): + # mark individual notification read when cliked + notification = self.get_object() + notification.is_read = True + notification.save() + return Response(status=HTTP_204_NO_CONTENT) + + @action(detail=False, methods=['get']) + def unread_count(self, request): + # this fetches the count for unread notif mainly for the badge + project_slug = self.request.query_params.get('project_slug') + queryset = self.get_queryset() + if project_slug: + queryset = queryset.filter( + Q(project__slug=project_slug) | Q(notification_type='system') + ) + count = queryset.filter(is_read=False).count() + return Response({'count': count}) + + @action(detail=False, methods=['post']) + def clear_all(self, request): + # when clicked on the clear button this must be called to clear all notif + project_slug = self.request.query_params.get('project_slug') + queryset = self.get_queryset() + if project_slug: + queryset = queryset.filter( + Q(project__slug=project_slug) | Q(notification_type='system') + ) + queryset.delete() + return Response(status=HTTP_204_NO_CONTENT) + + class OllamaManager(APIView): def get(self, request): """ @@ -943,8 +1009,21 @@ def get(self, request): return_response['status'] = True return_response['latest_version'] = latest_version return_response['current_version'] = current_version - return_response['update_available'] = version.parse(current_version) < version.parse(latest_version) - if version.parse(current_version) < version.parse(latest_version): + is_version_update_available = version.parse(current_version) < version.parse(latest_version) + + # if is_version_update_available then we should create inapp notification + create_inappnotification( + title='reNgine Update Available', + description=f'Update to version {latest_version} is available', + notification_type=SYSTEM_LEVEL_NOTIFICATION, + project_slug=None, + icon='mdi-update', + redirect_link='https://github.com/yogeshojha/rengine/releases', + open_in_new_tab=True + ) + + return_response['update_available'] = is_version_update_available + if is_version_update_available: return_response['changelog'] = response[0]['body'] return Response(return_response) diff --git a/web/dashboard/admin.py b/web/dashboard/admin.py index be2a79a67..881b18acb 100644 --- a/web/dashboard/admin.py +++ b/web/dashboard/admin.py @@ -5,3 +5,4 @@ admin.site.register(Project) admin.site.register(OpenAiAPIKey) admin.site.register(NetlasAPIKey) +admin.site.register(InAppNotification) \ No newline at end of file diff --git a/web/dashboard/migrations/0002_notification.py b/web/dashboard/migrations/0002_notification.py new file mode 100644 index 000000000..6e9faf78d --- /dev/null +++ b/web/dashboard/migrations/0002_notification.py @@ -0,0 +1,27 @@ +# Generated by Django 3.2.23 on 2024-08-30 00:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dashboard', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255)), + ('description', models.TextField()), + ('icon', models.CharField(max_length=50)), + ('is_read', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/web/dashboard/migrations/0003_auto_20240830_0135.py b/web/dashboard/migrations/0003_auto_20240830_0135.py new file mode 100644 index 000000000..5e7628e00 --- /dev/null +++ b/web/dashboard/migrations/0003_auto_20240830_0135.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.23 on 2024-08-30 01:35 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dashboard', '0002_notification'), + ] + + operations = [ + migrations.AddField( + model_name='notification', + name='notification_type', + field=models.CharField(choices=[('system', 'System-wide'), ('project', 'Project-specific')], default='system', max_length=10), + ), + migrations.AddField( + model_name='notification', + name='project', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dashboard.project'), + ), + ] diff --git a/web/dashboard/migrations/0004_rename_notification_inappnotification.py b/web/dashboard/migrations/0004_rename_notification_inappnotification.py new file mode 100644 index 000000000..2bc145eed --- /dev/null +++ b/web/dashboard/migrations/0004_rename_notification_inappnotification.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.23 on 2024-08-30 01:40 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dashboard', '0003_auto_20240830_0135'), + ] + + operations = [ + migrations.RenameModel( + old_name='Notification', + new_name='InAppNotification', + ), + ] diff --git a/web/dashboard/migrations/0005_alter_inappnotification_notification_type.py b/web/dashboard/migrations/0005_alter_inappnotification_notification_type.py new file mode 100644 index 000000000..49a5af804 --- /dev/null +++ b/web/dashboard/migrations/0005_alter_inappnotification_notification_type.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.23 on 2024-08-31 01:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dashboard', '0004_rename_notification_inappnotification'), + ] + + operations = [ + migrations.AlterField( + model_name='inappnotification', + name='notification_type', + field=models.CharField(choices=[('system', 'system'), ('project', 'project')], default='system', max_length=10), + ), + ] diff --git a/web/dashboard/migrations/0006_inappnotification_status.py b/web/dashboard/migrations/0006_inappnotification_status.py new file mode 100644 index 000000000..c674b7844 --- /dev/null +++ b/web/dashboard/migrations/0006_inappnotification_status.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.23 on 2024-08-31 02:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dashboard', '0005_alter_inappnotification_notification_type'), + ] + + operations = [ + migrations.AddField( + model_name='inappnotification', + name='status', + field=models.CharField(choices=[('success', 'Success'), ('info', 'Informational'), ('warning', 'Warning'), ('error', 'Error')], default='info', max_length=10), + ), + ] diff --git a/web/dashboard/migrations/0007_auto_20240831_0608.py b/web/dashboard/migrations/0007_auto_20240831_0608.py new file mode 100644 index 000000000..34acda1c6 --- /dev/null +++ b/web/dashboard/migrations/0007_auto_20240831_0608.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.23 on 2024-08-31 06:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dashboard', '0006_inappnotification_status'), + ] + + operations = [ + migrations.AddField( + model_name='inappnotification', + name='open_in_new_tab', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='inappnotification', + name='redirect_link', + field=models.URLField(blank=True, max_length=255, null=True), + ), + ] diff --git a/web/dashboard/models.py b/web/dashboard/models.py index 8ed77dd43..a43152b42 100644 --- a/web/dashboard/models.py +++ b/web/dashboard/models.py @@ -1,5 +1,5 @@ from django.db import models - +from reNgine.definitions import * class SearchHistory(models.Model): query = models.CharField(max_length=1000) @@ -41,3 +41,30 @@ class NetlasAPIKey(models.Model): def __str__(self): return self.key + + +class InAppNotification(models.Model): + project = models.ForeignKey(Project, on_delete=models.CASCADE, null=True, blank=True) + notification_type = models.CharField(max_length=10, choices=NOTIFICATION_TYPES, default='system') + status = models.CharField(max_length=10, choices=NOTIFICATION_STATUS_TYPES, default='info') + title = models.CharField(max_length=255) + description = models.TextField() + icon = models.CharField(max_length=50) # mdi icon class name + is_read = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + redirect_link = models.URLField(max_length=255, blank=True, null=True) + open_in_new_tab = models.BooleanField(default=False) + + class Meta: + ordering = ['-created_at'] + + def __str__(self): + if self.notification_type == 'system': + return f"System wide notif: {self.title}" + else: + return f"Project wide notif: {self.project.name}: {self.title}" + + @property + def is_system_wide(self): + # property to determine if the notification is system wide or project specific + return self.notification_type == 'system' diff --git a/web/reNgine/common_func.py b/web/reNgine/common_func.py index 35182fe9e..ce50b0e5b 100644 --- a/web/reNgine/common_func.py +++ b/web/reNgine/common_func.py @@ -1549,3 +1549,67 @@ def save_domain_info_to_db(target, domain_info): domain.save() return domain_info_obj + + +def create_inappnotification( + title, + description, + notification_type=SYSTEM_LEVEL_NOTIFICATION, + project_slug=None, + icon="mdi-bell", + is_read=False, + status='info', + redirect_link=None, + open_in_new_tab=False +): + """ + This function will create an inapp notification + Inapp Notification not to be confused with Notification model + that is used for sending alerts on telegram, slack etc. + Inapp notification is used to show notification on the web app + + Args: + title: str: Title of the notification + description: str: Description of the notification + notification_type: str: Type of the notification, it can be either + SYSTEM_LEVEL_NOTIFICATION or PROJECT_LEVEL_NOTIFICATION + project_slug: str: Slug of the project, if notification is PROJECT_LEVEL_NOTIFICATION + icon: str: Icon of the notification, only use mdi icons + is_read: bool: Whether the notification is read or not, default is False + status: str: Status of the notification (success, info, warning, error), default is info + redirect_link: str: Link to redirect when notification is clicked + open_in_new_tab: bool: Whether to open the redirect link in a new tab, default is False + + Returns: + ValueError: if error + InAppNotification: InAppNotification object if successful + """ + logger.info('Creating InApp Notification with title: %s', title) + if notification_type not in [SYSTEM_LEVEL_NOTIFICATION, PROJECT_LEVEL_NOTIFICATION]: + raise ValueError("Invalid notification type") + + if status not in [choice[0] for choice in NOTIFICATION_STATUS_TYPES]: + raise ValueError("Invalid notification status") + + project = None + if notification_type == PROJECT_LEVEL_NOTIFICATION: + if not project_slug: + raise ValueError("Project slug is required for project level notification") + try: + project = Project.objects.get(slug=project_slug) + except Project.DoesNotExist as e: + raise ValueError(f"No project exists: {e}") + + notification = InAppNotification( + title=title, + description=description, + notification_type=notification_type, + project=project, + icon=icon, + is_read=is_read, + status=status, + redirect_link=redirect_link, + open_in_new_tab=open_in_new_tab + ) + notification.save() + return notification \ No newline at end of file diff --git a/web/reNgine/definitions.py b/web/reNgine/definitions.py index e90ece4f5..58fce2dd5 100644 --- a/web/reNgine/definitions.py +++ b/web/reNgine/definitions.py @@ -548,3 +548,18 @@ # OSINT GooFuzz Path GOFUZZ_EXEC_PATH = '/usr/src/github/goofuzz/GooFuzz' + + +# In App Notification Definitions +SYSTEM_LEVEL_NOTIFICATION = 'system' +PROJECT_LEVEL_NOTIFICATION = 'project' +NOTIFICATION_TYPES = ( + ('system', SYSTEM_LEVEL_NOTIFICATION), + ('project', PROJECT_LEVEL_NOTIFICATION), +) +NOTIFICATION_STATUS_TYPES = ( + ('success', 'Success'), + ('info', 'Informational'), + ('warning', 'Warning'), + ('error', 'Error'), +) \ No newline at end of file diff --git a/web/reNgine/tasks.py b/web/reNgine/tasks.py index 12596e7a2..81c328f7f 100644 --- a/web/reNgine/tasks.py +++ b/web/reNgine/tasks.py @@ -3085,6 +3085,7 @@ def send_scan_notif( url = get_scan_url(scan_history_id, subscan_id) title = get_scan_title(scan_history_id, subscan_id) fields = get_scan_fields(engine, scan, subscan, status, tasks) + severity = None msg = f'{title} {status}\n' msg += '\n🡆 '.join(f'**{k}:** {v}' for k, v in fields.items()) @@ -3098,12 +3099,64 @@ def send_scan_notif( } logger.warning(f'Sending notification "{title}" [{severity}]') + generate_inapp_notification(scan, subscan, status, engine, fields) + # Send notification send_notif( msg, scan_history_id, subscan_id, **opts) + +def generate_inapp_notification(scan, subscan, status, engine, fields): + scan_type = "Subscan" if subscan else "Scan" + domain = subscan.domain.name if subscan else scan.domain.name + duration_msg = None + redirect_link = None + + if status == 'RUNNING': + title = f"{scan_type} Started" + description = f"{scan_type} has been initiated for {domain}" + icon = "mdi-play-circle-outline" + notif_status = 'info' + elif status == 'SUCCESS': + title = f"{scan_type} Completed" + description = f"{scan_type} was successful for {domain}" + icon = "mdi-check-circle-outline" + notif_status = 'success' + duration_msg = f'Completed in {fields.get("Duration")}' + elif status == 'ABORTED': + title = f"{scan_type} Aborted" + description = f"{scan_type} was aborted for {domain}" + icon = "mdi-alert-circle-outline" + notif_status = 'warning' + duration_msg = f'Aborted in {fields.get("Duration")}' + elif status == 'FAILED': + title = f"{scan_type} Failed" + description = f"{scan_type} has failed for {domain}" + icon = "mdi-close-circle-outline" + notif_status = 'error' + duration_msg = f'Failed in {fields.get("Duration")}' + + description += f"
Engine: {engine.engine_name if engine else 'N/A'}" + slug = scan.domain.project.slug if scan else subscan.history.domain.project.slug + if duration_msg: + description += f"
{duration_msg}" + + if status != 'RUNNING': + redirect_link = f"/scan/{slug}/detail/{scan.id}" if scan else None + + create_inappnotification( + title=title, + description=description, + notification_type='project', + project_slug=slug, + icon=icon, + is_read=False, + status=notif_status, + redirect_link=redirect_link, + open_in_new_tab=False + ) @app.task(name='send_task_notif', bind=False, queue='send_task_notif_queue') diff --git a/web/reNgine/utilities.py b/web/reNgine/utilities.py index 58a4503b0..9f9eee92f 100644 --- a/web/reNgine/utilities.py +++ b/web/reNgine/utilities.py @@ -161,6 +161,7 @@ def is_out_of_scope(self, subdomain): return any(pattern.search(subdomain) for pattern in self.regex_patterns) + def sorting_key(subdomain): # sort subdomains based on their http status code with priority 200 < 300 < 400 < rest status = subdomain['http_status'] diff --git a/web/static/custom/custom.css b/web/static/custom/custom.css index 52835d872..47fee0e0b 100644 --- a/web/static/custom/custom.css +++ b/web/static/custom/custom.css @@ -407,3 +407,178 @@ mark{ .scan-config-modal .badge { margin-left: 5px; } + +/*.notification-a-icon { + +}*/ + +.badge-notif-count { + font-size: 10px !important; +} + +.notif-counter { + top: 9px !important; + right: 5px !important; +} + +.notification-panel-dropdown { + width: 400px; + border: none; + border-radius: 8px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); + padding: 0; + overflow: hidden; +} + +.notification-panel-header { + background-color: #f8f9fa; + border-bottom: 1px solid rgba(0, 0, 0, 0.05); +} + +.notification-panel-clear-link { + color: #6c757d; + text-decoration: none; + font-size: 0.875rem; + transition: color 0.2s ease; +} + +.notification-panel-clear-link:hover { + color: #495057; +} + +.notification-panel-body { + max-height: 400px; + /* overflow-y: auto; */ +} + +.notification-panel-item { + border-left: 4px solid transparent; + transition: all 0.3s ease; + margin-bottom: 2px; +} + +.notification-panel-item:last-child { + border-bottom: none; +} + +.notification-panel-item:hover { + transform: translateY(-2px); + box-shadow: 0 2px 0px rgba(0, 0, 0, 0.10); +} + +/* status */ +.notification-panel-status-success { + border-left-color: #10B981; +} + +.notification-panel-status-info { + border-left-color: #3B82F6; +} + +.notification-panel-status-warning { + border-left-color: #F59E0B; +} + +.notification-panel-status-error { + border-left-color: #EF4444; +} + +.notification-panel-unread { + background-color: #F3F4F6; +} + +.notification-panel-unread:hover { + background-color: #E5E7EB; +} + +.notification-panel-icon { + font-size: 1.25rem; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 50%; + background-color: #E5E7EB; +} + +.notification-panel-title { + font-size: 0.95rem; + font-weight: 600; +} + +.notification-panel-status-success .notification-panel-title { + color: #10B981; +} + +.notification-panel-status-info .notification-panel-title { + color: #3B82F6; +} + +.notification-panel-status-warning .notification-panel-title { + color: #F59E0B; +} + +.notification-panel-status-error .notification-panel-title { + color: #EF4444; +} + +.notification-panel-description { + color: #4B5563; + font-size: 0.875rem; + margin-bottom: 0.25rem; + line-height: 1.4; +} + +.notification-panel-time { + color: #6B7280; + font-size: 0.8rem; +} + +.notification-panel-footer { + background-color: #f8f9fa; + border-top: 1px solid rgba(0, 0, 0, 0.05); +} + +.notification-panel-view-all { + color: #007bff; + text-decoration: none; + font-size: 0.875rem; + font-weight: 600; + transition: color 0.2s ease; +} + +.notification-panel-view-all:hover { + color: #0056b3; +} + +.notification-panel-status-success .notification-panel-icon { + color: #10B981; +} + +.notification-panel-status-info .notification-panel-icon { + color: #3B82F6; +} + +.notification-panel-status-warning .notification-panel-icon { + color: #F59E0B; +} + +.notification-panel-status-error .notification-panel-icon { + color: #EF4444; +} + +@keyframes notification-buzz { + 0% { transform: translateX(0) rotate(0); } + 15% { transform: translateX(-5px) rotate(-5deg); } + 30% { transform: translateX(5px) rotate(5deg); } + 45% { transform: translateX(-4px) rotate(-4deg); } + 60% { transform: translateX(4px) rotate(4deg); } + 75% { transform: translateX(-2px) rotate(-2deg); } + 90% { transform: translateX(2px) rotate(2deg); } + 100% { transform: translateX(0) rotate(0); } +} + +.notification-bell-icon.buzz { + animation: notification-buzz 0.8s ease-out; +} \ No newline at end of file diff --git a/web/static/custom/custom.js b/web/static/custom/custom.js index 570d72332..617fb4adb 100644 --- a/web/static/custom/custom.js +++ b/web/static/custom/custom.js @@ -1,3 +1,26 @@ +function loadScript(src) { + // helper function to load a script asynchronously + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = src; + script.onload = resolve; + script.onerror = reject; + document.head.appendChild(script); + }); +} + +function loadCSS(href) { + // helper function to load a css asynchronously + return new Promise((resolve, reject) => { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = href; + link.onload = resolve; + link.onerror = reject; + document.head.appendChild(link); + }); +} + function getCurrentProjectSlug(){ return document.querySelector('input[name="current_project"]').value; } @@ -3377,3 +3400,5 @@ function show_scan_configuration(starting_path, out_of_scope_subdomains, exclude $('#modal-content').append(content); $('#modal_dialog').modal('show'); } + + diff --git a/web/static/custom/notification.js b/web/static/custom/notification.js new file mode 100644 index 000000000..1c6e484bc --- /dev/null +++ b/web/static/custom/notification.js @@ -0,0 +1,229 @@ +// all the functions and event listeners for the notification panel + +// this is to check and compare the last notification id with the current notification id +let lastNotificationId = null; +let isInitialLoad = true; + +function updateNotifications() { + let api_url = "/api/notifications/"; + const currentProjectSlug = getCurrentProjectSlug(); + if (currentProjectSlug) { + api_url += `?project_slug=${currentProjectSlug}`; + } + fetch(api_url, { + method: "GET", + credentials: "same-origin", + headers: { + "X-CSRFToken": getCookie("csrftoken"), + }, + }) + .then((response) => response.json()) + .then((data) => { + const notificationPanel = document.querySelector( + ".notification-panel-body" + ); + notificationPanel.innerHTML = ""; + + if (data.length === 0) { + const noNotificationsMessage = document.createElement("div"); + noNotificationsMessage.className = + "notification-panel-item d-flex align-items-center justify-content-center p-3"; + noNotificationsMessage.innerHTML = ` +

Ping? Pong! No notifications, moving along

+ `; + notificationPanel.appendChild(noNotificationsMessage); + } else { + // decisive part to show the Snackbar + if (!isInitialLoad && data[0].id !== lastNotificationId) { + showNotificationSnackbar(data[0]); + } + lastNotificationId = data[0].id; + + data.forEach((notification) => { + const notificationItem = document.createElement("div"); + notificationItem.className = `notification-panel-item d-flex align-items-start p-3 ${ + notification.is_read ? "" : "notification-panel-unread" + } notification-panel-status-${notification.status}`; + notificationItem.innerHTML = ` +
+
+
${ + notification.title + }
+ + + +
+

${ + notification.description + }

+ ${timeago.format( + new Date(notification.created_at) + )} +
+ `; + notificationItem.addEventListener("click", (event) => { + notificationAction( + notification.id, + notification.redirect_link, + notification.open_in_new_tab + ); + }); + notificationPanel.appendChild(notificationItem); + }); + } + + updateUnreadCount(); + + // set first load to false + isInitialLoad = false; + }); +} + +function showNotificationSnackbar(notification) { + let backgroundColor, actionTextColor; + + switch (notification.status) { + case "error": + backgroundColor = "#e7515a"; + actionTextColor = "#fff"; + break; + case "warning": + backgroundColor = "#e2a03f"; + actionTextColor = "#fff"; + break; + case "success": + backgroundColor = "#8dbf42"; + actionTextColor = "#fff"; + break; + default: + backgroundColor = "#2196f3"; + actionTextColor = "#fff"; + } + + Snackbar.show({ + text: `New notification: ${notification.title}`, + pos: "top-right", + actionTextColor: actionTextColor, + backgroundColor: backgroundColor, + duration: 2500, + }); +} + +function updateUnreadCount() { + let api_url = "/api/notifications/unread_count/"; + const currentProjectSlug = getCurrentProjectSlug(); + if (currentProjectSlug) { + api_url += `?project_slug=${currentProjectSlug}`; + } + fetch(api_url, { + method: "GET", + credentials: "same-origin", + headers: { + "X-CSRFToken": getCookie("csrftoken"), + }, + }) + .then((response) => response.json()) + .then((data) => { + const badge = document.querySelector("#notification-counter"); + badge.textContent = data.count; + badge.style.display = data.count > 0 ? "inline-block" : "none"; + }); +} + +function notificationAction(notificationId, redirectLink, openInNewTab) { + // depending on notification we may also need to redirect to a specific page + // for example if the notification is related to scan, we may take to scan detail page + + // eithrways mark the notification as read + fetch(`/api/notifications/${notificationId}/mark_read/`, { + method: "POST", + credentials: "same-origin", + headers: { + "X-CSRFToken": getCookie("csrftoken"), + }, + }).then(() => { + updateNotifications(); + + // this is where we handle all the notification actions such as redirecting to a specific page + if (redirectLink) { + if (openInNewTab) { + window.open(redirectLink, "_blank"); + } else { + window.location.href = redirectLink; + } + } + }); +} + +function clearAllNotifications() { + let api_url = "/api/notifications/clear_all/"; + const currentProjectSlug = getCurrentProjectSlug(); + if (currentProjectSlug) { + api_url += `?project_slug=${currentProjectSlug}`; + } + fetch("/api/notifications/clear_all/", { + method: "POST", + credentials: "same-origin", + headers: { + "X-CSRFToken": getCookie("csrftoken"), + }, + }).then(() => { + updateNotifications(); + }); +} + +// Set up event listeners +document.addEventListener("DOMContentLoaded", () => { + const clearAllLink = document.querySelector("#clear-notif-btn"); + clearAllLink.addEventListener("click", clearAllNotifications); + + const markAllReadBtn = document.querySelector("#mark-all-read-btn"); + markAllReadBtn.addEventListener("click", markAllAsRead); + + // Update notifications every 15 seconds + updateNotifications(); + setInterval(updateNotifications, 15000); + + setInterval(updateTimes, 30000); +}); + +function getCurrentProjectSlug() { + const hiddenInput = document.querySelector('input[name="current_project"]'); + return hiddenInput ? hiddenInput.value : null; +} + +function markAllAsRead() { + fetch("/api/notifications/mark_all_read/", { + method: "POST", + credentials: "same-origin", + headers: { + "X-CSRFToken": getCookie("csrftoken"), + "Content-Type": "application/json", + }, + }) + .then((response) => { + if (response.ok) { + document + .querySelectorAll(".notification-panel-item") + .forEach((item) => { + item.classList.remove("notification-panel-unread"); + }); + updateUnreadCount(); + } + }) + .catch((error) => + console.error("Error marking all notifications as read:", error) + ); +} + +function updateTimes() { + document + .querySelectorAll(".notification-panel-time") + .forEach((timeElement) => { + const datetime = timeElement.getAttribute("datetime"); + if (datetime) { + timeElement.textContent = timeago.format(new Date(datetime)); + } + }); +} diff --git a/web/static/custom/right_sidebar.js b/web/static/custom/right_sidebar.js index 8ce02d39e..aefdafc2e 100644 --- a/web/static/custom/right_sidebar.js +++ b/web/static/custom/right_sidebar.js @@ -25,8 +25,8 @@ function getScanStatusSidebar(project, reload) { else{ $('#upcoming_scans').html(``); } - if (scans['scanning'].length > 0){ + // remove display none for current_scan_counter $('#current_scan_counter').html(scans['scanning'].length); $('#current_scan_count').html(`${scans['scanning'].length} Scans Currently Running`) for (var scan in scans['scanning']) { diff --git a/web/templates/base/_items/top_bar.html b/web/templates/base/_items/top_bar.html index 8b3d3c074..6e73f277e 100644 --- a/web/templates/base/_items/top_bar.html +++ b/web/templates/base/_items/top_bar.html @@ -112,11 +112,54 @@
Toolbox
+ + + - -