Update: YACLP for Slack/Hubot

on Monday, April 29, 2019

This is an update for Yet Another Command Line Parser for Slack/Hubot.

Since the last post, a few minor updates have been made:

  • Added a validation set for a required parameter

product-name action {required-param-name=option1|option2|option3}

  • Added a validation set for an optional parameter with a default value

product-name action {required-param-name=option1|option2:option2}

  • Made the parameter check Case Insensitive
  • Added ability to take in an empty string as an input value (“”)
  • Added parsing that can handle multiple spaces between parameters
  • Added parameters passed in format parameter-name=value format

Here’s the updated version:

require("./array-includes-polyfill")
function parse(format, input) {
format = format.toLowerCase()
var optionsIndex = format.indexOf("{")
var start = format.substring(0, optionsIndex).trim()
// console.log(`start = ${start}`)
var optionslist = format.substring(optionsIndex)
// console.log(`optionslist: ${optionslist}`)
var optionsSplit = optionslist.split(' ')
// console.log(`optionsSplit: ${optionsSplit}`)
// attempt to recombine options which might have multiterm default values
var recombinedOptions = []
for(var i = 0; i < optionsSplit.length; i++) {
var term = optionsSplit[i]
if(term.startsWith('{') == false) {
recombinedOptions.push(term)
continue
}
if(term.endsWith('}') == false) {
var combined = term
while(term.endsWith('}') == false && i < optionsSplit.length) {
i++
term = optionsSplit[i]
combined += " " + term
}
recombinedOptions.push(combined)
} else {
recombinedOptions.push(term)
}
}
optionsSplit = recombinedOptions
// parse out the default values
var defaults = {}
var validationSets = {}
optionsSplit.forEach(option => {
option = option.replace('{','')
option = option.replace('}','')
optionSplit = option.split(':')
if(optionSplit.length === 1) {
value = "required"
} else {
value = optionSplit[1]
}
var name = optionSplit[0]
var nameSplit = name.split('=')
var validValues = undefined
if(nameSplit.length > 1) {
name = nameSplit[0]
validValues = nameSplit[1].split('|')
}
// console.log(`option: ${name} = ${value}`)
defaults[name] = value
validationSets[name] = validValues
});
argv = JSON.parse(JSON.stringify(defaults)); // deep copy
used = JSON.parse(JSON.stringify(defaults)); // deep copy
var usedvalue = "used-" + (Math.random() * 99999)
// if no matching input was given, return the defaults
var match = input.match(start)
if(match === null) {
argv["success"] = false
argv["errors"] = { "required" : `Usage: ${usageString(start, defaults, validationSets)}` }
return argv
}
// parse input for the parameters
var inputIndex = input.indexOf(start)
// console.log(`input: ${input}`)
// console.log(`inputIndex: ${inputIndex}`)
// console.log(`start.length: ${start.length}`)
// console.log(`inputIndex + start.length: ${inputIndex + start.length}`)
var inputOptions = input.substring(inputIndex + start.length).trim()
if(inputOptions.length > 0) {
// console.log(`inputOptions: ${inputOptions}`)
// console.log(`inputOptions.length: ${inputOptions.length}`)
var inputOptionsSplit = inputOptions.split(/[\s=]+/)
// console.log(`inputOptionsSplit: ${inputOptionsSplit}`)
// console.log(`inputOptionsSplit.length: ${inputOptionsSplit.length}`)
// console.log(`argv-1:`);console.log(argv)
// try to fix typos, where a multiterm value has the double-quote accidently in the middle of a word
var fixedMissingSpaceTypoSplit = []
for(var i = 0; i < inputOptionsSplit.length; i++) {
var term = inputOptionsSplit[i]
var doubleQuotesIndex = term.indexOf('"')
if(doubleQuotesIndex > 0 && doubleQuotesIndex < term.length - 1) {
var first = term.substring(0, doubleQuotesIndex)
var last = term.substring(doubleQuotesIndex)
fixedMissingSpaceTypoSplit.push(first)
fixedMissingSpaceTypoSplit.push(last)
} else {
fixedMissingSpaceTypoSplit.push(term)
}
}
inputOptionsSplit = fixedMissingSpaceTypoSplit
// attempt to recombine options which might have multiterm default values
var recombinedOptions = []
for(var i = 0; i < inputOptionsSplit.length; i++) {
var term = inputOptionsSplit[i]
if(term.startsWith('"') == false) {
recombinedOptions.push(term)
continue
}
if(term.endsWith('"') == false) {
var combined = term
while(term.endsWith('"') == false && i < inputOptionsSplit.length) {
i++
term = inputOptionsSplit[i]
combined += " " + term
}
combined = combined.substring(1, combined.length - 1) // removes start and end quotes
recombinedOptions.push(combined)
} else {
recombinedOptions.push(term)
}
}
inputOptionsSplit = recombinedOptions
var currentIndex = 0
for(var i = 0; i < inputOptionsSplit.length; i++) {
var term = inputOptionsSplit[i]
var key = ""
if(term.startsWith("-") || term.startsWith("--")) {
// so a named parameter was explicitely passed in, set the appropriate value by name
key = term
if(key.startsWith("--")) { key = key.substring(2) }
if(key.startsWith("-")) { key = key.substring(1) }
i++
term = inputOptionsSplit[i]
var ki = keyIndex(defaults, key)
if(ki == currentIndex) currentIndex++
} else {
var ki = keyIndex(defaults, term)
if(ki !== -1) {
// a named parameter was inferred as passed in, set the appropriate value by name
key = term
i++
term = inputOptionsSplit[i]
var ki = keyIndex(defaults, key)
if(ki == currentIndex) currentIndex++
} else {
// no named parameter was given, so just set the next value
key = keyAt(defaults, currentIndex)
if(key == undefined) {
// sometimes there's a typo and you can get too many input values, then prevents an infinite loop when that happens
// for example, input of: @sysbot servicepro close 42191"Verified the Financial application and thank everybody for this big step in simplifying the deployments!""
// there should only be two parameters; but because there is a missing space, there are 15 parameters; so we need
// to stop when we run out of parameters to fill in.
continue;
}
currentIndex++
key = key
while(used[key] == usedvalue) { // check the value hasn't already been set
key = keyAt(defaults, currentIndex)
key = key
currentIndex++
}
}
}
if(term == "\"\"") { term = "" }
var lkey = key.toLowerCase()
argv[lkey] = term
used[lkey] = usedvalue
}
}
// validate required parameters were set
var errors = {}
var failures = false
for(var d in defaults) {
var a = argv[d]
if(defaults[d] == "required") {
if(a == "required" || a.length == 0) {
var usage = usageString(start, defaults, validationSets)
errors[d] = `'${d}' is a required parameter. Usage: ${usage}`
failures = true
}
}
var vs = validationSets[d]
if(vs != undefined) {
if(!vs.includes(a.toLowerCase())) {
var valids = combineParams(vs, ", ")
var usage = usageString(start, defaults, validationSets)
errors[d] = `'${d}' is not set to a valid value (value: ${a}). Valid values include: ${valids}. Usage: ${usage}`
failures = true
}
}
}
argv["success"] = failures == false
argv["errors"] = errors
return argv
}
function keyIndex(object, key) {
var i = 0
var lkey = key.toLowerCase()
for(var current in object) {
if(current == lkey) return i
i++
}
return -1
}
function keyAt(object, index) {
var i = 0
for(var current in object) {
if(i == index) return current
i++
}
}
function usageString(start, defaults, validationSets) {
var usage = ""
for(var d in defaults) {
var p = `${d}`
if(validationSets[d] != undefined) {
var vs = combineParams(validationSets[d], '|')
p = `${d}=${vs}`
}
var v = defaults[d]
var term = ""
if(v == "required") {
term = `<${p}>`
} else {
term = `[${p}:${v}]`
}
if(usage.length > 0) { usage += " " }
usage += term
}
if(!start.endsWith(" ")) { start += " " }
var result = start + usage
return result
}
function sendErrors(params, msg) {
errorMessage = ""
for(k in params.errors) {
if(errorMessage.length > 0) errorMessage += "\r\n"
errorMessage += params.errors[k]
}
errorMessage = "```" + errorMessage + "```"
msg.send(errorMessage)
}
function combineParams(toCombine, delim) {
var combined = ""
for(var k in toCombine) {
var v = toCombine[k]
if(combined.length > 0) { combined += delim }
combined += v
}
return combined
}
function displayParams(params, hide) {
var display = ""
hide = hide || []
if(hide.indexOf("success") === -1) hide.push("success")
if(hide.indexOf("errors") === -1) hide.push("errors")
for(var key in params) {
var hidden = false
for(var i = 0; i < hide.length; i++) {
var h = hide[i]
if(key == h) hidden = true
}
if(hidden) continue;
if(display.length > 0) display += ", "
display += `${key}:'${params[key]}'`
}
return display
}
module.exports = { parse: parse, sendErrors: sendErrors, displayParams: displayParams }
// function test(format, input, hide) {
// console.log("format: '" + format + "'")
// console.log("input: '" + input + "'")
// var results = parse(format, input)
// console.log(results)
// if(hide === undefined)
// console.log("displayParams: '" + displayParams(results) + "'")
// else
// console.log("displayParams: '" + displayParams(results, hide) + "'")
// }
// test("octo cancel {projectname:*} {releaseid:*}", "sysbot octo cancel gold")
// test("octo cancel {projectname:*} {releaseid:*}", "octo cancel gold 20")
// test("octo cancel {projectname:*} {releaseid:*}", "octo cancel")
// test("octo cancel {projectname:*} {releaseid:*}", "octo cancel --projectname gold")
// test("octo cancel {projectname:*} {releaseid:*}", "octo cancel --projectname gold 20")
// test("octo cancel {projectname:*} {releaseid:*}", "octo cancel projectname gold 20")
// test("octo cancel {projectname:*} {releaseid:*}", "octo cancel --releaseid 20 gold")
// test("octo cancel {projectname:*} {releaseid:*}", "octo cancel releaseid 20 gold")
// test("octo cancel {projectname:*} {releaseid:*} {somethignrequired}", "octo cancel releaseid 20 gold")
// test("octo cancel {projectname:*} {releaseid:*}", "octo cancel releaseid 20 gold", ["releaseid"])
// test("octo cancel {projectname:*} {releaseid:*}", "sysbot octo list")
// test("octo list {projectname:*} {releaseid:*}", "sysbot octo list")
// test("octo list {first:*} {second:*} {third:*} {fourth:*}", "sysbot octo list --first first-value -third 3 second-value fourth-value")
// test("servicepro close {ticketnumber} {memo:Verified and Closing}", "servicepro close 487156")
// test("servicepro close {ticketnumber} {memo:Verified and Closing}", "servicepro close 487156 \"a long description with a lot of terms\"")
// test("servicepro close {ticketnumber} {memo:Verified and Closing}", "sysbot servicepro close 487156 \"a long description with a lot of terms\"")
// test("servicepro close {ticketnumber} {memo:Verified and Closing}", "@sysbot servicepro close 42191\"Verified the Financial application and thank everybody for this big step in simplifying the deployments!\"")
// test("servicepro close {ticketnumber} {resolution=success-no-help|success-help|nil} {memo:Verified and Closing}", "@sysbot servicepro close 11111 success-help")
// test("servicepro close {ticketnumber} {resolution=success-no-help|success-help|nil} {memo:Verified and Closing}", "@sysbot servicepro close 11111 something-unknown")
// test("servicepro close {ticketnumber} {resolution=success-no-help|success-help|nil:nil} {memo:Verified and Closing}", "@sysbot servicepro close 11111 something-unknown")
// test("servicepro close {ticketnumber} {caseinsensitive}", "@sysbot servicepro close CaseInsensitive done 90809")
// test("notify sub list {contactType:*} {destination:*} {component:*} {messageType:*} {tags:*} {createdby:current-user}", "@sysbot notify sub list -CreatedBy maglio-s")
// test("notify sub list {contactType:*} {destination:*} {component:*} {messageType:*} {tags:*} {createdby:current-user}", "@sysbot notify sub list createdby \"\"")
// test("db create {dbname} {env=all|dev|test|prod}", "@sysbot db create Test All")
// test("octo cancel {projectname} {releaseid}", "sysbot octo cancel blah.bal 1.0.2342")
// test("octo cancel {projectname} {releaseid}", "sysbot octo cancel blah.bal releaseid=1.0.2342")

