Skip to content

Commit fc99a1c

Browse files
committed
display event stream history in user menu
1 parent 8dbd823 commit fc99a1c

File tree

7 files changed

+237
-98
lines changed

7 files changed

+237
-98
lines changed
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { Box, List, ListItem, Typography } from "@mui/material";
2+
import dayjs from "dayjs";
3+
import utc from "dayjs/plugin/utc";
4+
5+
import { DATE_FORMAT, TIME_FORMAT } from "../../constants/datetimes";
6+
import { useEventStream } from "../../state/eventStream";
7+
import { EventMessage } from "../eventMessages/EventMessage";
8+
9+
dayjs.extend(utc);
10+
11+
/**
12+
* Displays a list of events sorted by timestamp (newest first)
13+
*/
14+
export const EventList = () => {
15+
const { events } = useEventStream();
16+
17+
if (events.length === 0) {
18+
return (
19+
<Box sx={{ p: 2, textAlign: "center" }}>
20+
<Typography color="text.secondary" variant="body2">
21+
No events to display
22+
</Typography>
23+
</Box>
24+
);
25+
}
26+
27+
return (
28+
<Box sx={{ maxHeight: 400, overflow: "auto" }}>
29+
<Typography sx={{ p: 1, fontWeight: "bold" }} variant="subtitle2">
30+
Events ({events.length})
31+
</Typography>
32+
33+
<List dense>
34+
{events.map((event) => {
35+
const eventTime = dayjs.utc(event.timestamp);
36+
37+
return (
38+
<ListItem key={`${event.timestamp}-${event.ordinal}`} sx={{ mb: 1, borderRadius: 1 }}>
39+
<Box sx={{ width: "100%" }}>
40+
<Typography
41+
color="text.secondary"
42+
sx={{ display: "block", mb: 0.5 }}
43+
variant="caption"
44+
>
45+
{eventTime.local().format(TIME_FORMAT)}{eventTime.local().format(DATE_FORMAT)}
46+
</Typography>
47+
<EventMessage message={event} />
48+
</Box>
49+
</ListItem>
50+
);
51+
})}
52+
</List>
53+
</Box>
54+
);
55+
};

src/components/eventStream/EventStream.tsx

Lines changed: 57 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -6,36 +6,70 @@ import {
66
useGetEventStream,
77
} from "@squonk/account-server-client/event-stream";
88

9+
import dayjs from "dayjs";
10+
import utc from "dayjs/plugin/utc";
911
import { useAtom } from "jotai";
1012
import { useSnackbar } from "notistack";
1113

1214
import { useASAuthorizationStatus } from "../../hooks/useIsAuthorized";
13-
import { getMessageFromEvent, protoBlobToText } from "../../protobuf/protobuf";
14-
import { eventStreamEnabledAtom } from "../../state/eventStream";
15+
import { getMessageFromEvent } from "../../protobuf/protobuf";
16+
import { eventStreamEnabledAtom, useEventStream } from "../../state/eventStream";
1517
import { useUnreadEventCount } from "../../state/notifications";
1618
import { EventMessage } from "../eventMessages/EventMessage";
1719
import { useIsEventStreamInstalled } from "./useIsEventStreamInstalled";
1820

