Skip to content
This repository was archived by the owner on Jun 16, 2024. It is now read-only.

Commit 308b00c

Browse files
committed
KioskHeartbeat
#69
1 parent 540b36b commit 308b00c

19 files changed

+259
-15
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
class Api::Control::KiosksController < Api::Control::ApplicationController
2+
def index
3+
render(json: {
4+
kiosks: KioskHeartbeat.all.to_a.map(&:as_json),
5+
})
6+
end
7+
8+
def reload
9+
@kiosk_heartbeat = KioskHeartbeat.find(params[:id])
10+
SendKioskControlJob.perform_now(reload: {name: @kiosk_heartbeat.name})
11+
render(json: {})
12+
end
13+
14+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
class Api::KioskHeartbeatsController < ApplicationController
2+
def create
3+
raise Api::ApplicationController::Error::Unauthorized unless session[:kiosk]
4+
now = Time.zone.now
5+
at = params[:last_heartbeat_at]&.to_i
6+
KioskHeartbeat.find_or_initialize_by(
7+
name: session[:kiosk],
8+
).update!(
9+
version: params[:version],
10+
last_heartbeat_at: at ? Time.at(at) : nil,
11+
last_checkin_at: now,
12+
)
13+
render(json: {}.to_json)
14+
end
15+
end

app/javascript/Api.ts

+14
Original file line numberDiff line numberDiff line change
@@ -381,13 +381,22 @@ export type ChatAdminControl = {
381381
promo?: boolean;
382382

383383
outpost?: OutpostNotification;
384+
385+
kiosk_control?: KioskControlMessage;
384386
};
385387

386388
export type OutpostNotification = {
387389
conference?: string;
388390
venue_announcements?: string;
389391
};
390392

