Over the past few weeks at Release the Frontend Engineering team has started working on adding Redux to Release. We had been making use of React Context but felt that we were starting to stretch its capabilities. In some places we were having to add multiple providers to implement new features. After some research on the current state of Redux, we decided to go with Redux Toolkit and Redux Saga. Moving all our data into the Redux store and out of local state meant that we were going to have to change our approach with Action Cable and how we were going to receive the messages, store them, and display changes for the user.
Action Cable, Redux, and Release
Release uses Action Cable in a single direction, which is from the backend to the frontend. The frontend is a separate React application running as a Static Service Application, not a part of Rails. The backend will send messages to the frontend when the state of objects change or to stream logs of deployments and builds. Today I’m going to go through the thought process, including code snippets, of how we set up our Redux implementation for Action Cable when Release builds a Docker image. If you’re curious about how Release builds Docker images, read about we Cut Build Time In Half with Docker’s Buildx Kubernetes Driver.
Action Cable Setup
Let’s start off with how we set up the backend to send updates as a Build
object progresses. We have two ActiveRecord
models to consider in this scenario, Build
, and Log
. The Build
class includes the aasm gem functionality to progress it through the lifecycle of actually creating a Docker build. The following is an extremely pared down version of our Build
class, but has enough information to explain how we’re sending the Action Cable messages.
class Build < ApplicationRecord
include AASM
include Logging
has_many :logs
aasm use_transactions: false do
state :ready, initial: true
state :running, after_enter: Proc.new { update_started_at; log_start }
state :done, after_enter: Proc.new { set_duration; log_done }
state :errored, after_enter: Proc.new { set_duration; log_error }
event :start do
transitions from: [:ready], to: :running
end
event :finish do
transitions from: [:running], to: :done
end
event :error do
transitions from: [:running], to: :errored
end
def log_start
message = "Build starting for #{repository.name}!"
log_it(:info, message, metadata: log_metadata)
end
def log_done
message = "Build finished for #{repository.name}!"
log_it(:info, message, metadata: log_metadata)
end
def log_error
message = "Build errored for #{repository.name}!"
log_it(:error, message, metadata: log_metadata)
end
def log_metadata
{
build_id: self.id,
aasm_state: self.aasm_state,
started_at: self.started_at,
duration: self.total_duration
}
end
def logs_channel
"build_channel_#{self.id}"
end
end
Whenever the Build
transitions its state, we create a Log
record through the log_it
method. A log level is supplied, along with the message, and metadata about the Build
itself. That metadata is used by the frontend to make changes for the user as you’ll see when we go through the Redux code. log_it
also sends the message to the logs_channel
through Action Cable. Since that wasn’t defined above, let’s look at that now.
module Logging
module Log
def log_it(level, message, metadata: {})
log_hash = {
level: level,
message: message.dup.force_encoding('UTF-8')
}
self.logs << ::Log.new(log_hash)
payload = log_hash.merge(metadata)
ActionCable.server.broadcast(logs_channel, payload)
end
end
end
There is not too much to it. We create the Log
record and ensure the message is properly encoded. Then we combine the level, message, and supplied metadata to Action Cable and broadcast it. We use the log_it
method with more classes than just Build
and have found it makes for an easy and reliable way to store and send messages.
That takes care of our state transitions. The last piece needed to wrap up our backend setup is to create the BuildChannel
.
class BuildChannel < ApplicationCable::Channel
def subscribed
Rails.logger.info "Subscribing to: build_channel_#{params['room']}"
stream_from "build_channel_#{params['room']}"
end
end
The method receives a room parameter to ensure we are sending messages about a specific Build
and does not go to everyone. I like to have the logging message in there so that it is easy to tell in the Rails logs if the frontend has successfully connected to the channel. With all that covered, we’re ready to dive into the setup on the frontend to receive those messages!
Redux Setup
As you’ll recall we’re using Redux Toolkit and we’re not going to cover our entire setup with Toolkit, instead focusing only on the portions relevant to updating the Build
when we receive an Action Cable message. From there we’ll go over a small wrapper component we made to handle receiving the Action Cable messages and tie it all together with a small demo component.
We’ll start off with the BuildsSlice
.
import { createSlice } from "@reduxjs/toolkit";
import {
handleBuildMessageReceived,
} from "./helpers/actionCable/builds";
const initialState = {
activeBuild: undefined, // object
};
export const buildsSlice = createSlice({
updateBuildFromMessage(state, action) {
const message = action.payload;
const build = state.activeBuild;
const newBuild = handleBuildMessageReceived(build, message);
return {
...state,
activeBuild: newBuild,
};
},
})
export const {
updateBuildFromMessage,
} = buildsSlice.actions;
export default buildsSlice.reducer;
You’ll notice that we import handleBuildMessageReceived
from a file under helpers/actionCable
. We wanted to separate out the code for the logic of updating the build from the slice itself so that our slice file does not grow too enormous. Other than that, the slice itself follows the suggested setup of a slice from the createSlice documentation.
Now we need to look at our handleBuildMessageReceived
function.
const handleBuildMessageReceived = (build, message) => {
const buildId = message["build_id"];
const aasmState = message["aasm_state"];
const duration = message["duration"];
const startedAt = message["started_at"];
const level = message["level"];
const messageLog = message["message"];
const logs = build.logs;
if (build.id !== buildId) {
return build;
} else {
const newLogLine = { level: level, message: messageLog };
const newBuild = {
...build,
logs: [...logs, newLogLine],
aasm_state: aasmState || build.aasm_state,
total_duration: duration || build.total_duration,
started_at: startedAt || build.started_at,
};
return newBuild;
}
};
export { handleBuildMessageReceived };
First a sanity check is done to ensure we didn’t somehow receive a message for a Build
that we aren’t viewing. This shouldn’t happen because we open and close our Action Cable subscriptions when we enter and leave a page, but an extra check never hurts. Then we construct a new Build
object by appending the new log line and adding the metadata. If the metadata fields are undefined
, we’ll retain what the build
variable already had.
We’re ready to receive messages so we need a component that will handle that for us. The ActionCableWrapper
component is just that.
import React, { useEffect, useState } from "react";
import actionCable from "actioncable";
export default function ActionCableWrapper({ channel, room, onReceived }) {
const [actionCableConsumer, setActionCableConsumer] = useState(undefined);
useEffect(() => {
if (!actionCableConsumer) {
setActionCableConsumer(actionCable.createConsumer("ws://localhost:3000/cable"));
} else {
actionCableConsumer.subscriptions.create(
{ channel, room },
{
received: onReceived,
}
);
}
return () => {
if (actionCableConsumer) {
actionCableConsumer.disconnect();
}
};
}, [actionCableConsumer]);
return <></>;
}
This component will mount and check to see if actionCableConsumer
is not undefined
. However, if it is undefined
, which it will be on the first pass through the useEffect
, we will create a consumer through actionCable.createConsumer
connecting to a /cable
endpoint. "ws://localhost:3000/cable"
is hard coded but the URL should come from an environment variable so the component works locally or in production. That consumer is set into the local state actionCableConsumer
and the useEffect
will trigger a second time.
In the second pass through, the else
block is entered and a subscription is created with the passed in channel
, room
, and onReceived
properties. The return
function is set to call disconnect()
if we have an actionCableConsumer
set and will ensure that no web socket connections are left open if a user navigates away from the page. With that, we have a reusable component that will take care of our Action Cable needs throughout the application.
Pulling it all together, we can create a demo component that will display the state and logs and update whenever it receives a message.
import React from "react";
import { useDispatch, useSelector } from "react-redux";
import { Grid } from "@material-ui/core";
import ActionCableWrapper from "../ActionCableWrapper";
import { updateBuildFromMessage } from "redux/slices/builds";
export default function BuildDetailsCard(props) {
const dispatch = useDispatch();
const build = useSelector(state => state.builds.activeBuild);
const handleMessageReceived = message => dispatch(updateBuildFromMessage(message));
return (
<>
<ActionCableWrapper channel="BuildChannel" room={build.id} onReceived={handleMessageReceived} />
<Grid container>
<Grid item xs={3}>
<div>
<b>Repository Name:</b> {build.repository.name}
</div>
<div>
<b>Commit Message:</b> {build.commit_message}
</div>
<div>
<b>Commit SHA:</b> {build.commit_short}
</div>
<div>
<b>State:</b> {build.aasm_state}
</div>
</Grid>
<Grid
item
xs={9}
style={{
border: "2px",
backgroundColor: "#343a40",
fontSize: "0.9rem",
fontFamily: "Monaco",
color: "white",
padding: 10,
}}
>
{build.logs.map(log => (
<div>{log.message}</div>
))}
</Grid>
</Grid>
</>
);
}
For demo purposes I probably went a little overboard with the styling, but I wanted to create something that resembles our actual application which you saw at the start of this post. The two things needed to power the page are the build, which is retrieved with useSelector
and the handleMessageReceived
function, which dispatches updateBuildFromMessage
every time we receive a message through Action Cable. We supply the ”BuildChannel”
and build.id
as the channel and room to ActionCableWrapper
along with handleMessageReceived
as the onReceived
function.
In the video below I’ll move the build through its different states and we’ll be able to see the frontend receive the messages, update the state, and add the logs to the screen.
Conclusion
That's a wrap on my adventure into how we set up our Action Cable integration with Redux Toolkit. There are tons of places in the application we’re going to be adding live updates too so that our users will always be up to date on the state of their application. I hope you enjoyed taking a peek inside some development work at Release. If you're interested in having an ephemeral environment created whenever we receive a Pull Request webhook from your Repository, head on over to the homepage and sign up! If you’d like to join our awesome team, check out our job listsings.
Top comments (0)