PowerShell – Minidash

Just another Tech site

PowerShell – Minidash

Mini Dashboard for support management that i thought might help, trying to create more ways to help support systems

 

 

 

 

 

 

 

 

Here is the script

<#
    Dash.ps1 - Server Management Dashboard
    PowerShell 5.1 / Windows Forms
    Requires: .\lists\servers.txt, .\lists\links.txt, .\scripts\*.ps1
#>

Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
Add-Type -AssemblyName System.Speech
Add-Type -AssemblyName Microsoft.VisualBasic
[System.Windows.Forms.Application]::EnableVisualStyles()

#region ---- Paths / Init ----
$ScriptRoot   = Split-Path -Parent $MyInvocation.MyCommand.Path
$ListsPath    = Join-Path $ScriptRoot "lists"
$ScriptsPath  = Join-Path $ScriptRoot "scripts"
$ServersFile  = Join-Path $ListsPath "servers.txt"
$LinksFile    = Join-Path $ListsPath "links.txt"
$LogRoot      = "C:\logs\Dash"

foreach ($p in @($ListsPath, $ScriptsPath)) {
    if (-not (Test-Path $p)) { New-Item -Path $p -ItemType Directory -Force | Out-Null }
}
if (-not (Test-Path $ServersFile)) { New-Item -Path $ServersFile -ItemType File -Force | Out-Null }
if (-not (Test-Path $LinksFile))   { New-Item -Path $LinksFile -ItemType File -Force | Out-Null }

$Global:ActiveJobs = @{}
$Global:LinksMap   = [ordered]@{}
#endregion

#region ---- Logging ----
function Write-Log {
    param(
        [string]$Message,
        [string]$Level = "INFO",
        [string]$ProcessName = "Dash"
    )
    $dateFolder = Get-Date -Format "yyyyMMdd"
    $logDir = Join-Path $LogRoot $dateFolder
    if (-not (Test-Path $logDir)) { New-Item -Path $logDir -ItemType Directory -Force | Out-Null }
    $logFile = Join-Path $logDir "$ProcessName.txt"
    $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
    "$timestamp [$Level] $Message" | Out-File -FilePath $logFile -Append -Encoding UTF8
}
#endregion

#region ---- Startup Greeting (Voice) ----
Start-Job -ScriptBlock {
    param($UserName)
    Start-Sleep -Seconds 5   # delay so Bluetooth speakers have time to connect
    Add-Type -AssemblyName System.Speech
    $synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
    $femaleVoice = $synth.GetInstalledVoices() |
        Where-Object { $_.VoiceInfo.Gender -eq 'Female' } |
        Select-Object -First 1
    if ($femaleVoice) { $synth.SelectVoice($femaleVoice.VoiceInfo.Name) }
    $synth.Volume = 100
    $hour = (Get-Date).Hour
    $timeOfDay = if ($hour -lt 12) { "morning" } elseif ($hour -lt 17) { "afternoon" } else { "evening" }
    $currentTime = Get-Date -Format "hh:mm tt"
    $greeting = "Good $timeOfDay, $UserName. The current time is $currentTime."
    $synth.Speak($greeting)
    $synth.Dispose()
} -ArgumentList $env:USERNAME | Out-Null
#endregion

#region ---- Colors (Dark Mode) ----
$bgColor      = [System.Drawing.Color]::FromArgb(32,32,32)
$panelColor   = [System.Drawing.Color]::FromArgb(45,45,48)
$fgColor      = [System.Drawing.Color]::White
$limeGreen    = [System.Drawing.Color]::LimeGreen
#endregion

#region ---- Form ----
$form = New-Object System.Windows.Forms.Form
$form.Text = "Dash - Server Management Console"
$form.BackColor = $bgColor
$form.ForeColor = $fgColor
$form.FormBorderStyle = 'FixedSingle'
$form.MaximizeBox = $false
$form.TopMost = $true

$formWidth = 440
$formHeight = 900
$form.Size = New-Object System.Drawing.Size($formWidth, $formHeight)

