65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 PowerShell 进行 HTTP 监视

starIconstarIconstarIconstarIconstarIcon

5.00/5 (10投票s)

2017 年 11 月 17 日

CPOL

12分钟阅读

viewsIcon

25525

downloadIcon

423

一个 PowerShell 脚本,用于监控网站并将日志记录到数据库。

引言

这是一个 PowerShell 工具,用于监控一组 HTTP 主机,并将日志记录到 MSSQL 数据库。您可以在此处下载最新版本,并查看官方 GitHub。这是一个了解 PowerShell 基础知识的绝佳示例,也是一个关于 PowerShell 创建快速粗糙应用程序能力的不错例子。此外,这是一个无需痛苦或外部服务即可监控真实网站的不错工具。

 

为什么使用 PowerShell 工具来监控 HTTP 资源

该工具需要像瑞士军刀一样,无需使用外部服务或安装复杂的软件即可监控网站。当您需要监控某些网站,即使是出于测试/调试目的,并且不想花费太多时间去适应大型工具时,它可能很有用。或者您可能需要一个可以自定义以收集复杂统计信息或将业务逻辑添加到监控中的监控工具。

由于该项目在构建时力求尽可能简单,因此它只是一个简单的 PowerShell 脚本。这意味着您只需下载一个文件即可运行。无需担心安装过程、下载框架或先决条件。只需下载并运行。很简单,对吧?这将安装您的 PowerShell 作为 Windows 服务。所有设置都可以直接在脚本中或使用外部配置文件进行调整。服务运行后,它会定期重新加载网站列表并尝试调用每个条目,将结果保存到数据库中。

监控

监控是一个很大的主题,我想专注于网站监控,这与本文的行为有关。任何拥有网站的人都知道,事情有时会出错。为什么?所有 Web 开发人员都知道,即使一个简单的网站也比静态资源服务更多。有一个 Web 服务器,一个“引擎”(PHP、ASP.NET、Java...)来处理请求,在最简单的情况下还有一个数据库……如今,这种配置确实是最简单的。有时代码、Web 服务器、网络、操作系统……都会出错。监控系统如何帮助我们?当然,监控本身并不能解决真正的问题,但它可以帮助预防和快速解决问题。您认为这对一个简单的网站来说是否太多了?您确定吗?我们的网站就像我们的业务一样重要,只要客户可以通过它联系到我们。即使您的网站 99% 的时间都在运行,这意味着每月约有 7 小时客户无法联系到您。这听起来不太好。

本文的重点是创建一个可以帮助区分网站是正常运行还是宕机的**主动监控**。该工具将通过发出请求并解释结果来监控标准 HTTP(S) 服务。如果响应不正确(例如,HTTP 状态码为 200 且响应正文不为空),它将向您发出警报,并且无论如何,日志将写入数据库。因此,您将能够获得统计信息或检测网站弱点。这称为**主动监控**,因为系统注入人工流量到目标并检查结果(与**被动监控**相反,被动监控有一些探针分析真实流量的结果……)。

在 PowerShell 中实现 HTTP 监控

在本章中,我将阐述应用程序最重要的部分。这样做,我希望专注于“非标准”部分,因为我认为它们是最有趣的部分。让我们开始解释 http-monitor 的工作原理

 

  • 从配置文件读取 URL 列表 **=> 输入处理**

  • 检查 DNS 指向 **=> 避免误报**

  • 发出 HTTP 请求,获取结果 **=> 执行检查**

  • 将结果存储到数据库 **=> 结果日志记录**

  • 发生错误时发送电子邮件 **=> 警报**

 

 

输入处理

这可以通过“Get-Content”从文本文件输入轻松实现。每一行代表一个要监控的 URL。这是实现此功能的片段,我认为无需过多解释。

 

  $webSitesToMonitor = Get-Content $dbpath

避免误报

当需要监控大量站点或 URL 时,维护它们是一个大问题。真正的问题是网站可能会被停用、迁移到其他服务器或服务提供商。因此,我引入了向 http-monitor 指定要考虑的服务器 IP 的可能性。条件如下

  1. 我有许多服务器,例如服务器 A、服务器 B

  2. 一开始,我在这两台服务器上有 1000 个站点

  3. 现在,一些网站更换了服务提供商,我不再负责它们了

 