21+
dayjs.extend(utc);
22+
23+
/**
24+
* Builds WebSocket URL
25+
*/
26+
const buildWebSocketUrl = (location: string): string => {
27+
const url = new URL(location);
28+
url.protocol = "wss:";
29+
return url.toString();
30+
};
31+
32+
/**
33+
* Manages WebSocket connection for event stream and displays toast notifications
34+
*/
1935
export const EventStream = () => {
2036
const isEventStreamInstalled = useIsEventStreamInstalled();
2137
const [location, setLocation] = useState<string | null>(null);
2238
const { enqueueSnackbar } = useSnackbar();
2339
const { incrementCount } = useUnreadEventCount();
2440
const asRole = useASAuthorizationStatus();
41+
const { addEvent, isEventNewerThanSession, initializeSession } = useEventStream();
2542

2643
const { data, error: streamError } = useGetEventStream({
2744
query: { select: (data) => data.location, enabled: !!asRole && isEventStreamInstalled },
2845
});
46+
2947
const { mutate: createEventStream } = useCreateEventStream({
30-
mutation: {
31-
onSuccess: (eventStreamResponse) => {
32-
setLocation(eventStreamResponse.location);
33-
},
34-
},
48+
mutation: { onSuccess: (eventStreamResponse) => setLocation(eventStreamResponse.location) },
3549
});
50+
3651
const [eventStreamEnabled] = useAtom(eventStreamEnabledAtom);
3752

38-
// Define callbacks *before* useWebSocket hook
53+
const handleWebSocketMessage = useCallback(
54+
(event: MessageEvent) => {
55+
const message = getMessageFromEvent(JSON.parse(event.data));
56+
57+
if (
58+
message &&
59+
addEvent(message) && // Only show toast for events newer than session start
60+
isEventNewerThanSession(message)
61+
) {
62+
enqueueSnackbar(<EventMessage message={message} />, {
63+
variant: "default",
64+
anchorOrigin: { horizontal: "right", vertical: "bottom" },
65+
autoHideDuration: 10_000,
66+
});
67+
incrementCount();
68+
}
69+
},
70+
[enqueueSnackbar, incrementCount, addEvent, isEventNewerThanSession],
71+
);
72+
3973
const handleWebSocketOpen = useCallback(() => {
4074
enqueueSnackbar("Connected to event stream", {
4175
variant: "success",
@@ -45,19 +79,14 @@ export const EventStream = () => {
4579

4680
const handleWebSocketClose = useCallback(
4781
(event: CloseEvent) => {
48-
console.log(event);
49-
if (event.wasClean) {
50-
enqueueSnackbar("Disconnected from event stream", {
51-
variant: "info",
52-
anchorOrigin: { horizontal: "right", vertical: "bottom" },
53-
});
54-
} else {
55-
console.warn("EventStream: WebSocket closed unexpectedly.");
56-
enqueueSnackbar("Event stream disconnected unexpectedly. Attempting to reconnect...", {
57-
variant: "warning",
58-
anchorOrigin: { horizontal: "right", vertical: "bottom" },
59-
});
60-
}
82+
const message = event.wasClean
83+
? "Disconnected from event stream"
84+
: "Event stream disconnected unexpectedly. Attempting to reconnect...";
85+
86+
enqueueSnackbar(message, {
87+
variant: event.wasClean ? "info" : "warning",
88+
anchorOrigin: { horizontal: "right", vertical: "bottom" },
89+
});
6190
},
6291
[enqueueSnackbar],
6392
);
@@ -69,51 +98,8 @@ export const EventStream = () => {
6998
});
7099
}, [enqueueSnackbar]);
71100

72-
const handleWebSocketMessage = useCallback(
73-
(event: MessageEvent) => {
74-
console.log("message");
75-
if (event.data instanceof Blob) {
76-
protoBlobToText(event.data)
77-
.then((textData) => {
78-
const message = getMessageFromEvent(textData);
79-
if (message) {
80-
incrementCount();
81-
enqueueSnackbar(<EventMessage message={message} />, {
82-
variant: "default",
83-
anchorOrigin: { horizontal: "right", vertical: "bottom" },
84-
autoHideDuration: 10_000,
85-
});
86-
} else {
87-
console.warn(
88-
"Received event data could not be parsed into a known message type:",
89-
textData,
90-
);
91-
}
92-
})
93-
.catch((error) => {
94-
console.error("Error processing protobuf message:", error);
95-
enqueueSnackbar("Error processing incoming event", {
96-
variant: "error",
97-
anchorOrigin: { horizontal: "right", vertical: "bottom" },
98-
});
99-
});
100-
} else {
101-
console.warn("Received non-Blob WebSocket message:", event.data);
102-
}
103-
},
104-
[enqueueSnackbar, incrementCount],
105-
);
106-
107-
let wsUrl = null;
108-
if (eventStreamEnabled && asRole && location) {
109-
const url = new URL(location);
110-
url.protocol = "wss:";
111-
url.search = new URLSearchParams({
112-
// stream_from_timestamp: encodeURIComponent("2025-07-1T12:00:00Z"),
113-
stream_from_ordinal: "1",
114-
}).toString();
115-
wsUrl = url.toString();
116-
}
101+
// Build WebSocket URL
102+
const wsUrl = eventStreamEnabled && asRole && location ? buildWebSocketUrl(location) : null;
117103

118104
useWebSocket(wsUrl, {
119105
onOpen: handleWebSocketOpen,
@@ -126,7 +112,6 @@ export const EventStream = () => {
126112
reconnectInterval: 3000,
127113
});
128114

129-
// Effects can now safely use the hook results or return early based on auth
130115
useEffect(() => {
131116
if (asRole && data) {
132117
setLocation(data);
@@ -139,5 +124,10 @@ export const EventStream = () => {
139124
}
140125
}, [asRole, streamError, createEventStream]);
141126

127+
// Initialize session on client side only
128+
useEffect(() => {
129+
initializeSession();
130+
}, [initializeSession]);
131+
142132
return null;
143133
};
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Box, Divider, Typography } from "@mui/material";
2+
3+
import { EventList } from "./EventList";
4+
import { EventStreamToggle } from "./EventStreamToggle";
5+
6+
/**
7+
* Main event stream interface in the user menu popover
8+
*/
9+
export const EventStreamMessages = () => (
10+
<Box sx={{ minWidth: 300 }}>
11+
<Typography sx={{ mb: 1 }} variant="h6">
12+
Event Stream
13+
</Typography>
14+
15+
<EventStreamToggle />
16+
17+
<Divider sx={{ my: 2 }} />
18+
19+
<EventList />
20+
</Box>
21+
);

src/components/instances/JobDetails/JobInputSection/useGetJobInputs.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,6 @@ export const useGetJobInputs = (instance: InstanceGetResponse | InstanceSummary)
2727
{ query: { enabled: inputsEnabled, retry: instance.job_id === TEST_JOB_ID ? 1 : 3 } },
2828
);
2929

30-
console.log(instance);
31-
3230
// Parse application specification
3331
const applicationSpecification: ApplicationSpecification = instance.application_specification
3432
? JSON.parse(instance.application_specification)

src/layouts/navigation/UserMenuContent.tsx

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,30 +5,10 @@ import { AuthButton } from "../../components/auth/AuthButton";
55
import { CenterLoader } from "../../components/CenterLoader";
66
import { Chips } from "../../components/Chips";
77
import { ColourSchemeSelection } from "../../components/ColourSchemeSelection";
8-
import { EventStreamToggle } from "../../components/eventStream/EventStreamToggle";
8+
import { EventStreamMessages } from "../../components/eventStream/EventStreamMessages";
99
import { useASAuthorizationStatus, useDMAuthorizationStatus } from "../../hooks/useIsAuthorized";
1010
import { useKeycloakUser } from "../../hooks/useKeycloakUser";
1111

12-
/**
13-
* Content of the user menu
14-
*/
15-
export const UserMenuContent = () => {
16-
const theme = useTheme();
17-
const biggerThanMd = useMediaQuery(theme.breakpoints.up("md"));
18-
// Removed eventStreamEnabledAtom usage, now handled in EventStreamToggle
19-
20-
return (
21-
<Box sx={{ textAlign: biggerThanMd ? "center" : undefined }}>
22-
<Typography gutterBottom variant="h3">
23-
Account
24-
</Typography>
25-
<UserMenuContentInner />
26-
<ColourSchemeSelection />
27-
<EventStreamToggle />
28-
</Box>
29-
);
30-
};
31-
3212
const UserMenuContentInner = () => {
3313
const asRole = useASAuthorizationStatus();
3414
const dmRole = useDMAuthorizationStatus();
@@ -74,3 +54,23 @@ const UserMenuContentInner = () => {
7454

7555
return <AuthButton mode="login" />;
7656
};
57+
58+
/**
59+
* Content of the user menu
60+
*/
61+
export const UserMenuContent = () => {
62+
const theme = useTheme();
63+
const biggerThanMd = useMediaQuery(theme.breakpoints.up("md"));
64+
// Removed eventStreamEnabledAtom usage, now handled in EventStreamToggle
65+
66+
return (
67+
<Box sx={{ textAlign: biggerThanMd ? "center" : undefined }}>
68+
<Typography gutterBottom variant="h3">
69+
Account
70+
</Typography>
71+
<UserMenuContentInner />
72+
<ColourSchemeSelection />
73+
<EventStreamMessages />
74+
</Box>
75+
);
76+
};

src/protobuf/protobuf.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,19 +82,24 @@ export const storageType = getPrefixedMessageNameFromSchema(
8282
) as StorageTypeName;
8383
// --- End Runtime Constants ---
8484

85-
type ProcessingMessagePayload = {
85+
interface MessageBase {
86+
timestamp: string;
87+
ordinal: number;
88+
}
89+
90+
interface ProcessingMessagePayload extends MessageBase {
8691
type: ProcessingTypeName;
8792
name: string;
8893
coins: string;
8994
product: string;
90-
};
95+
}
9196

92-
type StorageMessagePayload = {
97+
interface StorageMessagePayload extends MessageBase {
9398
type: StorageTypeName;
9499
name: string;
95100
bytes: string;
96101
reason: StorageReasonEnum;
97-
};
102+
}
98103

99104
// Discriminated union type representing the possible charge message payloads
100105
// derived from Protobuf messages (Processing or Storage).
@@ -103,6 +108,8 @@ export type ChargeMessage = ProcessingMessagePayload | StorageMessagePayload;
103108
interface EventStreamMessage {
104109
message_type: string;
105110
message_body: any;
111+
ess_timestamp: string;
112+
ess_ordinal: number;
106113
}
107114

108115
/**
@@ -133,6 +140,8 @@ export const getMessageFromEvent = (event: EventStreamMessage): ChargeMessage |
133140
case processingType: {
134141
const parsed = fromJson(MerchantProcessingChargeMessageSchema, event.message_body);
135142
return {
143+
timestamp: parsed.timestamp,
144+
ordinal: event.ess_ordinal,
136145
type: processingType,
137146
name: parsed.name,
138147
coins: parsed.coins,
@@ -141,7 +150,14 @@ export const getMessageFromEvent = (event: EventStreamMessage): ChargeMessage |
141150
}
142151
case storageType: {
143152
const parsed = fromJson(MerchantStorageChargeMessageSchema, event.message_body);
144-
return { type: storageType, name: parsed.name, bytes: parsed.bytes, reason: parsed.reason };
153+
return {
154+
timestamp: parsed.timestamp,
155+
ordinal: event.ess_ordinal,
156+
type: storageType,
157+
name: parsed.name,
158+
bytes: parsed.bytes,
159+
reason: parsed.reason,
160+
};
145161
}
146162
default:
147163
return null;

0 commit comments

Comments
 (0)