Skip to content
Merged
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
3 changes: 0 additions & 3 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,6 @@ jobs:
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y rpm libarchive-tools

# Download ONLY the appropriate backend for this platform
- name: Download Linux backend
Expand Down Expand Up @@ -188,8 +187,6 @@ jobs:
electron-app/dist/*.exe
electron-app/dist/*.AppImage
electron-app/dist/*.deb
electron-app/dist/*.rpm
electron-app/dist/*.pacman
electron-app/dist/*.dmg
electron-app/dist/*.zip
electron-app/dist/*.yml
Expand Down
25 changes: 13 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Hyperloop Control Station H11
![Testing View](https://raw.githubusercontent.com/Hyperloop-UPV/webpage/5c1c827d82d380689856ee61af43da30da22e0fc/src/assets/backgrounds/testing-view.png)

![Testing View](https://raw.githubusercontent.com/Hyperloop-UPV/webpage/5c1c827d82d380689856ee61af43da30da22e0fc/src/assets/backgrounds/testing-view.png)

## Monorepo usage

Expand All @@ -20,17 +21,17 @@ Before starting, ensure you have the following installed:

Our `pnpm-workspace.yaml` defines the following workspaces:

| Workspace | Language | Description |
| :----------------------------- | :------- | :--------------------------------------------- |
| `testing-view` | TS/React | Web interface for telemetry testing |
| `competition-view` | TS/React | UI for the competition |
| `backend` | Go | Data ingestion and pod communication server |
| `packet-sender` | Rust | Utility for simulating vehicle packets |
| `electron-app` | JS | The main Control Station desktop application |
| `@workspace/ui` | TS/React | Shared UI component library (frontend-kit) |
| `@workspace/core` | TS | Shared business logic and types (frontend-kit) |
| `@workspace/eslint-config` | ESLint | Common ESLint configuration (frontend-kit) |
| `@workspace/typescript-config` | TS | Common TypeScript configuration (frontend-kit) |
| Workspace | Language | Description |
| :----------------------------- | :------- | :---------------------------------------------------- |
| `testing-view` | TS/React | Web interface for telemetry testing |
| `competition-view` | TS/React | UI for the competition |
| `backend` | Go | Data ingestion and pod communication server |
| `packet-sender` | Rust | Utility for simulating vehicle packets |
| `hyperloop-control-station` | JS | The main Control Station electron desktop application |
| `@workspace/ui` | TS/React | Shared UI component library (frontend-kit) |
| `@workspace/core` | TS | Shared business logic and types (frontend-kit) |
| `@workspace/eslint-config` | ESLint | Common ESLint configuration (frontend-kit) |
| `@workspace/typescript-config` | TS | Common TypeScript configuration (frontend-kit) |

---

Expand Down
5 changes: 4 additions & 1 deletion electron-app/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,10 @@ logger.header("Hyperloop Control Station Build");

if (frontendBuilt && !process.env.CI) {
logger.info("Finalizing Electron...");
run("pnpm --filter electron-app install --frozen-lockfile", __dirname);
run(
"pnpm --filter hyperloop-control-station install --frozen-lockfile",
__dirname
);
}

if (allSuccess) {
Expand Down
12 changes: 5 additions & 7 deletions electron-app/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,6 @@ if (process.platform === "linux") {
// Setup IPC handlers for renderer process communication
setupIpcHandlers();

app.setName("hyperloop-control-station");

// App lifecycle: wait for Electron to be ready
app.whenReady().then(async () => {
// Get the screen width and height
Expand Down Expand Up @@ -118,11 +116,11 @@ app.on("window-all-closed", () => {
});

// Cleanup before app quits
app.on("before-quit", () => {
// Stop backend process gracefully
stopBackend();
// Stop packet sender process gracefully
stopPacketSender();
app.on("before-quit", (e) => {
e.preventDefault();
Promise.all([stopBackend(), stopPacketSender()])
.catch((error) => logger.electron.error("Error during shutdown:", error))
.finally(() => app.exit());
});

// Handle uncaught exceptions globally
Expand Down
8 changes: 3 additions & 5 deletions electron-app/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "electron-app",
"name": "hyperloop-control-station",
"version": "1.0.0",
"description": "Hyperloop UPV Control Station",
"main": "main.js",
Expand Down Expand Up @@ -57,7 +57,7 @@
"owner": "Hyperloop-UPV",
"repo": "software"
},
"productName": "Hyperloop-Control-Station",
"productName": "Hyperloop-Ctrl",
"directories": {
"output": "dist"
},
Expand Down Expand Up @@ -107,9 +107,7 @@
"linux": {
"target": [
"AppImage",
"deb",
"rpm",
"pacman"
"deb"
],
"icon": "icons/512x512.png",
"category": "Utility",
Expand Down
8 changes: 6 additions & 2 deletions electron-app/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ contextBridge.exposeInMainWorld("electronAPI", {
// Open folder selection dialog
selectFolder: () => ipcRenderer.invoke("select-folder"),
// Receive log message from backend
onLog: (callback) =>
ipcRenderer.on("log", (_event, value) => callback(value)),
onLog: (callback) => {
const listener = (_event, value) => callback(value);
ipcRenderer.removeAllListeners("log");
ipcRenderer.on("log", listener);
return () => ipcRenderer.removeListener("log", listener);
},
});
41 changes: 41 additions & 0 deletions electron-app/src/config/__tests__/updateTomlValue.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -164,4 +164,45 @@ key2 = "value2"`;
0
);
});

it("should not corrupt a [section]-like line inside a multiline string", () => {
const toml = `[app]
note = """
[not-a-section]
just text
"""
name = "old"`;

const result = updateTomlValue(toml, "app", "name", "new");

expect(result).toContain('name = "new"');
expect(result).toContain("[not-a-section]");
});

it("should not update a key inside a multiline string", () => {
const toml = `[app]
note = """
name = "inside multiline"
"""
name = "real"`;

const result = updateTomlValue(toml, "app", "name", "updated");

expect(result).toContain('name = "updated"');
expect(result).toContain('name = "inside multiline"');
expect(result.indexOf('name = "inside multiline"')).toBeLessThan(
result.indexOf('name = "updated"')
);
});

it("should handle a multiline string that opens and closes on the same line", () => {
const toml = `[app]
note = """single line multiline"""
name = "old"`;

const result = updateTomlValue(toml, "app", "name", "new");

expect(result).toContain('name = "new"');
expect(result).toContain('note = """single line multiline"""');
});
});
14 changes: 13 additions & 1 deletion electron-app/src/config/configManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,30 @@ import { logger } from "../utils/logger.js";
* const updated = updateTomlValue(content, "database", "host", "192.168.1.1");
*/
function updateTomlValue(tomlContent, section, key, newValue) {
const lineEnding = tomlContent.includes("\r\n") ? "\r\n" : "\n";
// Split content into lines for processing
const lines = tomlContent.split(/\r?\n/);
// Track current section while iterating
let currentSection = null;
// Flag to track if update was successful
let updated = false;
// Track if we're inside a multiline string
let inMultilineString = false;

// Process each line
const result = lines.map((line) => {
// Get trimmed version for parsing
const trimmed = line.trim();

// Track multiline string boundaries (""" or ''')
const tripleDoubleQuotes = (line.match(/"""/g) || []).length;
const tripleSingleQuotes = (line.match(/'''/g) || []).length;
if (tripleDoubleQuotes % 2 !== 0 || tripleSingleQuotes % 2 !== 0) {
inMultilineString = !inMultilineString;
return line;
}
if (inMultilineString) return line;

// Track current section
const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/);
if (sectionMatch) {
Expand Down Expand Up @@ -104,7 +116,7 @@ function updateTomlValue(tomlContent, section, key, newValue) {
}

// Join lines back into string
return result.join("\n");
return result.join(lineEnding);
}

/**
Expand Down
22 changes: 12 additions & 10 deletions electron-app/src/processes/backend.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,7 @@ async function startBackend(logWindow = null) {
backendProcess.stderr.on("data", (data) => {
const errorMsg = data.toString().trim();
logger.backend.error(errorMsg);
// Store the last error message
lastBackendError = errorMsg;
if (errorMsg) lastBackendError = errorMsg;

// Send error message to log window
if (currentLogWindow && !currentLogWindow.isDestroyed()) {
Expand All @@ -108,17 +107,12 @@ async function startBackend(logWindow = null) {
return reject(new Error(`Failed to start backend: ${error.message}`));
});

// If the backend didn't fail in this period of time, resolve the promise
setTimeout(() => {
resolve(backendProcess);
}, 2000);

// Handle process exit
backendProcess.on("close", (code) => {
logger.backend.info(`Backend process exited with code ${code}`);
// Show error dialog if process crashed (non-zero exit code)
clearTimeout(startupTimer);

if (code !== 0 && code !== null) {
// Build error message with actual error details
let errorMessage = `Backend exited with code ${code}`;

if (lastBackendError) {
Expand All @@ -128,10 +122,18 @@ async function startBackend(logWindow = null) {
}

dialog.showErrorBox("Backend Crashed", errorMessage);
// Clear error message after showing
lastBackendError = null;
backendProcess = null;
return reject(new Error(errorMessage));
}

backendProcess = null;
});

// If the backend didn't fail in this period of time, resolve the promise
const startupTimer = setTimeout(() => {
resolve(backendProcess);
}, 2000);
});
}

Expand Down
2 changes: 2 additions & 0 deletions electron-app/src/windows/mainWindow.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ function loadView(view) {
// Construct path to view HTML file
const viewPath = path.join(appPath, "renderer", view, "index.html");

if (!mainWindow || mainWindow.isDestroyed()) return;

// Check if view file exists
if (fs.existsSync(viewPath)) {
// Load the view HTML file
Expand Down
9 changes: 9 additions & 0 deletions frontend/testing-view/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ export const config = {
/** Fallback history limit for a chart. Used when the history limit is not set. */
FALLBACK_CHART_HISTORY_LIMIT: 100,

/** Minimum history limit for the chart buffer size slider. */
CHART_HISTORY_MIN: 50,

/** Maximum history limit for the chart buffer size slider. */
CHART_HISTORY_MAX: 5000,

/** Step size for the chart buffer size slider. */
CHART_HISTORY_STEP: 50,

/** Default height of chart. */
DEFAULT_CHART_HEIGHT: 250,

Expand Down
40 changes: 26 additions & 14 deletions frontend/testing-view/src/components/settings/SettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import {
DialogHeader,
DialogTitle,
} from "@workspace/ui/components/shadcn/dialog";
import { useEffect, useState } from "react";
import { Loader2 } from "@workspace/ui/icons";
import { useEffect, useState, useTransition } from "react";
import {
AlertTriangle,
CheckCircle2,
Expand All @@ -24,6 +25,7 @@ export const SettingsDialog = () => {
const setRestarting = useStore((s) => s.setRestarting);
const [localConfig, setLocalConfig] = useState<ConfigData | null>(null);
const [isSynced, setIsSynced] = useState(false);
const [isSaving, startSaving] = useTransition();

const loadConfig = async () => {
if (window.electronAPI) {
Expand Down Expand Up @@ -54,24 +56,26 @@ export const SettingsDialog = () => {
}, [isSettingsOpen]);

const handleSave = async () => {
if (window.electronAPI) {
await window.electronAPI.saveConfig(localConfig);
} else {
console.log("Electron API not available. Using default config.");
}
startSaving(async () => {
if (window.electronAPI) {
await window.electronAPI.saveConfig(localConfig);
} else {
console.log("Electron API not available. Using default config.");
}

setRestarting(true);
setRestarting(true);

setTimeout(() => {
socketService.connect();
setSettingsOpen(false);
setRestarting(false);
}, config.SETTINGS_RESPONSE_TIMEOUT);
setTimeout(() => {
socketService.connect();
setSettingsOpen(false);
setRestarting(false);
}, config.SETTINGS_RESPONSE_TIMEOUT);
});
};

return (
<Dialog open={isSettingsOpen} onOpenChange={setSettingsOpen}>
<DialogContent className="flex max-h-[85vh] max-w-2xl min-w-[800px] flex-col">
<DialogContent className="flex max-h-[85vh] min-w-[800px] max-w-2xl flex-col">
<DialogHeader className="pr-5">
<div className="flex items-center justify-between">
<DialogTitle>System Configuration</DialogTitle>
Expand Down Expand Up @@ -101,7 +105,15 @@ export const SettingsDialog = () => {
<Button variant="ghost" onClick={() => setSettingsOpen(false)}>
Cancel
</Button>
<Button onClick={handleSave}>Save Changes</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" /> Saving...
</>
) : (
"Save Changes"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export const SettingsForm = ({ config, onChange }: SettingsFormProps) => {
<div className="space-y-8">
{schema.map((section) => (
<section key={section.title} className="space-y-4">
<h3 className="text-muted-foreground border-b pb-1 text-sm font-bold tracking-wider uppercase">
<h3 className="text-muted-foreground border-b pb-1 text-sm font-bold uppercase tracking-wider">
{section.title}
</h3>
<div className="grid gap-4">
Expand Down
Loading
Loading