IIS Healthcheck & Ninject Memory Leak

on Monday, April 22, 2019

This post is definitely not related to the new Healthcheck system introduced in ASP.NET Core.

I’ve written before about a problem with IIS where the Web Farm healthcheck subsystem creates “healthcheck spamming”. The memory leak described in this post is exacerbated by the healthcheck spamming problem and can create application pools with excessive memory use.

The Ninject Memory leak problem is not a problem with Ninject itself, but a problem with the implementation that I had prescribed within our architecture. I just want to be clear that Ninject doesn’t have a memory leak problem, but that I created an implementation which used Ninject improperly and resulted in a memory leak.

Originally, in our Top Level applications (ie. Company.Dealership.Web) the NinjectWebCommon loading system would configure a series of XxxHealthcheckClient and XxxHealthcheckProxy objects to be loaded In Transient Scope. Which is normally fine, as long as you use the created object within a using block; which would ensure the object is disposed. However, the way I was writing the code did not use a using block.

This meant that when a request came into the Company.Dealership.Web.Healtchcheck Controller an instance of Company.Car.HealtcheckClient and Company.Car.HealthcheckProxy would be created and loaded into memory. The request would then progress through Company.Dealership.Web and result in the Proxy making a call to a second website, Company.Car.Web.Healtcheck(Controller). The problem was that once all the calls had completed, the Client and Proxy objects within the Company.Dealership.Web website would not be disposed (the memory leak).

