From f4d5e74c59ebaff917aa39ceede4f425efe3aefd Mon Sep 17 00:00:00 2001 From: Christian <80114847+TheBest6337@users.noreply.github.com> Date: Wed, 15 Oct 2025 16:55:06 +0200 Subject: [PATCH] Add update step tracking and progress bar component --- electron/src/components/UpdateProgressBar.tsx | 137 ++++++++ .../src/helpers/ipc/update/update-channels.ts | 1 + .../src/helpers/ipc/update/update-context.ts | 22 ++ .../helpers/ipc/update/update-listeners.ts | 294 +++++++++++++++++- electron/src/helpers/update_helpers.ts | 25 ++ electron/src/setup/UpdateExecutePage.tsx | 14 + electron/src/stores/updateStore.ts | 134 ++++++++ electron/src/types.d.ts | 7 + 8 files changed, 631 insertions(+), 3 deletions(-) create mode 100644 electron/src/components/UpdateProgressBar.tsx diff --git a/electron/src/components/UpdateProgressBar.tsx b/electron/src/components/UpdateProgressBar.tsx new file mode 100644 index 00000000..55fc0c0e --- /dev/null +++ b/electron/src/components/UpdateProgressBar.tsx @@ -0,0 +1,137 @@ +import React from "react"; +import { Icon } from "./Icon"; +import { cn } from "@/lib/utils"; +import type { UpdateStep } from "@/stores/updateStore"; + +interface UpdateProgressBarProps { + steps: UpdateStep[]; + overallProgress: number; + className?: string; +} + +export function UpdateProgressBar({ + steps, + overallProgress, + className, +}: UpdateProgressBarProps) { + return ( +
+ {/* Overall Progress Bar */} +
+
+ Overall Progress + + {overallProgress}% + +
+
+
+
+
+ + {/* Steps List */} +
+ {steps.map((step) => ( +
+ {/* Status Icon */} +
+ {step.status === "pending" && ( +
+ )} + {step.status === "in-progress" && ( + + )} + {step.status === "completed" && ( +
+ +
+ )} + {step.status === "error" && ( +
+ +
+ )} +
+ + {/* Step Content */} +
+
+
+ + {step.label} + + {/* Subsector Badge - minimal inline display */} + + ( + {step.id === "rust-build" + ? "rust & electron" + : step.subsector} + ) + +
+ {/* Progress percentage for in-progress steps */} + {step.status === "in-progress" && + step.progress !== undefined && + step.progress > 0 && ( + + {Math.round(step.progress)}% + + )} +
+ + {/* Progress bar for steps with detailed tracking */} + {step.status === "in-progress" && + step.progress !== undefined && + step.progress > 0 && ( +
+
+
+
+
+ )} +
+
+ ))} +
+
+ ); +} diff --git a/electron/src/helpers/ipc/update/update-channels.ts b/electron/src/helpers/ipc/update/update-channels.ts index 8bae21ab..7043c022 100644 --- a/electron/src/helpers/ipc/update/update-channels.ts +++ b/electron/src/helpers/ipc/update/update-channels.ts @@ -2,3 +2,4 @@ export const UPDATE_EXECUTE = "update:execute"; export const UPDATE_LOG = "update:log"; export const UPDATE_END = "update:end"; export const UPDATE_CANCEL = "update:cancel"; +export const UPDATE_STEP = "update:step"; diff --git a/electron/src/helpers/ipc/update/update-context.ts b/electron/src/helpers/ipc/update/update-context.ts index 54b8891a..b5657876 100644 --- a/electron/src/helpers/ipc/update/update-context.ts +++ b/electron/src/helpers/ipc/update/update-context.ts @@ -3,6 +3,7 @@ import { UPDATE_END, UPDATE_EXECUTE, UPDATE_LOG, + UPDATE_STEP, } from "./update-channels"; type UpdateExecuteInvokeParams = { @@ -16,11 +17,20 @@ type UpdateExecuteInvokeParams = { }; }; +type UpdateStepParams = { + stepId: string; + status: "pending" | "in-progress" | "completed" | "error"; + progress?: number; +}; + export function exposeUpdateContext() { const { contextBridge, ipcRenderer } = window.require("electron"); let currentLogListener: ((event: any, log: string) => void) | null = null; let currentEndListener: ((event: any, params: any) => void) | null = null; + let currentStepListener: + | ((event: any, params: UpdateStepParams) => void) + | null = null; contextBridge.exposeInMainWorld("update", { execute: (params: UpdateExecuteInvokeParams) => @@ -52,5 +62,17 @@ export function exposeUpdateContext() { ipcRenderer.on(UPDATE_END, currentEndListener); }, + + onStep: (callback: (params: UpdateStepParams) => void) => { + if (currentStepListener) { + ipcRenderer.removeListener(UPDATE_STEP, currentStepListener); + } + + currentStepListener = (_event, params) => { + callback(params); + }; + + ipcRenderer.on(UPDATE_STEP, currentStepListener); + }, }); } diff --git a/electron/src/helpers/ipc/update/update-listeners.ts b/electron/src/helpers/ipc/update/update-listeners.ts index da6cf374..32ab0745 100644 --- a/electron/src/helpers/ipc/update/update-listeners.ts +++ b/electron/src/helpers/ipc/update/update-listeners.ts @@ -4,6 +4,7 @@ import { UPDATE_END, UPDATE_EXECUTE, UPDATE_LOG, + UPDATE_STEP, } from "./update-channels"; import { spawn, ChildProcess } from "child_process"; import tkill from "@jub3i/tree-kill"; @@ -116,6 +117,13 @@ async function update( commit, }); + // Reset Rust build progress tracking + rustBuildProgress = { + totalDerivations: 0, + builtDerivations: 0, + maxPercent: 0, + }; + const qitechControlEnv = process.env.QITECH_CONTROL_ENV; const homeDir = qitechControlEnv === "control-os" ? "/home/qitech" : process.env.HOME; @@ -129,6 +137,8 @@ async function update( // 1. first make sure the clone path is empty by deleting it if it containsa .git folder const repoDir = `${homeDir}/${githubRepoName}`; + + // Clear repo (not tracked in progress UI) const clearResult = await clearRepoDirectory( `${homeDir}/${githubRepoName}`, event, @@ -139,6 +149,10 @@ async function update( } // 2. clone the repository + event.sender.send(UPDATE_STEP, { + stepId: "clone-repo", + status: "in-progress", + }); const cloneResult = await cloneRepository( { githubRepoOwner, @@ -151,11 +165,19 @@ async function update( event, ); if (!cloneResult.success) { + event.sender.send(UPDATE_STEP, { + stepId: "clone-repo", + status: "error", + }); event.sender.send(UPDATE_LOG, cloneResult.error); return; } + event.sender.send(UPDATE_STEP, { + stepId: "clone-repo", + status: "completed", + }); - // 3. make the nixos-install.sh script executable + // 3. make the nixos-install.sh script executable (not tracked in progress UI) const chmodResult = await runCommand( "chmod", ["+x", "nixos-install.sh"], @@ -168,17 +190,35 @@ async function update( } // 4. run the nixos-install.sh script - const installResult = await runCommand( + // This script will handle rust-build, electron-build, and system-install + // Start with rust-build (cargo builds) + event.sender.send(UPDATE_STEP, { + stepId: "rust-build", + status: "in-progress", + }); + + const installResult = await runCommandWithStepTracking( "./nixos-install.sh", [], repoDir, event, ); + if (!installResult.success) { + // Mark current and remaining steps as error + event.sender.send(UPDATE_STEP, { + stepId: "rust-build", + status: "error", + }); + event.sender.send(UPDATE_STEP, { + stepId: "system-install", + status: "error", + }); event.sender.send(UPDATE_LOG, installResult.error); return; } + // Success - steps are already marked as completed by runCommandWithStepTracking resolve(); } catch (error: any) { reject(error); @@ -244,7 +284,7 @@ async function cloneRepository( : `https://github.com/${githubRepoOwner}/${githubRepoName}.git`; // Determine clone arguments based on whether tag, branch, or commit is specified - const cloneArgs = ["clone", repoUrl]; + const cloneArgs = ["clone", "--progress", repoUrl]; if (tag) { // Clone a specific tag @@ -333,6 +373,11 @@ async function runCommand( const log = data.toString(); console.error(log); event.sender.send(UPDATE_LOG, log); + + // Git outputs progress to stderr + if (cmd === "git" && args.includes("--progress")) { + parseGitProgress(log, event); + } }); // Handle process completion @@ -386,6 +431,249 @@ async function runCommand( } } +// Parse Git clone progress +function parseGitProgress( + log: string, + event: Electron.IpcMainInvokeEvent, +): void { + // Git progress format: "Receiving objects: 45% (234/520)" + // or "Resolving deltas: 100% (150/150)" + const receivingMatch = log.match(/Receiving objects:\s*(\d+)%/); + const resolvingMatch = log.match(/Resolving deltas:\s*(\d+)%/); + + if (receivingMatch) { + const percent = parseInt(receivingMatch[1], 10); + event.sender.send(UPDATE_STEP, { + stepId: "clone-repo", + status: "in-progress", + progress: Math.floor(percent * 0.8), // Receiving is 80% of clone + }); + } else if (resolvingMatch) { + const percent = parseInt(resolvingMatch[1], 10); + event.sender.send(UPDATE_STEP, { + stepId: "clone-repo", + status: "in-progress", + progress: Math.floor(80 + percent * 0.2), // Resolving is last 20% + }); + } +} + +// Track Rust build progress +let rustBuildProgress = { + totalDerivations: 0, + builtDerivations: 0, + maxPercent: 0, // Track max to prevent backward movement +}; + +// Parse Rust build output for progress +function parseRustBuildOutput( + log: string, + event: Electron.IpcMainInvokeEvent, +): void { + // Track derivations to build + const derivationsMatch = log.match(/these (\d+) derivations? will be built/i); + if (derivationsMatch) { + rustBuildProgress.totalDerivations = parseInt(derivationsMatch[1], 10); + rustBuildProgress.builtDerivations = 0; + rustBuildProgress.maxPercent = 0; + event.sender.send(UPDATE_STEP, { + stepId: "rust-build", + status: "in-progress", + progress: 0, + }); + return; + } + + // Track building packages + if ( + log.includes("building '/nix/store/") || + log.includes("building /nix/store/") + ) { + rustBuildProgress.builtDerivations++; + + // Check if this is the server-deps package (one of the last builds) + const isServerDeps = log.includes("-server-deps"); + + let percent = 15; + if (isServerDeps) { + // server-deps indicates we're at 85% + percent = 85; + } else if (rustBuildProgress.totalDerivations > 0) { + const derivationProgress = + rustBuildProgress.builtDerivations / rustBuildProgress.totalDerivations; + percent = 15 + Math.floor(derivationProgress * 70); // Map to 15-85% + } + + // Only move forward + percent = Math.max(percent, rustBuildProgress.maxPercent); + rustBuildProgress.maxPercent = percent; + + event.sender.send(UPDATE_STEP, { + stepId: "rust-build", + status: "in-progress", + progress: percent, + }); + } + + // Track installing phase - go up to 90% + if (log.includes("installing") || log.includes("Installing")) { + const percent = Math.max(90, rustBuildProgress.maxPercent); + rustBuildProgress.maxPercent = percent; + event.sender.send(UPDATE_STEP, { + stepId: "rust-build", + status: "in-progress", + progress: percent, + }); + } +} + +// Enhanced version of runCommand that tracks build steps based on log output +async function runCommandWithStepTracking( + cmd: string, + args: string[], + workingDir: string, + event: Electron.IpcMainInvokeEvent, +): Promise<{ success: boolean; error?: string }> { + try { + const completeCommand = `${cmd} ${args.join(" ")}`; + const workingDirText = terminalGray(workingDir); + event.sender.send( + UPDATE_LOG, + `🚀 ${workingDirText} ${terminalColor("blue", completeCommand)}`, + ); + + const childProcess = spawn(cmd, args, { + cwd: workingDir, + }); + + // Store reference to current process for cancellation + currentUpdateProcess = childProcess; + + // Track which steps have been marked as in-progress + // Note: rust-build is already marked as in-progress before calling this function + let systemInstallStarted = false; + + // Function to process log output and update steps + const processLogForSteps = (log: string) => { + const logLower = log.toLowerCase(); + + // Parse Rust build progress + parseRustBuildOutput(log, event); + + // Check for system install indicators + // System install happens after Rust build completes (at ~90%) + // Look for bootloader/activation messages that indicate final system installation + if ( + !systemInstallStarted && + rustBuildProgress.maxPercent >= 90 && + (logLower.includes("updating grub") || + logLower.includes("installing bootloader") || + logLower.includes("updating bootloader") || + logLower.includes("activating the configuration") || + logLower.includes("building the system configuration") || + logLower.includes("these 0 derivations")) + ) { + console.log("🟢 Detected system install start:", log.substring(0, 100)); + // Mark rust as complete, start system install + event.sender.send(UPDATE_STEP, { + stepId: "rust-build", + status: "completed", + }); + event.sender.send(UPDATE_STEP, { + stepId: "system-install", + status: "in-progress", + }); + systemInstallStarted = true; + } + }; + + // Stream stdout logs back to renderer + childProcess.stdout.on("data", (data) => { + const log = data.toString(); + console.log(log); + event.sender.send(UPDATE_LOG, log); + processLogForSteps(log); + }); + + // Stream stderr logs back to renderer + childProcess.stderr.on("data", (data) => { + const log = data.toString(); + console.error(log); + event.sender.send(UPDATE_LOG, log); + processLogForSteps(log); + }); + + // Handle process completion + return new Promise((resolve, reject) => { + childProcess.on("close", (code, signal) => { + // Clear process reference when completed + if (currentUpdateProcess === childProcess) { + currentUpdateProcess = null; + } + + if (signal === "SIGTERM" || signal === "SIGKILL") { + event.sender.send(UPDATE_LOG, terminalInfo("Command was cancelled")); + reject({ + success: false, + error: "Command was cancelled", + }); + } else if (code === 0) { + // Mark all remaining steps as completed on success + if (!systemInstallStarted) { + event.sender.send(UPDATE_STEP, { + stepId: "rust-build", + status: "completed", + }); + event.sender.send(UPDATE_STEP, { + stepId: "system-install", + status: "completed", + }); + } else { + event.sender.send(UPDATE_STEP, { + stepId: "system-install", + status: "completed", + }); + } + + event.sender.send( + UPDATE_LOG, + terminalSuccess("Command completed successfully"), + ); + resolve({ success: true, error: undefined }); + } else { + event.sender.send( + UPDATE_LOG, + terminalError(`Command failed with code ${code}`), + ); + reject({ + success: false, + error: terminalError(code?.toString() ?? "NO_CODE"), + }); + } + }); + + childProcess.on("error", (err) => { + // Clear process reference on error + if (currentUpdateProcess === childProcess) { + currentUpdateProcess = null; + } + + event.sender.send( + UPDATE_LOG, + terminalError(`Command error: ${err.message}`), + ); + reject({ + success: false, + error: terminalError(err.message), + }); + }); + }); + } catch (error: any) { + event.sender.send(UPDATE_LOG, terminalError(`Error: ${error.toString()}`)); + return { success: false, error: error.toString() }; + } +} + function terminalColor( color: "blue" | "green" | "red" | "cyan" | "gray", text: string, diff --git a/electron/src/helpers/update_helpers.ts b/electron/src/helpers/update_helpers.ts index 9716781e..cfe5df6c 100644 --- a/electron/src/helpers/update_helpers.ts +++ b/electron/src/helpers/update_helpers.ts @@ -2,6 +2,13 @@ import type { UpdateInfo } from "@/stores/updateStore"; import { useUpdateStore } from "@/stores/updateStore"; let currentLogListener: ((line: string) => void) | null = null; +let currentStepListener: + | ((params: { + stepId: string; + status: "pending" | "in-progress" | "completed" | "error"; + progress?: number; + }) => void) + | null = null; export async function updateExecute( source: UpdateInfo, @@ -14,15 +21,32 @@ export async function updateExecute( currentLogListener = null; } + if (currentStepListener) { + window.update.onStep(() => {}); // clear previous + currentStepListener = null; + } + currentLogListener = onLog; window.update.onLog(currentLogListener); + // Set up step listener + currentStepListener = (params) => { + const { setStepStatus, setStepProgress } = useUpdateStore.getState(); + setStepStatus(params.stepId, params.status); + if (params.progress !== undefined) { + setStepProgress(params.stepId, params.progress); + } + }; + window.update.onStep(currentStepListener); + window.update.execute(source); window.update.onEnd((params) => { window.update.onLog(() => {}); // remove listener window.update.onEnd(() => {}); + window.update.onStep(() => {}); // remove step listener currentLogListener = null; + currentStepListener = null; resolve(params); }); }); @@ -55,6 +79,7 @@ export async function updateCancel(): Promise<{ const result = await window.update.cancel(); window.update.onLog(() => {}); // remove listener window.update.onEnd(() => {}); // remove listener + window.update.onStep(() => {}); // remove step listener return result; } diff --git a/electron/src/setup/UpdateExecutePage.tsx b/electron/src/setup/UpdateExecutePage.tsx index af6cb264..2d167c1b 100644 --- a/electron/src/setup/UpdateExecutePage.tsx +++ b/electron/src/setup/UpdateExecutePage.tsx @@ -3,6 +3,7 @@ import { Page } from "@/components/Page"; import { SectionTitle } from "@/components/SectionTitle"; import { Terminal } from "@/components/Terminal"; import { TouchButton } from "@/components/touch/TouchButton"; +import { UpdateProgressBar } from "@/components/UpdateProgressBar"; import { updateExecute, updateCancelWithStore } from "@/helpers/update_helpers"; import { useUpdateStore } from "@/stores/updateStore"; import { useSearch } from "@tanstack/react-router"; @@ -18,12 +19,15 @@ export function UpdateExecutePage() { isUpdating, terminalLines, currentUpdateInfo, + steps, + overallProgress, setUpdateInfo, startUpdate, stopUpdate, addTerminalLine, clearTerminalLines, resetUpdateState, + initializeSteps, } = useUpdateStore(); // Set update info from search params when component mounts or search changes @@ -50,6 +54,7 @@ export function UpdateExecutePage() { commit: search.commit, }; + initializeSteps(); startUpdate(); // Perhaps we just need to clear the logs ? const res = await updateExecute(updateInfo, addTerminalLine); @@ -138,6 +143,15 @@ export function UpdateExecutePage() { )} + {/* Progress Bar */} + {isUpdating && ( + + )} + void; clearTerminalLines: () => void; resetUpdateState: () => void; + setStepStatus: (stepId: string, status: UpdateStep["status"]) => void; + setStepProgress: (stepId: string, progress: number) => void; + initializeSteps: () => void; + updateProgress: (progress: number) => void; }; export type UpdateStore = UpdateState & UpdateActions; +const defaultSteps: UpdateStep[] = [ + { + id: "clone-repo", + label: "Clone repository", + status: "pending", + subsector: "general", + progress: 0, + }, + { + id: "rust-build", + label: "Build system packages", + status: "pending", + subsector: "rust", + progress: 0, + }, + { + id: "system-install", + label: "Install system update", + status: "pending", + subsector: "nixos", + progress: 0, + }, +]; + +// Helper function to calculate overall progress based on step weights and individual progress +const calculateOverallProgress = (steps: UpdateStep[]): number => { + // Define weights for each step (should sum to 100) + const stepWeights: Record = { + "clone-repo": 10, // 10% for cloning + "rust-build": 80, // 80% for building (goes up to 90% internally) + "system-install": 10, // 10% for system installation + }; + + let totalProgress = 0; + + steps.forEach((step) => { + const weight = stepWeights[step.id] || 0; + + if (step.status === "completed") { + // Completed steps contribute their full weight + totalProgress += weight; + } else if (step.status === "in-progress") { + // In-progress steps: use progress value or 0 if not yet set + const stepProgress = step.progress ?? 0; + + if (step.id === "rust-build") { + // Rust build progress is 0-90%, map it to 0-100% of its weight + totalProgress += (stepProgress / 90) * weight; + } else { + // Other steps: progress is 0-100% + totalProgress += (stepProgress / 100) * weight; + } + } + // Pending and error steps contribute 0 + }); + + return Math.round(Math.min(100, Math.max(0, totalProgress))); +}; + const initialState: UpdateState = { isUpdating: false, terminalLines: [], currentUpdateInfo: null, + steps: defaultSteps, + currentStepIndex: 0, + overallProgress: 0, }; export const useUpdateStore = create((set) => ({ @@ -105,6 +182,63 @@ export const useUpdateStore = create((set) => ({ state.isUpdating = false; state.terminalLines = []; state.currentUpdateInfo = null; + state.steps = defaultSteps.map((step) => ({ ...step })); + state.currentStepIndex = 0; + state.overallProgress = 0; + }), + ), + + initializeSteps: () => + set( + produce((state: UpdateState) => { + state.steps = defaultSteps.map((step) => ({ ...step })); + state.currentStepIndex = 0; + state.overallProgress = 0; + }), + ), + + setStepStatus: (stepId: string, status: UpdateStep["status"]) => + set( + produce((state: UpdateState) => { + const stepIndex = state.steps.findIndex((s) => s.id === stepId); + if (stepIndex !== -1) { + state.steps[stepIndex].status = status; + + // Update current step index and ensure progress is initialized + if (status === "in-progress") { + state.currentStepIndex = stepIndex; + // Initialize progress to 0 if not already set + if (state.steps[stepIndex].progress === undefined) { + state.steps[stepIndex].progress = 0; + } + } + + // Calculate overall progress based on step weights and progress + state.overallProgress = calculateOverallProgress(state.steps); + } + }), + ), + + setStepProgress: (stepId: string, progress: number) => + set( + produce((state: UpdateState) => { + const stepIndex = state.steps.findIndex((s) => s.id === stepId); + if (stepIndex !== -1) { + state.steps[stepIndex].progress = Math.min( + 100, + Math.max(0, progress), + ); + + // Recalculate overall progress when step progress changes + state.overallProgress = calculateOverallProgress(state.steps); + } + }), + ), + + updateProgress: (progress: number) => + set( + produce((state: UpdateState) => { + state.overallProgress = Math.min(100, Math.max(0, progress)); }), ), })); diff --git a/electron/src/types.d.ts b/electron/src/types.d.ts index 48bf7733..f683fdc0 100644 --- a/electron/src/types.d.ts +++ b/electron/src/types.d.ts @@ -46,6 +46,13 @@ interface UpdateContext { onEnd: ( callback: (params: { success: boolean; error?: string }) => void, ) => void; + onStep: ( + callback: (params: { + stepId: string; + status: "pending" | "in-progress" | "completed" | "error"; + progress?: number; + }) => void, + ) => void; } interface TroubleshootContext {