synedgy.universal.helper.psm1
|
using namespace System.Net using namespace System.Collections #Region './Classes/00.Attributes/AiTool.ps1' -1 class AiTool : System.Attribute { # AiTool() attribute used to expose a function as a PowerShell Universal AI tool. # Mirrors the APIEndpoint() attribute pattern: decorate a public function with this # attribute, then call Import-PSUAiTool to discover and register it in PSU. [bool]$IsAiTool = $true # Marker indicating this is an AI tool attribute [string]$Name # Name of the AI tool. Defaults to the function name when not set. [string]$Description # Description of the AI tool. Defaults to the function's comment-based help synopsis. [bool]$Authenticated = $false # Whether the tool requires an authenticated user [string[]]$Role # Role(s) required to access/invoke the tool [bool]$Mcp = $true # Whether the tool is exposed through Model Context Protocol (MCP) [string]$Environment # PSU environment the backing script should run in AiTool () { # Default constructor for the attribute } } #EndRegion './Classes/00.Attributes/AiTool.ps1' 19 #Region './Classes/00.Attributes/APIEndpoint.ps1' -1 class APIEndpoint : System.Attribute { [bool]$IsEndpoint = $true # Indicates that this is an API endpoint attribute [bool]$AutoSerialization = $true # Whether the endpoint should automatically serialize the output to JSON [string]$Name [string]$ApiPrefix = 'api' # Prefix for the API endpoint URL [string]$Version = 'v1' # Version of the API endpoint [string]$Path # aka the URL path of the endpoint [string]$Description # Description of the endpoint [string]$Documentation # Documentation name the endpoint should be attached to [ValidateSet('GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD')] [string[]]$Method # HTTP method (GET, POST, PUT, DELETE, etc.) [bool]$Authentication = $false # Whether the endpoint requires authentication [string[]]$Role # Role required to access the endpoint [string]$Tag # Tag for categorizing the endpoint [int]$Timeout# Timeout for the endpoint in seconds [string]$Environment # Environment where the endpoint is executed (e.g. the PowerShell Universal environment) [string]$ContentType = 'application/json; charset=utf-8' # Content type of the response [ValidateSet('Information', 'Warning', 'Error', 'Verbose', 'Debug')] # TODO: Create the scriptlbock to add the -$loglevelAction Continue [string[]]$LogLevel = @('Information') # Log level for the endpoint [scriptblock]$Parameters # Override of parameters for the endpoint to splat to the command APIEndpoint () { # Default constructor for the attribute } } #EndRegion './Classes/00.Attributes/APIEndpoint.ps1' 28 #Region './Classes/00.Attributes/APIInput.ps1' -1 # using namespace System.Net class APIInput : System.Attribute { # APIInput() attribute used to specify type used for an API endpoint as input. [HttpStatusCode] $StatusCode = [HttpStatusCode]::OK [bool] $Required = $false [string] $Description [string] $Type [string] $ContentType = 'application/json' } #EndRegion './Classes/00.Attributes/APIInput.ps1' 12 #Region './Classes/00.Attributes/APIOutput.ps1' -1 # using namespace System.Net class APIOutput : System.Attribute { # APIOutput() attribute used to specify type used for an API endpoint as output. [HttpStatusCode] $StatusCode = [HttpStatusCode]::OK [string] $Description [string] $Type [string] $ContentType = 'application/json' } #EndRegion './Classes/00.Attributes/APIOutput.ps1' 12 #Region './Classes/00.Attributes/Documentation.ps1' -1 class Documentation : System.Attribute { # Documentation() attribute used to specify type used for an API endpoint as output or input. } #EndRegion './Classes/00.Attributes/Documentation.ps1' 5 #Region './Private/Convert-AnsiToHtml.ps1' -1 function Convert-AnsiToHtml { <# .SYNOPSIS Converts a string containing ANSI SGR escape sequences to HTML with inline span styles. .DESCRIPTION Parses ANSI SGR color and bold sequences and wraps affected text in <span> elements with inline CSS. Text segments without ANSI codes are passed through Convert-PlainTextToHtml. Accepts pipeline input for use in streaming pipelines. .PARAMETER Message The string potentially containing ANSI escape sequences. Accepts pipeline input. .EXAMPLE "$([char]27)[32mSuccess$([char]27)[0m: done" | Convert-AnsiToHtml # Returns: <span style='color:#66bb6a;'>Success</span>: done .EXAMPLE Get-PSUJobOutput -JobId 1 | Select-Object -Exp Message | Convert-AnsiToHtml #> [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [AllowEmptyString()] [string] $Message ) process { $ansiForegroundMap = @{ '30' = '#000000'; '31' = '#ef5350'; '32' = '#66bb6a'; '33' = '#ffee58' '34' = '#42a5f5'; '35' = '#ab47bc'; '36' = '#26c6da'; '37' = '#e0e0e0' '90' = '#9e9e9e'; '91' = '#e57373'; '92' = '#81c784'; '93' = '#fff176' '94' = '#64b5f6'; '95' = '#ba68c8'; '96' = '#4dd0e1'; '97' = '#ffffff' } $ansiBackgroundMap = @{ '40' = '#000000'; '41' = '#b71c1c'; '42' = '#1b5e20'; '43' = '#f57f17' '44' = '#0d47a1'; '45' = '#4a148c'; '46' = '#006064'; '47' = '#bdbdbd' '100' = '#616161'; '101' = '#ef9a9a'; '102' = '#a5d6a7'; '103' = '#fff59d' '104' = '#90caf9'; '105' = '#ce93d8'; '106' = '#80deea'; '107' = '#eeeeee' } $text = if ($null -eq $Message) { '' } else { [string]$Message } $ansiMatches = [regex]::Matches($text, '(?:\x1B|\u241B)\[([0-9;]*)m') if (@($ansiMatches).Count -eq 0) { return ($text | Convert-PlainTextToHtml) } $state = @{ Bold = $false; Foreground = $null; Background = $null } $fragments = [System.Collections.Generic.List[string]]::new() $currentIndex = 0 foreach ($match in $ansiMatches) { if ($match.Index -gt $currentIndex) { $segmentHtml = $text.Substring($currentIndex, $match.Index - $currentIndex) | Convert-PlainTextToHtml $styleParts = [System.Collections.Generic.List[string]]::new() if ($state.Bold) { $null = $styleParts.Add('font-weight:700') } if (-not [string]::IsNullOrWhiteSpace($state.Foreground)) { $null = $styleParts.Add('color:{0}' -f $state.Foreground) } if (-not [string]::IsNullOrWhiteSpace($state.Background)) { $null = $styleParts.Add('background-color:{0}' -f $state.Background) } $fragment = if ($styleParts.Count -eq 0) { $segmentHtml } else { "<span style='{0}'>{1}</span>" -f ($styleParts -join ';'), $segmentHtml } $null = $fragments.Add($fragment) } $codesRaw = $match.Groups[1].Value $codes = if ([string]::IsNullOrWhiteSpace($codesRaw)) { @('0') } else { @($codesRaw -split ';') } foreach ($code in $codes) { if ([string]::IsNullOrWhiteSpace($code)) { continue } switch ($code) { '0' { $state.Bold = $false; $state.Foreground = $null; $state.Background = $null; continue } '1' { $state.Bold = $true; continue } '22' { $state.Bold = $false; continue } '39' { $state.Foreground = $null; continue } '49' { $state.Background = $null; continue } } if ($ansiForegroundMap.ContainsKey($code)) { $state.Foreground = $ansiForegroundMap[$code]; continue } if ($ansiBackgroundMap.ContainsKey($code)) { $state.Background = $ansiBackgroundMap[$code]; continue } } $currentIndex = $match.Index + $match.Length } if ($currentIndex -lt $text.Length) { $segmentHtml = $text.Substring($currentIndex) | Convert-PlainTextToHtml $styleParts = [System.Collections.Generic.List[string]]::new() if ($state.Bold) { $null = $styleParts.Add('font-weight:700') } if (-not [string]::IsNullOrWhiteSpace($state.Foreground)) { $null = $styleParts.Add('color:{0}' -f $state.Foreground) } if (-not [string]::IsNullOrWhiteSpace($state.Background)) { $null = $styleParts.Add('background-color:{0}' -f $state.Background) } $fragment = if ($styleParts.Count -eq 0) { $segmentHtml } else { "<span style='{0}'>{1}</span>" -f ($styleParts -join ';'), $segmentHtml } $null = $fragments.Add($fragment) } $fragments -join '' } } #EndRegion './Private/Convert-AnsiToHtml.ps1' 106 #Region './Private/Convert-PlainTextToHtml.ps1' -1 function Convert-PlainTextToHtml { <# .SYNOPSIS Strips ANSI escape sequences, HTML-encodes plain text, and converts newlines to <br/>. .DESCRIPTION Accepts a plain text string via pipeline or direct parameter. Removes all ANSI/VT100 escape sequences, HTML-encodes the result, and replaces line endings with <br/> tags suitable for rendering in an HTML terminal view. .PARAMETER Text The plain text string to convert. Accepts pipeline input. .EXAMPLE 'Hello <World>' | Convert-PlainTextToHtml # Returns: Hello <World> .EXAMPLE "Line 1`nLine 2" | Convert-PlainTextToHtml # Returns: Line 1<br/>Line 2 #> [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [AllowEmptyString()] [string] $Text ) process { $value = if ($null -eq $Text) { '' } else { [string]$Text } $value = [regex]::Replace($value, '(?:\x1B|\u241B)\[[0-9;?]*[A-Za-z]', '') $encodedValue = [System.Net.WebUtility]::HtmlEncode($value) $encodedValue.Replace("`r`n", '<br/>').Replace("`n", '<br/>').Replace("`r", '<br/>') } } #EndRegion './Private/Convert-PlainTextToHtml.ps1' 41 #Region './Private/ConvertFrom-PsuJobOutputEntry.ps1' -1 function ConvertFrom-PsuJobOutputEntry { <# .SYNOPSIS Converts a raw PSU job output entry to a normalized display row object. .DESCRIPTION Maps raw objects returned by Get-PSUJobOutput (or stored in Job.Output) to a consistent PSCustomObject with Timestamp, Stream, StreamColor, and Message properties, suitable for rendering in a terminal or structured table view. StreamColor is a CSS var() reference (e.g. 'var(--psu-term-stream-error)') rather than a literal color, so rendered output follows the light/dark theme set by Get-UDPsuJobThemeStyleBlock instead of being hardcoded to one palette. Accepts pipeline input for use in streaming pipelines. .PARAMETER Entry A raw PSU job output entry object. Accepts pipeline input. .EXAMPLE Get-PSUJobOutput -JobId 1234 | ConvertFrom-PsuJobOutputEntry .EXAMPLE $job.Output | ConvertFrom-PsuJobOutputEntry | Where-Object { $_.Stream -eq 'Error' } #> [CmdletBinding()] [OutputType([PSCustomObject])] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [object] $Entry ) process { $streamName = switch ($Entry.Type) { 4 { 'Error' } 3 { 'Warning' } 2 { 'Verbose' } 1 { 'Debug' } 0 { 'Information' } 'Error' { 'Error' } 'Warning' { 'Warning' } 'Verbose' { 'Verbose' } 'Debug' { 'Debug' } 'Information' { 'Information' } Default { 'Output' } } $streamColor = switch ($streamName) { 'Error' { 'var(--psu-term-stream-error)' } 'Warning' { 'var(--psu-term-stream-warning)' } 'Information' { 'var(--psu-term-stream-information)' } 'Verbose' { 'var(--psu-term-stream-verbose)' } 'Debug' { 'var(--psu-term-stream-debug)' } Default { 'var(--psu-term-stream-default)' } } $messageValue = $null foreach ($prop in @('message', 'Message', 'data', 'Data', 'output', 'Output', 'text', 'Text')) { if ($Entry.PSObject.Properties.Match($prop).Count -eq 0) { continue } $candidate = $Entry.$prop if ($null -eq $candidate) { continue } $candidateText = [string]$candidate if (-not [string]::IsNullOrWhiteSpace($candidateText)) { $messageValue = $candidateText; break } } if ([string]::IsNullOrWhiteSpace([string]$messageValue)) { $messageValue = if ($Entry -is [string]) { [string]$Entry } else { [string](Microsoft.PowerShell.Utility\ConvertTo-Json -InputObject $Entry -Compress -Depth 5) } } [PSCustomObject]@{ Timestamp = $Entry.Timestamp Stream = $streamName StreamColor = $streamColor Message = $messageValue } } } #EndRegion './Private/ConvertFrom-PsuJobOutputEntry.ps1' 85 #Region './Private/ConvertTo-UDPsuThemedIconMarkup.ps1' -1 function ConvertTo-UDPsuThemedIconMarkup { <# .SYNOPSIS Builds inline SVG markup for the synedgy PowerShell icon, themed for light/dark/auto. .DESCRIPTION Always uses the plain monochrome icon variants: pwsh_custom_black.svg for light theme and pwsh_custom_white.svg for dark theme. When Theme is 'Auto', both variants are rendered and wrapped in '.psu-theme-light-only' / '.psu-theme-dark-only' spans so that Get-UDPsuJobThemeStyleBlock's CSS rules can show/hide the correct one live, following PSU's own theme switch without a page reload. When Theme is 'Light' or 'Dark', only the matching variant is rendered directly (no wrapper spans needed). .PARAMETER ModuleBase The module's base path (from $MyInvocation.MyCommand.Module.ModuleBase in the caller), used to locate images/synedgy_pwsh/*.svg. .PARAMETER IdPrefix A unique prefix used to uniquify SVG element ids/classes so multiple inlined copies of the same SVG on one page do not collide. .PARAMETER ExtraStyle Inline CSS applied to the root <svg> element (sizing, display, etc.). .PARAMETER Theme 'Auto' (default), 'Light', or 'Dark'. .EXAMPLE ConvertTo-UDPsuThemedIconMarkup -ModuleBase $moduleBase -IdPrefix 'jh-123' -ExtraStyle 'height:26px;' -Theme 'Auto' #> [CmdletBinding()] [OutputType([System.String])] param ( [Parameter(Mandatory = $true)] [System.String] $ModuleBase, [Parameter(Mandatory = $true)] [System.String] $IdPrefix, [Parameter()] [System.String] $ExtraStyle = '', [Parameter()] [ValidateSet('Auto', 'Light', 'Dark')] [System.String] $Theme = 'Auto' ) $imagesPath = Join-Path -Path $ModuleBase -ChildPath 'images' $imagesPath = Join-Path -Path $imagesPath -ChildPath 'synedgy_pwsh' $blackSvgPath = Join-Path -Path $imagesPath -ChildPath 'pwsh_custom_black.svg' $whiteSvgPath = Join-Path -Path $imagesPath -ChildPath 'pwsh_custom_white.svg' $renderSvg = { param([string]$svgPath, [string]$prefix, [string]$extraStyle) if (-not [System.IO.File]::Exists($svgPath)) { return '' } $c = [System.IO.File]::ReadAllText($svgPath) $c = $c -replace '<\?xml[^>]*\?>', '' # Uniquify gradient/element IDs and their references $c = $c -replace 'id="([^"]+)"', "id=`"$prefix-`$1`"" $c = $c -replace 'url\(#([^)]+)\)', "url(#$prefix-`$1)" $c = $c -replace 'xlink:href="#([^"]+)"', "xlink:href=`"#$prefix-`$1`"" # Uniquify CSS class names so two inlined SVGs don't share .cls-N rules $c = $c -replace '\.cls-(\d+)', ".$prefix-cls-`$1" $c = $c -replace 'class="cls-(\d+)"', "class=`"$prefix-cls-`$1`"" $c = $c -replace '<svg ', "<svg style=`"$extraStyle`" " return $c.Trim() } switch ($Theme) { 'Light' { return & $renderSvg $blackSvgPath ($IdPrefix + '-lt') $ExtraStyle } 'Dark' { return & $renderSvg $whiteSvgPath ($IdPrefix + '-dk') $ExtraStyle } Default { $lightSvg = & $renderSvg $blackSvgPath ($IdPrefix + '-lt') $ExtraStyle $darkSvg = & $renderSvg $whiteSvgPath ($IdPrefix + '-dk') $ExtraStyle $markup = '' if ($lightSvg) { $markup += '<span class="psu-theme-light-only" style="display:contents;">' + $lightSvg + '</span>' } if ($darkSvg) { $markup += '<span class="psu-theme-dark-only" style="display:none;">' + $darkSvg + '</span>' } return $markup } } } #EndRegion './Private/ConvertTo-UDPsuThemedIconMarkup.ps1' 103 #Region './Private/Get-HttpMethodFromPSVerb.ps1' -1 function Get-HttpMethodFromPSVerb { <# .SYNOPSIS Converts a PowerShell verb to an HTTP method. .DESCRIPTION This function takes a PowerShell verb and returns the corresponding HTTP method. .PARAMETER Verb The PowerShell verb to convert. .OUTPUTS Returns the HTTP method as a string. #> [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory = $true)] [string]$Verb ) switch -regex ($Verb) { # The POST method is used to submit an entity to the specified resource, often causing a change in state or side effects on the server. # The PUT method replaces all current representations of the target resource with the request payload. # The DELETE method deletes the specified resource. # The PATCH method is used to apply partial modifications to a resource. # The HEAD method asks for a response identical to that of a GET request, but without the response body. # The GET method requests a representation of the specified resource. # The OPTIONS method is used to describe the communication options for the target resource. # The CONNECT method establishes a tunnel to the server identified by the target resource. # The TRACE method performs a message loop-back test along the path to the target resource. 'Add' { 'POST' } 'Clear' { 'DELETE'} 'Close' { 'HEAD'} 'Copy' { 'PUT'} 'Enter' { 'PATCH' } 'Exit' { 'GET'} 'Find' { 'GET'} 'Format' { 'GET'} 'Get' { 'GET'} 'Hide' { 'PATCH' } 'Join' { 'PUT'} 'Lock' { 'POST' } 'Move' { 'PUT' } 'New' { 'POST' } 'Open' { 'GET' } 'Optimize' { 'PUT'} 'Push' { 'POST' } 'Pop' { 'DELETE' } 'Redo' { 'PATCH' } 'Remove' { 'DELETE' } 'Rename' { 'PUT' } 'Reset' { 'PATCH' } 'Resize' { 'PATCH' } 'Search' { 'GET' } 'Select' { 'GET' } 'Set' { 'PUT' } 'Show' { 'GET' } 'Skip' { 'PATCH' } 'Split' { 'PATCH' } 'Step' { 'PATCH' } 'Switch' { 'GET' } 'Undo' { 'PATCH' } 'Unlock' { 'DELETE' } 'Watch' { 'GET' } 'Connect' { 'POST' } 'Disconnect' { 'DELETE' } 'Read' { 'GET' } 'Receive' { 'POST' } 'Send' { 'POST' } 'Write' { 'PUT' } 'Backup' { 'PUT' } 'Checkpoint' { 'PUT' } 'Compare' { 'GET' } 'Compress' { 'POST' } 'Convert' { 'POST' } 'ConvertFrom' { 'GET' } 'ConvertTo' { 'POST' } 'Dismount' { 'DELETE' } 'Edit' { 'PATCH' } 'Expand' { 'GET' } 'Export' { 'POST' } 'Group' { 'GET' } 'Import' { 'POST' } 'Initialize' { 'PUT' } 'Limit' { 'PATCH' } 'Merge' { 'PUT' } 'Mount' { 'POST' } 'Out' { 'POST' } 'Publish' { 'POST' } 'Restore' { 'PUT' } 'Save' { 'PUT' } 'Sync' { 'POST' } 'Unpublish' { 'DELETE' } 'Update' { 'PUT' } 'Debug' { 'GET' } 'Measure' { 'GET' } 'Ping' { 'GET' } 'Repair' { 'PATCH' } 'Resolve' { 'GET' } 'Test' { 'GET' } 'Trace' { 'TRACE' } 'Approve' { 'POST' } 'Assert' { 'PATCH' } 'Build' { 'POST' } 'Complete' { 'PUT' } 'Confirm' { 'POST' } 'Deny' { 'DELETE' } 'Deploy' { 'POST' } 'Disable' { 'PATCH' } 'Enable' { 'PATCH' } 'Install' { 'POST' } 'Invoke' { 'POST' } 'Register' { 'POST' } 'Request' { 'POST' } 'Restart' { 'PATCH' } 'Resume' { 'PATCH' } 'Start' { 'POST' } 'Stop' { 'DELETE' } 'Submit' { 'POST' } 'Suspend' { 'PATCH' } 'Uninstall' { 'DELETE' } 'Unregister' { 'DELETE' } 'Wait' { 'GET' } 'Use' { 'GET' } 'Block' { 'POST' } 'Grant' { 'POST' } 'Protect' { 'POST' } 'Revoke' { 'DELETE' } 'Unblock' { 'PUT' } 'Unprotect' { 'PUT' } default { throw "Unsupported verb: $Verb" } } } #EndRegion './Private/Get-HttpMethodFromPSVerb.ps1' 138 #Region './Private/Get-UDPsuJobThemePalette.ps1' -1 function Get-UDPsuJobThemePalette { <# .SYNOPSIS Returns the light and dark color palettes used by the PSU job UD components. .DESCRIPTION Single source of truth for the CSS custom property values used by New-UDPsuJobHeader and New-UDPsuJobTerminalView to theme their rendered markup. The light palette is inspired by the classic PowerShell ISE / VS Code light theme color conventions (white background, dark text, ISE-style syntax accent colors for the job output stream types). The dark palette preserves the original color scheme. .EXAMPLE $palette = Get-UDPsuJobThemePalette $palette.Light['bg'] #> [CmdletBinding()] [OutputType([hashtable])] param () return @{ Light = [ordered]@{ 'bg' = '#ffffff' 'chrome-bg' = '#f3f3f3' 'fg' = '#1e1e1e' 'muted-fg' = '#6e6e6e' 'border' = '#d4d4d4' 'accent' = '#0060c0' 'tooltip-bg' = '#e8e8e8' 'tooltip-fg' = '#1e1e1e' 'status-bar-bg' = '#eaeaea' 'chip-bg' = '#eeeeee' 'tab-active-bg' = '#ffffff' 'stream-error' = '#a31515' 'stream-warning' = '#b5860b' 'stream-information' = '#0451a5' 'stream-verbose' = '#008080' 'stream-debug' = '#af00db' 'stream-default' = '#1e1e1e' } Dark = [ordered]@{ 'bg' = '#111111' 'chrome-bg' = '#263238' 'fg' = '#e0e0e0' 'muted-fg' = '#90a4ae' 'border' = '#37474f' 'accent' = '#42a5f5' 'tooltip-bg' = '#37474f' 'tooltip-fg' = '#e0e0e0' 'status-bar-bg' = '#1c2429' 'chip-bg' = '#263238' 'tab-active-bg' = '#1e2a2f' 'stream-error' = '#ef5350' 'stream-warning' = '#ffa726' 'stream-information' = '#42a5f5' 'stream-verbose' = '#ffee58' 'stream-debug' = '#ab47bc' 'stream-default' = '#e0e0e0' } } } #EndRegion './Private/Get-UDPsuJobThemePalette.ps1' 63 #Region './Private/Get-UDPsuJobThemeStyleBlock.ps1' -1 function Get-UDPsuJobThemeStyleBlock { <# .SYNOPSIS Builds the <style> block that themes a PSU job UD component instance. .DESCRIPTION Emits CSS custom properties (--psu-term-*) scoped to the component's own element id, so multiple instances on the same page never collide. When Theme is 'Auto', both the light (default) and dark (activated via PSU's own `html[data-theme="dark"]` attribute, which PSU's built-in theme switch sets and updates live) rule sets are emitted, so the component automatically follows PSU's theme without a page reload. When Theme is 'Light' or 'Dark', only that palette is emitted, overriding the ambient PSU theme for this component instance. Also emits the show/hide rules used to swap between the black/white themed icon variants rendered by ConvertTo-UDPsuThemedIconMarkup. .PARAMETER ElementId The root element id of the component instance to scope these CSS rules to. .PARAMETER Theme 'Auto' (default) follows PSU's theme switch live. 'Light' or 'Dark' force that theme for this component instance regardless of the ambient PSU theme. .PARAMETER CustomCss Optional raw CSS appended after the theme rules, so it naturally overrides them (e.g. targeting `#<ElementId> { --psu-term-accent: #ff00ff; }` or any other selector). .EXAMPLE Get-UDPsuJobThemeStyleBlock -ElementId 'psu-job-terminal-123' -Theme 'Auto' #> [CmdletBinding()] [OutputType([System.String])] param ( [Parameter(Mandatory = $true)] [System.String] $ElementId, [Parameter()] [ValidateSet('Auto', 'Light', 'Dark')] [System.String] $Theme = 'Auto', [Parameter()] [System.String] $CustomCss = '' ) $palette = Get-UDPsuJobThemePalette $toVarsCss = { param($set) ($set.GetEnumerator() | ForEach-Object { '--psu-term-{0}:{1};' -f $_.Key, $_.Value }) -join '' } $rules = switch ($Theme) { 'Light' { '#{0} {{ {1} }}' -f $ElementId, (& $toVarsCss $palette.Light) } 'Dark' { '#{0} {{ {1} }}' -f $ElementId, (& $toVarsCss $palette.Dark) } Default { ( ('#{0} {{ {1} }}' -f $ElementId, (& $toVarsCss $palette.Light)) + ('html[data-theme="dark"] #{0} {{ {1} }}' -f $ElementId, (& $toVarsCss $palette.Dark)) + ('html[data-theme="dark"] #{0} .psu-theme-light-only {{ display:none; }}' -f $ElementId) + ('html[data-theme="dark"] #{0} .psu-theme-dark-only {{ display:contents; }}' -f $ElementId) ) } } return '<style>{0}{1}</style>' -f $rules, $CustomCss } #EndRegion './Private/Get-UDPsuJobThemeStyleBlock.ps1' 79 #Region './Public/Get-ModuleAiTool.ps1' -1 function Get-ModuleAiTool { <# .SYNOPSIS Retrieves the AI tool metadata for functions decorated with [AiTool()] in a module. .DESCRIPTION Scans the exported functions of a module for the AiTool attribute and builds a metadata dictionary for each one, suitable for registering with PowerShell Universal via New-PSUScript and New-PSUAiTool (see Import-PSUAiTool). .PARAMETER Module The module to scan for AI tool functions. .PARAMETER Environment Overrides the PSU environment the backing script should run in. Falls back to the Environment set on the attribute, when present. .PARAMETER Authenticated Overrides whether discovered tools require an authenticated user. Falls back to the Authenticated value set on the attribute. .PARAMETER Mcp Overrides whether discovered tools are exposed through MCP. Falls back to the Mcp value set on the attribute (defaults to $true). .EXAMPLE Get-ModuleAiTool -Module PSUConfig #> [CmdletBinding()] [OutputType([System.Collections.IDictionary])] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', Justification = 'Output type is a dictionary')] param ( [Parameter()] [Microsoft.PowerShell.Commands.ModuleSpecification] $Module, [Parameter()] [string] $Environment, [Parameter()] [switch] $Authenticated, [Parameter()] [switch] $Mcp ) $aiToolModule = Get-Module $Module -ErrorAction Stop Write-Verbose -Message ('Module: {0} Version: {1}' -f $aiToolModule.Name, $aiToolModule.Version) $aiToolFunctions = $aiToolModule.ExportedFunctions.Values.Where{ $_.ScriptBlock.Attributes.Where{ $_.TypeId.ToString() -eq 'AiTool' -and $_.IsAiTool -eq $true } } Write-Verbose -Message ('Discovered AI tool functions are: {0}' -f ($aiToolFunctions.Name -join ', ')) foreach ($aiToolFunction in $aiToolFunctions) { # You can have more than one AiTool attribute on a single function, e.g. to # register it under multiple names/roles. $aiToolAttributes = $aiToolFunction.ScriptBlock.Attributes.Where{ $_.TypeId.ToString() -eq 'AiTool' -and $_.IsAiTool -eq $true } foreach ($aiToolAttribute in $aiToolAttributes) { $name = $aiToolAttribute.Name if ([string]::IsNullOrEmpty($name)) { $name = $aiToolFunction.Name } $description = $aiToolAttribute.Description if ([string]::IsNullOrEmpty($description)) { $description = (Get-Help -Name $aiToolFunction.Name -ErrorAction SilentlyContinue).Synopsis } $scriptFullPath = '{0}\{1}' -f $aiToolModule.Name, $aiToolFunction.Name $aiToolData = @{ Name = $name Description = $description ScriptFullPath = $scriptFullPath FunctionInfo = $aiToolFunction } if ($PSBoundParameters.ContainsKey('Environment')) { $aiToolData['Environment'] = $Environment } elseif (-not [string]::IsNullOrEmpty($aiToolAttribute.Environment)) { $aiToolData['Environment'] = $aiToolAttribute.Environment } if ($PSBoundParameters.ContainsKey('Authenticated')) { $aiToolData['Authenticated'] = $Authenticated.IsPresent } else { $aiToolData['Authenticated'] = $aiToolAttribute.Authenticated } if ($aiToolAttribute.Role -and $aiToolAttribute.Role.Count -gt 0) { $aiToolData['Role'] = $aiToolAttribute.Role } if ($PSBoundParameters.ContainsKey('Mcp')) { $aiToolData['Mcp'] = $Mcp.IsPresent } else { $aiToolData['Mcp'] = $aiToolAttribute.Mcp } $aiToolData } } } #EndRegion './Public/Get-ModuleAiTool.ps1' 129 #Region './Public/Get-ModuleApiEndpoint.ps1' -1 function Get-ModuleApiEndpoint { <# .SYNOPSIS Retrieves the API endpoint for the current module. .DESCRIPTION This function retrieves the API endpoint metadata for a specified service by getting the public functions of the module. .EXAMPLE Get-ModuleApiEndpoint #> [CmdletBinding()] [OutputType([System.Collections.IDictionary])] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'No parameters needed')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', Justification = 'Output type is a dictionary')] param ( [Parameter()] [Microsoft.PowerShell.Commands.ModuleSpecification] $Module, [Parameter()] [ValidateSet('Information', 'Debug', 'Verbose')] [string[]] $LogLevel, [Parameter()] [string] $Environment, [Parameter()] [switch] $Authentication, [Parameter()] [string] $ApiPrefix ) # TODO: Load module in Thread and process it there (once done the thread is closed and DLL handles freed) # $thread = [powershell]::Create([System.Management.Automation.RunspaceMode]::CurrentRunspace) $apiModule = Get-Module $Module -ErrorAction Stop Write-Verbose -Message ('Module: {0} Version: {1}' -f $apiModule.Name, $apiModule.Version) $endpoints = $apiModule.ExportedFunctions.Values.Where{ $_.ScriptBlock.Attributes.Where{ $_.TypeId.ToString() -eq 'APIEndpoint' -and $_.IsEndpoint -eq $true } } Write-Verbose -Message ('Discovered API functions are: {0}' -f ($endpoints.Name -join ', ')) foreach ($functionEndpoint in $endpoints) { # You can have more than one endpoint for a single function # by repeating the APIEndpoint attribute # This is useful for versioning or different methods, and overriding parameters signature (say between v1 and v2) $ApiEndpoints = $functionEndpoint.ScriptBlock.Attributes.Where{ $_.TypeId.ToString() -eq 'APIEndpoint' -and $_.IsEndpoint -eq $true } # TODO: $ApiInputs = $functionEndpoint.ScriptBlock.Attributes.Where{ $_.TypeId.ToString() -eq 'APIInput'} # TODO: $ApiOutputs = $functionEndpoint.ScriptBlock.Attributes.Where{ $_.TypeId.ToString() -eq 'APIOutput'} foreach ($ApiEndpoint in $ApiEndpoints) { $urlPath = $ApiEndpoint.Path $version = $ApiEndpoint.version $Name = $ApiEndpoint.Name if ($PSBoundParameters.ContainsKey('ApiPrefix')) { Write-Debug -Message ('Using ApiPrefix from Parameter (override) {0}' -f $ApiPrefix) } elseif (-not [string]::IsNullOrEmpty($ApiEndpoints.ApiPrefix)) { $ApiPrefix = $ApiEndpoint.ApiPrefix } if ($PSBoundParameters.ContainsKey('Documentation')) { Write-Debug -Message ('Using documentation from Parameter (override) {0}' -f $Documentation) } elseif (-not [string]::IsNullOrEmpty($ApiEndpoints.Documentation)) { $Documentation = $ApiEndpoint.Documentation } if ([string]::IsNullOrEmpty($urlPath)) { $urlPath = $functionEndpoint.Noun.ToLower() } if ($PSBoundParameters.ContainsKey('ApiPrefix')) { $apiPrefix = $PSBoundParameters['ApiPrefix'] # Building the /{apiPrefix}/endpoint style URL # not using the endpoint versioning from the attribute $url = ('{0}/{1}' -f $apiPrefix, $urlPath.TrimStart('/').ToLower()) -replace '//', '/' } else { # Building the /api/v1/endpoint style URL $url = (('/{0}/{1}/{2}' -f $apiPrefix, $version, $urlPath.TrimStart('/')).ToLower()) -replace '//', '/' } Write-Information -MessageData "Processing endpoint: $url for function: $($functionEndpoint.Name)" if ([string]::IsNullOrEmpty($Name)) { $Name = '{0}{1}{2}' -f $functionEndpoint.verb.ToLower(), $functionEndpoint.Noun, $version } $description = $ApiEndpoint.Description if ([string]::IsNullOrEmpty($description)) { $description = (Get-Help -Name $functionEndpoint.Name -ErrorAction SilentlyContinue).Description.Text } $method = $ApiEndpoint.Method if ([string]::IsNullOrEmpty($method)) { $method = Get-HttpMethodFromPSVerb -Verb $functionEndpoint.Verb } $apiEndpointData = @{ # Mandatory fields for the endpoint url = $url Description = $description Method = $method FunctionInfo = $functionEndpoint } if (-not [string]::IsNullOrEmpty($documentation)) { $apiEndpointData['Documentation'] = $documentation } if ($PSBoundParameters.ContainsKey('LogLevel')) { $apiEndpointData['LogLevel'] = $PSBoundParameters['LogLevel'] } elseif ($ApiEndpoint.LogLevel -and $ApiEndpoint.LogLevel.Count -gt 0) { $apiEndpointData['LogLevel'] = $ApiEndpoint.LogLevel } # Optional fields for the endpoint if ($PSBoundParameters.ContainsKey('Environment')) { $apiEndpointData['Environment'] = $PSBoundParameters['Environment'] } elseif (-not [string]::IsNullOrEmpty($ApiEndpoint.Environment)) { $apiEndpointData['Environment'] = $ApiEndpoint.Environment } if ($PSBoundParameters.ContainsKey('Authentication')) { $apiEndpointData['Authentication'] = $Authentication.IsPresent } else { $apiEndpointData['Authentication'] = $ApiEndpoint.Authentication } if ($PSBoundParameters.ContainsKey('Role')) { $apiEndpointData['Role'] = $PSBoundParameters['Role'] } elseif ($ApiEndpoint.Role -and $ApiEndpoint.Role.Count -gt 0) { $apiEndpointData['Role'] = $ApiEndpoint.Role } if ($ApiEndpoint.Tag -gt 0) { $apiEndpointData['Tag'] = $ApiEndpoint.Tag } if ($ApiEndpoint.Timeout -and $ApiEndpoint.Timeout -gt 0) { $apiEndpointData['Timeout'] = $ApiEndpoint.Timeout } if (-not [string]::IsNullOrEmpty($ApiEndpoint.ContentType)) { $apiEndpointData['ContentType'] = $ApiEndpoint.ContentType } if ($ApiEndpoint.Parameters -and $ApiEndpoint.Parameters -is [scriptblock]) { $apiEndpointData['Parameters'] = $ApiEndpoint.Parameters } $apiEndpointData } } } #EndRegion './Public/Get-ModuleApiEndpoint.ps1' 198 #Region './Public/Get-ModuleApiEndpointScriptblock.ps1' -1 # using namespace System.Collections function Get-ModuleApiEndpointScriptblock { <# .SYNOPSIS Returns a scriptblock that can be used as an endpoint for a PSU API. .DESCRIPTION This function returns a scriptblock that can be used as an endpoint for the module API. The scriptblock will return the module's API endpoints. .EXAMPLE $scriptBlock = Get-ModuleApiEndpointScriptblock Invoke-Command -ScriptBlock $scriptBlock .OUTPUTS [PSCustomObject[]] - An array of custom objects representing the module's API endpoints. #> [CmdletBinding()] [OutputType([ScriptBlock])] param ( [Parameter(Mandatory = $true)] [IDictionary] $ModuleApiEndpoint ) #region Force Endpoint to have pascalCase parameters $paramBlock = $ModuleApiEndpoint.FunctionInfo.ScriptBlock.ast.Body.ParamBlock $paramBlockStr = $paramBlock.Extent.Text # change the Extent to camelCase the Extent name $paramBlock.Parameters | ForEach-Object { # Update $_.Name to be camelCase $parameter = $_ $oldParamName = $parameter.Name.VariablePath.UserPath # If the parameter name is not in camelCase, convert it $FirstChar = $oldParamName.Substring(0, 1) # first letter after the $ $RestOfName = $oldParamName.Substring(1) # rest of the name after the first letter $newParamName = '{0}{1}' -f $FirstChar.ToLowerInvariant(),$RestOfName $replacedParamName = $parameter.Name.Extent.text -creplace ([regex]::Escape($oldParamName)), $newParamName $startOffSet = $parameter.Name.Extent.StartOffset - $paramBlock.Extent.StartOffset $paramBlockStr = $paramBlockStr.Remove($startOffSet,$replacedParamName.Length).Insert($startOffSet,$replacedParamName) #TODO: Change switch parameter to bool if PSU prefers that } #TODO: Add the Inputs/Outputs documentation to the CBH section #endregion if ($PSBoundParameters.ContainsKey('Environment')) { $paramBlockStr = $paramBlockStr -replace '\$Environment', '$PSBoundParameters["Environment"]' } [string] $InformationAction, $VerboseAction, $DebugAction = '' if ($ModuleApiEndpoint.LogLevel -contains 'Information') { $InformationAction = '$InformationPreference = ''Continue'';' } if ($ModuleApiEndpoint.LogLevel -contains 'Verbose') { $VerboseAction = '$VerbosePreference = ''Continue'';' } if ($ModuleApiEndpoint.LogLevel -contains 'Debug') { $DebugAction = '$DebugPreference = ''Continue'';' } $testingStream = ' Write-Host "this is a Write-Host test message" Write-Information "this is a Write-Information test message" Write-Debug -Message "This is a Write-Debug test message" Write-Verbose -Verbose -Message "This is a Write-Verbose test message" ' $loggingConfig = @( $InformationAction, $VerboseAction, $DebugAction # $testingStream #TODO: raise a bug with PowerShell Universal (only Information/Write-Host works) ) -join '' $functionCall = '{0}{1} {2} @PSBoundParameters{1}' -f $loggingConfig,"`r`n",$ModuleApiEndpoint.FunctionInfo.Name # $functionCall = '$result = {0} @PSBoundParameters' -f $ModuleApiEndpoint.FunctionInfo.Name # JSON automatic serialization # $jsonConvert = '$result | ConvertTo-Json -Depth 10' $scriptBlockStr = ' {0}{1}{1} {2}{1} {3}' -f $paramBlockStr,"`r`n", $functionCall,$jsonConvert Write-Information -MessageData ('Creating scriptblock for module API endpoint: {0}. {1}' -f $ModuleApiEndpoint.url, $scriptBlockStr) # TODO: Implement Generic HTTP codes # Create a scriptblock from the string $scriptBlock = [scriptblock]::Create($scriptBlockStr) return $scriptBlock } #EndRegion './Public/Get-ModuleApiEndpointScriptblock.ps1' 100 #Region './Public/Import-PSUAiTool.ps1' -1 function Import-PSUAiTool { <# .SYNOPSIS Discovers functions decorated with [AiTool()] in a module and registers them as PowerShell Universal AI tools. .DESCRIPTION For each function found via Get-ModuleAiTool, ensures a backing PSU Script resource exists (New-PSUScript, keyed by <Module>\<Command>) and registers the corresponding AI tool (New-PSUAiTool). Unlike Import-PSUEndpoint, which builds an inline wrapper scriptblock for New-PSUEndpoint, New-PSUAiTool requires an existing Script resource referenced by its ScriptFullPath, so this function always (re-)declares that backing script alongside the AI tool. Both New-PSUScript and New-PSUAiTool are idempotent when called repeatedly with the same identity, which is safe since PSU resource declaration files are re-executed on every config reload. .PARAMETER Module The module to scan for AI tool functions. .PARAMETER Environment The PSU environment the backing script should run in. Falls back to the Environment set on the AiTool attribute, when present. .PARAMETER Authenticated Forces whether discovered tools require an authenticated user, overriding the AiTool attribute's value. .PARAMETER Mcp Forces whether discovered tools are exposed through MCP, overriding the AiTool attribute's value. .EXAMPLE Import-PSUAiTool -Module PSUConfig -Environment 'PSUConfig' #> [CmdletBinding(SupportsShouldProcess = $true)] [OutputType([Object])] param ( [Parameter()] [Microsoft.PowerShell.Commands.ModuleSpecification] $Module, [Parameter()] [string] $Environment, [Parameter()] [switch] $Authenticated, [Parameter()] [switch] $Mcp ) $moduleAiToolParams = @{} if ($PSBoundParameters.ContainsKey('Module')) { $moduleAiToolParams['Module'] = $Module } if ($PSBoundParameters.ContainsKey('Environment')) { $moduleAiToolParams['Environment'] = $Environment } if ($PSBoundParameters.ContainsKey('Authenticated')) { $moduleAiToolParams['Authenticated'] = $Authenticated.IsPresent } if ($PSBoundParameters.ContainsKey('Mcp')) { $moduleAiToolParams['Mcp'] = $Mcp.IsPresent } $aiTools = Get-ModuleAiTool @moduleAiToolParams $aiTools | ForEach-Object -Process { $aiTool = $_ $newScriptParams = @{ Module = $Module Command = $aiTool.FunctionInfo.Name } if ($aiTool.ContainsKey('Environment')) { $newScriptParams['Environment'] = $aiTool.Environment } if ($PSCmdlet.ShouldProcess(('Registering backing PSU Script for AI tool {0}: {1}' -f $aiTool.Name, ($newScriptParams | ConvertTo-Json -Compress)))) { New-PSUScript @newScriptParams } $newAiToolParams = @{ Name = $aiTool.Name Description = $aiTool.Description ScriptFullPath = $aiTool.ScriptFullPath } if ($aiTool.ContainsKey('Authenticated')) { $newAiToolParams['Authenticated'] = $aiTool.Authenticated } if ($aiTool.ContainsKey('Role')) { $newAiToolParams['Role'] = $aiTool.Role } if ($aiTool.ContainsKey('Mcp')) { $newAiToolParams['Mcp'] = $aiTool.Mcp } if ($PSCmdlet.ShouldProcess(('Creating PSUAiTool with {0}' -f ($newAiToolParams | ConvertTo-Json -Compress)))) { New-PSUAiTool @newAiToolParams } } } #EndRegion './Public/Import-PSUAiTool.ps1' 125 #Region './Public/Import-PSUEndpoint.ps1' -1 function Import-PSUEndpoint { [CmdletBinding(SupportsShouldProcess = $true)] [OutputType([Object])] param ( [Parameter()] [Microsoft.PowerShell.Commands.ModuleSpecification] $Module, [Parameter()] [string] $Environment, [Parameter()] [string] $ApiPrefix, [Parameter()] [string] # Name of the documentation endpoint to use for this endpoint. # If not specified, the one defined by the attribute will be used, or the default one. $Documentation, [Parameter()] [switch] # Force authentication on the endpoint regardless of its configuration. $Authentication, [Parameter()] [ValidateSet('Information','Debug', 'Verbose')] [string[]] $LogLevel = 'Information' ) $moduleApiEndpointParams = @{} if ($PSBoundParameters.ContainsKey('Module')) { $moduleApiEndpointParams['Module'] = $Module } if ($PSBoundParameters.ContainsKey('Environment')) { $moduleApiEndpointParams['Environment'] = $Environment } if ($PSBoundParameters.ContainsKey('ApiPrefix')) { $moduleApiEndpointParams['ApiPrefix'] = $ApiPrefix } if ($PSBoundParameters.ContainsKey('Authentication')) { $moduleApiEndpointParams['Authentication'] = $Authentication.IsPresent } if ($PSBoundParameters.ContainsKey('LogLevel')) { $moduleApiEndpointParams['LogLevel'] = $LogLevel } if ($PSBoundParameters.ContainsKey('Documentation')) { $moduleApiEndpointParams['Documentation'] = $Documentation } $endpoints = Get-ModuleApiEndpoint @moduleApiEndpointParams $endpoints | ForEach-Object -Process { # Create a new endpoint for each function that has the APIEndpoint attribute $psuEndpointParams = @{} + $_ $psuCommand = Get-Command -Name 'New-PSUEndpoint' -Module Universal $psuEndpointParams.Keys.Where{ $_ -notin $psuCommand.Parameters.Keys } | ForEach-Object { $null = $psuEndpointParams.Remove($_) } if ($false -eq $psuEndpointParams['Authentication'] -and $psuEndpointParams.ContainsKey('Role')) { $null = $psuEndpointParams.Remove('Role') } $moduleEndpointScriptblockParameters = @{ ModuleApiEndpoint = $_ } $endpointScriptBlock = Get-ModuleApiEndpointScriptblock @moduleEndpointScriptblockParameters if ($PSCmdlet.ShouldProcess(('Creating PSUEndpoint with {0}' -f ($psuEndpointParams | ConvertTo-Json -depth 6 -Compress)))) { New-PSUEndpoint @psuEndpointParams -Endpoint $endpointScriptBlock } } } #EndRegion './Public/Import-PSUEndpoint.ps1' 96 #Region './Public/UDComponents/New-UDPsuJobHeader.ps1' -1 function New-UDPsuJobHeader { <# .SYNOPSIS Renders a summary header card for a PowerShell Universal job. .DESCRIPTION Produces a styled HTML metadata card showing the script path, a copy-to-clipboard job ID chip, status badge, and timestamps (Created, Started, Completed, Duration). Designed to pair with New-UDPsuJobTerminalView on a job detail page, but can be used independently wherever a concise job summary is needed. Colors are driven by CSS custom properties defined in a per-instance <style> block (see Get-UDPsuJobThemeStyleBlock in synedgy.universal.helper), so the card automatically follows PSU's own light/dark theme switch (Theme 'Auto', the default), or can be forced to a specific theme, or further customized via -CustomCss. .PARAMETER Job The PSU job object returned by Get-PSUJob. Used to derive all displayed fields. .PARAMETER ElementId Root element id, used to scope the theme CSS to this instance. Defaults to "psu-job-header-<JobId>". .PARAMETER Theme 'Auto' (default) follows PSU's own theme switch live, with no page reload required. 'Light' or 'Dark' force that theme for this component instance regardless of the ambient PSU theme. .PARAMETER CustomCss Optional raw CSS appended after the theme rules, so it naturally overrides them. For example, override a single color variable: '#psu-job-header-123 { --psu-term-accent: #ff00ff; }'. .EXAMPLE $job = Get-PSUJob -Id $id -AppToken $token New-UDPsuJobHeader -Job $job .EXAMPLE # Typical usage on a job detail page $job = Get-PSUJob -Id ([Int64]$id) New-UDPsuJobHeader -Job $job New-UDPsuJobTerminalView -JobId $job.Id -JobStatus ([string]$job.Status) ` -JobOutputSnapshot @($job.Output) -IncludeStructuredTable .EXAMPLE # Force dark theme regardless of PSU's ambient theme New-UDPsuJobHeader -Job $job -Theme Dark #> [CmdletBinding()] [OutputType([void])] param ( [Parameter(Mandatory)] $Job, [Parameter()] [System.String] $ElementId = ('psu-job-header-{0}' -f $Job.Id), [Parameter()] [ValidateSet('Auto', 'Light', 'Dark')] [System.String] $Theme = 'Auto', [Parameter()] [System.String] $CustomCss = '' ) $formatDt = { param($dt) if ($dt -and ([datetime]$dt).Year -gt 1) { '{0:yyyy-MM-dd HH:mm:ss}' -f ([datetime]$dt) } else { 'N/A' } } $createdStr = & $formatDt $Job.CreatedTime $startedStr = & $formatDt $Job.StartTime $completedStr = & $formatDt $Job.EndTime $durationStr = 'N/A' if ($Job.StartTime -and $Job.EndTime) { $s = [datetime]$Job.StartTime $e = [datetime]$Job.EndTime if ($s.Year -gt 1 -and $e.Year -gt 1 -and $e -gt $s) { $span = $e - $s $durationStr = if ($span.TotalHours -ge 1) { '{0}h {1:mm\:ss}' -f [int]$span.TotalHours, $span } elseif ($span.TotalMinutes -ge 1) { '{0:m\:ss}' -f $span } else { '{0}s' -f [int]$span.TotalSeconds } } } $statusStyle = switch ([string]$Job.Status) { 'Completed' { 'background:#1b5e20;color:#66bb6a;' } 'Failed' { 'background:#7f0000;color:#ef9a9a;' } 'Warning' { 'background:#4a3000;color:#ffa726;' } 'Running' { 'background:#0d47a1;color:#90caf9;' } 'Error' { 'background:#7f0000;color:#ef9a9a;' } Default { 'background:var(--psu-term-chip-bg);color:var(--psu-term-muted-fg);' } } $jobIdStr = [string]$Job.Id $scriptPath = [System.Net.WebUtility]::HtmlEncode([string]$Job.ScriptFullPath) $statusLabel = [System.Net.WebUtility]::HtmlEncode(([string]$Job.Status).ToUpper()) $chipId = 'job-id-chip-' + $jobIdStr $copyOnClick = ( "navigator.clipboard.writeText('" + $jobIdStr + "')." + "then(function(){" + "var e=document.getElementById('" + $chipId + "');" + "var o=e.innerHTML;" + "e.textContent='Copied!';" + "e.style.color='#66bb6a';" + "setTimeout(function(){e.innerHTML=o;e.style.color='';},1500)" + "}).catch(function(){})" ) $metaCol = 'display:flex;flex-direction:column;gap:3px;' $metaLbl = 'color:var(--psu-term-muted-fg);font-size:10px;font-family:Consolas,Monaco,monospace;text-transform:uppercase;letter-spacing:0.6px;' $metaVal = 'color:var(--psu-term-fg);font-size:12px;font-family:Consolas,Monaco,monospace;' # Inline SVG decoration - terminal icon top-right of the card $decorSvgHtml = '' $moduleBase = $MyInvocation.MyCommand.Module.ModuleBase if ($moduleBase) { $decorSvg = ConvertTo-UDPsuThemedIconMarkup -ModuleBase $moduleBase -IdPrefix ('jh-' + $jobIdStr) -ExtraStyle 'width:auto;height:100%;' -Theme $Theme if ($decorSvg) { $decorSvgHtml = '<div style="position:absolute;top:10px;right:16px;height:70px;opacity:0.18;pointer-events:none;">' + $decorSvg + '</div>' } } $themeStyleBlock = Get-UDPsuJobThemeStyleBlock -ElementId $ElementId -Theme $Theme -CustomCss $CustomCss $markup = ( $themeStyleBlock + '<div id="' + $ElementId + '" style="position:relative;overflow:hidden;background:var(--psu-term-bg);border-radius:8px;padding:16px 20px;margin-bottom:12px;border:1px solid var(--psu-term-border);">' + $decorSvgHtml + '<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:14px;">' + '<span style="color:var(--psu-term-fg);font-family:Consolas,Monaco,monospace;font-size:13px;font-weight:600;">' + $scriptPath + '</span>' + '<span id="' + $chipId + '" onclick="' + $copyOnClick + '" title="Click to copy Job ID" ' + 'style="background:var(--psu-term-chip-bg);color:var(--psu-term-muted-fg);font-family:Consolas,Monaco,monospace;font-size:11px;' + 'padding:3px 10px;border-radius:12px;border:1px solid var(--psu-term-border);cursor:pointer;user-select:none;white-space:nowrap;">' + '#' + $jobIdStr + ' ⧉' + '</span>' + '</div>' + '<div style="display:flex;gap:24px;flex-wrap:wrap;align-items:flex-start;">' + '<div style="' + $metaCol + '">' + '<span style="' + $metaLbl + '">Status</span>' + '<span style="' + $statusStyle + 'font-size:11px;font-family:Consolas,Monaco,monospace;font-weight:700;padding:2px 10px;border-radius:3px;letter-spacing:0.3px;">' + $statusLabel + '</span>' + '</div>' + '<div style="' + $metaCol + '">' + '<span style="' + $metaLbl + '">Created</span>' + '<span style="' + $metaVal + '">' + [System.Net.WebUtility]::HtmlEncode($createdStr) + '</span>' + '</div>' + '<div style="' + $metaCol + '">' + '<span style="' + $metaLbl + '">Started</span>' + '<span style="' + $metaVal + '">' + [System.Net.WebUtility]::HtmlEncode($startedStr) + '</span>' + '</div>' + '<div style="' + $metaCol + '">' + '<span style="' + $metaLbl + '">Completed</span>' + '<span style="' + $metaVal + '">' + [System.Net.WebUtility]::HtmlEncode($completedStr) + '</span>' + '</div>' + '<div style="' + $metaCol + '">' + '<span style="' + $metaLbl + '">Duration</span>' + '<span style="' + $metaVal + '">' + [System.Net.WebUtility]::HtmlEncode($durationStr) + '</span>' + '</div>' + '</div>' + '</div>' ) New-UDHtml -Markup $markup } #EndRegion './Public/UDComponents/New-UDPsuJobHeader.ps1' 188 #Region './Public/UDComponents/New-UDPsuJobTerminalView.ps1' -1 function New-UDPsuJobTerminalView { <# .SYNOPSIS Renders a terminal-style view for a PowerShell Universal job. .DESCRIPTION Displays job output in a terminal-style window. The terminal content panel is a New-UDDynamic that refreshes on demand via a button click, fetching the latest output from Get-PSUJobOutput each time. The structured events tab and status bar are rendered once from the initial fetch at page load. Uses private helpers from synedgy.universal.helper: ConvertFrom-PsuJobOutputEntry, Convert-AnsiToHtml, Convert-PlainTextToHtml. .PARAMETER JobId The PSU job identifier to render output for. .PARAMETER AppToken PSU app token used to call Get-PSUJobOutput. If omitted, the call is made without an explicit token (suitable when running inside the PSU dashboard with an ambient connection). .PARAMETER UniversalServerUrl PSU server URL for Get-PSUJobOutput. Defaults to UniversalServerUrl from module config (via Get-ModuleConfig, when available), then http://localhost:5000. .PARAMETER MaxRows Maximum number of most-recent output rows to render. Use 0 for no limit. .PARAMETER IncludeStructuredTable Include a structured events tab alongside the terminal view. .PARAMETER JobStatus Current job status string (passed to the terminal dynamic for context). .PARAMETER JobOutputSnapshot Pre-fetched job output records used as initial data for the structured table and status bar. The terminal dynamic performs its own live fetch. .PARAMETER ElementId Root element id. Defaults to "psu-job-terminal-<JobId>". .PARAMETER AutoRefreshInterval Interval in seconds for auto-refreshing the terminal output when the job is active (not in a terminal state). Defaults to 5. Set to 0 to disable auto-refresh entirely. .PARAMETER Theme 'Auto' (default) follows PSU's own theme switch live (PSU sets a `data-theme` attribute on <html> that updates instantly when the user toggles it, with no page reload), so this component's colors and icon variant switch automatically. 'Light' or 'Dark' force that theme for this component instance regardless of the ambient PSU theme. .PARAMETER CustomCss Optional raw CSS appended after the theme rules, so it naturally overrides them. For example, override a single color variable: '#psu-job-terminal-123 { --psu-term-accent: #ff00ff; }'. .PARAMETER HideLineNumbers Hides line numbers by default for a first-time viewer (shown by default when omitted). Once a viewer manually toggles line numbers, their choice is remembered in the browser's localStorage and takes precedence over this default on later visits. .PARAMETER HideTimestamps Hides timestamps by default for a first-time viewer (shown by default when omitted). Once a viewer manually toggles timestamps, their choice is remembered in the browser's localStorage and takes precedence over this default on later visits. .EXAMPLE New-UDPsuJobTerminalView -JobId 10494 -AppToken $secret:automation_bot_token -IncludeStructuredTable -AutoRefreshInterval 10 .EXAMPLE # Force light theme regardless of PSU's ambient theme New-UDPsuJobTerminalView -JobId 10494 -Theme Light .EXAMPLE # Hide line numbers and timestamps by default for first-time viewers New-UDPsuJobTerminalView -JobId 10494 -HideLineNumbers -HideTimestamps #> [CmdletBinding( SupportsShouldProcess = $true, ConfirmImpact = 'Low' )] [OutputType([object])] param ( [Parameter(Mandatory = $true)] [Int64] $JobId, [Parameter()] [System.String] $AppToken, [Parameter()] [System.String] $UniversalServerUrl, [Parameter()] [ValidateRange(0, 5000)] [Int32] $MaxRows = 0, [Parameter()] [System.Management.Automation.SwitchParameter] $IncludeStructuredTable, [Parameter()] [System.String] $JobStatus, [Parameter()] [object[]] $JobOutputSnapshot = @(), [Parameter()] [System.String] $ElementId = ('psu-job-terminal-{0}' -f $JobId), [Parameter()] [ValidateRange(0, 300)] [Int32] $AutoRefreshInterval = 5, [Parameter()] [ValidateSet('Auto', 'Light', 'Dark')] [System.String] $Theme = 'Auto', [Parameter()] [System.String] $CustomCss = '', [Parameter()] [System.Management.Automation.SwitchParameter] $HideLineNumbers, [Parameter()] [System.Management.Automation.SwitchParameter] $HideTimestamps ) $resolvedAppToken = $AppToken $configUrl = if (Get-Command -Name 'Get-ModuleConfig' -ErrorAction SilentlyContinue) { try { [string](Get-ModuleConfig).UniversalServerUrl } catch { $null } } $resolvedUrl = if (-not [string]::IsNullOrWhiteSpace($UniversalServerUrl)) { $UniversalServerUrl.TrimEnd('/') } elseif (-not [string]::IsNullOrWhiteSpace($configUrl)) { $configUrl.TrimEnd('/') } else { 'http://localhost:5000' } $resolvedIncludeStructuredTable = $IncludeStructuredTable.IsPresent # --- Initial fetch: drives the structured table and status bar --- $initialRaw = @() try { $fetchParams = @{ JobId = $JobId AsObject = $true ComputerName = $resolvedUrl ErrorAction = 'Stop' } if (-not [string]::IsNullOrWhiteSpace($resolvedAppToken)) { $fetchParams['AppToken'] = $resolvedAppToken } $initialRaw = @(Get-PSUJobOutput @fetchParams) } catch { $initialRaw = @() } if (@($initialRaw).Count -eq 0) { $initialRaw = @($JobOutputSnapshot) } $initialDisplayRows = @( $initialRaw | Microsoft.PowerShell.Utility\Sort-Object -Property Timestamp | ConvertFrom-PsuJobOutputEntry ) if ($MaxRows -gt 0) { $initialDisplayRows = @($initialDisplayRows | Microsoft.PowerShell.Utility\Select-Object -Last $MaxRows) } # --- Status bar --- $streamCounts = @( $initialDisplayRows | Microsoft.PowerShell.Utility\Group-Object -Property Stream | Microsoft.PowerShell.Utility\Sort-Object -Property Name ) $statusStreamSpans = @( $streamCounts.ForEach{ $c = switch ($_.Name) { 'Error' { 'var(--psu-term-stream-error)' }; 'Warning' { 'var(--psu-term-stream-warning)' }; 'Information' { 'var(--psu-term-stream-information)' } 'Verbose' { 'var(--psu-term-stream-verbose)' }; 'Debug' { 'var(--psu-term-stream-debug)' }; Default { 'var(--psu-term-stream-default)' } } "<span style='color:{0};'>{1}: {2}</span>" -f $c, $_.Name, $_.Count } ) $statusInner = if (@($statusStreamSpans).Count -gt 0) { ($statusStreamSpans -join "<span style='color:var(--psu-term-border);padding:0 6px;'>|</span>") + ("<span style='color:var(--psu-term-border);padding:0 6px;'>|</span><span style='color:var(--psu-term-muted-fg);'>Events: {0}</span>" -f @($initialDisplayRows).Count) } else { "<span style='color:var(--psu-term-muted-fg);'>Events: 0</span>" } # --- Inline SVG assets from module images --- $svgIconHtml = '' $svgWatermarkHtml = '' $moduleBase = $MyInvocation.MyCommand.Module.ModuleBase if ($moduleBase) { $iconSvg = ConvertTo-UDPsuThemedIconMarkup -ModuleBase $moduleBase -IdPrefix ($ElementId + '-icon') -ExtraStyle 'height:26px;width:auto;display:block;' -Theme $Theme $wmSvg = ConvertTo-UDPsuThemedIconMarkup -ModuleBase $moduleBase -IdPrefix ($ElementId + '-wm') -ExtraStyle 'height:100%;width:auto;' -Theme $Theme if ($iconSvg) { $svgIconHtml = '<span style="padding:4px 8px 4px 12px;display:flex;align-items:center;">' + $iconSvg + '</span><span style="padding:0 14px 0 0;color:var(--psu-term-fg);font-family:Consolas,Monaco,monospace;font-size:15px;font-weight:700;letter-spacing:2px;display:flex;align-items:center;border-right:1px solid var(--psu-term-border);">TERMINAL</span>' } if ($wmSvg) { $svgWatermarkHtml = '<div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);height:65%;pointer-events:none;opacity:0.05;z-index:0;">' + $wmSvg + '</div>' } } $iconSpan = if ($svgIconHtml) { $svgIconHtml } else { '<span style="padding:0 14px;color:var(--psu-term-accent);font-family:Consolas,Monaco,monospace;font-size:14px;font-weight:700;display:flex;align-items:center;border-right:1px solid var(--psu-term-border);">>_</span>' } # --- Title bar --- if ($resolvedIncludeStructuredTable) { $onClickTerminal = ( "document.getElementById('" + $ElementId + "-panel-terminal').style.display='';" + "document.getElementById('" + $ElementId + "-panel-structured').style.display='none';" + "document.getElementById('" + $ElementId + "-cbtn-terminal').style.background='var(--psu-term-tab-active-bg)';" + "document.getElementById('" + $ElementId + "-cbtn-terminal').style.color='var(--psu-term-fg)';" + "document.getElementById('" + $ElementId + "-cbtn-terminal').style.borderBottom='2px solid var(--psu-term-accent)';" + "document.getElementById('" + $ElementId + "-cbtn-structured').style.background='transparent';" + "document.getElementById('" + $ElementId + "-cbtn-structured').style.color='var(--psu-term-muted-fg)';" + "document.getElementById('" + $ElementId + "-cbtn-structured').style.borderBottom='2px solid transparent';" ) $onClickStructured = ( "document.getElementById('" + $ElementId + "-panel-structured').style.display='';" + "document.getElementById('" + $ElementId + "-panel-terminal').style.display='none';" + "document.getElementById('" + $ElementId + "-cbtn-structured').style.background='var(--psu-term-tab-active-bg)';" + "document.getElementById('" + $ElementId + "-cbtn-structured').style.color='var(--psu-term-fg)';" + "document.getElementById('" + $ElementId + "-cbtn-structured').style.borderBottom='2px solid var(--psu-term-accent)';" + "document.getElementById('" + $ElementId + "-cbtn-terminal').style.background='transparent';" + "document.getElementById('" + $ElementId + "-cbtn-terminal').style.color='var(--psu-term-muted-fg)';" + "document.getElementById('" + $ElementId + "-cbtn-terminal').style.borderBottom='2px solid transparent';" ) # JS tooltip helper - appends to body to bypass overflow:hidden on parent containers $tipShow = "var _t=document.createElement('div');_t.id='psu-tip';_t.textContent=this.dataset.tip;" + "_t.style.cssText='position:fixed;background:var(--psu-term-tooltip-bg);color:var(--psu-term-tooltip-fg);font-family:Consolas,Monaco,monospace;font-size:11px;padding:4px 8px;border-radius:4px;pointer-events:none;z-index:99999;white-space:nowrap;box-shadow:0 2px 6px rgba(0,0,0,0.4);';" + "document.body.appendChild(_t);" + "var r=this.getBoundingClientRect();" + "_t.style.left=(r.left+r.width/2-_t.offsetWidth/2)+'px';" + "_t.style.top=(r.top-_t.offsetHeight-6)+'px';" $tipHide = "var _t=document.getElementById('psu-tip');if(_t)_t.remove();" $lnToggleJs = ( "var p1=document.getElementById('" + $ElementId + "-panel-terminal');" + "var p2=document.getElementById('" + $ElementId + "-panel-structured');" + "if(p1)p1.classList.toggle('" + $ElementId + "-ln-hidden');" + "if(p2)p2.classList.toggle('" + $ElementId + "-ln-hidden');" + "var off=p1&&p1.classList.contains('" + $ElementId + "-ln-hidden');" + "this.style.opacity=off?'0.35':'1';" + "try{localStorage.setItem('" + $ElementId + "-ln',off?'0':'1')}catch(e){}" ) $lnToggleBtn = ( '<button id="' + $ElementId + '-ln-toggle" onclick="' + $lnToggleJs + '" data-tip="Toggle line numbers" ' + 'onmouseenter="' + $tipShow + '" onmouseleave="' + $tipHide + '" ' + 'style="padding:4px 10px;background:transparent;border:none;cursor:pointer;' + 'color:var(--psu-term-muted-fg);font-family:Consolas,Monaco,monospace;font-size:13px;font-weight:700;' + 'display:flex;align-items:center;outline:none;line-height:1;" ' + 'onmouseover="this.style.color=''var(--psu-term-fg)''" onmouseout="this.style.color=''var(--psu-term-muted-fg)''">#</button>' ) $tsToggleJs = ( "var p1=document.getElementById('" + $ElementId + "-panel-terminal');" + "var p2=document.getElementById('" + $ElementId + "-panel-structured');" + "if(p1)p1.classList.toggle('" + $ElementId + "-ts-hidden');" + "if(p2)p2.classList.toggle('" + $ElementId + "-ts-hidden');" + "var off=p1&&p1.classList.contains('" + $ElementId + "-ts-hidden');" + "this.style.opacity=off?'0.35':'1';" + "try{localStorage.setItem('" + $ElementId + "-ts',off?'0':'1')}catch(e){}" ) $tsToggleBtn = ( '<button id="' + $ElementId + '-ts-toggle" onclick="' + $tsToggleJs + '" data-tip="Toggle timestamps" ' + 'onmouseenter="' + $tipShow + '" onmouseleave="' + $tipHide + '" ' + 'style="padding:4px 8px;background:transparent;border:none;cursor:pointer;' + 'color:var(--psu-term-muted-fg);font-family:Consolas,Monaco,monospace;font-size:13px;' + 'display:flex;align-items:center;outline:none;line-height:1;" ' + 'onmouseover="this.style.color=''var(--psu-term-fg)''" onmouseout="this.style.color=''var(--psu-term-muted-fg)''">🕐</button>' ) $toggleBtns = '<div style="margin-left:auto;display:flex;align-items:center;">' + $lnToggleBtn + $tsToggleBtn + '</div>' # Restore toggle states on initial render: an explicit prior user choice in localStorage # always wins; otherwise fall back to the -HideLineNumbers/-HideTimestamps defaults. $showLnDefaultJs = if ($HideLineNumbers.IsPresent) { 'false' } else { 'true' } $showTsDefaultJs = if ($HideTimestamps.IsPresent) { 'false' } else { 'true' } $lnRestoreJs = ( "(function(){" + "try{" + "var p1=document.getElementById('" + $ElementId + "-panel-terminal');" + "var p2=document.getElementById('" + $ElementId + "-panel-structured');" + "var lnStored=localStorage.getItem('" + $ElementId + "-ln');" + "var lnVisible=(lnStored===null)?" + $showLnDefaultJs + ":(lnStored!=='0');" + "if(!lnVisible){" + "if(p1)p1.classList.add('" + $ElementId + "-ln-hidden');" + "if(p2)p2.classList.add('" + $ElementId + "-ln-hidden');" + "var b=document.getElementById('" + $ElementId + "-ln-toggle');if(b)b.style.opacity='0.35';}" + "var tsStored=localStorage.getItem('" + $ElementId + "-ts');" + "var tsVisible=(tsStored===null)?" + $showTsDefaultJs + ":(tsStored!=='0');" + "if(!tsVisible){" + "if(p1)p1.classList.add('" + $ElementId + "-ts-hidden');" + "if(p2)p2.classList.add('" + $ElementId + "-ts-hidden');" + "var b=document.getElementById('" + $ElementId + "-ts-toggle');if(b)b.style.opacity='0.35';}" + "}catch(e){}})();" ) $titleBarHtml = ( '<div style="display:flex;align-items:stretch;height:100%;width:100%;">' + $iconSpan + '<button role="tab" id="' + $ElementId + '-cbtn-terminal" onclick="' + $onClickTerminal + '" style="padding:8px 16px;background:var(--psu-term-tab-active-bg);color:var(--psu-term-fg);border:none;border-right:1px solid var(--psu-term-border);cursor:pointer;font-family:Consolas,Monaco,monospace;font-size:12px;border-bottom:2px solid var(--psu-term-accent);outline:none;">Output</button>' + '<button role="tab" id="' + $ElementId + '-cbtn-structured" onclick="' + $onClickStructured + '" style="padding:8px 16px;background:transparent;color:var(--psu-term-muted-fg);border:none;border-right:1px solid var(--psu-term-border);cursor:pointer;font-family:Consolas,Monaco,monospace;font-size:12px;border-bottom:2px solid transparent;outline:none;">Structured Events</button>' + $toggleBtns + '</div>' ) } else { $titleBarHtml = ( '<div style="display:flex;align-items:center;height:100%;width:100%;">' + $iconSpan + '<span style="color:var(--psu-term-muted-fg);font-family:Consolas,Monaco,monospace;font-size:12px;padding:0 12px;display:flex;align-items:center;">Output</span>' + $toggleBtns + '</div>' ) } if (-not $PSCmdlet.ShouldProcess($JobId, 'Render PSU job terminal view')) { return } $terminalStates = @('Completed', 'Failed', 'Warning', 'Error', 'Stopped') $isTerminalJob = [string]$JobStatus -in $terminalStates $terminalDynamicId = "$ElementId-terminal-content" $structuredDynamicId = "$ElementId-structured-content" # ArgumentList: 0=JobId, 1=Token, 2=Url, 3=MaxRows, 4=view, 5=ElementId $dynArgs = @($JobId, $resolvedAppToken, $resolvedUrl, $MaxRows) # Shared dynamic content factory - fetches output and renders the given view type. # viewType: 'terminal' | 'structured' $makeDynContent = { $dynJobId = [Int64]$ArgumentList[0] $dynToken = [string]$ArgumentList[1] $dynUrl = [string]$ArgumentList[2] $dynMaxRows = [Int32]$ArgumentList[3] $dynView = [string]$ArgumentList[4] # 'terminal' or 'structured' $dynEid = [string]$ArgumentList[5] # ElementId for CSS class scoping Microsoft.PowerShell.Core\Import-Module -Name 'synedgy.universal.helper' -ErrorAction SilentlyContinue $helperModule = Microsoft.PowerShell.Core\Get-Module -Name 'synedgy.universal.helper' $dynRaw = @() try { $fetchParams = @{ JobId = $dynJobId AsObject = $true ComputerName = $dynUrl ErrorAction = 'Stop' } if (-not [string]::IsNullOrWhiteSpace($dynToken)) { $fetchParams['AppToken'] = $dynToken } $dynRaw = @(Get-PSUJobOutput @fetchParams) } catch { $dynRaw = @() } $markup = if ($null -ne $helperModule) { & $helperModule { param($raw, $max, $view, $eid) $rows = @($raw | Sort-Object -Property Timestamp | ConvertFrom-PsuJobOutputEntry) if ($max -gt 0) { $rows = @($rows | Select-Object -Last $max) } $lnClass = 'ln-' + $eid $tsClass = 'ts-' + $eid $lnStyle = 'color:var(--psu-term-muted-fg);min-width:3ch;text-align:right;margin-right:12px;user-select:none;flex-shrink:0;font-variant-numeric:tabular-nums;' if ($view -eq 'structured') { $i = 0 $structRows = @($rows | ForEach-Object { $i++ $ts = if ($_.Timestamp) { '{0:yyyy-MM-dd HH:mm:ss}' -f ($_.Timestamp -as [datetime]) } else { '' } $msgHtml = $_.Message | Convert-PlainTextToHtml "<tr><td class='$lnClass' style='padding:4px 6px 4px 10px;color:var(--psu-term-muted-fg);text-align:right;user-select:none;white-space:nowrap;vertical-align:top;font-variant-numeric:tabular-nums;'>$i</td><td class='$tsClass' style='padding:4px 10px;color:var(--psu-term-muted-fg);white-space:nowrap;vertical-align:top;'>{0}</td><td style='padding:4px 10px;color:{1};font-weight:600;white-space:nowrap;vertical-align:top;'>{2}</td><td style='padding:4px 10px;color:var(--psu-term-fg);white-space:pre-wrap;vertical-align:top;'>{3}</td></tr>" -f [System.Net.WebUtility]::HtmlEncode($ts), $_.StreamColor, [System.Net.WebUtility]::HtmlEncode($_.Stream), $msgHtml }) '<div style="max-height:500px;overflow:auto;background:var(--psu-term-bg);"><table style="width:100%;border-collapse:collapse;font-family:Consolas,Monaco,monospace;font-size:12px;"><thead><tr><th class="' + $lnClass + '" style="text-align:right;padding:6px 6px 6px 10px;color:var(--psu-term-muted-fg);border-bottom:1px solid var(--psu-term-border);background:var(--psu-term-bg);position:sticky;top:0;user-select:none;">#</th><th class="' + $tsClass + '" style="text-align:left;padding:6px 10px;color:var(--psu-term-muted-fg);border-bottom:1px solid var(--psu-term-border);background:var(--psu-term-bg);position:sticky;top:0;">Timestamp</th><th style="text-align:left;padding:6px 10px;color:var(--psu-term-muted-fg);border-bottom:1px solid var(--psu-term-border);background:var(--psu-term-bg);position:sticky;top:0;">Stream</th><th style="text-align:left;padding:6px 10px;color:var(--psu-term-muted-fg);border-bottom:1px solid var(--psu-term-border);background:var(--psu-term-bg);position:sticky;top:0;">Message</th></tr></thead><tbody>' + ($structRows -join '') + '</tbody></table></div>' } else { $i = 0 $lines = @($rows | ForEach-Object { $i++ $ts = if ($_.Timestamp) { '{0:yyyy-MM-dd HH:mm:ss}' -f ($_.Timestamp -as [datetime]) } else { '' } $msgHtml = $_.Message | Convert-AnsiToHtml "<div style='display:flex;margin:0 0 4px 0;'><span class='$lnClass' style='$lnStyle'>$i</span><span><span class='$tsClass' style='color:var(--psu-term-muted-fg);'>[{0}]</span> <span style='color:{1};font-weight:600;'>[{2}]</span> <span>{3}</span></span></div>" -f $ts, $_.StreamColor, $_.Stream, $msgHtml }) '<div style="font-family:Consolas,Monaco,monospace;font-size:12px;line-height:1.4;max-height:500px;overflow:auto;background:transparent;color:var(--psu-term-fg);padding:12px 14px;position:relative;z-index:1;">' + ($lines -join '') + '</div>' } } $dynRaw ([Int32]$dynMaxRows) $dynView $dynEid } else { '<div style="font-family:Consolas,Monaco,monospace;font-size:12px;padding:12px 14px;background:var(--psu-term-bg);color:var(--psu-term-stream-error);">Module unavailable.</div>' } $refreshedAt = '{0:yyyy-MM-dd HH:mm:ss}' -f [datetime]::Now New-UDHtml -Markup ($markup + "<div style='font-family:Consolas,Monaco,monospace;font-size:10px;color:var(--psu-term-muted-fg);padding:2px 14px 4px;background:var(--psu-term-bg);'>fetched $refreshedAt</div>") } New-UDElement -Tag 'div' -Id $ElementId -Attributes @{ style = @{ borderRadius = '6px' overflow = 'hidden' border = '1px solid var(--psu-term-border)' boxShadow = '0 2px 8px rgba(0,0,0,0.3)' marginTop = '4px' } } -Content { # Theme CSS custom properties (see Get-UDPsuJobThemeStyleBlock), line-number/timestamp # toggle CSS, and localStorage restore script. Emitted once here (not inside the # auto-refreshing dynamic panels below) since New-UDHtml's dangerouslySetInnerHTML # would otherwise reset it on every refresh. $themeStyleBlock = Get-UDPsuJobThemeStyleBlock -ElementId $ElementId -Theme $Theme -CustomCss $CustomCss $lnCss = '.' + $ElementId + '-ln-hidden .ln-' + $ElementId + ' { display:none !important; }' + '.' + $ElementId + '-ts-hidden .ts-' + $ElementId + ' { display:none !important; }' New-UDHtml -Markup ($themeStyleBlock + '<style>' + $lnCss + '</style><script>' + $lnRestoreJs + '</script>') # Title bar New-UDElement -Tag 'div' -Attributes @{ style = @{ background = 'var(--psu-term-chrome-bg)' display = 'block' borderBottom = '1px solid var(--psu-term-border)' } } -Content { New-UDHtml -Markup $titleBarHtml } # Terminal panel - PSU wrapper preserves display style across dynamic re-renders New-UDElement -Tag 'div' -Id "$ElementId-panel-terminal" -Attributes @{ style = @{ background = 'var(--psu-term-bg)'; position = 'relative' } } -Content { if ($svgWatermarkHtml) { New-UDHtml -Markup $svgWatermarkHtml } $termDynParams = @{ Id = $terminalDynamicId Content = $makeDynContent ArgumentList = @($JobId, $resolvedAppToken, $resolvedUrl, $MaxRows, 'terminal', $ElementId) } if (-not $isTerminalJob -and $AutoRefreshInterval -gt 0) { $termDynParams['AutoRefresh'] = $true $termDynParams['AutoRefreshInterval'] = $AutoRefreshInterval } New-UDDynamic @termDynParams } # Structured panel - PSU wrapper, initially hidden; display controlled by tab JS only if ($resolvedIncludeStructuredTable) { New-UDElement -Tag 'div' -Id "$ElementId-panel-structured" -Attributes @{ style = @{ display = 'none' } } -Content { New-UDDynamic -Id $structuredDynamicId -Content $makeDynContent -ArgumentList @($JobId, $resolvedAppToken, $resolvedUrl, $MaxRows, 'structured', $ElementId) } } # Status bar + refresh button (syncs both dynamics) $liveIndicator = if (-not $isTerminalJob) { "<span style='color:var(--psu-term-accent);margin-right:8px;'>● live</span>" } else { '' } New-UDElement -Tag 'div' -Attributes @{ style = @{ background = 'var(--psu-term-status-bar-bg)' padding = '4px 14px' borderTop = '1px solid var(--psu-term-border)' fontFamily = 'Consolas,Monaco,monospace' fontSize = '11px' display = 'flex' alignItems = 'center' } } -Content { New-UDHtml -Markup ($liveIndicator + $statusInner) New-UDButton -Variant 'text' -Size 'small' -Icon (New-UDIcon -Icon 'sync') -OnClick { Sync-UDElement -Id $terminalDynamicId -ArgumentList @($JobId, $resolvedAppToken, $resolvedUrl, $MaxRows, 'terminal', $ElementId) if ($resolvedIncludeStructuredTable) { Sync-UDElement -Id $structuredDynamicId -ArgumentList @($JobId, $resolvedAppToken, $resolvedUrl, $MaxRows, 'structured', $ElementId) } } -Style @{ color = 'var(--psu-term-muted-fg)' marginLeft = 'auto' minWidth = 'unset' padding = '0 8px' fontSize = '11px' } } } } #EndRegion './Public/UDComponents/New-UDPsuJobTerminalView.ps1' 518 #Region './suffix.ps1' -1 #region TypeAccelerator export for classes # If classes/types are specified here, they won't be fully qualified (careful with conflicts) $typesToExportAsIs = @( 'APIEndpoint' 'Documentation' 'APIInput' 'APIOutput' 'AiTool' ) # The type accelerators created will be ModuleName.ClassName (to avoid conflicts with other modules until you use 'using moduleName' $typesToExportWithNamespace = @( ) # inspired from https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_classes?view=powershell-7.5 # Always clobber an existing type accelerator, but # warn if a type accelerator with the same name exists. function Get-CurrentModule { <# .SYNOPSIS This is a Private function to always be able to retrieve the module info even outside of a function (i.e. PSM1 during module loading) .DESCRIPTION This function is only meant to be used from the psm1, hence not exported. .EXAMPLE $null = Get-CurrentModule #> [OutputType([System.Management.Automation.PSModuleInfo])] param () # Get the current module $MyInvocation.MyCommand.ScriptBlock.Module } # Get the internal TypeAccelerators class to use its static methods. $typeAcceleratorsClass = [psobject].Assembly.GetType( 'System.Management.Automation.TypeAccelerators' ) $moduleName = (Get-CurrentModule).Name $existingTypeAccelerators = $typeAcceleratorsClass::Get foreach ($typeToExport in @($typesToExportWithNamespace + $typesToExportAsIs)) { if ($typeToExport -in $typesToExportAsIs) { $fullTypeToExport = $TypeToExport } else { $fullTypeToExport = '{0}.{1}' -f $moduleName,$TypeToExport } $type = $TypeToExport -as [System.Type] if (-not $type) { $Message = @( "Unable to register type accelerator '$fullTypeToExport' for '$typeToExport'" "Type '$typeToExport' not found." ) -join ' - ' throw [System.Management.Automation.ErrorRecord]::new( [System.InvalidOperationException]::new($Message), 'TypeAcceleratorTypeNotFound', [System.Management.Automation.ErrorCategory]::InvalidOperation, $fullTypeToExport ) } else { if ($fullTypeToExport -in $existingTypeAccelerators.Keys) { $Message = @( "Overriding type accelerator '$($fullTypeToExport)' with '$($Type.FullName)'" 'Accelerator already exists.' ) -join ' - ' Write-Debug -Message $Message } else { Write-Verbose -Message "Added type accelerator '$($fullTypeToExport)' for '$($Type.FullName)'." } $null = $TypeAcceleratorsClass::Add($fullTypeToExport, $Type) } } # Remove type accelerators when the module is removed. $MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = { foreach ($TypeName in $typesToExportWithNamespace) { $fullTypeToExport = '{0}.{1}' -f $moduleName,$TypeName $null = $TypeAcceleratorsClass::Remove($fullTypeToExport) } }.GetNewClosure() #endregion #EndRegion './suffix.ps1' 106 |