我同意,最终解决方案是更改输入列表,删除不再有用的条目。在现实世界中,告诉系统只监控您服务器上托管的网站是避免监控不必要事物和管理无关警报的好方法。当然,您可以随时定期检查正在忽略的监控,然后相应地更新输入列表。

这个系统是通过 DNS 解析实现的。如果 DNS 不指向一组 IP,则不会对其进行监控。

 

try
{
    $ip=[System.Net.Dns]::GetHostAddresses($line.Substring($line.IndexOf("//")+2))
                          .IPAddressToString
    Write-Host $line " respond to ip " $ip
    $monitorStatus="OK"

    if($monitoring.Length -gt 0)
    {
        $toMonitor=$toMonitor -and $monitoring.Contains($ip)
        if($toMonitor -eq $false)
        {
            $monitorStatus="SKIPPED"
        }
    }
}
catch
{
    $toMonitor=$false
    Write-Warning " $line unable to resolve IP "
    throw $_
    $monitorStatus="NOT RESOLVED"
}

执行检查

执行检查是最简单的一部分,因为 PowerShell 为我们提供了一个简单的命令。这将返回一个请求对象,我们可以从中读取有关状态、计时等信息。

 

    try
    {
        $RequestTime = Get-Date
        $R = Invoke-WebRequest -URI $line -UserAgent $userAgent
        $TimeTaken = ((Get-Date) - $RequestTime).TotalMilliseconds 
        $status=$R.StatusCode
        $len=$R.RawContentLength

    } 
    catch 
    {
        #many http status fall in exception 
        $status=$_.Exception.Response.StatusCode.Value__
        $len=0
    }

结果日志记录

存储结果的最佳方法是使用数据库。我还考虑过使用 CSV 文件以避免数据库依赖,但从 CSV 处理数据很困难。您需要每次需要进行复杂查询时将其导入数据库。因此,我决定使用数据库(MSSQL,Express 版本即可!)或禁用此功能。写入数据库非常简单,让我想起了 2000 年初使用纯 ADO.Net 的普遍做法。

 

    # Function used to create table if not exists during setup
    
    
Function CreateTableIfNotExists 
{
[CmdletBinding()]
    Param(
    [System.Data.SqlClient.SqlConnection]$OpenSQLConnection
    )
  $script=@" 
  if not exists (select * from sysobjects where name='logs' and xtype='U')
	CREATE TABLE [logs]
	(	[date] [datetime] NOT NULL DEFAULT (getdate()) ,
		[site] [varchar](500) NULL,
		[status] [varchar](50) NULL,
		[length] [bigint] NULL,
		[time] [bigint] NULL,
        [ip] [varchar](50) NULL,
        [monitored] [varchar](50) NULL
	) ON [PRIMARY]
"@
 $sqlCommand = New-Object System.Data.SqlClient.SqlCommand
    $sqlCommand.Connection = $sqlConnection
 
    # This SQL query will insert 1 row based on the parameters, 
    # and then will return the ID
    # field of the row that was inserted.
    $sqlCommand.CommandText =$script
    $result= $sqlCommand.ExecuteNonQuery()
    Write-Warning "Table log created $result"
}

