diff --git a/.pkgr.yml b/.pkgr.yml index 228a7d55a34a..d31f9a32faea 100644 --- a/.pkgr.yml +++ b/.pkgr.yml @@ -34,6 +34,7 @@ dependencies: - nginx - jq - "libffi7 | libffi8" + - pushpin targets: ubuntu-22.04: true ubuntu-24.04: true diff --git a/contrib/packager.io/functions.sh b/contrib/packager.io/functions.sh index c4703d6eba2c..a97c1f843d90 100755 --- a/contrib/packager.io/functions.sh +++ b/contrib/packager.io/functions.sh @@ -274,6 +274,24 @@ function create_initscripts() { ${INIT_CMD} start nginx echo "# POI09| Started nginx" + # Pushpin setup for SSE support + echo "# POI09| Stopping pushpin" + ${INIT_CMD} stop pushpin + echo "# POI09| Stopped pushpin" + echo "# POI09| Setting up pushpin" + $pushpin_routes_file="/etc/pushpin/routes" + # Check if pushpin config exists and was not created by us + if test -f "$pushpin_routes_file" && $(read -r firstline < "$pushpin_routes_file" && [[ $firstline = '#INVENTREE-SET'* ]]) + echo "# POI09| Backing up existing pushpin routes file to $pushpin_routes_file.bak" + mv $pushpin_routes_file "$pushpin_routes_file.bak" + fi + echo "# POI09| Writing pushpin routes file to $pushpin_routes_file" + cp ${APP_HOME}/contrib/packager.io/pushpin.conf $pushpin_routes_file + + echo "# POI09| Starting pushpin" + ${INIT_CMD} start pushpin + echo "# POI09| Started pushpin" + echo "# POI09| (Re)creating init scripts" # This resets scale parameters to a known state inventree scale web="1" worker="1" diff --git a/contrib/packager.io/nginx.prod.conf b/contrib/packager.io/nginx.prod.conf index a78b9ebd0f42..37bc78541a40 100644 --- a/contrib/packager.io/nginx.prod.conf +++ b/contrib/packager.io/nginx.prod.conf @@ -64,4 +64,13 @@ server { proxy_set_header X-Original-URI $request_uri; } + # Realtime events(SSE) are passed to local Pushpin server + location /events/ { + proxy_pass http://localhost:7999/; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } } diff --git a/contrib/packager.io/pushpin.conf b/contrib/packager.io/pushpin.conf new file mode 100644 index 000000000000..133b90272c6d --- /dev/null +++ b/contrib/packager.io/pushpin.conf @@ -0,0 +1,2 @@ +#INVENTREE +* localhost:6000 diff --git a/src/backend/InvenTree/InvenTree/middleware.py b/src/backend/InvenTree/InvenTree/middleware.py index 60b3dbe48af3..d20f1a73dce5 100644 --- a/src/backend/InvenTree/InvenTree/middleware.py +++ b/src/backend/InvenTree/InvenTree/middleware.py @@ -71,6 +71,7 @@ def ensure_slashes(path: str): '/anymail/', # Mails - wehbhooks etc '/accounts/', # allauth account management - has its own auth model '/assets/', # Web assets - only used for testing, no security model needed + '/events/', # SSE endpoints - have their own security model ensure_slashes( settings.STATIC_URL ), # Static files - static files are considered safe to serve diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index 747f946779e0..55d74343c3a4 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -1504,3 +1504,23 @@ if _media: MEDIA_URL = _media PRESIGNED_URL_EXPIRATION = 600 + + +# SSE / GRIP +GRIP_ENABLED = get_boolean_setting('INVENTREE_GRIP_ENABLED', 'grip.enabled', False) +if GRIP_ENABLED: # pragma: no cover + logger.info('GRIP / SSE support enabled') + INSTALLED_APPS.append('django_eventstream') + EVENTSTREAM_STORAGE_CLASS = 'django_eventstream.storage.DjangoModelStorage' + EVENTSTREAM_CHANNELMANAGER_CLASS = 'InvenTree.sse.MyChannelManager' + + # GRIP backend + # MIDDLEWARE.insert(0, 'django_grip.GripMiddleware') + MIDDLEWARE.append('django_grip.GripMiddleware') + GRIP_URL = get_setting('INVENTREE_GRIP_URL', 'grip.url', 'http://localhost:5561') + REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'].append( + 'django_eventstream.renderers.SSEEventRenderer' + ) + REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'].append( + 'django_eventstream.renderers.BrowsableAPIEventStreamRenderer' + ) diff --git a/src/backend/InvenTree/InvenTree/sse.py b/src/backend/InvenTree/InvenTree/sse.py new file mode 100644 index 000000000000..48535aedce7f --- /dev/null +++ b/src/backend/InvenTree/InvenTree/sse.py @@ -0,0 +1,51 @@ +"""Stuff for Server Side Events / Websockets.""" + +import random + +from django.http import HttpResponse +from django.urls import include, path + +import django_eventstream +from django_eventstream import send_event +from django_eventstream.channelmanager import DefaultChannelManager + + +class MyChannelManager(DefaultChannelManager): + """Custom channel manager to restrict access to user-specific channels.""" + + def can_read_channel(self, user, channel): + """Override to restrict channel access.""" + # require auth for prefixed channels - there should only be prefixed channels + if channel.startswith('_') and user is None: + return False + return channel == f'_user_{user.pk}' + + +def push_msg(request): + """Function to push a test message to the current user.""" + usr_pk = request.user.pk + rndm = random.randint(0, 1000) + send_event(f'_user_{usr_pk}', 'message', {'text': f'hello world {usr_pk}: {rndm}'}) + return HttpResponse('Message sent') + + +sse_urlpatterns = [ + path('events/push_msg/', push_msg, name='push-msg'), + path( + 'events//', + include(django_eventstream.urls), + {'format-channels': ['_user_{usr_pk}']}, + ), +] + +# router = DefaultRouter() + +# # register by class +# router.register( +# "events2", +# EventsViewSet(channels=["channel1", "channel2"]), +# basename="events") + +# sse_urlpatterns = [ +# path("", include(router.urls)), +# ] diff --git a/src/backend/InvenTree/InvenTree/urls.py b/src/backend/InvenTree/InvenTree/urls.py index 78ecedc7444f..4b8939719894 100644 --- a/src/backend/InvenTree/InvenTree/urls.py +++ b/src/backend/InvenTree/InvenTree/urls.py @@ -162,6 +162,14 @@ ] urlpatterns += platform_urls + +# SSE endpoints +if settings.GRIP_ENABLED: + from .sse import sse_urlpatterns + + urlpatterns += sse_urlpatterns + + # Append custom plugin URLs (if custom plugin support is enabled) if settings.PLUGINS_ENABLED: urlpatterns.append(get_plugin_urls()) diff --git a/src/backend/requirements.in b/src/backend/requirements.in index 16c3e438cd6d..d656ac45e106 100644 --- a/src/backend/requirements.in +++ b/src/backend/requirements.in @@ -8,6 +8,7 @@ django-cleanup # Automated deletion of old / unused upl django-cors-headers # CORS headers extension for DRF django-dbbackup>=5.0.0 # Backup / restore of database and media files django-error-report-2 # Error report viewer for the admin interface +django-eventstream[drf] # Server-Sent Events support django-filter # Extended filtering options django-flags # Feature flags django-ical # iCal export for calendar views diff --git a/src/backend/requirements.txt b/src/backend/requirements.txt index 7dd5272c85e5..9252f864faf7 100644 --- a/src/backend/requirements.txt +++ b/src/backend/requirements.txt @@ -500,8 +500,10 @@ django==5.2.9 \ # django-cors-headers # django-dbbackup # django-error-report-2 + # django-eventstream # django-filter # django-flags + # django-grip # django-ical # django-js-asset # django-markdownify @@ -546,6 +548,9 @@ django-error-report-2==0.4.2 \ --hash=sha256:1dd99c497af09b7ea99f5fbaf910501838150a9d5390796ea00e187bc62f6c1b \ --hash=sha256:603e1e3b24d01bbfeab6379af948893b2b034031c80fa8b45cf1c4735341c04b # via -r src/backend/requirements.in +django-eventstream[drf]==5.3.3 \ + --hash=sha256:6880b03298eebf18c1b736b972fb862eaf631dfbb79f8b27496418a3495d08dc + # via -r src/backend/requirements.in django-filter==25.2 \ --hash=sha256:760e984a931f4468d096f5541787efb8998c61217b73006163bf2f9523fe8f23 \ --hash=sha256:9c0f8609057309bba611062fe1b720b4a873652541192d232dd28970383633e3 @@ -554,6 +559,9 @@ django-flags==5.1.0 \ --hash=sha256:47f33efba238fe83a2c1f6f5764147fe62d770039f64b3a039e0e461bbc36e79 \ --hash=sha256:6cfb15151f94d13de1a497901fc1dd4f0510251e5ec23a6732d674824f499e72 # via -r src/backend/requirements.in +django-grip==3.5.2 \ + --hash=sha256:1ee1601492cd110256bd03e4a68797a9fbefa27c15f5a838bf245df97db0450c + # via django-eventstream django-ical==1.9.2 \ --hash=sha256:44c9b6fa90d09f25e9ebaa91ed9eb007f079afbc23d6aac909cfc18188a8e90c \ --hash=sha256:74a16bca05735f91a00120cad7250f3c3aa292a9f698a6cfdc544a922c11de70 @@ -647,6 +655,7 @@ djangorestframework==3.16.1 \ --hash=sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec # via # -r src/backend/requirements.in + # django-eventstream # djangorestframework-simplejwt # drf-spectacular djangorestframework-simplejwt[crypto]==5.5.1 \ @@ -772,6 +781,11 @@ googleapis-common-protos==1.72.0 \ # via # opentelemetry-exporter-otlp-proto-grpc # opentelemetry-exporter-otlp-proto-http +gripcontrol==4.2.1 \ + --hash=sha256:5f7c2de4b6cceaa4fc2ba0b77d591c881216004ff99ad7582a7d3e52a12f58bb + # via + # django-eventstream + # django-grip grpcio==1.76.0 \ --hash=sha256:035d90bc79eaa4bed83f524331d55e35820725c9fbb00ffa1904d5550ed7ede3 \ --hash=sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280 \ @@ -1125,7 +1139,9 @@ markupsafe==3.0.3 \ --hash=sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc \ --hash=sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a \ --hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50 - # via jinja2 + # via + # jinja2 + # werkzeug oauthlib==3.3.1 \ --hash=sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9 \ --hash=sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1 @@ -1427,6 +1443,11 @@ psutil==7.1.3 \ --hash=sha256:f39c2c19fe824b47484b96f9692932248a54c43799a84282cfe58d05a6449efd \ --hash=sha256:fac9cd332c67f4422504297889da5ab7e05fd11e3c4392140f7370f4208ded1f # via opentelemetry-instrumentation-system-metrics +pubcontrol==3.5.0 \ + --hash=sha256:a5ec6b3f53edfd005675518e5e4cc23b34122776835ae7c6dbd1db173d1ff0cb + # via + # django-grip + # gripcontrol py-moneyed==3.0 \ --hash=sha256:4906f0f02cf2b91edba2e156f2d4e9a78f224059ab8c8fa2ff26230c75d894e8 \ --hash=sha256:9583a14f99c05b46196193d8185206e9b73c8439fc8a5eee9cfc7e733676d9bb @@ -1444,7 +1465,10 @@ pyjwt[crypto]==2.10.1 \ --hash=sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb # via # django-allauth + # django-eventstream # djangorestframework-simplejwt + # gripcontrol + # pubcontrol pynacl==1.6.0 \ --hash=sha256:04f20784083014e265ad58c1b2dd562c3e35864b5394a14ab54f5d150ee9e53e \ --hash=sha256:10d755cf2a455d8c0f8c767a43d68f24d163b8fe93ccfaabfa7bafd26be58d73 \ @@ -1701,6 +1725,7 @@ requests==2.32.5 \ # django-anymail # django-oauth-toolkit # opentelemetry-exporter-otlp-proto-http + # pubcontrol rpds-py==0.28.0 \ --hash=sha256:03065002fd2e287725d95fbc69688e0c6daf6c6314ba38bdbaa3895418e09296 \ --hash=sha256:04c1b207ab8b581108801528d59ad80aa83bb170b35b0ddffb29c20e411acdc1 \ @@ -1842,7 +1867,11 @@ sgmllib3k==1.0.0 \ six==1.17.0 \ --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 - # via python-dateutil + # via + # django-eventstream + # django-grip + # gripcontrol + # python-dateutil sqlparse==0.5.3 \ --hash=sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272 \ --hash=sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca @@ -1920,6 +1949,10 @@ webencodings==0.5.1 \ # cssselect2 # tinycss2 # tinyhtml5 +werkzeug==3.1.4 \ + --hash=sha256:2ad50fb9ed09cc3af22c54698351027ace879a0b60a3b5edf5730b2f7d876905 \ + --hash=sha256:cd3cd98b1b92dc3b7b3995038826c68097dcb16f9baa63abe35f20eafeb9fe5e + # via django-grip whitenoise==6.11.0 \ --hash=sha256:0f5bfce6061ae6611cd9396a8231e088722e4fc67bc13a111be74c738d99375f \ --hash=sha256:b2aeb45950597236f53b5342b3121c5de69c8da0109362aee506ce88e022d258 diff --git a/src/frontend/src/components/nav/Layout.tsx b/src/frontend/src/components/nav/Layout.tsx index 6122133c8c6b..d39f73578a06 100644 --- a/src/frontend/src/components/nav/Layout.tsx +++ b/src/frontend/src/components/nav/Layout.tsx @@ -1,5 +1,7 @@ +import type { UserStateProps } from '@lib/types/User'; import { t } from '@lingui/core/macro'; import { Container, Flex, Space } from '@mantine/core'; +import { notifications } from '@mantine/notifications'; import { Spotlight, type SpotlightActionData, @@ -141,7 +143,39 @@ export default function LayoutComponent() { nothingFound={t`Nothing found...`} /> )} + ); } + +function NotifiySSEElement({ user }: { user: UserStateProps }) { + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + if (!user?.user) { + return; + } + if (!isOpen) { + const evtSource = new EventSource( + `http://localhost:7999/events/${user?.user?.pk}/`, + { + withCredentials: true + } + ); + evtSource.onmessage = (event) => { + notifications.show({ + title: 'Server notification', + message: event.data + }); + }; + evtSource.onerror = (err) => { + console.error('SSE error:', err); + evtSource.close(); + }; + setIsOpen(true); + } + }, [user, isOpen]); + + return <>; +}