diff --git a/Directory.Build.props b/Directory.Build.props
index 6b5b8ee..3296403 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,5 +1,6 @@
- 0.6.2
+ Tilework
+ 0.6.3
diff --git a/tilework.core/AppMetadata.cs b/tilework.core/AppMetadata.cs
new file mode 100644
index 0000000..da9003a
--- /dev/null
+++ b/tilework.core/AppMetadata.cs
@@ -0,0 +1,21 @@
+using System.Reflection;
+
+namespace Tilework.Core;
+
+public static class AppMetadata
+{
+ private static readonly Assembly _assembly = typeof(AppMetadata).Assembly;
+
+ public static string Name { get; } =
+ _assembly.GetCustomAttribute()?.Product
+ ?? _assembly.GetName().Name
+ ?? "Application";
+
+ public static string InformationalVersion { get; } =
+ _assembly.GetCustomAttribute()?.InformationalVersion
+ ?? "0.0.0";
+
+ public static string Version { get; } = InformationalVersion.Split('+')[0];
+
+ public static string DisplayVersion => $"v{Version}";
+}
diff --git a/tilework.core/Commands/PrintVersion.cs b/tilework.core/Commands/PrintVersion.cs
index ebe3169..688f955 100644
--- a/tilework.core/Commands/PrintVersion.cs
+++ b/tilework.core/Commands/PrintVersion.cs
@@ -1,5 +1,4 @@
using Tilework.Core.Interfaces;
-using System.Reflection;
namespace Tilework.Core.Commands;
@@ -14,9 +13,7 @@ public PrintVersionInfoCommand()
public async Task run(string[] args)
{
- var assembly = Assembly.GetExecutingAssembly();
- var informationalVersion = assembly.GetCustomAttribute()?.InformationalVersion;
- Console.WriteLine(informationalVersion);
+ Console.WriteLine(AppMetadata.InformationalVersion);
return 0;
}
-}
\ No newline at end of file
+}
diff --git a/tilework.core/Enums/LoadBalancing/ConditionType.cs b/tilework.core/Enums/LoadBalancing/ConditionType.cs
index 48996f2..2c0f4c6 100644
--- a/tilework.core/Enums/LoadBalancing/ConditionType.cs
+++ b/tilework.core/Enums/LoadBalancing/ConditionType.cs
@@ -11,6 +11,7 @@ public enum ConditionType
[Description("Query string")]
QueryString,
[Description("SNI FQDN")]
- SNI
+ SNI,
+ [Description("Source IP")]
+ SourceIp
}
-
diff --git a/tilework.core/Enums/LoadBalancing/LoadBalancerConditionRules.cs b/tilework.core/Enums/LoadBalancing/LoadBalancerConditionRules.cs
index 84408db..d49301f 100644
--- a/tilework.core/Enums/LoadBalancing/LoadBalancerConditionRules.cs
+++ b/tilework.core/Enums/LoadBalancing/LoadBalancerConditionRules.cs
@@ -9,7 +9,8 @@ public static class LoadBalancerConditionRules
{
ConditionType.HostHeader,
ConditionType.Path,
- ConditionType.QueryString
+ ConditionType.QueryString,
+ ConditionType.SourceIp
};
private static readonly ConditionType[] HttpsConditions =
@@ -17,12 +18,14 @@ public static class LoadBalancerConditionRules
ConditionType.HostHeader,
ConditionType.Path,
ConditionType.QueryString,
- ConditionType.SNI
+ ConditionType.SNI,
+ ConditionType.SourceIp
};
private static readonly ConditionType[] TlsConditions =
{
- ConditionType.SNI
+ ConditionType.SNI,
+ ConditionType.SourceIp
};
public static IReadOnlyList GetAllowedConditions(LoadBalancerProtocol protocol)
diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Enums/AclCondition.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Enums/AclCondition.cs
new file mode 100644
index 0000000..1d9e5a3
--- /dev/null
+++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Enums/AclCondition.cs
@@ -0,0 +1,13 @@
+using System.ComponentModel;
+
+namespace Tilework.LoadBalancing.Haproxy;
+
+public enum AclCondition
+{
+ HostHeader,
+ Path,
+ QueryString,
+ SNI,
+ SourceIp,
+ VariableSet,
+}
\ No newline at end of file
diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Enums/HttpRequestAction.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Enums/HttpRequestAction.cs
new file mode 100644
index 0000000..80a8a8d
--- /dev/null
+++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Enums/HttpRequestAction.cs
@@ -0,0 +1,12 @@
+using System.ComponentModel;
+
+namespace Tilework.LoadBalancing.Haproxy;
+
+public enum HttpRequestAction
+{
+ AddHeader,
+ Redirect,
+ Return,
+ Deny,
+ SetVariable
+}
diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyConfigurationProfile.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyConfigurationProfile.cs
index f510c7c..31606ed 100644
--- a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyConfigurationProfile.cs
+++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyConfigurationProfile.cs
@@ -8,102 +8,28 @@ namespace Tilework.LoadBalancing.Haproxy;
public class HAProxyConfigurationProfile : Profile
{
+ private const string BackendVariableName = "txn.tilework_backend";
+ private const string BackendSelectedAclName = "backend_selected";
+
public HAProxyConfigurationProfile()
{
+ CreateMap()
+ .ConvertUsing(src => MapToAclCondition(src));
+
CreateMap()
.ForMember(dest => dest.Name, opt => opt.MapFrom(src => src.Id.ToString()))
.ForPath(dest => dest.Bind.Address, opt => opt.MapFrom(src => "*"))
.ForPath(dest => dest.Bind.Port, opt => opt.MapFrom(src => src.Port))
- .AfterMap((src, dest) =>
+ .AfterMap((src, dest, context) =>
{
if (src.Protocol == LoadBalancerProtocol.HTTPS || src.Protocol == LoadBalancerProtocol.TLS)
dest.Bind.EnableTls = true;
- if (src.Type == LoadBalancerType.APPLICATION)
- {
- dest.AddHeaders.Add(new HttpHeader()
- {
- Name = "X-Forwarded-Proto",
- Value = src.Protocol == LoadBalancerProtocol.HTTPS ? "https" : "http"
- });
-
- dest.AddHeaders.Add(new HttpHeader()
- {
- Name = "X-Forwarded-Port",
- Value = src.Port.ToString()
- });
- }
-
dest.Mode = src.Type == LoadBalancerType.APPLICATION ? Mode.HTTP : Mode.TCP;
- if (src.Rules != null)
- {
- foreach (var rule in src.Rules.OrderBy(r => r.Priority))
- {
- var acls = new List();
- for (int i = 0; i < rule.Conditions.Count; i++)
- {
- var condition = rule.Conditions[i];
-
- var acl = new Acl()
- {
- Name = $"{rule.Id.ToString()}-{i}",
- Type = condition.Type,
- Values = condition.Values
- };
-
- acls.Add(acl);
- }
-
- dest.Acls.AddRange(acls);
-
- if (rule.Action == null)
- throw new InvalidOperationException($"Rule {rule.Id} is missing an action.");
-
- var actionType = rule.Action.Type;
- switch (actionType)
- {
- case RuleActionType.Forward:
- var targetGroup = rule.TargetGroup;
- if (targetGroup != null)
- {
- var usebe = new UseBackend()
- {
- Acls = acls.Select(a => a.Name).ToList(),
- Target = targetGroup.Id.ToString(),
- };
- dest.UseBackends.Add(usebe);
- }
- break;
- case RuleActionType.Redirect:
- dest.HttpRequests.Add(new HttpRequest()
- {
- ActionType = RuleActionType.Redirect,
- RedirectUrl = rule.Action?.RedirectUrl,
- RedirectStatusCode = rule.Action?.RedirectStatusCode,
- Acls = acls.Select(a => a.Name).ToList()
- });
- break;
- case RuleActionType.FixedResponse:
- dest.HttpRequests.Add(new HttpRequest()
- {
- ActionType = RuleActionType.FixedResponse,
- FixedResponseStatusCode = rule.Action?.FixedResponseStatusCode,
- FixedResponseContentType = rule.Action?.FixedResponseContentType,
- FixedResponseBody = rule.Action?.FixedResponseBody,
- Acls = acls.Select(a => a.Name).ToList()
- });
- break;
- case RuleActionType.Reject:
- dest.TcpRequests.Add(new TcpRequest()
- {
- Acls = acls.Select(a => a.Name).ToList()
- });
- break;
- default:
- throw new NotSupportedException($"Unsupported rule action: {actionType}");
- }
- }
- }
+ if (dest.Mode == Mode.HTTP)
+ MapHttpLoadBalancerRules(src, dest, context);
+ else
+ MapTcpLoadBalancerRules(src, dest, context);
});
CreateMap()
@@ -137,4 +63,158 @@ public HAProxyConfigurationProfile()
}).ToList();
});
}
+
+ private static AclCondition MapToAclCondition(ConditionType conditionType)
+ {
+ return conditionType switch
+ {
+ ConditionType.HostHeader => AclCondition.HostHeader,
+ ConditionType.Path => AclCondition.Path,
+ ConditionType.QueryString => AclCondition.QueryString,
+ ConditionType.SNI => AclCondition.SNI,
+ ConditionType.SourceIp => AclCondition.SourceIp,
+ _ => throw new NotSupportedException($"Unsupported condition type for HAProxy ACL mapping: {conditionType}")
+ };
+ }
+
+ private static void MapHttpLoadBalancerRules(LoadBalancer src, FrontendSection dest, ResolutionContext context)
+ {
+ dest.HttpRequests.Add(new AddHeaderHttpRequest(
+ "X-Forwarded-Proto",
+ src.Protocol == LoadBalancerProtocol.HTTPS ? "https" : "http"));
+
+ dest.HttpRequests.Add(new AddHeaderHttpRequest(
+ "X-Forwarded-Port",
+ src.Port.ToString()));
+
+ if (src.Rules is not { Count: > 0 })
+ return;
+
+ var hasForwardRule = false;
+ dest.Acls.Add(new Acl()
+ {
+ Name = BackendSelectedAclName,
+ Type = AclCondition.VariableSet,
+ Values = new List { BackendVariableName }
+ });
+
+ foreach (var rule in src.Rules.OrderBy(r => r.Priority))
+ {
+ var ruleAcls = BuildRuleAcls(rule, context);
+ dest.Acls.AddRange(ruleAcls);
+
+ if (rule.Action == null)
+ throw new InvalidOperationException($"Rule {rule.Id} is missing an action.");
+
+ var gatedAcls = new List { $"!{BackendSelectedAclName}" };
+ gatedAcls.AddRange(ruleAcls.Select(a => a.Name));
+
+ switch (rule.Action.Type)
+ {
+ case RuleActionType.Forward:
+ if (rule.TargetGroup != null)
+ {
+ hasForwardRule = true;
+ dest.HttpRequests.Add(new SetVariableHttpRequest(BackendVariableName, rule.TargetGroup.Id.ToString())
+ {
+ Acls = gatedAcls
+ });
+ }
+ break;
+ case RuleActionType.Redirect:
+ if (string.IsNullOrWhiteSpace(rule.Action.RedirectUrl))
+ throw new InvalidOperationException($"Rule {rule.Id} redirect action is missing RedirectUrl.");
+
+ dest.HttpRequests.Add(new RedirectHttpRequest(rule.Action.RedirectUrl)
+ {
+ StatusCode = rule.Action.RedirectStatusCode,
+ Acls = gatedAcls
+ });
+ break;
+ case RuleActionType.FixedResponse:
+ if (rule.Action.FixedResponseStatusCode == null)
+ throw new InvalidOperationException($"Rule {rule.Id} fixed response action is missing FixedResponseStatusCode.");
+
+ dest.HttpRequests.Add(new ReturnHttpRequest(rule.Action.FixedResponseStatusCode.Value)
+ {
+ ContentType = rule.Action.FixedResponseContentType,
+ Body = rule.Action.FixedResponseBody,
+ Acls = gatedAcls
+ });
+ break;
+ case RuleActionType.Reject:
+ dest.HttpRequests.Add(new DenyHttpRequest()
+ {
+ Acls = gatedAcls
+ });
+ break;
+ default:
+ throw new NotSupportedException($"Unsupported rule action: {rule.Action.Type}");
+ }
+ }
+
+ if (hasForwardRule)
+ {
+ dest.UseBackends.Add(new UseBackend()
+ {
+ Target = $"%[var({BackendVariableName})]",
+ Acls = new List { BackendSelectedAclName }
+ });
+ }
+ }
+
+ private static void MapTcpLoadBalancerRules(LoadBalancer src, FrontendSection dest, ResolutionContext context)
+ {
+ if (src.Rules is not { Count: > 0 })
+ return;
+
+ foreach (var rule in src.Rules.OrderBy(r => r.Priority))
+ {
+ var ruleAcls = BuildRuleAcls(rule, context);
+ dest.Acls.AddRange(ruleAcls);
+
+ if (rule.Action == null)
+ throw new InvalidOperationException($"Rule {rule.Id} is missing an action.");
+
+ var aclNames = ruleAcls.Select(a => a.Name).ToList();
+ switch (rule.Action.Type)
+ {
+ case RuleActionType.Forward:
+ if (rule.TargetGroup != null)
+ {
+ dest.UseBackends.Add(new UseBackend()
+ {
+ Acls = aclNames,
+ Target = rule.TargetGroup.Id.ToString()
+ });
+ }
+ break;
+ case RuleActionType.Reject:
+ dest.TcpRequests.Add(new TcpRequest()
+ {
+ Acls = aclNames
+ });
+ break;
+ default:
+ throw new NotSupportedException($"Unsupported TCP rule action: {rule.Action.Type}");
+ }
+ }
+ }
+
+ private static List BuildRuleAcls(Rule rule, ResolutionContext context)
+ {
+ var ruleAcls = new List();
+ for (int i = 0; i < rule.Conditions.Count; i++)
+ {
+ var condition = rule.Conditions[i];
+ ruleAcls.Add(new Acl()
+ {
+ Name = $"{rule.Id}-{i}",
+ Type = context.Mapper.Map(condition.Type),
+ Values = condition.Values
+ });
+ }
+
+ return ruleAcls;
+ }
}
diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Sections/FrontendSection.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Sections/FrontendSection.cs
index 1eae359..d209afa 100644
--- a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Sections/FrontendSection.cs
+++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Sections/FrontendSection.cs
@@ -23,9 +23,6 @@ public class FrontendSection : ConfigSection
[Statement("default_backend")]
public string DefaultBackend { get; set; }
- [Statement("http-request add-header")]
- public List AddHeaders { get; set; } = new List();
-
public FrontendSection() : base("frontend")
{
}
diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Statements/Acl.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Statements/Acl.cs
index 25eb389..d62b967 100644
--- a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Statements/Acl.cs
+++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Statements/Acl.cs
@@ -5,7 +5,7 @@ namespace Tilework.LoadBalancing.Haproxy;
public class Acl
{
public string Name { get; set; }
- public ConditionType Type { get; set; }
+ public AclCondition Type { get; set; }
public List Values { get; set; } = new List();
public Acl() {}
@@ -19,10 +19,12 @@ public override string ToString()
{
return Type switch
{
- ConditionType.HostHeader => $"{Name} hdr(host) -i {string.Join(" ", Values)}",
- ConditionType.Path => $"{Name} path_beg -i {string.Join(" ", Values)}",
- ConditionType.QueryString => $"{Name} {String.Join(" or ", Values.Select(v => $"url_param(plan) -i {v}"))}",
- ConditionType.SNI => $"{Name} req.ssl_sni -i {string.Join(" ", Values)}"
+ AclCondition.HostHeader => $"{Name} hdr(host) -i {string.Join(" ", Values)}",
+ AclCondition.Path => $"{Name} path_beg -i {string.Join(" ", Values)}",
+ AclCondition.QueryString => $"{Name} {String.Join(" or ", Values.Select(v => $"url_param(plan) -i {v}"))}",
+ AclCondition.SNI => $"{Name} req.ssl_sni -i {string.Join(" ", Values)}",
+ AclCondition.SourceIp => $"{Name} src {string.Join(" ", Values)}",
+ AclCondition.VariableSet => $"{Name} {String.Join(" or ", Values.Select(v => $"var({v}) -m found"))}",
};
}
-}
\ No newline at end of file
+}
diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Statements/HttpRequest.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Statements/HttpRequest.cs
index 3818839..dcafc18 100644
--- a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Statements/HttpRequest.cs
+++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Statements/HttpRequest.cs
@@ -1,45 +1,15 @@
-using System.Linq;
-using Tilework.LoadBalancing.Enums;
-
namespace Tilework.LoadBalancing.Haproxy;
-public class HttpRequest
+public abstract class HttpRequest
{
- public RuleActionType ActionType { get; set; }
- public string? RedirectUrl { get; set; }
- public int? RedirectStatusCode { get; set; }
- public int? FixedResponseStatusCode { get; set; }
- public string? FixedResponseContentType { get; set; }
- public string? FixedResponseBody { get; set; }
public List Acls { get; set; } = new();
-
- public HttpRequest() { }
-
- public HttpRequest(string[] parameters)
- {
- }
+ public abstract HttpRequestAction Action { get; }
public override string ToString()
{
- return ActionType switch
- {
- RuleActionType.Redirect => BuildRedirect(),
- RuleActionType.FixedResponse => BuildReturn(),
- _ => throw new NotSupportedException($"Unsupported HTTP action type: {ActionType}")
- };
- }
-
- private string BuildRedirect()
- {
- var parts = new List { "redirect", "location", RedirectUrl ?? string.Empty };
+ var parts = BuildParts();
- if (RedirectStatusCode.HasValue)
- {
- parts.Add("code");
- parts.Add(RedirectStatusCode.Value.ToString());
- }
-
- if (Acls != null && Acls.Count > 0)
+ if (Acls.Count > 0)
{
parts.Add("if");
parts.AddRange(Acls);
@@ -48,96 +18,96 @@ private string BuildRedirect()
return string.Join(" ", parts);
}
- private string BuildReturn()
- {
- var parts = new List
- {
- "return",
- "status",
- FixedResponseStatusCode!.ToString()
- };
+ protected abstract List BuildParts();
- if (!string.IsNullOrWhiteSpace(FixedResponseContentType))
- {
- parts.Add("content-type");
- parts.Add(FixedResponseContentType);
- }
-
- if (!string.IsNullOrWhiteSpace(FixedResponseBody))
- {
- parts.Add("lf-string");
- parts.Add(Quote(FixedResponseBody));
- }
+ protected static string Quote(string value)
+ {
+ var escaped = value
+ .Replace("\\", "\\\\")
+ .Replace("\"", "\\\"")
+ .Replace("\r", string.Empty)
+ .Replace("\n", "\\n");
+ return $"\"{escaped}\"";
+ }
+}
- if (Acls != null && Acls.Count > 0)
- {
- parts.Add("if");
- parts.AddRange(Acls);
- }
+public sealed class AddHeaderHttpRequest(string name, string value) : HttpRequest
+{
+ public string Name { get; set; } = name;
+ public string Value { get; set; } = value;
+ public override HttpRequestAction Action => HttpRequestAction.AddHeader;
- return string.Join(" ", parts);
+ protected override List BuildParts()
+ {
+ return new List { "add-header", Name, Value };
}
+}
- private void ParseRedirect(string[] parameters)
+public sealed class RedirectHttpRequest(string url) : HttpRequest
+{
+ public string Url { get; set; } = url;
+ public int? StatusCode { get; set; }
+ public override HttpRequestAction Action => HttpRequestAction.Redirect;
+
+ protected override List BuildParts()
{
- RedirectUrl = GetValueAfter(parameters, "location");
- var code = GetValueAfter(parameters, "code");
- if (int.TryParse(code, out var parsed))
+ var parts = new List { "redirect", "location", Url };
+
+ if (StatusCode.HasValue)
{
- RedirectStatusCode = parsed;
+ parts.Add("code");
+ parts.Add(StatusCode.Value.ToString());
}
- Acls = GetAcls(parameters);
+
+ return parts;
}
+}
+
+public sealed class ReturnHttpRequest(int statusCode) : HttpRequest
+{
+ public int StatusCode { get; set; } = statusCode;
+ public string? ContentType { get; set; }
+ public string? Body { get; set; }
+ public override HttpRequestAction Action => HttpRequestAction.Return;
- private void ParseReturn(string[] parameters)
+ protected override List BuildParts()
{
- var status = GetValueAfter(parameters, "status");
- if (int.TryParse(status, out var parsed))
- {
- FixedResponseStatusCode = parsed;
- }
- FixedResponseContentType = GetValueAfter(parameters, "content-type");
- var body = GetValueAfter(parameters, "lf-string");
- if (!string.IsNullOrWhiteSpace(body))
+ var parts = new List { "return", "status", StatusCode.ToString() };
+
+ if (!string.IsNullOrWhiteSpace(ContentType))
{
- FixedResponseBody = body.Trim('"');
+ parts.Add("content-type");
+ parts.Add(ContentType);
}
- Acls = GetAcls(parameters);
- }
- private static string? GetValueAfter(string[] parameters, string token)
- {
- for (int i = 0; i < parameters.Length - 1; i++)
+ if (!string.IsNullOrWhiteSpace(Body))
{
- if (string.Equals(parameters[i], token, StringComparison.OrdinalIgnoreCase))
- {
- return parameters[i + 1];
- }
+ parts.Add("lf-string");
+ parts.Add(Quote(Body));
}
- return null;
+ return parts;
}
+}
- private static List GetAcls(string[] parameters)
- {
- for (int i = 0; i < parameters.Length; i++)
- {
- if (string.Equals(parameters[i], "if", StringComparison.OrdinalIgnoreCase))
- {
- return parameters.Skip(i + 1).ToList();
- }
- }
+public sealed class SetVariableHttpRequest(string variableName, string variableValue) : HttpRequest
+{
+ public string VariableName { get; set; } = variableName;
+ public string VariableValue { get; set; } = variableValue;
+ public override HttpRequestAction Action => HttpRequestAction.SetVariable;
- return new List();
+ protected override List BuildParts()
+ {
+ return new List { $"set-var({VariableName}) str({VariableValue})" };
}
+}
- private static string Quote(string value)
+public sealed class DenyHttpRequest : HttpRequest
+{
+ public override HttpRequestAction Action => HttpRequestAction.Deny;
+
+ protected override List BuildParts()
{
- var escaped = value
- .Replace("\\", "\\\\")
- .Replace("\"", "\\\"")
- .Replace("\r", string.Empty)
- .Replace("\n", "\\n");
- return $"\"{escaped}\"";
+ return new List { "deny" };
}
}
diff --git a/tilework.ui/Components/Layout/MainLayout.razor b/tilework.ui/Components/Layout/MainLayout.razor
index ab39147..2ce3fc2 100644
--- a/tilework.ui/Components/Layout/MainLayout.razor
+++ b/tilework.ui/Components/Layout/MainLayout.razor
@@ -7,7 +7,7 @@
- Tilework
+ @AppMetadata.Name
diff --git a/tilework.ui/Components/Layout/NavMenu.razor b/tilework.ui/Components/Layout/NavMenu.razor
index bf6be0f..1e8e819 100644
--- a/tilework.ui/Components/Layout/NavMenu.razor
+++ b/tilework.ui/Components/Layout/NavMenu.razor
@@ -1,5 +1,4 @@
@namespace Tilework.Ui.Components.Layout
-@using System.Reflection
@@ -18,14 +17,6 @@
- @AppVersion
+ @AppMetadata.DisplayVersion
-
-@code {
- private static readonly string AppVersion =
- "v" + Assembly.GetExecutingAssembly()
- .GetCustomAttribute()
- ?.InformationalVersion?
- .Split('+')[0] ?? "0.0.0";
-}
diff --git a/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityDetail.razor b/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityDetail.razor
index 37a8708..6e6a973 100644
--- a/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityDetail.razor
+++ b/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityDetail.razor
@@ -12,7 +12,7 @@
@page "/cm/authorities/{Id:guid}"
-Certificate authority details
+@PageTitleHelper.Format("Certificate authority details")
@if(_item != null)
{
diff --git a/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityList.razor b/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityList.razor
index 5bed706..e3f764d 100644
--- a/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityList.razor
+++ b/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityList.razor
@@ -10,7 +10,7 @@
@page "/cm/authorities"
-Certificate authorities
+@PageTitleHelper.Format("Certificate authorities")
diff --git a/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityNew.razor b/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityNew.razor
index 8a34f9e..61320df 100644
--- a/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityNew.razor
+++ b/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityNew.razor
@@ -14,7 +14,7 @@
@page "/cm/authorities/new"
-New certificate authority
+@PageTitleHelper.Format("New certificate authority")
@if(form is NewAcmeCertificateAuthorityForm acmeForm)
{
diff --git a/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor b/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor
index 1297710..fbd841f 100644
--- a/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor
+++ b/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor
@@ -19,7 +19,7 @@
@page "/cm/certificates/{Id:guid}"
-Certificate details
+@PageTitleHelper.Format("Certificate details")
@if(_item != null)
{
diff --git a/tilework.ui/Components/Pages/CertificateManagement/CertificateList.razor b/tilework.ui/Components/Pages/CertificateManagement/CertificateList.razor
index d79d275..870f381 100644
--- a/tilework.ui/Components/Pages/CertificateManagement/CertificateList.razor
+++ b/tilework.ui/Components/Pages/CertificateManagement/CertificateList.razor
@@ -15,7 +15,7 @@
@page "/cm/certificates"
-Certificates
+@PageTitleHelper.Format("Certificates")
diff --git a/tilework.ui/Components/Pages/CertificateManagement/CertificateNew.razor b/tilework.ui/Components/Pages/CertificateManagement/CertificateNew.razor
index e252185..95a1e82 100644
--- a/tilework.ui/Components/Pages/CertificateManagement/CertificateNew.razor
+++ b/tilework.ui/Components/Pages/CertificateManagement/CertificateNew.razor
@@ -13,7 +13,7 @@
@page "/cm/certificates/new"
-New certificate
+@PageTitleHelper.Format("New certificate")
Error
+@PageTitleHelper.Format("Error")
Error.
An error occurred while processing your request.
diff --git a/tilework.ui/Components/Pages/Home.razor b/tilework.ui/Components/Pages/Home.razor
index 41d8b0f..34538ab 100644
--- a/tilework.ui/Components/Pages/Home.razor
+++ b/tilework.ui/Components/Pages/Home.razor
@@ -3,7 +3,7 @@
@inject IContainerManager containerManager
-Home
+@PageTitleHelper.Format("Home")
@code {
diff --git a/tilework.ui/Components/Pages/IdentityManagement/UserDetail.razor b/tilework.ui/Components/Pages/IdentityManagement/UserDetail.razor
index 711efde..0c1672f 100644
--- a/tilework.ui/Components/Pages/IdentityManagement/UserDetail.razor
+++ b/tilework.ui/Components/Pages/IdentityManagement/UserDetail.razor
@@ -12,7 +12,7 @@
@page "/im/users/{Id:guid}"
-User details
+@PageTitleHelper.Format("User details")
@if(_item != null)
{
diff --git a/tilework.ui/Components/Pages/IdentityManagement/UserEdit.razor b/tilework.ui/Components/Pages/IdentityManagement/UserEdit.razor
index 7d58d7b..25c4ab1 100644
--- a/tilework.ui/Components/Pages/IdentityManagement/UserEdit.razor
+++ b/tilework.ui/Components/Pages/IdentityManagement/UserEdit.razor
@@ -16,7 +16,7 @@
@page "/im/users/{Id:guid}/edit"
-Edit user
+@PageTitleHelper.Format("Edit user")
Users
+@PageTitleHelper.Format("Users")
diff --git a/tilework.ui/Components/Pages/IdentityManagement/UserNew.razor b/tilework.ui/Components/Pages/IdentityManagement/UserNew.razor
index e71ce98..81edc69 100644
--- a/tilework.ui/Components/Pages/IdentityManagement/UserNew.razor
+++ b/tilework.ui/Components/Pages/IdentityManagement/UserNew.razor
@@ -15,7 +15,7 @@
@page "/im/users/new"
-New user
+@PageTitleHelper.Format("New user")
Load balancer details
+@PageTitleHelper.Format("Load balancer details")
diff --git a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerEdit.razor b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerEdit.razor
index 36f15a6..8394b85 100644
--- a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerEdit.razor
+++ b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerEdit.razor
@@ -12,7 +12,7 @@
@inject ISnackbar Snackbar
@page "/lb/loadbalancers/{Id:guid}/edit"
-Edit load balancer
+@PageTitleHelper.Format("Edit load balancer")
Load balancers
+@PageTitleHelper.Format("Load balancers")
diff --git a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerNew.razor b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerNew.razor
index 57009cc..ebe105a 100644
--- a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerNew.razor
+++ b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerNew.razor
@@ -17,7 +17,7 @@
@page "/lb/loadbalancers/new"
-New balancer
+@PageTitleHelper.Format("New balancer")
Target group details
+@PageTitleHelper.Format("Target group details")
@if(_item != null)
{
diff --git a/tilework.ui/Components/Pages/LoadBalancing/TargetGroupEdit.razor b/tilework.ui/Components/Pages/LoadBalancing/TargetGroupEdit.razor
index 40e7de5..237eebc 100644
--- a/tilework.ui/Components/Pages/LoadBalancing/TargetGroupEdit.razor
+++ b/tilework.ui/Components/Pages/LoadBalancing/TargetGroupEdit.razor
@@ -14,7 +14,7 @@
@page "/lb/targetgroups/{Id:guid}/edit"
-Edit target group
+@PageTitleHelper.Format("Edit target group")
Target groups
+@PageTitleHelper.Format("Target groups")
diff --git a/tilework.ui/Components/Pages/LoadBalancing/TargetGroupNew.razor b/tilework.ui/Components/Pages/LoadBalancing/TargetGroupNew.razor
index 76eea2b..28cb016 100644
--- a/tilework.ui/Components/Pages/LoadBalancing/TargetGroupNew.razor
+++ b/tilework.ui/Components/Pages/LoadBalancing/TargetGroupNew.razor
@@ -17,7 +17,7 @@
@page "/lb/targetgroups/new"
-New target group
+@PageTitleHelper.Format("New target group")
Login
+@PageTitleHelper.Format("Login")
- Sign in
+ Sign in to tilework