feat: add backend beamer live updates
This commit is contained in:
parent
e41ef870f9
commit
7151976d26
|
|
@ -30,6 +30,7 @@ bundle exec rspec
|
|||
Leader/follower tournament sync docs:
|
||||
|
||||
- [doc/leader_follower.md](doc/leader_follower.md)
|
||||
- [doc/beamer_live_updates.md](doc/beamer_live_updates.md)
|
||||
|
||||
## Docker
|
||||
[Registry](https://gitlab.com/turniere/turniere-backend/container_registry)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class TournamentBeamersChannel < ApplicationCable::Channel
|
||||
class << self
|
||||
def stream_name(tournament_id:, beamer_name:)
|
||||
"tournament_#{tournament_id}:beamers:#{beamer_name}"
|
||||
end
|
||||
end
|
||||
|
||||
def subscribed
|
||||
tournament = Tournament.find(params[:tournament_id])
|
||||
beamer_name = params[:beamer_name].presence
|
||||
beamer = tournament.beamers.find_by(name: beamer_name) || tournament.beamers.find_by(name: Beamer::DEFAULT_NAME)
|
||||
|
||||
return reject unless beamer
|
||||
|
||||
stream_from self.class.stream_name(tournament_id: tournament.id, beamer_name: beamer.name)
|
||||
transmit BeamerBroadcastPayload.build(beamer:, event: 'snapshot')
|
||||
end
|
||||
end
|
||||
|
|
@ -13,4 +13,37 @@ class Beamer < ApplicationRecord
|
|||
validates :display_state, presence: true, inclusion: { in: DISPLAY_STATES }
|
||||
|
||||
delegate :owner, to: :tournament
|
||||
|
||||
before_update :store_previous_name_for_broadcast, if: :will_save_change_to_name?
|
||||
before_destroy :store_snapshot_for_broadcast
|
||||
after_commit :broadcast_state_change, on: %i[create update destroy]
|
||||
|
||||
private
|
||||
|
||||
def store_previous_name_for_broadcast
|
||||
@broadcast_previous_name = name_in_database
|
||||
end
|
||||
|
||||
def store_snapshot_for_broadcast
|
||||
@broadcast_snapshot = BeamerBroadcastPayload.snapshot(self)
|
||||
end
|
||||
|
||||
def broadcast_state_change
|
||||
snapshot = @broadcast_snapshot || BeamerBroadcastPayload.snapshot(self)
|
||||
|
||||
BeamerBroadcast.call(
|
||||
tournament_id: snapshot.fetch(:tournament_id),
|
||||
beamer_names: [@broadcast_previous_name, snapshot.fetch(:name)].compact.uniq,
|
||||
payload: BeamerBroadcastPayload.build(snapshot: snapshot, event: broadcast_event)
|
||||
)
|
||||
ensure
|
||||
@broadcast_previous_name = nil
|
||||
@broadcast_snapshot = nil
|
||||
end
|
||||
|
||||
def broadcast_event
|
||||
return 'deleted' if destroyed?
|
||||
|
||||
previous_changes.key?('id') ? 'created' : 'updated'
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class BeamerSerializer < ApplicationSerializer
|
||||
attributes :name, :display_state, :qr_code_url, :logo_url
|
||||
attributes :id, :name, :display_state, :qr_code_url, :logo_url
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class BeamerBroadcast
|
||||
class << self
|
||||
def call(tournament_id:, beamer_names:, payload:)
|
||||
Array(beamer_names).compact.uniq.each do |beamer_name|
|
||||
ActionCable.server.broadcast(
|
||||
TournamentBeamersChannel.stream_name(tournament_id:, beamer_name:),
|
||||
payload
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class BeamerBroadcastPayload
|
||||
class << self
|
||||
def build(beamer: nil, snapshot: nil, event:)
|
||||
data = snapshot || self.snapshot(beamer)
|
||||
|
||||
{
|
||||
type: "beamer.#{event}",
|
||||
event: event,
|
||||
tournament_id: data.fetch(:tournament_id),
|
||||
beamer: serialized_beamer(data)
|
||||
}
|
||||
end
|
||||
|
||||
def snapshot(beamer)
|
||||
{
|
||||
id: beamer.id,
|
||||
tournament_id: beamer.tournament_id,
|
||||
name: beamer.name,
|
||||
display_state: beamer.display_state,
|
||||
qr_code_url: beamer.qr_code_url,
|
||||
logo_url: beamer.logo_url
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def serialized_beamer(snapshot)
|
||||
{
|
||||
id: snapshot.fetch(:id),
|
||||
name: snapshot.fetch(:name),
|
||||
display_state: snapshot.fetch(:display_state),
|
||||
qr_code_url: snapshot[:qr_code_url],
|
||||
logo_url: snapshot[:logo_url]
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
# Beamer Live Updates
|
||||
|
||||
Beamer/projector live updates are backend-owned and use Rails ActionCable at `/cable`.
|
||||
|
||||
## Goal
|
||||
|
||||
Frontend fullscreen pages talk only to `turniere-backend`.
|
||||
No `TURNIERE_MATCH_URL`.
|
||||
No Socket.IO dependency on `turniere-match`.
|
||||
|
||||
## Transport
|
||||
|
||||
- websocket endpoint: `GET /cable`
|
||||
- ActionCable channel: `TournamentBeamersChannel`
|
||||
- subscription params:
|
||||
- `tournament_id`
|
||||
- `beamer_name`
|
||||
|
||||
If requested beamer does not exist, channel falls back to tournament default beamer when present.
|
||||
|
||||
## Payload contract
|
||||
|
||||
Initial subscribe snapshot:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "beamer.snapshot",
|
||||
"event": "snapshot",
|
||||
"tournament_id": 12,
|
||||
"beamer": {
|
||||
"id": 34,
|
||||
"name": "default",
|
||||
"display_state": "matches_all",
|
||||
"qr_code_url": null,
|
||||
"logo_url": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Mutation events use same `beamer` shape with `type` / `event` values:
|
||||
|
||||
- `beamer.created`
|
||||
- `beamer.updated`
|
||||
- `beamer.deleted`
|
||||
|
||||
## Broadcast source
|
||||
|
||||
Broadcasts come from `Beamer` model commit callbacks.
|
||||
That means normal HTTP writes and follower-sync imports use same broadcast path.
|
||||
|
||||
Covered mutations:
|
||||
|
||||
- create
|
||||
- update
|
||||
- delete
|
||||
|
||||
## Frontend expectation
|
||||
|
||||
Frontend should:
|
||||
|
||||
- fetch beamer config from backend REST API
|
||||
- subscribe to `TournamentBeamersChannel`
|
||||
- update fullscreen state from explicit `beamer` payload
|
||||
- reconnect if beamer name changes or current beamer gets deleted
|
||||
|
|
@ -17,25 +17,26 @@ module TurniereE2E
|
|||
end
|
||||
|
||||
def subscribe_team_action_lists!(tournament_id:)
|
||||
connect!
|
||||
identifier = {
|
||||
channel: 'TournamentTeamActionListsChannel',
|
||||
tournament_id: tournament_id
|
||||
}.to_json
|
||||
|
||||
driver.text(
|
||||
JSON.dump(
|
||||
command: 'subscribe',
|
||||
identifier: identifier
|
||||
)
|
||||
)
|
||||
|
||||
handshake_message = wait_for! do |message|
|
||||
message['type'] == 'confirm_subscription' ||
|
||||
(message['identifier'] == identifier && message['message'].is_a?(Hash))
|
||||
subscribe!(identifier:) do |message|
|
||||
team_action_lists_payload_message?(message)
|
||||
end
|
||||
end
|
||||
|
||||
@messages << handshake_message if team_action_lists_payload_message?(handshake_message)
|
||||
def subscribe_beamer!(tournament_id:, beamer_name:)
|
||||
identifier = {
|
||||
channel: 'TournamentBeamersChannel',
|
||||
tournament_id: tournament_id,
|
||||
beamer_name: beamer_name
|
||||
}.to_json
|
||||
|
||||
subscribe!(identifier:) do |message|
|
||||
beamer_payload_message?(message)
|
||||
end
|
||||
end
|
||||
|
||||
def wait_for_payload!(timeout: 20)
|
||||
|
|
@ -45,6 +46,12 @@ module TurniereE2E
|
|||
end.fetch('message')
|
||||
end
|
||||
|
||||
def wait_for_beamer_payload!(timeout: 20)
|
||||
wait_for!(timeout: timeout) do |message|
|
||||
beamer_payload_message?(message)
|
||||
end.fetch('message')
|
||||
end
|
||||
|
||||
def close
|
||||
return if @closed
|
||||
|
||||
|
|
@ -67,6 +74,24 @@ module TurniereE2E
|
|||
|
||||
attr_reader :base_url, :socket, :driver
|
||||
|
||||
def subscribe!(identifier:)
|
||||
connect!
|
||||
|
||||
driver.text(
|
||||
JSON.dump(
|
||||
command: 'subscribe',
|
||||
identifier: identifier
|
||||
)
|
||||
)
|
||||
|
||||
handshake_message = wait_for! do |message|
|
||||
message['type'] == 'confirm_subscription' ||
|
||||
(message['identifier'] == identifier && yield(message))
|
||||
end
|
||||
|
||||
@messages << handshake_message if yield(handshake_message)
|
||||
end
|
||||
|
||||
def connect!
|
||||
return unless @driver.nil?
|
||||
|
||||
|
|
@ -143,5 +168,10 @@ module TurniereE2E
|
|||
payload = message['message']
|
||||
payload.is_a?(Hash) && payload['type'] == 'team_action_lists.updated'
|
||||
end
|
||||
|
||||
def beamer_payload_message?(message)
|
||||
payload = message['message']
|
||||
payload.is_a?(Hash) && payload['type'].to_s.start_with?('beamer.')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
require 'securerandom'
|
||||
require_relative '../../../e2e/lib/api_client'
|
||||
require_relative '../../../e2e/lib/action_cable_client'
|
||||
|
||||
RSpec.describe 'Tournament beamer websocket HTTP E2E' do
|
||||
required_env_vars = %w[
|
||||
TURNIERE_E2E_BASE_URL
|
||||
TURNIERE_E2E_EMAIL
|
||||
TURNIERE_E2E_PASSWORD
|
||||
].freeze
|
||||
|
||||
before do
|
||||
missing_vars = required_env_vars.reject { |name| ENV.key?(name) }
|
||||
skip("HTTP E2E requires #{missing_vars.join(', ')}") unless missing_vars.empty?
|
||||
end
|
||||
|
||||
let(:base_url) { ENV.fetch('TURNIERE_E2E_BASE_URL') }
|
||||
let(:owner_email) { ENV.fetch('TURNIERE_E2E_EMAIL') }
|
||||
let(:owner_password) { ENV.fetch('TURNIERE_E2E_PASSWORD') }
|
||||
let(:client) { login_client }
|
||||
|
||||
it 'broadcasts beamer snapshots and updates over websocket' do
|
||||
tournament = create_tournament(name_prefix: 'Ws Beamer')
|
||||
create_beamer = client.post("/tournaments/#{tournament.fetch(:id)}/beamers", body: {
|
||||
name: 'projector-a',
|
||||
display_state: 'matches_all'
|
||||
})
|
||||
expect(create_beamer[:status]).to eq(201)
|
||||
beamer = create_beamer.fetch(:json)
|
||||
|
||||
cable_client = TurniereE2E::ActionCableClient.new(base_url:)
|
||||
cable_client.subscribe_beamer!(tournament_id: tournament.fetch(:id), beamer_name: beamer.fetch(:name))
|
||||
|
||||
initial_payload = cable_client.wait_for_beamer_payload!
|
||||
expect(initial_payload.fetch('type')).to eq('beamer.snapshot')
|
||||
expect(initial_payload.dig('beamer', 'id')).to eq(beamer.fetch(:id))
|
||||
expect(initial_payload.dig('beamer', 'display_state')).to eq('matches_all')
|
||||
|
||||
update = client.patch("/tournaments/#{tournament.fetch(:id)}/beamers/#{beamer.fetch(:id)}", body: {
|
||||
display_state: 'during_game',
|
||||
qr_code_url: 'https://example.com/qr',
|
||||
logo_url: '/static/logo.png'
|
||||
})
|
||||
expect(update[:status]).to eq(200)
|
||||
|
||||
updated_payload = wait_for_beamer_payload!(
|
||||
cable_client,
|
||||
event_type: 'beamer.updated',
|
||||
display_state: 'during_game'
|
||||
)
|
||||
expect(updated_payload.dig('beamer', 'qr_code_url')).to eq('https://example.com/qr')
|
||||
expect(updated_payload.dig('beamer', 'logo_url')).to eq('/static/logo.png')
|
||||
|
||||
delete = client.delete("/tournaments/#{tournament.fetch(:id)}/beamers/#{beamer.fetch(:id)}")
|
||||
expect(delete[:status]).to eq(204)
|
||||
|
||||
deleted_payload = wait_for_beamer_payload!(
|
||||
cable_client,
|
||||
event_type: 'beamer.deleted',
|
||||
display_state: 'during_game'
|
||||
)
|
||||
expect(deleted_payload.dig('beamer', 'name')).to eq('projector-a')
|
||||
ensure
|
||||
cable_client&.close
|
||||
end
|
||||
|
||||
def login_client
|
||||
api_client = TurniereE2E::ApiClient.new(base_url:)
|
||||
response = api_client.login!(email: owner_email, password: owner_password)
|
||||
|
||||
expect(response[:status]).to eq(200)
|
||||
api_client
|
||||
end
|
||||
|
||||
def create_tournament(name_prefix:)
|
||||
response = client.post('/tournaments', body: {
|
||||
name: "#{name_prefix} #{SecureRandom.hex(3)}",
|
||||
description: 'Websocket beamer tournament',
|
||||
public: true
|
||||
})
|
||||
expect(response[:status]).to eq(201)
|
||||
response.fetch(:json)
|
||||
end
|
||||
|
||||
def wait_for_beamer_payload!(cable_client, event_type:, display_state:, timeout: 20)
|
||||
deadline = Time.now + timeout
|
||||
loop do
|
||||
payload = cable_client.wait_for_beamer_payload!(timeout: [deadline - Time.now, 0.1].max)
|
||||
return payload if payload.fetch('type') == event_type &&
|
||||
payload.dig('beamer', 'display_state') == display_state
|
||||
|
||||
raise "beamer websocket payload not received within #{timeout}s" if Time.now >= deadline
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
require 'securerandom'
|
||||
require_relative '../../../e2e/lib/api_client'
|
||||
require_relative '../../../e2e/lib/action_cable_client'
|
||||
|
||||
RSpec.describe 'Tournament follower sync beamer websocket HTTP E2E' do
|
||||
required_env_vars = %w[
|
||||
TURNIERE_E2E_EMAIL
|
||||
TURNIERE_E2E_PASSWORD
|
||||
TURNIERE_SOURCE_E2E_BASE_URL
|
||||
TURNIERE_FOLLOWER_E2E_BASE_URL
|
||||
].freeze
|
||||
|
||||
before do
|
||||
missing_vars = required_env_vars.reject { |name| ENV.key?(name) }
|
||||
skip("HTTP E2E requires #{missing_vars.join(', ')}") unless missing_vars.empty?
|
||||
end
|
||||
|
||||
let(:source_base_url) { ENV.fetch('TURNIERE_SOURCE_E2E_BASE_URL') }
|
||||
let(:follower_base_url) { ENV.fetch('TURNIERE_FOLLOWER_E2E_BASE_URL') }
|
||||
let(:owner_email) { ENV.fetch('TURNIERE_E2E_EMAIL') }
|
||||
let(:owner_password) { ENV.fetch('TURNIERE_E2E_PASSWORD') }
|
||||
let(:sync_token) { "sync-#{SecureRandom.hex(8)}" }
|
||||
let(:source_client) { login_client(base_url: source_base_url) }
|
||||
|
||||
it 'broadcasts follower beamer websocket updates after source sync import' do
|
||||
source = create_tournament(client: source_client, base_url: source_base_url, name_prefix: 'Ws Beamer Source')
|
||||
follower = create_follower_tournament(name_prefix: 'Ws Beamer Follower')
|
||||
|
||||
configure_sync = source_client.patch("/tournaments/#{source.fetch(:id)}", body: {
|
||||
sync_target_url: "#{follower_base_url}/tournaments/#{follower.fetch(:id)}/sync_state",
|
||||
sync_auth_token: sync_token
|
||||
})
|
||||
expect(configure_sync[:status]).to eq(200)
|
||||
|
||||
follower_default_beamer = wait_for_default_beamer!(tournament_id: follower.fetch(:id))
|
||||
|
||||
cable_client = TurniereE2E::ActionCableClient.new(base_url: follower_base_url)
|
||||
cable_client.subscribe_beamer!(tournament_id: follower.fetch(:id), beamer_name: follower_default_beamer.fetch(:name))
|
||||
cable_client.wait_for_beamer_payload!
|
||||
|
||||
source_default_beamer = fetch_beamer(base_url: source_base_url, tournament_id: source.fetch(:id), beamer_name: Beamer::DEFAULT_NAME)
|
||||
update = source_client.patch("/tournaments/#{source.fetch(:id)}/beamers/#{source_default_beamer.fetch(:id)}", body: {
|
||||
display_state: 'groups'
|
||||
})
|
||||
expect(update[:status]).to eq(200)
|
||||
|
||||
follower_payload = wait_for_beamer_payload!(
|
||||
cable_client,
|
||||
event_type: 'beamer.updated',
|
||||
display_state: 'groups'
|
||||
)
|
||||
expect(follower_payload.dig('beamer', 'name')).to eq(Beamer::DEFAULT_NAME)
|
||||
ensure
|
||||
cable_client&.close
|
||||
end
|
||||
|
||||
def login_client(base_url:)
|
||||
client = TurniereE2E::ApiClient.new(base_url:)
|
||||
response = client.login!(email: owner_email, password: owner_password)
|
||||
expect(response[:status]).to eq(200)
|
||||
client
|
||||
end
|
||||
|
||||
def create_tournament(client:, base_url:, name_prefix:)
|
||||
response = client.post('/tournaments', body: {
|
||||
name: "#{name_prefix} #{SecureRandom.hex(3)}",
|
||||
description: 'Follower beamer websocket source tournament',
|
||||
public: true
|
||||
})
|
||||
expect(response[:status]).to eq(201)
|
||||
fetch_tournament(base_url:, tournament_id: response.dig(:json, :id))
|
||||
end
|
||||
|
||||
def create_follower_tournament(name_prefix:)
|
||||
client = login_client(base_url: follower_base_url)
|
||||
response = client.post('/tournaments', body: {
|
||||
name: "#{name_prefix} #{SecureRandom.hex(3)}",
|
||||
description: 'Follower beamer websocket target tournament',
|
||||
public: true,
|
||||
read_only_mode: true,
|
||||
sync_auth_token: sync_token
|
||||
})
|
||||
expect(response[:status]).to eq(201)
|
||||
fetch_tournament(base_url: follower_base_url, tournament_id: response.dig(:json, :id))
|
||||
end
|
||||
|
||||
def fetch_tournament(base_url:, tournament_id:)
|
||||
client = TurniereE2E::ApiClient.new(base_url:)
|
||||
response = client.get("/tournaments/#{tournament_id}")
|
||||
expect(response[:status]).to eq(200)
|
||||
response.fetch(:json)
|
||||
end
|
||||
|
||||
def fetch_beamer(base_url:, tournament_id:, beamer_name:)
|
||||
client = TurniereE2E::ApiClient.new(base_url:)
|
||||
response = client.get("/tournaments/#{tournament_id}/beamers/by_name", params: { name: beamer_name })
|
||||
expect(response[:status]).to eq(200)
|
||||
response.fetch(:json)
|
||||
end
|
||||
|
||||
def wait_for_default_beamer!(tournament_id:, timeout: 20)
|
||||
deadline = Time.now + timeout
|
||||
loop do
|
||||
beamer = fetch_beamer(base_url: follower_base_url, tournament_id:, beamer_name: Beamer::DEFAULT_NAME)
|
||||
return beamer if beamer.fetch(:name) == Beamer::DEFAULT_NAME
|
||||
|
||||
raise "follower default beamer not present within #{timeout}s" if Time.now >= deadline
|
||||
|
||||
sleep 0.5
|
||||
end
|
||||
end
|
||||
|
||||
def wait_for_beamer_payload!(cable_client, event_type:, display_state:, timeout: 20)
|
||||
deadline = Time.now + timeout
|
||||
loop do
|
||||
payload = cable_client.wait_for_beamer_payload!(timeout: [deadline - Time.now, 0.1].max)
|
||||
return payload if payload.fetch('type') == event_type &&
|
||||
payload.dig('beamer', 'display_state') == display_state
|
||||
|
||||
raise "follower beamer websocket payload not received within #{timeout}s" if Time.now >= deadline
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -42,4 +42,54 @@ RSpec.describe Beamer, type: :model do
|
|||
expect(beamer.owner).to eq(beamer.tournament.owner)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'live broadcasts' do
|
||||
it 'broadcasts create payload to current beamer stream' do
|
||||
tournament = create(:tournament)
|
||||
payload = nil
|
||||
|
||||
expect(BeamerBroadcast).to receive(:call) do |args|
|
||||
payload = args.fetch(:payload)
|
||||
expect(args.fetch(:tournament_id)).to eq(tournament.id)
|
||||
expect(args.fetch(:beamer_names)).to eq(['projector'])
|
||||
end
|
||||
|
||||
tournament.beamers.create!(name: 'projector', display_state: 'groups')
|
||||
|
||||
expect(payload[:type]).to eq('beamer.created')
|
||||
expect(payload.dig(:beamer, :display_state)).to eq('groups')
|
||||
end
|
||||
|
||||
it 'broadcasts update payload to previous and current beamer names when renamed' do
|
||||
beamer = create(:beamer, name: 'old-name')
|
||||
payload = nil
|
||||
|
||||
expect(BeamerBroadcast).to receive(:call) do |args|
|
||||
payload = args.fetch(:payload)
|
||||
expect(args.fetch(:beamer_names)).to contain_exactly('old-name', 'new-name')
|
||||
end
|
||||
|
||||
beamer.update!(name: 'new-name', display_state: 'during_game')
|
||||
|
||||
expect(payload[:type]).to eq('beamer.updated')
|
||||
expect(payload.dig(:beamer, :name)).to eq('new-name')
|
||||
expect(payload.dig(:beamer, :display_state)).to eq('during_game')
|
||||
end
|
||||
|
||||
it 'broadcasts delete payload with last persisted snapshot' do
|
||||
beamer = create(:beamer, name: 'projector', display_state: 'between_games')
|
||||
payload = nil
|
||||
|
||||
expect(BeamerBroadcast).to receive(:call) do |args|
|
||||
payload = args.fetch(:payload)
|
||||
expect(args.fetch(:beamer_names)).to eq(['projector'])
|
||||
end
|
||||
|
||||
beamer.destroy!
|
||||
|
||||
expect(payload[:type]).to eq('beamer.deleted')
|
||||
expect(payload.dig(:beamer, :name)).to eq('projector')
|
||||
expect(payload.dig(:beamer, :display_state)).to eq('between_games')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Reference in New Issue