VBAF.Center.WebPortal.ps1
|
#Requires -Version 5.1 <# .SYNOPSIS VBAF-Center Phase 9 — Web Portal .DESCRIPTION Starts a local web server and opens a browser dashboard showing live signals, AI recommendations and run history for any VBAF-Center customer. Phase 14 — Signal colours from GoodBelow/BadAbove thresholds Phase 15 — Signal weight displayed per signal Phase 18 — Accept/Override buttons for write-back Phase 16 — Threshold suggestion Yes/No buttons Functions: Start-VBAFCenterPortal — start the web portal Stop-VBAFCenterPortal — stop the web portal Get-VBAFCenterPortalURLs — show all customer portal URLs #> $script:PortalListener = $null $script:PortalRunning = $false $script:PortalPort = 8080 # ============================================================ # VALIDATE TOKEN # ============================================================ function Test-PortalToken { param([string]$CustomerID, [string]$Token) if ($CustomerID -eq "" -or $Token -eq "") { return $false } $schedFile = Join-Path $env:USERPROFILE "VBAFCenter\schedules\$CustomerID-schedule.json" if (-not (Test-Path $schedFile)) { return $false } $sched = Get-Content $schedFile -Raw | ConvertFrom-Json if (-not $sched.PortalToken) { return $false } return ($sched.PortalToken -eq $Token) } # ============================================================ # GET-VBAFCENTERPORTALURLS # ============================================================ function Get-VBAFCenterPortalURLs { param([int] $Port = 8080) $schedPath = Join-Path $env:USERPROFILE "VBAFCenter\schedules" if (-not (Test-Path $schedPath)) { Write-Host "No customers found." -ForegroundColor Yellow; return } $files = Get-ChildItem $schedPath -Filter "*.json" if ($files.Count -eq 0) { Write-Host "No customers found." -ForegroundColor Yellow; return } Write-Host "" Write-Host " +--------------------------------------------------+" -ForegroundColor Cyan Write-Host " | VBAF-Center Portal URLs |" -ForegroundColor Cyan Write-Host " +--------------------------------------------------+" -ForegroundColor Cyan Write-Host "" foreach ($file in $files) { $s = Get-Content $file.FullName -Raw | ConvertFrom-Json if ($s.PortalToken) { Write-Host (" Customer : {0}" -f $s.CustomerID) -ForegroundColor White Write-Host (" URL : http://localhost:{0}/?customer={1}&token={2}" -f $Port, $s.CustomerID, $s.PortalToken) -ForegroundColor Yellow Write-Host "" } } } # ============================================================ # CUSTOMER TABS # ============================================================ function Get-PortalCustomerTabs { param([string]$CurrentCustomerID, [int]$Port = 8080) $schedPath = Join-Path $env:USERPROFILE "VBAFCenter\schedules" $tabs = "" if (Test-Path $schedPath) { Get-ChildItem $schedPath -Filter "*.json" | ForEach-Object { $s = Get-Content $_.FullName -Raw | ConvertFrom-Json if ($s.PortalToken) { $active = if ($s.CustomerID -eq $CurrentCustomerID) { " class='tab active'" } else { " class='tab'" } $url = "http://localhost:$Port/?customer=$($s.CustomerID)&token=$($s.PortalToken)" $label = if ($s.CompanyName) { $s.CompanyName } else { $s.CustomerID } $tabs += "<a href='$url'$active>$label</a>" } } } return $tabs } # ============================================================ # SIGNAL COLOUR # ============================================================ function Resolve-PortalSignalColour { param([double]$RawValue, [double]$Normalised, [double]$GoodBelow = -1, [double]$BadAbove = -1) if ($GoodBelow -ge 0 -or $BadAbove -ge 0) { if ($BadAbove -ge 0 -and $RawValue -gt $BadAbove) { return "#E24B4A" } if ($GoodBelow -ge 0 -and $RawValue -lt $GoodBelow) { return "#1D9E75" } return "#EF9F27" } if ($Normalised -gt 0.75) { return "#E24B4A" } if ($Normalised -gt 0.40) { return "#EF9F27" } return "#1D9E75" } function Resolve-PortalSignalStatus { param([string]$HexColour) switch ($HexColour) { "#E24B4A" { return "RED" } "#EF9F27" { return "WATCH" } default { return "OK" } } } # ============================================================ # GET THRESHOLD SUGGESTION # ============================================================ function Get-PortalThresholdSuggestion { param([string]$CustomerID) $suggPath = Join-Path $env:USERPROFILE "VBAFCenter\suggestions\$CustomerID-suggestion.json" $dismissPath = Join-Path $env:USERPROFILE "VBAFCenter\suggestions\$CustomerID-dismissed.txt" if (-not (Test-Path $suggPath)) { return $null } if (Test-Path $dismissPath) { $dismissedDate = Get-Content $dismissPath -Raw if ($dismissedDate -and [DateTime]::Parse($dismissedDate.Trim()) -gt (Get-Date).AddDays(-7)) { return $null } } try { return Get-Content $suggPath -Raw | ConvertFrom-Json } catch { return $null } } # ============================================================ # SAVE THRESHOLD SUGGESTION (call from Learning Engine) # ============================================================ function Save-VBAFCenterThresholdSuggestion { param( [string] $CustomerID, [double] $SuggestedAction1, [double] $SuggestedAction2, [double] $SuggestedAction3, [string] $Reason ) $suggPath = Join-Path $env:USERPROFILE "VBAFCenter\suggestions" if (-not (Test-Path $suggPath)) { New-Item -ItemType Directory -Path $suggPath -Force | Out-Null } @{ CustomerID = $CustomerID SuggestedAction1 = $SuggestedAction1 SuggestedAction2 = $SuggestedAction2 SuggestedAction3 = $SuggestedAction3 Reason = $Reason CreatedAt = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") } | ConvertTo-Json | Set-Content "$suggPath\$CustomerID-suggestion.json" -Encoding UTF8 Write-Host ("Threshold suggestion saved for: {0}" -f $CustomerID) -ForegroundColor Green } # ============================================================ # GET CUSTOMER DATA # ============================================================ function Get-PortalCustomerData { param([string]$CustomerID) $profilePath = Join-Path $env:USERPROFILE "VBAFCenter\customers\$CustomerID.json" if (-not (Test-Path $profilePath)) { return $null } $profile = Get-Content $profilePath -Raw | ConvertFrom-Json $signalPath = Join-Path $env:USERPROFILE "VBAFCenter\signals" $signals = @() if (Test-Path $signalPath) { Get-ChildItem $signalPath -Filter "$CustomerID-*.json" | Sort-Object Name | ForEach-Object { $sc = Get-Content $_.FullName -Raw | ConvertFrom-Json [double]$range = $sc.RawMax - $sc.RawMin [double]$raw = $sc.RawMin + (Get-Random -Minimum 0 -Maximum 100) / 100.0 * $range [double]$norm = if ($range -gt 0) { ($raw - $sc.RawMin) / $range } else { 0.0 } $norm = [Math]::Max(0.0, [Math]::Min(1.0, [Math]::Round($norm, 2))) $raw = [Math]::Round($raw, 1) $goodBelow = if ($null -ne $sc.GoodBelow -and $sc.GoodBelow -ge 0) { [double]$sc.GoodBelow } else { -1 } $badAbove = if ($null -ne $sc.BadAbove -and $sc.BadAbove -ge 0) { [double]$sc.BadAbove } else { -1 } $colour = Resolve-PortalSignalColour -RawValue $raw -Normalised $norm -GoodBelow $goodBelow -BadAbove $badAbove $status = Resolve-PortalSignalStatus -HexColour $colour $weight = if ($null -ne $sc.Weight -and $sc.Weight -gt 0) { [int]$sc.Weight } else { 3 } $threshLabel = "" if ($goodBelow -ge 0 -or $badAbove -ge 0) { $parts = @() if ($goodBelow -ge 0) { $parts += "Good <$goodBelow" } if ($badAbove -ge 0) { $parts += "Bad >$badAbove" } $threshLabel = $parts -join " | " } $signals += @{ SignalName = $sc.SignalName SignalIndex = $sc.SignalIndex RawValue = $raw Normalised = $norm Status = $status Color = $colour SourceType = $sc.SourceType Weight = $weight ThreshLabel = $threshLabel ThreshActive = ($goodBelow -ge 0 -or $badAbove -ge 0) } } } $historyPath = Join-Path $env:USERPROFILE "VBAFCenter\history" $action = 0 $actionName = "Monitor" $actionColor = "#1D9E75" $actionReason = "" $overrideApplied = $false $redCount = 0 $yellowCount = 0 $avgUsed = 0.0 $weightedAvg = $null $lastTimestamp = "No runs yet" if (Test-Path $historyPath) { $latestFile = Get-ChildItem $historyPath -Filter "$CustomerID-*.json" | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if ($latestFile) { $h = Get-Content $latestFile.FullName -Raw | ConvertFrom-Json $action = [int]$h.Action $actionName = $h.ActionName $actionColor = @("#1D9E75","#EF9F27","#EF6B27","#E24B4A")[$action] $actionReason = if ($h.ActionReason) { $h.ActionReason } else { "" } $overrideApplied = if ($null -ne $h.OverrideApplied) { [bool]$h.OverrideApplied } else { $false } $redCount = if ($null -ne $h.RedSignalCount) { [int]$h.RedSignalCount } else { 0 } $yellowCount = if ($null -ne $h.YellowSignalCount) { [int]$h.YellowSignalCount } else { 0 } $avgUsed = if ($null -ne $h.AvgSignal) { [double]$h.AvgSignal } else { 0.0 } $weightedAvg = if ($null -ne $h.WeightedAvg) { $h.WeightedAvg } else { $null } $lastTimestamp = $h.Timestamp } } $actionFile = Join-Path $env:USERPROFILE "VBAFCenter\actions\$CustomerID-actions.txt" $actionCommand = "" if (Test-Path $actionFile) { Get-Content $actionFile | ForEach-Object { $parts = $_ -split "\|" if ($parts.Length -ge 3 -and [int]$parts[0] -eq $action) { $actionCommand = $parts[2] } } } $history = @() if (Test-Path $historyPath) { Get-ChildItem $historyPath -Filter "$CustomerID-*.json" | Sort-Object LastWriteTime -Descending | Select-Object -First 10 | ForEach-Object { $h = Get-Content $_.FullName -Raw | ConvertFrom-Json $history += @{ Timestamp = $h.Timestamp Action = $h.Action ActionName = $h.ActionName AvgSignal = $h.AvgSignal WeightedAvg = $h.WeightedAvg ActionReason = if ($h.ActionReason) { $h.ActionReason } else { "" } OverrideApplied = if ($null -ne $h.OverrideApplied) { [bool]$h.OverrideApplied } else { $false } RedSignalCount = if ($null -ne $h.RedSignalCount) { [int]$h.RedSignalCount } else { 0 } YellowSignalCount = if ($null -ne $h.YellowSignalCount){ [int]$h.YellowSignalCount } else { 0 } } } } # Current thresholds $thresholds = $null $schedFile = Join-Path $env:USERPROFILE "VBAFCenter\schedules\$CustomerID-schedule.json" if (Test-Path $schedFile) { $sched = Get-Content $schedFile -Raw | ConvertFrom-Json $thresholds = @{ Action1 = if ($sched.Action1Threshold) { $sched.Action1Threshold } else { 0.25 } Action2 = if ($sched.Action2Threshold) { $sched.Action2Threshold } else { 0.50 } Action3 = if ($sched.Action3Threshold) { $sched.Action3Threshold } else { 0.75 } } } return @{ Profile = $profile Signals = @($signals) Action = $action ActionName = $actionName ActionCommand = $actionCommand ActionColor = $actionColor ActionReason = $actionReason OverrideApplied = $overrideApplied RedSignalCount = $redCount YellowSignalCount = $yellowCount AvgSignal = $avgUsed WeightedAvg = $weightedAvg LastTimestamp = $lastTimestamp History = $history Thresholds = $thresholds Timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") } } # ============================================================ # HANDLE POST — write-back accept/override + threshold # ============================================================ function Invoke-PortalPostAction { param([string]$CustomerID, [string]$PostBody, [int]$Port) $params = @{} $PostBody -split "&" | ForEach-Object { $kv = $_ -split "=" if ($kv.Length -ge 2) { $params[$kv[0]] = [System.Uri]::UnescapeDataString($kv[1]) } } $postAction = $params["postaction"] switch ($postAction) { "accept" { # Dispatcher accepts VBAF recommendation — write to TMS Write-Host (" [PORTAL] Accept clicked — CustomerID: {0}" -f $CustomerID) -ForegroundColor Green if (Get-Command Invoke-VBAFCenterWriteBack -ErrorAction SilentlyContinue) { $actionNum = [int]$params["action"] Invoke-VBAFCenterWriteBack -CustomerID $CustomerID -Action $actionNum -Note "Dispatcher accepted via portal" Write-Host (" [PORTAL] Write-back fired: Action {0}" -f $actionNum) -ForegroundColor Green } else { Write-Host " [PORTAL] WriteBack module not loaded." -ForegroundColor Yellow } } "override" { # Dispatcher overrides — log the reason Write-Host (" [PORTAL] Override clicked — CustomerID: {0}" -f $CustomerID) -ForegroundColor Yellow $vbafAction = [int]$params["vbafaction"] $dispAction = [int]$params["dispaction"] $reason = $params["reason"] if (-not $reason) { $reason = "No reason given" } if (Get-Command Start-VBAFCenterOverride -ErrorAction SilentlyContinue) { Start-VBAFCenterOverride -CustomerID $CustomerID -VBAFAction $vbafAction -DispatcherAction $dispAction -Reason $reason Write-Host (" [PORTAL] Override logged: VBAF={0} Dispatcher={1}" -f $vbafAction, $dispAction) -ForegroundColor Yellow } else { Write-Host " [PORTAL] Override module not loaded." -ForegroundColor Yellow } } "apply-threshold" { # Dispatcher approves suggested threshold change Write-Host (" [PORTAL] Threshold apply clicked — CustomerID: {0}" -f $CustomerID) -ForegroundColor Cyan $a1 = [double]$params["a1"] $a2 = [double]$params["a2"] $a3 = [double]$params["a3"] if (Get-Command Set-VBAFCenterActionThresholds -ErrorAction SilentlyContinue) { Set-VBAFCenterActionThresholds -CustomerID $CustomerID -Action1 $a1 -Action2 $a2 -Action3 $a3 Write-Host (" [PORTAL] Thresholds applied: {0} / {1} / {2}" -f $a1, $a2, $a3) -ForegroundColor Green } # Remove suggestion $suggFile = Join-Path $env:USERPROFILE "VBAFCenter\suggestions\$CustomerID-suggestion.json" if (Test-Path $suggFile) { Remove-Item $suggFile -Force } } "dismiss-threshold" { # Dispatcher dismisses threshold suggestion for 7 days Write-Host (" [PORTAL] Threshold dismissed — CustomerID: {0}" -f $CustomerID) -ForegroundColor DarkGray $dismissPath = Join-Path $env:USERPROFILE "VBAFCenter\suggestions" if (-not (Test-Path $dismissPath)) { New-Item -ItemType Directory -Path $dismissPath -Force | Out-Null } (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") | Set-Content "$dismissPath\$CustomerID-dismissed.txt" -Encoding UTF8 } } } # ============================================================ # ACCESS DENIED PAGE # ============================================================ function Get-PortalDeniedHTML { return @" <!DOCTYPE html><html lang='en'><head><meta charset='UTF-8'> <title>VBAF-Center — Access Denied</title> <style>*{box-sizing:border-box;margin:0;padding:0}body{font-family:Arial,sans-serif;background:#f4f4f0;display:flex;align-items:center;justify-content:center;min-height:100vh}.box{background:#fff;border-radius:12px;padding:40px;text-align:center;max-width:400px}.icon{font-size:48px;margin-bottom:16px}h1{font-size:20px;color:#E24B4A;margin-bottom:8px}p{font-size:14px;color:#888;line-height:1.6}</style> </head><body><div class='box'><div class='icon'>🔒</div><h1>Access Denied</h1><p>This portal requires a valid customer URL with token.<br><br>Contact your VBAF-Center administrator.</p></div></body></html> "@ } # ============================================================ # BUILD PORTAL HTML # ============================================================ function Get-PortalHTML { param([string]$CustomerID = "", [string]$Token = "", [int]$Port = 8080, [string]$Message = "") $data = Get-PortalCustomerData -CustomerID $CustomerID if (-not $data) { return Get-PortalDeniedHTML } $tabs = Get-PortalCustomerTabs -CurrentCustomerID $CustomerID -Port $Port $suggestion = Get-PortalThresholdSuggestion -CustomerID $CustomerID $baseURL = "http://localhost:$Port/?customer=$CustomerID&token=$Token" # Signal rows $signalRows = ($data.Signals | ForEach-Object { $wb = "<span style='background:#f0f0ee;color:#666;border-radius:4px;padding:2px 6px;font-size:11px;'>W$($_.Weight)/5</span>" $tc = if ($_.ThreshActive) { "<span style='font-size:11px;color:#888;'>$($_.ThreshLabel)</span>" } else { "<span style='color:#ccc;'>—</span>" } "<tr> <td><b>$($_.SignalName)</b></td> <td style='color:$($_.Color);font-weight:500'>$($_.RawValue)</td> <td style='color:$($_.Color);font-weight:500'>$($_.Normalised)</td> <td><span class='badge' style='background:$($_.Color)20;color:$($_.Color);border:1px solid $($_.Color)40'>$($_.Status)</span></td> <td>$wb</td><td>$tc</td> <td style='color:#888;font-size:12px'>$($_.SourceType)</td> </tr>" }) -join "`n" if (-not $signalRows) { $signalRows = "<tr><td colspan='7' style='text-align:center;color:#888'>No signals configured</td></tr>" } # History rows $historyRows = ($data.History | ForEach-Object { $hc = @("#1D9E75","#EF9F27","#EF6B27","#E24B4A")[[int]$_.Action] $ov = if ($_.OverrideApplied) { "<span style='background:#E24B4A20;color:#E24B4A;border:1px solid #E24B4A40;border-radius:4px;padding:1px 6px;font-size:11px;margin-left:4px'>OVERRIDE</span>" } else { "" } $rc = if ($_.RedSignalCount -gt 0) { "<span style='color:#E24B4A;font-weight:500'>$($_.RedSignalCount)</span>" } else { "<span style='color:#ccc'>—</span>" } $yc = if ($_.YellowSignalCount -gt 0) { "<span style='color:#EF9F27;font-weight:500'>$($_.YellowSignalCount)</span>" } else { "<span style='color:#ccc'>—</span>" } $wa = if ($null -ne $_.WeightedAvg) { $_.WeightedAvg.ToString("F4") } else { "—" } "<tr> <td style='font-size:12px;color:#888'>$($_.Timestamp)</td> <td style='color:$hc;font-weight:500'>$($_.ActionName)$ov</td> <td>$($_.AvgSignal)</td><td>$wa</td><td>$rc</td><td>$yc</td> </tr>" }) -join "`n" if (-not $historyRows) { $historyRows = "<tr><td colspan='6' style='text-align:center;color:#888'>No history yet</td></tr>" } # Override banner $overrideBanner = "" if ($data.OverrideApplied) { $overrideBanner = "<div class='card' style='border-left:4px solid #E24B4A;background:#fff5f5'><div style='display:flex;align-items:center;gap:12px'><span style='font-size:24px'>⚠</span><div><div style='font-weight:600;color:#E24B4A'>RED Signal Override Applied</div><div style='color:#666;font-size:13px;margin-top:4px'>$($data.ActionReason)</div></div></div></div>" } # Avg display $avgDisplay = if ($null -ne $data.WeightedAvg) { "Weighted avg: <b>$($data.WeightedAvg)</b> | Simple avg: $($data.AvgSignal)" } else { "Average signal: <b>$($data.AvgSignal)</b>" } # Signal summary $rc = @($data.Signals | Where-Object { $_.Color -eq "#E24B4A" }).Count $yc = @($data.Signals | Where-Object { $_.Color -eq "#EF9F27" }).Count $gc = @($data.Signals | Where-Object { $_.Color -eq "#1D9E75" }).Count $signalSummary = "<span style='color:#1D9E75;font-weight:500;margin-right:12px'>● $gc OK</span><span style='color:#EF9F27;font-weight:500;margin-right:12px'>● $yc Watch</span><span style='color:#E24B4A;font-weight:500'>● $rc Red</span>" # ── WRITE-BACK BUTTONS ──────────────────────────────────── $actionNum = $data.Action $actionName = $data.ActionName $writebackSection = @" <div class='card' style='border-left:4px solid $($data.ActionColor);margin-top:0'> <div class='rec-label'>VBAF Recommendation — $($data.LastTimestamp)</div> <div class='rec-action' style='color:$($data.ActionColor)'>$actionName</div> <div class='rec-command'>$($data.ActionCommand)</div> <div class='rec-avg'>$avgDisplay</div> $(if ($data.ActionReason -ne "" -and -not $data.OverrideApplied) { "<div class='rec-reason'>$($data.ActionReason)</div>" }) <div class='action-buttons'> <div style='font-size:12px;color:#888;margin-bottom:10px;margin-top:16px;text-transform:uppercase;letter-spacing:0.5px'>Dispatcher Action</div> <form method='POST' action='$baseURL' style='display:inline'> <input type='hidden' name='postaction' value='accept'> <input type='hidden' name='action' value='$actionNum'> <button type='submit' class='btn-accept'>✓ Accept & Execute</button> </form> <button class='btn-override' onclick="document.getElementById('override-form-$actionNum').style.display='block';this.style.display='none'">✗ Override</button> <div id='override-form-$actionNum' style='display:none;margin-top:12px;background:#fafafa;padding:14px;border-radius:8px;border:0.5px solid #ddd'> <form method='POST' action='$baseURL'> <input type='hidden' name='postaction' value='override'> <input type='hidden' name='vbafaction' value='$actionNum'> <div style='margin-bottom:8px;font-size:13px;font-weight:500'>What action did you take instead?</div> <div style='display:flex;gap:8px;margin-bottom:10px'> <label><input type='radio' name='dispaction' value='0' required> Monitor</label> <label><input type='radio' name='dispaction' value='1'> Reassign</label> <label><input type='radio' name='dispaction' value='2'> Reroute</label> <label><input type='radio' name='dispaction' value='3'> Escalate</label> </div> <textarea name='reason' placeholder='Why did you override? (helps VBAF learn)' rows='2' style='width:100%;padding:8px;border:0.5px solid #ddd;border-radius:6px;font-size:13px;resize:none;margin-bottom:8px'></textarea> <button type='submit' class='btn-override-submit'>Log Override</button> <button type='button' onclick="document.getElementById('override-form-$actionNum').style.display='none'" style='background:none;border:none;color:#888;cursor:pointer;margin-left:8px;font-size:13px'>Cancel</button> </form> </div> </div> </div> "@ # ── THRESHOLD SUGGESTION ────────────────────────────────── $thresholdSection = "" if ($suggestion) { $thresholdSection = @" <div class='card' style='border-left:4px solid #AFA9EC;background:#EEEDFE20'> <div style='display:flex;align-items:center;gap:10px;margin-bottom:12px'> <span style='font-size:20px'>🧠</span> <div> <div style='font-weight:600;color:#26215C;font-size:15px'>Learning Engine Suggestion</div> <div style='color:#666;font-size:13px;margin-top:2px'>$($suggestion.Reason)</div> </div> </div> <div style='background:#fff;border-radius:8px;padding:12px;margin-bottom:12px;font-size:13px'> <div style='display:flex;gap:24px'> <div><span style='color:#888'>Current Action1</span><br><b>$($data.Thresholds.Action1)</b></div> <div style='color:#AFA9EC;font-size:20px;padding-top:8px'>→</div> <div><span style='color:#26215C'>Suggested Action1</span><br><b style='color:#26215C'>$($suggestion.SuggestedAction1)</b></div> <div><span style='color:#888'>Current Action2</span><br><b>$($data.Thresholds.Action2)</b></div> <div style='color:#AFA9EC;font-size:20px;padding-top:8px'>→</div> <div><span style='color:#26215C'>Suggested Action2</span><br><b style='color:#26215C'>$($suggestion.SuggestedAction2)</b></div> <div><span style='color:#888'>Current Action3</span><br><b>$($data.Thresholds.Action3)</b></div> <div style='color:#AFA9EC;font-size:20px;padding-top:8px'>→</div> <div><span style='color:#26215C'>Suggested Action3</span><br><b style='color:#26215C'>$($suggestion.SuggestedAction3)</b></div> </div> </div> <div style='display:flex;gap:10px'> <form method='POST' action='$baseURL' style='display:inline'> <input type='hidden' name='postaction' value='apply-threshold'> <input type='hidden' name='a1' value='$($suggestion.SuggestedAction1)'> <input type='hidden' name='a2' value='$($suggestion.SuggestedAction2)'> <input type='hidden' name='a3' value='$($suggestion.SuggestedAction3)'> <button type='submit' class='btn-accept'>✓ Yes — Apply Thresholds</button> </form> <form method='POST' action='$baseURL' style='display:inline'> <input type='hidden' name='postaction' value='dismiss-threshold'> <button type='submit' class='btn-dismiss'>✗ No — Dismiss for 7 days</button> </form> </div> </div> "@ } # Message banner (after POST) $messageBanner = "" if ($Message -ne "") { $messageBanner = "<div style='background:#E1F5EE;border-left:4px solid #1D9E75;padding:12px 16px;border-radius:8px;margin-bottom:16px;font-size:13px;color:#04342C'>✓ $Message</div>" } return @" <!DOCTYPE html> <html lang='en'> <head> <meta charset='UTF-8'> <meta http-equiv='refresh' content='600'> <title>VBAF-Center — $($data.Profile.CompanyName)</title> <style> *{box-sizing:border-box;margin:0;padding:0} body{font-family:Arial,sans-serif;background:#f4f4f0;color:#2C2C2A;font-size:14px} .header{background:#2C2C2A;color:#fff;padding:16px 32px;display:flex;align-items:center;justify-content:space-between} .header h1{font-size:18px;font-weight:500} .header .ts{font-size:12px;color:#888} .tabs{background:#1a1a18;padding:0 32px;display:flex;gap:4px} .tab{display:inline-block;padding:10px 20px;color:#aaa;text-decoration:none;font-size:13px;border-bottom:3px solid transparent} .tab:hover{color:#fff;background:#2C2C2A} .tab.active{color:#fff;border-bottom:3px solid #EF9F27} .container{max-width:980px;margin:24px auto;padding:0 24px} .card{background:#fff;border-radius:8px;padding:20px 24px;margin-bottom:16px;box-shadow:0 1px 3px rgba(0,0,0,0.08)} .card-header{display:flex;justify-content:space-between;align-items:center;font-size:16px;font-weight:500;margin-bottom:8px} .meta{color:#666;font-size:13px} .badge{padding:3px 10px;border-radius:12px;font-size:12px;font-weight:500} .rec-label{font-size:12px;color:#888;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px} .rec-action{font-size:30px;font-weight:600;margin-bottom:4px} .rec-command{font-size:14px;color:#444;margin-bottom:4px} .rec-avg{font-size:13px;color:#888} .rec-reason{font-size:13px;color:#666;margin-top:8px;padding-top:8px;border-top:1px solid #f0f0ee} .section-title{font-weight:500;margin-bottom:12px;color:#444} table{width:100%;border-collapse:collapse} th{text-align:left;padding:8px 12px;background:#f8f8f6;color:#666;font-weight:500;font-size:12px;border-bottom:1px solid #eee} td{padding:10px 12px;border-bottom:1px solid #f0f0ee;font-size:13px;vertical-align:middle} tr:last-child td{border-bottom:none} .footer{text-align:center;color:#aaa;font-size:12px;padding:24px} .btn-accept{background:#1D9E75;color:#fff;border:none;padding:10px 20px;border-radius:6px;font-size:13px;font-weight:500;cursor:pointer;margin-right:8px} .btn-accept:hover{background:#178a64} .btn-override{background:#fff;color:#E24B4A;border:1px solid #E24B4A;padding:10px 20px;border-radius:6px;font-size:13px;font-weight:500;cursor:pointer} .btn-override:hover{background:#fff5f5} .btn-override-submit{background:#E24B4A;color:#fff;border:none;padding:8px 16px;border-radius:6px;font-size:13px;cursor:pointer} .btn-dismiss{background:#fff;color:#888;border:1px solid #ddd;padding:10px 20px;border-radius:6px;font-size:13px;cursor:pointer} .btn-dismiss:hover{background:#f8f8f6} .action-buttons{border-top:1px solid #f0f0ee;padding-top:16px;margin-top:12px} </style> </head> <body> <div class='header'> <h1>VBAF-Center Portal</h1> <span class='ts'>Live · Auto-refresh 10 min · $($data.Timestamp)</span> </div> <div class='tabs'>$tabs</div> <div class='container'> $messageBanner <div class='card'> <div class='card-header'> <span>$($data.Profile.CompanyName)</span> <span class='badge' style='background:#1D9E7520;color:#1D9E75;border:1px solid #1D9E7540'>$($data.Profile.Status)</span> </div> <div class='meta'>Agent: <b>$($data.Profile.Agent)</b> | Type: <b>$($data.Profile.BusinessType)</b> | $signalSummary</div> </div> $overrideBanner $thresholdSection $writebackSection <div class='card'> <div class='section-title'>Live Signals</div> <table> <thead><tr><th>Signal</th><th>Raw</th><th>Norm</th><th>Status</th><th>Weight</th><th>Thresholds</th><th>Source</th></tr></thead> <tbody>$signalRows</tbody> </table> </div> <div class='card'> <div class='section-title'>Run History (last 10)</div> <table> <thead><tr><th>Timestamp</th><th>Action</th><th>Avg</th><th>Weighted</th><th>Red</th><th>Yellow</th></tr></thead> <tbody>$historyRows</tbody> </table> </div> </div> <div class='footer'>VBAF-Center · Phase 14/15/18 active · Roskilde, Denmark · Built with PowerShell 5.1</div> </body> </html> "@ } # ============================================================ # START-VBAFCENTERPORTAL # ============================================================ function Start-VBAFCenterPortal { param([int]$Port = 8080) if ($script:PortalRunning) { Write-Host "Portal already running at http://localhost:$script:PortalPort" -ForegroundColor Yellow return } $script:PortalPort = $Port $script:PortalRunning = $true Write-Host "" Write-Host " Starting VBAF-Center Web Portal..." -ForegroundColor Cyan Write-Host (" URL : http://localhost:{0}" -f $Port) -ForegroundColor White Write-Host " Press Ctrl+C to stop." -ForegroundColor Yellow Write-Host "" Get-VBAFCenterPortalURLs -Port $Port $listener = [System.Net.HttpListener]::new() $listener.Prefixes.Add("http://localhost:$Port/") $listener.Start() $script:PortalListener = $listener $firstSched = Get-ChildItem (Join-Path $env:USERPROFILE "VBAFCenter\schedules") -Filter "*.json" -ErrorAction SilentlyContinue | Select-Object -First 1 if ($firstSched) { $s = Get-Content $firstSched.FullName -Raw | ConvertFrom-Json if ($s.PortalToken) { Start-Process ("http://localhost:{0}/?customer={1}&token={2}" -f $Port, $s.CustomerID, $s.PortalToken) } } Write-Host " Portal running — browser opened." -ForegroundColor Green Write-Host "" try { while ($script:PortalRunning) { $context = $listener.GetContext() $request = $context.Request $response = $context.Response $customerID = $request.QueryString["customer"] $token = $request.QueryString["token"] if (-not $customerID) { $customerID = "" } if (-not $token) { $token = "" } $valid = Test-PortalToken -CustomerID $customerID -Token $token $html = "" $message = "" if ($valid) { # Handle POST requests (button clicks) if ($request.HttpMethod -eq "POST") { $reader = [System.IO.StreamReader]::new($request.InputStream) $postBody = $reader.ReadToEnd() $reader.Close() Write-Host (" [POST] {0} — {1}" -f $customerID, $postBody) -ForegroundColor Cyan Invoke-PortalPostAction -CustomerID $customerID -PostBody $postBody -Port $Port # Extract action for message if ($postBody -like "*postaction=accept*") { $message = "Action accepted and sent to TMS." } elseif ($postBody -like "*postaction=override*") { $message = "Override logged. VBAF will learn from this." } elseif ($postBody -like "*postaction=apply-threshold*") { $message = "Thresholds updated successfully." } elseif ($postBody -like "*postaction=dismiss-threshold*") { $message = "Suggestion dismissed for 7 days." } } $html = Get-PortalHTML -CustomerID $customerID -Token $token -Port $Port -Message $message Write-Host (" [{0}] GET {1}" -f (Get-Date -Format "HH:mm:ss"), $customerID) -ForegroundColor Green } else { $html = Get-PortalDeniedHTML Write-Host (" [{0}] Denied: {1}" -f (Get-Date -Format "HH:mm:ss"), $request.RawUrl) -ForegroundColor Red } $buffer = [System.Text.Encoding]::UTF8.GetBytes($html) $response.Headers.Add("Cache-Control","no-cache,no-store,must-revalidate") $response.ContentType = "text/html; charset=utf-8" $response.ContentLength64 = $buffer.Length $response.OutputStream.Write($buffer, 0, $buffer.Length) $response.OutputStream.Close() } } finally { $listener.Stop() $script:PortalRunning = $false Write-Host " Portal stopped." -ForegroundColor Yellow } } # ============================================================ # STOP-VBAFCENTERPORTAL # ============================================================ function Stop-VBAFCenterPortal { $script:PortalRunning = $false if ($script:PortalListener) { $script:PortalListener.Stop() Write-Host "Portal stopped." -ForegroundColor Yellow } } # ============================================================ # LOAD MESSAGE # ============================================================ Write-Host "" Write-Host " +--------------------------------------------------+" -ForegroundColor Cyan Write-Host " | VBAF-Center Phase 9 — Web Portal |" -ForegroundColor Cyan Write-Host " | Phase 14: threshold colours + override banner |" -ForegroundColor Cyan Write-Host " | Phase 15: signal weights displayed |" -ForegroundColor Cyan Write-Host " | Phase 18: Accept/Override write-back buttons |" -ForegroundColor Cyan Write-Host " | Phase 16: Threshold suggestion Yes/No |" -ForegroundColor Cyan Write-Host " +--------------------------------------------------+" -ForegroundColor Cyan Write-Host "" Write-Host " Start-VBAFCenterPortal — open browser dashboard" -ForegroundColor White Write-Host " Stop-VBAFCenterPortal — stop the portal" -ForegroundColor White Write-Host " Get-VBAFCenterPortalURLs — show all customer URLs" -ForegroundColor White Write-Host " Save-VBAFCenterThresholdSuggestion — push suggestion to portal" -ForegroundColor White Write-Host "" |