#-----------------------------------------------------------------------------#
#                                                                             #
#   Function        WriteLogToDB                                              #
#                                                                             #
#   Description     Write a log row to db                                     #
#                                                                             #
#   Arguments       See the Param() block                                     #
#                                                                             #
#   Notes                                                                     #
#                                                                             #
#                                                                             #
#-----------------------------------------------------------------------------#
Function WriteLogToDB { 
    [CmdletBinding()]
    Param(
    [System.Data.SqlClient.SqlConnection]$OpenSQLConnection, 
    [string]$site,
    [int]$status,
    [int]$length,
    [int]$time,
    [string]$ip,
    [string]$monitored
    ) 
 
    $sqlCommand = New-Object System.Data.SqlClient.SqlCommand
    $sqlCommand.Connection = $sqlConnection
 
    # This SQL query will insert 1 row based on the parameters, and then will return the ID
    # field of the row that was inserted.
    $sqlCommand.CommandText =
        "INSERT INTO [dbo].[logs] ([site] ,[status] ,[length] ,[time],[ip],[monitored]) "+
        " VALUES   (@site,@status  ,@lenght ,@time,@ip,@monitored) " 
    $sqlCommand.Parameters.Add(New-Object SqlParameter("@site",[Data.SQLDBType]::NVarChar, 500)) | Out-Null
    $sqlCommand.Parameters.Add(New-Object SqlParameter("@status",[Data.SQLDBType]::NVarChar, 500)) | Out-Null
    $sqlCommand.Parameters.Add(New-Object SqlParameter("@lenght",[Data.SQLDBType]::BigInt)) | Out-Null
    $sqlCommand.Parameters.Add(New-Object SqlParameter("@time",[Data.SQLDBType]::BigInt)) | Out-Null
    $sqlCommand.Parameters.Add(New-Object SqlParameter("@ip",[Data.SQLDBType]::NVarChar, 500))) | Out-Null
    $sqlCommand.Parameters.Add(New-Object SqlParameter("@monitored",[Data.SQLDBType]::NVarChar, 500)) | Out-Null
   
        # Here we set the values of the pre-existing parameters based on the $file iterator
        $sqlCommand.Parameters[0].Value = $site
        $sqlCommand.Parameters[1].Value = $status
        $sqlCommand.Parameters[2].Value = $length
        $sqlCommand.Parameters[3].Value = $time
        $sqlCommand.Parameters[4].Value = $ip
        $sqlCommand.Parameters[5].Value = $monitored
 
        # Run the query and get the scope ID back into $InsertedID
        $InsertedID = $sqlCommand.ExecuteScalar()
        # Write to the console.
        # "Inserted row ID $InsertedID for file " + $file.Name
    
 
}
    
    # Funtion that write a single line of log
    
    
    #... and its usage into monitor cycle
    
    #if db write is enabled log into SQL SERVER
    if($writeToDB -eq $true)
    {
        WriteLogToDB $sqlConnection $line $status $len $TimeTaken $ip $monitored
    }

警报

我设想的最简单的警报系统是电子邮件警报。我知道现在我们被数以千计的无关信息轰炸,这可能会很难找到。但是,在没有 UI 的情况下,在一个设计得尽可能简单的工具中,这仍然是最好的选择。我还考虑过写入事件日志或使用 syslog 或类似系统发送日志。问题是,在这种情况下,我们需要手动设置来自这些系统的通知才能“物理地”收到错误通知。


实现起来相当简单,因为 PowerShell 提供了一个函数来完成这项工作

    #if send mail is enabled send an alert
    if($sendEmail -eq $true -and $emailErrorCodes.Length -ge 0 -and $emailErrorCodes.Contains( $R.StatusCode) )
    {
    $statusEmail=$R.StatusCode
    $content=$R.RawContent


        
        $subject="$errorSubject $line"
        $body= @".. this value is omitted for readability "@
        #prepare attachment
        $attachment="$workingPath\tmp.txt"      
        # In case some previous execution goes in error whitout deleting the temp file      
        Remove-Item $workingPath\tmp.txt -Force 
        Write-Host $attachment
        $content|Set-Content $attachment

        #send email
        Write-Host "Sending  mail notification"
        Send-MailMessage -From $emailFrom -To $emailTo -Subject $subject  -Body $body -Attachments $attachment -Priority High -dno onSuccess, onFailure -SmtpServer $smtpServer

        #remove attachment
        Remove-Item $workingPath\tmp.txt -Force

    }

 

持续监控

这是最棘手的部分。最简单的解决方案是将此 PowerShell 安排为 Windows 任务。通过调整配置,可以设置为每 x 分钟执行一次操作,并避免并发启动实例。我还将其实现为服务。这更多是为了练习而不是实际需要,因为作为任务执行的 PowerShell 是一个可行的解决方案,并且可以使用 PowerShell API 轻松自动化。

如何将 PowerShell 设置为服务

