diff --git a/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs b/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs new file mode 100644 index 0000000..7b2d201 --- /dev/null +++ b/Source/Applications/StreamSplitter/Api/Controllers/ConnectionsController.cs @@ -0,0 +1,605 @@ +//****************************************************************************************************** +// ConnectionsController.cs - Gbtc +// +// Copyright © 2026, Grid Protection Alliance. All Rights Reserved. +// +// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See +// the NOTICE file distributed with this work for additional information regarding copyright ownership. +// The GPA licenses this file to you under the Eclipse Public License -v 1.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain a copy of the License at: +// +// http://www.opensource.org/licenses/eclipse-1.0.php +// +// Unless agreed to in writing, the subject software distributed under the License is distributed on an +// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the +// License for the specific language governing permissions and limitations. +// +// Code Modification History: +// ---------------------------------------------------------------------------------------------------- +// 06/01/2026 - Marcos Vinicius Snak +// Generated original version of source code. +// +//****************************************************************************************************** + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using System.Web.Http; +using System.Web.Http.Description; +using GSF; +using GSF.Diagnostics; +using StreamSplitter.Api.Models; + +namespace StreamSplitter.Api.Controllers +{ + /// + /// Provides read access to the currently configured proxy connections. + /// + [RoutePrefix("api/connections")] + public class ConnectionsController : ApiController + { + #region [ Members ] + + // Fields + private static readonly LogPublisher s_log = + Logger.CreatePublisher(typeof(ConnectionsController), MessageClass.Application); + + #endregion + + #region [ Methods ] + + /// + /// Returns all currently configured proxy connections. + /// + /// Array of objects representing all configured connections. + /// Array of connections. Empty array when no connections are configured. + [HttpGet, Route("")] + [ResponseType(typeof(ConnectionDto[]))] + public IHttpActionResult GetConnections() + { + string correlationId = GetCorrelationId(); + + s_log.Publish( + MessageLevel.Info, + "GetConnections", + $"GET /api/connections requested. CorrelationId={correlationId}"); + + ProxyConnectionCollection configuration = ServiceHost.Current?.CurrentConfiguration; + + if (configuration is null) + { + s_log.Publish( + MessageLevel.Warning, + "GetConnections", + $"Configuration not yet loaded. Returning empty list. CorrelationId={correlationId}"); + + return Ok(Array.Empty()); + } + + ConnectionDto[] dtos = configuration + .Select(c => ConnectionDto.FromProxyConnection(c, ServiceHost.Current.GetRuntimeConnectionState(c.ID))) + .ToArray(); + + s_log.Publish( + MessageLevel.Info, + "GetConnections", + $"Returning {dtos.Length} connection(s). CorrelationId={correlationId}"); + + return Ok(dtos); + } + + /// + /// Returns the proxy connection identified by . + /// + /// Unique identifier of the connection. + /// + /// when found; 404 with a problem detail body when not found. + /// + /// The requested connection with its current runtime state. + /// No connection found with the given ID. + [HttpGet, Route("{id:guid}")] + [ResponseType(typeof(ConnectionDto))] + public IHttpActionResult GetConnectionById(Guid id) + { + string correlationId = GetCorrelationId(); + + s_log.Publish( + MessageLevel.Info, + "GetConnectionById", + $"GET /api/connections/{id} requested. CorrelationId={correlationId}"); + + ProxyConnectionCollection configuration = ServiceHost.Current?.CurrentConfiguration; + ProxyConnection connection = configuration?[id]; + + if (connection is null) + { + s_log.Publish( + MessageLevel.Info, + "GetConnectionById", + $"Connection {id} not found. CorrelationId={correlationId}"); + + return Content(HttpStatusCode.NotFound, new + { + status = 404, + title = "Not Found", + detail = $"Connection with ID '{id}' was not found." + }); + } + + ConnectionDto dto = ConnectionDto.FromProxyConnection( + connection, + ServiceHost.Current.GetRuntimeConnectionState(id)); + + s_log.Publish( + MessageLevel.Info, + "GetConnectionById", + $"Returning connection '{connection.Name}'. CorrelationId={correlationId}"); + + return Ok(dto); + } + + /// + /// Creates one or more proxy connections from a JSON array. + /// All items are validated before any connection is created (all-or-nothing semantics). + /// + /// + /// Array of connection data. connectionString is required on each item. + /// + /// + /// 201 Created with the array of created objects, + /// or 400 Bad Request if any item fails validation. + /// + /// + /// The connectionString uses the GSF key=value pair format, with nested + /// sourceSettings and proxySettings sub-strings. Example: + /// + /// [ + /// { + /// "connectionString": "name=PDC-001;enabled=true;sourceSettings={server=192.168.1.1;port=4712;phasorProtocol=IEEEC37_118V2;accessID=1};proxySettings={port=4713}" + /// } + /// ] + /// + /// + /// Array of created connections, each with a server-generated id. + /// One or more items are missing a required connectionString. + /// Service configuration not yet loaded. + [HttpPost, Route("")] + [ResponseType(typeof(ConnectionDto[]))] + public IHttpActionResult CreateConnections([FromBody] CreateConnectionRequest[] requests) + { + string correlationId = GetCorrelationId(); + + s_log.Publish( + MessageLevel.Info, + "CreateConnections", + $"POST /api/connections requested ({requests?.Length ?? 0} item(s)). CorrelationId={correlationId}"); + + if (requests is null || requests.Length == 0) + { + return Content(HttpStatusCode.BadRequest, new + { + status = 400, + title = "Bad Request", + detail = "The request body must be a non-empty JSON array of connection objects." + }); + } + + // Validate all items before creating any — all-or-nothing semantics. + for (int i = 0; i < requests.Length; i++) + { + if (string.IsNullOrWhiteSpace(requests[i]?.ConnectionString)) + { + s_log.Publish( + MessageLevel.Warning, + "CreateConnections", + $"Validation failed at index {i}: connectionString is required. CorrelationId={correlationId}"); + + return Content(HttpStatusCode.BadRequest, new + { + status = 400, + title = "Bad Request", + detail = $"Item at index {i} is missing a required connectionString." + }); + } + } + + List created = new List(); + + foreach (CreateConnectionRequest request in requests) + { + ProxyConnection connection = new ProxyConnection { ConnectionString = request.ConnectionString }; + + try + { + ServiceHost.Current.AddConnection(connection); + } + catch (InvalidOperationException ex) + { + return Content(HttpStatusCode.ServiceUnavailable, new + { + status = 503, + title = "Service Unavailable", + detail = ex.Message + }); + } + + created.Add(ConnectionDto.FromProxyConnection( + connection, + ServiceHost.Current.GetRuntimeConnectionState(connection.ID))); + + s_log.Publish( + MessageLevel.Info, + "CreateConnections", + $"Connection '{connection.Name}' created. Id={connection.ID}. CorrelationId={correlationId}"); + } + + return Created("/api/connections", created.ToArray()); + } + + /// + /// Imports connections from a .s3config file sent as multipart/form-data. + /// Each connection in the file is assigned a new server-generated ID to ensure + /// CREATE semantics regardless of the IDs in the file. + /// + /// + /// 201 Created with the array of imported objects, + /// or 400 Bad Request if the file is missing, empty, or not a valid .s3config. + /// + /// + /// Upload a .s3config file exported from StreamSplitterManager using + /// File → Save Configuration. The file is a SOAP-serialized ProxyConnectionCollection. + /// All connections in the file are created with new server-generated IDs. + /// + /// Array of imported connections, each with a new server-generated id. + /// File missing, empty, or not a valid .s3config. + [HttpPost, Route("import")] + [ResponseType(typeof(ConnectionDto[]))] + public async Task ImportFromFile() + { + string correlationId = GetCorrelationId(); + + s_log.Publish( + MessageLevel.Info, + "ImportFromFile", + $"POST /api/connections/import requested. CorrelationId={correlationId}"); + + if (!Request.Content.IsMimeMultipartContent()) + { + return Content(HttpStatusCode.BadRequest, new + { + status = 400, + title = "Bad Request", + detail = "Expected multipart/form-data with a .s3config file." + }); + } + + MultipartMemoryStreamProvider provider = new MultipartMemoryStreamProvider(); + await Request.Content.ReadAsMultipartAsync(provider); + + HttpContent filePart = provider.Contents.FirstOrDefault(); + + if (filePart is null) + { + return Content(HttpStatusCode.BadRequest, new + { + status = 400, + title = "Bad Request", + detail = "No file was included in the request." + }); + } + + // Read the part as a stream to avoid a redundant byte[] copy — the + // MultipartMemoryStreamProvider already buffers content in a MemoryStream. + using Stream fileStream = await filePart.ReadAsStreamAsync(); + + if (fileStream.Length == 0) + { + return Content(HttpStatusCode.BadRequest, new + { + status = 400, + title = "Bad Request", + detail = "The uploaded file is empty." + }); + } + + ProxyConnectionCollection imported; + + try + { + imported = ProxyConnectionCollection.DeserializeConfiguration(fileStream); + } + catch (Exception ex) + { + s_log.Publish( + MessageLevel.Warning, + "ImportFromFile", + $"Failed to deserialize file. Error={ex.Message}. CorrelationId={correlationId}"); + + return Content(HttpStatusCode.BadRequest, new + { + status = 400, + title = "Bad Request", + detail = "The file could not be read as a valid .s3config configuration." + }); + } + + if (imported.Count == 0) + { + return Content(HttpStatusCode.BadRequest, new + { + status = 400, + title = "Bad Request", + detail = "The .s3config file contains no connections." + }); + } + + List created = new List(); + + foreach (ProxyConnection source in imported) + { + // Assign a new ID to guarantee CREATE semantics — never UPDATE an existing connection. + ProxyConnection connection = new ProxyConnection + { + ConnectionString = source.ConnectionString, + ConnectionParameters = source.ConnectionParameters + }; + + ServiceHost.Current.AddConnection(connection); + + created.Add(ConnectionDto.FromProxyConnection( + connection, + ServiceHost.Current.GetRuntimeConnectionState(connection.ID))); + } + + s_log.Publish( + MessageLevel.Info, + "ImportFromFile", + $"Imported {created.Count} connection(s). CorrelationId={correlationId}"); + + return Created("/api/connections", created.ToArray()); + } + + /// + /// Partially updates a proxy connection. Only the fields present in the request body + /// are changed; omitted fields retain their current values. The connection is + /// automatically restarted after the update. + /// + /// Unique identifier of the connection to update. + /// Fields to update. All properties are optional. + /// + /// 200 OK with the updated , or 404 if not found. + /// + /// + /// Send only the fields that need to change; all others are preserved. + /// Disable a connection: + /// { "enabled": false } + /// Rename a connection: + /// { "name": "PDC-001-updated" } + /// Update the source settings: + /// { "sourceSettings": "server=192.168.1.2;port=4712;phasorProtocol=IEEEC37_118V2;accessID=1" } + /// + /// Each field is independent — you can combine any of them in the same request. + /// For sourceSettings and proxySettings, sending an empty string removes the sub-string. + /// + /// + /// Updated connection with its new runtime state. + /// Request body is missing. + /// No connection found with the given ID. + /// Service configuration not yet loaded. + [HttpPatch, Route("{id:guid}")] + [ResponseType(typeof(ConnectionDto))] + public IHttpActionResult UpdateConnection(Guid id, [FromBody] UpdateConnectionRequest request) + { + string correlationId = GetCorrelationId(); + + s_log.Publish( + MessageLevel.Info, + "UpdateConnection", + $"PATCH /api/connections/{id} requested. CorrelationId={correlationId}"); + + if (request is null) + { + return Content(HttpStatusCode.BadRequest, new + { + status = 400, + title = "Bad Request", + detail = "Request body is required." + }); + } + + ProxyConnectionCollection configuration = ServiceHost.Current?.CurrentConfiguration; + ProxyConnection existing = configuration?[id]; + + if (existing is null) + { + s_log.Publish( + MessageLevel.Info, + "UpdateConnection", + $"Connection {id} not found. CorrelationId={correlationId}"); + + return Content(HttpStatusCode.NotFound, new + { + status = 404, + title = "Not Found", + detail = $"Connection with ID '{id}' was not found." + }); + } + + // Merge only the provided sub-fields into the existing connection string. + // Omitted fields (null) retain their current values. + Dictionary settings = existing.ConnectionString.ParseKeyValuePairs(); + + if (request.Name != null) + settings["name"] = request.Name; + + if (request.Enabled.HasValue) + settings["enabled"] = request.Enabled.Value.ToString().ToLower(); + + if (request.SourceSettings != null) + { + if (string.IsNullOrEmpty(request.SourceSettings)) + settings.Remove("sourceSettings"); + else + settings["sourceSettings"] = request.SourceSettings; + } + + if (request.ProxySettings != null) + { + if (string.IsNullOrEmpty(request.ProxySettings)) + settings.Remove("proxySettings"); + else + settings["proxySettings"] = request.ProxySettings; + } + + string updatedConnectionString = settings.JoinKeyValuePairs(); + + ProxyConnection updated = new ProxyConnection + { + ConnectionString = updatedConnectionString, + ConnectionParameters = existing.ConnectionParameters + }; + + updated.ID = existing.ID; + + try + { + ServiceHost.Current.AddConnection(updated); + } + catch (InvalidOperationException ex) + { + return Content(HttpStatusCode.ServiceUnavailable, new + { + status = 503, + title = "Service Unavailable", + detail = ex.Message + }); + } + + ConnectionDto dto = ConnectionDto.FromProxyConnection( + updated, + ServiceHost.Current.GetRuntimeConnectionState(id)); + + s_log.Publish( + MessageLevel.Info, + "UpdateConnection", + $"Connection '{updated.Name}' updated. Id={id}. CorrelationId={correlationId}"); + + return Ok(dto); + } + + /// + /// Returns the current operational status of the proxy connection identified by + /// without retrieving its full configuration. + /// + /// Unique identifier of the connection. + /// + /// 200 OK with ; 404 if not found. + /// + /// Current operational status including state, metrics, and recent messages. + /// No connection found with the given ID. + [HttpGet, Route("{id:guid}/status")] + [ResponseType(typeof(ConnectionStatusDto))] + public IHttpActionResult GetConnectionStatus(Guid id) + { + string correlationId = GetCorrelationId(); + + s_log.Publish( + MessageLevel.Info, + "GetConnectionStatus", + $"GET /api/connections/{id}/status requested. CorrelationId={correlationId}"); + + ConnectionStatusDto status = ServiceHost.Current?.GetConnectionStatus(id); + + if (status is null) + { + s_log.Publish( + MessageLevel.Info, + "GetConnectionStatus", + $"Connection {id} not found. CorrelationId={correlationId}"); + + return Content(HttpStatusCode.NotFound, new + { + status = 404, + title = "Not Found", + detail = $"Connection with ID '{id}' was not found." + }); + } + + return Ok(status); + } + + /// + /// Removes the proxy connection identified by , stops its data + /// flow, and persists the updated configuration. + /// + /// Unique identifier of the connection to remove. + /// 204 No Content on success; 404 if not found. + /// Connection removed and data flow stopped. + /// No connection found with the given ID. + /// Service configuration not yet loaded. + [HttpDelete, Route("{id:guid}")] + [ResponseType(typeof(void))] + public IHttpActionResult DeleteConnection(Guid id) + { + string correlationId = GetCorrelationId(); + + s_log.Publish( + MessageLevel.Info, + "DeleteConnection", + $"DELETE /api/connections/{id} requested. CorrelationId={correlationId}"); + + bool removed; + + try + { + removed = ServiceHost.Current.RemoveConnection(id); + } + catch (InvalidOperationException ex) + { + return Content(HttpStatusCode.ServiceUnavailable, new + { + status = 503, + title = "Service Unavailable", + detail = ex.Message + }); + } + + if (!removed) + { + s_log.Publish( + MessageLevel.Info, + "DeleteConnection", + $"Connection {id} not found. CorrelationId={correlationId}"); + + return Content(HttpStatusCode.NotFound, new + { + status = 404, + title = "Not Found", + detail = $"Connection with ID '{id}' was not found." + }); + } + + s_log.Publish( + MessageLevel.Info, + "DeleteConnection", + $"Connection {id} removed. CorrelationId={correlationId}"); + + return StatusCode(HttpStatusCode.NoContent); + } + + // Extracts the X-Correlation-Id header value, or generates a new GUID string if absent. + private string GetCorrelationId() + { + if (Request.Headers.TryGetValues("X-Correlation-Id", out IEnumerable values)) + return values.FirstOrDefault() ?? Guid.NewGuid().ToString(); + + return Guid.NewGuid().ToString(); + } + + #endregion + } +} diff --git a/Source/Applications/StreamSplitter/Api/Filters/FileUploadOperationFilter.cs b/Source/Applications/StreamSplitter/Api/Filters/FileUploadOperationFilter.cs new file mode 100644 index 0000000..d910cd1 --- /dev/null +++ b/Source/Applications/StreamSplitter/Api/Filters/FileUploadOperationFilter.cs @@ -0,0 +1,65 @@ +//****************************************************************************************************** +// FileUploadOperationFilter.cs - Gbtc +// +// Copyright © 2026, Grid Protection Alliance. All Rights Reserved. +// +// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See +// the NOTICE file distributed with this work for additional information regarding copyright ownership. +// The GPA licenses this file to you under the Eclipse Public License -v 1.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain a copy of the License at: +// +// http://www.opensource.org/licenses/eclipse-1.0.php +// +// Unless agreed to in writing, the subject software distributed under the License is distributed on an +// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the +// License for the specific language governing permissions and limitations. +// +// Code Modification History: +// ---------------------------------------------------------------------------------------------------- +// 06/05/2026 - Marcos Vinicius Snak +// Generated original version of source code. +// +//****************************************************************************************************** + +using System.Collections.Generic; +using System.Web.Http.Description; +using Swashbuckle.Swagger; + +namespace StreamSplitter.Api.Filters +{ + /// + /// Swashbuckle operation filter that replaces the body parameter on the + /// ImportFromFile action with a multipart/form-data file picker, + /// enabling file upload directly from the Swagger UI. + /// + public class FileUploadOperationFilter : IOperationFilter + { + #region [ Methods ] + + /// + public void Apply(Operation operation, SchemaRegistry schemaRegistry, ApiDescription apiDescription) + { + bool isImport = + apiDescription.ActionDescriptor.ControllerDescriptor.ControllerName == "Connections" + && apiDescription.ActionDescriptor.ActionName == "ImportFromFile"; + + if (!isImport) + return; + + operation.consumes = new List { "multipart/form-data" }; + operation.parameters = new List + { + new Parameter + { + name = "file", + @in = "formData", + required = true, + type = "file", + description = "Configuration file (.s3config) exported from StreamSplitterManager." + } + }; + } + + #endregion + } +} diff --git a/Source/Applications/StreamSplitter/Api/Models/ConnectionDto.cs b/Source/Applications/StreamSplitter/Api/Models/ConnectionDto.cs new file mode 100644 index 0000000..478ce65 --- /dev/null +++ b/Source/Applications/StreamSplitter/Api/Models/ConnectionDto.cs @@ -0,0 +1,122 @@ +//****************************************************************************************************** +// ConnectionDto.cs - Gbtc +// +// Copyright © 2026, Grid Protection Alliance. All Rights Reserved. +// +// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See +// the NOTICE file distributed with this work for additional information regarding copyright ownership. +// The GPA licenses this file to you under the Eclipse Public License -v 1.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain a copy of the License at: +// +// http://www.opensource.org/licenses/eclipse-1.0.php +// +// Unless agreed to in writing, the subject software distributed under the License is distributed on an +// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the +// License for the specific language governing permissions and limitations. +// +// Code Modification History: +// ---------------------------------------------------------------------------------------------------- +// 06/01/2026 - Marcos Vinicius Snak +// Generated original version of source code. +// +//****************************************************************************************************** + +using System; +using GSF; +using StreamSplitter; + +namespace StreamSplitter.Api.Models +{ + /// + /// Read-only data transfer object representing a . + /// + public sealed class ConnectionDto + { + #region [ Constructors ] + + private ConnectionDto() + { + } + + #endregion + + #region [ Properties ] + + /// + /// Gets the unique identifier of the connection. + /// + public Guid Id { get; private set; } + + /// + /// Gets the display name of the connection. + /// + public string Name { get; private set; } + + /// + /// Gets a value indicating whether the connection is enabled. + /// + public bool Enabled { get; private set; } + + /// + /// Gets the current of the connection. + /// + public ConnectionState ConnectionState { get; private set; } + + /// + /// Gets a human-readable description of the current connection state. + /// + public string ConnectionStateDescription { get; private set; } + + /// + /// Gets the raw GSF connection string. + /// + public string ConnectionString { get; private set; } + + /// + /// Gets the source-side settings extracted from the connection string. + /// + public string SourceSettings { get; private set; } + + /// + /// Gets the proxy-side settings extracted from the connection string. + /// + public string ProxySettings { get; private set; } + + #endregion + + #region [ Static ] + + // Static Methods + + /// + /// Creates a new from a and its + /// runtime . + /// + /// Source to map from. + /// + /// Live read from the associated . + /// Pass when no live proxy exists. + /// + /// A populated . + /// is null. + public static ConnectionDto FromProxyConnection(ProxyConnection connection, ConnectionState runtimeState) + { + if (connection is null) + throw new ArgumentNullException(nameof(connection)); + + return new ConnectionDto + { + Id = connection.ID, + Name = connection.Name, + Enabled = connection.Enabled, + ConnectionState = runtimeState, + ConnectionStateDescription = runtimeState.GetDescription(), + ConnectionString = connection.ConnectionString, + SourceSettings = connection.SourceSettings, + ProxySettings = connection.ProxySettings + }; + } + + #endregion + } +} diff --git a/Source/Applications/StreamSplitter/Api/Models/ConnectionStatusDto.cs b/Source/Applications/StreamSplitter/Api/Models/ConnectionStatusDto.cs new file mode 100644 index 0000000..a1035b0 --- /dev/null +++ b/Source/Applications/StreamSplitter/Api/Models/ConnectionStatusDto.cs @@ -0,0 +1,121 @@ +//****************************************************************************************************** +// ConnectionStatusDto.cs - Gbtc +// +// Copyright © 2026, Grid Protection Alliance. All Rights Reserved. +// +// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See +// the NOTICE file distributed with this work for additional information regarding copyright ownership. +// The GPA licenses this file to you under the Eclipse Public License -v 1.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain a copy of the License at: +// +// http://www.opensource.org/licenses/eclipse-1.0.php +// +// Unless agreed to in writing, the subject software distributed under the License is distributed on an +// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the +// License for the specific language governing permissions and limitations. +// +// Code Modification History: +// ---------------------------------------------------------------------------------------------------- +// 06/08/2026 - Marcos Vinicius Snak +// Generated original version of source code. +// +//****************************************************************************************************** + +using System; +using GSF; + +namespace StreamSplitter.Api.Models +{ + /// + /// Lightweight data transfer object representing the current operational status of a + /// and its associated . + /// Returned by GET /api/connections/{id}/status. + /// + public sealed class ConnectionStatusDto + { + #region [ Properties ] + + /// Gets the unique identifier of the connection. + public Guid Id { get; private set; } + + /// Gets the display name of the connection. + public string Name { get; private set; } + + /// Gets a value indicating whether the connection is enabled in configuration. + public bool Enabled { get; private set; } + + /// Gets the current runtime of the connection. + public ConnectionState ConnectionState { get; private set; } + + /// Gets a human-readable description of the current connection state. + public string ConnectionStateDescription { get; private set; } + + /// + /// Gets the source connection endpoint in the format host:port/accessID. + /// null when no live exists (e.g. connection is disabled). + /// + public string ConnectionInfo { get; private set; } + + /// + /// Gets the total number of bytes sent to downstream clients since the proxy started. + /// Zero when no live exists. + /// + public long TotalBytesSent { get; private set; } + + /// + /// Gets the total active run time of the proxy in seconds. + /// Zero when no live exists. + /// + public double RunTime { get; private set; } + + /// + /// Gets the most recent status messages produced by the proxy (up to 2 048 characters). + /// null when no live exists. + /// + public string RecentStatusMessages { get; private set; } + + #endregion + + #region [ Static ] + + // Static Methods + + /// + /// Creates a from the provided service-layer objects. + /// + /// Connection identifier. + /// + /// that holds the configuration. Must not be null. + /// + /// + /// Live associated with , + /// or null when the connection is disabled or not yet materialized. + /// + /// A populated . + /// + /// is null. + /// + public static ConnectionStatusDto FromServiceHost(Guid id, ProxyConnection connection, StreamProxy splitter) + { + if (connection is null) + throw new ArgumentNullException(nameof(connection)); + + ConnectionState state = splitter?.StreamProxyStatus.ConnectionState ?? ConnectionState.Disabled; + + return new ConnectionStatusDto + { + Id = id, + Name = connection.Name, + Enabled = connection.Enabled, + ConnectionState = state, + ConnectionStateDescription = state.GetDescription(), + ConnectionInfo = splitter?.ConnectionInfo, + TotalBytesSent = splitter?.TotalBytesSent ?? 0L, + RunTime = (double)(splitter?.RunTime ?? 0.0D), + RecentStatusMessages = splitter?.StreamProxyStatus.RecentStatusMessages + }; + } + + #endregion + } +} diff --git a/Source/Applications/StreamSplitter/Api/Models/CreateConnectionRequest.cs b/Source/Applications/StreamSplitter/Api/Models/CreateConnectionRequest.cs new file mode 100644 index 0000000..abf7295 --- /dev/null +++ b/Source/Applications/StreamSplitter/Api/Models/CreateConnectionRequest.cs @@ -0,0 +1,44 @@ +//****************************************************************************************************** +// CreateConnectionRequest.cs - Gbtc +// +// Copyright © 2026, Grid Protection Alliance. All Rights Reserved. +// +// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See +// the NOTICE file distributed with this work for additional information regarding copyright ownership. +// The GPA licenses this file to you under the Eclipse Public License -v 1.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain a copy of the License at: +// +// http://www.opensource.org/licenses/eclipse-1.0.php +// +// Unless agreed to in writing, the subject software distributed under the License is distributed on an +// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the +// License for the specific language governing permissions and limitations. +// +// Code Modification History: +// ---------------------------------------------------------------------------------------------------- +// 06/05/2026 - Marcos Vinicius Snak +// Generated original version of source code. +// +//****************************************************************************************************** + +namespace StreamSplitter.Api.Models +{ + /// + /// Request body for creating a new proxy connection via the REST API. + /// + public class CreateConnectionRequest + { + #region [ Properties ] + + /// + /// Gets or sets the full GSF connection string (key=value pairs). + /// The string must include all required parameters such as name, enabled, + /// sourceSettings and proxySettings embedded in the standard GSF format. + /// This is the only required field; all other connection attributes are + /// parsed from it automatically. + /// + public string ConnectionString { get; set; } + + #endregion + } +} diff --git a/Source/Applications/StreamSplitter/Api/Models/UpdateConnectionRequest.cs b/Source/Applications/StreamSplitter/Api/Models/UpdateConnectionRequest.cs new file mode 100644 index 0000000..3dcf6de --- /dev/null +++ b/Source/Applications/StreamSplitter/Api/Models/UpdateConnectionRequest.cs @@ -0,0 +1,64 @@ +//****************************************************************************************************** +// UpdateConnectionRequest.cs - Gbtc +// +// Copyright © 2026, Grid Protection Alliance. All Rights Reserved. +// +// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See +// the NOTICE file distributed with this work for additional information regarding copyright ownership. +// The GPA licenses this file to you under the Eclipse Public License -v 1.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain a copy of the License at: +// +// http://www.opensource.org/licenses/eclipse-1.0.php +// +// Unless agreed to in writing, the subject software distributed under the License is distributed on an +// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the +// License for the specific language governing permissions and limitations. +// +// Code Modification History: +// ---------------------------------------------------------------------------------------------------- +// 06/05/2026 - Marcos Vinicius Snak +// Generated original version of source code. +// 06/08/2026 - Marcos Vinicius Snak +// Removed ConnectionString field; PATCH now uses individual sub-fields only for +// true partial-update semantics without conflicting override behaviour. +// +//****************************************************************************************************** + +namespace StreamSplitter.Api.Models +{ + /// + /// Request body for partially updating an existing proxy connection via the REST API. + /// All properties are optional — only the fields that are present (non-null) are applied; + /// omitted fields retain their current values. + /// + public class UpdateConnectionRequest + { + #region [ Properties ] + + /// + /// Gets or sets the display name of the connection. + /// Null keeps the existing value. + /// + public string Name { get; set; } + + /// + /// Gets or sets a value indicating whether the connection should be enabled. + /// Null keeps the existing value. + /// + public bool? Enabled { get; set; } + + /// + /// Gets or sets the source-side settings sub-string. + /// Null keeps the existing value; empty string removes the sub-string entirely. + /// + public string SourceSettings { get; set; } + + /// + /// Gets or sets the proxy-side settings sub-string. + /// Null keeps the existing value; empty string removes the sub-string entirely. + /// + public string ProxySettings { get; set; } + + #endregion + } +} diff --git a/Source/Applications/StreamSplitter/Api/Startup.cs b/Source/Applications/StreamSplitter/Api/Startup.cs new file mode 100644 index 0000000..4628088 --- /dev/null +++ b/Source/Applications/StreamSplitter/Api/Startup.cs @@ -0,0 +1,91 @@ +//****************************************************************************************************** +// Startup.cs - Gbtc +// +// Copyright © 2026, Grid Protection Alliance. All Rights Reserved. +// +// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See +// the NOTICE file distributed with this work for additional information regarding copyright ownership. +// The GPA licenses this file to you under the Eclipse Public License -v 1.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain a copy of the License at: +// +// http://www.opensource.org/licenses/eclipse-1.0.php +// +// Unless agreed to in writing, the subject software distributed under the License is distributed on an +// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the +// License for the specific language governing permissions and limitations. +// +// Code Modification History: +// ---------------------------------------------------------------------------------------------------- +// 06/01/2026 - Marcos Vinicius Snak +// Generated original version of source code. +// 06/05/2026 - Marcos Vinicius Snak +// Added FileUploadOperationFilter to enable file picker on /import endpoint in Swagger UI. +// Added XML documentation comments to Swagger via IncludeXmlComments. +// +//****************************************************************************************************** + +using System; +using System.IO; +using System.Web.Http; +using Newtonsoft.Json.Converters; +using Owin; +using Swashbuckle.Application; +using StreamSplitter.Api.Filters; + +namespace StreamSplitter.Api +{ + /// + /// OWIN startup class for the Stream Splitter Web API. + /// Configures attribute routing, JSON serialization, Swagger UI, and the Web API middleware. + /// + /// + /// This class is passed directly to WebApp.Start<Startup>() in + /// — the [assembly: OwinStartup] + /// auto-discovery attribute is intentionally omitted. + /// + public class Startup + { + #region [ Methods ] + + /// + /// Configures the OWIN middleware pipeline. + /// + /// The instance to configure. + public void Configuration(IAppBuilder app) + { + HttpConfiguration config = new HttpConfiguration(); + + // Enable attribute-based routing ([RoutePrefix] / [Route] on controllers) + config.MapHttpAttributeRoutes(); + + // Serialize enum values as strings for readability in JSON responses + config.Formatters.JsonFormatter.SerializerSettings.Converters + .Add(new StringEnumConverter()); + + // Remove XML formatter — this API speaks JSON only + config.Formatters.Remove(config.Formatters.XmlFormatter); + + // Configure Swashbuckle: single API version, enums as strings in schema + config + .EnableSwagger(c => + { + c.SingleApiVersion("v1", "Stream Splitter API"); + c.DescribeAllEnumsAsStrings(); + c.OperationFilter(); + + // Load XML documentation generated by the C# compiler so that + // controller summaries, parameter descriptions, and remarks with + // examples are displayed in the Swagger UI. + string xmlPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "StreamSplitter.xml"); + + if (File.Exists(xmlPath)) + c.IncludeXmlComments(xmlPath); + }) + .EnableSwaggerUi(); + + app.UseWebApi(config); + } + + #endregion + } +} diff --git a/Source/Applications/StreamSplitter/ServiceHost.cs b/Source/Applications/StreamSplitter/ServiceHost.cs index 7a9ac20..3dba359 100755 --- a/Source/Applications/StreamSplitter/ServiceHost.cs +++ b/Source/Applications/StreamSplitter/ServiceHost.cs @@ -18,6 +18,9 @@ // ---------------------------------------------------------------------------------------------------- // 09/04/2013 - J. Ritchie Carroll // Generated original version of source code. +// 06/01/2026 - Marcos Vinicius Snak +// Added OWIN-based Web API hosting (Story 1): TryStartWebHosting, CurrentConfiguration, +// ServiceHost.Current static accessor. // //****************************************************************************************************** @@ -43,7 +46,9 @@ using GSF.PhasorProtocols; using GSF.ServiceProcess; using GSF.Units; +using Microsoft.Owin.Hosting; using Microsoft.Win32; +using StreamSplitter.Api; namespace StreamSplitter { @@ -62,6 +67,9 @@ public sealed partial class ServiceHost : ServiceBase private const int DefaultMinThreadPoolIOPortSize = (int)(DefaultMinThreadPoolWorkerSize + DefaultMinThreadPoolWorkerSize * 0.2D); private const int DefaultMaxThreadPoolIOPortSize = (int)(DefaultMaxThreadPoolWorkerSize + DefaultMaxThreadPoolWorkerSize * 0.2D); private const int DefaultMaxLogFiles = 300; + private const bool DefaultWebHostingEnabled = true; + private const string DefaultWebHostURL = "http://localhost:8283"; + private const string ApiConfigChangedBroadcast = "[API_CONFIG_CHANGED]"; // Fields private AutoResetEvent m_configurationLoadComplete; @@ -69,6 +77,7 @@ public sealed partial class ServiceHost : ServiceBase private volatile ProxyConnectionCollection m_currentConfiguration; private readonly List m_streamSplitters; private readonly ConcurrentDictionary m_derivedNameCache; + private IDisposable m_webAppHost; #endregion @@ -94,6 +103,8 @@ public ServiceHost() // Create a cache for derived proxy connection names m_derivedNameCache = new ConcurrentDictionary(); + + Current = this; } public ServiceHost(IContainer container) @@ -121,6 +132,51 @@ public ServiceHost(IContainer container) /// private TcpServer RemotingServer => m_remotingServer; + /// + /// Gets the current proxy connection configuration. + /// Returns null if the configuration has not yet been loaded. + /// + internal ProxyConnectionCollection CurrentConfiguration => m_currentConfiguration; + + /// + /// Returns an operational status snapshot for the connection identified by . + /// Returns null when the connection does not exist in the current configuration. + /// + /// ID of the to look up. + internal Api.Models.ConnectionStatusDto GetConnectionStatus(Guid id) + { + ProxyConnection connection = m_currentConfiguration?[id]; + + if (connection is null) + return null; + + lock (m_streamSplitters) + { + StreamProxy splitter = m_streamSplitters.Find(s => s.ID == id); + return Api.Models.ConnectionStatusDto.FromServiceHost(id, connection, splitter); + } + } + + /// + /// Gets the runtime for the identified by + /// . Returns when no live + /// proxy exists for the given ID (connection is disabled or not yet materialized). + /// + /// ID of the to look up. + internal ConnectionState GetRuntimeConnectionState(Guid connectionId) + { + lock (m_streamSplitters) + { + StreamProxy splitter = m_streamSplitters.Find(s => s.ID == connectionId); + return splitter?.StreamProxyStatus.ConnectionState ?? ConnectionState.Disabled; + } + } + + /// + /// Gets the singleton instance, available after the service starts. + /// + internal static ServiceHost Current { get; private set; } + #endregion #region [ Methods ] @@ -145,6 +201,8 @@ private void ServiceHelper_ServiceStarting(object sender, EventArgs e) systemSettings.Add("LogPath", defaultLogPath, "Defines the path used to archive log files"); systemSettings.Add("MaxLogFiles", DefaultMaxLogFiles, "Defines the maximum number of log files to keep"); systemSettings.Add("DefaultCulture", "en-US", "Default culture to use for language, country/region and calendar formats."); + systemSettings.Add("WebHostingEnabled", DefaultWebHostingEnabled, "Flag that determines if the web API hosting is enabled."); + systemSettings.Add("WebHostURL", DefaultWebHostURL, "URL endpoint where the Stream Splitter web API is hosted."); // Create a handler for unobserved task exceptions TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException; @@ -276,10 +334,15 @@ private void ServiceHelper_ServiceStarted(object sender, EventArgs e) m_serviceHelper.ClientRequestHandlers.Add(new ClientRequestHandler("SendCommand", "Sends command to a specific stream splitter", SendCommandHandler)); LoadCurrentConfiguration(); + TryStartWebHosting(); } private void ServiceHelper_ServiceStopping(object sender, EventArgs e) { + // Stop web API host before stream splitters so no new requests arrive during shutdown + m_webAppHost?.Dispose(); + m_webAppHost = null; + lock (m_streamSplitters) { foreach (StreamProxy splitter in m_streamSplitters) @@ -317,6 +380,42 @@ private void ServiceHelper_ServiceStopping(object sender, EventArgs e) // Unattach from handler for unobserved task exceptions TaskScheduler.UnobservedTaskException -= TaskScheduler_UnobservedTaskException; + + Current = null; + } + + // Starts the OWIN-based web API host using settings from the configuration file. + private void TryStartWebHosting() + { + try + { + CategorizedSettingsElementCollection systemSettings = + ConfigurationFile.Current.Settings["systemSettings"]; + + if (!systemSettings["WebHostingEnabled"].ValueAs(DefaultWebHostingEnabled)) + { + DisplayStatusMessage("Web API hosting is disabled per configuration.", UpdateType.Information); + return; + } + + string webHostURL = systemSettings["WebHostURL"].ValueAs(DefaultWebHostURL); + + m_webAppHost = WebApp.Start(webHostURL); + + DisplayStatusMessage( + "Web API hosting started at \"{0}\". Swagger UI: {0}/swagger", + UpdateType.Information, + webHostURL); + } + catch (Exception ex) + { + DisplayStatusMessage( + "Failed to start web API hosting due to exception: {0}", + UpdateType.Alarm, + ex.Message); + + Logger.SwallowException(ex); + } } // Handle task scheduler exceptions @@ -1198,6 +1297,99 @@ private void HandleException(Exception ex) m_serviceHelper.UpdateStatus(UpdateType.Alarm, ex.Message + newLines); } + /// + /// Adds or updates a in the running configuration, + /// materializes the corresponding , and persists the change to disk. + /// Mirrors the logic used by the TCP-based UploadConnection command handler. + /// + /// to add or update. + /// Configuration has not yet been loaded. + internal void AddConnection(ProxyConnection connection) + { + if (m_currentConfiguration is null) + throw new InvalidOperationException("Configuration is not yet loaded."); + + lock (m_streamSplitters) + { + StreamProxy existing = m_streamSplitters.Find(s => s.ID == connection.ID); + + if (existing is not null) + { + existing.ProxyConnection = connection; + m_currentConfiguration[connection.ID] = connection; + } + else + { + StreamProxy splitter = new StreamProxy(connection); + + splitter.StatusMessage += splitter_StatusMessage; + splitter.ProcessException += splitter_ProcessException; + + m_streamSplitters.Add(splitter); + m_serviceHelper.ServiceComponents.Add(splitter); + m_currentConfiguration.Add(connection); + } + } + + BackupConfiguration(); + + ProxyConnectionCollection.SaveConfiguration( + m_currentConfiguration, + FilePath.GetAbsolutePath(ConfigurationFileName)); + + // Notify connected Manager instances to refresh their configuration view. + DisplayStatusMessage(ApiConfigChangedBroadcast, UpdateType.Information); + } + + /// + /// Removes the identified by from the + /// running configuration, stops the associated , and persists the change. + /// + /// ID of the to remove. + /// + /// true if the connection was found and removed; false if it did not exist. + /// + /// Configuration is not yet loaded. + internal bool RemoveConnection(Guid id) + { + if (m_currentConfiguration is null) + throw new InvalidOperationException("Configuration is not yet loaded."); + + lock (m_streamSplitters) + { + ProxyConnection connection = m_currentConfiguration[id]; + + if (connection is null) + return false; + + StreamProxy splitter = m_streamSplitters.Find(s => s.ID == id); + + if (splitter is not null) + { + splitter.Stop(); + splitter.Dispose(); + m_streamSplitters.Remove(splitter); + m_serviceHelper.ServiceComponents.Remove(splitter); + } + + // RemovingItem fires but has no subscriber in ServiceHost — no UI dialog. + // In StreamSplitterManager the event shows a confirmation dialog, but that + // code runs in a different process and does not affect the service. + m_currentConfiguration.Remove(connection); + } + + BackupConfiguration(); + + ProxyConnectionCollection.SaveConfiguration( + m_currentConfiguration, + FilePath.GetAbsolutePath(ConfigurationFileName)); + + // Notify connected Manager instances to refresh their configuration view. + DisplayStatusMessage(ApiConfigChangedBroadcast, UpdateType.Information); + + return true; + } + #endregion #endregion diff --git a/Source/Applications/StreamSplitter/StreamSplitter.csproj b/Source/Applications/StreamSplitter/StreamSplitter.csproj index fa3728c..76e45da 100755 --- a/Source/Applications/StreamSplitter/StreamSplitter.csproj +++ b/Source/Applications/StreamSplitter/StreamSplitter.csproj @@ -53,6 +53,8 @@ 4 AllRules.ruleset false + ..\..\..\Build\Output\Debug\Applications\StreamSplitter\StreamSplitter.xml + 1591 pdbonly @@ -63,6 +65,8 @@ 4 AllRules.ruleset false + ..\..\..\Build\Output\Release\Applications\StreamSplitter\StreamSplitter.xml + 1591 @@ -82,10 +86,66 @@ False ..\..\Dependencies\GSF\GSF.TimeSeries.dll + + ..\..\Dependencies\GSF\Microsoft.Owin.dll + + + ..\..\Dependencies\GSF\Microsoft.Owin.Host.HttpListener.dll + + + ..\..\Dependencies\GSF\Microsoft.Owin.Hosting.dll + + + + ..\..\packages\Newtonsoft.Json.13.0.4\lib\net45\Newtonsoft.Json.dll + + + ..\..\packages\Newtonsoft.Json.Bson.1.0.2\lib\net45\Newtonsoft.Json.Bson.dll + + + ..\..\Dependencies\GSF\Owin.dll + + + ..\..\packages\Swashbuckle.Core.5.6.0\lib\net40\Swashbuckle.Core.dll + + + + ..\..\packages\System.Buffers.4.5.1\lib\net461\System.Buffers.dll + + + ..\..\packages\System.Memory.4.5.5\lib\net461\System.Memory.dll + + + + ..\..\packages\Microsoft.Net.Http.2.2.29\lib\net45\System.Net.Http.Extensions.dll + + + ..\..\packages\Microsoft.AspNet.WebApi.Client.6.0.0\lib\net45\System.Net.Http.Formatting.dll + + + ..\..\packages\Microsoft.Net.Http.2.2.29\lib\net45\System.Net.Http.Primitives.dll + + + + + ..\..\packages\System.Numerics.Vectors.4.5.0\lib\net46\System.Numerics.Vectors.dll + + + ..\..\Dependencies\GSF\System.Runtime.CompilerServices.Unsafe.dll + + + ..\..\packages\System.Threading.Tasks.Extensions.4.5.4\lib\net461\System.Threading.Tasks.Extensions.dll + + + ..\..\packages\Microsoft.AspNet.WebApi.Core.5.3.0\lib\net45\System.Web.Http.dll + + + ..\..\Dependencies\GSF\System.Web.Http.Owin.dll + @@ -113,8 +173,19 @@ ServiceHost.cs + + + + + + + + + + + diff --git a/Source/Applications/StreamSplitter/app.config b/Source/Applications/StreamSplitter/app.config index 4a9bee3..2dcb81a 100755 --- a/Source/Applications/StreamSplitter/app.config +++ b/Source/Applications/StreamSplitter/app.config @@ -1,39 +1,66 @@ - + -
+
- + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - + + + + + + + + - + - + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/Applications/StreamSplitter/packages.config b/Source/Applications/StreamSplitter/packages.config new file mode 100644 index 0000000..c2a6fca --- /dev/null +++ b/Source/Applications/StreamSplitter/packages.config @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Source/Applications/StreamSplitterManager/StreamSplitterManager.cs b/Source/Applications/StreamSplitterManager/StreamSplitterManager.cs index 8ac0a7d..bb8b6c7 100755 --- a/Source/Applications/StreamSplitterManager/StreamSplitterManager.cs +++ b/Source/Applications/StreamSplitterManager/StreamSplitterManager.cs @@ -18,6 +18,9 @@ // ---------------------------------------------------------------------------------------------------- // 09/03/2013 - J. Ritchie Carroll // Generated original version of source code. +// 06/05/2026 - Marcos Vinicius Snak +// ApplyStreamProxyStatusUpdates: detect connections added externally (e.g. via REST API) +// and trigger automatic DownloadConfig so the Manager list stays in sync. // //****************************************************************************************************** @@ -72,6 +75,7 @@ public partial class StreamSplitterManager : Form // Constants private const int MaximumToolTipSize = 1500; + private const string ApiConfigChangedMarker = "[API_CONFIG_CHANGED]"; // Fields private string m_configurationFileName; @@ -813,6 +817,10 @@ private void m_serviceConnection_StatusMessage(object sender, EventArgs toolStripStatusLabelStatus.Text = e.Argument2.Replace(Environment.NewLine, " "))); UpdateToolTip(e.Argument2); + + // Auto-refresh configuration when the REST API signals a change. + if (e.Argument2.ToNonNullString().Contains(ApiConfigChangedMarker)) + m_serviceConnection?.SendCommand("DownloadConfig"); } private void m_refreshProxyStatusTimer_Elapsed(object sender, System.Timers.ElapsedEventArgs e) @@ -861,7 +869,7 @@ private void ApplyStreamProxyStatusUpdates(object state) { // Attempt to find associated proxy connection ProxyConnection proxyConnection; - + lock (m_proxyConnections) proxyConnection = m_proxyConnections.FirstOrDefault(connection => connection.ID == proxyStatus.ID); diff --git a/Source/Applications/StreamSplitterSetup/StreamSplitterSetup.wixproj b/Source/Applications/StreamSplitterSetup/StreamSplitterSetup.wixproj index 790feec..21af24c 100755 --- a/Source/Applications/StreamSplitterSetup/StreamSplitterSetup.wixproj +++ b/Source/Applications/StreamSplitterSetup/StreamSplitterSetup.wixproj @@ -8,8 +8,9 @@ 2.0 StreamSplitterSetup Package - $(MSBuildExtensionsPath32)\Microsoft\WiX\v3.x\Wix.targets - $(MSBuildExtensionsPath)\Microsoft\WiX\v3.x\Wix.targets + $(MSBuildExtensionsPath32)\Microsoft\WiX\v3.x\Wix.targets + $(MSBuildExtensionsPath)\Microsoft\WiX\v3.x\Wix.targets + C:\Program Files (x86)\MSBuild\Microsoft\WiX\v3.x\Wix.targets SAK SAK SAK diff --git a/Source/Applications/StreamSplitterSetup/StreamSplitterSetup.wxs b/Source/Applications/StreamSplitterSetup/StreamSplitterSetup.wxs index bfe2d17..56e5de9 100755 --- a/Source/Applications/StreamSplitterSetup/StreamSplitterSetup.wxs +++ b/Source/Applications/StreamSplitterSetup/StreamSplitterSetup.wxs @@ -29,6 +29,7 @@ + @@ -231,6 +232,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +