diff --git a/crates/client/src/consumers.rs b/crates/client/src/consumers.rs index dbe9a809..4c427cca 100644 --- a/crates/client/src/consumers.rs +++ b/crates/client/src/consumers.rs @@ -1,29 +1,29 @@ -use std::sync::{Arc, Mutex}; +use std::sync::Arc; +use tokio::sync::RwLock; use data::merge::merge; use serde_json::{json, Map, Value}; use tokio::sync::broadcast::{self, Receiver, Sender}; use tokio_stream::{wrappers::ReceiverStream, StreamExt}; -use tracing::error; use crate::message::Message; -pub fn broadcast(mut stream: ReceiverStream) -> (Sender, Receiver) { +pub fn broadcast(stream: ReceiverStream) -> (Sender, Receiver) { let (tx, rx) = broadcast::channel::(32); - - let manage_tx = tx.clone(); + let tx_clone = tx.clone(); tokio::spawn(async move { + tokio::pin!(stream); while let Some(message) = stream.next().await { - let _ = manage_tx.send(message); + let _ = tx_clone.send(message); } }); (tx, rx) } -pub fn keep_state(mut reciver: Receiver) -> Arc> { - let state = Arc::new(Mutex::new(json!({}))); +pub fn keep_state(mut reciver: Receiver) -> Arc> { + let state = Arc::new(RwLock::new(json!({}))); let manage_state = state.clone(); @@ -31,10 +31,7 @@ pub fn keep_state(mut reciver: Receiver) -> Arc> { while let Ok(message) = reciver.recv().await { match message { Message::Updates(updates) => { - let Ok(mut state) = manage_state.lock() else { - error!("failed to lock state"); - continue; - }; + let mut state = manage_state.write().await; for (topic, update) in updates { let mut map = Map::new(); @@ -43,11 +40,7 @@ pub fn keep_state(mut reciver: Receiver) -> Arc> { } } Message::Initial(initial) => { - let Ok(mut state) = manage_state.lock() else { - error!("failed to lock state"); - continue; - }; - + let mut state = manage_state.write().await; *state = initial; } } diff --git a/dash/package.json b/dash/package.json index 0912481d..8a67160b 100644 --- a/dash/package.json +++ b/dash/package.json @@ -20,6 +20,7 @@ "pako": "2.1.0", "react": "19.1.0", "react-dom": "19.1.0", + "react-icons": "^5.5.0", "sharp": "0.34.1", "zod": "3.23.8", "zustand": "5.0.3" diff --git a/dash/src/components/dashboard/LeaderBoard.tsx b/dash/src/components/dashboard/LeaderBoard.tsx index 69f05be9..f68117f3 100644 --- a/dash/src/components/dashboard/LeaderBoard.tsx +++ b/dash/src/components/dashboard/LeaderBoard.tsx @@ -1,49 +1,160 @@ import { AnimatePresence, LayoutGroup } from "motion/react"; import clsx from "clsx"; +import { BiSortAlt2, BiSortDown, BiSortUp } from "react-icons/bi"; import { useSettingsStore } from "@/stores/useSettingsStore"; import { useDataStore } from "@/stores/useDataStore"; +import { useSortingStore, type SortingCriteria } from "@/stores/useSortingStore"; -import { sortPos } from "@/lib/sorting"; +import { sortDrivers } from "@/lib/sorting"; import Driver from "@/components/driver/Driver"; +import Select from "@/components/ui/Select"; + +const sortOptions = [ + { label: "Position", value: "position" as SortingCriteria }, + { label: "Best Lap", value: "bestLap" as SortingCriteria }, + { label: "Last Lap", value: "lastLap" as SortingCriteria }, + { label: "Pit Status", value: "pitStatus" as SortingCriteria }, + { label: "Position Change", value: "positionChange" as SortingCriteria }, + { label: "Sector 1", value: "sector1" as SortingCriteria }, + { label: "Sector 2", value: "sector2" as SortingCriteria }, + { label: "Sector 3", value: "sector3" as SortingCriteria }, + { label: "Tyre Age", value: "tyreAge" as SortingCriteria }, +]; + +const columnSortMapping: Record = { + Position: "position", + Tire: "tyreAge", + Info: "positionChange", + Gap: "position", + LapTime: "bestLap", + Sectors: "sector1", +}; export default function LeaderBoard() { const drivers = useDataStore((state) => state?.driverList); const driversTiming = useDataStore((state) => state?.timingData); + const driversAppTiming = useDataStore((state) => state?.timingAppData); const showTableHeader = useSettingsStore((state) => state.tableHeaders); + const sortCriteria = useSortingStore((state) => state.criteria); + const sortDirection = useSortingStore((state) => state.direction); + const showSortOptions = useSortingStore((state) => state.showSortOptions); + const setSortCriteria = useSortingStore((state) => state.setCriteria); + const toggleDirection = useSortingStore((state) => state.toggleDirection); + const toggleSortOptions = useSortingStore((state) => state.toggleSortOptions); + const setSort = useSortingStore((state) => state.setSort); return ( -
- {showTableHeader && } - - {(!drivers || !driversTiming) && - new Array(20).fill("").map((_, index) => )} - - - {drivers && driversTiming && ( - - {Object.values(driversTiming.lines) - .sort(sortPos) - .map((timingDriver, index) => ( - - ))} - +
+
+ + + {sortDirection === "asc" ? ( + + ) : ( + + )} + + {showSortOptions && ( +
+ + placeholder="Sort by" + options={sortOptions} + selected={sortCriteria} + setSelected={(value) => value && setSortCriteria(value)} + /> +
)} - +
+ +
+ {showTableHeader && ( + + )} + + {(!drivers || !driversTiming) && + new Array(20).fill("").map((_, index) => )} + + + {drivers && driversTiming && ( + + {Object.values(driversTiming.lines) + .sort((a, b) => + sortDrivers( + sortCriteria, + sortDirection, + a, + b, + driversAppTiming?.lines[a.racingNumber], + driversAppTiming?.lines[b.racingNumber], + ), + ) + .map((timingDriver, index) => ( + + ))} + + )} + +
); } -const TableHeaders = () => { +type TableHeadersProps = { + currentSort: SortingCriteria; + direction: "asc" | "desc"; + onSortChange: (criteria: SortingCriteria) => void; +}; + +const TableHeaders = ({ currentSort, direction, onSortChange }: TableHeadersProps) => { const carMetrics = useSettingsStore((state) => state.carMetrics); + const renderSortIcon = (column: string) => { + const criteria = columnSortMapping[column]; + if (!criteria || currentSort !== criteria) return null; + + return direction === "asc" ? ( + + ) : ( + + ); + }; + + const createClickHandler = (column: string) => { + const criteria = columnSortMapping[column]; + if (!criteria) return undefined; + + return () => onSortChange(criteria); + }; + + const headerClass = (column: string) => + clsx( + "cursor-pointer hover:text-zinc-300 transition-colors duration-150 flex items-center", + columnSortMapping[column] && currentSort === columnSortMapping[column] ? "text-sky-400" : "", + ); + return (
{ : "5.5rem 3.5rem 5.5rem 4rem 5rem 5.5rem auto", }} > -

Position

+
+ Position + {renderSortIcon("Position")} +

DRS

-

Tire

-

Info

-

Gap

-

LapTime

-

Sectors

- {carMetrics &&

Car Metrics

} +
+ Tire + {renderSortIcon("Tire")} +
+
+ Info + {renderSortIcon("Info")} +
+
+ Gap + {renderSortIcon("Gap")} +
+
+ LapTime + {renderSortIcon("LapTime")} +
+
+ Sectors + {renderSortIcon("Sectors")} +
); }; diff --git a/dash/src/components/ui/Select.tsx b/dash/src/components/ui/Select.tsx index b52d87e5..928a5439 100644 --- a/dash/src/components/ui/Select.tsx +++ b/dash/src/components/ui/Select.tsx @@ -1,8 +1,9 @@ "use client"; -import { Combobox, ComboboxButton, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react"; import { useState } from "react"; +import { Combobox, ComboboxButton, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react"; import clsx from "clsx"; +import { BiChevronDown } from "react-icons/bi"; type Option = { value: T; @@ -11,9 +12,7 @@ type Option = { type Props = { placeholder?: string; - options: Option[]; - selected: T | null; setSelected: (value: T | null) => void; }; @@ -21,44 +20,43 @@ type Props = { export default function Select({ placeholder, options, selected, setSelected }: Props) { const [query, setQuery] = useState(""); + const selectedOption = options.find((option) => option.value === selected); + const filteredOptions = query === "" ? options : options.filter((option) => option.label.toLowerCase().includes(query.toLowerCase())); return ( - setSelected(value)} onClose={() => setQuery("")}> +
- | null) => option?.label ?? ""} - onChange={(event) => setQuery(event.target.value)} - /> - - {/* */} - +
+ selectedOption?.label || ""} + placeholder={placeholder || "Select option"} + onChange={(event) => setQuery(event.target.value)} + /> + + +
+ + {filteredOptions.map((option, index) => ( + + clsx( + "relative cursor-default py-2 pr-9 pl-3 select-none", + active ? "bg-zinc-700 text-white" : "text-zinc-300", + selected && "bg-zinc-700", + ) + } + > + {option.label} + + ))} +
- - - {filteredOptions.map((option, idx) => ( - - {/* */} -
{option.label}
-
- ))} -
); } diff --git a/dash/src/hooks/useDataEngine.ts b/dash/src/hooks/useDataEngine.ts index 6727dc6f..d23b3209 100644 --- a/dash/src/hooks/useDataEngine.ts +++ b/dash/src/hooks/useDataEngine.ts @@ -83,25 +83,25 @@ export const useDataEngine = ({ updateState, updatePosition, updateCarData }: Pr } }; - const handleUpdate = ({ carDataZ, positionZ, ...update }: MessageUpdate) => { - Object.keys(buffers).forEach((key) => { - const data = update[key as keyof typeof update]; - const buffer = buffers[key as keyof typeof buffers]; - if (data) buffer.push(data); - }); - - if (carDataZ) { - const carData = inflate(carDataZ); - for (const entry of carData.Entries) { - carBuffer.pushTimed(entry.Cars, utcToLocalMs(entry.Utc)); + const handleUpdate = (updates: MessageUpdate) => { + for (const [topic, data] of updates) { + if (topic === "carDataZ" && data) { + const carData = inflate(data); + for (const entry of carData.Entries) { + carBuffer.pushTimed(entry.Cars, utcToLocalMs(entry.Utc)); + } + continue; } - } - if (positionZ) { - const position = inflate(positionZ); - for (const entry of position.Position) { - posBuffer.pushTimed(entry.Entries, utcToLocalMs(entry.Timestamp)); + if (topic === "positionZ" && data) { + const position = inflate(data); + for (const entry of position.Position) { + posBuffer.pushTimed(entry.Entries, utcToLocalMs(entry.Timestamp)); + } + continue; } + + buffers[topic as keyof typeof buffers]?.push(data); } }; diff --git a/dash/src/hooks/useDevMode.ts b/dash/src/hooks/useDevMode.ts deleted file mode 100644 index 5cc9a809..00000000 --- a/dash/src/hooks/useDevMode.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const useDevMode = () => { - let active = false; - - if (typeof window != undefined) { - active = !!localStorage.getItem("dev"); - } - - return { active }; -}; diff --git a/dash/src/hooks/useSocket.ts b/dash/src/hooks/useSocket.ts index 20b67f01..fed07ac0 100644 --- a/dash/src/hooks/useSocket.ts +++ b/dash/src/hooks/useSocket.ts @@ -22,7 +22,7 @@ export const useSocket = ({ handleInitial, handleUpdate }: Props) => { handleInitial(JSON.parse(message.data)); }); - sse.addEventListener("update", (message) => { + sse.addEventListener("updates", (message) => { handleUpdate(JSON.parse(message.data)); }); diff --git a/dash/src/lib/sorting.ts b/dash/src/lib/sorting.ts index 59e8be33..ae31d78c 100644 --- a/dash/src/lib/sorting.ts +++ b/dash/src/lib/sorting.ts @@ -1,4 +1,6 @@ import { utc } from "moment"; +import type { TimingDataDriver, TimingAppDataDriver } from "@/types/state.type"; +import type { SortingCriteria, SortDirection } from "@/stores/useSortingStore"; type PosObject = { position: string }; export const sortPos = (a: PosObject, b: PosObject) => { @@ -21,3 +23,66 @@ type UtcObject = { utc: string }; export const sortUtc = (a: UtcObject, b: UtcObject) => { return utc(b.utc).diff(utc(a.utc)); }; + +// Convert lap time string (e.g. "1:23.456") to milliseconds for comparison +const lapTimeToMs = (time: string): number => { + if (!time) return Infinity; + const [mins, secs] = time.split(":"); + if (!secs) return parseFloat(mins) * 1000; + return (parseInt(mins) * 60 + parseFloat(secs)) * 1000; +}; + +export const sortDrivers = ( + criteria: SortingCriteria, + direction: SortDirection, + a: TimingDataDriver, + b: TimingDataDriver, + appDataA?: TimingAppDataDriver, + appDataB?: TimingAppDataDriver +): number => { + let result = 0; + + switch (criteria) { + case "position": + result = parseInt(a.position) - parseInt(b.position); + break; + + case "bestLap": + result = lapTimeToMs(a.bestLapTime.value) - lapTimeToMs(b.bestLapTime.value); + break; + + case "lastLap": + result = lapTimeToMs(a.lastLapTime.value) - lapTimeToMs(b.lastLapTime.value); + break; + + case "pitStatus": + // Sort pit status: pit out first, then in pit, then on track + const getPitPriority = (d: TimingDataDriver) => (d.pitOut ? 0 : d.inPit ? 1 : 2); + result = getPitPriority(a) - getPitPriority(b); + break; + + case "positionChange": + // Calculate position changes if grid position data is available + const aChange = appDataA ? parseInt(appDataA.gridPos) - parseInt(a.position) : 0; + const bChange = appDataB ? parseInt(appDataB.gridPos) - parseInt(b.position) : 0; + result = bChange - aChange; // Sort by most positions gained + break; + + case "sector1": + case "sector2": + case "sector3": + const sectorIndex = parseInt(criteria.slice(-1)) - 1; + result = lapTimeToMs(a.sectors[sectorIndex]?.value || "") - lapTimeToMs(b.sectors[sectorIndex]?.value || ""); + break; + + case "tyreAge": + // Sort by tyre age (number of laps on current stint) + const aAge = appDataA?.stints?.length ? appDataA.stints[appDataA.stints.length - 1].totalLaps || 0 : 0; + const bAge = appDataB?.stints?.length ? appDataB.stints[appDataB.stints.length - 1].totalLaps || 0 : 0; + result = bAge - aAge; // Sort by oldest tyres first + break; + } + + // Apply direction + return direction === "asc" ? result : -result; +}; diff --git a/dash/src/stores/useSortingStore.ts b/dash/src/stores/useSortingStore.ts new file mode 100644 index 00000000..8d101a0e --- /dev/null +++ b/dash/src/stores/useSortingStore.ts @@ -0,0 +1,45 @@ +import { create } from "zustand"; + +export type SortingCriteria = + | "position" // Default race position + | "bestLap" // Best lap time + | "lastLap" // Last lap time + | "pitStatus" // In pit/pit out status + | "positionChange" // Positions gained/lost + | "sector1" // Best sector 1 time + | "sector2" // Best sector 2 time + | "sector3" // Best sector 3 time + | "tyreAge"; // Tyre age + +export type SortDirection = "asc" | "desc"; + +interface SortingState { + criteria: SortingCriteria; + direction: SortDirection; + showSortOptions: boolean; + setCriteria: (criteria: SortingCriteria) => void; + toggleDirection: () => void; + toggleSortOptions: () => void; + setSort: (criteria: SortingCriteria) => void; // Set criteria and toggle direction if same criteria +} + +export const useSortingStore = create((set) => ({ + criteria: "position", + direction: "asc", + showSortOptions: false, + setCriteria: (criteria) => set({ criteria }), + toggleDirection: () => set((state) => ({ + direction: state.direction === "asc" ? "desc" : "asc" + })), + toggleSortOptions: () => set((state) => ({ + showSortOptions: !state.showSortOptions + })), + setSort: (criteria) => set((state) => { + // If clicking the same criteria, toggle direction + if (state.criteria === criteria) { + return { direction: state.direction === "asc" ? "desc" : "asc" }; + } + // Otherwise, change criteria and set direction to ascending + return { criteria, direction: "asc" }; + }), +})); \ No newline at end of file diff --git a/dash/src/types/message.type.ts b/dash/src/types/message.type.ts index f979bc24..9259341c 100644 --- a/dash/src/types/message.type.ts +++ b/dash/src/types/message.type.ts @@ -1,4 +1,22 @@ -import type { State } from "./state.type"; +import type { + ChampionshipPrediction, + DriverList, + ExtrapolatedClock, + Heartbeat, + LapCount, + RaceControlMessages, + SessionData, + SessionInfo, + SessionStatus, + State, + TeamRadio, + TimingAppData, + TimingData, + TimingStats, + TopThree, + TrackStatus, + WeatherData, +} from "./state.type"; export type RecursivePartial = { [P in keyof T]?: T[P] extends (infer U)[] @@ -13,6 +31,27 @@ type FullState = State & { positionZ?: string; }; -export type MessageUpdate = RecursivePartial; +export type UpdateState = { + heartbeat: Heartbeat; + extrapolatedClock: ExtrapolatedClock; + topThree: TopThree; + timingStats: TimingStats; + timingAppData: TimingAppData; + weatherData: WeatherData; + trackStatus: TrackStatus; + sessionStatus: SessionStatus; + driverList: DriverList; + raceControlMessages: RaceControlMessages; + sessionInfo: SessionInfo; + sessionData: SessionData; + lapCount: LapCount; + timingData: TimingData; + teamRadio: TeamRadio; + championshipPrediction: ChampionshipPrediction; + carDataZ: string; + positionZ: string; +}; + +export type MessageUpdate = { [K in keyof UpdateState]: [K, RecursivePartial] }[keyof UpdateState][]; export type MessageInitial = FullState; diff --git a/dash/yarn.lock b/dash/yarn.lock index 2d818f69..4bdcf182 100644 --- a/dash/yarn.lock +++ b/dash/yarn.lock @@ -127,21 +127,21 @@ __metadata: linkType: hard "@floating-ui/core@npm:^1.6.0": - version: 1.6.9 - resolution: "@floating-ui/core@npm:1.6.9" + version: 1.6.8 + resolution: "@floating-ui/core@npm:1.6.8" dependencies: - "@floating-ui/utils": "npm:^0.2.9" - checksum: 10c0/77debdfc26bc36c6f5ae1f26ab3c15468215738b3f5682af4e1915602fa21ba33ad210273f31c9d2da1c531409929e1afb1138b1608c6b54a0f5853ee84c340d + "@floating-ui/utils": "npm:^0.2.8" + checksum: 10c0/d6985462aeccae7b55a2d3f40571551c8c42bf820ae0a477fc40ef462e33edc4f3f5b7f11b100de77c9b58ecb581670c5c3f46d0af82b5e30aa185c735257eb9 languageName: node linkType: hard "@floating-ui/dom@npm:^1.0.0": - version: 1.6.13 - resolution: "@floating-ui/dom@npm:1.6.13" + version: 1.6.12 + resolution: "@floating-ui/dom@npm:1.6.12" dependencies: "@floating-ui/core": "npm:^1.6.0" - "@floating-ui/utils": "npm:^0.2.9" - checksum: 10c0/272242d2eb6238ffcee0cb1f3c66e0eafae804d5d7b449db5ecf904bc37d31ad96cf575a9e650b93c1190f64f49a684b1559d10e05ed3ec210628b19116991a9 + "@floating-ui/utils": "npm:^0.2.8" + checksum: 10c0/c67b39862175b175c6ac299ea970f17a22c7482cfdf3b1bc79313407bf0880188b022b878953fa69d3ce166ff2bd9ae57c86043e5dd800c262b470d877591b7d languageName: node linkType: hard @@ -171,7 +171,7 @@ __metadata: languageName: node linkType: hard -"@floating-ui/utils@npm:^0.2.8, @floating-ui/utils@npm:^0.2.9": +"@floating-ui/utils@npm:^0.2.8": version: 0.2.9 resolution: "@floating-ui/utils@npm:0.2.9" checksum: 10c0/48bbed10f91cb7863a796cc0d0e917c78d11aeb89f98d03fc38d79e7eb792224a79f538ed8a2d5d5584511d4ca6354ef35f1712659fd569868e342df4398ad6f @@ -607,22 +607,21 @@ __metadata: linkType: hard "@react-aria/focus@npm:^3.17.1": - version: 3.20.2 - resolution: "@react-aria/focus@npm:3.20.2" + version: 3.18.4 + resolution: "@react-aria/focus@npm:3.18.4" dependencies: - "@react-aria/interactions": "npm:^3.25.0" - "@react-aria/utils": "npm:^3.28.2" - "@react-types/shared": "npm:^3.29.0" + "@react-aria/interactions": "npm:^3.22.4" + "@react-aria/utils": "npm:^3.25.3" + "@react-types/shared": "npm:^3.25.0" "@swc/helpers": "npm:^0.5.0" clsx: "npm:^2.0.0" peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/83c7ce227affed990833664b75c99601390ea9c879a44032541447268da22508712c512f5a943f702aef07bfe1e0ea51f554f49db132f17d80b2da9cb71ec687 + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + checksum: 10c0/141f8ef80060c5b58384af4af9446c0792618671e9f963942c3edc29bb15b7eb0ebb62cbe118135c7379c2732e86071aa7d7c890903a0ae411be07f2ec854e6a languageName: node linkType: hard -"@react-aria/interactions@npm:^3.21.3, @react-aria/interactions@npm:^3.25.0": +"@react-aria/interactions@npm:^3.21.3": version: 3.25.0 resolution: "@react-aria/interactions@npm:3.25.0" dependencies: @@ -638,6 +637,31 @@ __metadata: languageName: node linkType: hard +"@react-aria/interactions@npm:^3.22.4": + version: 3.22.4 + resolution: "@react-aria/interactions@npm:3.22.4" + dependencies: + "@react-aria/ssr": "npm:^3.9.6" + "@react-aria/utils": "npm:^3.25.3" + "@react-types/shared": "npm:^3.25.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + checksum: 10c0/8455a68540a4085b71ed034cad5c349a7e756e44cd30d69d340d7f7a66ce1886882021fbcc8049a5d8aeba54b47cd2ca49a7bc4e6910aab2d13b41703d55c7a5 + languageName: node + linkType: hard + +"@react-aria/ssr@npm:^3.9.6": + version: 3.9.6 + resolution: "@react-aria/ssr@npm:3.9.6" + dependencies: + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + checksum: 10c0/be52f2909035e093d3f72cccde15b66b4eef2dc30c71dac46a1ea43d3847dace1a709114640bfa3e9aa72ba716749635fb72116f4da16f7d80248ca348146456 + languageName: node + linkType: hard + "@react-aria/ssr@npm:^3.9.8": version: 3.9.8 resolution: "@react-aria/ssr@npm:3.9.8" @@ -649,6 +673,21 @@ __metadata: languageName: node linkType: hard +"@react-aria/utils@npm:^3.25.3": + version: 3.25.3 + resolution: "@react-aria/utils@npm:3.25.3" + dependencies: + "@react-aria/ssr": "npm:^3.9.6" + "@react-stately/utils": "npm:^3.10.4" + "@react-types/shared": "npm:^3.25.0" + "@swc/helpers": "npm:^0.5.0" + clsx: "npm:^2.0.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + checksum: 10c0/dc86ea48c24232f5c51d0b5317d947c4ccf01a8afb3bdc89cb880a7b0a695a04c8a7c615fb190664f4f3c7da8669ab2bd2f7cdfb2861339f5816cbd600249a84 + languageName: node + linkType: hard + "@react-aria/utils@npm:^3.28.2": version: 3.28.2 resolution: "@react-aria/utils@npm:3.28.2" @@ -675,6 +714,17 @@ __metadata: languageName: node linkType: hard +"@react-stately/utils@npm:^3.10.4": + version: 3.10.4 + resolution: "@react-stately/utils@npm:3.10.4" + dependencies: + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + checksum: 10c0/875c11424fadf4419caceeee13e5bfdee2b0c330fe0220c0ea9d68d570cc9a34525f2f124d977e519b397a738cd2f8e36b7b03a046e3e7da99460e99282977a4 + languageName: node + linkType: hard + "@react-stately/utils@npm:^3.10.6": version: 3.10.6 resolution: "@react-stately/utils@npm:3.10.6" @@ -686,6 +736,15 @@ __metadata: languageName: node linkType: hard +"@react-types/shared@npm:^3.25.0": + version: 3.25.0 + resolution: "@react-types/shared@npm:3.25.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + checksum: 10c0/d168f6b404c345928ef8ead94f0cecd3831d8f6df708dbe897ac62d566949a0931c3b0d95ef6dd02bc5af05b183781b531e6f041ffd1d320bc2cab7697fd27d0 + languageName: node + linkType: hard + "@react-types/shared@npm:^3.29.0": version: 3.29.0 resolution: "@react-types/shared@npm:3.29.0" @@ -1523,9 +1582,9 @@ __metadata: linkType: hard "caniuse-lite@npm:^1.0.30001579": - version: 1.0.30001713 - resolution: "caniuse-lite@npm:1.0.30001713" - checksum: 10c0/f5468abfe73ce30e29cc8bde2ea67df2aab69032bdd93345e0640efefb76b7901c84fe1d28d591a797e65fe52fc24cae97060bb5552f9f9740322aff95ce2f9d + version: 1.0.30001680 + resolution: "caniuse-lite@npm:1.0.30001680" + checksum: 10c0/11a4e7f6f5d5f965cfd4b7dc4aef34e12a26e99647f02b5ac9fd7f7670845473b95ada416a785473237e4b1b67281f7b043c8736c85b77097f6b697e8950b15f languageName: node linkType: hard @@ -2214,6 +2273,7 @@ __metadata: prettier-plugin-tailwindcss: "npm:0.6.11" react: "npm:19.1.0" react-dom: "npm:19.1.0" + react-icons: "npm:^5.5.0" sharp: "npm:0.34.1" tailwindcss: "npm:4.0.8" typescript: "npm:5.6.3" @@ -2243,15 +2303,15 @@ __metadata: linkType: hard "fast-glob@npm:^3.3.2": - version: 3.3.3 - resolution: "fast-glob@npm:3.3.3" + version: 3.3.2 + resolution: "fast-glob@npm:3.3.2" dependencies: "@nodelib/fs.stat": "npm:^2.0.2" "@nodelib/fs.walk": "npm:^1.2.3" glob-parent: "npm:^5.1.2" merge2: "npm:^1.3.0" - micromatch: "npm:^4.0.8" - checksum: 10c0/f6aaa141d0d3384cf73cbcdfc52f475ed293f6d5b65bfc5def368b09163a9f7e5ec2b3014d80f733c405f58e470ee0cc451c2937685045cddcdeaa24199c43fe + micromatch: "npm:^4.0.4" + checksum: 10c0/42baad7b9cd40b63e42039132bde27ca2cb3a4950d0a0f9abe4639ea1aa9d3e3b40f98b1fe31cbc0cc17b664c9ea7447d911a152fa34ec5b72977b125a6fc845 languageName: node linkType: hard @@ -2270,11 +2330,11 @@ __metadata: linkType: hard "fastq@npm:^1.6.0": - version: 1.19.1 - resolution: "fastq@npm:1.19.1" + version: 1.17.1 + resolution: "fastq@npm:1.17.1" dependencies: reusify: "npm:^1.0.4" - checksum: 10c0/ebc6e50ac7048daaeb8e64522a1ea7a26e92b3cee5cd1c7f2316cdca81ba543aa40a136b53891446ea5c3a67ec215fbaca87ad405f102dd97012f62916905630 + checksum: 10c0/1095f16cea45fb3beff558bb3afa74ca7a9250f5a670b65db7ed585f92b4b48381445cd328b3d87323da81e43232b5d5978a8201bde84e0cd514310f1ea6da34 languageName: node linkType: hard @@ -3233,7 +3293,7 @@ __metadata: languageName: node linkType: hard -"micromatch@npm:^4.0.4, micromatch@npm:^4.0.8": +"micromatch@npm:^4.0.4": version: 4.0.8 resolution: "micromatch@npm:4.0.8" dependencies: @@ -3767,6 +3827,15 @@ __metadata: languageName: node linkType: hard +"react-icons@npm:^5.5.0": + version: 5.5.0 + resolution: "react-icons@npm:5.5.0" + peerDependencies: + react: "*" + checksum: 10c0/a24309bfc993c19cbcbfc928157e53a137851822779977b9588f6dd41ffc4d11ebc98b447f4039b0d309a858f0a42980f6bfb4477fb19f9f2d1bc2e190fcf79c + languageName: node + linkType: hard + "react-is@npm:^16.13.1": version: 16.13.1 resolution: "react-is@npm:16.13.1" @@ -3887,9 +3956,9 @@ __metadata: linkType: hard "reusify@npm:^1.0.4": - version: 1.1.0 - resolution: "reusify@npm:1.1.0" - checksum: 10c0/4eff0d4a5f9383566c7d7ec437b671cc51b25963bd61bf127c3f3d3f68e44a026d99b8d2f1ad344afff8d278a8fe70a8ea092650a716d22287e8bef7126bb2fa + version: 1.0.4 + resolution: "reusify@npm:1.0.4" + checksum: 10c0/c19ef26e4e188f408922c46f7ff480d38e8dfc55d448310dfb518736b23ed2c4f547fb64a6ed5bdba92cd7e7ddc889d36ff78f794816d5e71498d645ef476107 languageName: node linkType: hard diff --git a/services/importer/src/main.rs b/services/importer/src/main.rs index be290a2f..6655de5c 100644 --- a/services/importer/src/main.rs +++ b/services/importer/src/main.rs @@ -43,7 +43,7 @@ async fn main() -> Result<(), anyhow::Error> { Message::Updates(updates) => { trace!(?updates, "recived updates, saving"); - let currnet_state = state.lock().unwrap().clone(); + let currnet_state = state.read().await.clone(); let _ = parse_update(&pool, currnet_state, updates).await; } diff --git a/services/live/src/compression.rs b/services/live/src/compression.rs index 336bc7e4..472d62ab 100644 --- a/services/live/src/compression.rs +++ b/services/live/src/compression.rs @@ -9,81 +9,80 @@ use futures_util::stream::Stream; use std::io::Write; use std::pin::{pin, Pin}; -use std::task::Context; -use std::task::Poll; +use std::task::{Context, Poll}; pub async fn compress_sse(request: Request, next: Next) -> Response { - let accept_encoding = request.headers().get(header::ACCEPT_ENCODING).cloned(); + let accept_encoding = request.headers().get(header::ACCEPT_ENCODING).cloned(); - let response = next.run(request).await; + let response = next.run(request).await; - let content_encoding = response.headers().get(header::CONTENT_ENCODING); - let content_type = response.headers().get(header::CONTENT_TYPE); + let content_encoding = response.headers().get(header::CONTENT_ENCODING); + let content_type = response.headers().get(header::CONTENT_TYPE); - // No accept-encoding from client or content-type from server. - let (Some(ct), Some(ae)) = (content_type, accept_encoding) else { - return response; - }; - // Already compressed. - if content_encoding.is_some() { - return response; - } - // Not text/event-stream. - if ct.as_bytes() != b"text/event-stream" { - return response; - } - // Client doesn't accept gzip compression. - if !ae.to_str().map(|v| v.contains("gzip")).unwrap_or(false) { - return response; - } + // No accept-encoding from client or content-type from server. + let (Some(ct), Some(ae)) = (content_type, accept_encoding) else { + return response; + }; + // Already compressed. + if content_encoding.is_some() { + return response; + } + // Not text/event-stream. + if ct.as_bytes() != b"text/event-stream" { + return response; + } + // Client doesn't accept gzip compression. + if !ae.to_str().map(|v| v.contains("gzip")).unwrap_or(false) { + return response; + } - let (mut parts, body) = response.into_parts(); + let (mut parts, body) = response.into_parts(); - let body = body.into_data_stream(); - let body = Body::from_stream(CompressedStream::new(body)); + let body = body.into_data_stream(); + let body = Body::from_stream(CompressedStream::new(body)); - parts.headers.insert( - header::CONTENT_ENCODING, - header::HeaderValue::from_static("gzip"), - ); - parts.headers.insert( - header::VARY, - header::HeaderValue::from_static("accept-encoding"), - ); + parts.headers.insert( + header::CONTENT_ENCODING, + header::HeaderValue::from_static("gzip"), + ); + parts.headers.insert( + header::VARY, + header::HeaderValue::from_static("accept-encoding"), + ); - Response::from_parts(parts, body) + Response::from_parts(parts, body) } struct CompressedStream { - inner: BodyDataStream, - compression: GzEncoder>, + inner: BodyDataStream, + compression: GzEncoder>, } impl CompressedStream { - pub fn new(body: BodyDataStream) -> Self { - Self { - inner: body, - compression: GzEncoder::new(Vec::new(), Compression::default()), - } - } + pub fn new(body: BodyDataStream) -> Self { + Self { + inner: body, + compression: GzEncoder::new(Vec::new(), Compression::default()), + } + } } impl Stream for CompressedStream { - type Item = Result; + type Item = Result; - #[inline] - fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - match pin!(&mut self.inner).as_mut().poll_next(cx) { - Poll::Ready(Some(Ok(x))) => { - self.compression.write_all(&x).unwrap(); - self.compression.flush().unwrap(); + #[inline] + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match pin!(&mut self.inner).as_mut().poll_next(cx) { + Poll::Ready(Some(Ok(x))) => { + self.compression.write_all(&x).unwrap(); + self.compression.flush().unwrap(); - let mut buf = Vec::new(); - std::mem::swap(&mut buf, self.compression.get_mut()); + let mut buf = Vec::new(); + std::mem::swap(&mut buf, self.compression.get_mut()); - Poll::Ready(Some(Ok(buf.into()))) - } - x => x, - } - } + Poll::Ready(Some(Ok(buf.into()))) + } + x => x, + } + } } diff --git a/services/live/src/main.rs b/services/live/src/main.rs index e5684685..8e294a0b 100644 --- a/services/live/src/main.rs +++ b/services/live/src/main.rs @@ -1,8 +1,4 @@ -use std::{ - env, - net::SocketAddr, - sync::{Arc, Mutex}, -}; +use std::{env, net::SocketAddr, sync::Arc}; use axum::{ http::{HeaderValue, Method}, @@ -12,6 +8,7 @@ use axum::{ use compression::compress_sse; use dotenvy::dotenv; use serde_json::Value; +use tokio::sync::RwLock; use tokio::{net::TcpListener, sync::broadcast}; use tower_http::cors::CorsLayer; use tracing::info; @@ -28,7 +25,7 @@ mod server { pub struct AppState { tx: broadcast::Sender, - state: Arc>, + state: Arc>, } #[tokio::main] @@ -70,7 +67,7 @@ async fn main() -> Result<(), anyhow::Error> { } pub fn cors_layer() -> Result { - let origin = env::var("ORIGIN")?; // origins string split by semicolumn + let origin = env::var("ORIGIN")?; let origins = origin .split(';') diff --git a/services/live/src/server/drivers.rs b/services/live/src/server/drivers.rs index faf70f9b..6dc85690 100644 --- a/services/live/src/server/drivers.rs +++ b/services/live/src/server/drivers.rs @@ -1,4 +1,4 @@ -use std::{mem, sync::Arc}; +use std::sync::Arc; use axum::extract::State; use serde_json::Value; @@ -16,11 +16,8 @@ fn map_to_vec(value: Value) -> Vec { pub async fn get_drivers( State(state): State>, ) -> Result>, axum::http::StatusCode> { - let state_lock = state.state.lock().unwrap(); - let live_state = state_lock.clone(); - mem::drop(state_lock); - - match live_state.pointer("/driverList") { + let state_guard = state.state.read().await; + match state_guard.pointer("/driverList") { Some(drivers) => Ok(axum::Json(map_to_vec(drivers.clone()))), None => { error!("failed to get drivers from live state"); diff --git a/services/live/src/server/live.rs b/services/live/src/server/live.rs index 8406eed8..39ef8a99 100644 --- a/services/live/src/server/live.rs +++ b/services/live/src/server/live.rs @@ -1,4 +1,4 @@ -use std::{convert::Infallible, mem, sync::Arc, time::Duration}; +use std::{convert::Infallible, sync::Arc}; use axum::{ extract::State, @@ -6,34 +6,22 @@ use axum::{ }; use client::message::Message; use futures::Stream; -use serde_json::{json, Map, Value}; use tokio_stream::{wrappers::BroadcastStream, StreamExt}; use tracing::{debug, info}; -use data::merge::merge; - use crate::AppState; -// TODO clean this up a bit maybe -fn sse_event(message: Message) -> sse::Event { - let (event, data): (&str, Value) = match message { - Message::Updates(updates) => { - let mut batched_update = json!({}); - - for (topic, update) in updates { - let mut map = Map::new(); - map.insert(topic, update); - merge(&mut batched_update, Value::Object(map)); - } - - // TODO maybe send the updates in array instead of object - - ("update", batched_update) - } - Message::Initial(value) => ("initial", value), - }; - - sse::Event::default().event(event).json_data(data).unwrap() +fn sse_event(message: Message) -> Option { + match message { + Message::Updates(updates) => sse::Event::default() + .event("updates") + .json_data(updates) + .ok(), + Message::Initial(initial) => sse::Event::default() + .event("initial") + .json_data(initial) + .ok(), + } } pub async fn sse_handler( @@ -44,13 +32,14 @@ pub async fn sse_handler( info!(connections, "new sse connection"); - let initial_state_lock = state.state.lock().unwrap(); - let initial_state = initial_state_lock.clone(); - mem::drop(initial_state_lock); + // Use RwLock for concurrent reads and avoid unnecessary clone + let initial_state = { + let state_guard = state.state.read().await; + state_guard.clone() + }; let initial_stream = futures::stream::once(async { debug!("streaming current initial"); - Ok(sse::Event::default() .event("initial") .json_data(initial_state) @@ -59,14 +48,8 @@ pub async fn sse_handler( let updates_stream = BroadcastStream::new(rx) .filter_map(|msg| msg.ok()) - .map(|message| sse_event(message)) + .filter_map(sse_event) .map(Ok); - let stream = initial_stream.chain(updates_stream); - - let keep_alive = sse::KeepAlive::new() - .interval(Duration::from_secs(10)) - .text("keep-alive-text"); - - Sse::new(stream).keep_alive(keep_alive) + Sse::new(initial_stream.chain(updates_stream)) }