For a low utilization website/webapi this could go unnoticed as IIS’ built-in application pool recycle time of 29 hours could clean up a small memory leak before anyone notices. But, when you compound this memory leak issue with IIS’ healthcheck spamming the issue can become apparent very quickly. In my testing, a website with a single healthcheck client/proxy pair could consume about 100 MB of memory every hour when there are ~200 application pools on the proxy server. (200 appPools x 1 healthcheck every 30 seconds per appPool = 24,000 healthchecks per hour).

The guidance from Ninject’s Web development side is to change your configurations to no longer use Transient Scope when handling web requests. Instead, configurations should scope instances to In Singleton Scope or In Request Scope. I did some testing and In Singleton Scope consistently proved to remove the memory leak issue every time, which In Request Scope didn’t. I tested In Request Scope a few times and one of the times, the memory leak issue reoccurred. Unfortunately, I could not determine why it was leaking and it truly made no sense to me why it happened (it was most likely a misconfigured build). But, either should work.

When using Ninject within a website, any classes which extend Entity Framework’s DBContext should always be configured In Request Scope.

Company.Dealership.Web
Company.Car.Web
Company.Car.Services
Company.Car
HealtcheckController
HealthcheckClient
(Singleton or Request Scope)
HealthcheckProxy : IHealthcheckService
(Singleton or Request Scope)
HealthcheckController
HealthcheckService : IHealthcheckService
(Singleton or Request Scope)
IHealthcheckService
NinjectWebCommon
NinjectWebCommon
CarDao
(Request Scope)
CarNinjectModule
CarServices
NinjectModule