在 Windows 中,我们知道并非所有可执行文件都可以作为服务。与 Linux 中可以将任何脚本作为服务运行不同,在 Windows 中,我们需要实现具有特定结构的 executable。例如,使用 .NET Framework,我们需要实现一个基于 ServiceBase 的类来公开 Start\Stop 功能。这在所有其他语言中也是如此,因为 OS 需要 Start\Stop API 来控制服务。为了在 PowerShell 中做到这一点,我找到了一个有趣的参考实现,它

  1. 将 C# 类嵌入 PowerShell,作为字符串

  2. 在此类中,实现 Start\Stop 方法来调用 PowerShell 脚本

  3. 大部分脚本是动态的,因此路径等详细信息在设置期间进行设置

  4. 一个设置方法,其中

    1. 类根据运行参数进行实例化

    2. 类被编译

    3. 创建一个与服务兼容的可执行文件(与脚本在同一文件夹中)

    4. 此可执行文件被注册为服务。

 

    
    # The class script embedded into code (some part are omitter for readability)
    $source = @"
  using System;
  using System.ServiceProcess;
  using System.Diagnostics;
  using System.Runtime.InteropServices;                                 // SET STATUS
  using System.ComponentModel;                                          // SET STATUS
 
 
  public class Service_$serviceName : ServiceBase { 
    [DllImport("advapi32.dll", SetLastError=true)]                      // SET STATUS
    private static extern bool SetServiceStatus(IntPtr handle, ref ServiceStatus serviceStatus);
    protected override void OnStart(string [] args) {
      EventLog.WriteEntry(ServiceName, "$exeName OnStart() // Entry. Starting script '$scriptCopyCname' -Start"); // EVENT LOG
      // Set the service state to Start Pending.                        // SET STATUS [
      // Only useful if the startup time is long. Not really necessary here for a 2s startup time.
      serviceStatus.dwServiceType = ServiceType.SERVICE_WIN32_OWN_PROCESS;
      serviceStatus.dwCurrentState = ServiceState.SERVICE_START_PENDING;
      serviceStatus.dwWin32ExitCode = 0;
      serviceStatus.dwWaitHint = 2000; // It takes about 2 seconds to start PowerShell
      SetServiceStatus(ServiceHandle, ref serviceStatus);               // SET STATUS ]
      // Start a child process with another copy of this script
      try {
        Process p = new Process();
        // Redirect the output stream of the child process.
        p.StartInfo.UseShellExecute = false;
        p.StartInfo.RedirectStandardOutput = true;
        p.StartInfo.FileName = "PowerShell.exe";
        p.StartInfo.Arguments = "-ExecutionPolicy Bypass -c & '$scriptCopyCname' -Start"; // Works if path has spaces, but not if it contains ' quotes.
        p.Start();
        // Read the output stream first and then wait. (To avoid deadlocks says Microsoft!)
        string output = p.StandardOutput.ReadToEnd();
        // Wait for the completion of the script startup code, that launches the -Service instance
        p.WaitForExit();
        if (p.ExitCode != 0) throw new Win32Exception((int)(Win32Error.ERROR_APP_INIT_FAILURE));
        // Success. Set the service state to Running.                   // SET STATUS
        serviceStatus.dwCurrentState = ServiceState.SERVICE_RUNNING;    // SET STATUS
      } catch (Exception e) {
        EventLog.WriteEntry(ServiceName, "$exeName OnStart() // Failed to start $scriptCopyCname. " + e.Message, EventLogEntryType.Error); // EVENT LOG
        // Change the service state back to Stopped.                    // SET STATUS [
        serviceStatus.dwCurrentState = ServiceState.SERVICE_STOPPED;
        Win32Exception w32ex = e as Win32Exception; // Try getting the WIN32 error code
        if (w32ex == null) { // Not a Win32 exception, but maybe the inner one is...
          w32ex = e.InnerException as Win32Exception;
        }    
        if (w32ex != null) {    // Report the actual WIN32 error
          serviceStatus.dwWin32ExitCode = w32ex.NativeErrorCode;
        } else {                // Make up a reasonable reason
          serviceStatus.dwWin32ExitCode = (int)(Win32Error.ERROR_APP_INIT_FAILURE);
        }                                                               // SET STATUS ]
      } finally {
        serviceStatus.dwWaitHint = 0;                                   // SET STATUS
        SetServiceStatus(ServiceHandle, ref serviceStatus);             // SET STATUS
        EventLog.WriteEntry(ServiceName, "$exeName OnStart() // Exit"); // EVENT LOG
      }
    }
    protected override void OnStop() {
      EventLog.WriteEntry(ServiceName, "$exeName OnStop() // Entry");   // EVENT LOG
      // Start a child process with another copy of ourselves
      Process p = new Process();
      // Redirect the output stream of the child process.
      p.StartInfo.UseShellExecute = false;
      p.StartInfo.RedirectStandardOutput = true;
      p.StartInfo.FileName = "PowerShell.exe";
      p.StartInfo.Arguments = "-c & '$scriptCopyCname' -Stop"; // Works if path has spaces, but not if it contains ' quotes.
      p.Start();
      // Read the output stream first and then wait.
      string output = p.StandardOutput.ReadToEnd();
      // Wait for the PowerShell script to be fully stopped.
      p.WaitForExit();
      // Change the service state back to Stopped.                      // SET STATUS
      serviceStatus.dwCurrentState = ServiceState.SERVICE_STOPPED;      // SET STATUS
      SetServiceStatus(ServiceHandle, ref serviceStatus);               // SET STATUS
      EventLog.WriteEntry(ServiceName, "$exeName OnStop() // Exit");    // EVENT LOG
    }
    public static void Main() {
      System.ServiceProcess.ServiceBase.Run(new Service_$serviceName());
    }
  }
