Skip to content
This repository has been archived by the owner on Nov 20, 2023. It is now read-only.

Dashboard quality of life enhancements #1566

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/Microsoft.Tye.Hosting/Ansi2Html/Constants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using System.Collections.Generic;

namespace Microsoft.Tye.Hosting.Ansi2Html
{
public static class Constants
{
public const string Red = "#800000";
public const string Black = "#000000";
public const string Green = "#008000";
public const string Yellow = "#808000";
public const string Blue = "#000080";
public const string Purple = "#800080";
public const string Cyan = "#008080";
public const string LightGray = "#c0c0c0";
public const string DarkGray = "#808080";
public const string BrightRed = "#ff0000";
public const string BrightGreen = "#00ff00";
public const string BrightYellow = "#ffff00";
public const string BrightBlue = "#0000ff";
public const string BrightPurple = "#ff00ff";
public const string BrightCyan = "#00ffff";
public const string White = "#ffffff";

public static Dictionary<string, string> ColorMap = new Dictionary<string, string>()
{
{ "0", Black }, // Black
{ "1", Red }, // Red
{ "2", Green }, // Green
{ "3", Yellow }, // Yellow
{ "4", Blue }, // Blue
{ "5", Purple }, // Purple
{ "6", Cyan }, // Cyan
{ "7", LightGray }, // Light Gray
{ "8", DarkGray }, // Dark Gray
{ "9", BrightRed }, // Bright Red
{ "10", BrightGreen }, // Bright Green
{ "11", BrightYellow }, // Bright Yellow
{ "12", BrightBlue }, // Bright Blue
{ "13", BrightPurple }, // Bright Purple
{ "14", BrightCyan }, // Bright Cyan
{ "15", White } // White
};

public static class SelectGraphicRenditionParameters
{
public const int Reset = 0;

public static HashSet<int> SetForeground = new HashSet<int>()
{
30, 31, 32, 33, 34, 35, 36, 37,38
};

}
}
}
49 changes: 49 additions & 0 deletions src/Microsoft.Tye.Hosting/Ansi2Html/Converter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;

namespace Microsoft.Tye.Hosting.Ansi2Html
{
public class Converter
{
private readonly Regex _rule = new Regex("\u001b\\[(?<code>\\d+)(?<args>;\\d+)*m", RegexOptions.Compiled | RegexOptions.Multiline | RegexOptions.IgnoreCase);

public string Parse(string input)
{
var htmlText = _rule.Replace(input, match =>
{
var code = Convert.ToInt32(match.Groups["code"].Value);
var args = match.Groups["args"].Value;
List<string> attributes = args.Split(';').ToList();

var tagBuilder = new StringBuilder();
if (code == Constants.SelectGraphicRenditionParameters.Reset)
{
tagBuilder.Append("</span>");
}
else
{
var color = Constants.White;
if (attributes.Count > 0)
{
string colorCode = attributes.Last();
if (Constants.ColorMap.ContainsKey(colorCode))
{
color = Constants.ColorMap[colorCode];
}
}

tagBuilder.Append("<span style=\"color:");
tagBuilder.Append(color);
tagBuilder.Append(";\">");
}

return tagBuilder.ToString();
});

return htmlText;
}
}
}
21 changes: 18 additions & 3 deletions src/Microsoft.Tye.Hosting/Dashboard/Pages/Index.razor
Original file line number Diff line number Diff line change
Expand Up @@ -12,29 +12,34 @@
<table class="table service-table">
<thead>
<tr>
<th></th>
<th>Name</th>
<th>Type</th>
<th>Source</th>
<th>Bindings</th>
<th>Replicas</th>
<th>Restarts</th>
<th>Logs</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var service in application.Services.Values)
{
var logsPath = $"logs/{service.Description.Name}";
var servicePath = $"services/{service.Description.Name}";
var serviceState = service.State;
<tr @key="service.Description.Name">
<td>
<span class="badge @GetServiceStateClass(serviceState)">@serviceState</span>
</td>
<td>
@if(service.ServiceType == ServiceType.External)
{
<span>@service.Description.Name</span>
}
else
{
<a href="@servicePath">@service.Description.Name</a>
<a href="@logsPath">@service.Description.Name</a>
}
</td>
<td>
Expand Down Expand Up @@ -91,7 +96,7 @@
{
<td>@service.Replicas.Count/@service.Description.Replicas</td>
<td>@service.Restarts</td>
<td><NavLink href="@logsPath">View</NavLink></td>
<td><NavLink href="@logsPath">Logs</NavLink> | <NavLink href="@servicePath">Metrics</NavLink></td>
}
</tr>
}
Expand All @@ -102,6 +107,16 @@

