Skip to content

Commit b491630

Browse files
committed
Fixes #39083 - Use Action Cable for UI notifications
1 parent e27225a commit b491630

File tree

12 files changed

+124
-5
lines changed

12 files changed

+124
-5
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
module ApplicationCable
2+
class Channel < ActionCable::Channel::Base
3+
end
4+
end
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
module ApplicationCable
2+
class Connection < ActionCable::Connection::Base
3+
identified_by :current_user
4+
5+
def connect
6+
self.current_user = find_verified_user
7+
end
8+
9+
private
10+
11+
def find_verified_user
12+
user_id = request.session['user']
13+
# Use unscoped to bypass Foreman's organization/location default scopes
14+
# since the tenant context isn't established during WebSocket handshake
15+
found_user = User.unscoped.find_by(id: user_id) if user_id
16+
found_user || reject_unauthorized_connection
17+
end
18+
end
19+
end
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
class NotificationChannel < ApplicationCable::Channel
2+
def subscribed
3+
stream_for current_user
4+
end
5+
end

app/models/notification_recipient.rb

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,20 @@ def current_user?
2828
private
2929

3030
def delete_user_cache
31-
UINotifications::CacheHandler.new(user_id).clear
31+
cache_handler = UINotifications::CacheHandler.new(user_id)
32+
cache_handler.clear
33+
34+
# Broadcast notification update via Action Cable
35+
begin
36+
payload_json = cache_handler.payload
37+
user = User.unscoped.find_by(id: user_id)
38+
39+
if user
40+
NotificationChannel.broadcast_to(user, { payload: payload_json })
41+
end
42+
rescue => e
43+
# Don't let broadcast errors break notification system
44+
Rails.logger.error("Failed to broadcast notification update: #{e.message}")
45+
end
3246
end
3347
end

config/application.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
'action_view/railtie',
1515
'action_mailer/railtie',
1616
'active_job/railtie',
17-
# 'action_cable/engine',
17+
'action_cable/engine',
1818
# 'action_mailbox/engine',
1919
# 'action_text/engine',
2020
'rails/test_unit/railtie',

config/cable.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
development:
2+
adapter: redis
3+
url: redis://localhost:6379/1
4+
5+
test:
6+
adapter: test
7+
8+
production:
9+
adapter: redis
10+
url: redis://10.10.3.153:6381
11+
channel_prefix: appname_production

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"@patternfly/react-table": "^5.4.8",
3535
"@patternfly/react-tokens": "^5.4.1",
3636
"@patternfly/react-templates": "^1.1.8",
37+
"@rails/actioncable": "^8.1.200",
3738
"@reduxjs/toolkit": "^1.6.0",
3839
"@scalprum/core": "^0.8.1",
3940
"@scalprum/react-core": "^0.9.3",
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { createConsumer } from '@rails/actioncable';
2+
3+
export default createConsumer();
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import consumer from './actionCableConsumer';
2+
3+
// Export a function to subscribe with custom callbacks
4+
export const subscribeToNotifications = (callbacks = {}) =>
5+
consumer.subscriptions.create(
6+
{ channel: 'NotificationChannel' },
7+
{
8+
connected() {
9+
if (callbacks.connected) callbacks.connected();
10+
},
11+
disconnected() {
12+
if (callbacks.disconnected) callbacks.disconnected();
13+
},
14+
received(data) {
15+
if (callbacks.received) callbacks.received(data);
16+
},
17+
}
18+
);

webpack/assets/javascripts/react_app/components/notifications/NotificationsContext.js

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ import React, { useEffect, useState, useMemo } from 'react';
33
import { useDispatch, useSelector } from 'react-redux';
44
import { NotificationBadgeVariant as BadgeVariant } from '@patternfly/react-core';
55
import { groupBy } from 'lodash';
6-
import { startNotificationsPolling } from '../../redux/actions/notifications';
6+
import { subscribeToNotifications } from '../../common/channels/notificationChannel';
7+
import {
8+
fetchNotifications,
9+
startNotificationsPolling,
10+
} from '../../redux/actions/notifications';
11+
import { NOTIFICATIONS } from '../../redux/consts';
12+
import { actionTypeGenerator } from '../../redux/API/APIActionTypeGenerator';
713
import forceSingleton from '../../common/forceSingleton';
814

915
export const NotificationsContext = forceSingleton('NotificationsContext', () =>
@@ -19,6 +25,7 @@ export const NotificationsContextWrapper = ({ children }) => {
1925

2026
const notificationsState = useSelector(state => state.notifications);
2127
const notifications = groupBy(notificationsState.notifications, n => n.group);
28+
const { SUCCESS } = actionTypeGenerator(NOTIFICATIONS);
2229

2330
const [expandedNotifications, setExpandedNotifications] = useState([]);
2431
const [expandedKebab, setExpandedKebab] = useState('');
@@ -45,8 +52,35 @@ export const NotificationsContextWrapper = ({ children }) => {
4552
};
4653

4754
useEffect(() => {
55+
// Initial fetch of notifications
56+
dispatch(fetchNotifications());
57+
58+
// Subscribe to real-time updates via Action Cable
59+
const subscription = subscribeToNotifications({
60+
received: data => {
61+
try {
62+
const payload = JSON.parse(data.payload);
63+
dispatch({
64+
type: SUCCESS,
65+
response: payload,
66+
});
67+
} catch (error) {
68+
console.error('Failed to parse notification payload:', error);
69+
}
70+
},
71+
});
72+
73+
// Start polling as fallback (in case WebSocket connection fails)
74+
// Action Cable handles real-time updates, polling runs every 5 minutes as a safety net
4875
dispatch(startNotificationsPolling());
49-
}, [dispatch]);
76+
77+
// Cleanup: unsubscribe when component unmounts
78+
return () => {
79+
if (subscription && subscription.unsubscribe) {
80+
subscription.unsubscribe();
81+
}
82+
};
83+
}, [dispatch, SUCCESS]);
5084

5185
const countUnreadMessages = useMemo(() => {
5286
if (notificationsState.notifications) {

0 commit comments

Comments
 (0)