From 6cfe8de5ec09d36f046027f9d8656ffa1e063480 Mon Sep 17 00:00:00 2001
From: Maxim <74974283+maximka76667@users.noreply.github.com>
Date: Mon, 16 Mar 2026 14:19:47 +0100
Subject: [PATCH 01/49] feat: allow enum charts
---
backend/cmd/config.toml | 2 +-
backend/cmd/dev-config.toml | 2 +-
.../charts/components/ChartSurface.tsx | 27 ++++++---
.../charts/components/SortableChart.tsx | 8 ++-
.../charts/components/TelemetryChart.tsx | 11 ++++
.../charts/components/tooltipPlugin.ts | 8 ++-
.../src/features/charts/types/charts.ts | 1 +
.../tabs/telemetry/VariableItem.tsx | 29 ++++-----
.../src/features/workspace/hooks/useDnd.ts | 9 ++-
.../src/features/workspace/types/dndData.ts | 1 +
frontend/testing-view/src/lib/utils.test.ts | 59 +++++++++++++++++++
frontend/testing-view/src/lib/utils.ts | 25 ++++++++
12 files changed, 152 insertions(+), 30 deletions(-)
diff --git a/backend/cmd/config.toml b/backend/cmd/config.toml
index 43a2bf434..2b837170b 100644
--- a/backend/cmd/config.toml
+++ b/backend/cmd/config.toml
@@ -18,7 +18,7 @@ boards = ["BCU", "BMSL", "HVSCU", "HVSCU-Cabinet", "LCU", "PCU", "VCU", "BLCU"]
# ADJ (Architecture Description JSON) Configuration
[adj]
branch = "main" # Leave blank when using ADJ as a submodule (like this: "")
-validate = true # Execute ADJ validator before starting backend
+validate = false # Execute ADJ validator before starting backend
# Transport Configuration
[transport]
diff --git a/backend/cmd/dev-config.toml b/backend/cmd/dev-config.toml
index 1cbac364a..878dcf4f9 100644
--- a/backend/cmd/dev-config.toml
+++ b/backend/cmd/dev-config.toml
@@ -19,7 +19,7 @@ boards = ["HVSCU", "HVSCU-Cabinet", "PCU", "LCU", "BCU", "BMSL"]
# ADJ (Architecture Description JSON) Configuration
[adj]
branch = "software" # Leave blank when using ADJ as a submodule (like this: "")
-validate = true # Execute ADJ validator before starting backend
+validate = false # Execute ADJ validator before starting backend
# Transport Configuration
[transport]
diff --git a/frontend/testing-view/src/features/charts/components/ChartSurface.tsx b/frontend/testing-view/src/features/charts/components/ChartSurface.tsx
index ca788495a..85fad7fb2 100644
--- a/frontend/testing-view/src/features/charts/components/ChartSurface.tsx
+++ b/frontend/testing-view/src/features/charts/components/ChartSurface.tsx
@@ -96,6 +96,8 @@ export const ChartSurface = memo(
.getPropertyValue(varName)
.trim();
+ const enumOptions = series[0]?.enumOptions;
+
const opts: uPlot.Options = {
width: containerRef.current.clientWidth - 32,
height: config.DEFAULT_CHART_HEIGHT,
@@ -106,14 +108,16 @@ export const ChartSurface = memo(
padding: [20, 10, 5, 15],
scales: {
x: { time: false },
- y: {
- range: (_, min, max) => {
- if (min === max) return [min - 1, max + 1];
- const span = max - min;
- const buffer = span * 0.15;
- return [min - buffer, max + buffer];
- },
- },
+ y: enumOptions?.length
+ ? { range: () => [-0.5, enumOptions.length - 0.5] }
+ : {
+ range: (_, min, max) => {
+ if (min === max) return [min - 1, max + 1];
+ const span = max - min;
+ const buffer = span * 0.15;
+ return [min - buffer, max + buffer];
+ },
+ },
},
series: [
{},
@@ -141,7 +145,11 @@ export const ChartSurface = memo(
stroke: getStyle("--muted-foreground"),
grid: { stroke: getStyle("--border") },
font: "10px Archivo",
- size: 40,
+ size: enumOptions?.length ? 80 : 40,
+ ...(enumOptions?.length && {
+ values: (_u: uPlot, vals: number[]) =>
+ vals.map((v) => enumOptions[Math.round(v)] ?? ""),
+ }),
},
],
cursor: { drag: { setScale: true, x: true, y: true } },
@@ -184,6 +192,7 @@ export const ChartSurface = memo(
const m = pkt?.measurementUpdates?.[p.variable];
if (typeof m === "boolean") return m ? 1 : 0;
if (typeof m === "object" && m !== null && "last" in m) return m.last;
+ if (typeof m === "string") return p.enumOptions?.indexOf(m) ?? 0;
return m ?? 0;
}),
};
diff --git a/frontend/testing-view/src/features/charts/components/SortableChart.tsx b/frontend/testing-view/src/features/charts/components/SortableChart.tsx
index 76efbfc37..f9353058b 100644
--- a/frontend/testing-view/src/features/charts/components/SortableChart.tsx
+++ b/frontend/testing-view/src/features/charts/components/SortableChart.tsx
@@ -1,5 +1,6 @@
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
+import { canAddSeriesToChart } from "../../../lib/utils";
import type { WorkspaceChartSeries } from "../types/charts";
import { TelemetryChart } from "./TelemetryChart";
@@ -24,6 +25,10 @@ export function SortableChart({ id, series }: SortableChartProps) {
});
const isVariableOver = isOver && active?.data.current?.type === "variable";
+ const draggedIsEnum =
+ (active?.data.current?.variableEnumOptions?.length ?? 0) > 0;
+ const isIncompatibleDrop =
+ isVariableOver && !canAddSeriesToChart(series, draggedIsEnum);
const style = {
transform: CSS.Transform.toString(transform),
@@ -37,7 +42,8 @@ export function SortableChart({ id, series }: SortableChartProps) {
id={id}
series={series}
isDragging={false}
- isOver={isVariableOver}
+ isOver={isVariableOver && !isIncompatibleDrop}
+ isIncompatibleDrop={isIncompatibleDrop}
dragAttributes={attributes}
dragListeners={listeners}
/>
diff --git a/frontend/testing-view/src/features/charts/components/TelemetryChart.tsx b/frontend/testing-view/src/features/charts/components/TelemetryChart.tsx
index 81355f499..aa7457beb 100644
--- a/frontend/testing-view/src/features/charts/components/TelemetryChart.tsx
+++ b/frontend/testing-view/src/features/charts/components/TelemetryChart.tsx
@@ -14,6 +14,7 @@ interface TelemetryChartProps {
series: WorkspaceChartSeries[];
isDragging: boolean;
isOver?: boolean;
+ isIncompatibleDrop?: boolean;
dragAttributes?: DraggableAttributes;
dragListeners?: SyntheticListenerMap;
}
@@ -34,6 +35,7 @@ export const TelemetryChart = ({
series,
isDragging,
isOver,
+ isIncompatibleDrop,
dragAttributes,
dragListeners,
}: TelemetryChartProps) => {
@@ -77,6 +79,7 @@ export const TelemetryChart = ({
className={cn(
"border-border bg-card hover:border-accent group relative h-full w-full rounded-xl border p-4 shadow-sm transition-colors duration-200",
isOver ? "border-primary/20 bg-primary/5" : "",
+ isIncompatibleDrop ? "border-destructive/40 bg-destructive/5" : "",
)}
>
@@ -101,6 +104,14 @@ export const TelemetryChart = ({
+ {isIncompatibleDrop && (
+
+
+ Cannot mix enum and numeric series
+
+
+ )}
+
{
if (u.series[i + 1].show) {
row.classList.remove("hidden");
row.classList.add("flex");
- vals[i].textContent = u.data[i + 1][idx]?.toFixed(2) ?? "0.00";
+ const rawVal = u.data[i + 1][idx];
+ const enumOptions = series[i].enumOptions;
+ if (enumOptions?.length && rawVal != null) {
+ vals[i].textContent = enumOptions[Math.round(rawVal)] ?? String(rawVal);
+ } else {
+ vals[i].textContent = rawVal?.toFixed(2) ?? "0.00";
+ }
anyVisible = true;
} else {
row.classList.add("hidden");
diff --git a/frontend/testing-view/src/features/charts/types/charts.ts b/frontend/testing-view/src/features/charts/types/charts.ts
index b1277bda0..f446408b8 100644
--- a/frontend/testing-view/src/features/charts/types/charts.ts
+++ b/frontend/testing-view/src/features/charts/types/charts.ts
@@ -4,6 +4,7 @@
export interface WorkspaceChartSeries {
packetId: number;
variable: string;
+ enumOptions?: string[];
}
/**
diff --git a/frontend/testing-view/src/features/workspace/components/rightSidebar/tabs/telemetry/VariableItem.tsx b/frontend/testing-view/src/features/workspace/components/rightSidebar/tabs/telemetry/VariableItem.tsx
index 8c0351b29..aeeaf61d9 100644
--- a/frontend/testing-view/src/features/workspace/components/rightSidebar/tabs/telemetry/VariableItem.tsx
+++ b/frontend/testing-view/src/features/workspace/components/rightSidebar/tabs/telemetry/VariableItem.tsx
@@ -2,7 +2,7 @@ import { useDraggable } from "@dnd-kit/core";
import { Badge } from "@workspace/ui";
import { cn } from "@workspace/ui/lib";
import { useShallow } from "zustand/shallow";
-import { getTypeBadgeClass } from "../../../../../../lib/utils";
+import { canAddSeriesToChart, getTypeBadgeClass } from "../../../../../../lib/utils";
import { useStore } from "../../../../../../store/store";
import type { Variable } from "../../../../../../types/data/telemetryCatalogItem";
import ChartPicker from "./ChartPicker";
@@ -34,13 +34,13 @@ export const VariableItem = ({ packetId, variable }: VariableItemProps) => {
const { attributes, listeners, setNodeRef, transform, isDragging } =
useDraggable({
id: `draggable-${packetId}-${variable.id}`,
- disabled: variable.type === "enum",
data: {
type: "variable",
packetId,
variableId: variable.id,
variableName: variable.name,
variableType: variable.type,
+ variableEnumOptions: "options" in variable ? variable.options : undefined,
},
});
@@ -52,9 +52,12 @@ export const VariableItem = ({ packetId, variable }: VariableItemProps) => {
const handleAddToChart = (chartId: string) => {
if (!activeWorkspaceId) return;
+ const chart = charts.find((c) => c.id === chartId);
+ if (!canAddSeriesToChart(chart?.series ?? [], "options" in variable)) return;
addSeries(activeWorkspaceId, chartId, {
packetId,
variable: variable.id,
+ enumOptions: "options" in variable ? variable.options : undefined,
});
};
@@ -65,11 +68,7 @@ export const VariableItem = ({ packetId, variable }: VariableItemProps) => {
handleAddToChart(newChartId);
};
- const isEnum = variable.type === "enum";
-
- const cursorStyle = isEnum
- ? "cursor-default"
- : "cursor-grab active:cursor-grabbing";
+ const cursorStyle = "cursor-grab active:cursor-grabbing";
const opacityStyle = isDragging
? "scale-[0.98] border-dashed opacity-20 grayscale"
@@ -112,15 +111,13 @@ export const VariableItem = ({ packetId, variable }: VariableItemProps) => {
{/* Chart Picker */}
- {!isEnum && (
-
-
-
- )}
+
+
+
{/* Live Value */}
diff --git a/frontend/testing-view/src/features/workspace/hooks/useDnd.ts b/frontend/testing-view/src/features/workspace/hooks/useDnd.ts
index 821d99e59..1909a6ef8 100644
--- a/frontend/testing-view/src/features/workspace/hooks/useDnd.ts
+++ b/frontend/testing-view/src/features/workspace/hooks/useDnd.ts
@@ -6,6 +6,7 @@ import {
useSensors,
} from "@dnd-kit/core";
import { useState } from "react";
+import { canAddSeriesToChart } from "../../../lib/utils";
import { useStore } from "../../../store/store";
import type { DndActiveData } from "../types/dndData";
@@ -34,12 +35,17 @@ export function useDnd() {
// Logic for adding a variable to a chart
if (active.data.current?.type === "variable") {
const chartId = over.data.current?.chartId;
- const { packetId, variableId } = active.data.current;
+ const { packetId, variableId, variableEnumOptions } =
+ active.data.current;
+ const incomingIsEnum = (variableEnumOptions?.length ?? 0) > 0;
// Add a variable to an existing chart
if (chartId) {
+ const chart = charts.find((c) => c.id === chartId);
+ if (!canAddSeriesToChart(chart?.series ?? [], incomingIsEnum)) return;
addSeries(activeWorkspaceId, chartId, {
packetId,
variable: variableId,
+ enumOptions: variableEnumOptions,
});
// Add a new chart to the main panel
} else if (over.id === "main-panel-droppable") {
@@ -47,6 +53,7 @@ export function useDnd() {
addSeries(activeWorkspaceId, newChartId, {
packetId,
variable: variableId,
+ enumOptions: variableEnumOptions,
});
}
}
diff --git a/frontend/testing-view/src/features/workspace/types/dndData.ts b/frontend/testing-view/src/features/workspace/types/dndData.ts
index 3a13adf3d..7f9c8ca7e 100644
--- a/frontend/testing-view/src/features/workspace/types/dndData.ts
+++ b/frontend/testing-view/src/features/workspace/types/dndData.ts
@@ -6,6 +6,7 @@ type DndVariableData = {
variableId: string;
variableType: string;
variableName: string;
+ variableEnumOptions?: string[];
};
type DndChartData = {
diff --git a/frontend/testing-view/src/lib/utils.test.ts b/frontend/testing-view/src/lib/utils.test.ts
index fa667af7a..ed2000942 100644
--- a/frontend/testing-view/src/lib/utils.test.ts
+++ b/frontend/testing-view/src/lib/utils.test.ts
@@ -3,10 +3,12 @@ import { variablesBadgeClasses } from "../constants/variablesBadgeClasses";
import type { FilterScope } from "../features/filtering/types/filters";
import type { MessageTimestamp } from "../types/data/message";
import {
+ canAddSeriesToChart,
createEmptyFilter,
createFullFilter,
formatName,
formatTimestamp,
+ formatVariableValue,
getCatalogKey,
getTypeBadgeClass,
} from "./utils";
@@ -103,6 +105,63 @@ describe("getTypeBadgeClass", () => {
});
});
+describe("canAddSeriesToChart", () => {
+ it("should allow adding a numeric series to an empty chart", () => {
+ expect(canAddSeriesToChart([], false)).toBe(true);
+ });
+
+ it("should allow adding an enum series to an empty chart", () => {
+ expect(canAddSeriesToChart([], true)).toBe(true);
+ });
+
+ it("should prevent adding an enum series to a chart with existing series", () => {
+ expect(canAddSeriesToChart([{}], true)).toBe(false);
+ expect(canAddSeriesToChart([{ enumOptions: ["A", "B"] }], true)).toBe(false);
+ });
+
+ it("should prevent adding a numeric series to a chart with an enum series", () => {
+ expect(canAddSeriesToChart([{ enumOptions: ["A", "B"] }], false)).toBe(false);
+ });
+
+ it("should allow adding a numeric series to a chart with existing numeric series", () => {
+ expect(canAddSeriesToChart([{}], false)).toBe(true);
+ expect(canAddSeriesToChart([{}, {}], false)).toBe(true);
+ });
+});
+
+describe("formatVariableValue", () => {
+ it("should return '—' for null or undefined", () => {
+ expect(formatVariableValue(null)).toBe("—");
+ expect(formatVariableValue(undefined)).toBe("—");
+ });
+
+ it("should return enum label by string value", () => {
+ expect(formatVariableValue("Running", ["Idle", "Running", "Fault"])).toBe("Running");
+ });
+
+ it("should return enum label by numeric index", () => {
+ expect(formatVariableValue(1, ["Idle", "Running", "Fault"])).toBe("Running");
+ });
+
+ it("should return raw string if index is out of bounds", () => {
+ expect(formatVariableValue(5, ["Idle", "Running"])).toBe("5");
+ });
+
+ it("should format booleans as 0/1", () => {
+ expect(formatVariableValue(true)).toBe("1");
+ expect(formatVariableValue(false)).toBe("0");
+ });
+
+ it("should format numbers with 2 decimal places", () => {
+ expect(formatVariableValue(3.14159)).toBe("3.14");
+ expect(formatVariableValue(42)).toBe("42.00");
+ });
+
+ it("should format object with last/average using last", () => {
+ expect(formatVariableValue({ last: 1.5, average: 1.2 })).toBe("1.50");
+ });
+});
+
describe("emptyFilter", () => {
it("should return the correct empty filter", () => {
const boards = [
diff --git a/frontend/testing-view/src/lib/utils.ts b/frontend/testing-view/src/lib/utils.ts
index 0df30e9c3..dc73e8ba9 100644
--- a/frontend/testing-view/src/lib/utils.ts
+++ b/frontend/testing-view/src/lib/utils.ts
@@ -1,3 +1,4 @@
+import type { VariableValue } from "@workspace/core";
import { ACRONYMS } from "../constants/acronyms";
import { variablesBadgeClasses } from "../constants/variablesBadgeClasses";
import type {
@@ -120,6 +121,30 @@ export const formatTimestamp = (ts: MessageTimestamp) => {
return `${ts.hour.toString().padStart(2, "0")}:${ts.minute.toString().padStart(2, "0")}:${ts.second.toString().padStart(2, "0")}`;
};
+export const canAddSeriesToChart = (
+ chartSeries: { enumOptions?: string[] }[],
+ incomingIsEnum: boolean,
+): boolean => {
+ const chartHasEnum = chartSeries.some((s) => (s.enumOptions?.length ?? 0) > 0);
+ if (incomingIsEnum && chartSeries.length > 0) return false;
+ if (!incomingIsEnum && chartHasEnum) return false;
+ return true;
+};
+
+export const formatVariableValue = (
+ m: VariableValue | null | undefined,
+ enumOptions?: string[],
+): string => {
+ if (m == null) return "—";
+ if (enumOptions?.length) {
+ return typeof m === "string" ? m : (enumOptions[m as number] ?? String(m));
+ }
+ if (typeof m === "boolean") return m ? "1" : "0";
+ if (typeof m === "object" && "last" in m) return m.last.toFixed(2);
+ if (typeof m === "number") return m.toFixed(2);
+ return String(m);
+};
+
export const detectExtraBoards = (
activeFilters: TabFilter | undefined,
boards: BoardName[],
From d4cd711d22b32d1ce8287d75af0075ea9e043c97 Mon Sep 17 00:00:00 2001
From: Maxim <74974283+maximka76667@users.noreply.github.com>
Date: Tue, 17 Mar 2026 12:53:11 +0100
Subject: [PATCH 02/49] fix: command item's params state reset
---
.../components/rightSidebar/sections/CommandsSection.tsx | 9 ++++++---
.../rightSidebar/tabs/commands/CommandItem.tsx | 5 +++--
2 files changed, 9 insertions(+), 5 deletions(-)
diff --git a/frontend/testing-view/src/features/workspace/components/rightSidebar/sections/CommandsSection.tsx b/frontend/testing-view/src/features/workspace/components/rightSidebar/sections/CommandsSection.tsx
index 59b3dbaff..f0b6cb8ed 100644
--- a/frontend/testing-view/src/features/workspace/components/rightSidebar/sections/CommandsSection.tsx
+++ b/frontend/testing-view/src/features/workspace/components/rightSidebar/sections/CommandsSection.tsx
@@ -1,8 +1,13 @@
import { useStore } from "../../../../../store/store";
+import type { CatalogItem } from "../../../../../types/common/item";
import type { CommandCatalogItem } from "../../../../../types/data/commandCatalogItem";
import { CommandItem } from "../tabs/commands/CommandItem";
import { Tab } from "../tabs/Tab";
+const CommandItemWrapper = ({ item }: { item: CatalogItem }) => (
+
+);
+
export const CommandsSection = () => {
const boards = useStore((s) => s.boards);
@@ -11,9 +16,7 @@ export const CommandsSection = () => {
title="Commands"
scope="commands"
categories={boards}
- ItemComponent={(props) => (
-
- )}
+ ItemComponent={CommandItemWrapper}
/>
);
};
diff --git a/frontend/testing-view/src/features/workspace/components/rightSidebar/tabs/commands/CommandItem.tsx b/frontend/testing-view/src/features/workspace/components/rightSidebar/tabs/commands/CommandItem.tsx
index 7b0374313..df9ac8168 100644
--- a/frontend/testing-view/src/features/workspace/components/rightSidebar/tabs/commands/CommandItem.tsx
+++ b/frontend/testing-view/src/features/workspace/components/rightSidebar/tabs/commands/CommandItem.tsx
@@ -30,8 +30,8 @@ export const CommandItem = ({ item: commandCatalogItem }: CommandItemProps) => {
() => getDefaultParameterValues(commandCatalogItem.fields),
);
- const hasParameters = Object.keys(commandCatalogItem.fields).length > 0;
const paramCount = Object.keys(commandCatalogItem.fields).length;
+ const hasParameters = paramCount > 0;
const hasInvalidNumeric = Object.entries(commandCatalogItem.fields).some(
([key, field]) =>
@@ -87,7 +87,7 @@ export const CommandItem = ({ item: commandCatalogItem }: CommandItemProps) => {
{
) : (