Here is some code which can detect if a class is currently configured to use In Transient Scope (or is not configured at all) and will reconfigure (rebind) the class to In Singleton Scope:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using Ninject;
using Ninject.Infrastructure;
namespace Company.AspNet.WebApi.WebHost
{
public static class NinjectWebCommonLoader
{
/// <summary>
/// A standardized way to load assemblies
///
/// here's the issue:
///
/// The current design pattern for Client classes has
/// an issue that it never disposes itself or the objects
/// it uses(Proxy's). This creates a memory leak, as
/// every requests (especially the thousands of requests
/// to the healthchecks) will create instances of these
/// classes and they will never be garbage collected.
///
/// okay, so here's the new methodology to fix the problem:
///
/// * Load bindings from NinjectModule's
/// * Search through all the bindings for Client
/// and Proxy class bindings and ensure they are bound
/// to either InSingletonScope or InRequestScope.
/// * If they are not bound to one of those, then bind
/// it to InSingletonScope.
///
///
/// here's how it does it in detail:
///
/// 1. Find all the assemblies made by Ucsb
/// 2. Split them into the Public and Non-Public assemblies
/// (Really, this should be done smarter to organize
/// them by dependency graphs. The lowest level ones
/// need to be loaded first)
/// 3. Load the NinjectModules in them.With the Non-Public
/// first, then load the Public.
/// 4. Now search through all the assemblies and look for
/// clases that match* Client, *Proxy and I* Service.
/// The goal is to use this class/interface list
/// to find all bindings that have been loaded
/// for the Client and Proxy list.The I*Service
/// list is not really needed, but I wanted to cover all
/// bases.Bindings often look like Bind<I*Service>.To<*Client>.
/// 5. If we can find the* Service or* Class in Bind<*>.ToSelf() or
/// Bind<I*Service>.To<*Client> pattern then we need to check if
/// it was bound to InTransientScope(the default scope).
/// 6. If it was bound to InTransientScope, or no binding
/// was found then switch it to InSingletonScope.
/// </summary>
/// <param name="kernel">The kernel.</param>
/// <param name="assemblies">
/// All the assemblies currently loaded in the application.
/// Use AppDomain.CurrentDomain.GetAssemblies().
/// </param>
/// <param name="callingClassType">
/// Essentially a this reference for the class that called this method.
/// Use typeof(ClassName).
/// </param>
public static void RegisterServices(IKernel kernel, Assembly[] assemblies, Type callingClassType)
{
var topLevelAssembly = callingClassType.Assembly;
var ucsbAssemblies = assemblies.Where(i => i.FullName.Contains("Company.") && i.IsDynamic == false);
var notThisOneUcsbAssemblies = ucsbAssemblies.Where(i => i != topLevelAssembly);
var publicAssemblies = notThisOneUcsbAssemblies.Where(i => i.FullName.Contains("Public"));
var nonPublicAssemblies = notThisOneUcsbAssemblies.Where(i => i.FullName.Contains("Public") == false);
foreach (var assembly in nonPublicAssemblies)
{
kernel.Load(assembly);
}
foreach (var assembly in publicAssemblies)
{
kernel.Load(assembly);
}
kernel.Load(topLevelAssembly);
var clientsAndProxies = new List<Type>();
foreach (var assembly in ucsbAssemblies)
{
var found = assembly.GetTypes().Where(i =>
{
return i.Name.EndsWith("Client")
|| i.Name.EndsWith("Proxy");
});
clientsAndProxies.AddRange(found);
}
foreach (var type in clientsAndProxies)
{
var typeMatched = false;
var typeBindings = kernel.GetBindings(type).ToList();
if (typeBindings.Count > 0)
{
typeMatched = true;
var binding = typeBindings[0];
if (binding.ScopeCallback == StandardScopeCallbacks.Transient)
{
kernel.Rebind(type).ToSelf().InSingletonScope();
}
}
var interfaces = type.GetInterfaces();
foreach (var iface in interfaces)
{
var ifaceBindings = kernel.GetBindings(iface);
foreach (var binding in ifaceBindings)
{
var prototype = GetPrivateVariable2(binding.ProviderCallback.Target, "prototype");
if (prototype != null)
{
if ((Type)prototype == type)
{
typeMatched = true;
if (binding.ScopeCallback == StandardScopeCallbacks.Transient)
{
kernel.Rebind(iface).To(type).InSingletonScope();
}
}
}
}
}
if (typeMatched == false)
{
kernel.Bind(type).ToSelf().InSingletonScope();
}
}
}
/// <summary>
/// Retrieves a private variables value using reflection.
/// </summary>
/// <param name="obj">Object to retrieve the value from.</param>
/// <param name="variableName">The variable to read.</param>
/// <returns>The variable's contents as an object.</returns>
public static object GetPrivateVariable2(
this object obj,
string variableName
)
{
// TODO: Move into Enterprise.Utility.Core
var currentType = obj.GetType();
FieldInfo info;
const BindingFlags bFlags = BindingFlags.GetField | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
do
{
info = currentType.GetField(
variableName,
bFlags
);
currentType = currentType.BaseType;
} while (info == null && currentType != null);
if (currentType == null) return null;
return info.GetValue(obj);
}
}
}

Creating Charts/Graphs with Powershell for Slack - Pt3

on Monday, April 15, 2019

Continued from Creating Charts/Graphs with Powershell for Slack – Pt2.

Send the Graph Back Into Slack

Since ChartJs was able to create a .png image of the chart, now we just have to figure out how to get the image into a message and send it back to slack.

In my imagination, the best possible approach would be to use Slack APIs files.upload endpoint to push the image up to slack and simply reference the image using slack’s private urls. However, I could not get this to work. Maybe someday in the future.

