From ef98de2cb90f48369e40f9a3fcebe73e81f2d446 Mon Sep 17 00:00:00 2001 From: Maxim <74974283+maximka76667@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:16:52 +0200 Subject: [PATCH 1/5] feat: disable number keybindings and allow them in almost any moment, also add refetching button for branches --- frontend/testing-view/config.ts | 6 +++ .../src/components/settings/ComboboxField.tsx | 42 ++++++++++++---- .../components/settings/SettingsDialog.tsx | 31 ++---------- .../src/components/settings/SettingsForm.tsx | 11 +++-- .../src/constants/settingsSchema.ts | 1 + .../components/AddKeyBindingDialog.tsx | 5 ++ .../constants/specialKeyBindings.ts | 1 - .../keyBindings/hooks/useGlobalKeyBindings.ts | 12 ----- .../testing-view/src/hooks/useBranches.ts | 48 +++++++++++++++++++ frontend/testing-view/src/store/store.ts | 26 ++++++++++ .../testing-view/src/types/common/settings.ts | 3 ++ 11 files changed, 132 insertions(+), 54 deletions(-) create mode 100644 frontend/testing-view/src/hooks/useBranches.ts diff --git a/frontend/testing-view/config.ts b/frontend/testing-view/config.ts index a64641ef9..87f5bfa2e 100644 --- a/frontend/testing-view/config.ts +++ b/frontend/testing-view/config.ts @@ -31,4 +31,10 @@ export const config = { /** Timeout applied after settings save to give enough time for backend to restart. */ SETTINGS_RESPONSE_TIMEOUT: 1000, + + /** GitHub repository used to fetch available branches for ADJ configuration. */ + ADJ_GITHUB_REPO: "hyperloop-upv/adj", + + /** Timeout for fetching branches from GitHub API. */ + BRANCHES_FETCH_TIMEOUT: 5000, } as const; diff --git a/frontend/testing-view/src/components/settings/ComboboxField.tsx b/frontend/testing-view/src/components/settings/ComboboxField.tsx index cb1687e91..8c14abf1f 100644 --- a/frontend/testing-view/src/components/settings/ComboboxField.tsx +++ b/frontend/testing-view/src/components/settings/ComboboxField.tsx @@ -6,8 +6,11 @@ import { ComboboxItem, ComboboxList, } from "@workspace/ui/components"; +import { Button } from "@workspace/ui/components/shadcn/button"; import { Label } from "@workspace/ui/components/shadcn/label"; -import { useState } from "react"; +import { RefreshCw } from "@workspace/ui/icons"; +import { useEffect, useState } from "react"; +import type { BranchesFetchState } from "../../hooks/useBranches"; import type { FieldProps } from "../../types/common/settings"; export const ComboboxField = ({ @@ -15,36 +18,59 @@ export const ComboboxField = ({ value, onChange, loading, -}: FieldProps) => { + fetchState, +}: FieldProps & { fetchState?: BranchesFetchState }) => { const predefined = field.options ?? []; const items = value && !predefined.includes(value) ? [value, ...predefined] : predefined; const [inputValue, setInputValue] = useState(value ?? ""); + const [selectedValue, setSelectedValue] = useState(value ?? null); + + // Sync when value prop changes (e.g. after external save) + useEffect(() => { + setInputValue(value ?? ""); + setSelectedValue(value ?? null); + }, [value]); const commitInput = () => { - if (inputValue && inputValue !== value) onChange(inputValue); + if (inputValue !== value) onChange(inputValue); }; return (
- +
+ + {fetchState && ( + + )} +
{ - onChange(v ?? ""); + setSelectedValue(v); setInputValue(v ?? ""); + onChange(v ?? ""); }} > { - if (e.target.value !== value) setInputValue(e.target.value); + setInputValue(e.target.value); + // Clear selection so base-ui doesn't reset the input to the selected value + setSelectedValue(null); }} onBlur={commitInput} onKeyDown={(e) => { diff --git a/frontend/testing-view/src/components/settings/SettingsDialog.tsx b/frontend/testing-view/src/components/settings/SettingsDialog.tsx index 33db5109d..2ffda360c 100644 --- a/frontend/testing-view/src/components/settings/SettingsDialog.tsx +++ b/frontend/testing-view/src/components/settings/SettingsDialog.tsx @@ -5,6 +5,7 @@ import { AlertTriangle, CheckCircle2, Loader2, X } from "@workspace/ui/icons"; import { useEffect, useState, useTransition } from "react"; import { config } from "../../../config"; import { DEFAULT_CONFIG } from "../../constants/defaultConfig"; +import { useBranches } from "../../hooks/useBranches"; import { useStore } from "../../store/store"; import type { ConfigData } from "../../types/common/config"; import { SettingsForm } from "./SettingsForm"; @@ -17,8 +18,7 @@ export const SettingsDialog = () => { const [localConfig, setLocalConfig] = useState(null); const [isSynced, setIsSynced] = useState(false); const [isSaving, startSaving] = useTransition(); - const [isBranchesLoading, startBranchesTransition] = useTransition(); - const [branches, setBranches] = useState([]); + const branchesFetch = useBranches(isSettingsOpen); const loadConfig = async () => { if (window.electronAPI) { @@ -39,33 +39,9 @@ export const SettingsDialog = () => { } }; - const loadBranches = (signal: AbortSignal) => { - startBranchesTransition(async () => { - try { - const res = await fetch( - "https://api.github.com/repos/hyperloop-upv/adj/branches?per_page=100", - { signal: AbortSignal.any([signal, AbortSignal.timeout(2000)]) }, - ); - const data = await res.json(); - setBranches(data.map((b: { name: string }) => b.name)); - } catch (error) { - if ( - error instanceof Error && - error.name !== "AbortError" && - error.name !== "TimeoutError" - ) { - console.error("Error loading branches:", error); - } - } - }); - }; - useEffect(() => { if (isSettingsOpen) { - const controller = new AbortController(); loadConfig(); - loadBranches(controller.signal); - return () => controller.abort(); } }, [isSettingsOpen]); @@ -120,8 +96,7 @@ export const SettingsDialog = () => { )}
diff --git a/frontend/testing-view/src/components/settings/SettingsForm.tsx b/frontend/testing-view/src/components/settings/SettingsForm.tsx index 8e0bc768a..543e301df 100644 --- a/frontend/testing-view/src/components/settings/SettingsForm.tsx +++ b/frontend/testing-view/src/components/settings/SettingsForm.tsx @@ -1,6 +1,7 @@ import { get, set } from "lodash"; import { useMemo } from "react"; import { getSettingsSchema } from "../../constants/settingsSchema"; +import type { BranchesFetchState } from "../../hooks/useBranches"; import { useStore } from "../../store/store"; import type { ConfigData } from "../../types/common/config"; import type { SettingField } from "../../types/common/settings"; @@ -14,11 +15,10 @@ import { TextField } from "./TextField"; interface SettingsFormProps { config: ConfigData; onChange: (newConfig: ConfigData) => void; - branches: string[]; - branchesLoading: boolean; + branchesFetch: BranchesFetchState; } -export const SettingsForm = ({ config, onChange, branches, branchesLoading }: SettingsFormProps) => { +export const SettingsForm = ({ config, onChange, branchesFetch }: SettingsFormProps) => { const handleFieldChange = ( path: string, value: string | number | boolean | string[], @@ -30,7 +30,7 @@ export const SettingsForm = ({ config, onChange, branches, branchesLoading }: Se const boards = useStore((s) => s.boards); const sortedBoard = boards.sort(); - const schema = useMemo(() => getSettingsSchema(sortedBoard, branches), [sortedBoard, branches]); + const schema = useMemo(() => getSettingsSchema(sortedBoard, branchesFetch.branches), [sortedBoard, branchesFetch.branches]); const renderField = (field: SettingField) => { const currentValue = get(config, field.path); @@ -103,7 +103,8 @@ export const SettingsForm = ({ config, onChange, branches, branchesLoading }: Se field={field} value={currentValue as unknown as string} onChange={(value) => handleFieldChange(field.path, value)} - loading={branchesLoading} + loading={branchesFetch.isLoading} + fetchState={field.refetchable ? branchesFetch : undefined} /> ); diff --git a/frontend/testing-view/src/constants/settingsSchema.ts b/frontend/testing-view/src/constants/settingsSchema.ts index b240e1647..efcbae2e4 100644 --- a/frontend/testing-view/src/constants/settingsSchema.ts +++ b/frontend/testing-view/src/constants/settingsSchema.ts @@ -22,6 +22,7 @@ export const getSettingsSchema = (boards: BoardName[], branches: string[] = []): path: "adj.branch", type: "combobox", options: branches, + refetchable: true, }, ], }, diff --git a/frontend/testing-view/src/features/keyBindings/components/AddKeyBindingDialog.tsx b/frontend/testing-view/src/features/keyBindings/components/AddKeyBindingDialog.tsx index a8eb99928..5106f0795 100644 --- a/frontend/testing-view/src/features/keyBindings/components/AddKeyBindingDialog.tsx +++ b/frontend/testing-view/src/features/keyBindings/components/AddKeyBindingDialog.tsx @@ -87,6 +87,11 @@ export const AddKeyBindingDialog = ({ return; } + // Disallow number keys (0-9) and Backspace + if (/^\d$/.test(e.key) || e.key === "Backspace") { + return; + } + let key: string; if (SPECIAL_KEY_BINDINGS[e.key]) { key = SPECIAL_KEY_BINDINGS[e.key]; diff --git a/frontend/testing-view/src/features/keyBindings/constants/specialKeyBindings.ts b/frontend/testing-view/src/features/keyBindings/constants/specialKeyBindings.ts index cc903a5ea..a9c17b1f9 100644 --- a/frontend/testing-view/src/features/keyBindings/constants/specialKeyBindings.ts +++ b/frontend/testing-view/src/features/keyBindings/constants/specialKeyBindings.ts @@ -7,6 +7,5 @@ export const SPECIAL_KEY_BINDINGS: Record = { ArrowDown: "ArrowDown", ArrowLeft: "ArrowLeft", ArrowRight: "ArrowRight", - Backspace: "Backspace", Delete: "Delete", }; diff --git a/frontend/testing-view/src/features/keyBindings/hooks/useGlobalKeyBindings.ts b/frontend/testing-view/src/features/keyBindings/hooks/useGlobalKeyBindings.ts index 456f83cb6..bc53f6e72 100644 --- a/frontend/testing-view/src/features/keyBindings/hooks/useGlobalKeyBindings.ts +++ b/frontend/testing-view/src/features/keyBindings/hooks/useGlobalKeyBindings.ts @@ -18,18 +18,6 @@ export const useGlobalKeyBindings = () => { useEffect(() => { const handleKeyPress = (e: KeyboardEvent) => { - // Skip if a dialog is open - if (document.querySelector('[role="dialog"]')) return; - - // Skip if user is typing in an input/textarea/contenteditable - if ( - e.target instanceof HTMLInputElement || - e.target instanceof HTMLTextAreaElement || - (e.target as HTMLElement).isContentEditable - ) { - return; - } - // Build key string (matching the format from AddKeyBindingDialog) let key: string; if (SPECIAL_KEY_BINDINGS[e.key]) { diff --git a/frontend/testing-view/src/hooks/useBranches.ts b/frontend/testing-view/src/hooks/useBranches.ts new file mode 100644 index 000000000..60b78fd4c --- /dev/null +++ b/frontend/testing-view/src/hooks/useBranches.ts @@ -0,0 +1,48 @@ +import { useEffect, useState, useTransition } from "react"; +import { config } from "../../config"; + +export interface BranchesFetchState { + branches: string[]; + isLoading: boolean; + error: boolean; + refetch: () => void; +} + +export const useBranches = (enabled: boolean): BranchesFetchState => { + const [branches, setBranches] = useState([]); + const [isLoading, startTransition] = useTransition(); + const [error, setError] = useState(false); + + const load = (signal: AbortSignal) => { + startTransition(async () => { + try { + setError(false); + const res = await fetch( + `https://api.github.com/repos/${config.ADJ_GITHUB_REPO}/branches?per_page=100`, + { signal: AbortSignal.any([signal, AbortSignal.timeout(config.BRANCHES_FETCH_TIMEOUT)]) }, + ); + const data = await res.json(); + setBranches(data.map((b: { name: string }) => b.name)); + } catch (err) { + if (err instanceof Error && err.name !== "AbortError") { + setError(true); + } + } + }); + }; + + const refetch = () => { + const controller = new AbortController(); + load(controller.signal); + }; + + useEffect(() => { + if (enabled) { + const controller = new AbortController(); + load(controller.signal); + return () => controller.abort(); + } + }, [enabled]); + + return { branches, isLoading, error, refetch }; +}; diff --git a/frontend/testing-view/src/store/store.ts b/frontend/testing-view/src/store/store.ts index dfc7c58c8..264f5c370 100644 --- a/frontend/testing-view/src/store/store.ts +++ b/frontend/testing-view/src/store/store.ts @@ -41,6 +41,16 @@ export type Store = AppSlice & ChartsSlice & FilteringSlice; +type PersistedStore = { + charts: Store["charts"]; + workspaces: Store["workspaces"]; + activeWorkspace: Store["activeWorkspace"]; + colorScheme: Store["colorScheme"]; + isDarkMode: Store["isDarkMode"]; + testingPage: Store["testingPage"]; + workspaceFilters: Store["workspaceFilters"]; +}; + export const useStore = create()( // devtools( persist( @@ -58,6 +68,22 @@ export const useStore = create()( { // Partial persist name: "testing-view-storage", + version: 1, + migrate: (persistedState: unknown, version: number) => { + const state = persistedState as PersistedStore; + if (version < 1) { + // Remove keybindings that use number keys (0-9) or Backspace + const strip = (workspace: Store["workspaces"][number]): Store["workspaces"][number] => ({ + ...workspace, + keyBindings: (workspace.keyBindings ?? []).filter( + (b) => !/^\d$/.test(b.key) && b.key !== "Backspace", + ), + }); + if (state.workspaces) state.workspaces = state.workspaces.map(strip); + if (state.activeWorkspace) state.activeWorkspace = strip(state.activeWorkspace); + } + return state; + }, onRehydrateStorage: () => () => { document.documentElement.setAttribute("data-store-hydrated", "true"); }, diff --git a/frontend/testing-view/src/types/common/settings.ts b/frontend/testing-view/src/types/common/settings.ts index 9ca9f41ba..ab25c874d 100644 --- a/frontend/testing-view/src/types/common/settings.ts +++ b/frontend/testing-view/src/types/common/settings.ts @@ -41,6 +41,9 @@ export type SettingField = { /** Options of the field */ options?: string[]; + /** Whether the field supports refetching its options */ + refetchable?: boolean; + /** Placeholder of the field. The text showed inside the field when it is empty. */ placeholder?: string; From 4e1957cac271af4c70788793b182882330e93951 Mon Sep 17 00:00:00 2001 From: Maxim <74974283+maximka76667@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:24:41 +0200 Subject: [PATCH 2/5] feat: disable caching --- .github/workflows/electron-tests.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/electron-tests.yaml b/.github/workflows/electron-tests.yaml index 0e5c1f1f0..f7b14eaf5 100644 --- a/.github/workflows/electron-tests.yaml +++ b/.github/workflows/electron-tests.yaml @@ -25,7 +25,6 @@ jobs: - uses: actions/setup-node@v4 with: node-version: "20" - cache: "pnpm" - name: Install dependencies run: pnpm install --frozen-lockfile --filter=hyperloop-control-station From adc851569f8b94bb6478ea42d5fb53af69c0675a Mon Sep 17 00:00:00 2001 From: Maxim <74974283+maximka76667@users.noreply.github.com> Date: Sat, 4 Apr 2026 11:56:11 +0200 Subject: [PATCH 3/5] feat: forward errors to stderr --- backend/pkg/logger/trace/trace.go | 32 +++++++++++++++++++++++++-- electron-app/src/processes/backend.js | 30 +++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/backend/pkg/logger/trace/trace.go b/backend/pkg/logger/trace/trace.go index d6b44b876..0c36f21c6 100644 --- a/backend/pkg/logger/trace/trace.go +++ b/backend/pkg/logger/trace/trace.go @@ -69,6 +69,13 @@ func InitTrace(traceLevel string) *os.File { // Human-friendly console writer that prints logs to stdout. consoleWriter := zerolog.ConsoleWriter{Out: os.Stdout} + // Console writer that prints warn/error/fatal logs to stderr so the + // parent process (Electron) can capture them via the stderr pipe. + stderrConsoleWriter := &levelFilterWriter{ + w: zerolog.ConsoleWriter{Out: os.Stderr}, + minLevel: zerolog.WarnLevel, + } + // Try to create/open the file for writing logs. On failure, fall back to console only and exit. file, err := loggerbase.CreateFile(traceDir, Trace, traceFile) if err != nil { @@ -78,8 +85,8 @@ func InitTrace(traceLevel string) *os.File { return nil } - // Write logs to both the console and the file. - multi := zerolog.MultiLevelWriter(consoleWriter, file) + // Write logs to stdout, stderr (warn+), and the file. + multi := zerolog.MultiLevelWriter(consoleWriter, stderrConsoleWriter, file) // Create a new logger that includes timestamps and caller information. trace.Logger = zerolog.New(multi).With().Timestamp().Caller().Logger() @@ -96,3 +103,24 @@ func InitTrace(traceLevel string) *os.File { return file } + +// levelFilterWriter is a zerolog.LevelWriter that forwards log entries to w +// only when their level is >= minLevel. This lets us route warn/error/fatal +// to stderr while keeping info/debug on stdout. +type levelFilterWriter struct { + w zerolog.ConsoleWriter + minLevel zerolog.Level +} + +// Write satisfies the io.Writer interface required by zerolog.LevelWriter. +// MultiLevelWriter always calls WriteLevel instead. +func (f *levelFilterWriter) Write(p []byte) (int, error) { + return f.w.Write(p) +} + +func (f *levelFilterWriter) WriteLevel(l zerolog.Level, p []byte) (int, error) { + if l >= f.minLevel { + return f.w.Write(p) + } + return len(p), nil +} diff --git a/electron-app/src/processes/backend.js b/electron-app/src/processes/backend.js index 5ec35879e..8be49dc35 100644 --- a/electron-app/src/processes/backend.js +++ b/electron-app/src/processes/backend.js @@ -28,9 +28,30 @@ let backendProcess = null; // Common log window instance for all backend processes let storedLogWindow = null; -// Store error messages (keep last 10 lines to avoid memory issues) +// Store error messages accumulated from the current process run let lastBackendError = null; +const ERROR_HINTS = [ + { + pattern: /bind: The requested address is not valid/, + message: "Network address unavailable", + advice: + "The configured IP address doesn't exist on this machine. Check your network adapter or ADJ.", + }, + { + pattern: /failed to start UDP server/, + message: "UDP server failed to start", + advice: "Another process may already be using this port.", + }, +]; + +function getHint(errorText) { + const match = ERROR_HINTS.find(({ pattern }) => pattern.test(errorText)); + return match + ? `${match.message}\n\n${match.advice}\n\n${errorText}` + : errorText; +} + /** * Starts the backend process by spawning the backend binary with the user configuration. * @returns {void} @@ -73,6 +94,9 @@ async function startBackend(logWindow = null) { cwd: workingDir, }); + console.log("[DEBUG] backendProcess.stderr:", backendProcess.stderr); + console.log("[DEBUG] backendProcess.stdout:", backendProcess.stdout); + // Log stdout output from backend backendProcess.stdout.on("data", (data) => { const text = data.toString().trim(); @@ -101,6 +125,7 @@ async function startBackend(logWindow = null) { backendProcess.stderr.on("data", (data) => { const errorMsg = data.toString().trim(); logger.backend.error(errorMsg); + console.log("[DEBUG stderr chunk]", JSON.stringify(errorMsg)); lastBackendError = errorMsg; // Send error message to log window @@ -129,7 +154,8 @@ async function startBackend(logWindow = null) { let errorMessage = `Backend exited with code ${code}`; if (lastBackendError) { - errorMessage += `\n\n${lastBackendError}`; + const stripped = lastBackendError.replace(/\x1b\[[0-9;]*m/g, ""); + errorMessage += `\n\n${getHint(stripped)}`; } else { errorMessage += "\n\n(No error output captured)"; } From 3bbba264083e37c8e0d8e20c2ad51846e443530e Mon Sep 17 00:00:00 2001 From: Maxim <74974283+maximka76667@users.noreply.github.com> Date: Sat, 4 Apr 2026 12:34:52 +0200 Subject: [PATCH 4/5] refact: add backendError.js with hints --- backend/pkg/logger/trace/trace.go | 4 +- electron-app/README.md | 2 +- electron-app/src/processes/backend.js | 29 +------ electron-app/src/processes/backendError.js | 93 ++++++++++++++++++++++ 4 files changed, 99 insertions(+), 29 deletions(-) create mode 100644 electron-app/src/processes/backendError.js diff --git a/backend/pkg/logger/trace/trace.go b/backend/pkg/logger/trace/trace.go index 0c36f21c6..21ce9d18b 100644 --- a/backend/pkg/logger/trace/trace.go +++ b/backend/pkg/logger/trace/trace.go @@ -69,11 +69,11 @@ func InitTrace(traceLevel string) *os.File { // Human-friendly console writer that prints logs to stdout. consoleWriter := zerolog.ConsoleWriter{Out: os.Stdout} - // Console writer that prints warn/error/fatal logs to stderr so the + // Console writer that prints fatal logs to stderr so the // parent process (Electron) can capture them via the stderr pipe. stderrConsoleWriter := &levelFilterWriter{ w: zerolog.ConsoleWriter{Out: os.Stderr}, - minLevel: zerolog.WarnLevel, + minLevel: zerolog.FatalLevel, } // Try to create/open the file for writing logs. On failure, fall back to console only and exit. diff --git a/electron-app/README.md b/electron-app/README.md index 7034c163e..d4e30bf0d 100644 --- a/electron-app/README.md +++ b/electron-app/README.md @@ -32,7 +32,7 @@ When running in development mode (unpackaged), the application creates temporary - **Configuration and Logs**: Stored in `{UserConfigDir}/hyperloop-control-station/` (using Go's `os.UserConfigDir()`) - Config files and backups: `{UserConfigDir}/hyperloop-control-station/configs/` - - Trace/log files: `{UserConfigDir}/hyperloop-control-station/trace-*.json` + - Trace/log files: `{UserConfigDir}/hyperloop-control-station/configs/trace-*.json` - **ADJ Module**: Stored in `{UserCacheDir}/hyperloop-control-station/adj/` (using Go's `os.UserCacheDir()`) diff --git a/electron-app/src/processes/backend.js b/electron-app/src/processes/backend.js index 8be49dc35..b021edfdc 100644 --- a/electron-app/src/processes/backend.js +++ b/electron-app/src/processes/backend.js @@ -15,6 +15,7 @@ import { getBinaryPath, getUserConfigPath, } from "../utils/paths.js"; +import { formatBackendError, getHint } from "./backendError.js"; // Create ANSI to HTML converter const convert = new AnsiToHtml(); @@ -31,27 +32,6 @@ let storedLogWindow = null; // Store error messages accumulated from the current process run let lastBackendError = null; -const ERROR_HINTS = [ - { - pattern: /bind: The requested address is not valid/, - message: "Network address unavailable", - advice: - "The configured IP address doesn't exist on this machine. Check your network adapter or ADJ.", - }, - { - pattern: /failed to start UDP server/, - message: "UDP server failed to start", - advice: "Another process may already be using this port.", - }, -]; - -function getHint(errorText) { - const match = ERROR_HINTS.find(({ pattern }) => pattern.test(errorText)); - return match - ? `${match.message}\n\n${match.advice}\n\n${errorText}` - : errorText; -} - /** * Starts the backend process by spawning the backend binary with the user configuration. * @returns {void} @@ -94,9 +74,6 @@ async function startBackend(logWindow = null) { cwd: workingDir, }); - console.log("[DEBUG] backendProcess.stderr:", backendProcess.stderr); - console.log("[DEBUG] backendProcess.stdout:", backendProcess.stdout); - // Log stdout output from backend backendProcess.stdout.on("data", (data) => { const text = data.toString().trim(); @@ -125,7 +102,6 @@ async function startBackend(logWindow = null) { backendProcess.stderr.on("data", (data) => { const errorMsg = data.toString().trim(); logger.backend.error(errorMsg); - console.log("[DEBUG stderr chunk]", JSON.stringify(errorMsg)); lastBackendError = errorMsg; // Send error message to log window @@ -155,7 +131,8 @@ async function startBackend(logWindow = null) { if (lastBackendError) { const stripped = lastBackendError.replace(/\x1b\[[0-9;]*m/g, ""); - errorMessage += `\n\n${getHint(stripped)}`; + const formatted = formatBackendError(stripped); + errorMessage += `\n\n${getHint(stripped, formatted)}`; } else { errorMessage += "\n\n(No error output captured)"; } diff --git a/electron-app/src/processes/backendError.js b/electron-app/src/processes/backendError.js new file mode 100644 index 000000000..a75a49fe4 --- /dev/null +++ b/electron-app/src/processes/backendError.js @@ -0,0 +1,93 @@ +/** + * @module processes + * @description Error formatting and hint utilities for backend crash diagnostics. + * Parses zerolog console output, strips ANSI codes, and maps known error patterns + * to actionable user-facing messages shown in the crash dialog. + */ + +/** + * List of known error patterns with human-readable messages and fix advice. + * Each entry is matched against the raw stripped stderr output. + */ +const ERROR_HINTS = [ + { + pattern: /bind: The requested address is not valid/, + message: "Network address unavailable", + advice: + "The configured IP address doesn't exist on this machine. Check your network adapter or ADJ.", + }, + { + pattern: /failed to start UDP server/, + message: "UDP server failed to start", + advice: "Another process may already be using this port.", + }, + { + pattern: /jsonschema/, + message: "ADJ Validator dependency missing", + advice: + "Install the required Python package by running: pip install jsonschema==4.25.0", + }, + { + pattern: /No Python interpreter found/, + message: "Python not found", + advice: + "Install Python 3 and make sure it is accessible via 'python3', 'python', or 'py' in your PATH.", + }, + { + pattern: /ADJ Validator failed with error/, + message: "ADJ validation failed", + advice: + "Your ADJ files contain schema errors. Check the ADJ validator log file in the logs folder for details.", + }, +]; + +/** + * Reformats a single stripped zerolog console line into a readable block. + * Zerolog console format: "TIME LEVEL FILE > message key=value ..." + * @param {string} line - A single log line with ANSI codes already stripped. + * @returns {string} A formatted multi-line string with level, file, and key-value pairs on separate lines. + * @example + * formatLine("11:43AM FTL setup_transport.go:143 > failed to start UDP server error=\"some error\""); + * // "[FTL] at setup_transport.go:143\n failed to start UDP server\n error: \"some error\"" + */ +function formatLine(line) { + const m = line.match(/^\S+\s+(\S+)\s+(\S+)\s+>\s+(.*)/); + if (!m) return line; + const [, level, file, rest] = m; + const body = rest.replace( + /\s+(\w+)=("(?:[^"\\]|\\.)*"|\S+)/g, + "\n $1: $2", + ); + return `[${level}] at ${file}\n ${body.trim()}`; +} + +/** + * Formats a full multi-line stderr output by reformatting each zerolog line. + * @param {string} text - Raw stderr text with ANSI codes already stripped. + * @returns {string} Formatted text with each log line reformatted for readability. + * @example + * formatBackendError("11:43AM FTL file.go:10 > something failed error=\"bad\""); + */ +function formatBackendError(text) { + return text.split("\n").filter(Boolean).map(formatLine).join("\n\n"); +} + +/** + * Returns a user-facing error message by matching the raw error against known patterns. + * If a match is found, prepends a hint and advice to the formatted error. + * Falls back to the formatted error text if no pattern matches. + * @param {string} raw - Raw stripped stderr text used for pattern matching. + * @param {string} formatted - Pre-formatted version of the error for display. + * @returns {string} The final message to show in the crash dialog. + * @example + * getHint("failed to start UDP server ...", "[FTL] at ..."); + * // "UDP server failed to start\n\nAnother process may already be using this port.\n\n[FTL] at ..." + */ +function getHint(raw, formatted) { + const match = ERROR_HINTS.find(({ pattern }) => pattern.test(raw)); + return match + ? `${match.message}\n\n${match.advice}\n\n${formatted}` + : formatted; +} + +export { formatBackendError, getHint }; From 010e99b8871d92871621f47caa5affeca04b9c72 Mon Sep 17 00:00:00 2001 From: Maxim <74974283+maximka76667@users.noreply.github.com> Date: Sat, 4 Apr 2026 12:55:44 +0200 Subject: [PATCH 5/5] feat: add more error hints --- electron-app/src/processes/backendError.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/electron-app/src/processes/backendError.js b/electron-app/src/processes/backendError.js index a75a49fe4..01df228ac 100644 --- a/electron-app/src/processes/backendError.js +++ b/electron-app/src/processes/backendError.js @@ -39,6 +39,24 @@ const ERROR_HINTS = [ advice: "Your ADJ files contain schema errors. Check the ADJ validator log file in the logs folder for details.", }, + { + pattern: /error reading config file/, + message: "Config file not found", + advice: + "The configuration file could not be read. Check that the config file path is correct and the file exists.", + }, + { + pattern: /error unmarshaling toml file/, + message: "Config file has errors", + advice: + "The configuration file contains invalid TOML. Check the config file for syntax or type errors.", + }, + { + pattern: /setting up ADJ/, + message: "ADJ not available", + advice: + "Could not load the ADJ. If this is your first run, connect to the internet so the ADJ can be downloaded.", + }, ]; /**