393+
export type KioskControlMessage = {
394+
reload?: KioskControlReload;
395+
ping?: number;
396+
};
397+
398+
export type KioskControlReload = { name?: string; all?: boolean };
399+
391400
export type ChatCaption = {
392401
result_id: string;
393402
is_partial: boolean;
@@ -757,6 +766,11 @@ export const Api = {
757766
useVenueAnnouncements() {
758767
return useSWR<GetVenueAnnouncementsResponse, ApiError>(`/api/venue_announcements`, swrFetcher);
759768
},
769+
770+
async updateKioskHeartbeat(params: { last_heartbeat_at: number; version: string }) {
771+
const resp = await request("/api/kiosk_heartbeats", "POST", null, params);
772+
return resp.json();
773+
},
760774
};
761775

762776
export default Api;

app/javascript/App.tsx

+3
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const ControlScreenPage = loadable(() => import("./ControlScreenPage"));
2222
const ControlNextSessionPage = loadable(() => import("./ControlNextSessionPage"));
2323
const ControlTrackPage = loadable(() => import("./ControlTrackPage"));
2424
const ControlVenueAnnouncementsPage = loadable(() => import("./ControlVenueAnnouncementsPage"));
25+
const ControlKiosksPage = loadable(() => import("./ControlKiosksPage"));
2526

2627
const IntermissionScreen = loadable(() => import("./IntermissionScreen"));
2728
const SubScreen = loadable(() => import("./SubScreen"));
@@ -72,6 +73,8 @@ export const App: React.FC<Props> = (_props) => {
7273

7374
<Route path="next_session" element={<ControlNextSessionPage />} />
7475

76+
<Route path="kiosks" element={<ControlKiosksPage />} />
77+
7578
<Route path="session/new" element={<ControlLogin />} />
7679
</Route>
7780
</Route>

app/javascript/ChatSession.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export type ChatUpdate = {
4848
kind: ChatUpdateKind;
4949
message?: ChatMessage;
5050
member?: Identity;
51-
}
51+
};
5252

5353
interface AdminMessage {
5454
message?: string;
@@ -388,6 +388,7 @@ function parseChimeName(chimeName: string): { name: string; version: string; fla
388388
}
389389

390390
function parseAdminMessage(message: string): [string | null, ChatAdminControl | null] {
391+
console.log("parseAdminMessage", message);
391392
try {
392393
const adminMessage: AdminMessage = JSON.parse(message);
393394
return [adminMessage.message ?? null, adminMessage.control ?? null];

app/javascript/ControlApi.tsx

+26
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,22 @@ export type ControlUpdateVenueAnnouncementRequest = {
157157

158158
export type ControlDeleteVenueAnnouncementRequest = {};
159159

160+
export type ControlKioskHeartbeat = {
161+
id: number;
162+
name: string;
163+
version: string;
164+
last_heartbeat_at?: number;
165+
last_checkin_at?: number;
166+
};
167+
168+
export type ControlListKiosksResponse = {
169+
kiosks: ControlKioskHeartbeat[];
170+
};
171+
172+
export type ControlReloadKioskRequest = {
173+
name: string;
174+
};
175+
160176
export const ControlApi = {
161177
useConference() {
162178
return useSWR<ControlGetConferenceResponse, ApiError>("/api/control/conference", swrFetcher, {
@@ -318,5 +334,15 @@ export const ControlApi = {
318334
mutate(`/api/control/venue_announcements`);
319335
return resp.json() as Promise<{ venue_announcement: VenueAnnouncement }>;
320336
},
337+
338+
useKiosks() {
339+
return useSWR<ControlListKiosksResponse, ApiError>(`/api/control/kiosks`, swrFetcher);
340+
},
341+
342+
async reloadKiosk(id: number) {
343+
const url = `/api/control/kiosks/${id}/reload`;
344+
await request(url, "POST", null, {});
345+
return {};
346+
},
321347
};
322348
export default ControlApi;

app/javascript/ControlKiosksPage.tsx

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import React from "react";
2+
3+
import { Box, Flex } from "@chakra-ui/react";
4+
5+
import ControlApi from "./ControlApi";
6+
7+
export const ControlKiosksPage: React.FC = () => {
8+
const { data } = ControlApi.useKiosks();
9+
10+
if (!data) return <p>Loading..</p>;
11+
12+
return (
13+
<Box mx="50px">
14+
<Flex direction="row">
15+
{data.kiosks.map((kiosk) => (
16+
<Box flex={1} key={kiosk.id}>
17+
{JSON.stringify(kiosk)}
18+
</Box>
19+
))}
20+
</Flex>
21+
</Box>
22+
);
23+
};
24+
export default ControlKiosksPage;

app/javascript/IntermissionScreen.tsx

+3-5
Original file line numberDiff line numberDiff line change
@@ -14,24 +14,22 @@ import { ScreenHeroFiller } from "./ScreenHeroFiller";
1414
import { ScreenAnnounceView } from "./ScreenAnnounceView";
1515
import { SignageVenueAnnouncementView } from "./SignageVenueAnnouncementView";
1616

17-
import { ChatProvider } from "./ChatProvider";
1817
import { useChat } from "./ChatProvider";
1918
import { ChatUpdate } from "./ChatSession";
20-
import { KioskLogin } from "./KioskLogin";
2119
import { useSearchParams } from "react-router-dom";
20+
import { KioskProvider } from "./KioskProvider";
2221

2322
export const IntermissionScreen: React.FC = () => {
2423
return (
25-
<ChatProvider isKiosk>
26-
<KioskLogin />
24+
<KioskProvider>
2725
<Box w="100vw" h="auto">
2826
<AspectRatio ratio={16 / 9}>
2927
<Box bgColor={Colors.bg} bgSize="contain" w="100%" h="100%" p="2.5vw">
3028
<IntermissionScreenInner />
3129
</Box>
3230
</AspectRatio>
3331
</Box>
34-
</ChatProvider>
32+
</KioskProvider>
3533
);
3634
};
3735

app/javascript/KioskHeartbeat.tsx

+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import React, { useCallback, useState } from "react";
2+
import Api, { GetSessionResponse, KioskControlReload } from "./Api";
3+
import { useChat } from "./ChatProvider";
4+
import { ChatUpdate } from "./ChatSession";
5+
import { COMMIT } from "./meta";
6+
7+
const respondPing = async (hb: number) => {
8+
try {
9+
await Api.updateKioskHeartbeat({ last_heartbeat_at: hb, version: COMMIT });
10+
} catch (e) {
11+
console.warn(e);
12+
}
13+
};
14+
15+
const handleReload = async (session: GetSessionResponse, reload: KioskControlReload) => {
16+
if (session?.kiosk === reload.name || reload.all) {
17+
location.reload();
18+
}
19+
};
20+
21+
export const KioskHeartbeat: React.FC = () => {
22+
const { data: session } = Api.useSession();
23+
const chat = useChat();
24+
const [_lastHeartbeat, setLastHeartbeat] = useState(-1);
25+
const systemsChannel = chat.systems_channel_arn;
26+
27+
const onMessage = useCallback(
28+
(update: ChatUpdate) => {
29+
if (!session) return null;
30+
if (!session.kiosk) return null;
31+
32+
console.log("KioskHeartbeat: onMessage", update);
33+
const kioskControl = update.message?.adminControl?.kiosk_control;
34+
if (!kioskControl) return;
35+
36+
if (kioskControl.reload) handleReload(session, kioskControl.reload);
37+
38+
if (kioskControl.ping) {
39+
const t = kioskControl.ping;
40+
setLastHeartbeat(t);
41+
setTimeout(() => respondPing(t), Math.floor(Math.random() * 15000));
42+
}
43+
},
44+
[session],
45+
);
46+
47+
React.useEffect(() => {
48+
if (!session) return;
49+
if (!session.kiosk) return;
50+
if (!chat.session) return;
51+
if (!systemsChannel) return;
52+
53+
console.log("KioskHeartbeat: subscribeMessageUpdate");
54+
55+
const unsubscribe = chat.session.subscribeMessageUpdate(systemsChannel, onMessage);
56+
57+
return () => {
58+
console.log("KioskHeartbeat: subscribeMessageUpdate; unsubscribing");
59+
unsubscribe();
60+
};
61+
}, [chat.session, systemsChannel, onMessage]);
62+
63+
if (!session) return null;
64+
if (!session.kiosk) return null;
65+
66+
return null;
67+
};

app/javascript/KioskProvider.tsx

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import React from "react";
2+
import { ChatProvider } from "./ChatProvider";
3+
import { KioskLogin } from "./KioskLogin";
4+
import { KioskHeartbeat } from "./KioskHeartbeat";
5+
6+
export const KioskProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
7+
return (
8+
<>
9+
<ChatProvider isKiosk>
10+
<KioskLogin />
11+
<KioskHeartbeat />
12+
<>{children}</>
13+
</ChatProvider>
14+
</>
15+
);
16+
};

app/javascript/SubScreen.tsx

+3-5
Original file line numberDiff line numberDiff line change
@@ -7,30 +7,28 @@ import Api, { consumeChatAdminControl, TrackSlug } from "./Api";
77
import { Colors } from "./theme";
88
import { Logo } from "./Logo";
99

10-
import { ChatProvider } from "./ChatProvider";
1110
import { useChat } from "./ChatProvider";
1211
import { ChatUpdate } from "./ChatSession";
13-
import { KioskLogin } from "./KioskLogin";
1412
import { useParams } from "react-router-dom";
1513

1614
import { SubScreenChatView } from "./SubScreenChatView";
1715
import { SubScreenCaptionView } from "./SubScreenCaptionView";
1816
import { SubScreenAnnouncementsView } from "./SubScreenAnnouncementsView";
1917
import { SubScreenLightningTimerView } from "./SubScreenLightningTimerView";
18+
import { KioskProvider } from "./KioskProvider";
2019

2120
export const SubScreen: React.FC = () => {
2221
const { slug: trackSlug }: Readonly<Partial<{ slug: TrackSlug }>> = useParams();
2322
return (
24-
<ChatProvider isKiosk>
25-
<KioskLogin />
23+
<KioskProvider>
2624
<Box w="100vw" h="auto">
2725
<AspectRatio ratio={16 / 9}>
2826
<Box bgColor={Colors.bg} bgSize="contain" w="100%" h="100%" p="0.7vw">
2927
<SubScreenInner trackSlug={trackSlug!} />
3028
</Box>
3129
</AspectRatio>
3230
</Box>
33-
</ChatProvider>
31+
</KioskProvider>
3432
);
3533
};
3634

app/javascript/TrackVideo.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { AspectRatio, Box, Center, VStack, Skeleton, Image, Heading, Button } fr
99

1010
import { Api, IvsMetadata, Track, TrackStreamOptions, consumeIvsMetadata } from "./Api";
1111
import { Colors } from "./theme";
12-
import {CACHE_BUSTER} from "./meta";
12+
import { CACHE_BUSTER } from "./meta";
1313

1414
export type Props = {
1515
track: Track;

app/javascript/meta.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
export const DEFAULT_AVATAR_URL = document.querySelector<HTMLMetaElement>('meta[name="rkto:default-avatar"]')?.content;
22
export const SENTRY_DSN = document.querySelector<HTMLMetaElement>('meta[name="rkto:sentry-dsn"]')?.content;
33
export const CACHE_BUSTER = document.querySelector<HTMLMetaElement>('meta[name="rkto:cache-buster"]')?.content;
4-
export const COMMIT = document.querySelector<HTMLMetaElement>('meta[name="rkto:commit"]')?.content || "";
4+
export const COMMIT = document.querySelector<HTMLMetaElement>('meta[name="rkto:commit"]')?.content || "unknown";

app/jobs/send_kiosk_control_job.rb

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
class SendKioskControlJob < ApplicationJob
2+
def perform(ping: true, reload: nil, now: Time.zone.now)
3+
@chimemessaging = Aws::ChimeSDKMessaging::Client.new(region: 'us-east-1', logger: Rails.logger)
4+
5+
control = {
6+
control: {
7+
kiosk_control: {
8+
ping: now.to_i,
9+
reload: reload,
10+
},
11+
},
12+
}
13+
14+
@chimemessaging.send_channel_message(
15+
chime_bearer: Conference.data.fetch(:chime).fetch(:app_user_arn),
16+
channel_arn: Conference.data.fetch(:chime).fetch(:systems_channel_arn),
17+
content: control.to_json,
18+
type: 'STANDARD',
19+
persistence: 'NON_PERSISTENT',
20+
)
21+
end
22+
end

app/models/kiosk_heartbeat.rb

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
class KioskHeartbeat < ApplicationRecord
2+
validates :name, presence: true
3+
validates :version, presence: true
4+
5+
def as_json
6+
{
7+
id: id,
8+
name: name,
9+
version: version,
10+
last_heartbeat_at: last_heartbeat_at&.to_i,
11+
last_checkin_at: last_checkin_at&.to_i,
12+
}
13+
end
14+
end

config/routes.rb

+9
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
/control/next_session
3434
/control/tracks/:slug
3535
/control/venue_announcements
36+
/control/kiosks
3637
).each do |_|
3738
get _ => 'frontend#show_require_control'
3839
end
@@ -85,8 +86,16 @@
8586
resources :venue_announcements, only: %i(index destroy create update)
8687

8788
resources :attendees, only: %i(index show update)
89+
90+
resources :kiosks, only: %i(index) do
91+
member do
92+
post :reload
93+
end
94+
end
8895
end
8996

97+
resources :kiosk_heartbeats, only: %i(create)
98+
9099
scope path: 'oidc' do
91100
get :jwks, to: 'oidc#show_jwks'
92101
end

0 commit comments

Comments
 (0)