The PSSlack module (which is great!) does have an upload feature built into the New-SlackMessageAttachment command. But, in my experimentation I was only able to upload files or large text blocks; when I tried to upload images they never appeared as image. They just appeared as files that could be downloaded. Maybe I was doing something wrong.

So, I went a third route and did something which is pretty bad design. I used a website that I had access to in order to host the images and reference them as urls. This comes with the drawback that the images hosted on the website would need to be monitored for retention periods and cleanup. But, it’s a quick and easy way to get the image up there.

Below is a wrapper command which will use the image url in an PSSlack message. This will display the newly created graph in chat just as you would hope.

Script using the functions together:

Send-SlackHubotImageUrl:

$pngPath = New-VmwareUtilizationGraph -ServerName $ServerName -Type $Type -Timeframe $Timeframe
$filename = Split-Path -Path $pngPath -Leaf
Copy-Item -Path $pngPath -Destination "D:\AllContent\Websites\website.name.com\vmwaregraphs"
Remove-Item -Path $pngPath
$url = "https://website.name.com/vmwaregraphs/$filename"
Send-SlackSysbotImageUrl -Channel $Channel -Url $url
view raw PS-Graph.ps1 hosted with ❤ by GitHub

function Send-SlackHubotImageUrl {
[CmdletBinding()]
param (
[string] $Channel,
[string] $Url,
[switch] $Throw = $false
)
$attachment = PSSlack\New-SlackMessageAttachment `
-ImageURL $Url `
-Fallback $Url
PSSlack\Send-SlackMessage `
-Channel $Channel `
-Token $global:SlackYourCorpModule.HubotSlackToken `
-Username $global:SlackYourCorpModule.HubotName `
-Attachments $attachment `
-Throw:$Throw
}

Creating Charts/Graphs with Powershell for Slack–Pt2

on Monday, April 8, 2019

Continued from Creating Charts/Graphs with Powershell for Slack – Pt1.

Generate a graph/chart

So, this was an adventure that went down a wild number of paths. What I needed was a program that could run from powershell and generate a graph in .png/.svg format. The image would be used later to send a message in slack.

Skip down below to chartjs/canvas for a solution.

Initially I wanted to be able to build a graph using powershell, so I searched the https://www.powershellgallery.com/ and github for what they had under 'chart’ or ‘graph’. Here’s some of the ones I tried and I why I didn’t settle on them:

At this point, I changed directions and started looking for regular .NET or nuget packages which could do graphing. This also resulted in a dead end, usually because of price.

  • Live Charts (https://lvcharts.net/)

    I didn’t see a way to generate an image.
  • Highcharts .NET (https://www.highcharts.com/blog/products/dotnet/)

    This one came up a lot on stackoverflow answers and blog posts. I think it’s really good and the price tag shows that the developers believe that too.
  • DotNetCharting (https://www.dotnetcharting.com/)

    I think this could do the job, but it also costs money.
  • OxyPlot (http://www.oxyplot.org/)

    I don’t think I spent as much time on this as I should have. I was still a little sore at OxyPlotCli making it seem like it was impossible to force the setting of the X axis from 0 to 100. When working with the powershell version I used the OxyPlot source code to check if I could set the Y axis min/max values using reflection, but the X axis is always recalculated dynamically on line graphs. So, I assumed that would also be the case with the nuget version.

So, by this point I reached back in time to about 15 years ago and a friend was showing me the plot he had made in a just a couple hours with Gnuplot. And, he was using a Windows PC.

  • Gnuplot win64 mingw pre-compiled
    (http://tmacchant3.starfree.jp/gnuplot/Eng/winbin/)

    I really should have spent more time with this one. I only spent about 30 minutes on it and didn’t get a graph produced, so I scratched it. But, looking back now, it was probably the most straight forward product with the least amount of dependencies. I would really like to take a second look at this one.

At this point, I decided to try to stick with the two technology stacks I was already using (powershell & nodejs). Since, I felt like I had exhausted the powershell stack, I took a look at npm for the nodejs stack.

  • Google Charts (https://developers.google.com/chart/)

    Google Charts came up over and over again on stackoverflow and blog post answers. But, most of the answers had to do with an older (and recently deprecated 3/18/2019) version which allowed for web api calls to be made to google which would return the generated charts.

    The newer Google Charts runs completely in the browser and generates the graphs on the client side. To make this work, I would just need to be able to use a shim in nodejs to make it use the google charts as if it were in the browser. The recommended choice was JSDOM.

    However, before I really took the time to make this work I remembered that a co-worker got charting working using chartjs & canvas. So, I did a google search on that and …

chartjs/canvas on node charting

image

This finally did the trick. The software was pretty easy to use, with plenty of examples on their website and it was in a technology stack that had previous experience with.

The challenge with using this combination is that I wanted to make the charting functionality available from a Powershell command. So, doing the ridiculous, I built a Powershell module around ChartJS. (Powershell code which, if you remember from Pt 1, is being called from nodejs!)

Some notes on this code …

  • packages.json

    To use this module you will need to use npm install to pull down the node dependencies found in package.json.
  • ChartJs.psm1

    This will setup some variables which will be referenced else where.
  • New-ChartJsUtilizationGraph.ps1

    Obviously, this is the one that actually generates the graph. It expects data to be either CPU or Memory utilization data; anything else will probably cause an issue.

    In order to make a useful graph, we want as many data points from VMware as possible to display (see previous post). Each data points X value will have a datestamp associated with it. Unfortunately, that can make the X axis display become very dense / unreadable as the multiple timestamps display on top of each other (or they don’t display at all). To have better control over this, lines 41-52 ensure that there are only 5 datestamps displayed: first in time, 1/4 of the way through, 1/2 way, 3/4 of the way, and the most recent timestamp.

    The function works by generating the javascript which can actually run in nodejs. This script is placed in a temporary folder and the node_modules needed to use it are copied into that same folder (for local reference/usage).

    At the end of the script, it should clean up the temporary folder and files, but it won’t do that if an error occurs. I wanted to be able to debug errors, and if the temporary was always cleaned up … well, you get it.
  • GraphUtilizationTemplate.js

    This is a copy and paste of an example file I found on stackoverflow. Using the documentation, it didn’t take too long to change things to what I needed; but it also wasn’t straight forward. You’ll probably need to do some experimentation to find what you need.

    An important thing to note, xAxes > ticket > autoSkip: false is really important to make the labels on the x axis appear correctly.
{
"name": "chartjsucsb",
"version": "1.0.0",
"description": "",
"main": "index.js",
"directories": {
"test": "tests"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"chart.js": "^2.8.0",
"chartjs-node-canvas": "^2.0.1"
}
}
view raw package.json hosted with ❤ by GitHub
# http://stackoverflow.com/questions/1183183/path-of-currently-executing-powershell-script
$root = Split-Path $MyInvocation.MyCommand.Path -Parent;
# grab functions from files (from C:\Chocolatey\chocolateyinstall\helpers\chocolateyInstaller.psm1)
Resolve-Path $root\ChartJs.*.ps1 |
? { -not ($_.ProviderPath.Contains(".Tests.")) } |
% { . $_.ProviderPath; }
# grab functions from files (from C:\Chocolatey\chocolateyinstall\helpers\chocolateyInstaller.psm1)
$privateFiles = dir -Path $root\private -Recurse -Include *.ps1 -ErrorAction SilentlyContinue
$publicFiles = dir -Path $root\public -Recurse -Include *.ps1 -ErrorAction SilentlyContinue
if(@($privateFiles).Count -gt 0) { $privateFiles.FullName |% { . $_; } }
if(@($publicFiles).Count -gt 0) { $publicFiles.FullName |% { . $_; } }
$publicFuncs = $publicFiles |% { $_.Name.Substring(0, $_.Name.Length - 4) }
Export-ModuleMember -Function $publicFuncs
# setup namespaced domain variabless
if($global:ChartJs -eq $null) {
$global:ChartJs = @{
ResourcesPath = "$root\Resources"
ModuleRoot = $root
};
$global:ChartJs.GraphUtilizationTemplate = "$($global:ChartJs.ResourcesPath)\GraphUtilizationTemplate.js"
}
$ExecutionContext.SessionState.Module.OnRemove += {
Remove-Variable -Name ChartJs -Scope global
}
view raw ChartJs.psm1 hosted with ❤ by GitHub
<#
.SYNOPSIS
Create a new utlization over time graph. This will produce a .png file.
.PARAMETER Data
The is expected to be an array of PSCustomObjects with properties X, Y.
.PARAMETER PngPath
The location on disk to write the image to.
#>
function New-ChartJsUtilizationGraph {
[CmdletBinding()]
Param (
[Parameter(Mandatory = $true)]
[string] $ServerName,
[Parameter(Mandatory = $true)]
[ValidateSet("cpu","mem")]
[string] $Type,
[Parameter(Mandatory = $true)]
[Array] $Data,
[Parameter(Mandatory = $true)]
[string] $PngPath,
[Decimal] $Max = -1.0
)
if($Type -eq "mem" -and $Max -eq -1.0) {
throw "New-ChartJsUtilizationGraph: When using Type 'mem' the Max parameter is required. The Max value should be total amount of memory on the system in GB."
}
$nodescript = Get-Content -Path $global:ChartJs.GraphUtilizationTemplate -Raw
$title = "{0} - {1}" -f $ServerName, $Type.ToUpper()
$nodescript = $nodescript -replace "PLACEHOLDER_GRAPH_TITLE", $title
$nodescript = $nodescript -replace "PLACEHOLDER_X_DATA", ($Data.X -join ",")
$cnt = $Data.Count
$xlabels = @()
foreach($d in $Data) {
$xlabels += @("")
}
$xlabels[0] = ConvertTo-ChartJsDateTimeFormat -DateTime $Data.Y[0]
$xlabels[$cnt - 1] = ConvertTo-ChartJsDateTimeFormat -DateTime $Data.Y[$cnt - 1]
$mid = [int]([Math]::Floor($cnt / 2))
$xlabels[$mid] = ConvertTo-ChartJsDateTimeFormat -DateTime $Data.Y[$mid]
if($Data.Count -gt 9) {
$fqtr = [int]([Math]::Floor($cnt / 4))
$xlabels[$fqtr] = ConvertTo-ChartJsDateTimeFormat -DateTime $Data.Y[$fqtr]
$lqtr = [int]([Math]::Floor(($cnt / 4) * 3))
$xlabels[$lqtr] = ConvertTo-ChartJsDateTimeFormat -DateTime $Data.Y[$lqtr]
}
$xlabel = "{0}" -f ($xlabels -join ",")
$nodescript = $nodescript -replace "PLACEHOLDER_X_LABELS", $xlabel
switch($Type) {
"cpu" {
$Max = 100
$step = 25
$valueFormatter = "value + '%'"
}
"mem" {
$step = $Max / 4
$valueFormatter = "value + ' GB'"
}
}
$nodescript = $nodescript -replace "PLACEHOLDER_Y_MAX", $Max
$nodescript = $nodescript -replace "PLACEHOLDER_Y_STEP", $step
$nodescript = $nodescript -replace "PLACEHOLDER_Y_FORMAT", $valueFormatter
$now = [DateTime]::Now
$tempfolder = "{0}\ChartJs\{1}_{2}_{3}" -f $env:TEMP, $now.ToString("yyyyMMdd_HHmmss"), $ServerName, $Type
#$pngPath = "{0}\{1}_{2}.png" -f $tempfolder, $ServerName, $Type
$escapedPngPath = $PngPath -replace "\\", "\\"
$nodescript = $nodescript -replace "PLACEHOLDER_PNG_PATH", $escapedPngPath
# if((Test-Path -Path $tempfolder) -eq $false) {
# mkdir -Path $tempfolder
# }
if((Test-Path -Path $tempfolder) -eq $false) {
mkdir -Path $tempfolder | Out-Null
}
$src = "{0}\node_modules" -f $global:ChartJs.ModuleRoot
# . robocopy "$src" "$nmFolder" /r:0 /w:0 /e /ns /nc
Copy-Item -Path $src -Destination $tempfolder -Recurse
$jsPath = "$tempfolder\create-image.js"
$nodescript | Set-Content -Path $jsPath
Push-Location -Path $tempfolder
try {
$nodeResults = . node "$jsPath"
if($LASTEXITCODE -ne 0) {
throw $nodeResults
}
} finally {
Pop-Location
}
if((Test-Path -Path $tempfolder)) {
rm -Path $tempfolder -Recurse -Force
rm -Path $tempfolder -ErrorAction SilentlyContinue
}
}
function ConvertTo-ChartJsDateTimeFormat {
param(
[Parameter(Mandatory = $true)]
[DateTime] $DateTime
)
$ts = $DateTime.ToString("MM/dd/yyyy,\\r\\nHHHH:mm:ss")
return "`"$ts`""
}
const { CanvasRenderService } = require('chartjs-node-canvas');
const fs = require('fs');
const width = 550;
const height = 300;
const configuration = {
type: 'line',
data: {
//labels: ["January","February","","April","May","June","July"],
labels: [PLACEHOLDER_X_LABELS],
datasets: [{
label: 'PLACEHOLDER_GRAPH_TITLE',
//label: 'My First Dataset',
data: [PLACEHOLDER_X_DATA],
//data: [65,59,80,81,56,55,40],
fill: true,
backgroundColor: [
'rgba(111, 179, 231, 1)'
],
lineTension: 0.4
}]
},
options: {
title: {
text: 'title-123'
},
scales: {
yAxes: [{
ticks: {
beginAtZero: true,
max: PLACEHOLDER_Y_MAX,
//max: 100,
stepSize: PLACEHOLDER_Y_STEP,
//stepSize: 10,
callback: (value) => PLACEHOLDER_Y_FORMAT
//callback: (value) => '$' + value
}
}],
xAxes: [{
ticks: {
autoSkip: false
}
}]
}
}
};
const chartCallback = (ChartJS) => {
ChartJS.defaults.global.responsive = true;
ChartJS.defaults.global.maintainAspectRatio = false;
};
(async () => {
const canvasRenderService = new CanvasRenderService(width, height, chartCallback);
//const image = await canvasRenderService.renderToBuffer(configuration);
//const dataUrl = await canvasRenderService.renderToDataURL(configuration);
var out = fs.createWriteStream('PLACEHOLDER_PNG_PATH');
var stream = canvasRenderService.renderToStream(configuration);
stream.on('data', function(chunk){
out.write(chunk);
});
stream.on('end', function(){
console.log('Saved PLACEHOLDER_PNG_PATH');
});
})();