private List<IDisposable> _subscriptions = new List<IDisposable>();

string GetServiceStateClass(ServiceState serviceState) => serviceState switch
{
ServiceState.Starting => "badge-secondary",
ServiceState.Started => "badge-success",
ServiceState.Degraded => "badge-danger",
ServiceState.Failed => "badge-warning",
ServiceState.Stopped => "badge-light",
_ => "badge-dark"
};

string GetUrl(ServiceBinding b)
{
return $"{(b.Protocol ?? "tcp")}://{b.Host ?? "localhost"}:{b.Port}";
Expand Down
9 changes: 6 additions & 3 deletions src/Microsoft.Tye.Hosting/Dashboard/Pages/Logs.razor
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
@page "/logs/{ServiceName}"
@using Microsoft.Tye.Hosting.Ansi2Html;
@inject IJSRuntime JS
@inject Application application
@inject Converter ansi2HtmlParser
@implements IDisposable

<style>
Expand Down Expand Up @@ -42,7 +44,7 @@ else
[Parameter]
public string ServiceName { get; set; } = default!;

public List<(string Text, int Id)>? ApplicationLogs { get; set; }
public List<(MarkupString Text, int Id)>? ApplicationLogs { get; set; }

private IDisposable? _subscription;

Expand All @@ -53,7 +55,7 @@ else
// TODO: handle this returning false
if (application.Services.TryGetValue(ServiceName, out var service))
{
ApplicationLogs = service.CachedLogs.Select((item, index) => (item, index)).ToList();
ApplicationLogs = service.CachedLogs.Select((item, index) => ((MarkupString)item, index)).ToList();
var count = ApplicationLogs.Count;
StateHasChanged();

Expand All @@ -62,7 +64,8 @@ else
count++;
InvokeAsync(() =>
{
ApplicationLogs.Add((log, count));
string htmlLog = ansi2HtmlParser.Parse(log);
ApplicationLogs.Add(((MarkupString)htmlLog, count));
StateHasChanged();
});
});
Expand Down
51 changes: 51 additions & 0 deletions src/Microsoft.Tye.Hosting/Model/Service.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Reactive.Subjects;

namespace Microsoft.Tye.Hosting.Model
Expand Down Expand Up @@ -83,5 +84,55 @@ public ServiceType ServiceType
public Subject<string> Logs { get; } = new Subject<string>();

public Subject<ReplicaEvent> ReplicaEvents { get; } = new Subject<ReplicaEvent>();

public ServiceState State
{
get
{
var replicaStates = Replicas.Values.Select(r => r.State);
int replicaCount = replicaStates.Count();


if (replicaCount == 0)
return ServiceState.Unknown;

if (replicaStates.Any(r => r == ReplicaState.Added))
return ServiceState.Starting;

if (replicaStates.All(r => r == ReplicaState.Started || r == ReplicaState.Ready || r == ReplicaState.Healthy))
return ServiceState.Started;

if (replicaCount == 1)
{
ReplicaState? replicaState = replicaStates.Single();

if (replicaState == ReplicaState.Removed)
return ServiceState.Failed;

if (replicaState == ReplicaState.Stopped)
return ServiceState.Stopped;
}
else
{
if (replicaStates.All(r => r == ReplicaState.Stopped))
return ServiceState.Stopped;

if (replicaStates.Any(r => r == ReplicaState.Removed || r == ReplicaState.Stopped))
return ServiceState.Degraded;
}

return ServiceState.Unknown;
}
}
}

public enum ServiceState
{
Unknown,
Starting,
Started,
Degraded,
Failed,
Stopped
}
}
5 changes: 1 addition & 4 deletions src/Microsoft.Tye.Hosting/ProcessRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -200,10 +200,7 @@ async Task RunApplicationAsync(IEnumerable<(int ExternalPort, int Port, string?
{
// Default to development environment
["DOTNET_ENVIRONMENT"] = "Development",
["ASPNETCORE_ENVIRONMENT"] = "Development",
// Remove the color codes from the console output
["DOTNET_LOGGING__CONSOLE__DISABLECOLORS"] = "true",
["ASPNETCORE_LOGGING__CONSOLE__DISABLECOLORS"] = "true"
["ASPNETCORE_ENVIRONMENT"] = "Development"
};

// Set up environment variables to use the version of dotnet we're using to run
Expand Down
2 changes: 2 additions & 0 deletions src/Microsoft.Tye.Hosting/TyeHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Tye.Hosting.Ansi2Html;
using Microsoft.Tye.Hosting.Diagnostics;
using Microsoft.Tye.Hosting.Model;
using Serilog;
Expand Down Expand Up @@ -204,6 +205,7 @@ private IHost BuildWebApplication(Application application, HostOptions options,
});
});
services.AddSingleton(application);
services.AddTransient<Converter>();
})
.Build();
}
Expand Down
21 changes: 21 additions & 0 deletions test/UnitTests/Ansi2HtmlConverterTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using Microsoft.Tye.Hosting.Ansi2Html;
using Xunit;

