Skip to content

Commit 38e8226

Browse files
Merge pull request #492 from Hyperloop-UPV/develop
2 parents fdb3004 + 59abdd6 commit 38e8226

16 files changed

Lines changed: 279 additions & 60 deletions

File tree

.github/workflows/electron-tests.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ jobs:
2525
- uses: actions/setup-node@v4
2626
with:
2727
node-version: "20"
28-
cache: "pnpm"
2928

3029
- name: Install dependencies
3130
run: pnpm install --frozen-lockfile --filter=hyperloop-control-station

backend/pkg/logger/trace/trace.go

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,13 @@ func InitTrace(traceLevel string) *os.File {
6969
// Human-friendly console writer that prints logs to stdout.
7070
consoleWriter := zerolog.ConsoleWriter{Out: os.Stdout}
7171

72+
// Console writer that prints fatal logs to stderr so the
73+
// parent process (Electron) can capture them via the stderr pipe.
74+
stderrConsoleWriter := &levelFilterWriter{
75+
w: zerolog.ConsoleWriter{Out: os.Stderr},
76+
minLevel: zerolog.FatalLevel,
77+
}
78+
7279
// Try to create/open the file for writing logs. On failure, fall back to console only and exit.
7380
file, err := loggerbase.CreateFile(traceDir, Trace, traceFile)
7481
if err != nil {
@@ -78,8 +85,8 @@ func InitTrace(traceLevel string) *os.File {
7885
return nil
7986
}
8087

81-
// Write logs to both the console and the file.
82-
multi := zerolog.MultiLevelWriter(consoleWriter, file)
88+
// Write logs to stdout, stderr (warn+), and the file.
89+
multi := zerolog.MultiLevelWriter(consoleWriter, stderrConsoleWriter, file)
8390

8491
// Create a new logger that includes timestamps and caller information.
8592
trace.Logger = zerolog.New(multi).With().Timestamp().Caller().Logger()
@@ -96,3 +103,24 @@ func InitTrace(traceLevel string) *os.File {
96103

97104
return file
98105
}
106+
107+
// levelFilterWriter is a zerolog.LevelWriter that forwards log entries to w
108+
// only when their level is >= minLevel. This lets us route warn/error/fatal
109+
// to stderr while keeping info/debug on stdout.
110+
type levelFilterWriter struct {
111+
w zerolog.ConsoleWriter
112+
minLevel zerolog.Level
113+
}
114+
115+
// Write satisfies the io.Writer interface required by zerolog.LevelWriter.
116+
// MultiLevelWriter always calls WriteLevel instead.
117+
func (f *levelFilterWriter) Write(p []byte) (int, error) {
118+
return f.w.Write(p)
119+
}
120+
121+
func (f *levelFilterWriter) WriteLevel(l zerolog.Level, p []byte) (int, error) {
122+
if l >= f.minLevel {
123+
return f.w.Write(p)
124+
}
125+
return len(p), nil
126+
}

electron-app/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ When running in development mode (unpackaged), the application creates temporary
3232

3333
- **Configuration and Logs**: Stored in `{UserConfigDir}/hyperloop-control-station/` (using Go's `os.UserConfigDir()`)
3434
- Config files and backups: `{UserConfigDir}/hyperloop-control-station/configs/`
35-
- Trace/log files: `{UserConfigDir}/hyperloop-control-station/trace-*.json`
35+
- Trace/log files: `{UserConfigDir}/hyperloop-control-station/configs/trace-*.json`
3636

3737
- **ADJ Module**: Stored in `{UserCacheDir}/hyperloop-control-station/adj/` (using Go's `os.UserCacheDir()`)
3838

electron-app/src/processes/backend.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
getBinaryPath,
1616
getUserConfigPath,
1717
} from "../utils/paths.js";
18+
import { formatBackendError, getHint } from "./backendError.js";
1819

1920
// Create ANSI to HTML converter
2021
const convert = new AnsiToHtml();
@@ -28,7 +29,7 @@ let backendProcess = null;
2829
// Common log window instance for all backend processes
2930
let storedLogWindow = null;
3031

31-
// Store error messages (keep last 10 lines to avoid memory issues)
32+
// Store error messages accumulated from the current process run
3233
let lastBackendError = null;
3334

3435
/**
@@ -129,7 +130,9 @@ async function startBackend(logWindow = null) {
129130
let errorMessage = `Backend exited with code ${code}`;
130131

131132
if (lastBackendError) {
132-
errorMessage += `\n\n${lastBackendError}`;
133+
const stripped = lastBackendError.replace(/\x1b\[[0-9;]*m/g, "");
134+
const formatted = formatBackendError(stripped);
135+
errorMessage += `\n\n${getHint(stripped, formatted)}`;
133136
} else {
134137
errorMessage += "\n\n(No error output captured)";
135138
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/**
2+
* @module processes
3+
* @description Error formatting and hint utilities for backend crash diagnostics.
4+
* Parses zerolog console output, strips ANSI codes, and maps known error patterns
5+
* to actionable user-facing messages shown in the crash dialog.
6+
*/
7+
8+
/**
9+
* List of known error patterns with human-readable messages and fix advice.
10+
* Each entry is matched against the raw stripped stderr output.
11+
*/
12+
const ERROR_HINTS = [
13+
{
14+
pattern: /bind: The requested address is not valid/,
15+
message: "Network address unavailable",
16+
advice:
17+
"The configured IP address doesn't exist on this machine. Check your network adapter or ADJ.",
18+
},
19+
{
20+
pattern: /failed to start UDP server/,
21+
message: "UDP server failed to start",
22+
advice: "Another process may already be using this port.",
23+
},
24+
{
25+
pattern: /jsonschema/,
26+
message: "ADJ Validator dependency missing",
27+
advice:
28+
"Install the required Python package by running: pip install jsonschema==4.25.0",
29+
},
30+
{
31+
pattern: /No Python interpreter found/,
32+
message: "Python not found",
33+
advice:
34+
"Install Python 3 and make sure it is accessible via 'python3', 'python', or 'py' in your PATH.",
35+
},
36+
{
37+
pattern: /ADJ Validator failed with error/,
38+
message: "ADJ validation failed",
39+
advice:
40+
"Your ADJ files contain schema errors. Check the ADJ validator log file in the logs folder for details.",
41+
},
42+
{
43+
pattern: /error reading config file/,
44+
message: "Config file not found",
45+
advice:
46+
"The configuration file could not be read. Check that the config file path is correct and the file exists.",
47+
},
48+
{
49+
pattern: /error unmarshaling toml file/,
50+
message: "Config file has errors",
51+
advice:
52+
"The configuration file contains invalid TOML. Check the config file for syntax or type errors.",
53+
},
54+
{
55+
pattern: /setting up ADJ/,
56+
message: "ADJ not available",
57+
advice:
58+
"Could not load the ADJ. If this is your first run, connect to the internet so the ADJ can be downloaded.",
59+
},
60+
];
61+
62+
/**
63+
* Reformats a single stripped zerolog console line into a readable block.
64+
* Zerolog console format: "TIME LEVEL FILE > message key=value ..."
65+
* @param {string} line - A single log line with ANSI codes already stripped.
66+
* @returns {string} A formatted multi-line string with level, file, and key-value pairs on separate lines.
67+
* @example
68+
* formatLine("11:43AM FTL setup_transport.go:143 > failed to start UDP server error=\"some error\"");
69+
* // "[FTL] at setup_transport.go:143\n failed to start UDP server\n error: \"some error\""
70+
*/
71+
function formatLine(line) {
72+
const m = line.match(/^\S+\s+(\S+)\s+(\S+)\s+>\s+(.*)/);
73+
if (!m) return line;
74+
const [, level, file, rest] = m;
75+
const body = rest.replace(
76+
/\s+(\w+)=("(?:[^"\\]|\\.)*"|\S+)/g,
77+
"\n $1: $2",
78+
);
79+
return `[${level}] at ${file}\n ${body.trim()}`;
80+
}
81+
82+
/**
83+
* Formats a full multi-line stderr output by reformatting each zerolog line.
84+
* @param {string} text - Raw stderr text with ANSI codes already stripped.
85+
* @returns {string} Formatted text with each log line reformatted for readability.
86+
* @example
87+
* formatBackendError("11:43AM FTL file.go:10 > something failed error=\"bad\"");
88+
*/
89+
function formatBackendError(text) {
90+
return text.split("\n").filter(Boolean).map(formatLine).join("\n\n");
91+
}
92+
93+
/**
94+
* Returns a user-facing error message by matching the raw error against known patterns.
95+
* If a match is found, prepends a hint and advice to the formatted error.
96+
* Falls back to the formatted error text if no pattern matches.
97+
* @param {string} raw - Raw stripped stderr text used for pattern matching.
98+
* @param {string} formatted - Pre-formatted version of the error for display.
99+
* @returns {string} The final message to show in the crash dialog.
100+
* @example
101+
* getHint("failed to start UDP server ...", "[FTL] at ...");
102+
* // "UDP server failed to start\n\nAnother process may already be using this port.\n\n[FTL] at ..."
103+
*/
104+
function getHint(raw, formatted) {
105+
const match = ERROR_HINTS.find(({ pattern }) => pattern.test(raw));
106+
return match
107+
? `${match.message}\n\n${match.advice}\n\n${formatted}`
108+
: formatted;
109+
}
110+
111+
export { formatBackendError, getHint };

frontend/testing-view/config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,10 @@ export const config = {
3131

3232
/** Timeout applied after settings save to give enough time for backend to restart. */
3333
SETTINGS_RESPONSE_TIMEOUT: 1000,
34+
35+
/** GitHub repository used to fetch available branches for ADJ configuration. */
36+
ADJ_GITHUB_REPO: "hyperloop-upv/adj",
37+
38+
/** Timeout for fetching branches from GitHub API. */
39+
BRANCHES_FETCH_TIMEOUT: 5000,
3440
} as const;

frontend/testing-view/src/components/settings/ComboboxField.tsx

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,45 +6,71 @@ import {
66
ComboboxItem,
77
ComboboxList,
88
} from "@workspace/ui/components";
9+
import { Button } from "@workspace/ui/components/shadcn/button";
910
import { Label } from "@workspace/ui/components/shadcn/label";
10-
import { useState } from "react";
11+
import { RefreshCw } from "@workspace/ui/icons";
12+
import { useEffect, useState } from "react";
13+
import type { BranchesFetchState } from "../../hooks/useBranches";
1114
import type { FieldProps } from "../../types/common/settings";
1215

1316
export const ComboboxField = ({
1417
field,
1518
value,
1619
onChange,
1720
loading,
18-
}: FieldProps<string>) => {
21+
fetchState,
22+
}: FieldProps<string> & { fetchState?: BranchesFetchState }) => {
1923
const predefined = field.options ?? [];
2024
const items =
2125
value && !predefined.includes(value) ? [value, ...predefined] : predefined;
2226

2327
const [inputValue, setInputValue] = useState(value ?? "");
28+
const [selectedValue, setSelectedValue] = useState<string | null>(value ?? null);
29+
30+
// Sync when value prop changes (e.g. after external save)
31+
useEffect(() => {
32+
setInputValue(value ?? "");
33+
setSelectedValue(value ?? null);
34+
}, [value]);
2435

2536
const commitInput = () => {
26-
if (inputValue && inputValue !== value) onChange(inputValue);
37+
if (inputValue !== value) onChange(inputValue);
2738
};
2839

2940
return (
3041
<div className="w-70 space-y-2">
31-
<Label>{field.label}</Label>
42+
<div className="flex items-center gap-2">
43+
<Label>{field.label}</Label>
44+
{fetchState && (
45+
<Button
46+
variant="ghost"
47+
size="sm"
48+
onClick={fetchState.refetch}
49+
disabled={loading}
50+
className={`h-5 w-5 p-0 ${fetchState.error ? "text-destructive hover:text-destructive" : ""}`}
51+
>
52+
<RefreshCw className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} />
53+
</Button>
54+
)}
55+
</div>
3256
<Combobox
3357
items={items}
34-
value={value ?? null}
58+
value={selectedValue}
3559
onValueChange={(v) => {
36-
onChange(v ?? "");
60+
setSelectedValue(v);
3761
setInputValue(v ?? "");
62+
onChange(v ?? "");
3863
}}
3964
>
4065
<ComboboxInput
4166
placeholder={
4267
loading ? "Loading..." : (field.placeholder ?? "Choose or type...")
4368
}
44-
disabled={loading}
4569
value={inputValue}
4670
onChange={(e) => {
47-
if (e.target.value !== value) setInputValue(e.target.value);
71+
setInputValue(e.target.value);
72+
// Clear selection so base-ui doesn't reset the input to the selected value
73+
setSelectedValue(null);
4874
}}
4975
onBlur={commitInput}
5076
onKeyDown={(e) => {

frontend/testing-view/src/components/settings/SettingsDialog.tsx

Lines changed: 3 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { AlertTriangle, CheckCircle2, Loader2, X } from "@workspace/ui/icons";
55
import { useEffect, useState, useTransition } from "react";
66
import { config } from "../../../config";
77
import { DEFAULT_CONFIG } from "../../constants/defaultConfig";
8+
import { useBranches } from "../../hooks/useBranches";
89
import { useStore } from "../../store/store";
910
import type { ConfigData } from "../../types/common/config";
1011
import { SettingsForm } from "./SettingsForm";
@@ -17,8 +18,7 @@ export const SettingsDialog = () => {
1718
const [localConfig, setLocalConfig] = useState<ConfigData | null>(null);
1819
const [isSynced, setIsSynced] = useState(false);
1920
const [isSaving, startSaving] = useTransition();
20-
const [isBranchesLoading, startBranchesTransition] = useTransition();
21-
const [branches, setBranches] = useState<string[]>([]);
21+
const branchesFetch = useBranches(isSettingsOpen);
2222

2323
const loadConfig = async () => {
2424
if (window.electronAPI) {
@@ -39,33 +39,9 @@ export const SettingsDialog = () => {
3939
}
4040
};
4141

42-
const loadBranches = (signal: AbortSignal) => {
43-
startBranchesTransition(async () => {
44-
try {
45-
const res = await fetch(
46-
"https://api.github.com/repos/hyperloop-upv/adj/branches?per_page=100",
47-
{ signal: AbortSignal.any([signal, AbortSignal.timeout(2000)]) },
48-
);
49-
const data = await res.json();
50-
setBranches(data.map((b: { name: string }) => b.name));
51-
} catch (error) {
52-
if (
53-
error instanceof Error &&
54-
error.name !== "AbortError" &&
55-
error.name !== "TimeoutError"
56-
) {
57-
console.error("Error loading branches:", error);
58-
}
59-
}
60-
});
61-
};
62-
6342
useEffect(() => {
6443
if (isSettingsOpen) {
65-
const controller = new AbortController();
6644
loadConfig();
67-
loadBranches(controller.signal);
68-
return () => controller.abort();
6945
}
7046
}, [isSettingsOpen]);
7147

@@ -120,8 +96,7 @@ export const SettingsDialog = () => {
12096
<SettingsForm
12197
config={localConfig}
12298
onChange={setLocalConfig}
123-
branches={branches}
124-
branchesLoading={isBranchesLoading}
99+
branchesFetch={branchesFetch}
125100
/>
126101
)}
127102
</div>

0 commit comments

Comments
 (0)