Next up: Creating Charts/Graphs with Powershell for Slack–Pt3 (Sending an Image to Slack).

Creating Charts/Graphs with Powershell for Slack–Pt1

on Monday, April 1, 2019

This adventure came about from troubleshooting a problem with coworkers through slack. While we had hubot commands available to us to look at CPU and Memory usage for an given point in time, we would go to vSphere’s UI to screen grab Memory usage over time. It became quickly apparent that we needed a command to just generate these charts as we needed the in chat. So, our use case was:

Using historical VMWare/vSphere data generate a graph of CPU or Memory usage over time and upload it to slack using a hubot command.

This is actually way harder than it sounds because of one reason. Software that generates charts & graphs is pretty complicated. So, breaking down the use case into smaller sub-problems this is what it look like:

  • Build a Hubot (nodejs) command that will kick off the process.call into Powershell to execute the operations necessary.
  • Retrieve historical CPU/Memory data about a machine from our VMWare/vSphere infrastructure using PowerCLI.
  • Generate a graph/chart from the data using any graphing technology that will actually run (a) outside of a browser and (b) in our environment. This is much more difficult than it sounds. (A Future Post)
  • Send the graph back into slack so that it can be displayed in the channel where it’s needed. (Yet Another Future Post)

Build a Hubot Command

