Feat: Add live log monitoring tab to UI

Introduces a new "Monitor" tab in the `BuildFFUVM` UI to display live log output from the build process.

When a build is started, the UI now automatically switches to the Monitor tab. It tails the main log file in real-time and displays the content in a list view, which auto-scrolls as new entries appear.

This provides immediate visual feedback on the build progress and any errors without needing to manually open the log file.

Additionally, this change adds a Ctrl+C keyboard shortcut to copy selected log lines from the monitor view to the clipboard.
This commit is contained in:
rbalsleyMSFT
2025-07-10 19:56:59 -07:00
parent ebbb3e8ed0
commit 7043af47c3
5 changed files with 106 additions and 13 deletions
+1 -1
View File
@@ -421,7 +421,7 @@ Import-Module "$PSScriptRoot\FFU.Common" -Force
# Set the module's verbose preference to match the script's - allows logging verbose output to console. # Set the module's verbose preference to match the script's - allows logging verbose output to console.
$moduleInfo = Get-Module -Name 'FFU.Common' $moduleInfo = Get-Module -Name 'FFU.Common'
if ($moduleInfo) { if ($moduleInfo) {
& $moduleInfo { $script:VerbosePreference = $args[0] } $VerbosePreference & $moduleInfo { $script:VerbosePreference = $args[0] } $VerbosePreference | Out-Null
} }
# If a config file is specified and it exists, load it # If a config file is specified and it exists, load it
+67 -11
View File
@@ -22,7 +22,10 @@ $script:uiState = [PSCustomObject]@{
allDriverModels = [System.Collections.Generic.List[PSCustomObject]]::new(); allDriverModels = [System.Collections.Generic.List[PSCustomObject]]::new();
appsScriptVariablesDataList = [System.Collections.Generic.List[PSCustomObject]]::new(); appsScriptVariablesDataList = [System.Collections.Generic.List[PSCustomObject]]::new();
versionData = $null; versionData = $null;
vmSwitchMap = @{} vmSwitchMap = @{};
logData = $null;
logStreamReader = $null;
pollTimer = $null
}; };
Flags = @{ Flags = @{
installAppsForcedByUpdates = $false; installAppsForcedByUpdates = $false;
@@ -36,8 +39,8 @@ $script:uiState = [PSCustomObject]@{
} }
# Remove any existing modules to avoid conflicts # Remove any existing modules to avoid conflicts
if (Get-Module -Name 'FFU.Common.Core' -ErrorAction SilentlyContinue) { if (Get-Module -Name 'FFU.Common' -ErrorAction SilentlyContinue) {
Remove-Module -Name 'FFU.Common.Core' -Force Remove-Module -Name 'FFU.Common' -Force
} }
if (Get-Module -Name 'FFUUI.Core' -ErrorAction SilentlyContinue) { if (Get-Module -Name 'FFUUI.Core' -ErrorAction SilentlyContinue) {
Remove-Module -Name 'FFUUI.Core' -Force Remove-Module -Name 'FFUUI.Core' -Force
@@ -114,6 +117,14 @@ $script:uiState.Controls.btnRun.Add_Click({
# Disable button to prevent multiple clicks # Disable button to prevent multiple clicks
$btnRun.IsEnabled = $false $btnRun.IsEnabled = $false
# Switch to Monitor Tab
$script:uiState.Controls.MainTabControl.SelectedItem = $script:uiState.Controls.MonitorTab
# Clear previous log data
if ($null -ne $script:uiState.Data.logData) {
$script:uiState.Data.logData.Clear()
}
$progressBar = $script:uiState.Controls.pbOverallProgress $progressBar = $script:uiState.Controls.pbOverallProgress
$txtStatus = $script:uiState.Controls.txtStatus $txtStatus = $script:uiState.Controls.txtStatus
$progressBar.Visibility = 'Visible' $progressBar.Visibility = 'Visible'
@@ -151,25 +162,63 @@ $script:uiState.Controls.btnRun.Add_Click({
# Start the job and store it in the shared state object # Start the job and store it in the shared state object
$script:uiState.Data.currentBuildJob = Start-Job -ScriptBlock $scriptBlock -ArgumentList @($buildParams, $PSScriptRoot) $script:uiState.Data.currentBuildJob = Start-Job -ScriptBlock $scriptBlock -ArgumentList @($buildParams, $PSScriptRoot)
# Open a stream reader to the main log file
$mainLogPath = "$($config.FFUDevelopmentPath)\FFUDevelopment.log"
# Wait a moment for the file to be created by the new process
Start-Sleep -Seconds 1
if (Test-Path $mainLogPath) {
$fileStream = [System.IO.File]::Open($mainLogPath, 'Open', 'Read', 'ReadWrite')
$script:uiState.Data.logStreamReader = [System.IO.StreamReader]::new($fileStream)
}
else {
WriteLog "Warning: Main log file not found at $mainLogPath. Monitor tab will not update."
}
# Create a timer to poll the job status from the UI thread # Create a timer to poll the job status from the UI thread
$timer = New-Object System.Windows.Threading.DispatcherTimer $script:uiState.Data.pollTimer = New-Object System.Windows.Threading.DispatcherTimer
$timer.Interval = [TimeSpan]::FromSeconds(1) $script:uiState.Data.pollTimer.Interval = [TimeSpan]::FromSeconds(1)
# Add the Tick event handler # Add the Tick event handler
$timer.Add_Tick({ $script:uiState.Data.pollTimer.Add_Tick({
param($sender, $e)
# This scriptblock runs on the UI thread, so it can safely access script-scoped variables # This scriptblock runs on the UI thread, so it can safely access script-scoped variables
$currentJob = $script:uiState.Data.currentBuildJob $currentJob = $script:uiState.Data.currentBuildJob
# If job is somehow null, stop the timer # Read from log stream
if ($null -eq $currentJob) { if ($null -ne $script:uiState.Data.logStreamReader) {
$timer.Stop() while ($null -ne ($line = $script:uiState.Data.logStreamReader.ReadLine())) {
$script:uiState.Data.logData.Add($line)
# Auto-scroll to the new item
$script:uiState.Controls.lstLogOutput.ScrollIntoView($line)
}
}
# If job is somehow null or the timer has been nulled out, stop the timer
if ($null -eq $currentJob -or $null -eq $script:uiState.Data.pollTimer) {
if ($null -ne $sender) {
$sender.Stop()
}
$script:uiState.Data.pollTimer = $null
return return
} }
# Check if the job has reached a terminal state # Check if the job has reached a terminal state
if ($currentJob.State -in 'Completed', 'Failed', 'Stopped') { if ($currentJob.State -in 'Completed', 'Failed', 'Stopped') {
# Stop the timer, we're done polling # Stop the timer, we're done polling
$timer.Stop() if ($null -ne $sender) {
$sender.Stop()
}
$script:uiState.Data.pollTimer = $null
# Final read of the log stream
if ($null -ne $script:uiState.Data.logStreamReader) {
while ($null -ne ($line = $script:uiState.Data.logStreamReader.ReadLine())) {
$script:uiState.Data.logData.Add($line)
}
$script:uiState.Data.logStreamReader.Close()
$script:uiState.Data.logStreamReader.Dispose()
$script:uiState.Data.logStreamReader = $null
}
$finalStatusText = "FFU build completed successfully." $finalStatusText = "FFU build completed successfully."
if ($currentJob.State -eq 'Failed') { if ($currentJob.State -eq 'Failed') {
@@ -197,7 +246,7 @@ $script:uiState.Controls.btnRun.Add_Click({
}) })
# Start the timer # Start the timer
$timer.Start() $script:uiState.Data.pollTimer.Start()
} }
catch { catch {
# This catch block handles errors during the setup of the job (e.g., Get-UIConfig fails) # This catch block handles errors during the setup of the job (e.g., Get-UIConfig fails)
@@ -205,6 +254,13 @@ $script:uiState.Controls.btnRun.Add_Click({
WriteLog $errorMessage WriteLog $errorMessage
[System.Windows.MessageBox]::Show($errorMessage, "Error", "OK", "Error") [System.Windows.MessageBox]::Show($errorMessage, "Error", "OK", "Error")
# Clean up stream reader if it was opened
if ($null -ne $script:uiState.Data.logStreamReader) {
$script:uiState.Data.logStreamReader.Close()
$script:uiState.Data.logStreamReader.Dispose()
$script:uiState.Data.logStreamReader = $null
}
# Re-enable UI elements # Re-enable UI elements
$script:uiState.Controls.txtStatus.Text = "FFU build failed to start." $script:uiState.Controls.txtStatus.Text = "FFU build failed to start."
$script:uiState.Controls.pbOverallProgress.Visibility = 'Collapsed' $script:uiState.Controls.pbOverallProgress.Visibility = 'Collapsed'
+8 -1
View File
@@ -72,7 +72,7 @@
</Grid.RowDefinitions> </Grid.RowDefinitions>
<!-- TabControl with multiple tabs --> <!-- TabControl with multiple tabs -->
<TabControl TabStripPlacement="Left" VerticalAlignment="Stretch" HorizontalAlignment="Stretch" FontSize="14" Padding="10" Grid.Row="0"> <TabControl x:Name="MainTabControl" TabStripPlacement="Left" VerticalAlignment="Stretch" HorizontalAlignment="Stretch" FontSize="14" Padding="10" Grid.Row="0">
<!-- TAB: Home --> <!-- TAB: Home -->
<TabItem Header="Home" Padding="20"> <TabItem Header="Home" Padding="20">
<ScrollViewer VerticalScrollBarVisibility="Auto"> <ScrollViewer VerticalScrollBarVisibility="Auto">
@@ -794,6 +794,13 @@
</Grid> </Grid>
</ScrollViewer> </ScrollViewer>
</TabItem> </TabItem>
<!-- TAB: Monitor -->
<TabItem Header="Monitor" x:Name="MonitorTab" Padding="20">
<Grid Margin="10">
<ListBox x:Name="lstLogOutput" SelectionMode="Extended" VirtualizingStackPanel.IsVirtualizing="True" VirtualizingStackPanel.VirtualizationMode="Recycling" ScrollViewer.VerticalScrollBarVisibility="Auto" ScrollViewer.HorizontalScrollBarVisibility="Auto" HorizontalContentAlignment="Stretch"/>
</Grid>
</TabItem>
</TabControl> </TabControl>
<!-- Progress Bar --> <!-- Progress Bar -->
@@ -756,5 +756,27 @@ function Register-EventHandlers {
$localState = $window.Tag $localState = $window.Tag
Invoke-SaveConfiguration -State $localState Invoke-SaveConfiguration -State $localState
}) })
# Monitor Tab Event Handlers
$State.Controls.lstLogOutput.Add_KeyDown({
param($eventSource, $keyEventArgs)
# Check for Ctrl+C
if ($keyEventArgs.Key -eq 'C' -and ($keyEventArgs.KeyboardDevice.Modifiers -band [System.Windows.Input.ModifierKeys]::Control)) {
$listBox = $eventSource
if ($listBox.SelectedItems.Count -gt 0) {
$selectedLines = $listBox.SelectedItems | ForEach-Object { $_.ToString() }
$clipboardText = $selectedLines -join [System.Environment]::NewLine
try {
[System.Windows.Clipboard]::SetText($clipboardText)
WriteLog "Copied $($listBox.SelectedItems.Count) log lines to clipboard."
}
catch {
WriteLog "Error copying to clipboard: $($_.Exception.Message)"
}
}
$keyEventArgs.Handled = $true
}
})
} }
Export-ModuleMember -Function * Export-ModuleMember -Function *
@@ -150,6 +150,14 @@ function Initialize-UIControls {
$State.Controls.btnLoadConfig = $window.FindName('btnLoadConfig') $State.Controls.btnLoadConfig = $window.FindName('btnLoadConfig')
$State.Controls.btnBuildConfig = $window.FindName('btnBuildConfig') $State.Controls.btnBuildConfig = $window.FindName('btnBuildConfig')
# Monitor Tab
$State.Controls.MainTabControl = $window.FindName('MainTabControl')
$State.Controls.MonitorTab = $window.FindName('MonitorTab')
$State.Controls.lstLogOutput = $window.FindName('lstLogOutput')
# Initialize and bind the log data collection
$State.Data.logData = New-Object System.Collections.ObjectModel.ObservableCollection[string]
$State.Controls.lstLogOutput.ItemsSource = $State.Data.logData
} }
function Initialize-VMSwitchData { function Initialize-VMSwitchData {