Files
Logo/StackMonkey.ps1

1176 lines
40 KiB
PowerShell

# region changes to be done
# [Debug][FetchSites] calling webhook...
# [Error][FetchSites] webhook failed: The remote server returned an error: (403) Forbidden.
# [Debug][FetchSites] calling webhook...
# [Debug][FetchSites] getting OAuth token...
# [Debug][FetchSites] fetching sites list...
# need to have it install/update the Module
#endregion
# STACK = Scripted Tooling for Automated Client Kickoff
# MONKEY = Module-based Onboarding & Next-step Kickoff Engine Yoke
# Conveys the idea of coupling tasks together and keeping them under control.
#region Config & Task Definitions
# Listening port for HTTP UI
$Port = 8082
# Define every task once here:
# Id → checkbox HTML `id`
# Name → URL path (`/Name`)
# Label → user-visible text
# HandlerFn → the PowerShell function to invoke
# Page → which tab/page it appears on
$Global:Tasks = @(
# On-Boarding, left column
@{ Id='setSVSPowerplan'; Name='setSVSPowerplan'; Label='Set SVS Powerplan'; HandlerFn='Handle-setSVSPowerPlan'; Page='onboard'; Column='left' },
@{ Id='installSVSMSPModule'; Name='installSVSMSPModule'; Label='Install SVSMSP Module'; HandlerFn='Handle-InstallSVSMSP'; Page='onboard'; Column='left' },
@{ Id='installCyberQP'; Name='installCyberQP'; Label='Install CyberQP'; HandlerFn='Handle-InstallCyberQP'; Page='onboard'; Column='left' },
@{ Id='installSVSHelpDesk'; Name='installSVSHelpDesk'; Label='Install SVS HelpDesk'; HandlerFn='Handle-InstallSVSHelpDesk'; Page='onboard'; Column='left' },
@{ Id='installThreatLocker'; Name='installThreatLocker'; Label='Install ThreatLocker'; HandlerFn='Handle-InstallThreatLocker'; Page='onboard'; Column='left' },
@{ Id='installRocketCyber'; Name='installRocketCyber'; Label='Install RocketCyber'; HandlerFn='Handle-InstallRocketCyber'; Page='onboard'; Column='left' },
@{ Id='installDattoRMM'; Name='installDattoRMM'; Label='Install DattoRMM'; HandlerFn='Handle-InstallDattoRMM'; Page='onboard'; Column='left';
SubOptions= @(
@{ Value='inputVar'; Label='Copy Site Variables' },
@{ Value='rmm'; Label='Install RMM Agent' },
@{ Value='exe'; Label='Download Executable' }
)
},
# On-Boarding, right column (optional bits)
@{ Id='enableBitLocker'; Name='EnableBitLocker'; Label='Enable BitLocker'; HandlerFn='Set-SVSBitLocker'; Page='onboard'; Column='right' },
@{ Id='setEdgeDefaultSearch';Name='setedgedefaultsearch';Label='Set Edge Default Search';HandlerFn='set-EdgeDefaultSearchProvider';Page='onboard'; Column='right' },
# Off-Boarding
@{ Id='uninstallCyberQP'; Name='uninstallCyberQP'; Label='Uninstall CyberQP'; HandlerFn='Uninstall-CyberQP'; Page='offboard' },
@{ Id='uninstallSVSMSPModule';Name='uninstallSVSMSPModule';Label='Uninstall SVSMSP Module'; HandlerFn='Cleanup-SVSMSP'; Page='offboard' },
# Tweaks
@{ Id='disableAnimations'; Name='disableAnimations'; Label='Disable Animations'; HandlerFn='Disable-Animations'; Page='tweaks' },
# SVS Apps
@{ Id='wingetLastpass'; Name='wingetLastpass'; Label='LastPass Desktop App'; HandlerFn='Install-WingetLastPass'; Page='SVSApps' }
)
#endregion
#region Logging Helpers
# Initialize a global in-memory log cache
if (-not $Global:LogCache -or -not ($Global:LogCache -is [System.Collections.ArrayList])) {
$Global:LogCache = [System.Collections.ArrayList]::new()
}
# Core Write-Log function (advanced with event-log support)
function Write-LogHelper {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$Message,
[ValidateSet("Info","Warning","Error","Success","General")]
[string]$Level = "Info",
[string]$TaskCategory = "GeneralTask",
[switch]$LogToEvent,
[string]$EventSource = "SVSMSP_Module",
[string]$EventLog = "Application",
[int]$CustomEventID
)
$EventID = @{ Info=1000; Warning=2000; Error=3000; Success=4000; General=1000 }[$Level]
$Icon = @{Info=[System.Char]::ConvertFromUtf32(0x1F4CB);Warning=[char]0x26A0;Error=[char]0x274C;Success=[char]0x2705;General=[char]0x1F4E6}[$Level]
$logEntry = [PSCustomObject]@{
Timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
Level = $Level
Message = "$Icon [$Level] [$TaskCategory] $Message (EventID:$EventID)"
}
[void]$Global:LogCache.Add($logEntry)
if ($LogToEvent) {
try {
if (-not (Get-EventLog -LogName $EventLog -Source $EventSource -ErrorAction SilentlyContinue)) {
New-EventLog -LogName $EventLog -Source $EventSource -ErrorAction SilentlyContinue
}
Write-EventLog -LogName $EventLog -Source $EventSource `
-EntryType $Level -EventId $EventID `
-Message $Message
} catch {
Write-Host "([char]0x26A0) [Warning] [EventLog] Failed to write to Event Log: $($_.Exception.Message)" -ForegroundColor Yellow
}
}
}
# Hybrid wrapper: uses your module's Write-Log if available, else falls back
if (Get-Command Write-Log -ErrorAction SilentlyContinue) {
function Write-LogHybrid { param($Message,$Level,$TaskCategory,$LogToEvent) Write-Log @PSBoundParameters }
} else {
function Write-LogHybrid { param($Message,$Level,$TaskCategory,$LogToEvent) Write-LogHelper @PSBoundParameters }
}
#endregion
#region Handler Stubs
function Respond-Text {
param($Context, $Text)
$bytes = [Text.Encoding]::UTF8.GetBytes($Text)
$Context.Response.ContentType = 'text/plain'
$Context.Response.ContentLength64 = $bytes.Length
$Context.Response.OutputStream.Write($bytes,0,$bytes.Length)
$Context.Response.OutputStream.Close()
}
function Respond-HTML {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][object] $Context,
[Parameter(Mandatory = $true)][string] $Html
)
$bytes = [Text.Encoding]::UTF8.GetBytes($Html)
$Context.Response.ContentType = 'text/html'
$Context.Response.ContentLength64 = $bytes.Length
$Context.Response.OutputStream.Write($bytes, 0, $bytes.Length)
$Context.Response.OutputStream.Close()
}
# new helper to return JSON
function Respond-JSON {
param($Context, $Object)
$json = $Object | ConvertTo-Json -Depth 5
$bytes = [Text.Encoding]::UTF8.GetBytes($json)
$Context.Response.ContentType = 'application/json'
$Context.Response.ContentLength64 = $bytes.Length
$Context.Response.OutputStream.Write($bytes,0,$bytes.Length)
$Context.Response.OutputStream.Close()
}
#region Get-DattoApiCreds
function Get-DattoApiCredentials {
param ([string]$Password)
$url = "https://automate.svstools.ca/webhook/svsmspkit"
$headers = @{ "SVSMSPKit" = $Password }
try {
$response = Invoke-RestMethod -Uri $url -Headers $headers -Method GET
return @{
ApiUrl = $response.ApiUrl
ApiKey = $response.ApiKey
ApiSecretKey = $response.ApiSecretKey
}
} catch {
Write-LogHybrid "Failed to fetch API credentials: $($_.Exception.Message)" "Error" "DattoAuth"
return $null
}
}
#endregion
#region Install-DattoRMM-Helper
function Install-DattoRMM-Helper {
param (
[string]$ApiUrl,
[string]$ApiKey,
[string]$ApiSecretKey,
[switch]$FetchSitesOnly,
[string]$SiteName,
[string]$SiteUID
)
if (-not $ApiUrl -or -not $ApiKey -or -not $ApiSecretKey) {
Write-LogHybrid -Message "Missing required parameters. Please provide ApiUrl, ApiKey, and ApiSecretKey." -Level "Error" -LogToEvent
return
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Write-LogHybrid -Message "Fetching OAuth token..." -Level "Info"
try {
$securePassword = ConvertTo-SecureString -String 'public' -AsPlainText -Force
$apiGenToken = Invoke-WebRequest -Credential (New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList ('public-client', $securePassword)) `
-Uri ('{0}/auth/oauth/token' -f $ApiUrl) `
-Method 'POST' `
-ContentType 'application/x-www-form-urlencoded' `
-Body ('grant_type=password&username={0}&password={1}' -f $ApiKey, $ApiSecretKey) `
| ConvertFrom-Json
$requestToken = $apiGenToken.access_token
Write-LogHybrid -Message "OAuth token fetched successfully." -Level "Success" -LogToEvent
} catch {
Write-LogHybrid -Message "Failed to fetch OAuth token. Details: $($_.Exception.Message)" -Level "Error" -LogToEvent
return
}
$getHeaders = @{"Authorization" = "Bearer $requestToken"}
if ($FetchSitesOnly) {
Write-Host "Fetching list of sites from the Datto RMM API..." -ForegroundColor Cyan
try {
$getHeaders = @{"Authorization" = "Bearer $requestToken" }
$getSites = Invoke-WebRequest -Uri "$ApiUrl/api/v2/account/sites" -Method Get -Headers $getHeaders -ContentType "application/json"
$sitesJson = $getSites.Content | ConvertFrom-Json
$siteList = $sitesJson.sites | ForEach-Object {
[PSCustomObject]@{
Name = $_.name
UID = $_.uid
}
}
Write-Host "Successfully fetched list of sites." -ForegroundColor Green
return $siteList
}
catch {
Write-Host "Failed to fetch sites from the API. Details: $($_.Exception.Message)" -ForegroundColor Red
return
}
}
}
#endregion
#region SVS Module
function Install-SVSMSP {
param (
[switch] $Cleanup,
[switch] $InstallToolkit,
[Parameter(Mandatory = $false)][array] $AllModules = @(@{ ModuleName = "SVS_Toolkit" }, @{ ModuleName = "SVSMSP" }),
[Parameter(Mandatory = $false)][array] $AllRepositories = @(@{ RepoName = "SVS_Repo" }, @{ RepoName = "SVS_Toolkit" }),
[Parameter(Mandatory = $false)][string] $NewModuleName = "SVSMSP",
[Parameter(Mandatory = $false)][string] $NewRepositoryName = "SVS_Repo",
[Parameter(Mandatory = $false)][string] $NewRepositoryURL = "http://proget.svstools.ca:8083/nuget/SVS_Repo/"
)
function Perform-Cleanup {
Write-LogHybrid "Cleanup mode enabled. Starting cleanup..." "Info" "SVSModule"
# …your old cleanup logic here…
}
function Perform-ToolkitInstallation {
Perform-Cleanup
Write-LogHybrid "Registering repo $NewRepositoryName" "Info" "SVSModule"
if (-not (Get-PSRepository -Name $NewRepositoryName -ErrorAction SilentlyContinue)) {
Register-PSRepository -Name $NewRepositoryName -SourceLocation $NewRepositoryURL -InstallationPolicy Trusted
}
Write-LogHybrid "Installing module $NewModuleName" "Info" "SVSModule"
Install-Module -Name $NewModuleName -Repository $NewRepositoryName -Scope AllUsers -Force
Write-LogHybrid "Toolkit installation complete." "Success" "SVSModule"
}
Write-LogHybrid "Install-SVSMSP called" "Info" "SVSModule"
if ($Cleanup) {
Perform-Cleanup; return
}
if ($InstallToolkit) {
Perform-ToolkitInstallation; return
}
# default if no switch passed:
Perform-ToolkitInstallation
}
#endregion
# POST /getpw → read JSON body, call helper, return JSON
function Handle-FetchSites {
param($Context)
# 1) Read incoming JSON
$raw = (New-Object IO.StreamReader $Context.Request.InputStream).ReadToEnd()
try {
$pw = (ConvertFrom-Json $raw).password
} catch {
Write-Host "[Error][FetchSites] Invalid JSON: $_"
returnEmpty $Context; return
}
# 2) Fetch your Datto API creds from the webhook
Write-Host "[Debug][FetchSites] calling webhook..."
try {
$hdr = @{ "SVSMSPKit" = $pw }
$resp = Invoke-RestMethod -Uri "https://automate.svstools.ca/webhook/svsmspkit" `
-Headers $hdr -Method Get
$apiUrl = $resp.ApiUrl
$apiKey = $resp.ApiKey
$apiSecretKey = $resp.ApiSecretKey
} catch {
Write-Host "[Error][FetchSites] webhook failed: $_"
returnEmpty $Context; return
}
# 3) Exchange for a bearer token
Write-Host "[Debug][FetchSites] getting OAuth token..."
try {
$securePublic = ConvertTo-SecureString -String 'public' -AsPlainText -Force
$creds = New-Object System.Management.Automation.PSCredential('public-client',$securePublic)
$tokenResp = Invoke-RestMethod -Uri "$apiUrl/auth/oauth/token" `
-Credential $creds `
-Method Post `
-ContentType 'application/x-www-form-urlencoded' `
-Body "grant_type=password&username=$apiKey&password=$apiSecretKey"
$token = $tokenResp.access_token
} catch {
Write-Host "[Error][FetchSites] token request failed: $_"
returnEmpty $Context; return
}
# 4) Pull the site list
Write-Host "[Debug][FetchSites] fetching sites list..."
try {
$hdr = @{ Authorization = "Bearer $token" }
$sitesResp = Invoke-RestMethod -Uri "$apiUrl/api/v2/account/sites" `
-Method Get `
-Headers $hdr `
-ContentType 'application/json'
$siteList = $sitesResp.sites | ForEach-Object {
[PSCustomObject]@{ Name = $_.name; UID = $_.uid }
}
} catch {
Write-Host "[Error][FetchSites] site list failed: $_"
returnEmpty $Context; return
}
# 5) Return JSON array
$json = $siteList | ConvertTo-Json -Depth 2
$bytes = [Text.Encoding]::UTF8.GetBytes($json)
$Context.Response.ContentType = 'application/json'
$Context.Response.ContentLength64 = $bytes.Length
$Context.Response.OutputStream.Write($bytes, 0, $bytes.Length)
$Context.Response.OutputStream.Close()
}
# Helper function to consistently return an empty JSON array
function returnRespondEmpty {
param($Context)
$empty = [Text.Encoding]::UTF8.GetBytes("[]")
$Context.Response.StatusCode = 500
$Context.Response.ContentType = 'application/json'
$Context.Response.ContentLength64 = $empty.Length
$Context.Response.OutputStream.Write($empty, 0, $empty.Length)
$Context.Response.OutputStream.Close()
}
# On-boarding handlers
function Handle-SetSVSPowerPlan {
param($Context)
# 1) call into your module
Set-SVSPowerPlan
# 2) log & write back a simple text response
Write-LogHybrid "PowerPlan set" "Success" "OnBoard"
Respond-Text $Context "PowerPlan applied"
}
function Handle-InstallSVSMSP {
param($Context)
Write-LogHybrid "HTTP trigger: Handle-InstallSVSMSP" "Info" "OnBoard"
try {
Install-SVSMSP -InstallToolkit
Respond-Text $Context "SVSMSP Module installed/updated."
} catch {
Write-LogHybrid "Error in Install-SVSMSP: $_" "Error" "OnBoard"
Respond-Text $Context "ERROR: $_"
}
}
function Handle-InstallCyberQP {
param($Context)
# 1) call into your module
Install-CyberQP
# 2) log & write back a simple text response
Write-LogHybrid "CyberQP installed" "Success" "OnBoard"
Respond-Text $Context "CyberQP installed"
}
function Handle-InstallThreatLocker {
param($Context)
# 1) call into your module
Install-ThreatLocker
# 2) log & write back a simple text response
Write-LogHybrid "ThreatLocker installed" "Success" "OnBoard"
Respond-Text $Context "ThreatLocker installed"
}
function Handle-InstallRocketCyber {
param($Context)
# 1) call into your module
Install-RocketCyber
# 2) log & write back a simple text response
Write-LogHybrid "RocketCyber installed" "Success" "OnBoard"
Respond-Text $Context "RocketCyber installed"
}
function Handle-InstallSVSHelpDesk {
param($Context)
# 1) call into your module
Install-SVSHelpDesk
# 2) log & write back a simple text response
Write-LogHybrid "SVS HelpDesk installed" "Success" "OnBoard"
Respond-Text $Context "SVS HelpDesk installed"
}
function Handle-InstallDattoRMM {
param($Context)
$request = $Context.Request
$response = $Context.Response
if ($request.HttpMethod -ne "POST") {
$response.StatusCode = 405
$response.ContentType = "text/plain"
$response.OutputStream.Write(
[Text.Encoding]::UTF8.GetBytes("Method not allowed. Use POST."),
0, 29
)
$response.OutputStream.Close()
return
}
try {
$body = (New-Object IO.StreamReader $request.InputStream).ReadToEnd()
$requestData = $body | ConvertFrom-Json
$checked = $requestData.checkedValues
$UID = $requestData.UID
$Name = $requestData.Name
if (-not $checked -or -not $UID -or -not $Name) {
throw "Missing required parameters"
}
# Build the command
$cmd = "Install-DattoRMM -ApiUrl '$ApiUrl' -ApiKey '$ApiKey' -ApiSecretKey '$ApiSecretKey' -SiteName '$Name' -SiteUID '$UID'"
if ($checked -contains 'inputVar') { $cmd += " -PushSiteVars" }
if ($checked -contains 'rmm') { $cmd += " -InstallRMM" }
if ($checked -contains 'exe') { $cmd += " -SaveCopy" }
# Invoke and respond
try {
Invoke-Expression $cmd
Write-LogHybrid "RMM install triggered for $Name" "Success" "DattoRMM"
$response.StatusCode = 200
$responseString = "RMM installation triggered successfully for $Name."
} catch {
Write-LogHybrid "Error triggering RMM install: $_" "Error" "DattoRMM"
$response.StatusCode = 500
$responseString = "Error triggering RMM install: $_"
}
}
catch {
Write-LogHybrid "Bad request to /installDattoRMM: $_" "Error" "DattoRMM"
$response.StatusCode = 400
$responseString = "Error: $($_.Exception.Message)"
}
# write the response
$bytes = [Text.Encoding]::UTF8.GetBytes($responseString)
$response.ContentType = "text/plain"
$response.ContentLength64 = $bytes.Length
$response.OutputStream.Write($bytes, 0, $bytes.Length)
$response.OutputStream.Close()
}
# Off-boarding handlers
function Uninstall-CyberQP {
param($Context)
# 1) call into your module
Uninstall-CyberQP
Write-LogHybrid "CyberQP uninstalled" "Success" "OffBoard"
Respond-Text $Context "CyberQP uninstalled"
}
function Cleanup-SVSMSP {
param($Context)
Write-LogHybrid "SVSMSP cleaned up" "Success" "OffBoard"
Respond-Text $Context "SVSMSP cleaned up"
}
# Tweaks handler
function Disable-Animations {
param($Context)
Write-LogHybrid "Animations disabled" "Success" "Tweaks"
Respond-Text $Context "Animations disabled"
}
# SVSApps handler
function Install-WingetLastPass {
param($Context)
Write-LogHybrid "Winget LastPass installed" "Success" "SVSApps"
Respond-Text $Context "Winget LastPass installed"
}
#endregion
#region UI Generation
function Build-Checkboxes {
param($Page, $Column)
(
$Global:Tasks |
Where-Object Page -EQ $Page |
Where-Object Column -EQ $Column |
ForEach-Object {
$taskId = $_.Id
$html = "<label><input type='checkbox' id='$taskId' name='$($_.Name)' data-column='$Column'> $($_.Label)</label>"
if ($_.SubOptions) {
$subHtml = (
$_.SubOptions | ForEach-Object {
"<label style='margin-left:20px; display:block;'>
<input type='checkbox' class='sub-option-$taskId' name='$($_.Value)' value='$($_.Value)'> $($_.Label)
</label>"
}
) -join "`n"
$html += @"
<div id='${taskId}OptionsContainer' style='display:none; margin-top:4px;'>
$subHtml
</div>
"@
}
$html
}
) -join "`n"
}
### Get SVSMSP module version to display in the UI
function Get-ModuleVersionHtml {
$mod = Get-Module -ListAvailable -Name SVSMSP | Sort-Object Version -Descending | Select-Object -First 1
if ($mod) {
return "<div style='color:#bbb; font-size:0.9em; margin-top:1em;'>Module Version: $($mod.Version)</div>"
}
return "<div style='color:#f66;'>SVSMSP_Module not found</div>"
}
function Get-UIHtml {
param([string]$Page = 'onboard')
#
# 1) Inline your full original CSS here
#
$style = @'
<style>
:root {
/* Cool Palette */
--background-color: rgba(18, 18, 18, 1);
--border-color: rgba(255,127,0,0.25);
/* Neutral Colors */
--white-color: rgba(255,255,255);
--gray-color: rgba(102,102,102);
--dark-gray-color: rgba(51,51,51);
--light-gray-color: rgba(187,187,187);
/* Sidebar Button Colors */
--btn-sidebar-light-gray: rgba(68,68,68);
--btn-sidebar-blue: rgba(30,144,255,1);
--btn-hover: rgba(0,86,179,1);
--btn-hover-scale: 1.05;
/* Button Colors */
--btn-success: rgba(40,167,69);
--btn-success-disabled: rgba(108,117,125);
--btn-danger: rgba(220,53,69);
}
body {
font-family: Arial, sans-serif;
margin: 0; padding: 0;
background-color: var(--background-color);
color: var(--white-color);
height: 100%; overflow: hidden;
}
.logo-container { display: grid; grid-template-columns: auto 1fr; align-items: center; padding: 20px; }
.logo-container img { max-width:300px; height:auto; }
.subtitle { font-size: 1.2rem; color: var(--gray-color); margin-top: 0.5em; }
.container { display:flex; height:100vh; overflow:hidden; }
.sidebar { width:200px; background:var(--background-color); padding:10px; }
.sidebar button {
display:block; width:100%; margin-bottom:10px; padding:10px;
color:var(--white-color); background:var(--btn-sidebar-light-gray);
border:none; border-radius:5px; cursor:pointer; text-align:left;
transition:background-color 0.3s, transform 0.2s;
}
.sidebar button.active { background:var(--btn-sidebar-blue); }
.sidebar button:hover {
background:var(--btn-hover); transform:scale(var(--btn-hover-scale));
}
.content {
position: relative;
flex:1;
padding:20px;
overflow-y:auto;
max-height:calc(100vh - 50px);
}
.fixed-buttons {
position: fixed;
bottom: 20px;
right: 20px;
display: flex;
gap: 10px; /* space between Exit and Run */
z-index: 1000;
}
.exit-button,
.run-button {
border: none;
border-radius: 5px;
padding: 10px 20px;
cursor: pointer;
color: var(--white-color);
}
/* Specific overrides */
.exit-button {
background-color: var(--btn-danger);
}
/* Specific overrides */
.run-button {
background-color: var(--btn-success);
}
.tab-content { display:none; }
.tab-content.active { display:block; }
.columns-container {
display:flex; gap:20px; flex-wrap:wrap; align-items:flex-start;
}
/* column styling, same as old script */
.column {
flex: 1; /* fill available space */
max-width: 45%; /* or whatever width you like */
border: 2px solid var(--border-color);
border-radius: 8px;
padding: 10px;
background-color: var(--dark-gray-color);
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
}
.checkbox-group label {
display:flex; align-items:center; margin-bottom:8px;
}
.button-group { text-align:right; margin-top:20px; }
.exit-button {
background:var(--btn-danger); color:var(--white-color);
padding:10px 20px; border:none; border-radius:5px; cursor:pointer;
}
#PasswordContainer, #dattoRmmContainer {
margin-top: 1em;
}
/* Common styles for inputs, buttons, and selects */
#PasswordContainer input,
#PasswordContainer button,
#dattoRmmContainer select {
background-color: var(--dark-gray-color);
color: var(--white-color);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 8px;
font-size: 14px;
display: block;
width: 40%;
max-width: 200px;
}
/* Style specifically for the fetch button */
#PasswordContainer button {
background-color: var(--btn-sidebar-blue);
cursor: pointer;
transition: background-color 0.3s ease;
}
/* Hover effect for the fetch button */
#PasswordContainer button:hover {
background-color: var(--btn-hover);
}
/* Tag line */
#tagline {
font-size: 1.2rem;
color: var(--light-gray-color);
font-weight: bold;
justify-self: center;
}
@media (max-width:768px) {
.container { flex-direction:column; }
.sidebar { width:100%; }
}
</style>
'@
$script = @'
<script>
// =======================================================================
// Tab Navigation
// =======================================================================
const tabButtons = document.querySelectorAll(".tab-button");
const tabContents = document.querySelectorAll(".tab-content");
tabButtons.forEach(btn => {
btn.addEventListener("click", () => {
// clear active state
tabButtons.forEach(b => b.classList.remove("active"));
tabContents.forEach(c => c.classList.remove("active"));
// set new active
btn.classList.add("active");
document.getElementById(btn.dataset.tab).classList.add("active");
});
});
// initialize default tab on load
document.querySelector(".tab-button[data-tab='{{defaultPage}}Tab']").classList.add("active");
document.getElementById("{{defaultPage}}Tab").classList.add("active");
// =======================================================================
// Task Trigger
// =======================================================================
const tasks = [
{{tasksJsAll}}
];
// =======================================================================
// Column “Select All” toggling for On-Boarding
// =======================================================================
function toggleColumn(col) {
const master = document.getElementById(`selectAll${col[0].toUpperCase() + col.slice(1)}Checkbox`);
const children = document.querySelectorAll(`#onboardTab input[type=checkbox][data-column=${col}]`);
children.forEach(cb => {
cb.checked = master.checked;
});
// Now simulate change events after setting all checkboxes
setTimeout(() => {
children.forEach(cb => {
cb.dispatchEvent(new Event('change'));
});
}, 0);
}
// =======================================================================
// Un-check “Select All” if any child is unchecked (& re-check if all are checked)
// =======================================================================
function updateSelectAll(col) {
const master = document.getElementById(
`selectAll${col[0].toUpperCase() + col.slice(1)}Checkbox`
);
const children = document.querySelectorAll(
`#onboardTab input[type=checkbox][data-column=${col}]`
);
master.checked = Array.from(children).every(cb => cb.checked);
}
// Attach listeners on load
['left','right'].forEach(col => {
document
.querySelectorAll(`#onboardTab input[type=checkbox][data-column=${col}]`)
.forEach(cb => cb.addEventListener('change', () => updateSelectAll(col)));
});
// =======================================================================
// DattoRMM Options
// =======================================================================
function toggleDattoRMMOptions() {
const master = document.getElementById('installDattoRMM');
const container = document.getElementById('installDattoRMMOptionsContainer');
if (!container) return;
container.style.display = master.checked ? 'block' : 'none';
container.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = master.checked);
}
document.addEventListener('DOMContentLoaded', () => {
const master = document.getElementById('installDattoRMM');
if (master) master.addEventListener('change', toggleDattoRMMOptions);
});
// =======================================================================
// Fetch Sites Handler
// =======================================================================
async function fetchSites() {
const pwd = document.getElementById("Password").value;
if (!pwd) {
alert("Please enter the password.");
return;
}
const dropdown = document.getElementById("dattoDropdown");
dropdown.innerHTML = '<option disabled selected>Loading sites...</option>';
try {
const resp = await fetch("/getpw", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: pwd })
});
if (!resp.ok) throw("HTTP " + resp.status);
const sites = await resp.json();
dropdown.innerHTML = ''; // clear the loading message
sites.forEach(site => {
const option = document.createElement("option");
option.value = site.UID;
option.textContent = site.Name;
dropdown.appendChild(option);
});
document.getElementById("dattoRmmContainer").style.display = "block";
}
catch (e) {
console.error(e);
dropdown.innerHTML = '<option disabled selected>Error loading sites</option>';
alert("Failed to fetch sites. Check password and try again.");
}
}
async function triggerInstall() {
for (const t of tasks) {
const cb = document.getElementById(t.id);
if (cb && cb.checked) {
try {
await fetch(t.handler, { method: "GET" });
} catch (e) {
console.error(`Error running ${t.label}:`, e);
}
}
}
}
// =======================================================================
// Shutdown Handler
// =======================================================================
function endSession() {
fetch("/quit", { method: "GET" })
.finally(() => window.close());
}
// =======================================================================
// Sub-Options Auto-Toggle for Tasks
// =======================================================================
document.addEventListener('DOMContentLoaded', function () {
// Auto-handle visibility and checking for tasks with sub-options
const tasksWithSubOptions = document.querySelectorAll('[id$="OptionsContainer"]');
tasksWithSubOptions.forEach(container => {
const taskId = container.id.replace('OptionsContainer', '');
const masterCheckbox = document.getElementById(taskId);
if (!masterCheckbox) return;
function updateVisibility() {
const checked = masterCheckbox.checked;
container.style.display = checked ? 'block' : 'none';
container.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = checked);
// Show/hide Password and RMM only if it's installDattoRMM
if (taskId === 'installDattoRMM') {
const pwdBox = document.getElementById('PasswordContainer');
const rmmBox = document.getElementById('dattoRmmContainer');
if (pwdBox) pwdBox.style.display = checked ? 'block' : 'none';
if (rmmBox) rmmBox.style.display = checked ? 'block' : 'none';
}
}
masterCheckbox.addEventListener('change', updateVisibility);
updateVisibility(); // call once on load
});
});
// ===========================================
// ─ rotating tagline ───────────────────────────────
// ===========================================
document.addEventListener('DOMContentLoaded', () => {
const taglines = [
"Fast deployments, no monkey business.",
"Bananas for better builds.",
"Deploy without flinging code.",
"Tame your stack. Unleash the monkey.",
"Monkey see, monkey deploy.",
"Deploy smarter -- with a monkey on your team.",
"Don't pass the monkey -- let it deploy.",
"No more monkeying around. Stack handled.",
"Own your stack. But let the monkey do the work.",
"Why throw code when the monkey's got it?",
"Deployments so easy, a monkey could do it. Ours does.",
"Monkey in the stack, not on your back."
];
const el = document.getElementById("tagline");
let idx = Math.floor(Math.random() * taglines.length);
el.textContent = taglines[idx];
setInterval(() => {
idx = (idx + 1) % taglines.length;
el.textContent = taglines[idx];
}, 10_000);
});
// when the browser window is closed (X), notify the server to quit
window.addEventListener('beforeunload', () => {
// keepalive: true ensures the request is sent even as the page unloads
fetch('/quit', { method: 'GET', keepalive: true });
});
</script>
'@
#
# 3) The HTML skeleton with placeholders
#
$htmlTemplate = @"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>SVS TaskGate</title>
<link rel="icon" href="https://git.svstools.com/syelle/Logo/raw/branch/main/SVS_Favicon.ico">
$style
</head>
<body>
<div class="logo-container">
<div class="logo-left">
<img src="https://git.svstools.com/syelle/Logo/raw/branch/main/SVS_logo.svg" alt="SVS Logo">
{{moduleVersion}}
</div>
<div id="tagline"></div>
</div>
<div class="container">
<div class="sidebar">
<button class="tab-button" data-tab="onboardTab">On-Boarding</button>
<button class="tab-button" data-tab="offboardTab">Off-Boarding</button>
<button class="tab-button" data-tab="tweaksTab">Tweaks</button>
<button class="tab-button" data-tab="SVSAppsTab">SVS APPs</button>
</div>
<div class="content">
<div id="onboardTab" class="tab-content">
<h2>On-Boarding</h2>
<h3 class="subtitle">This new deployment method ensures everything is successfully deployed with greater ease!</h3>
<!-- 1) Dynamic task checkboxes -->
<div class="columns-container">
<div class="checkbox-group column">
<h3>SVSMSP Stack</h3>
<label><input type="checkbox" id="selectAllLeftCheckbox" onclick="toggleColumn('left')"> Select All</label>
{{onboardLeftColumn}}
</div>
<div class="checkbox-group column">
<h3>Optional</h3>
<label><input type="checkbox" id="selectAllRightCheckbox" onclick="toggleColumn('right')"> Select All</label>
{{onboardRightColumn}}
</div>
</div>
<!-- 2) Password and Datto Site dropdown shown conditionally -->
<div id="PasswordContainer" style="display:none; margin-bottom:1em;">
<label for="Password">Enter Password:</label>
<div style="display:flex; gap:5px;">
<input type="password" id="Password" placeholder="Enter Password" style="flex:1;" />
<button onclick="fetchSites()" style="padding:4px 10px; background-color: var(--btn-sidebar-blue); color: var(--white-color); border: none; border-radius: 4px;">GO!</button>
</div>
</div>
<div id="dattoRmmContainer" style="display:none; margin-bottom:1em;">
<label for="dattoDropdown">Select a Datto RMM site:</label>
<select id="dattoDropdown" style="width:100%;">
<option disabled selected>Fetching sites...</option>
</select>
</div>
<div id="offboardTab" class="tab-content">
<h2>Off-Boarding</h2>
<div class="columns-container">
{{offboardCheckboxes}}
</div>
</div>
<div id="tweaksTab" class="tab-content">
<h2>Tweaks</h2>
<div class="columns-container">
{{tweaksCheckboxes}}
</div>
</div>
<div id="SVSAppsTab" class="tab-content">
<h2>SVS APPs</h2>
<div class="columns-container">
{{appsCheckboxes}}
</div>
</div>
</div>
</div>
$script
<!-- floating button group -->
<div class="fixed-buttons">
<button class="exit-button" onclick="endSession()">Exit</button>
<button class="run-button" onclick="triggerInstall()">Run Selected</button>
</div>
</body>
</html>
"@
#
# 4) Build the checkbox HTML and tasks JS from $Global:Tasks
#
# On-boarding now has two columns:
$onboardLeft = Build-Checkboxes -Page 'onboard' -Column 'left'
$onboardRight = Build-Checkboxes -Page 'onboard' -Column 'right'
# Off-boarding, Tweaks, SVSApps stay one-column:
$offboard = Build-Checkboxes -Page 'offboard' -Column ''
$tweaks = Build-Checkboxes -Page 'tweaks' -Column ''
$apps = Build-Checkboxes -Page 'SVSApps' -Column ''
# Tasks JS array (fixed)
$tasksJsAll = (
$Global:Tasks | ForEach-Object {
" { id: '$($_.Id)', handler: '/$($_.Name)', label: '$($_.Label)' }"
}
) -join ",`n"
#
# 5) Inject into template
#
$html = $htmlTemplate
$html = $html.Replace('{{moduleVersion}}', (Get-ModuleVersionHtml))
$html = $html.Replace('{{onboardLeftColumn}}', $onboardLeft)
$html = $html.Replace('{{onboardRightColumn}}', $onboardRight)
$html = $html.Replace('{{offboardCheckboxes}}', $offboard)
$html = $html.Replace('{{tweaksCheckboxes}}', $tweaks)
$html = $html.Replace('{{appsCheckboxes}}', $apps)
$html = $html.Replace('{{tasksJsAll}}', $tasksJsAll)
$html = $html.Replace('{{defaultPage}}', $Page)
return $html
}
#endregion
#region HTTP Listener & Routing
# Handle shutdown command
if ($path -eq 'quit') {
Write-LogHybrid "Shutdown requested" "Info" "Server"
Respond-Text $Context "Server shutting down."
# This will break out of the while loop in Start-Server
$Global:Listener.Stop()
return
}
# Sends the HTML for a given page or invokes a task handler
function Dispatch-Request {
param($Context)
# figure out the path
$path = $Context.Request.Url.AbsolutePath.TrimStart('/')
# ---- Shutdown handler ----
if ($path -eq 'quit') {
Write-LogHybrid "Shutdown requested" "Info" "Server"
Respond-Text $Context "Server shutting down."
# stop the listener loop
$Global:Listener.Stop()
return
}
# ---- Fetch Sites endpoint ----
if ($Context.Request.HttpMethod -eq 'POST' -and $path -eq 'getpw') {
Handle-FetchSites $Context
return
}
# ---- Serve UI pages ----
if ($path -in @('', 'onboard', 'offboard', 'tweaks', 'SVSApps')) {
$page = if ($path -eq '') { 'onboard' } else { $path }
$html = Get-UIHtml -Page $page
Respond-HTML $Context $html
return
}
# ---- Task invocation ----
$task = $Global:Tasks | Where-Object Name -EQ $path
if ($task) {
& $task.HandlerFn $Context
return
}
# ---- 404 ----
$Context.Response.StatusCode = 404
Respond-Text $Context '404 - Not Found'
}
# Starts the HTTP listener loop
function Start-Server {
# make it accessible to Dispatch-Request
$Global:Listener = [System.Net.HttpListener]::new()
$Global:Listener.Prefixes.Add("http://localhost:$Port/")
$Global:Listener.Start()
Write-Host "Listening on http://localhost:$Port/ ..."
try {
while ($Global:Listener.IsListening) {
$ctx = $Global:Listener.GetContext()
try {
Dispatch-Request $ctx
} catch {
Write-LogHybrid "Dispatch error: $_" "Error" "Server"
}
}
} finally {
# once the loop exits, clean up
$Global:Listener.Close()
Write-LogHybrid "Listener closed." "Info" "Server"
}
}
#endregion
# open browser on whatever port you've set
Start-Process "msedge.exe" -ArgumentList "--app=http://localhost:$Port"
# now start your server (this will block until you hit Exit in the UI)
Start-Server