"@



# The setup part
try {
    $pss = Get-Service $serviceName -ea stop # Will error-out if not installed
    #service installed. Nothing to do
    Write-Warning "Service installed nothing to do."
    exit 0
  } catch {
    # This is the normal case here. Do not throw or write any error!
    Write-Debug "Installation is necessary" # Also avoids a ScriptAnalyzer warning
    # And continue with the installation.
  }
  if (!(Test-Path $installDir)) {
    New-Item -ItemType directory -Path $installDir | Out-Null
  }
  
  # Generate the service .EXE from the C# source embedded in this script
  try {
    Write-Verbose "Compiling $exeFullName"
    Add-Type -TypeDefinition $source -Language CSharp -OutputAssembly $exeFullName -OutputType ConsoleApplication -ReferencedAssemblies "System.ServiceProcess" -Debug:$false
  } catch {
    $msg = $_.Exception.Message
    Write-error "Failed to create the $exeFullName service stub. $msg"
    exit 1
  }
  # Register the service
  Write-Verbose "Registering service $serviceName"
  $pss = New-Service $serviceName $exeFullName -DisplayName $serviceDisplayName -Description $ServiceDescription -StartupType Automatic

 

注意:此代码源自 JFLarvoire 的脚本(一项非常出色的工作!),并根据 http-monitor 的需求进行了调整。如果您有兴趣将 PowerShell 脚本作为 Windows 服务运行,请参考原始实现。

动态配置

这一点对于通过许多服务器或监控工作站交付脚本很重要。您可以设想的最简单的方法是将许多常量放在脚本的顶部,然后在初始设置期间进行更改。这很容易实现,但很难管理,因为软件会更改,您会更改文件,因此需要合并文件而不是覆盖。所以我指向 PowerShell 数据文件解决方案(psd1 文件)。这是一个存储所有配置数据(除了这些数据是静态的)的绝佳解决方案。如您在这个出色的示例中所见,您可以将设置存储在 psd1 文件中,然后加载到应用程序中。但在我的情况下,我有许多动态参数。最重要的就是路径。我想定义一个基本路径,然后通过连接基本路径和相对路径来计算许多路径。我们还希望一次性定义数据库的用户名/密码,然后动态构建连接字符串,并有可能在连接字符串本身中添加参数。


因此,为了获得这种灵活性,我决定使用一种更简单、也许是手工制作但功能强大的解决方案。主脚本定义脚本中的所有参数。这意味着如果您想在脚本本身内部进行编辑,这会使升级更困难,但您仍然可以这样做。参数定义后,应用程序会检查一个 settings ps1 脚本。如果找到,它将被包含在主脚本中。此处定义的所有变量将覆盖脚本中的变量。一旦脚本成为“真正的”PowerShell 脚本,您就可以使用变量连接,并进行任何您需要的技巧。

 

    # DB SETTINGS
# -----------------------------------   
$writeToDB= $true # enable or disable db logging
$DBServer = "(localdb)\Projects" # MSSQL host, usully .\SQLEXPRESS, .\SQLSERVER 
$DBName = "httpstatus" # name of db.(HAVE TO BE CREATED)
# full connection string. Write here password if not in integrated security
$ConnectionString = "Server=$DBServer;Database=$DBName;Integrated Security=True;" 

# EMAIL SETTINGS
# -----------------------------------

#... 

# MONITOR SETTINGS
# ----------------------------------   

# ...

# LOGGING FILES
# ----------------------------------

#....


if ( (Test-Path -Path $workingPath\settings.ps1))
{
    Write-Host "Apply external changes"
   . ("$workingPath\settings.ps1") 
}

 

