Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 23 additions & 11 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,25 @@ The frontend is organized as 6 workspaces out of 9 in the whole monorepo, divide

## State Management

The application uses **Zustand** with a slice-based architecture:
The application uses **Zustand** with a slice-based architecture, organized by feature domain:

- `workspacesSlice` - Manages workspaces, filters, charts, and tabs
- `catalogSlice` - Stores telemetry and command catalogs
- `telemetrySlice` - Real-time telemetry data
### Global Slices

- `appSlice` - Application mode, settings, and configuration
- `connectionsSlice` - WebSockets connection statuses
- `telemetrySlice` - Real-time telemetry data buffer
- `messagesSlice` - System messages and logs
- `appSlice` - Application mode and settings
- `rightSidebarSlice` - UI state for sidebar panels
- `catalogSlice` - Static definitions for telemetry packets and commands

### Feature Slices

- **Workspace Feature** (`features/workspace`)
- `workspacesSlice` - Manages workspace layout
- `rightSidebarSlice` - UI state for the collapsible sidebar and its tabs
- **Charts Feature** (`features/charts`)
- `chartsSlice` - Manages chart instances, series configuration, and visualization settings
- **Filtering Feature** (`features/filtering`)
- `filteringSlice` - Manages active filters, search queries, and category selection

### Workspace System

Expand Down Expand Up @@ -98,7 +109,7 @@ import { Plus, Settings } from "@workspace/ui/icons";
- **CSS Variables** for theming (defined in `globals.css`)
- **Tailwind CSS** for utility classes
- **Dark mode** support via CSS class toggling
- Multiple color schemes (default, pink, etc.)
- Multiple color schemes (default and pink)

### Adding Icons

Expand Down Expand Up @@ -133,13 +144,14 @@ frontend/
├── testing-view/
│ ├── src/
│ │ ├── assets/ # Assets (images, gifs, etc.)
│ │ ├── components/ # UI components
│ │ ├── components/ # Global UI components
│ │ ├── features/ # Components, hooks, types and store slices related to features
│ │ ├── layout/ # App layout
│ │ ├── pages/ # Route pages
│ │ ├── store/ # Zustand store slices
│ │ ├── hooks/ # Custom hooks
│ │ ├── store/ # Global Zustand store slices
│ │ ├── hooks/ # Global custom hooks
│ │ ├── constants/ # Config and constants
│ │ ├── types/ # TypeScript types
│ │ ├── types/ # Global TypeScript types
│ │ ├── mocks/ # Mocks
│ │ └── lib/ # Utilities
│ └── public/ # Static assets
Expand Down
32 changes: 19 additions & 13 deletions frontend/frontend-kit/core/src/logger.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,30 @@
type LoggerModule = "testing-view" | "competition-view" | "core" | "ui";

const colors = {
"testing-view": "\x1b[36m", // Cyan
"competition-view": "\x1b[35m", // Magenta
core: "\x1b[33m", // Yellow
ui: "\x1b[32m", // Green
reset: "\x1b[0m",
};
import { loggerColors } from "./loggerColors";
import type { LoggerModule } from "./types";

/**
* Creates a logger for a given module
* @param module - the module to log for (it's just a colored string that will be shown between `[` and `]` before the message itself)
* @returns a logger object with `log`, `warn`, and `error` methods
*/
function createLogger(module: LoggerModule) {
const color = colors[module];
const color = loggerColors[module];
const prefix = `[${module.toUpperCase()}]`;

return {
log: console.log.bind(console, `${color}${prefix}${colors.reset}`),
warn: console.warn.bind(console, `${color}${prefix}${colors.reset}`),
error: console.error.bind(console, `${color}${prefix}${colors.reset}`),
// It's important to use `bind` here to correctly display log file path and line number in the console
// Otherwise, console prints will just point to this file
log: console.log.bind(console, `${color}${prefix}${loggerColors.reset}`),
warn: console.warn.bind(console, `${color}${prefix}${loggerColors.reset}`),
error: console.error.bind(
console,
`${color}${prefix}${loggerColors.reset}`,
),
};
}