We currently run a hubot (nodejs) instance to handle ChatOps commands through Slack. Even though hubot is build on top of nodejs, we are a Microsoft shop and the majority of knowledge with scripting is in Powershell. So, long ago, when we implemented hubot we used PoshHubot to bridge the technology gap and allow the nodejs platform call our Powershell modules.

If we had to do it over again, with the current technology that available today, we would probably use Poshbot instead.

Anyways, we’ve been doing this for a long time, so this part of things wasn’t too new or difficult.

Retrieve Historical CPU/Memory Data

Within the debugging procedures that started all of this, we were pulling in graphs using screen grabs of vSphere. So, the best place to get data from would be vSphere and VMWare does a great job of making that information available through PowerCLI.

To do this, we used the Get-Stat command with either the cpu.usage.average or mem.usage.average stat being retrieved.

Quick note: I’m having a difficult time getting Connect-VIServer to work with a PSCredentials object. I’m not sure what the issue is, but for now the authentication process to the server is working because Connect-VIServer allows you to store credentials on a machine and reuse them. That was pretty nice of them.

The Get-Stat data is somewhat wonky at times. In the “1h” timeframe I’m using an IntervalSecs of 20 simply because that’s the only interval allowed. Each data point is always 20 seconds apart at 00, 20, and 40 seconds. If you use a Start and Finish range of over a week along with an IntervalSec amount, you could wait a real long time to get your data back; but you will get back all the data.