实际应用:设置和使用说明

如何安装

简单来说,PowerShell Http Monitor 工具提供了三种可能的用法。

  • 作为独立应用程序,手动运行

  • 作为计划任务,定期安排

  • 作为服务,在后台持续运行

运行一次

运行此命令后,脚本将读取所有网站,尝试调用它们,最后将结果下载到 MSSQL 数据库或日志文件中。此使用模式无需任何安装。它也可以使用 Windows 计划任务进行安排。

Http-Monitor -Run

作为计划任务运行

在 PowerShell 中设置 Http-monitor 工具配置非常简单。关键设置:

  • 每 5 分钟运行一次(或其他间隔)

  • 设置脚本路径

  • 指示调度程序不要启动多个实例

请参阅截图了解所有步骤

  1. 创建新的计划任务

  2. 设置时间:诀窍是定义一个立即开始但每 x 分钟无限重复一次的任务。

  3. 定义启动脚本:这很简单。您只需要在编辑框中输入以下文本(只需替换为您文件的绝对路径)。PowerShell -file "<文件路径>\Http-Monitor.ps1"

避免多个实例

向导的最后一步是避免同时出现多个实例。只需在下拉菜单中选择“不允许新实例”。

作为服务运行

这会将 PowerShell 脚本安装为服务。

 PS> Http-Monitor -Setup

安装后,您可以使用脚本或 service.mmc 进行控制

PS> Http-Monitor -Start

PS> Http-Monitor -Stop

配置

由于 PowerShell Http Monitor 是一个没有 UI 的纯应用程序,配置是最棘手的部分,但最终需要学习的东西不多。

  1. 应用程序设置:可以在主应用程序脚本内部或使用外部脚本进行编辑。

  2. 输入:有一个文本文件,每行一个主机。

应用程序设置

输入文件

文件路径必须与应用程序设置路径匹配。文件必须包含网站列表,带 http 或 https 前缀。这通常是主页列表,但根据设计,PowerShell Monitoring Tool 可以接受任何其他 URL。

从 IIS 生成网站列表

这可能有助于监控一个 IIS 服务器上的所有站点。为此,需要使用 appcmd 命令将绑定转储到文本文件中。使用正则表达式很容易处理,可以将其转换为站点 URL 列表。

设置

您可以通过两种方式配置应用程序设置

  1. 内联编辑脚本(请参阅“应用程序设置”部分)

  2. 管理单独的文件(推荐)。应用程序在应用程序目录中查找“settings.ps1”文件,并用它覆盖默认设置。

您可以复制设置并正确保留它下载文件

设置非常容易理解,只要稍加注意,您就可以获得正确的调整。

结论

PowerShell 是一个强大的工具,可以帮助我们快速构建简单的解决方案。它能够完成 .Net Framework 的所有功能,并且拥有一套与操作系统深度关联的内置函数,这使得它几乎无所不能。PowerShell 附带了一个 IDE,这对于习惯编写操作系统级脚本的人来说是一场革命。它不是 Visual Studio,但足以帮助您编写和调试代码。此外,互联网上共享的资源和现成脚本的数量也是一个优势。那么,有什么问题呢?

我注意到 PowerShell 是一个很棒的工具,但是……它仍然是一个脚本工具。因此,在某个时候,它必须停止,并将舞台让给其他更结构化的解决方案。在我看来,这个应用程序达到了这个极限。它写入数据库、读取输入、发送电子邮件警报、自行安装。将 PowerShell 推到这个极限非常有趣,但我可以说,展望未来,随着复杂性的增长,下一步将基于其他平台。在没有 ORM 的情况下写入数据库、在没有 UI 的情况下读取数据、使用电子邮件作为唯一的用户警报媒介……这对于获得快速结果、轻松融入我们的需求和共享非常棒,但随着我们需要的越来越多,它就不再是合适的工具了。

所以,关于下一步:如果这个工具能对他人有所帮助,那就太好了,如果有人能为此做出贡献,我将感到自豪。这可能是一个开始监控网站的好起点,也是一个有趣地找到真实世界监控工具的经验。也许使用另一种堆栈,也许使用 UI,最好是使用另一种技术……

参考文献

历史

  • 2017-11-18:发布到 CodeProject
  • 2017-10-23:首次发布到 GitHub
  • 2017-10-17:开始处理
© . All rights reserved.