/**
* Logger object with methods for each module
*/
export const logger = {
testingView: createLogger("testing-view"),
competitionView: createLogger("competition-view"),
Expand Down
7 changes: 7 additions & 0 deletions frontend/frontend-kit/core/src/loggerColors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const loggerColors = {
"testing-view": "\x1b[36m", // Cyan
"competition-view": "\x1b[35m", // Magenta
core: "\x1b[33m", // Yellow
ui: "\x1b[32m", // Green
reset: "\x1b[0m",
};
69 changes: 61 additions & 8 deletions frontend/frontend-kit/core/src/minMaxDownsample.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,74 @@
export const minMaxDownsample = (buffer: any[]) => {
import { type TelemetryPacket, type VariableValue } from "./types";

/**
* Helper to extract a numeric value for comparison.
* Handles both primitive numbers, booleans and { last, average } objects.
*/
const getNumericValue = (
val: VariableValue | undefined,
): number | undefined => {
if (typeof val === "number") {
return val;
}

if (typeof val === "boolean") {
return val ? 1 : 0;
}

if (
typeof val === "object" &&
val !== null &&
"last" in val &&
"average" in val
) {
return val.last;
}

return undefined;
};

/**
* Downsamples a buffer of packets using the min-max algorithm.\
* It considers only numeric variables, booleans and { last, average } object variables.
*
* The idea is to reduce the number of packets in the buffer by keeping only the min and max packets
* to prevent the app from freezing when there are too many packets. (Usually happens on start)
*
* @param buffer - array of packets to downsample, should contain at least 2 elements
* @returns downsampled buffer with only min and max packets from the original buffer (in chronological order)
*/
export const minMaxDownsample = (buffer: TelemetryPacket[]) => {
if (buffer.length < 2) return buffer;

let minIdx = 0;
let maxIdx = 0;

buffer.forEach((packet, i) => {
const measurements = packet.measurementUpdates || {};

// At the beginning the initial champion is the first variable in the packet
const firstKey = Object.keys(measurements)[0];
const val = measurements[firstKey as keyof typeof measurements];
if (!firstKey) return;

const rawVal = measurements[firstKey];
const val = getNumericValue(rawVal);

const minVal = getNumericValue(
buffer[minIdx]?.measurementUpdates[firstKey],
);
const maxVal = getNumericValue(
buffer[maxIdx]?.measurementUpdates[firstKey],
);

if (typeof val === "number") {
if (val < (buffer[minIdx].measurementUpdates[firstKey] ?? Infinity))
minIdx = i;
if (val > (buffer[maxIdx].measurementUpdates[firstKey] ?? -Infinity))
maxIdx = i;
// Compare local min and max with the global champions
// If one of them is undefined, use Infinity or -Infinity respectively
if (val !== undefined) {
if (val < (minVal ?? Infinity)) minIdx = i;
if (val > (maxVal ?? -Infinity)) maxIdx = i;
}
});

// 4. Return them in chronological order to maintain X-axis integrity
// Return them in chronological order to maintain X-axis integrity
const result =
minIdx < maxIdx
? [buffer[minIdx], buffer[maxIdx]]
Expand Down
37 changes: 37 additions & 0 deletions frontend/frontend-kit/core/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,41 @@
/**
* Options for the `onTopic` method of the `SocketService` class.
*/
export interface TopicOptions {
/** The downsampling method to use. */
downsample?: "min-max" | "none";

/** The throttle time in milliseconds. */
throttle?: number;
}

/**
* The value of a variable in a telemetry packet.
*/
export type VariableValue =
| { last: number; average: number }
| boolean
| string
| number;

/**
* The variables of a telemetry packet.
*/
export type Variables = Record<string, VariableValue>;

/**
* A telemetry packet that arrives in high frequency.\
* Don't confuse it with the `TelemetryCatalogItem` type.
*/
export interface TelemetryPacket {
count: number;
cycleTime: number;
hexValue: string;
id: number;
measurementUpdates: Variables;
}

/**
* The modules that can be logged to. Used for the `logger` object.
*/
export type LoggerModule = "testing-view" | "competition-view" | "core" | "ui";
43 changes: 42 additions & 1 deletion frontend/frontend-kit/core/src/websocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,39 @@ import { logger } from "./logger";
import { minMaxDownsample } from "./minMaxDownsample";
import { type TopicOptions } from "./types";

/**
* Service for connecting to the WebSocket server and subscribing to topics
*/
class SocketService {
/**
* Singleton instance that holds the WebSocket subject
*/
private socketSource$ = new ReplaySubject<WebSocketSubject<any>>(1);

/**
* Subject that holds the status of the WebSocket connection.
*/
public status$ = new BehaviorSubject<
"connected" | "disconnected" | "connecting"
>("disconnected");

/**
* Observable that emits the messages from the WebSocket server.
*/
public messages$: Observable<any> = this.socketSource$.pipe(
switchMap((socket) => socket),
shareReplay(1),
);

/**
* Disposable WebSocket connection object. Lives only as long as the connection is open.
*/
private ws: WebSocketSubject<any> | null = null;

/**
* Connects to the WebSocket server by creating a new connection object and pushing it to `socketSource$`.
* @param port - the port to connect to. Defaults to 4000.
*/
connect(port: number = 4000) {
if (this.ws) return;

Expand Down Expand Up @@ -62,20 +82,34 @@ class SocketService {
this.socketSource$.next(this.ws);
}

/**
* Cleans up the WebSocket connection by setting the connection object to null and updating the status to "disconnected".
*/
private cleanup() {
this.ws = null;
this.status$.next("disconnected");
}

/**
* Creates an observable that emits the messages from the WebSocket server for a given topic.
* @param topic - the topic to subscribe to.
* @param options - options for the observable.

* Downsampling and throttling are supported.
* In case of downsampling, throttling option is used as the buffering time and defaults to 100ms.
* @returns an observable that emits the messages from the WebSocket server for a given topic.
*/
onTopic(topic: string, options: TopicOptions = {}) {
let pipe$ = this.messages$.pipe(
filter((msg) => msg.topic === topic),
map((msg) => msg.payload),
);

// Apply downsampling if requested
if (options.downsample == "min-max") {
pipe$ = pipe$.pipe(
bufferTime(options.throttle || 100),
// Apply buffering
bufferTime(options.throttle ?? 100),
filter((buffer) => buffer.length > 0),
concatMap((buffer) => {
if (buffer.length <= 2) return from(buffer);
Expand All @@ -89,13 +123,20 @@ class SocketService {
);
}

// Apply throttling if requested
if (options.throttle) {
pipe$ = pipe$.pipe(throttleTime(options.throttle, asyncScheduler));
}

return pipe$;
}

/**
* Posts a message to the WebSocket server. If the connection is not established, an error is logged and the message is not sent.
* @param topic - the topic to post to.
* @param payload - the payload to post.
* // TODO: reference payloads definition file
*/
post(topic: string, payload: any) {
if (!this.ws) {
logger.core.error("Cannot post: Socket not connected.");
Expand Down
4 changes: 2 additions & 2 deletions frontend/testing-view/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { useTopic, useWebSocket } from "@workspace/ui/hooks";
import { Route, Routes } from "react-router";
import { AppModeRouter } from "./components/AppModeRouter";
import { ModeSwitcher } from "./components/DevTools/ModeSwitcher";
import { ModeSwitcher } from "./components/devTools/ModeSwitcher";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { useChartsConfiguration } from "./features/charts/hooks/useChartsConfiguration";
import useAppConfigs from "./hooks/useAppConfigs";
import { useAppMode } from "./hooks/useAppMode";
import { useChartsConfiguration } from "./hooks/useChartsConfiguration";
import { useErrorHandler } from "./hooks/useErrorHandler";
import { useTransformedBoards } from "./hooks/useTransformedBoards";
import { AppLayout } from "./layout/AppLayout";
Expand Down
6 changes: 6 additions & 0 deletions frontend/testing-view/src/components/AppModeRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ interface AppModeRouterProps {
children: React.ReactNode;
}

/**
* This component works as a router
* and renders the appropriate page based on the app mode.
*
* Note: If mode is not loading or error, it renders the children normally
*/
export const AppModeRouter = ({ children }: AppModeRouterProps) => {
const appMode = useStore((s) => s.appMode);

Expand Down
8 changes: 8 additions & 0 deletions frontend/testing-view/src/components/Error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,18 @@ import errorGif from "../assets/error.gif";
import { useStore } from "../store/store";

interface ErrorProps {
/** Optional error to display. Can be null or undefined. In this case component will show default error message */
error?: Error | null;
/** Optional component stack trace to display. Can be null or undefined. In this case component will not show anything */
componentStack?: string | null;
}

/**
* Renders error page with the given error and component stack
*
* Displays an error message, optional component stack trace,\
* and provides actions to reload the application or inspect the stack.
*/
export const Error = ({ error: propError, componentStack }: ErrorProps) => {
const storeError = useStore((s) => s.error);
const error = propError || storeError;
Expand Down
6 changes: 6 additions & 0 deletions frontend/testing-view/src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ interface State {
componentStack?: string | null;
}

/**
* This component works as a wrapper and
* catches and handles any unhandled errors and unhandled promise rejections.
*
* The idea is to prevent the app from crashing when an unhandled error occurs.
*/
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
Expand Down
Loading
Loading