Because of all the data that will come back if you use an Interval amount, when you start to get a time range longer than a few hours it’s best to just let the Get-Stat command figure out what’s the appropriate interval amount to send back. That’s why on the “1d”, “1w”, “1m”, and “1y” timeframes I just use the Start and Finish parameters without an interval.

Both the cpu.usage.average and mem.usage.average data points return a percentage value back. This is fine for the CPU data, because we normally think about CPU usage in percentages. But, for memory, we normally think of its usage in GBs. So, there’s a quick section which converts the Memory usage percentage over to the actual amount of GBs used.

Next time, I’ll dig into New-ChartJsUtilizationGraph.

<#
.SYNOPSIS
Pulls CPU or Memory usage data from VMWare and creates a .png image
of the utilization.
#>
function New-VmwareUtilizationGraph {
[CmdletBinding()]
Param (
[Parameter(Mandatory = $true)]
[string] $ServerName,
[Parameter(Mandatory = $true)]
[ValidateSet("cpu","mem")]
[string] $Type,
[Parameter(Mandatory = $false)]
[ValidateSet("1h","1d","1w","1m","1y")]
[string] $Timeframe = "1h"
)
### Put this stuff in the .psm1 file - Start
Import-Module VMware.PowerCLI
Import-Module ChartJsUcsb
Connect-VIServer -Server "your.vmsphere.server.company.com" | Out-Null
### Put this stuff in the .psm1 file - End
$vm = VMware.VimAutomation.Core\Get-VM -Name $ServerName
$stat = "cpu.usage.average"
if($Type -eq "mem") {
$stat = "mem.usage.average"
}
$now = [DateTime]::Now
switch($Timeframe) {
"1h" {
# I think VMWare only records on 20 second intervals
$rawdata = Get-Stat -Entity $vm -Stat $stat -IntervalSecs 20 -MaxSamples 180
}
"1d" {
$start = $now.AddDays(-1)
$rawdata = Get-Stat -Entity $vm -Stat $stat -Start $start -Finish $now -MaxSamples 200
}
"1w" {
$start = $now.AddDays(-7)
$rawdata = Get-Stat -Entity $vm -Stat $stat -Start $start -Finish $now -MaxSamples 200
}
"1m" {
$start = $now.AddMonths(-1)
$rawdata = Get-Stat -Entity $vm -Stat $stat -Start $start -Finish $now -MaxSamples 200
}
"1y" {
$start = $now.AddYears(-1)
$rawdata = Get-Stat -Entity $vm -Stat $stat -Start $start -Finish $now -MaxSamples 200
}
}
$data = @()
for($i = $rawdata.Count - 1; $i -ge 0; $i--) {
$d = $rawdata[$i]
$x = $d.Value
if($Type -eq "mem") {
$x = ($d.Value / 100) * $vm.MemoryGB
}
$n = [PSCustomObject] @{ X = $x; Y = $d.Timestamp }
$data += @($n)
}
$timestamp = [DateTime]::Now.ToString("yyyyMMdd-HHmmss")
$tempPngBasePath = "{0}\Vmware" -f $env:TEMP
$tempPngPath = "{0}\{1}_{2}_{3}.png" -f $tempPngBasePath, $ServerName, $Type, $timestamp
if((Test-Path -Path $tempPngBasePath) -eq $false) {
mkdir -Path $tempPngBasePath | Out-Null
}
$max = 100
if($Type -eq "mem") {
$max = $vm.MemoryGB
}
New-ChartJsUtilizationGraph `
-ServerName $ServerName `
-Type $Type `
-PngPath $tempPngPath `
-Max $max `
-Data $data
#$types = Get-StatType -Entity $vm
#$types |? { $_.StartsWith("mem") }
#Get-StatInterval -Name mem.usage.average
return $tempPngPath
}


Creative Commons License
This site uses Alex Gorbatchev's SyntaxHighlighter, and hosted by herdingcode.com's Jon Galloway.