diff --git a/MTConnect.NET.sln b/MTConnect.NET.sln index 9bf4d7374..a1ef3b9fe 100644 --- a/MTConnect.NET.sln +++ b/MTConnect.NET.sln @@ -83,6 +83,8 @@ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MTConnect.NET-AgentModule-HttpServer", "agent\Modules\MTConnect.NET-AgentModule-HttpServer\MTConnect.NET-AgentModule-HttpServer.csproj", "{7025F1F6-F914-4830-8171-30872F184918}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MTConnect.NET-AgentModule-ShdrAdapter", "agent\Modules\MTConnect.NET-AgentModule-ShdrAdapter\MTConnect.NET-AgentModule-ShdrAdapter.csproj", "{B6AAE7CE-AEBF-4F1A-929F-01665E85109E}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MTConnect.NET-AgentModule-WebUI", "agent\Modules\MTConnect.NET-AgentModule-WebUI\MTConnect.NET-AgentModule-WebUI.csproj", "{0443E446-52DA-4812-B8BD-F2555B5AD7C8}" +EndProject EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MTConnect.NET-AdapterModule-MQTT", "adapter\Modules\MTConnect.NET-AdapterModule-MQTT\MTConnect.NET-AdapterModule-MQTT.csproj", "{BEBFD8B9-5448-49F9-BC92-C98564AA7C72}" EndProject @@ -336,13 +338,21 @@ Global {7025F1F6-F914-4830-8171-30872F184918}.Release|Any CPU.ActiveCfg = Release|Any CPU {7025F1F6-F914-4830-8171-30872F184918}.Release|Any CPU.Build.0 = Release|Any CPU {B6AAE7CE-AEBF-4F1A-929F-01665E85109E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0443E446-52DA-4812-B8BD-F2555B5AD7C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B6AAE7CE-AEBF-4F1A-929F-01665E85109E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0443E446-52DA-4812-B8BD-F2555B5AD7C8}.Debug|Any CPU.Build.0 = Debug|Any CPU {B6AAE7CE-AEBF-4F1A-929F-01665E85109E}.Docker|Any CPU.ActiveCfg = Debug|Any CPU + {0443E446-52DA-4812-B8BD-F2555B5AD7C8}.Docker|Any CPU.ActiveCfg = Debug|Any CPU {B6AAE7CE-AEBF-4F1A-929F-01665E85109E}.Docker|Any CPU.Build.0 = Debug|Any CPU + {0443E446-52DA-4812-B8BD-F2555B5AD7C8}.Docker|Any CPU.Build.0 = Debug|Any CPU {B6AAE7CE-AEBF-4F1A-929F-01665E85109E}.Package|Any CPU.ActiveCfg = Package|Any CPU + {0443E446-52DA-4812-B8BD-F2555B5AD7C8}.Package|Any CPU.ActiveCfg = Package|Any CPU {B6AAE7CE-AEBF-4F1A-929F-01665E85109E}.Package|Any CPU.Build.0 = Package|Any CPU + {0443E446-52DA-4812-B8BD-F2555B5AD7C8}.Package|Any CPU.Build.0 = Package|Any CPU {B6AAE7CE-AEBF-4F1A-929F-01665E85109E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0443E446-52DA-4812-B8BD-F2555B5AD7C8}.Release|Any CPU.ActiveCfg = Release|Any CPU {B6AAE7CE-AEBF-4F1A-929F-01665E85109E}.Release|Any CPU.Build.0 = Release|Any CPU + {0443E446-52DA-4812-B8BD-F2555B5AD7C8}.Release|Any CPU.Build.0 = Release|Any CPU {BEBFD8B9-5448-49F9-BC92-C98564AA7C72}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {BEBFD8B9-5448-49F9-BC92-C98564AA7C72}.Debug|Any CPU.Build.0 = Debug|Any CPU {BEBFD8B9-5448-49F9-BC92-C98564AA7C72}.Docker|Any CPU.ActiveCfg = Debug|Any CPU @@ -552,6 +562,7 @@ Global {2104A3F5-A157-4763-8B90-38CD103D93D8} = {0F01D2F5-0CFA-4DAE-A0FD-5733D291F6FB} {7025F1F6-F914-4830-8171-30872F184918} = {0F01D2F5-0CFA-4DAE-A0FD-5733D291F6FB} {B6AAE7CE-AEBF-4F1A-929F-01665E85109E} = {0F01D2F5-0CFA-4DAE-A0FD-5733D291F6FB} + {0443E446-52DA-4812-B8BD-F2555B5AD7C8} = {0F01D2F5-0CFA-4DAE-A0FD-5733D291F6FB} {BEBFD8B9-5448-49F9-BC92-C98564AA7C72} = {494ACCF3-8FB7-429D-9119-92F7DD646B5F} {D2125645-0149-4D2D-BBF6-5E36E6EBB6C1} = {494ACCF3-8FB7-429D-9119-92F7DD646B5F} {B4A1AFBE-5D57-45BA-B735-A2C2C20A971C} = {B7CCD58C-A3F3-43BE-BBF8-2B20D118C39D} diff --git a/agent/MTConnect.NET-Agent/agent.config.yaml b/agent/MTConnect.NET-Agent/agent.config.yaml index 7f525d95f..fe56b8207 100644 --- a/agent/MTConnect.NET-Agent/agent.config.yaml +++ b/agent/MTConnect.NET-Agent/agent.config.yaml @@ -9,6 +9,9 @@ processors: # - Modules - modules: +- web-ui: # - Add Web UI module (browse data and edit this configuration at http://localhost:5080) + port: 5080 + - http-server: # - Add HTTP Server module port: 5000 indentOutput: true diff --git a/agent/MTConnect.NET-Applications-Agents/MTConnect.NET-Applications-Agents.csproj b/agent/MTConnect.NET-Applications-Agents/MTConnect.NET-Applications-Agents.csproj index 2b8fd4748..401036c9a 100644 --- a/agent/MTConnect.NET-Applications-Agents/MTConnect.NET-Applications-Agents.csproj +++ b/agent/MTConnect.NET-Applications-Agents/MTConnect.NET-Applications-Agents.csproj @@ -58,6 +58,7 @@ + diff --git a/agent/MTConnect.NET-Applications-Agents/agent.config.default.yaml b/agent/MTConnect.NET-Applications-Agents/agent.config.default.yaml index 478ec0fb9..95ba7d572 100644 --- a/agent/MTConnect.NET-Applications-Agents/agent.config.default.yaml +++ b/agent/MTConnect.NET-Applications-Agents/agent.config.default.yaml @@ -1,6 +1,11 @@ # - Modules - modules: +# - web-ui: # - Add Web UI module (browse data and edit this configuration at http://localhost:5080) +# server: localhost +# port: 5080 +# allowConfigurationEdit: true + - http-server: # - Add HTTP Server module # hostname: localhost port: 5000 diff --git a/agent/Modules/MTConnect.NET-AgentModule-WebUI/MTConnect.NET-AgentModule-WebUI.csproj b/agent/Modules/MTConnect.NET-AgentModule-WebUI/MTConnect.NET-AgentModule-WebUI.csproj new file mode 100644 index 000000000..e4fe2410c --- /dev/null +++ b/agent/Modules/MTConnect.NET-AgentModule-WebUI/MTConnect.NET-AgentModule-WebUI.csproj @@ -0,0 +1,65 @@ + + + + net8.0 + + + net461;net462;net47;net471;net472;net48;netstandard2.0;net6.0;net7.0;net8.0;net9.0;net10.0 + false + None + true + + + net461;net462;net47;net471;net472;net48;netstandard2.0;net6.0;net7.0;net8.0;net9.0;net10.0 + true + + + + MTConnect + Debug;Release;Package + + MTConnect.NET-AgentModule-WebUI hosts a lightweight browser user interface for the MTConnectAgentApplication class in the MTConnect.NET-Applications-Agents library. It lets a user browse Devices, current and sampled Observations, and Assets, and edit the Agent configuration file. Supports .NET Framework 4.6.1 up to .NET 10 + README-Nuget.md + + + true + + + true + + + true + snupkg + + + + true + + + + + + + + + + + + + True + \licenses\ + + + + + + True + \ + + + True + \ + + + + diff --git a/agent/Modules/MTConnect.NET-AgentModule-WebUI/Module.cs b/agent/Modules/MTConnect.NET-AgentModule-WebUI/Module.cs new file mode 100644 index 000000000..0e17fce39 --- /dev/null +++ b/agent/Modules/MTConnect.NET-AgentModule-WebUI/Module.cs @@ -0,0 +1,78 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using MTConnect.Agents; +using MTConnect.Configurations; +using MTConnect.Logging; + +namespace MTConnect.Modules.WebUI +{ + /// + /// Agent module that hosts a browser user interface for the Agent. + /// The UI lets a user browse Devices, current / sampled Observations + /// and Assets, and view or edit the Agent configuration file. + /// + public class Module : MTConnectAgentModule + { + /// + /// Token used in agent.config.yaml to bind this module + /// (- web-ui:). + /// + public const string ConfigurationTypeId = "web-ui"; + private const string ModuleId = "Web UI"; + + private readonly WebUiModuleConfiguration _configuration; + private WebUiServer _server; + + + /// + /// Initialises the module and binds the supplied controller- + /// configuration payload to . + /// + /// Agent broker the UI reads from. + /// Raw configuration payload + /// bound to . + public Module(IMTConnectAgentBroker mtconnectAgent, object controllerConfiguration) : base(mtconnectAgent) + { + Id = ModuleId; + + _configuration = AgentApplicationConfiguration.GetConfiguration(controllerConfiguration); + if (_configuration == null) _configuration = new WebUiModuleConfiguration(); + } + + + /// + /// Module lifecycle hook: creates the UI web server before the Agent + /// loads its devices so the per-Device Observation counts include + /// the Observations added while the devices initialize. + /// + /// Inherited flag; unused by + /// this module. + protected override void OnStartBeforeLoad(bool initializeDataItems) + { + _server = new WebUiServer(_configuration, Agent); + } + + /// + /// Module lifecycle hook: starts the UI web server once the Agent + /// has loaded its devices. + /// + /// Inherited flag; unused by + /// this module. + protected override void OnStartAfterLoad(bool initializeDataItems) + { + if (_server == null) _server = new WebUiServer(_configuration, Agent); + _server.LogReceived += (s, message) => Log(MTConnectLogLevel.Information, message); + _server.ErrorReceived += (s, message) => Log(MTConnectLogLevel.Warning, message); + _server.Start(); + } + + /// + /// Module lifecycle hook: stops the UI web server. + /// + protected override void OnStop() + { + if (_server != null) _server.Stop(); + } + } +} diff --git a/agent/Modules/MTConnect.NET-AgentModule-WebUI/README-Nuget.md b/agent/Modules/MTConnect.NET-AgentModule-WebUI/README-Nuget.md new file mode 100644 index 000000000..e8e874fb4 --- /dev/null +++ b/agent/Modules/MTConnect.NET-AgentModule-WebUI/README-Nuget.md @@ -0,0 +1,34 @@ +![MTConnect.NET Logo](https://raw.githubusercontent.com/TrakHound/MTConnect.NET/master/img/logo.png) + +# MTConnect Web UI Agent Module +This Agent Module hosts a browser user interface for the MTConnect.NET Agent. It can be used to: + +* View Agent information and buffer state +* Browse Devices / Components and their current Observations (with auto refresh) +* View the recent history of any Data Item +* Browse Assets and view them as MTConnectAssets XML +* View and edit the Agent configuration file (`agent.config.yaml`) + +## Configuration +```yaml +modules: + - web-ui: + server: localhost + port: 5080 + allowConfigurationEdit: true +``` + +* `server` - The hostname to bind to. Defaults to `localhost` so the UI is only reachable from the Agent machine. Set to `*` to listen on all interfaces (on Windows this requires administrator rights or a `netsh http add urlacl` reservation). + +* `port` - The port the UI listens on. Default is `5080`. + +* `allowConfigurationEdit` - Sets whether the Agent configuration file can be saved from the UI. Default is `true`. When `false` the configuration is shown read-only. + +Open `http://localhost:5080/` in a browser once the Agent is running. + +The page and its fonts (Roboto and Roboto Mono, licensed under the SIL Open Font License 1.1) are embedded in the module assembly, so the UI makes no requests outside the Agent and works on networks without internet access. + +## Applying configuration changes +Saving validates the YAML, keeps a copy of the previous file as `agent.config.yaml.bak`, and writes the new file. When `monitorConfigurationFiles` is enabled (the default) the Agent detects the change and restarts itself to apply it; otherwise restart the Agent manually. + +> **Security** - The UI has no authentication. Anyone who can reach it can change the Agent configuration. Keep `server: localhost` or set `allowConfigurationEdit: false` when exposing it on a network. diff --git a/agent/Modules/MTConnect.NET-AgentModule-WebUI/README.md b/agent/Modules/MTConnect.NET-AgentModule-WebUI/README.md new file mode 100644 index 000000000..e8e874fb4 --- /dev/null +++ b/agent/Modules/MTConnect.NET-AgentModule-WebUI/README.md @@ -0,0 +1,34 @@ +![MTConnect.NET Logo](https://raw.githubusercontent.com/TrakHound/MTConnect.NET/master/img/logo.png) + +# MTConnect Web UI Agent Module +This Agent Module hosts a browser user interface for the MTConnect.NET Agent. It can be used to: + +* View Agent information and buffer state +* Browse Devices / Components and their current Observations (with auto refresh) +* View the recent history of any Data Item +* Browse Assets and view them as MTConnectAssets XML +* View and edit the Agent configuration file (`agent.config.yaml`) + +## Configuration +```yaml +modules: + - web-ui: + server: localhost + port: 5080 + allowConfigurationEdit: true +``` + +* `server` - The hostname to bind to. Defaults to `localhost` so the UI is only reachable from the Agent machine. Set to `*` to listen on all interfaces (on Windows this requires administrator rights or a `netsh http add urlacl` reservation). + +* `port` - The port the UI listens on. Default is `5080`. + +* `allowConfigurationEdit` - Sets whether the Agent configuration file can be saved from the UI. Default is `true`. When `false` the configuration is shown read-only. + +Open `http://localhost:5080/` in a browser once the Agent is running. + +The page and its fonts (Roboto and Roboto Mono, licensed under the SIL Open Font License 1.1) are embedded in the module assembly, so the UI makes no requests outside the Agent and works on networks without internet access. + +## Applying configuration changes +Saving validates the YAML, keeps a copy of the previous file as `agent.config.yaml.bak`, and writes the new file. When `monitorConfigurationFiles` is enabled (the default) the Agent detects the change and restarts itself to apply it; otherwise restart the Agent manually. + +> **Security** - The UI has no authentication. Anyone who can reach it can change the Agent configuration. Keep `server: localhost` or set `allowConfigurationEdit: false` when exposing it on a network. diff --git a/agent/Modules/MTConnect.NET-AgentModule-WebUI/WebUiModuleConfiguration.cs b/agent/Modules/MTConnect.NET-AgentModule-WebUI/WebUiModuleConfiguration.cs new file mode 100644 index 000000000..487ce1dba --- /dev/null +++ b/agent/Modules/MTConnect.NET-AgentModule-WebUI/WebUiModuleConfiguration.cs @@ -0,0 +1,45 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +namespace MTConnect.Configurations +{ + /// + /// Configuration shape for the Web UI agent module. Controls the + /// address the UI listens on and whether the Agent configuration + /// file may be edited from the browser. + /// + public class WebUiModuleConfiguration + { + /// + /// The hostname the UI binds to. Defaults to localhost so the + /// UI (and its configuration editor) is only reachable from the + /// machine the Agent runs on. Use * or + to listen on + /// every interface (may require elevated permissions on Windows). + /// + public string Server { get; set; } + + /// + /// The port the UI listens on. + /// + public int Port { get; set; } + + /// + /// Gets or Sets whether the Agent configuration file can be edited + /// and saved from the UI. When false the configuration is + /// shown read-only. + /// + public bool AllowConfigurationEdit { get; set; } + + + /// + /// Initialises a new instance bound to localhost:5080 with + /// configuration editing enabled. + /// + public WebUiModuleConfiguration() + { + Server = "localhost"; + Port = 5080; + AllowConfigurationEdit = true; + } + } +} diff --git a/agent/Modules/MTConnect.NET-AgentModule-WebUI/WebUiServer.cs b/agent/Modules/MTConnect.NET-AgentModule-WebUI/WebUiServer.cs new file mode 100644 index 000000000..08ee3d137 --- /dev/null +++ b/agent/Modules/MTConnect.NET-AgentModule-WebUI/WebUiServer.cs @@ -0,0 +1,546 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using MTConnect.Agents; +using MTConnect.Configurations; +using MTConnect.Devices; +using MTConnect.Formatters; +using MTConnect.Observations; +using MTConnect.Observations.Output; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using YamlDotNet.Core; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace MTConnect.Modules.WebUI +{ + /// + /// Minimal -based web server that serves the + /// embedded UI page and the JSON API it calls. + /// + /// + /// Routes: + /// + /// GET / – the UI page + /// GET /fonts/{name}.woff2 – the embedded Roboto / Roboto Mono fonts + /// GET /api/agent – Agent information and buffer state + /// GET /api/devices – Device / Component / DataItem tree + /// GET /api/current?device= – current Observations for a Device + /// GET /api/sample?device=&dataItemId=&count= – most recent Observations + /// GET /api/assets?device= – Asset list + /// GET /api/asset?id= – a single Asset as an MTConnectAssets XML document + /// GET /api/config / PUT /api/config – read / save the Agent configuration file + /// + /// + public class WebUiServer + { + private const string IndexResourceName = "MTConnect.WebUI.index.html"; + private const string FontResourcePrefix = "MTConnect.WebUI.fonts."; + private const int MaxSampleCount = 1000; + + private static readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + private readonly WebUiModuleConfiguration _configuration; + private readonly IMTConnectAgentBroker _agent; + private readonly ConcurrentDictionary _observationCounts = new ConcurrentDictionary(); + private HttpListener _listener; + private CancellationTokenSource _stop; + + + /// + /// Raised with an informational message (for example the listening address). + /// + public event EventHandler LogReceived; + + /// + /// Raised with a message when the listener or a request fails. + /// + public event EventHandler ErrorReceived; + + + /// + /// Initialises a new server for the given configuration and Agent. + /// + /// Listen address and edit policy. + /// Agent broker the API reads from. + public WebUiServer(WebUiModuleConfiguration configuration, IMTConnectAgentBroker agent) + { + _configuration = configuration ?? new WebUiModuleConfiguration(); + _agent = agent; + + // Count Observations per Device from the moment the server is created + if (_agent != null) _agent.ObservationAdded += ObservationAdded; + } + + + /// + /// Starts listening on a background task. Returns immediately. + /// + public void Start() + { + var host = _configuration.Server; + if (string.IsNullOrEmpty(host) || host == "*" || host == "0.0.0.0") host = "+"; + var prefix = $"http://{host}:{_configuration.Port}/"; + + try + { + _stop = new CancellationTokenSource(); + _listener = new HttpListener(); + _listener.Prefixes.Add(prefix); + _listener.Start(); + + LogReceived?.Invoke(this, $"Web UI listening at {prefix.Replace("+", "localhost")}"); + + _ = Task.Run(() => Listen(_stop.Token)); + } + catch (Exception ex) + { + ErrorReceived?.Invoke(this, $"Web UI failed to start at {prefix} : {ex.Message}"); + } + } + + /// + /// Stops the listener and releases the port. + /// + public void Stop() + { + if (_agent != null) _agent.ObservationAdded -= ObservationAdded; + + try + { + if (_stop != null) _stop.Cancel(); + if (_listener != null) _listener.Close(); + } + catch { } + } + + + private void ObservationAdded(object sender, IObservation observation) + { + if (observation != null && observation.DeviceUuid != null) + { + _observationCounts.AddOrUpdate(observation.DeviceUuid, 1, (key, count) => count + 1); + } + } + + private async Task Listen(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested && _listener.IsListening) + { + HttpListenerContext context; + try + { + context = await _listener.GetContextAsync(); + } + catch + { + // Listener was closed + break; + } + + _ = Task.Run(() => HandleRequest(context)); + } + } + + private void HandleRequest(HttpListenerContext context) + { + var request = context.Request; + var response = context.Response; + + try + { + var path = request.Url.AbsolutePath.TrimEnd('/').ToLowerInvariant(); + var method = request.HttpMethod; + + if (method == "GET" && (path == "" || path == "/index.html")) + { + WriteIndex(response); + } + else if (method == "GET" && path.StartsWith("/fonts/")) WriteFont(response, path.Substring("/fonts/".Length)); + else if (method == "GET" && path == "/api/agent") WriteJson(response, GetAgentInfo()); + else if (method == "GET" && path == "/api/devices") WriteJson(response, GetDevices()); + else if (method == "GET" && path == "/api/current") WriteJson(response, GetCurrent(request.QueryString["device"])); + else if (method == "GET" && path == "/api/sample") WriteJson(response, GetSample(request.QueryString["device"], request.QueryString["dataItemId"], request.QueryString["count"])); + else if (method == "GET" && path == "/api/assets") WriteJson(response, GetAssets(request.QueryString["device"])); + else if (method == "GET" && path == "/api/asset") WriteAsset(response, request.QueryString["id"]); + else if (method == "GET" && path == "/api/config") WriteJson(response, GetConfiguration()); + else if (method == "PUT" && path == "/api/config") SaveConfiguration(request, response); + else WriteJson(response, new { error = "Not Found" }, 404); + } + catch (Exception ex) + { + ErrorReceived?.Invoke(this, $"Web UI request error ({request.HttpMethod} {request.Url.AbsolutePath}) : {ex.Message}"); + try { WriteJson(response, new { error = ex.Message }, 500); } catch { } + } + finally + { + try { response.Close(); } catch { } + } + } + + + #region "API" + + private object GetAgentInfo() + { + var configuration = _agent.Configuration; + var applicationConfiguration = configuration as IAgentApplicationConfiguration; + + return new + { + uuid = _agent.Uuid, + instanceId = _agent.InstanceId, + sender = _agent.Sender, + version = _agent.Version?.ToString(), + mtconnectVersion = _agent.MTConnectVersion?.ToString(), + deviceModelChangeTime = _agent.DeviceModelChangeTime, + bufferSize = _agent.BufferSize, + firstSequence = _agent.FirstSequence, + lastSequence = _agent.LastSequence, + nextSequence = _agent.NextSequence, + assetBufferSize = _agent.AssetBufferSize, + assetCount = _agent.AssetCount, + configurationPath = configuration?.Path, + monitorConfigurationFiles = applicationConfiguration?.MonitorConfigurationFiles ?? false, + allowConfigurationEdit = _configuration.AllowConfigurationEdit, + observationCounts = _observationCounts.ToDictionary(o => o.Key, o => o.Value), + mtconnectServer = GetMTConnectServer(applicationConfiguration) + }; + } + + /// + /// Minimal view of an http-server module entry, used to link to the + /// Agent's MTConnect REST API without referencing the HTTP server assembly. + /// + private class HttpServerModuleEntry + { + public int Port { get; set; } = 5000; + public string Server { get; set; } + public object Tls { get; set; } + } + + private static object GetMTConnectServer(IAgentApplicationConfiguration configuration) + { + // Link to the first configured HTTP server module (the Agent's MTConnect REST API) + var server = configuration?.GetModules("http-server")?.FirstOrDefault(); + if (server == null) return null; + + return new + { + port = server.Port, + server = server.Server, + tls = server.Tls != null + }; + } + + private object GetDevices() + { + var devices = _agent.GetDevices(); + if (devices == null) return new object[0]; + + return devices.Select(device => new + { + id = device.Id, + uuid = device.Uuid, + name = device.Name, + type = device.Type, + dataItems = ToDataItems(device.DataItems), + components = ToComponents(device.Components) + }).ToList(); + } + + private static IEnumerable ToComponents(IEnumerable components) + { + if (components == null) return new object[0]; + + return components.Select(component => new + { + id = component.Id, + name = component.Name, + type = component.Type, + dataItems = ToDataItems(component.DataItems), + components = ToComponents(component.Components) + }).ToList(); + } + + private static IEnumerable ToDataItems(IEnumerable dataItems) + { + if (dataItems == null) return new object[0]; + + return dataItems.Select(dataItem => new + { + id = dataItem.Id, + name = dataItem.Name, + type = dataItem.Type, + subType = dataItem.SubType, + category = dataItem.Category.ToString(), + units = dataItem.Units + }).ToList(); + } + + private object GetCurrent(string deviceKey) + { + var observations = string.IsNullOrEmpty(deviceKey) + ? _agent.GetCurrentObservations() + : _agent.GetCurrentObservations(deviceKey); + + return ToObservations(observations); + } + + private object GetSample(string deviceKey, string dataItemId, string countText) + { + if (string.IsNullOrEmpty(deviceKey)) return new object[0]; + + var count = 100; + if (int.TryParse(countText, out var parsedCount)) count = Math.Max(1, Math.Min(MaxSampleCount, parsedCount)); + + var dataItemIds = string.IsNullOrEmpty(dataItemId) ? null : new[] { dataItemId }; + var first = _agent.FirstSequence; + var last = _agent.LastSequence; + if (last < first) return new object[0]; + + // Walk backwards from the end of the buffer with a growing window + // until enough Observations are found (or the whole buffer is read) + var results = new List(); + ulong window = (ulong)count; + while (true) + { + var from = last >= first + window ? last - window + 1 : first; + + var document = dataItemIds != null + ? _agent.GetDeviceStreamsResponseDocument(deviceKey, dataItemIds, from, last, (uint)Math.Min(window, uint.MaxValue)) + : _agent.GetDeviceStreamsResponseDocument(deviceKey, from, last, (uint)Math.Min(window, uint.MaxValue)); + + results.Clear(); + if (document != null && document.Streams != null) + { + foreach (var stream in document.Streams) + { + if (stream.Observations != null) results.AddRange(stream.Observations); + } + } + + if (results.Count >= count || from <= first) break; + window *= 10; + } + + return ToObservations(results.OrderByDescending(o => o.Sequence).Take(count)); + } + + private static IEnumerable ToObservations(IEnumerable observations) + { + if (observations == null) return new object[0]; + + return observations.Select(observation => new + { + deviceUuid = observation.DeviceUuid, + dataItemId = observation.DataItemId, + name = observation.Name, + type = observation.Type, + subType = observation.SubType, + category = observation.Category.ToString(), + representation = observation.Representation.ToString(), + sequence = observation.Sequence, + timestamp = observation.Timestamp, + values = observation.Values != null + ? observation.Values.Where(v => v.Key != null).GroupBy(v => v.Key).ToDictionary(g => g.Key, g => g.First().Value) + : new Dictionary() + }).ToList(); + } + + private object GetAssets(string deviceKey) + { + var assets = string.IsNullOrEmpty(deviceKey) ? _agent.GetAssets() : _agent.GetAssets(deviceKey); + if (assets == null) return new object[0]; + + return assets.Select(asset => new + { + assetId = asset.AssetId, + type = asset.Type, + deviceUuid = asset.DeviceUuid, + timestamp = asset.Timestamp, + removed = asset.Removed + }).ToList(); + } + + private void WriteAsset(HttpListenerResponse response, string assetId) + { + if (string.IsNullOrEmpty(assetId)) + { + WriteJson(response, new { error = "Missing 'id' parameter" }, 400); + return; + } + + var document = _agent.GetAssetsResponseDocument(new[] { assetId }); + var options = new List> { new KeyValuePair("indentOutput", "true") }; + var result = ResponseDocumentFormatter.Format(DocumentFormat.XML, document, options); + if (!result.Success || result.Content == null) + { + WriteJson(response, new { error = "Unable to format Asset" }, 500); + return; + } + + using (var reader = new StreamReader(result.Content)) + { + WriteText(response, reader.ReadToEnd(), "application/xml; charset=utf-8"); + } + } + + private object GetConfiguration() + { + var path = _agent.Configuration?.Path; + string content = null; + if (!string.IsNullOrEmpty(path) && File.Exists(path)) content = File.ReadAllText(path); + + return new + { + path, + content, + editable = _configuration.AllowConfigurationEdit && !string.IsNullOrEmpty(path) + }; + } + + private void SaveConfiguration(HttpListenerRequest request, HttpListenerResponse response) + { + if (!_configuration.AllowConfigurationEdit) + { + WriteJson(response, new { error = "Configuration editing is disabled (allowConfigurationEdit: false)" }, 403); + return; + } + + var path = _agent.Configuration?.Path; + if (string.IsNullOrEmpty(path)) + { + WriteJson(response, new { error = "The Agent was not loaded from a configuration file" }, 400); + return; + } + + if (!path.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase) && !path.EndsWith(".yml", StringComparison.OrdinalIgnoreCase)) + { + WriteJson(response, new { error = "Only YAML configuration files can be edited from the UI" }, 400); + return; + } + + string content; + using (var reader = new StreamReader(request.InputStream, request.ContentEncoding ?? Encoding.UTF8)) + { + content = reader.ReadToEnd(); + } + + if (string.IsNullOrWhiteSpace(content)) + { + WriteJson(response, new { error = "Configuration is empty" }, 400); + return; + } + + // Validate the YAML with the same settings the Agent reads it with + try + { + var deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .IgnoreUnmatchedProperties() + .Build(); + + deserializer.Deserialize(content); + } + catch (YamlException ex) + { + WriteJson(response, new { error = $"Invalid configuration (line {ex.Start.Line}, column {ex.Start.Column}) : {ex.InnerException?.Message ?? ex.Message}" }, 400); + return; + } + + // Keep a copy of the previous file before overwriting it + if (File.Exists(path)) File.Copy(path, path + ".bak", true); + File.WriteAllText(path, content, new UTF8Encoding(false)); + + LogReceived?.Invoke(this, $"Agent configuration saved from Web UI ({path})"); + + var applicationConfiguration = _agent.Configuration as IAgentApplicationConfiguration; + WriteJson(response, new + { + saved = true, + restartPending = applicationConfiguration?.MonitorConfigurationFiles ?? false + }); + } + + #endregion + + #region "Response Writers" + + private static void WriteIndex(HttpListenerResponse response) + { + using (var stream = typeof(WebUiServer).GetTypeInfo().Assembly.GetManifestResourceStream(IndexResourceName)) + { + if (stream == null) + { + WriteJson(response, new { error = "UI resource not found" }, 500); + return; + } + + using (var reader = new StreamReader(stream)) + { + WriteText(response, reader.ReadToEnd(), "text/html; charset=utf-8"); + } + } + } + + private static void WriteFont(HttpListenerResponse response, string fileName) + { + // Only names of embedded .woff2 resources resolve; anything else is a 404 + Stream stream = null; + if (fileName.EndsWith(".woff2") && fileName.IndexOf('/') < 0) + { + stream = typeof(WebUiServer).GetTypeInfo().Assembly.GetManifestResourceStream(FontResourcePrefix + fileName); + } + + if (stream == null) + { + WriteJson(response, new { error = "Not Found" }, 404); + return; + } + + using (stream) + using (var memoryStream = new MemoryStream()) + { + stream.CopyTo(memoryStream); + var bytes = memoryStream.ToArray(); + + response.StatusCode = 200; + response.ContentType = "font/woff2"; + response.Headers["Cache-Control"] = "public, max-age=86400"; + response.ContentLength64 = bytes.Length; + response.OutputStream.Write(bytes, 0, bytes.Length); + } + } + + private static void WriteJson(HttpListenerResponse response, object value, int statusCode = 200) + { + WriteText(response, JsonSerializer.Serialize(value, _jsonOptions), "application/json; charset=utf-8", statusCode); + } + + private static void WriteText(HttpListenerResponse response, string text, string contentType, int statusCode = 200) + { + var bytes = Encoding.UTF8.GetBytes(text ?? ""); + response.StatusCode = statusCode; + response.ContentType = contentType; + response.Headers["Cache-Control"] = "no-store"; + response.ContentLength64 = bytes.Length; + response.OutputStream.Write(bytes, 0, bytes.Length); + } + + #endregion + } +} diff --git a/agent/Modules/MTConnect.NET-AgentModule-WebUI/wwwroot/fonts/LICENSE-Roboto.txt b/agent/Modules/MTConnect.NET-AgentModule-WebUI/wwwroot/fonts/LICENSE-Roboto.txt new file mode 100644 index 000000000..e96f4bc8e --- /dev/null +++ b/agent/Modules/MTConnect.NET-AgentModule-WebUI/wwwroot/fonts/LICENSE-Roboto.txt @@ -0,0 +1,93 @@ +Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic) Roboto-Italic[wdth,wght].ttf: Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/agent/Modules/MTConnect.NET-AgentModule-WebUI/wwwroot/fonts/LICENSE-RobotoMono.txt b/agent/Modules/MTConnect.NET-AgentModule-WebUI/wwwroot/fonts/LICENSE-RobotoMono.txt new file mode 100644 index 000000000..04f5d2a34 --- /dev/null +++ b/agent/Modules/MTConnect.NET-AgentModule-WebUI/wwwroot/fonts/LICENSE-RobotoMono.txt @@ -0,0 +1,93 @@ +Copyright 2015 The Roboto Mono Project Authors (https://github.com/googlefonts/robotomono) RobotoMono-Italic[wght].ttf: Copyright 2015 The Roboto Mono Project Authors (https://github.com/googlefonts/robotomono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/agent/Modules/MTConnect.NET-AgentModule-WebUI/wwwroot/fonts/roboto-latin-ext-wght-normal.woff2 b/agent/Modules/MTConnect.NET-AgentModule-WebUI/wwwroot/fonts/roboto-latin-ext-wght-normal.woff2 new file mode 100644 index 000000000..1ee37693e Binary files /dev/null and b/agent/Modules/MTConnect.NET-AgentModule-WebUI/wwwroot/fonts/roboto-latin-ext-wght-normal.woff2 differ diff --git a/agent/Modules/MTConnect.NET-AgentModule-WebUI/wwwroot/fonts/roboto-latin-wght-normal.woff2 b/agent/Modules/MTConnect.NET-AgentModule-WebUI/wwwroot/fonts/roboto-latin-wght-normal.woff2 new file mode 100644 index 000000000..9b0f14165 Binary files /dev/null and b/agent/Modules/MTConnect.NET-AgentModule-WebUI/wwwroot/fonts/roboto-latin-wght-normal.woff2 differ diff --git a/agent/Modules/MTConnect.NET-AgentModule-WebUI/wwwroot/fonts/roboto-mono-latin-ext-wght-normal.woff2 b/agent/Modules/MTConnect.NET-AgentModule-WebUI/wwwroot/fonts/roboto-mono-latin-ext-wght-normal.woff2 new file mode 100644 index 000000000..532a888ac Binary files /dev/null and b/agent/Modules/MTConnect.NET-AgentModule-WebUI/wwwroot/fonts/roboto-mono-latin-ext-wght-normal.woff2 differ diff --git a/agent/Modules/MTConnect.NET-AgentModule-WebUI/wwwroot/fonts/roboto-mono-latin-wght-normal.woff2 b/agent/Modules/MTConnect.NET-AgentModule-WebUI/wwwroot/fonts/roboto-mono-latin-wght-normal.woff2 new file mode 100644 index 000000000..bfa169c30 Binary files /dev/null and b/agent/Modules/MTConnect.NET-AgentModule-WebUI/wwwroot/fonts/roboto-mono-latin-wght-normal.woff2 differ diff --git a/agent/Modules/MTConnect.NET-AgentModule-WebUI/wwwroot/index.html b/agent/Modules/MTConnect.NET-AgentModule-WebUI/wwwroot/index.html new file mode 100644 index 000000000..bfc43efd2 --- /dev/null +++ b/agent/Modules/MTConnect.NET-AgentModule-WebUI/wwwroot/index.html @@ -0,0 +1,892 @@ + + + + + +MTConnect.NET Agent + + + +
+

MTConnect.NET Agent

+ + +
+ +
+ +
+
+

MTConnect Data

+ +
+
+

Agent

+
+
+
+

Buffer

+
+
+
+

Devices

+ + + +
NameUUIDTypeData ItemsObservationsMTConnect Data
+
+
+ + +
+
+
+
+
+
+ Current + + + + + +
+
+ + + +
Name / IdTypeSubTypeValueTimestampSeq
+
+
+
+
+ History + + + +
+
+ + + +
TimestampValueSeq
Select an observation to view its recent history
+
+
+
+
+
+ + +
+
+
+ Assets + + + + +
+ + + +
Asset IdTypeDeviceTimestampRemoved
+
+ +
+ + +
+
+
+ Agent Configuration + + + + + +
+ +

+
+
+
+ + + +