namespace Microsoft.Tye.UnitTests;

public class Ansi2HtmlConverterTests
{
[Theory]
[InlineData("\u001b[31;1mThis text is red\u001b[0m", $"<span style=\"color:{Constants.Red};\">This text is red</span>")]
[InlineData(
"\u001b[31;1m\u001b[0m\u001b[36;1m\u001b[36;1m 3 | \u001b[0m \u001b[36;1muvicorn\u001b[0m app.main:app --port $Env:PORT --reload --log-level debug\u001b[0m",
$"<span style=\"color:{Constants.Red};\"></span><span style=\"color:{Constants.Red};\"><span style=\"color:{Constants.Red};\"> 3 | </span> <span style=\"color:{Constants.Red};\">uvicorn</span> app.main:app --port $Env:PORT --reload --log-level debug</span>")]
public void ShouldParse(string input, string expected)
{
Converter converter = new Converter();

string actual = converter.Parse(input);

Assert.Equal(expected, actual);
}
}
8 changes: 8 additions & 0 deletions test/UnitTests/Microsoft.Tye.UnitTests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@
<TestRunnerName>XUnit</TestRunnerName>
</PropertyGroup>

<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<LangVersion>10.0</LangVersion>
</PropertyGroup>

<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<LangVersion>10.0</LangVersion>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="1.0.1" />
<PackageReference Include="coverlet.msbuild" Version="2.8.0">
Expand Down
52 changes: 52 additions & 0 deletions test/UnitTests/ServiceUnitTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using System.Collections.Generic;
using Microsoft.Tye.Hosting.Model;
using Xunit;

namespace Microsoft.Tye.UnitTests
{
public class ServiceUnitTests
{
[Theory]
[MemberData(nameof(ServiceStateTestData))]
public void ServiceStateIsBasedOnReplicaStates(ServiceState expected, List<ReplicaState> replicaStates)
{
Service service = new(new ServiceDescription("test", null), ServiceSource.Unknown);

for (int i = 0; i < replicaStates.Count; i++)
{
string replicaName = i.ToString();

service.Replicas.TryAdd(replicaName, new ReplicaStatus(service, replicaName)
{
State = replicaStates[i],
});
}

Assert.Equal(expected, service.State);

}

public static IEnumerable<object[]> ServiceStateTestData =>
new List<object[]>
{
//no replica - should not happen
new object[] { ServiceState.Unknown, new List<ReplicaState>() },

//one replica
new object[] { ServiceState.Starting, new List<ReplicaState>() { ReplicaState.Added } },
new object[] { ServiceState.Started, new List<ReplicaState>() { ReplicaState.Started } },
new object[] { ServiceState.Started, new List<ReplicaState>() { ReplicaState.Ready } },
new object[] { ServiceState.Started, new List<ReplicaState>() { ReplicaState.Healthy } },
new object[] { ServiceState.Failed, new List<ReplicaState>() { ReplicaState.Removed } },
new object[] { ServiceState.Stopped, new List<ReplicaState>() { ReplicaState.Stopped } },

//multiple replicas
new object[] { ServiceState.Starting, new List<ReplicaState>() { ReplicaState.Added, ReplicaState.Started, ReplicaState.Ready, ReplicaState.Healthy } },
new object[] { ServiceState.Started, new List<ReplicaState>() { ReplicaState.Started, ReplicaState.Ready, ReplicaState.Healthy } },
new object[] { ServiceState.Degraded, new List<ReplicaState>() { ReplicaState.Removed, ReplicaState.Started, ReplicaState.Ready, ReplicaState.Healthy } },
new object[] { ServiceState.Degraded, new List<ReplicaState>() { ReplicaState.Stopped, ReplicaState.Started, ReplicaState.Ready, ReplicaState.Healthy } },
new object[] { ServiceState.Degraded, new List<ReplicaState>() { ReplicaState.Removed, ReplicaState.Stopped, ReplicaState.Started, ReplicaState.Ready, ReplicaState.Healthy } },
new object[] { ServiceState.Stopped, new List<ReplicaState>() { ReplicaState.Stopped, ReplicaState.Stopped, ReplicaState.Stopped } },
};
}
}