$screen = [System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea
$form.StartPosition = 'Manual'
$form.Location = New-Object System.Drawing.Point(($screen.Right - $formWidth), $screen.Top)

# --- Menu Strip ---
$menuStrip = New-Object System.Windows.Forms.MenuStrip
$menuStrip.BackColor = $panelColor
$menuStrip.ForeColor = $fgColor
$menuStrip.Renderer = New-Object System.Windows.Forms.ToolStripProfessionalRenderer

$fileMenu = New-Object System.Windows.Forms.ToolStripMenuItem("File")
$miExplorer = New-Object System.Windows.Forms.ToolStripMenuItem("Open File Explorer")
$miExit = New-Object System.Windows.Forms.ToolStripMenuItem("Exit")
$fileMenu.DropDownItems.AddRange(@($miExplorer, $miExit))

$helpMenu = New-Object System.Windows.Forms.ToolStripMenuItem("Help")
$miAbout = New-Object System.Windows.Forms.ToolStripMenuItem("About")
$helpMenu.DropDownItems.Add($miAbout) | Out-Null

$menuStrip.Items.AddRange(@($fileMenu, $helpMenu))
$form.MainMenuStrip = $menuStrip
$form.Controls.Add($menuStrip)

# --- Date/Time Display ---
$txtDateTime = New-Object System.Windows.Forms.TextBox
$txtDateTime.Font = New-Object System.Drawing.Font("Consolas", 12, [System.Drawing.FontStyle]::Bold)
$txtDateTime.ForeColor = $limeGreen
$txtDateTime.BackColor = $bgColor
$txtDateTime.ReadOnly = $true
$txtDateTime.BorderStyle = 'FixedSingle'
$txtDateTime.TextAlign = 'Center'
$txtDateTime.Location = New-Object System.Drawing.Point(10, 28)
$txtDateTime.Size = New-Object System.Drawing.Size(400, 30)
$form.Controls.Add($txtDateTime)

# --- Remote Execution Group ---
$grpRemote = New-Object System.Windows.Forms.GroupBox
$grpRemote.Text = "Remote Script Execution"
$grpRemote.ForeColor = $fgColor
$grpRemote.Location = New-Object System.Drawing.Point(10, 65)
$grpRemote.Size = New-Object System.Drawing.Size(400, 140)

$lblServer = New-Object System.Windows.Forms.Label
$lblServer.Text = "Server:"
$lblServer.ForeColor = $fgColor
$lblServer.Location = New-Object System.Drawing.Point(10, 25)
$lblServer.Size = New-Object System.Drawing.Size(55, 20)

$cboServers = New-Object System.Windows.Forms.ComboBox
$cboServers.BackColor = $panelColor
$cboServers.ForeColor = $fgColor
$cboServers.DropDownStyle = 'DropDownList'
$cboServers.Location = New-Object System.Drawing.Point(70, 22)
$cboServers.Size = New-Object System.Drawing.Size(230, 22)

$btnAddServer = New-Object System.Windows.Forms.Button
$btnAddServer.Text = "+"
$btnAddServer.BackColor = [System.Drawing.Color]::FromArgb(0,120,80)
$btnAddServer.ForeColor = $fgColor
$btnAddServer.FlatStyle = 'Flat'
$btnAddServer.Location = New-Object System.Drawing.Point(305, 21)
$btnAddServer.Size = New-Object System.Drawing.Size(40, 24)

$btnRemoveServer = New-Object System.Windows.Forms.Button
$btnRemoveServer.Text = "-"
$btnRemoveServer.BackColor = [System.Drawing.Color]::FromArgb(150,40,40)
$btnRemoveServer.ForeColor = $fgColor
$btnRemoveServer.FlatStyle = 'Flat'
$btnRemoveServer.Location = New-Object System.Drawing.Point(350, 21)
$btnRemoveServer.Size = New-Object System.Drawing.Size(40, 24)

$lblScript = New-Object System.Windows.Forms.Label
$lblScript.Text = "Script:"
$lblScript.ForeColor = $fgColor
$lblScript.Location = New-Object System.Drawing.Point(10, 60)
$lblScript.Size = New-Object System.Drawing.Size(55, 20)

$cboScripts = New-Object System.Windows.Forms.ComboBox
$cboScripts.BackColor = $panelColor
$cboScripts.ForeColor = $fgColor
$cboScripts.DropDownStyle = 'DropDownList'
$cboScripts.Location = New-Object System.Drawing.Point(70, 57)
$cboScripts.Size = New-Object System.Drawing.Size(230, 22)

$btnAddScript = New-Object System.Windows.Forms.Button
$btnAddScript.Text = "+"
$btnAddScript.BackColor = [System.Drawing.Color]::FromArgb(0,120,80)
$btnAddScript.ForeColor = $fgColor
$btnAddScript.FlatStyle = 'Flat'
$btnAddScript.Location = New-Object System.Drawing.Point(305, 56)
$btnAddScript.Size = New-Object System.Drawing.Size(40, 24)

$btnRemoveScript = New-Object System.Windows.Forms.Button
$btnRemoveScript.Text = "-"
$btnRemoveScript.BackColor = [System.Drawing.Color]::FromArgb(150,40,40)
$btnRemoveScript.ForeColor = $fgColor
$btnRemoveScript.FlatStyle = 'Flat'
$btnRemoveScript.Location = New-Object System.Drawing.Point(350, 56)
$btnRemoveScript.Size = New-Object System.Drawing.Size(40, 24)

$btnRunScript = New-Object System.Windows.Forms.Button
$btnRunScript.Text = "Run Script on Selected Server"
$btnRunScript.BackColor = [System.Drawing.Color]::FromArgb(0,150,136)
$btnRunScript.ForeColor = $fgColor
$btnRunScript.FlatStyle = 'Flat'
$btnRunScript.Location = New-Object System.Drawing.Point(10, 95)
$btnRunScript.Size = New-Object System.Drawing.Size(380, 32)

$grpRemote.Controls.AddRange(@($lblServer,$cboServers,$btnAddServer,$btnRemoveServer,$lblScript,$cboScripts,$btnAddScript,$btnRemoveScript,$btnRunScript))
$form.Controls.Add($grpRemote)

# --- System Actions Group ---
$grpActions = New-Object System.Windows.Forms.GroupBox
$grpActions.Text = "System Actions"
$grpActions.ForeColor = $fgColor
$grpActions.Location = New-Object System.Drawing.Point(10, 215)
$grpActions.Size = New-Object System.Drawing.Size(400, 175)

$btnRestart = New-Object System.Windows.Forms.Button
$btnRestart.Text = "Restart System"
$btnRestart.BackColor = [System.Drawing.Color]::FromArgb(178,34,34)
$btnRestart.ForeColor = $fgColor
$btnRestart.FlatStyle = 'Flat'
$btnRestart.Location = New-Object System.Drawing.Point(10, 25)
$btnRestart.Size = New-Object System.Drawing.Size(180, 35)

$btnRDP = New-Object System.Windows.Forms.Button
$btnRDP.Text = "RDP"
$btnRDP.BackColor = [System.Drawing.Color]::FromArgb(30,90,160)
$btnRDP.ForeColor = $fgColor
$btnRDP.FlatStyle = 'Flat'
$btnRDP.Location = New-Object System.Drawing.Point(200, 25)
$btnRDP.Size = New-Object System.Drawing.Size(180, 35)

$btnPingTracert = New-Object System.Windows.Forms.Button
$btnPingTracert.Text = "Ping / Tracert"
$btnPingTracert.BackColor = [System.Drawing.Color]::FromArgb(204,120,0)
$btnPingTracert.ForeColor = $fgColor
$btnPingTracert.FlatStyle = 'Flat'
$btnPingTracert.Location = New-Object System.Drawing.Point(10, 70)
$btnPingTracert.Size = New-Object System.Drawing.Size(180, 35)

$btnRemotePS = New-Object System.Windows.Forms.Button
$btnRemotePS.Text = "Remote PowerShell"
$btnRemotePS.BackColor = [System.Drawing.Color]::FromArgb(102,51,153)
$btnRemotePS.ForeColor = $fgColor
$btnRemotePS.FlatStyle = 'Flat'
$btnRemotePS.Location = New-Object System.Drawing.Point(200, 70)
$btnRemotePS.Size = New-Object System.Drawing.Size(180, 35)

$btnCleanup = New-Object System.Windows.Forms.Button
$btnCleanup.Text = "Extensive System Cleanup"
$btnCleanup.BackColor = [System.Drawing.Color]::FromArgb(85,107,47)
$btnCleanup.ForeColor = $fgColor
$btnCleanup.FlatStyle = 'Flat'
$btnCleanup.Location = New-Object System.Drawing.Point(10, 115)
$btnCleanup.Size = New-Object System.Drawing.Size(370, 35)

$grpActions.Controls.AddRange(@($btnRestart,$btnRDP,$btnPingTracert,$btnRemotePS,$btnCleanup))
$form.Controls.Add($grpActions)

# --- Quick Links Group ---
$grpLinks = New-Object System.Windows.Forms.GroupBox
$grpLinks.Text = "Quick Links"
$grpLinks.ForeColor = $fgColor
$grpLinks.Location = New-Object System.Drawing.Point(10, 400)
$grpLinks.Size = New-Object System.Drawing.Size(400, 65)

$cboLinks = New-Object System.Windows.Forms.ComboBox
$cboLinks.BackColor = $panelColor
$cboLinks.ForeColor = $fgColor
$cboLinks.DropDownStyle = 'DropDownList'
$cboLinks.Location = New-Object System.Drawing.Point(10, 25)
$cboLinks.Size = New-Object System.Drawing.Size(280, 22)

$btnOpenLink = New-Object System.Windows.Forms.Button
$btnOpenLink.Text = "Open"
$btnOpenLink.BackColor = [System.Drawing.Color]::FromArgb(0,90,140)
$btnOpenLink.ForeColor = $fgColor
$btnOpenLink.FlatStyle = 'Flat'
$btnOpenLink.Location = New-Object System.Drawing.Point(300, 24)
$btnOpenLink.Size = New-Object System.Drawing.Size(90, 24)

$grpLinks.Controls.AddRange(@($cboLinks,$btnOpenLink))
$form.Controls.Add($grpLinks)

# --- Event Status ---
$lblEventStatus = New-Object System.Windows.Forms.Label
$lblEventStatus.Text = "Event Status:"
$lblEventStatus.ForeColor = $fgColor
$lblEventStatus.Location = New-Object System.Drawing.Point(10, 475)
$lblEventStatus.Size = New-Object System.Drawing.Size(200, 20)
$form.Controls.Add($lblEventStatus)

$txtEventStatus = New-Object System.Windows.Forms.TextBox
$txtEventStatus.Multiline = $true
$txtEventStatus.ScrollBars = 'Vertical'
$txtEventStatus.ReadOnly = $true
$txtEventStatus.BackColor = [System.Drawing.Color]::Black
$txtEventStatus.ForeColor = [System.Drawing.Color]::LightGray
$txtEventStatus.Font = New-Object System.Drawing.Font("Consolas", 9)
$txtEventStatus.Location = New-Object System.Drawing.Point(10, 498)
$txtEventStatus.Size = New-Object System.Drawing.Size(400, 220)
$form.Controls.Add($txtEventStatus)

# --- Current Status Box ---
$lblStatusHeader = New-Object System.Windows.Forms.Label
$lblStatusHeader.Text = "Current Status:"
$lblStatusHeader.ForeColor = $fgColor
$lblStatusHeader.Location = New-Object System.Drawing.Point(10, 728)
$lblStatusHeader.Size = New-Object System.Drawing.Size(200, 20)
$form.Controls.Add($lblStatusHeader)

$lblCurrentStatus = New-Object System.Windows.Forms.Label
$lblCurrentStatus.Text = "Idle"
$lblCurrentStatus.Font = New-Object System.Drawing.Font("Segoe UI", 11, [System.Drawing.FontStyle]::Bold)
$lblCurrentStatus.ForeColor = $limeGreen
$lblCurrentStatus.BackColor = $panelColor
$lblCurrentStatus.BorderStyle = 'FixedSingle'
$lblCurrentStatus.TextAlign = 'MiddleCenter'
$lblCurrentStatus.Location = New-Object System.Drawing.Point(10, 750)
$lblCurrentStatus.Size = New-Object System.Drawing.Size(400, 35)
$form.Controls.Add($lblCurrentStatus)

# --- Status Bar ---
$statusStrip = New-Object System.Windows.Forms.StatusStrip
$statusStrip.BackColor = $panelColor
$statusLabel = New-Object System.Windows.Forms.ToolStripStatusLabel
$statusLabel.Text = "Idle"
$statusLabel.ForeColor = $fgColor
$statusUserLabel = New-Object System.Windows.Forms.ToolStripStatusLabel
$statusUserLabel.Text = "User: $env:USERNAME   Host: $env:COMPUTERNAME"
$statusUserLabel.ForeColor = $fgColor
$statusStrip.Items.AddRange(@($statusLabel, $statusUserLabel))
$form.Controls.Add($statusStrip)
#endregion

#region ---- Helper Functions ----
function Add-EventStatus {
    param([string]$Message, [string]$Level = "INFO")
    $timestamp = Get-Date -Format "HH:mm:ss"
    $txtEventStatus.AppendText("[$timestamp] [$Level] $Message`r`n")
    Write-Log -Message $Message -Level $Level -ProcessName "Dash"
}

function Set-CurrentStatus {
    param([string]$Status)
    $lblCurrentStatus.Text = $Status
    $statusLabel.Text = $Status
}

function Refresh-ServerList {
    $cboServers.Items.Clear()
    if (Test-Path $ServersFile) {
        Get-Content $ServersFile | Where-Object { $_.Trim() -ne "" } |
            ForEach-Object { $cboServers.Items.Add($_.Trim()) | Out-Null }
    }
    if ($cboServers.Items.Count -gt 0) { $cboServers.SelectedIndex = 0 }
}

function Refresh-ScriptList {
    $cboScripts.Items.Clear()
    if (Test-Path $ScriptsPath) {
        Get-ChildItem -Path $ScriptsPath -Filter *.ps1 -File |
            ForEach-Object { $cboScripts.Items.Add($_.Name) | Out-Null }
    }
    if ($cboScripts.Items.Count -gt 0) { $cboScripts.SelectedIndex = 0 }
}

function Get-LinksMap {
    $map = [ordered]@{}
    if (Test-Path $LinksFile) {
        Get-Content $LinksFile | Where-Object { $_.Trim() -ne "" } | ForEach-Object {
            $parts = $_ -split ',', 2
            if ($parts.Count -eq 2) {
                $name = $parts[0].Trim()
                $url  = $parts[1].Trim()
                if ($name -and $url) { $map[$name] = $url }
            }
        }
    }
    return $map
}

function Refresh-LinksList {
    $Global:LinksMap = Get-LinksMap
    $cboLinks.Items.Clear()
    foreach ($key in $Global:LinksMap.Keys) { $cboLinks.Items.Add($key) | Out-Null }
    if ($cboLinks.Items.Count -gt 0) { $cboLinks.SelectedIndex = 0 }
}

function Start-RemoteJob {
    param(
        [string]$Server,
        [scriptblock]$ScriptBlock,
        [string]$Description,
        [switch]$RequireCredential
    )
    Set-CurrentStatus "$Description..."
    Add-EventStatus "$Description started on $Server."
    $cred = $null
    if ($RequireCredential) {
        $cred = Get-Credential -Message "Admin credentials for $Server"
        if (-not $cred) {
            Add-EventStatus "$Description cancelled - no credentials provided." "WARN"
            Set-CurrentStatus "Idle"
            return
        }
    }
    try {
        if ($cred) {
            $job = Invoke-Command -ComputerName $Server -Credential $cred -ScriptBlock $ScriptBlock -AsJob -JobName $Description
        } else {
            $job = Invoke-Command -ComputerName $Server -ScriptBlock $ScriptBlock -AsJob -JobName $Description
        }
        $Global:ActiveJobs[$job.Id] = @{ Job = $job; Description = $Description; Server = $Server }
    } catch {
        Add-EventStatus "Failed to start '$Description' on $Server : $_" "ERROR"
        Set-CurrentStatus "Idle"
    }
}
#endregion

#region ---- Action Functions ----
function Invoke-RunScript {
    $server = $cboServers.SelectedItem
    $script = $cboScripts.SelectedItem
    if (-not $server -or -not $script) { Add-EventStatus "Select a server and a script first." "WARN"; return }
    $scriptPath = Join-Path $ScriptsPath $script
    if (-not (Test-Path $scriptPath)) { Add-EventStatus "Script not found: $script" "ERROR"; return }
    $sb = [scriptblock]::Create((Get-Content $scriptPath -Raw))
    Start-RemoteJob -Server $server -ScriptBlock $sb -Description "Run $script" -RequireCredential
}

function Invoke-RestartSystem {
    $server = $cboServers.SelectedItem
    if (-not $server) { Add-EventStatus "Select a server first." "WARN"; return }
    $confirm = [System.Windows.Forms.MessageBox]::Show("Restart $server now?", "Confirm Restart", [System.Windows.Forms.MessageBoxButtons]::YesNo, [System.Windows.Forms.MessageBoxIcon]::Warning)
    if ($confirm -ne [System.Windows.Forms.DialogResult]::Yes) { return }
    $cred = Get-Credential -Message "Admin credentials for $server"
    if (-not $cred) { return }
    try {
        Restart-Computer -ComputerName $server -Credential $cred -Force -ErrorAction Stop
        Add-EventStatus "Restart command sent to $server."
    } catch {
        Add-EventStatus "Restart failed on $server : $_" "ERROR"
    }
}

function Invoke-RDP {
    $server = $cboServers.SelectedItem
    if (-not $server) { Add-EventStatus "Select a server first." "WARN"; return }
    Start-Process "mstsc.exe" -ArgumentList "/v:$server"
    Add-EventStatus "RDP session launched for $server."
}

function Invoke-PingTracert {
    $server = $cboServers.SelectedItem
    if (-not $server) { Add-EventStatus "Select a server first." "WARN"; return }
    Set-CurrentStatus "Ping/Tracert $server..."
    $job = Start-Job -ScriptBlock {
        param($t)
        $out = @()
        $out += "--- PING $t ---"
        $out += (ping -n 4 $t | Out-String)
        $out += "--- TRACERT $t ---"
        $out += (tracert -h 20 $t | Out-String)
        return ($out -join "`r`n")
    } -ArgumentList $server
    $Global:ActiveJobs[$job.Id] = @{ Job = $job; Description = "Ping/Tracert"; Server = $server }
    Add-EventStatus "Ping/Tracert started for $server."
}

function Invoke-RemotePS {
    $server = $cboServers.SelectedItem
    if (-not $server) { Add-EventStatus "Select a server first." "WARN"; return }
    $cmd = "Enter-PSSession -ComputerName $server -Credential (Get-Credential -Message 'Admin credentials for $server')"
    Start-Process "powershell.exe" -ArgumentList "-NoExit -Command $cmd"
    Add-EventStatus "Remote PowerShell session launched for $server."
}

function Invoke-Cleanup {
    $server = $cboServers.SelectedItem
    $sb = {
        $results = @()
        $paths = @(
            "$env:WINDIR\Temp\*",
            "$env:TEMP\*",
            "$env:WINDIR\SoftwareDistribution\Download\*",
            "$env:WINDIR\Prefetch\*"
        )
        foreach ($p in $paths) {
            try { Remove-Item -Path $p -Recurse -Force -ErrorAction SilentlyContinue; $results += "Cleared: $p" }
            catch { $results += "Failed: $p" }
        }
        try { Clear-RecycleBin -Force -ErrorAction SilentlyContinue; $results += "Recycle Bin cleared." } catch {}
        $results += "Cleanup completed on $env:COMPUTERNAME at $(Get-Date)"
        return ($results -join "`r`n")
    }
    if ($server) {
        Start-RemoteJob -Server $server -ScriptBlock $sb -Description "Extensive Cleanup" -RequireCredential
    } else {
        Set-CurrentStatus "Running local cleanup..."
        Add-EventStatus "Extensive cleanup started on local machine."
        $job = Start-Job -ScriptBlock $sb
        $Global:ActiveJobs[$job.Id] = @{ Job = $job; Description = "Extensive Cleanup"; Server = $env:COMPUTERNAME }
    }
}

function Invoke-OpenLink {
    $name = $cboLinks.SelectedItem
    if (-not $name) { Add-EventStatus "Select a website first." "WARN"; return }
    $url = $Global:LinksMap[$name]
    if ($url) {
        Start-Process $url
        Add-EventStatus "Opened link: $name"
    }
}
#endregion

#region ---- Event Wiring ----
$miExplorer.Add_Click({ Start-Process explorer.exe })
$miExit.Add_Click({ $form.Close() })
$miAbout.Add_Click({
    [System.Windows.Forms.MessageBox]::Show("Dash - Server Management Console`r`nPowerShell 5.1 / WinForms`r`n$(Get-Date -Format yyyy)", "About Dash", [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Information)
})

$btnAddServer.Add_Click({
    $newServer = [Microsoft.VisualBasic.Interaction]::InputBox("Enter server name to add:", "Add Server", "")
    if ($newServer) {
        Add-Content -Path $ServersFile -Value $newServer.Trim()
        Refresh-ServerList
        Add-EventStatus "Added server: $newServer"
    }
})
$btnRemoveServer.Add_Click({
    $sel = $cboServers.SelectedItem
    if ($sel) {
        $confirm = [System.Windows.Forms.MessageBox]::Show("Remove '$sel' from server list?", "Confirm", [System.Windows.Forms.MessageBoxButtons]::YesNo)
        if ($confirm -eq [System.Windows.Forms.DialogResult]::Yes) {
            (Get-Content $ServersFile) | Where-Object { $_.Trim() -ne $sel } | Set-Content $ServersFile
            Refresh-ServerList
            Add-EventStatus "Removed server: $sel"
        }
    }
})

$btnAddScript.Add_Click({
    $ofd = New-Object System.Windows.Forms.OpenFileDialog
    $ofd.Filter = "PowerShell Scripts (*.ps1)|*.ps1"
    $ofd.Title = "Select PowerShell script to add"
    if ($ofd.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
        Copy-Item -Path $ofd.FileName -Destination $ScriptsPath -Force
        Refresh-ScriptList
        Add-EventStatus "Added script: $(Split-Path $ofd.FileName -Leaf)"
    }
})
$btnRemoveScript.Add_Click({
    $sel = $cboScripts.SelectedItem
    if ($sel) {
        $confirm = [System.Windows.Forms.MessageBox]::Show("Delete script '$sel'?", "Confirm", [System.Windows.Forms.MessageBoxButtons]::YesNo)
        if ($confirm -eq [System.Windows.Forms.DialogResult]::Yes) {
            Remove-Item -Path (Join-Path $ScriptsPath $sel) -Force
            Refresh-ScriptList
            Add-EventStatus "Removed script: $sel"
        }
    }
})

$btnRunScript.Add_Click({ Invoke-RunScript })
$btnRestart.Add_Click({ Invoke-RestartSystem })
$btnRDP.Add_Click({ Invoke-RDP })
$btnPingTracert.Add_Click({ Invoke-PingTracert })
$btnRemotePS.Add_Click({ Invoke-RemotePS })
$btnCleanup.Add_Click({ Invoke-Cleanup })
$btnOpenLink.Add_Click({ Invoke-OpenLink })
#endregion

#region ---- Timers ----
$clockTimer = New-Object System.Windows.Forms.Timer
$clockTimer.Interval = 1000
$clockTimer.Add_Tick({
    $txtDateTime.Text = Get-Date -Format "dddd, MMMM dd, yyyy   hh:mm:ss tt"
})

$jobTimer = New-Object System.Windows.Forms.Timer
$jobTimer.Interval = 1000
$jobTimer.Add_Tick({
    if ($Global:ActiveJobs.Count -eq 0) { return }
    $doneIds = @()
    foreach ($id in @($Global:ActiveJobs.Keys)) {
        $info = $Global:ActiveJobs[$id]
        $job = $info.Job
        if ($job.State -in @('Completed','Failed','Stopped')) {
            try {
                $output = Receive-Job -Job $job -ErrorAction SilentlyContinue
                if ($output) {
                    $outText = ($output | Out-String).Trim()
                    $txtEventStatus.AppendText("`r`n--- $($info.Description) on $($info.Server) ---`r`n$outText`r`n")
                }
                if ($job.State -eq 'Failed') {
                    Add-EventStatus "$($info.Description) on $($info.Server) FAILED." "ERROR"
                } else {
                    Add-EventStatus "$($info.Description) on $($info.Server) completed."
                }
            } catch {
                Add-EventStatus "Error retrieving job output: $_" "ERROR"
            }
            Remove-Job -Job $job -Force -ErrorAction SilentlyContinue
            $doneIds += $id
        }
    }
    foreach ($id in $doneIds) { $Global:ActiveJobs.Remove($id) }
    if ($Global:ActiveJobs.Count -eq 0) { Set-CurrentStatus "Idle" }
})
#endregion

#region ---- Startup / Shutdown ----
$form.Add_Shown({
    Refresh-ServerList
    Refresh-ScriptList
    Refresh-LinksList
    $clockTimer.Start()
    $jobTimer.Start()
    Add-EventStatus "Dash started."
})

$form.Add_FormClosing({
    $clockTimer.Stop()
    $jobTimer.Stop()
    Get-Job | Where-Object { $Global:ActiveJobs.ContainsKey($_.Id) } | Remove-Job -Force -ErrorAction SilentlyContinue
    Write-Log -Message "Dash closed." -ProcessName "Dash"
})

[void]$form.ShowDialog()
#endregion