diff --git a/CLAUDE.md b/CLAUDE.md index 7c6fa53..f65d651 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,9 +109,9 @@ This project contains design documentation for a distributed SCADA system built ### Security & Auth - Authentication: direct LDAP bind (username/password), no Kerberos/NTLM. LDAPS/StartTLS required. -- JWT sessions: HMAC-SHA256 shared symmetric key, 15-minute expiry with sliding refresh, 30-minute idle timeout. +- Cookie+JWT hybrid sessions: HttpOnly/Secure cookie carries an embedded JWT (HMAC-SHA256 shared symmetric key), 15-minute expiry with sliding refresh, 30-minute idle timeout. Cookies are the correct transport for Blazor Server (SignalR circuits). - LDAP failure: new logins fail; active sessions continue with current roles. -- Load balancer in front of central UI; JWT + shared Data Protection keys for failover transparency. +- Load balancer in front of central UI; cookie-embedded JWT + shared Data Protection keys for failover transparency. ### Cluster & Failover - Keep-oldest split-brain resolver with `down-if-alone = on`, 15s stable-after. @@ -123,7 +123,7 @@ This project contains design documentation for a distributed SCADA system built ### UI & Monitoring - Central UI: Blazor Server (ASP.NET Core + SignalR) with Bootstrap CSS. No third-party component frameworks (no Blazorise, MudBlazor, Radzen, etc.). Build custom Blazor components for tables, grids, forms, etc. - UI design: Clean, corporate, internal-use aesthetic. Not flashy. Use the `frontend-design` skill when designing UI pages/components. -- Real-time push for debug view, health dashboard, deployment status. +- Debug view: 2s polling timer. Health dashboard: 10s polling timer. Deployment status: real-time push via SignalR. - Health reports: 30s interval, 60s offline threshold, monotonic sequence numbers, raw error counts per interval. - Dead letter monitoring as a health metric. - Site Event Logging: 30-day retention, 1GB storage cap, daily purge, paginated queries with keyword search. @@ -149,3 +149,12 @@ This project contains design documentation for a distributed SCADA system built - When consulting with the Codex MCP tool, use model `gpt-5.4`. - When a task requires setting up or controlling system state (sites, templates, instances, data connections, deployments, security, etc.) and the Central UI is not needed, prefer the ScadaLink CLI over manual DB edits or UI navigation. See [`src/ScadaLink.CLI/README.md`](src/ScadaLink.CLI/README.md) for the full command reference. + +### CLI Quick Reference (Docker / OrbStack) + +- **Contact point**: `akka.tcp://scadalink@scadalink-central-a:8081` — the hostname must match the container's Akka `NodeHostname` config. Do NOT use `localhost:9011`; Akka remoting requires the hostname in the URI to match what the node advertises. +- **Test user**: `--username multi-role --password password` — has Admin, Design, and Deployment roles. The `admin` user only has the Admin role and cannot create templates, data connections, or deploy. +- **Config file**: `~/.scadalink/config.json` — stores contact points, LDAP settings (including `searchBase`, `serviceAccountDn`, `serviceAccountPassword`), and default format. See `docker/README.md` for a ready-to-use test config. +- **Rebuild cluster**: `bash docker/deploy.sh` — builds the `scadalink:latest` image and recreates all containers. Run this after code changes to ManagementActor, Host, or any server-side component. +- **Infrastructure services**: `cd infra && docker compose up -d` — starts LDAP, MS SQL, OPC UA, SMTP, REST API, and LmxFakeProxy. These are separate from the cluster containers in `docker/`. +- **All test LDAP passwords**: `password` (see `infra/glauth/config.toml` for users and groups). diff --git a/Component-CentralUI.md b/Component-CentralUI.md index 081ddde..159dfe7 100644 --- a/Component-CentralUI.md +++ b/Component-CentralUI.md @@ -18,19 +18,15 @@ Central cluster only. Sites have no user interface. - A **load balancer** sits in front of the central cluster and routes to the active node. - On central failover, the Blazor Server SignalR circuit is interrupted. The browser automatically attempts to reconnect via SignalR's built-in reconnection logic. -- Since sessions use **JWT tokens** (not server-side state), the user's authentication survives failover — the new active node validates the same JWT. No re-login required if the token is still valid. -- Active debug view streams and in-progress real-time subscriptions are lost on failover and must be re-opened by the user. +- Since sessions use **authentication cookies** carrying an embedded JWT (not server-side state), the user's authentication survives failover — the new active node validates the same cookie-embedded JWT. No re-login required if the token is still valid. +- Active debug view polling and in-progress deployment status subscriptions are lost on failover and must be re-opened by the user. - Both central nodes share the same **ASP.NET Data Protection keys** (stored in the configuration database or shared configuration) so that tokens and anti-forgery tokens remain valid across failover. ## Real-Time Updates -All real-time features use **server push via SignalR** (built into Blazor Server): - -- **Debug view**: Attribute value and alarm state changes streamed live from sites. -- **Health dashboard**: Site status, connection health, error rates, and buffer depths update automatically when new health reports arrive. -- **Deployment status**: Pending/in-progress/success/failed transitions push to the UI immediately. - -No manual refresh or polling is required for any of these features. +- **Debug view**: Near-real-time display of attribute values and alarm states, updated via a **2-second polling timer**. This avoids the complexity of cross-cluster streaming while providing responsive feedback — 2s latency is imperceptible for debugging purposes. +- **Health dashboard**: Site status, connection health, error rates, and buffer depths update via a **10-second auto-refresh timer**. Since health reports arrive from sites every 30 seconds, a 10s poll interval catches updates within one reporting cycle without unnecessary overhead. +- **Deployment status**: Pending/in-progress/success/failed transitions **push to the UI immediately** via SignalR (built into Blazor Server). No polling required for deployment tracking. ## Responsibilities @@ -104,8 +100,8 @@ No manual refresh or polling is required for any of these features. ### Debug View (Deployment Role) - Select a deployed instance and open a live debug view. -- Real-time streaming of all attribute values (with quality and timestamp) and alarm states for that instance. -- Initial snapshot of current state followed by streaming updates via the site-wide Akka stream. +- Near-real-time polling (2s interval) of all attribute values (with quality and timestamp) and alarm states for that instance. +- Initial snapshot of current state followed by periodic polling for updates. - Stream includes attribute values formatted as `[InstanceUniqueName].[AttributePath].[AttributeName]` and alarm states formatted as `[InstanceUniqueName].[AlarmName]`. - Subscribe-on-demand — stream starts when opened, stops when closed. diff --git a/Component-ManagementService.md b/Component-ManagementService.md index c40ef0e..0a8521b 100644 --- a/Component-ManagementService.md +++ b/Component-ManagementService.md @@ -6,7 +6,7 @@ The Management Service is an Akka.NET actor on the central cluster that provides ## Location -Central cluster only (active node). The ManagementActor runs as a cluster singleton on the central cluster. +Central cluster only. The ManagementActor runs as a plain actor on **every** central node (not a cluster singleton). Because the actor is completely stateless — it holds no locks and no local state, delegating all work to repositories and services — running on all nodes improves availability without requiring coordination between instances. Either node can serve any request independently. `src/ScadaLink.ManagementService/` @@ -17,7 +17,7 @@ Central cluster only (active node). The ManagementActor runs as a cluster single - Validate and authorize all incoming commands using the authenticated user identity carried in message envelopes. - Delegate to the appropriate services and repositories for each operation. - Return structured response messages for all commands and queries. -- Failover: The ManagementActor is available on the active central node and fails over with it. ClusterClient handles reconnection transparently. +- Failover: The ManagementActor runs on all central nodes, so no actor-level failover is needed. If one node goes down, the ClusterClient transparently routes to the ManagementActor on the remaining node. ## Key Classes @@ -168,7 +168,7 @@ The ManagementActor receives the following services and repositories via DI (inj - **Configuration Database (via IAuditService)**: All mutating operations are audit logged through the existing transactional audit mechanism. - **Communication Layer**: Deployment commands and remote queries (parked messages, event logs) are routed to sites via Communication. - **Security & Auth**: Authorization rules are enforced on every command using the authenticated user identity from the message envelope. -- **Cluster Infrastructure**: ManagementActor runs on the active central node; ClusterClientReceptionist requires cluster membership. +- **Cluster Infrastructure**: ManagementActor runs on all central nodes; ClusterClientReceptionist requires cluster membership. - **All service components**: The ManagementActor delegates to the same services used by the Central UI — Template Engine, Deployment Manager, etc. ## Interactions diff --git a/Component-Security.md b/Component-Security.md index be4f872..0e137ef 100644 --- a/Component-Security.md +++ b/Component-Security.md @@ -24,17 +24,21 @@ Central cluster. Sites do not have user-facing interfaces and do not perform ind ## Session Management -### JWT Tokens +### Cookie + JWT Hybrid - On successful authentication, the app issues a **JWT** signed with a shared symmetric key (HMAC-SHA256). Both central cluster nodes use the same signing key from configuration, so either node can issue and validate tokens. -- **JWT claims**: User display name, username, list of roles (Admin, Design, Deployment), and for site-scoped Deployment, the list of permitted site IDs. All authorization decisions are made from token claims without hitting the database. +- The JWT is embedded in an **authentication cookie** rather than being passed as a bearer token. This is the correct transport for Blazor Server, where persistent SignalR circuits do not carry Authorization headers — the browser automatically sends the cookie with every SignalR connection and HTTP request. +- The cookie is **HttpOnly** and **Secure** (requires HTTPS). +- On each request, the server extracts and validates the JWT from the cookie. All authorization decisions are made from the JWT claims without hitting the database. +- **JWT claims**: User display name, username, list of roles (Admin, Design, Deployment), and for site-scoped Deployment, the list of permitted site IDs. ### Token Lifecycle -- **JWT expiry**: 15 minutes. On each request, if the token is near expiry, the app re-queries LDAP for current group memberships and issues a fresh token with updated claims. Roles are never more than 15 minutes stale. +- **JWT expiry**: 15 minutes. On each request, if the cookie-embedded JWT is near expiry, the app re-queries LDAP for current group memberships and issues a fresh JWT, writing an updated cookie. Roles are never more than 15 minutes stale. - **Idle timeout**: Configurable, default **30 minutes**. If no requests are made within the idle window, the token is not refreshed and the user must re-login. Tracked via a last-activity timestamp in the token. - **Sliding refresh**: Active users stay logged in indefinitely — the token refreshes every 15 minutes as long as requests are made within the 30-minute idle window. ### Load Balancer Compatibility -- JWT tokens are self-contained — no server-side session state. A load balancer in front of the central cluster can route requests to either node without sticky sessions or a shared session store. Central failover is transparent to users with valid tokens. +- The authentication cookie carries a self-contained JWT — no server-side session state. A load balancer in front of the central cluster can route requests to either node without sticky sessions or a shared session store. +- Since both central nodes share the same JWT signing key, either node can validate the cookie-embedded JWT. Central failover is transparent to users with valid cookies. ## LDAP Connection Failure diff --git a/docker/README.md b/docker/README.md index a78a25a..1d998bc 100644 --- a/docker/README.md +++ b/docker/README.md @@ -185,12 +185,38 @@ curl -s http://localhost:9002/health/ready | python3 -m json.tool ### CLI Access -Connect the ScadaLink CLI to the central cluster via host-mapped Akka remoting ports: +Connect the ScadaLink CLI to the central cluster. With OrbStack, the contact point hostname must match the container's Akka `NodeHostname` config, so use the container name directly (OrbStack resolves container names via DNS): ```bash dotnet run --project src/ScadaLink.CLI -- \ - --contact-points akka.tcp://scadalink@localhost:9011 \ - --username admin --password password \ + --contact-points akka.tcp://scadalink@scadalink-central-a:8081 \ + --username multi-role --password password \ + template list +``` + +> **Note:** The `multi-role` test user has Admin, Design, and Deployment roles. The `admin` user only has the Admin role and cannot perform design or deployment operations. See `infra/glauth/config.toml` for all test users and their group memberships. + +A recommended `~/.scadalink/config.json` for the Docker test environment: + +```json +{ + "contactPoints": ["akka.tcp://scadalink@scadalink-central-a:8081"], + "ldap": { + "server": "localhost", + "port": 3893, + "useTls": false, + "searchBase": "dc=scadalink,dc=local", + "serviceAccountDn": "cn=admin,dc=scadalink,dc=local", + "serviceAccountPassword": "password" + } +} +``` + +With this config file in place, the contact points and LDAP settings are automatic: + +```bash +dotnet run --project src/ScadaLink.CLI -- \ + --username multi-role --password password \ template list ``` diff --git a/infra/lmxfakeproxy/Dockerfile b/infra/lmxfakeproxy/Dockerfile index eb74716..47139b3 100644 --- a/infra/lmxfakeproxy/Dockerfile +++ b/infra/lmxfakeproxy/Dockerfile @@ -1,4 +1,5 @@ -FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +# Build stage forced to amd64: Grpc.Tools protoc crashes on linux/arm64 (Apple Silicon) +FROM --platform=linux/amd64 mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src COPY LmxFakeProxy.csproj . RUN dotnet restore diff --git a/infra/lmxfakeproxy/TagMapper.cs b/infra/lmxfakeproxy/TagMapper.cs index a41abf9..340c9b5 100644 --- a/infra/lmxfakeproxy/TagMapper.cs +++ b/infra/lmxfakeproxy/TagMapper.cs @@ -1,3 +1,5 @@ +using System.Collections; +using System.Text.Json; using LmxFakeProxy.Grpc; namespace LmxFakeProxy; @@ -30,12 +32,20 @@ public class TagMapper return "Uncertain"; } + public static string FormatValue(object? value) + { + if (value is null) return string.Empty; + if (value is Array or IList) + return JsonSerializer.Serialize(value); + return value.ToString() ?? string.Empty; + } + public static VtqMessage ToVtqMessage(string tag, object? value, DateTime timestampUtc, uint statusCode) { return new VtqMessage { Tag = tag, - Value = value?.ToString() ?? string.Empty, + Value = FormatValue(value), TimestampUtcTicks = timestampUtc.Ticks, Quality = MapQuality(statusCode) }; diff --git a/infra/opcua/nodes.json b/infra/opcua/nodes.json index fca0733..185cdfb 100644 --- a/infra/opcua/nodes.json +++ b/infra/opcua/nodes.json @@ -133,6 +133,43 @@ "Description": "Valve command (0=Close, 1=Open, 2=Stop)" } ] + }, + { + "Folder": "JoeAppEngine", + "NodeList": [ + { + "NodeId": "JoeAppEngine.BTCS", + "Name": "BTCS", + "DataType": "String", + "ValueRank": -1, + "AccessLevel": "CurrentReadOrWrite", + "Description": "BTCS string value" + }, + { + "NodeId": "JoeAppEngine.AlarmCntsBySeverity", + "Name": "AlarmCntsBySeverity", + "DataType": "Int32", + "ValueRank": 1, + "ArrayDimensions": [13], + "AccessLevel": "CurrentReadOrWrite", + "Description": "13-element alarm counts by severity level" + } + ], + "FolderList": [ + { + "Folder": "Scheduler", + "NodeList": [ + { + "NodeId": "JoeAppEngine.Scheduler.ScanTime", + "Name": "ScanTime", + "DataType": "DateTime", + "ValueRank": -1, + "AccessLevel": "CurrentReadOrWrite", + "Description": "Current scan time (updates every second)" + } + ] + } + ] } ] } diff --git a/src/ScadaLink.CLI/CliConfig.cs b/src/ScadaLink.CLI/CliConfig.cs index 22bf13b..d0f7f9d 100644 --- a/src/ScadaLink.CLI/CliConfig.cs +++ b/src/ScadaLink.CLI/CliConfig.cs @@ -8,6 +8,9 @@ public class CliConfig public string? LdapServer { get; set; } public int LdapPort { get; set; } = 636; public bool LdapUseTls { get; set; } = true; + public string LdapSearchBase { get; set; } = string.Empty; + public string LdapServiceAccountDn { get; set; } = string.Empty; + public string LdapServiceAccountPassword { get; set; } = string.Empty; public string DefaultFormat { get; set; } = "json"; public static CliConfig Load() @@ -31,6 +34,12 @@ public class CliConfig config.LdapServer = fileConfig.Ldap.Server; config.LdapPort = fileConfig.Ldap.Port; config.LdapUseTls = fileConfig.Ldap.UseTls; + if (!string.IsNullOrEmpty(fileConfig.Ldap.SearchBase)) + config.LdapSearchBase = fileConfig.Ldap.SearchBase; + if (!string.IsNullOrEmpty(fileConfig.Ldap.ServiceAccountDn)) + config.LdapServiceAccountDn = fileConfig.Ldap.ServiceAccountDn; + if (!string.IsNullOrEmpty(fileConfig.Ldap.ServiceAccountPassword)) + config.LdapServiceAccountPassword = fileConfig.Ldap.ServiceAccountPassword; } if (!string.IsNullOrEmpty(fileConfig.DefaultFormat)) config.DefaultFormat = fileConfig.DefaultFormat; } @@ -62,5 +71,8 @@ public class CliConfig public string? Server { get; set; } public int Port { get; set; } = 636; public bool UseTls { get; set; } = true; + public string? SearchBase { get; set; } + public string? ServiceAccountDn { get; set; } + public string? ServiceAccountPassword { get; set; } } } diff --git a/src/ScadaLink.CLI/Commands/ApiMethodCommands.cs b/src/ScadaLink.CLI/Commands/ApiMethodCommands.cs index b6860c2..18eccdf 100644 --- a/src/ScadaLink.CLI/Commands/ApiMethodCommands.cs +++ b/src/ScadaLink.CLI/Commands/ApiMethodCommands.cs @@ -6,31 +6,31 @@ namespace ScadaLink.CLI.Commands; public static class ApiMethodCommands { - public static Command Build(Option contactPointsOption, Option formatOption) + public static Command Build(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var command = new Command("api-method") { Description = "Manage inbound API methods" }; - command.Add(BuildList(contactPointsOption, formatOption)); - command.Add(BuildGet(contactPointsOption, formatOption)); - command.Add(BuildCreate(contactPointsOption, formatOption)); - command.Add(BuildUpdate(contactPointsOption, formatOption)); - command.Add(BuildDelete(contactPointsOption, formatOption)); + command.Add(BuildList(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildGet(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildCreate(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildUpdate(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildDelete(contactPointsOption, formatOption, usernameOption, passwordOption)); return command; } - private static Command BuildList(Option contactPointsOption, Option formatOption) + private static Command BuildList(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var cmd = new Command("list") { Description = "List all API methods" }; cmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new ListApiMethodsCommand()); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new ListApiMethodsCommand()); }); return cmd; } - private static Command BuildGet(Option contactPointsOption, Option formatOption) + private static Command BuildGet(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "API method ID", Required = true }; var cmd = new Command("get") { Description = "Get an API method by ID" }; @@ -39,12 +39,12 @@ public static class ApiMethodCommands { var id = result.GetValue(idOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new GetApiMethodCommand(id)); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new GetApiMethodCommand(id)); }); return cmd; } - private static Command BuildCreate(Option contactPointsOption, Option formatOption) + private static Command BuildCreate(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var nameOption = new Option("--name") { Description = "Method name", Required = true }; var scriptOption = new Option("--script") { Description = "Script code", Required = true }; @@ -67,13 +67,13 @@ public static class ApiMethodCommands var parameters = result.GetValue(parametersOption); var returnDef = result.GetValue(returnDefOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new CreateApiMethodCommand(name, script, timeout, parameters, returnDef)); }); return cmd; } - private static Command BuildUpdate(Option contactPointsOption, Option formatOption) + private static Command BuildUpdate(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "API method ID", Required = true }; var scriptOption = new Option("--script") { Description = "Script code", Required = true }; @@ -96,13 +96,13 @@ public static class ApiMethodCommands var parameters = result.GetValue(parametersOption); var returnDef = result.GetValue(returnDefOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new UpdateApiMethodCommand(id, script, timeout, parameters, returnDef)); }); return cmd; } - private static Command BuildDelete(Option contactPointsOption, Option formatOption) + private static Command BuildDelete(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "API method ID", Required = true }; var cmd = new Command("delete") { Description = "Delete an API method" }; @@ -111,7 +111,7 @@ public static class ApiMethodCommands { var id = result.GetValue(idOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new DeleteApiMethodCommand(id)); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new DeleteApiMethodCommand(id)); }); return cmd; } diff --git a/src/ScadaLink.CLI/Commands/AuditLogCommands.cs b/src/ScadaLink.CLI/Commands/AuditLogCommands.cs index c09b91d..78d4437 100644 --- a/src/ScadaLink.CLI/Commands/AuditLogCommands.cs +++ b/src/ScadaLink.CLI/Commands/AuditLogCommands.cs @@ -6,16 +6,16 @@ namespace ScadaLink.CLI.Commands; public static class AuditLogCommands { - public static Command Build(Option contactPointsOption, Option formatOption) + public static Command Build(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var command = new Command("audit-log") { Description = "Query audit logs" }; - command.Add(BuildQuery(contactPointsOption, formatOption)); + command.Add(BuildQuery(contactPointsOption, formatOption, usernameOption, passwordOption)); return command; } - private static Command BuildQuery(Option contactPointsOption, Option formatOption) + private static Command BuildQuery(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var userOption = new Option("--user") { Description = "Filter by username" }; var entityTypeOption = new Option("--entity-type") { Description = "Filter by entity type" }; @@ -45,7 +45,7 @@ public static class AuditLogCommands var page = result.GetValue(pageOption); var pageSize = result.GetValue(pageSizeOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new QueryAuditLogCommand(user, entityType, action, from, to, page, pageSize)); }); return cmd; diff --git a/src/ScadaLink.CLI/Commands/CommandHelpers.cs b/src/ScadaLink.CLI/Commands/CommandHelpers.cs index add7c2a..1da0676 100644 --- a/src/ScadaLink.CLI/Commands/CommandHelpers.cs +++ b/src/ScadaLink.CLI/Commands/CommandHelpers.cs @@ -1,27 +1,33 @@ using System.CommandLine; using System.CommandLine.Parsing; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; using ScadaLink.Commons.Messages.Management; +using ScadaLink.Security; namespace ScadaLink.CLI.Commands; internal static class CommandHelpers { - internal static AuthenticatedUser PlaceholderUser { get; } = - new("cli-user", "CLI User", ["Admin", "Design", "Deployment"], Array.Empty()); - internal static string NewCorrelationId() => Guid.NewGuid().ToString("N"); internal static async Task ExecuteCommandAsync( ParseResult result, Option contactPointsOption, Option formatOption, + Option usernameOption, + Option passwordOption, object command) { var contactPointsRaw = result.GetValue(contactPointsOption); + var format = result.GetValue(formatOption) ?? "json"; + + var config = CliConfig.Load(); if (string.IsNullOrWhiteSpace(contactPointsRaw)) { - var config = CliConfig.Load(); if (config.ContactPoints.Count > 0) contactPointsRaw = string.Join(",", config.ContactPoints); } @@ -34,21 +40,97 @@ internal static class CommandHelpers var contactPoints = contactPointsRaw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + // Authenticate via LDAP + var username = result.GetValue(usernameOption); + var password = result.GetValue(passwordOption); + + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) + { + OutputFormatter.WriteError( + "Credentials required. Use --username and --password options.", + "NO_CREDENTIALS"); + return 1; + } + + // Authenticate against LDAP + var securityOptions = new SecurityOptions + { + LdapServer = config.LdapServer ?? string.Empty, + LdapPort = config.LdapPort, + LdapUseTls = config.LdapUseTls, + AllowInsecureLdap = !config.LdapUseTls, + LdapSearchBase = config.LdapSearchBase, + LdapServiceAccountDn = config.LdapServiceAccountDn, + LdapServiceAccountPassword = config.LdapServiceAccountPassword + }; + + var ldapAuth = new LdapAuthService( + Options.Create(securityOptions), + NullLogger.Instance); + + var authResult = await ldapAuth.AuthenticateAsync(username, password); + + if (!authResult.Success) + { + OutputFormatter.WriteError( + authResult.ErrorMessage ?? "Authentication failed.", + "AUTH_FAILED"); + return 1; + } + await using var connection = new ClusterConnection(); await connection.ConnectAsync(contactPoints, TimeSpan.FromSeconds(10)); - var envelope = new ManagementEnvelope(PlaceholderUser, command, NewCorrelationId()); + // Resolve roles server-side + var resolveEnvelope = new ManagementEnvelope( + new AuthenticatedUser(authResult.Username!, authResult.DisplayName!, Array.Empty(), Array.Empty()), + new ResolveRolesCommand(authResult.Groups ?? (IReadOnlyList)Array.Empty()), + NewCorrelationId()); + var resolveResponse = await connection.AskManagementAsync(resolveEnvelope, TimeSpan.FromSeconds(30)); + + string[] roles; + string[] permittedSiteIds; + + if (resolveResponse is ManagementSuccess resolveSuccess) + { + var rolesDoc = JsonDocument.Parse(resolveSuccess.JsonData); + roles = rolesDoc.RootElement.TryGetProperty("Roles", out var rolesEl) + ? rolesEl.EnumerateArray().Select(e => e.GetString()!).ToArray() + : Array.Empty(); + permittedSiteIds = rolesDoc.RootElement.TryGetProperty("PermittedSiteIds", out var sitesEl) + ? sitesEl.EnumerateArray().Select(e => e.GetString()!).ToArray() + : Array.Empty(); + } + else + { + return HandleResponse(resolveResponse, format); + } + + var authenticatedUser = new AuthenticatedUser( + authResult.Username!, + authResult.DisplayName!, + roles, + permittedSiteIds); + + var envelope = new ManagementEnvelope(authenticatedUser, command, NewCorrelationId()); var response = await connection.AskManagementAsync(envelope, TimeSpan.FromSeconds(30)); - return HandleResponse(response); + return HandleResponse(response, format); } - internal static int HandleResponse(object response) + internal static int HandleResponse(object response, string format) { switch (response) { case ManagementSuccess success: - Console.WriteLine(success.JsonData); + if (string.Equals(format, "table", StringComparison.OrdinalIgnoreCase)) + { + WriteAsTable(success.JsonData); + } + else + { + Console.WriteLine(success.JsonData); + } return 0; case ManagementError error: @@ -64,4 +146,51 @@ internal static class CommandHelpers return 1; } } + + private static void WriteAsTable(string json) + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + if (root.ValueKind == JsonValueKind.Array) + { + var items = root.EnumerateArray().ToList(); + if (items.Count == 0) + { + Console.WriteLine("(no results)"); + return; + } + + // Extract headers from first object's property names + var headers = items[0].ValueKind == JsonValueKind.Object + ? items[0].EnumerateObject().Select(p => p.Name).ToArray() + : new[] { "Value" }; + + var rows = items.Select(item => + { + if (item.ValueKind == JsonValueKind.Object) + { + return headers.Select(h => + item.TryGetProperty(h, out var val) + ? val.ValueKind == JsonValueKind.Null ? "" : val.ToString() + : "").ToArray(); + } + return new[] { item.ToString() }; + }); + + OutputFormatter.WriteTable(rows, headers); + } + else if (root.ValueKind == JsonValueKind.Object) + { + // Single object: render as key-value pairs + var headers = new[] { "Property", "Value" }; + var rows = root.EnumerateObject().Select(p => + new[] { p.Name, p.Value.ValueKind == JsonValueKind.Null ? "" : p.Value.ToString() }); + OutputFormatter.WriteTable(rows, headers); + } + else + { + Console.WriteLine(root.ToString()); + } + } } diff --git a/src/ScadaLink.CLI/Commands/DataConnectionCommands.cs b/src/ScadaLink.CLI/Commands/DataConnectionCommands.cs index 3a55476..e2c58ba 100644 --- a/src/ScadaLink.CLI/Commands/DataConnectionCommands.cs +++ b/src/ScadaLink.CLI/Commands/DataConnectionCommands.cs @@ -6,22 +6,22 @@ namespace ScadaLink.CLI.Commands; public static class DataConnectionCommands { - public static Command Build(Option contactPointsOption, Option formatOption) + public static Command Build(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var command = new Command("data-connection") { Description = "Manage data connections" }; - command.Add(BuildList(contactPointsOption, formatOption)); - command.Add(BuildGet(contactPointsOption, formatOption)); - command.Add(BuildCreate(contactPointsOption, formatOption)); - command.Add(BuildUpdate(contactPointsOption, formatOption)); - command.Add(BuildDelete(contactPointsOption, formatOption)); - command.Add(BuildAssign(contactPointsOption, formatOption)); - command.Add(BuildUnassign(contactPointsOption, formatOption)); + command.Add(BuildList(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildGet(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildCreate(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildUpdate(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildDelete(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildAssign(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildUnassign(contactPointsOption, formatOption, usernameOption, passwordOption)); return command; } - private static Command BuildGet(Option contactPointsOption, Option formatOption) + private static Command BuildGet(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Data connection ID", Required = true }; var cmd = new Command("get") { Description = "Get a data connection by ID" }; @@ -30,12 +30,12 @@ public static class DataConnectionCommands { var id = result.GetValue(idOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new GetDataConnectionCommand(id)); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new GetDataConnectionCommand(id)); }); return cmd; } - private static Command BuildUpdate(Option contactPointsOption, Option formatOption) + private static Command BuildUpdate(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Data connection ID", Required = true }; var nameOption = new Option("--name") { Description = "Connection name", Required = true }; @@ -54,13 +54,13 @@ public static class DataConnectionCommands var protocol = result.GetValue(protocolOption)!; var config = result.GetValue(configOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new UpdateDataConnectionCommand(id, name, protocol, config)); }); return cmd; } - private static Command BuildUnassign(Option contactPointsOption, Option formatOption) + private static Command BuildUnassign(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--assignment-id") { Description = "Assignment ID", Required = true }; var cmd = new Command("unassign") { Description = "Unassign a data connection from a site" }; @@ -69,23 +69,23 @@ public static class DataConnectionCommands { var id = result.GetValue(idOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new UnassignDataConnectionFromSiteCommand(id)); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new UnassignDataConnectionFromSiteCommand(id)); }); return cmd; } - private static Command BuildList(Option contactPointsOption, Option formatOption) + private static Command BuildList(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var cmd = new Command("list") { Description = "List all data connections" }; cmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new ListDataConnectionsCommand()); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new ListDataConnectionsCommand()); }); return cmd; } - private static Command BuildCreate(Option contactPointsOption, Option formatOption) + private static Command BuildCreate(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var nameOption = new Option("--name") { Description = "Connection name", Required = true }; var protocolOption = new Option("--protocol") { Description = "Protocol (e.g. OpcUa)", Required = true }; @@ -101,13 +101,13 @@ public static class DataConnectionCommands var protocol = result.GetValue(protocolOption)!; var config = result.GetValue(configOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new CreateDataConnectionCommand(name, protocol, config)); }); return cmd; } - private static Command BuildDelete(Option contactPointsOption, Option formatOption) + private static Command BuildDelete(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Data connection ID", Required = true }; var cmd = new Command("delete") { Description = "Delete a data connection" }; @@ -116,12 +116,12 @@ public static class DataConnectionCommands { var id = result.GetValue(idOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new DeleteDataConnectionCommand(id)); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new DeleteDataConnectionCommand(id)); }); return cmd; } - private static Command BuildAssign(Option contactPointsOption, Option formatOption) + private static Command BuildAssign(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var connectionIdOption = new Option("--connection-id") { Description = "Data connection ID", Required = true }; var siteIdOption = new Option("--site-id") { Description = "Site ID", Required = true }; @@ -134,7 +134,7 @@ public static class DataConnectionCommands var connectionId = result.GetValue(connectionIdOption); var siteId = result.GetValue(siteIdOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new AssignDataConnectionToSiteCommand(connectionId, siteId)); }); return cmd; diff --git a/src/ScadaLink.CLI/Commands/DbConnectionCommands.cs b/src/ScadaLink.CLI/Commands/DbConnectionCommands.cs index 27b5400..a606ec7 100644 --- a/src/ScadaLink.CLI/Commands/DbConnectionCommands.cs +++ b/src/ScadaLink.CLI/Commands/DbConnectionCommands.cs @@ -6,31 +6,31 @@ namespace ScadaLink.CLI.Commands; public static class DbConnectionCommands { - public static Command Build(Option contactPointsOption, Option formatOption) + public static Command Build(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var command = new Command("db-connection") { Description = "Manage database connections" }; - command.Add(BuildList(contactPointsOption, formatOption)); - command.Add(BuildGet(contactPointsOption, formatOption)); - command.Add(BuildCreate(contactPointsOption, formatOption)); - command.Add(BuildUpdate(contactPointsOption, formatOption)); - command.Add(BuildDelete(contactPointsOption, formatOption)); + command.Add(BuildList(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildGet(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildCreate(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildUpdate(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildDelete(contactPointsOption, formatOption, usernameOption, passwordOption)); return command; } - private static Command BuildList(Option contactPointsOption, Option formatOption) + private static Command BuildList(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var cmd = new Command("list") { Description = "List all database connections" }; cmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new ListDatabaseConnectionsCommand()); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new ListDatabaseConnectionsCommand()); }); return cmd; } - private static Command BuildGet(Option contactPointsOption, Option formatOption) + private static Command BuildGet(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Database connection ID", Required = true }; var cmd = new Command("get") { Description = "Get a database connection by ID" }; @@ -39,12 +39,12 @@ public static class DbConnectionCommands { var id = result.GetValue(idOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new GetDatabaseConnectionCommand(id)); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new GetDatabaseConnectionCommand(id)); }); return cmd; } - private static Command BuildCreate(Option contactPointsOption, Option formatOption) + private static Command BuildCreate(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var nameOption = new Option("--name") { Description = "Connection name", Required = true }; var connStrOption = new Option("--connection-string") { Description = "Connection string", Required = true }; @@ -57,13 +57,13 @@ public static class DbConnectionCommands var name = result.GetValue(nameOption)!; var connStr = result.GetValue(connStrOption)!; return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new CreateDatabaseConnectionDefCommand(name, connStr)); }); return cmd; } - private static Command BuildUpdate(Option contactPointsOption, Option formatOption) + private static Command BuildUpdate(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Database connection ID", Required = true }; var nameOption = new Option("--name") { Description = "Connection name", Required = true }; @@ -79,13 +79,13 @@ public static class DbConnectionCommands var name = result.GetValue(nameOption)!; var connStr = result.GetValue(connStrOption)!; return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new UpdateDatabaseConnectionDefCommand(id, name, connStr)); }); return cmd; } - private static Command BuildDelete(Option contactPointsOption, Option formatOption) + private static Command BuildDelete(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Database connection ID", Required = true }; var cmd = new Command("delete") { Description = "Delete a database connection" }; @@ -94,7 +94,7 @@ public static class DbConnectionCommands { var id = result.GetValue(idOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new DeleteDatabaseConnectionDefCommand(id)); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new DeleteDatabaseConnectionDefCommand(id)); }); return cmd; } diff --git a/src/ScadaLink.CLI/Commands/DebugCommands.cs b/src/ScadaLink.CLI/Commands/DebugCommands.cs index f5b6287..bd906ae 100644 --- a/src/ScadaLink.CLI/Commands/DebugCommands.cs +++ b/src/ScadaLink.CLI/Commands/DebugCommands.cs @@ -6,16 +6,16 @@ namespace ScadaLink.CLI.Commands; public static class DebugCommands { - public static Command Build(Option contactPointsOption, Option formatOption) + public static Command Build(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var command = new Command("debug") { Description = "Runtime debugging" }; - command.Add(BuildSnapshot(contactPointsOption, formatOption)); + command.Add(BuildSnapshot(contactPointsOption, formatOption, usernameOption, passwordOption)); return command; } - private static Command BuildSnapshot(Option contactPointsOption, Option formatOption) + private static Command BuildSnapshot(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Instance ID", Required = true }; var cmd = new Command("snapshot") { Description = "Get a point-in-time snapshot of instance attribute values and alarm states" }; @@ -23,7 +23,7 @@ public static class DebugCommands cmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new DebugSnapshotCommand(result.GetValue(idOption))); }); return cmd; diff --git a/src/ScadaLink.CLI/Commands/DeployCommands.cs b/src/ScadaLink.CLI/Commands/DeployCommands.cs index 9a14838..51009ea 100644 --- a/src/ScadaLink.CLI/Commands/DeployCommands.cs +++ b/src/ScadaLink.CLI/Commands/DeployCommands.cs @@ -6,18 +6,18 @@ namespace ScadaLink.CLI.Commands; public static class DeployCommands { - public static Command Build(Option contactPointsOption, Option formatOption) + public static Command Build(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var command = new Command("deploy") { Description = "Deployment operations" }; - command.Add(BuildInstance(contactPointsOption, formatOption)); - command.Add(BuildArtifacts(contactPointsOption, formatOption)); - command.Add(BuildStatus(contactPointsOption, formatOption)); + command.Add(BuildInstance(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildArtifacts(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildStatus(contactPointsOption, formatOption, usernameOption, passwordOption)); return command; } - private static Command BuildInstance(Option contactPointsOption, Option formatOption) + private static Command BuildInstance(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Instance ID", Required = true }; var cmd = new Command("instance") { Description = "Deploy a single instance" }; @@ -26,12 +26,12 @@ public static class DeployCommands { var id = result.GetValue(idOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new MgmtDeployInstanceCommand(id)); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new MgmtDeployInstanceCommand(id)); }); return cmd; } - private static Command BuildArtifacts(Option contactPointsOption, Option formatOption) + private static Command BuildArtifacts(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var siteIdOption = new Option("--site-id") { Description = "Target site ID (all sites if omitted)" }; var cmd = new Command("artifacts") { Description = "Deploy artifacts to site(s)" }; @@ -40,12 +40,12 @@ public static class DeployCommands { var siteId = result.GetValue(siteIdOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new MgmtDeployArtifactsCommand(siteId)); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new MgmtDeployArtifactsCommand(siteId)); }); return cmd; } - private static Command BuildStatus(Option contactPointsOption, Option formatOption) + private static Command BuildStatus(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var instanceIdOption = new Option("--instance-id") { Description = "Filter by instance ID" }; var statusOption = new Option("--status") { Description = "Filter by status" }; @@ -66,7 +66,7 @@ public static class DeployCommands var page = result.GetValue(pageOption); var pageSize = result.GetValue(pageSizeOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new QueryDeploymentsCommand(instanceId, status, page, pageSize)); }); return cmd; diff --git a/src/ScadaLink.CLI/Commands/ExternalSystemCommands.cs b/src/ScadaLink.CLI/Commands/ExternalSystemCommands.cs index 45a75c8..c554aad 100644 --- a/src/ScadaLink.CLI/Commands/ExternalSystemCommands.cs +++ b/src/ScadaLink.CLI/Commands/ExternalSystemCommands.cs @@ -6,21 +6,21 @@ namespace ScadaLink.CLI.Commands; public static class ExternalSystemCommands { - public static Command Build(Option contactPointsOption, Option formatOption) + public static Command Build(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var command = new Command("external-system") { Description = "Manage external systems" }; - command.Add(BuildList(contactPointsOption, formatOption)); - command.Add(BuildGet(contactPointsOption, formatOption)); - command.Add(BuildCreate(contactPointsOption, formatOption)); - command.Add(BuildUpdate(contactPointsOption, formatOption)); - command.Add(BuildDelete(contactPointsOption, formatOption)); - command.Add(BuildMethodGroup(contactPointsOption, formatOption)); + command.Add(BuildList(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildGet(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildCreate(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildUpdate(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildDelete(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildMethodGroup(contactPointsOption, formatOption, usernameOption, passwordOption)); return command; } - private static Command BuildGet(Option contactPointsOption, Option formatOption) + private static Command BuildGet(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "External system ID", Required = true }; var cmd = new Command("get") { Description = "Get an external system by ID" }; @@ -29,12 +29,12 @@ public static class ExternalSystemCommands { var id = result.GetValue(idOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new GetExternalSystemCommand(id)); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new GetExternalSystemCommand(id)); }); return cmd; } - private static Command BuildUpdate(Option contactPointsOption, Option formatOption) + private static Command BuildUpdate(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "External system ID", Required = true }; var nameOption = new Option("--name") { Description = "System name", Required = true }; @@ -56,24 +56,24 @@ public static class ExternalSystemCommands var authType = result.GetValue(authTypeOption)!; var authConfig = result.GetValue(authConfigOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new UpdateExternalSystemCommand(id, name, url, authType, authConfig)); }); return cmd; } - private static Command BuildList(Option contactPointsOption, Option formatOption) + private static Command BuildList(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var cmd = new Command("list") { Description = "List all external systems" }; cmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new ListExternalSystemsCommand()); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new ListExternalSystemsCommand()); }); return cmd; } - private static Command BuildCreate(Option contactPointsOption, Option formatOption) + private static Command BuildCreate(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var nameOption = new Option("--name") { Description = "System name", Required = true }; var urlOption = new Option("--endpoint-url") { Description = "Endpoint URL", Required = true }; @@ -92,13 +92,13 @@ public static class ExternalSystemCommands var authType = result.GetValue(authTypeOption)!; var authConfig = result.GetValue(authConfigOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new CreateExternalSystemCommand(name, url, authType, authConfig)); }); return cmd; } - private static Command BuildDelete(Option contactPointsOption, Option formatOption) + private static Command BuildDelete(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "External system ID", Required = true }; var cmd = new Command("delete") { Description = "Delete an external system" }; @@ -107,25 +107,25 @@ public static class ExternalSystemCommands { var id = result.GetValue(idOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new DeleteExternalSystemCommand(id)); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new DeleteExternalSystemCommand(id)); }); return cmd; } - // ── Method subcommands ── + // -- Method subcommands -- - private static Command BuildMethodGroup(Option contactPointsOption, Option formatOption) + private static Command BuildMethodGroup(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var group = new Command("method") { Description = "Manage external system methods" }; - group.Add(BuildMethodList(contactPointsOption, formatOption)); - group.Add(BuildMethodGet(contactPointsOption, formatOption)); - group.Add(BuildMethodCreate(contactPointsOption, formatOption)); - group.Add(BuildMethodUpdate(contactPointsOption, formatOption)); - group.Add(BuildMethodDelete(contactPointsOption, formatOption)); + group.Add(BuildMethodList(contactPointsOption, formatOption, usernameOption, passwordOption)); + group.Add(BuildMethodGet(contactPointsOption, formatOption, usernameOption, passwordOption)); + group.Add(BuildMethodCreate(contactPointsOption, formatOption, usernameOption, passwordOption)); + group.Add(BuildMethodUpdate(contactPointsOption, formatOption, usernameOption, passwordOption)); + group.Add(BuildMethodDelete(contactPointsOption, formatOption, usernameOption, passwordOption)); return group; } - private static Command BuildMethodList(Option contactPointsOption, Option formatOption) + private static Command BuildMethodList(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var sysIdOption = new Option("--external-system-id") { Description = "External system ID", Required = true }; var cmd = new Command("list") { Description = "List methods for an external system" }; @@ -133,13 +133,13 @@ public static class ExternalSystemCommands cmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new ListExternalSystemMethodsCommand(result.GetValue(sysIdOption))); }); return cmd; } - private static Command BuildMethodGet(Option contactPointsOption, Option formatOption) + private static Command BuildMethodGet(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Method ID", Required = true }; var cmd = new Command("get") { Description = "Get an external system method by ID" }; @@ -147,13 +147,13 @@ public static class ExternalSystemCommands cmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new GetExternalSystemMethodCommand(result.GetValue(idOption))); }); return cmd; } - private static Command BuildMethodCreate(Option contactPointsOption, Option formatOption) + private static Command BuildMethodCreate(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var sysIdOption = new Option("--external-system-id") { Description = "External system ID", Required = true }; var nameOption = new Option("--name") { Description = "Method name", Required = true }; @@ -172,7 +172,7 @@ public static class ExternalSystemCommands cmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new CreateExternalSystemMethodCommand( result.GetValue(sysIdOption), result.GetValue(nameOption)!, @@ -184,7 +184,7 @@ public static class ExternalSystemCommands return cmd; } - private static Command BuildMethodUpdate(Option contactPointsOption, Option formatOption) + private static Command BuildMethodUpdate(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Method ID", Required = true }; var nameOption = new Option("--name") { Description = "Method name" }; @@ -203,7 +203,7 @@ public static class ExternalSystemCommands cmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new UpdateExternalSystemMethodCommand( result.GetValue(idOption), result.GetValue(nameOption), @@ -215,7 +215,7 @@ public static class ExternalSystemCommands return cmd; } - private static Command BuildMethodDelete(Option contactPointsOption, Option formatOption) + private static Command BuildMethodDelete(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Method ID", Required = true }; var cmd = new Command("delete") { Description = "Delete an external system method" }; @@ -223,7 +223,7 @@ public static class ExternalSystemCommands cmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new DeleteExternalSystemMethodCommand(result.GetValue(idOption))); }); return cmd; diff --git a/src/ScadaLink.CLI/Commands/HealthCommands.cs b/src/ScadaLink.CLI/Commands/HealthCommands.cs index 0ced68b..abb0ef4 100644 --- a/src/ScadaLink.CLI/Commands/HealthCommands.cs +++ b/src/ScadaLink.CLI/Commands/HealthCommands.cs @@ -6,30 +6,30 @@ namespace ScadaLink.CLI.Commands; public static class HealthCommands { - public static Command Build(Option contactPointsOption, Option formatOption) + public static Command Build(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var command = new Command("health") { Description = "Health monitoring" }; - command.Add(BuildSummary(contactPointsOption, formatOption)); - command.Add(BuildSite(contactPointsOption, formatOption)); - command.Add(BuildEventLog(contactPointsOption, formatOption)); - command.Add(BuildParkedMessages(contactPointsOption, formatOption)); + command.Add(BuildSummary(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildSite(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildEventLog(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildParkedMessages(contactPointsOption, formatOption, usernameOption, passwordOption)); return command; } - private static Command BuildSummary(Option contactPointsOption, Option formatOption) + private static Command BuildSummary(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var cmd = new Command("summary") { Description = "Get system health summary" }; cmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new GetHealthSummaryCommand()); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new GetHealthSummaryCommand()); }); return cmd; } - private static Command BuildSite(Option contactPointsOption, Option formatOption) + private static Command BuildSite(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var identifierOption = new Option("--identifier") { Description = "Site identifier", Required = true }; var cmd = new Command("site") { Description = "Get health for a specific site" }; @@ -38,12 +38,12 @@ public static class HealthCommands { var identifier = result.GetValue(identifierOption)!; return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new GetSiteHealthCommand(identifier)); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new GetSiteHealthCommand(identifier)); }); return cmd; } - private static Command BuildEventLog(Option contactPointsOption, Option formatOption) + private static Command BuildEventLog(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var siteOption = new Option("--site") { Description = "Site identifier", Required = true }; var eventTypeOption = new Option("--event-type") { Description = "Filter by event type" }; @@ -55,6 +55,7 @@ public static class HealthCommands pageOption.DefaultValueFactory = _ => 1; var pageSizeOption = new Option("--page-size") { Description = "Page size" }; pageSizeOption.DefaultValueFactory = _ => 50; + var instanceNameOption = new Option("--instance-name") { Description = "Filter by instance name" }; var cmd = new Command("event-log") { Description = "Query site event logs" }; cmd.Add(siteOption); @@ -65,10 +66,11 @@ public static class HealthCommands cmd.Add(toOption); cmd.Add(pageOption); cmd.Add(pageSizeOption); + cmd.Add(instanceNameOption); cmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new QueryEventLogsCommand( result.GetValue(siteOption)!, result.GetValue(eventTypeOption), @@ -77,12 +79,13 @@ public static class HealthCommands result.GetValue(fromOption), result.GetValue(toOption), result.GetValue(pageOption), - result.GetValue(pageSizeOption))); + result.GetValue(pageSizeOption), + result.GetValue(instanceNameOption))); }); return cmd; } - private static Command BuildParkedMessages(Option contactPointsOption, Option formatOption) + private static Command BuildParkedMessages(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var siteOption = new Option("--site") { Description = "Site identifier", Required = true }; var pageOption = new Option("--page") { Description = "Page number" }; @@ -97,7 +100,7 @@ public static class HealthCommands cmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new QueryParkedMessagesCommand( result.GetValue(siteOption)!, result.GetValue(pageOption), diff --git a/src/ScadaLink.CLI/Commands/InstanceCommands.cs b/src/ScadaLink.CLI/Commands/InstanceCommands.cs index 42b0c8c..1ec2d02 100644 --- a/src/ScadaLink.CLI/Commands/InstanceCommands.cs +++ b/src/ScadaLink.CLI/Commands/InstanceCommands.cs @@ -6,23 +6,26 @@ namespace ScadaLink.CLI.Commands; public static class InstanceCommands { - public static Command Build(Option contactPointsOption, Option formatOption) + public static Command Build(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var command = new Command("instance") { Description = "Manage instances" }; - command.Add(BuildList(contactPointsOption, formatOption)); - command.Add(BuildGet(contactPointsOption, formatOption)); - command.Add(BuildCreate(contactPointsOption, formatOption)); - command.Add(BuildSetBindings(contactPointsOption, formatOption)); - command.Add(BuildDeploy(contactPointsOption, formatOption)); - command.Add(BuildEnable(contactPointsOption, formatOption)); - command.Add(BuildDisable(contactPointsOption, formatOption)); - command.Add(BuildDelete(contactPointsOption, formatOption)); + command.Add(BuildList(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildGet(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildCreate(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildSetBindings(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildSetOverrides(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildSetArea(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildDiff(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildDeploy(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildEnable(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildDisable(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildDelete(contactPointsOption, formatOption, usernameOption, passwordOption)); return command; } - private static Command BuildGet(Option contactPointsOption, Option formatOption) + private static Command BuildGet(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Instance ID", Required = true }; var cmd = new Command("get") { Description = "Get an instance by ID" }; @@ -31,12 +34,12 @@ public static class InstanceCommands { var id = result.GetValue(idOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new GetInstanceCommand(id)); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new GetInstanceCommand(id)); }); return cmd; } - private static Command BuildSetBindings(Option contactPointsOption, Option formatOption) + private static Command BuildSetBindings(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Instance ID", Required = true }; var bindingsOption = new Option("--bindings") { Description = "JSON array of [attributeName, dataConnectionId] pairs", Required = true }; @@ -53,13 +56,13 @@ public static class InstanceCommands var bindings = pairs.Select(p => (p[0].ToString()!, int.Parse(p[1].ToString()!))).ToList(); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new SetConnectionBindingsCommand(id, bindings)); }); return cmd; } - private static Command BuildList(Option contactPointsOption, Option formatOption) + private static Command BuildList(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var siteIdOption = new Option("--site-id") { Description = "Filter by site ID" }; var templateIdOption = new Option("--template-id") { Description = "Filter by template ID" }; @@ -75,13 +78,13 @@ public static class InstanceCommands var templateId = result.GetValue(templateIdOption); var search = result.GetValue(searchOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new ListInstancesCommand(siteId, templateId, search)); }); return cmd; } - private static Command BuildCreate(Option contactPointsOption, Option formatOption) + private static Command BuildCreate(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var nameOption = new Option("--name") { Description = "Unique instance name", Required = true }; var templateIdOption = new Option("--template-id") { Description = "Template ID", Required = true }; @@ -100,13 +103,13 @@ public static class InstanceCommands var siteId = result.GetValue(siteIdOption); var areaId = result.GetValue(areaIdOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new CreateInstanceCommand(name, templateId, siteId, areaId)); }); return cmd; } - private static Command BuildDeploy(Option contactPointsOption, Option formatOption) + private static Command BuildDeploy(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Instance ID", Required = true }; var cmd = new Command("deploy") { Description = "Deploy an instance" }; @@ -115,12 +118,12 @@ public static class InstanceCommands { var id = result.GetValue(idOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new MgmtDeployInstanceCommand(id)); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new MgmtDeployInstanceCommand(id)); }); return cmd; } - private static Command BuildEnable(Option contactPointsOption, Option formatOption) + private static Command BuildEnable(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Instance ID", Required = true }; var cmd = new Command("enable") { Description = "Enable an instance" }; @@ -129,12 +132,12 @@ public static class InstanceCommands { var id = result.GetValue(idOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new MgmtEnableInstanceCommand(id)); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new MgmtEnableInstanceCommand(id)); }); return cmd; } - private static Command BuildDisable(Option contactPointsOption, Option formatOption) + private static Command BuildDisable(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Instance ID", Required = true }; var cmd = new Command("disable") { Description = "Disable an instance" }; @@ -143,12 +146,12 @@ public static class InstanceCommands { var id = result.GetValue(idOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new MgmtDisableInstanceCommand(id)); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new MgmtDisableInstanceCommand(id)); }); return cmd; } - private static Command BuildDelete(Option contactPointsOption, Option formatOption) + private static Command BuildDelete(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Instance ID", Required = true }; var cmd = new Command("delete") { Description = "Delete an instance" }; @@ -157,7 +160,63 @@ public static class InstanceCommands { var id = result.GetValue(idOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new MgmtDeleteInstanceCommand(id)); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new MgmtDeleteInstanceCommand(id)); + }); + return cmd; + } + + private static Command BuildSetOverrides(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) + { + var idOption = new Option("--id") { Description = "Instance ID", Required = true }; + var overridesOption = new Option("--overrides") { Description = "JSON object of attribute name -> value pairs, e.g. {\"Speed\": \"100\", \"Mode\": null}", Required = true }; + + var cmd = new Command("set-overrides") { Description = "Set attribute overrides for an instance" }; + cmd.Add(idOption); + cmd.Add(overridesOption); + cmd.SetAction(async (ParseResult result) => + { + var id = result.GetValue(idOption); + var overridesJson = result.GetValue(overridesOption)!; + var overrides = System.Text.Json.JsonSerializer.Deserialize>(overridesJson) + ?? throw new InvalidOperationException("Invalid overrides JSON"); + return await CommandHelpers.ExecuteCommandAsync( + result, contactPointsOption, formatOption, usernameOption, passwordOption, + new SetInstanceOverridesCommand(id, overrides)); + }); + return cmd; + } + + private static Command BuildSetArea(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) + { + var idOption = new Option("--id") { Description = "Instance ID", Required = true }; + var areaIdOption = new Option("--area-id") { Description = "Area ID (omit to clear area assignment)" }; + + var cmd = new Command("set-area") { Description = "Reassign an instance to a different area" }; + cmd.Add(idOption); + cmd.Add(areaIdOption); + cmd.SetAction(async (ParseResult result) => + { + var id = result.GetValue(idOption); + var areaId = result.GetValue(areaIdOption); + return await CommandHelpers.ExecuteCommandAsync( + result, contactPointsOption, formatOption, usernameOption, passwordOption, + new SetInstanceAreaCommand(id, areaId)); + }); + return cmd; + } + + private static Command BuildDiff(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) + { + var idOption = new Option("--id") { Description = "Instance ID", Required = true }; + + var cmd = new Command("diff") { Description = "Show deployment diff (deployed vs current template)" }; + cmd.Add(idOption); + cmd.SetAction(async (ParseResult result) => + { + var id = result.GetValue(idOption); + return await CommandHelpers.ExecuteCommandAsync( + result, contactPointsOption, formatOption, usernameOption, passwordOption, + new GetDeploymentDiffCommand(id)); }); return cmd; } diff --git a/src/ScadaLink.CLI/Commands/NotificationCommands.cs b/src/ScadaLink.CLI/Commands/NotificationCommands.cs index 54962e4..474417a 100644 --- a/src/ScadaLink.CLI/Commands/NotificationCommands.cs +++ b/src/ScadaLink.CLI/Commands/NotificationCommands.cs @@ -6,21 +6,21 @@ namespace ScadaLink.CLI.Commands; public static class NotificationCommands { - public static Command Build(Option contactPointsOption, Option formatOption) + public static Command Build(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var command = new Command("notification") { Description = "Manage notification lists" }; - command.Add(BuildList(contactPointsOption, formatOption)); - command.Add(BuildGet(contactPointsOption, formatOption)); - command.Add(BuildCreate(contactPointsOption, formatOption)); - command.Add(BuildUpdate(contactPointsOption, formatOption)); - command.Add(BuildDelete(contactPointsOption, formatOption)); - command.Add(BuildSmtp(contactPointsOption, formatOption)); + command.Add(BuildList(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildGet(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildCreate(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildUpdate(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildDelete(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildSmtp(contactPointsOption, formatOption, usernameOption, passwordOption)); return command; } - private static Command BuildGet(Option contactPointsOption, Option formatOption) + private static Command BuildGet(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Notification list ID", Required = true }; var cmd = new Command("get") { Description = "Get a notification list by ID" }; @@ -29,12 +29,12 @@ public static class NotificationCommands { var id = result.GetValue(idOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new GetNotificationListCommand(id)); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new GetNotificationListCommand(id)); }); return cmd; } - private static Command BuildUpdate(Option contactPointsOption, Option formatOption) + private static Command BuildUpdate(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Notification list ID", Required = true }; var nameOption = new Option("--name") { Description = "List name", Required = true }; @@ -51,13 +51,13 @@ public static class NotificationCommands var emailsRaw = result.GetValue(emailsOption)!; var emails = emailsRaw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new UpdateNotificationListCommand(id, name, emails)); }); return cmd; } - private static Command BuildSmtp(Option contactPointsOption, Option formatOption) + private static Command BuildSmtp(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var group = new Command("smtp") { Description = "Manage SMTP configuration" }; @@ -65,7 +65,7 @@ public static class NotificationCommands listCmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new ListSmtpConfigsCommand()); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new ListSmtpConfigsCommand()); }); group.Add(listCmd); @@ -88,7 +88,7 @@ public static class NotificationCommands var authMode = result.GetValue(authModeOption)!; var from = result.GetValue(fromOption)!; return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new UpdateSmtpConfigCommand(id, server, port, authMode, from)); }); group.Add(updateCmd); @@ -96,18 +96,18 @@ public static class NotificationCommands return group; } - private static Command BuildList(Option contactPointsOption, Option formatOption) + private static Command BuildList(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var cmd = new Command("list") { Description = "List all notification lists" }; cmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new ListNotificationListsCommand()); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new ListNotificationListsCommand()); }); return cmd; } - private static Command BuildCreate(Option contactPointsOption, Option formatOption) + private static Command BuildCreate(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var nameOption = new Option("--name") { Description = "Notification list name", Required = true }; var emailsOption = new Option("--emails") { Description = "Comma-separated recipient emails", Required = true }; @@ -121,13 +121,13 @@ public static class NotificationCommands var emailsRaw = result.GetValue(emailsOption)!; var emails = emailsRaw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new CreateNotificationListCommand(name, emails)); }); return cmd; } - private static Command BuildDelete(Option contactPointsOption, Option formatOption) + private static Command BuildDelete(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Notification list ID", Required = true }; var cmd = new Command("delete") { Description = "Delete a notification list" }; @@ -136,7 +136,7 @@ public static class NotificationCommands { var id = result.GetValue(idOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new DeleteNotificationListCommand(id)); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new DeleteNotificationListCommand(id)); }); return cmd; } diff --git a/src/ScadaLink.CLI/Commands/SecurityCommands.cs b/src/ScadaLink.CLI/Commands/SecurityCommands.cs index 1628820..64499aa 100644 --- a/src/ScadaLink.CLI/Commands/SecurityCommands.cs +++ b/src/ScadaLink.CLI/Commands/SecurityCommands.cs @@ -6,18 +6,18 @@ namespace ScadaLink.CLI.Commands; public static class SecurityCommands { - public static Command Build(Option contactPointsOption, Option formatOption) + public static Command Build(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var command = new Command("security") { Description = "Manage security settings" }; - command.Add(BuildApiKey(contactPointsOption, formatOption)); - command.Add(BuildRoleMapping(contactPointsOption, formatOption)); - command.Add(BuildScopeRule(contactPointsOption, formatOption)); + command.Add(BuildApiKey(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildRoleMapping(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildScopeRule(contactPointsOption, formatOption, usernameOption, passwordOption)); return command; } - private static Command BuildApiKey(Option contactPointsOption, Option formatOption) + private static Command BuildApiKey(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var group = new Command("api-key") { Description = "Manage API keys" }; @@ -25,7 +25,7 @@ public static class SecurityCommands listCmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new ListApiKeysCommand()); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new ListApiKeysCommand()); }); group.Add(listCmd); @@ -36,7 +36,7 @@ public static class SecurityCommands { var name = result.GetValue(nameOption)!; return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new CreateApiKeyCommand(name)); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new CreateApiKeyCommand(name)); }); group.Add(createCmd); @@ -47,7 +47,7 @@ public static class SecurityCommands { var id = result.GetValue(idOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new DeleteApiKeyCommand(id)); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new DeleteApiKeyCommand(id)); }); group.Add(deleteCmd); @@ -61,14 +61,14 @@ public static class SecurityCommands var id = result.GetValue(updateIdOption); var enabled = result.GetValue(enabledOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new UpdateApiKeyCommand(id, enabled)); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new UpdateApiKeyCommand(id, enabled)); }); group.Add(updateCmd); return group; } - private static Command BuildRoleMapping(Option contactPointsOption, Option formatOption) + private static Command BuildRoleMapping(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var group = new Command("role-mapping") { Description = "Manage LDAP role mappings" }; @@ -76,7 +76,7 @@ public static class SecurityCommands listCmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new ListRoleMappingsCommand()); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new ListRoleMappingsCommand()); }); group.Add(listCmd); @@ -90,7 +90,7 @@ public static class SecurityCommands var ldapGroup = result.GetValue(ldapGroupOption)!; var role = result.GetValue(roleOption)!; return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new CreateRoleMappingCommand(ldapGroup, role)); }); group.Add(createCmd); @@ -102,7 +102,7 @@ public static class SecurityCommands { var id = result.GetValue(idOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new DeleteRoleMappingCommand(id)); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new DeleteRoleMappingCommand(id)); }); group.Add(deleteCmd); @@ -119,7 +119,7 @@ public static class SecurityCommands var ldapGroup = result.GetValue(updateLdapGroupOption)!; var role = result.GetValue(updateRoleOption)!; return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new UpdateRoleMappingCommand(id, ldapGroup, role)); }); group.Add(updateCmd); @@ -127,7 +127,7 @@ public static class SecurityCommands return group; } - private static Command BuildScopeRule(Option contactPointsOption, Option formatOption) + private static Command BuildScopeRule(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var group = new Command("scope-rule") { Description = "Manage LDAP scope rules" }; @@ -138,7 +138,7 @@ public static class SecurityCommands { var mappingId = result.GetValue(mappingIdOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new ListScopeRulesCommand(mappingId)); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new ListScopeRulesCommand(mappingId)); }); group.Add(listCmd); @@ -152,7 +152,7 @@ public static class SecurityCommands var mappingId = result.GetValue(addMappingIdOption); var siteId = result.GetValue(siteIdOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new AddScopeRuleCommand(mappingId, siteId)); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new AddScopeRuleCommand(mappingId, siteId)); }); group.Add(addCmd); @@ -163,7 +163,7 @@ public static class SecurityCommands { var id = result.GetValue(deleteIdOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new DeleteScopeRuleCommand(id)); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new DeleteScopeRuleCommand(id)); }); group.Add(deleteCmd); diff --git a/src/ScadaLink.CLI/Commands/SharedScriptCommands.cs b/src/ScadaLink.CLI/Commands/SharedScriptCommands.cs index bac1a0f..182ced5 100644 --- a/src/ScadaLink.CLI/Commands/SharedScriptCommands.cs +++ b/src/ScadaLink.CLI/Commands/SharedScriptCommands.cs @@ -6,31 +6,31 @@ namespace ScadaLink.CLI.Commands; public static class SharedScriptCommands { - public static Command Build(Option contactPointsOption, Option formatOption) + public static Command Build(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var command = new Command("shared-script") { Description = "Manage shared scripts" }; - command.Add(BuildList(contactPointsOption, formatOption)); - command.Add(BuildGet(contactPointsOption, formatOption)); - command.Add(BuildCreate(contactPointsOption, formatOption)); - command.Add(BuildUpdate(contactPointsOption, formatOption)); - command.Add(BuildDelete(contactPointsOption, formatOption)); + command.Add(BuildList(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildGet(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildCreate(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildUpdate(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildDelete(contactPointsOption, formatOption, usernameOption, passwordOption)); return command; } - private static Command BuildList(Option contactPointsOption, Option formatOption) + private static Command BuildList(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var cmd = new Command("list") { Description = "List all shared scripts" }; cmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new ListSharedScriptsCommand()); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new ListSharedScriptsCommand()); }); return cmd; } - private static Command BuildGet(Option contactPointsOption, Option formatOption) + private static Command BuildGet(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Shared script ID", Required = true }; var cmd = new Command("get") { Description = "Get a shared script by ID" }; @@ -39,12 +39,12 @@ public static class SharedScriptCommands { var id = result.GetValue(idOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new GetSharedScriptCommand(id)); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new GetSharedScriptCommand(id)); }); return cmd; } - private static Command BuildCreate(Option contactPointsOption, Option formatOption) + private static Command BuildCreate(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var nameOption = new Option("--name") { Description = "Script name", Required = true }; var codeOption = new Option("--code") { Description = "Script code", Required = true }; @@ -63,13 +63,13 @@ public static class SharedScriptCommands var parameters = result.GetValue(parametersOption); var returnDef = result.GetValue(returnDefOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new CreateSharedScriptCommand(name, code, parameters, returnDef)); }); return cmd; } - private static Command BuildUpdate(Option contactPointsOption, Option formatOption) + private static Command BuildUpdate(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Shared script ID", Required = true }; var nameOption = new Option("--name") { Description = "Script name", Required = true }; @@ -91,13 +91,13 @@ public static class SharedScriptCommands var parameters = result.GetValue(parametersOption); var returnDef = result.GetValue(returnDefOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new UpdateSharedScriptCommand(id, name, code, parameters, returnDef)); }); return cmd; } - private static Command BuildDelete(Option contactPointsOption, Option formatOption) + private static Command BuildDelete(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Shared script ID", Required = true }; var cmd = new Command("delete") { Description = "Delete a shared script" }; @@ -106,7 +106,7 @@ public static class SharedScriptCommands { var id = result.GetValue(idOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new DeleteSharedScriptCommand(id)); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new DeleteSharedScriptCommand(id)); }); return cmd; } diff --git a/src/ScadaLink.CLI/Commands/SiteCommands.cs b/src/ScadaLink.CLI/Commands/SiteCommands.cs index 1cd51f4..7435f59 100644 --- a/src/ScadaLink.CLI/Commands/SiteCommands.cs +++ b/src/ScadaLink.CLI/Commands/SiteCommands.cs @@ -6,22 +6,22 @@ namespace ScadaLink.CLI.Commands; public static class SiteCommands { - public static Command Build(Option contactPointsOption, Option formatOption) + public static Command Build(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var command = new Command("site") { Description = "Manage sites" }; - command.Add(BuildList(contactPointsOption, formatOption)); - command.Add(BuildGet(contactPointsOption, formatOption)); - command.Add(BuildCreate(contactPointsOption, formatOption)); - command.Add(BuildUpdate(contactPointsOption, formatOption)); - command.Add(BuildDelete(contactPointsOption, formatOption)); - command.Add(BuildDeployArtifacts(contactPointsOption, formatOption)); - command.Add(BuildArea(contactPointsOption, formatOption)); + command.Add(BuildList(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildGet(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildCreate(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildUpdate(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildDelete(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildDeployArtifacts(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildArea(contactPointsOption, formatOption, usernameOption, passwordOption)); return command; } - private static Command BuildGet(Option contactPointsOption, Option formatOption) + private static Command BuildGet(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Site ID", Required = true }; var cmd = new Command("get") { Description = "Get a site by ID" }; @@ -30,23 +30,23 @@ public static class SiteCommands { var id = result.GetValue(idOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new GetSiteCommand(id)); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new GetSiteCommand(id)); }); return cmd; } - private static Command BuildList(Option contactPointsOption, Option formatOption) + private static Command BuildList(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var cmd = new Command("list") { Description = "List all sites" }; cmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new ListSitesCommand()); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new ListSitesCommand()); }); return cmd; } - private static Command BuildCreate(Option contactPointsOption, Option formatOption) + private static Command BuildCreate(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var nameOption = new Option("--name") { Description = "Site name", Required = true }; var identifierOption = new Option("--identifier") { Description = "Site identifier", Required = true }; @@ -68,13 +68,13 @@ public static class SiteCommands var nodeA = result.GetValue(nodeAOption); var nodeB = result.GetValue(nodeBOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new CreateSiteCommand(name, identifier, desc, nodeA, nodeB)); }); return cmd; } - private static Command BuildUpdate(Option contactPointsOption, Option formatOption) + private static Command BuildUpdate(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Site ID", Required = true }; var nameOption = new Option("--name") { Description = "Site name", Required = true }; @@ -96,13 +96,13 @@ public static class SiteCommands var nodeA = result.GetValue(nodeAOption); var nodeB = result.GetValue(nodeBOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new UpdateSiteCommand(id, name, desc, nodeA, nodeB)); }); return cmd; } - private static Command BuildDelete(Option contactPointsOption, Option formatOption) + private static Command BuildDelete(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Site ID", Required = true }; var cmd = new Command("delete") { Description = "Delete a site" }; @@ -111,12 +111,12 @@ public static class SiteCommands { var id = result.GetValue(idOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new DeleteSiteCommand(id)); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new DeleteSiteCommand(id)); }); return cmd; } - private static Command BuildArea(Option contactPointsOption, Option formatOption) + private static Command BuildArea(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var group = new Command("area") { Description = "Manage areas" }; @@ -127,7 +127,7 @@ public static class SiteCommands { var siteId = result.GetValue(siteIdOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new ListAreasCommand(siteId)); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new ListAreasCommand(siteId)); }); group.Add(listCmd); @@ -144,7 +144,7 @@ public static class SiteCommands var name = result.GetValue(nameOption)!; var parentId = result.GetValue(parentOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new CreateAreaCommand(siteId, name, parentId)); }); group.Add(createCmd); @@ -159,7 +159,7 @@ public static class SiteCommands var id = result.GetValue(updateIdOption); var name = result.GetValue(updateNameOption)!; return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new UpdateAreaCommand(id, name)); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new UpdateAreaCommand(id, name)); }); group.Add(updateCmd); @@ -170,14 +170,14 @@ public static class SiteCommands { var id = result.GetValue(deleteIdOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new DeleteAreaCommand(id)); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new DeleteAreaCommand(id)); }); group.Add(deleteCmd); return group; } - private static Command BuildDeployArtifacts(Option contactPointsOption, Option formatOption) + private static Command BuildDeployArtifacts(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var siteIdOption = new Option("--site-id") { Description = "Target site ID (all sites if omitted)" }; var cmd = new Command("deploy-artifacts") { Description = "Deploy artifacts to site(s)" }; @@ -186,7 +186,7 @@ public static class SiteCommands { var siteId = result.GetValue(siteIdOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new MgmtDeployArtifactsCommand(siteId)); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new MgmtDeployArtifactsCommand(siteId)); }); return cmd; } diff --git a/src/ScadaLink.CLI/Commands/TemplateCommands.cs b/src/ScadaLink.CLI/Commands/TemplateCommands.cs index 4a6e362..9b221f6 100644 --- a/src/ScadaLink.CLI/Commands/TemplateCommands.cs +++ b/src/ScadaLink.CLI/Commands/TemplateCommands.cs @@ -6,36 +6,36 @@ namespace ScadaLink.CLI.Commands; public static class TemplateCommands { - public static Command Build(Option contactPointsOption, Option formatOption) + public static Command Build(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var command = new Command("template") { Description = "Manage templates" }; - command.Add(BuildList(contactPointsOption, formatOption)); - command.Add(BuildGet(contactPointsOption, formatOption)); - command.Add(BuildCreate(contactPointsOption, formatOption)); - command.Add(BuildUpdate(contactPointsOption, formatOption)); - command.Add(BuildValidate(contactPointsOption, formatOption)); - command.Add(BuildDelete(contactPointsOption, formatOption)); - command.Add(BuildAttribute(contactPointsOption, formatOption)); - command.Add(BuildAlarm(contactPointsOption, formatOption)); - command.Add(BuildScript(contactPointsOption, formatOption)); - command.Add(BuildComposition(contactPointsOption, formatOption)); + command.Add(BuildList(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildGet(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildCreate(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildUpdate(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildValidate(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildDelete(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildAttribute(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildAlarm(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildScript(contactPointsOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildComposition(contactPointsOption, formatOption, usernameOption, passwordOption)); return command; } - private static Command BuildList(Option contactPointsOption, Option formatOption) + private static Command BuildList(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var cmd = new Command("list") { Description = "List all templates" }; cmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new ListTemplatesCommand()); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new ListTemplatesCommand()); }); return cmd; } - private static Command BuildGet(Option contactPointsOption, Option formatOption) + private static Command BuildGet(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Template ID", Required = true }; var cmd = new Command("get") { Description = "Get a template by ID" }; @@ -44,12 +44,12 @@ public static class TemplateCommands { var id = result.GetValue(idOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new GetTemplateCommand(id)); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new GetTemplateCommand(id)); }); return cmd; } - private static Command BuildCreate(Option contactPointsOption, Option formatOption) + private static Command BuildCreate(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var nameOption = new Option("--name") { Description = "Template name", Required = true }; var descOption = new Option("--description") { Description = "Template description" }; @@ -65,13 +65,13 @@ public static class TemplateCommands var desc = result.GetValue(descOption); var parentId = result.GetValue(parentOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new CreateTemplateCommand(name, desc, parentId)); }); return cmd; } - private static Command BuildUpdate(Option contactPointsOption, Option formatOption) + private static Command BuildUpdate(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Template ID", Required = true }; var nameOption = new Option("--name") { Description = "Template name", Required = true }; @@ -90,13 +90,13 @@ public static class TemplateCommands var desc = result.GetValue(descOption); var parentId = result.GetValue(parentOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new UpdateTemplateCommand(id, name, desc, parentId)); }); return cmd; } - private static Command BuildValidate(Option contactPointsOption, Option formatOption) + private static Command BuildValidate(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Template ID", Required = true }; var cmd = new Command("validate") { Description = "Validate a template" }; @@ -105,12 +105,12 @@ public static class TemplateCommands { var id = result.GetValue(idOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new ValidateTemplateCommand(id)); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new ValidateTemplateCommand(id)); }); return cmd; } - private static Command BuildDelete(Option contactPointsOption, Option formatOption) + private static Command BuildDelete(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Template ID", Required = true }; var cmd = new Command("delete") { Description = "Delete a template" }; @@ -119,12 +119,12 @@ public static class TemplateCommands { var id = result.GetValue(idOption); return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, new DeleteTemplateCommand(id)); + result, contactPointsOption, formatOption, usernameOption, passwordOption, new DeleteTemplateCommand(id)); }); return cmd; } - private static Command BuildAttribute(Option contactPointsOption, Option formatOption) + private static Command BuildAttribute(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var group = new Command("attribute") { Description = "Manage template attributes" }; @@ -148,7 +148,7 @@ public static class TemplateCommands addCmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new AddTemplateAttributeCommand( result.GetValue(templateIdOption), result.GetValue(nameOption)!, @@ -180,7 +180,7 @@ public static class TemplateCommands updateCmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new UpdateTemplateAttributeCommand( result.GetValue(updateIdOption), result.GetValue(updateNameOption)!, @@ -198,7 +198,7 @@ public static class TemplateCommands deleteCmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new DeleteTemplateAttributeCommand(result.GetValue(deleteIdOption))); }); group.Add(deleteCmd); @@ -206,7 +206,7 @@ public static class TemplateCommands return group; } - private static Command BuildAlarm(Option contactPointsOption, Option formatOption) + private static Command BuildAlarm(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var group = new Command("alarm") { Description = "Manage template alarms" }; @@ -230,7 +230,7 @@ public static class TemplateCommands addCmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new AddTemplateAlarmCommand( result.GetValue(templateIdOption), result.GetValue(nameOption)!, @@ -262,7 +262,7 @@ public static class TemplateCommands updateCmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new UpdateTemplateAlarmCommand( result.GetValue(updateIdOption), result.GetValue(updateNameOption)!, @@ -280,7 +280,7 @@ public static class TemplateCommands deleteCmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new DeleteTemplateAlarmCommand(result.GetValue(deleteIdOption))); }); group.Add(deleteCmd); @@ -288,7 +288,7 @@ public static class TemplateCommands return group; } - private static Command BuildScript(Option contactPointsOption, Option formatOption) + private static Command BuildScript(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var group = new Command("script") { Description = "Manage template scripts" }; @@ -315,7 +315,7 @@ public static class TemplateCommands addCmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new AddTemplateScriptCommand( result.GetValue(templateIdOption), result.GetValue(nameOption)!, @@ -351,7 +351,7 @@ public static class TemplateCommands updateCmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new UpdateTemplateScriptCommand( result.GetValue(updateIdOption), result.GetValue(updateNameOption)!, @@ -370,7 +370,7 @@ public static class TemplateCommands deleteCmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new DeleteTemplateScriptCommand(result.GetValue(deleteIdOption))); }); group.Add(deleteCmd); @@ -378,7 +378,7 @@ public static class TemplateCommands return group; } - private static Command BuildComposition(Option contactPointsOption, Option formatOption) + private static Command BuildComposition(Option contactPointsOption, Option formatOption, Option usernameOption, Option passwordOption) { var group = new Command("composition") { Description = "Manage template compositions" }; @@ -393,7 +393,7 @@ public static class TemplateCommands addCmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new AddTemplateCompositionCommand( result.GetValue(templateIdOption), result.GetValue(instanceNameOption)!, @@ -407,7 +407,7 @@ public static class TemplateCommands deleteCmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( - result, contactPointsOption, formatOption, + result, contactPointsOption, formatOption, usernameOption, passwordOption, new DeleteTemplateCompositionCommand(result.GetValue(deleteIdOption))); }); group.Add(deleteCmd); diff --git a/src/ScadaLink.CLI/Program.cs b/src/ScadaLink.CLI/Program.cs index 5ba8b89..fef2c00 100644 --- a/src/ScadaLink.CLI/Program.cs +++ b/src/ScadaLink.CLI/Program.cs @@ -16,20 +16,20 @@ rootCommand.Add(passwordOption); rootCommand.Add(formatOption); // Register command groups -rootCommand.Add(TemplateCommands.Build(contactPointsOption, formatOption)); -rootCommand.Add(InstanceCommands.Build(contactPointsOption, formatOption)); -rootCommand.Add(SiteCommands.Build(contactPointsOption, formatOption)); -rootCommand.Add(DeployCommands.Build(contactPointsOption, formatOption)); -rootCommand.Add(DataConnectionCommands.Build(contactPointsOption, formatOption)); -rootCommand.Add(ExternalSystemCommands.Build(contactPointsOption, formatOption)); -rootCommand.Add(NotificationCommands.Build(contactPointsOption, formatOption)); -rootCommand.Add(SecurityCommands.Build(contactPointsOption, formatOption)); -rootCommand.Add(AuditLogCommands.Build(contactPointsOption, formatOption)); -rootCommand.Add(HealthCommands.Build(contactPointsOption, formatOption)); -rootCommand.Add(DebugCommands.Build(contactPointsOption, formatOption)); -rootCommand.Add(SharedScriptCommands.Build(contactPointsOption, formatOption)); -rootCommand.Add(DbConnectionCommands.Build(contactPointsOption, formatOption)); -rootCommand.Add(ApiMethodCommands.Build(contactPointsOption, formatOption)); +rootCommand.Add(TemplateCommands.Build(contactPointsOption, formatOption, usernameOption, passwordOption)); +rootCommand.Add(InstanceCommands.Build(contactPointsOption, formatOption, usernameOption, passwordOption)); +rootCommand.Add(SiteCommands.Build(contactPointsOption, formatOption, usernameOption, passwordOption)); +rootCommand.Add(DeployCommands.Build(contactPointsOption, formatOption, usernameOption, passwordOption)); +rootCommand.Add(DataConnectionCommands.Build(contactPointsOption, formatOption, usernameOption, passwordOption)); +rootCommand.Add(ExternalSystemCommands.Build(contactPointsOption, formatOption, usernameOption, passwordOption)); +rootCommand.Add(NotificationCommands.Build(contactPointsOption, formatOption, usernameOption, passwordOption)); +rootCommand.Add(SecurityCommands.Build(contactPointsOption, formatOption, usernameOption, passwordOption)); +rootCommand.Add(AuditLogCommands.Build(contactPointsOption, formatOption, usernameOption, passwordOption)); +rootCommand.Add(HealthCommands.Build(contactPointsOption, formatOption, usernameOption, passwordOption)); +rootCommand.Add(DebugCommands.Build(contactPointsOption, formatOption, usernameOption, passwordOption)); +rootCommand.Add(SharedScriptCommands.Build(contactPointsOption, formatOption, usernameOption, passwordOption)); +rootCommand.Add(DbConnectionCommands.Build(contactPointsOption, formatOption, usernameOption, passwordOption)); +rootCommand.Add(ApiMethodCommands.Build(contactPointsOption, formatOption, usernameOption, passwordOption)); rootCommand.SetAction(_ => { diff --git a/src/ScadaLink.CLI/README.md b/src/ScadaLink.CLI/README.md index f05509c..b92013a 100644 --- a/src/ScadaLink.CLI/README.md +++ b/src/ScadaLink.CLI/README.md @@ -41,8 +41,8 @@ These options are accepted by the root command and inherited by all subcommands. | Option | Description | |--------|-------------| | `--contact-points ` | Comma-separated Akka cluster contact point URIs | -| `--username ` | LDAP username (reserved for future auth integration) | -| `--password ` | LDAP password (reserved for future auth integration) | +| `--username ` | LDAP username for authentication | +| `--password ` | LDAP password for authentication | | `--format ` | Output format (default: `json`) | ## Configuration File @@ -55,12 +55,19 @@ These options are accepted by the root command and inherited by all subcommands. "ldap": { "server": "ldap.company.com", "port": 636, - "useTls": true + "useTls": true, + "searchBase": "dc=example,dc=com", + "serviceAccountDn": "cn=admin,dc=example,dc=com", + "serviceAccountPassword": "secret" }, "defaultFormat": "json" } ``` +The `searchBase` and `serviceAccountDn`/`serviceAccountPassword` fields are required for LDAP servers that need search-then-bind authentication (including the test GLAuth server). Without them, direct bind with `cn={username},{searchBase}` is attempted, which may fail if the user's DN doesn't follow that pattern. + +For the Docker test environment, see `docker/README.md` for a ready-to-use config. + ## Environment Variables | Variable | Description | @@ -435,7 +442,7 @@ scadalink --contact-points instance set-bindings --id --bindings true scadalink + + + + + + diff --git a/src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor b/src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor index 6c7e7fe..1864ec6 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor @@ -65,6 +65,16 @@ } +
+ + +
@@ -181,10 +191,62 @@ } + + + @if (_overrideInstanceId == inst.Id) + { + + +
+ Attribute Overrides for @inst.UniqueName +
+ + + +
+
+ @if (_overrideAttrs.Count == 0) + { +

No overridable (non-locked) attributes in this template.

+ } + else + { + + + + + + @foreach (var attr in _overrideAttrs) + { + + + + + + } + +
AttributeTemplate ValueOverride Value
@attr.Name @attr.DataType@(attr.Value ?? "—") + +
+ + } + + + } @if (_bindingInstanceId == inst.Id) { @@ -268,6 +330,55 @@
@_filteredInstances.Count instance(s) total
+ + @* Diff Modal *@ + @if (_showDiffModal) + { + + } }
@@ -508,6 +619,7 @@ private string _createName = string.Empty; private int _createTemplateId; private int _createSiteId; + private int _createAreaId; private string? _createError; private void ShowCreateForm() @@ -515,6 +627,7 @@ _createName = string.Empty; _createTemplateId = 0; _createSiteId = 0; + _createAreaId = 0; _createError = null; _showCreateForm = true; } @@ -530,7 +643,7 @@ { var user = await GetCurrentUserAsync(); var result = await InstanceService.CreateInstanceAsync( - _createName.Trim(), _createTemplateId, _createSiteId, null, user); + _createName.Trim(), _createTemplateId, _createSiteId, _createAreaId == 0 ? null : _createAreaId, user); if (result.IsSuccess) { _showCreateForm = false; @@ -548,6 +661,118 @@ } } + // Override state + private int _overrideInstanceId; + private List _overrideAttrs = new(); + private Dictionary _overrideValues = new(); + private int _reassignAreaId; + + private async Task ToggleOverrides(Instance inst) + { + if (_overrideInstanceId == inst.Id) { _overrideInstanceId = 0; return; } + _overrideInstanceId = inst.Id; + _overrideValues.Clear(); + _reassignAreaId = inst.AreaId ?? 0; + + var attrs = await TemplateEngineRepository.GetAttributesByTemplateIdAsync(inst.TemplateId); + _overrideAttrs = attrs.Where(a => !a.IsLocked).ToList(); + + var overrides = await TemplateEngineRepository.GetOverridesByInstanceIdAsync(inst.Id); + foreach (var o in overrides) + { + _overrideValues[o.AttributeName] = o.OverrideValue; + } + } + + private string? GetOverrideValue(string attrName) => + _overrideValues.GetValueOrDefault(attrName); + + private void OnOverrideChanged(string attrName, ChangeEventArgs e) + { + var val = e.Value?.ToString(); + if (string.IsNullOrEmpty(val)) + _overrideValues.Remove(attrName); + else + _overrideValues[attrName] = val; + } + + private async Task SaveOverrides() + { + _actionInProgress = true; + try + { + var user = await GetCurrentUserAsync(); + foreach (var (attrName, value) in _overrideValues) + { + await InstanceService.SetAttributeOverrideAsync(_overrideInstanceId, attrName, value, user); + } + _toast.ShowSuccess($"Saved {_overrideValues.Count} override(s)."); + _overrideInstanceId = 0; + } + catch (Exception ex) + { + _toast.ShowError($"Save overrides failed: {ex.Message}"); + } + _actionInProgress = false; + } + + private async Task ReassignArea(Instance inst) + { + _actionInProgress = true; + try + { + var user = await GetCurrentUserAsync(); + var result = await InstanceService.AssignToAreaAsync(inst.Id, _reassignAreaId == 0 ? null : _reassignAreaId, user); + if (result.IsSuccess) + { + _toast.ShowSuccess($"Area reassigned for '{inst.UniqueName}'."); + await LoadDataAsync(); + } + else + { + _toast.ShowError($"Reassign failed: {result.Error}"); + } + } + catch (Exception ex) + { + _toast.ShowError($"Reassign failed: {ex.Message}"); + } + _actionInProgress = false; + } + + // Diff state + private bool _showDiffModal; + private bool _diffLoading; + private string? _diffError; + private string _diffInstanceName = string.Empty; + private DeploymentComparisonResult? _diffResult; + + private async Task ShowDiff(Instance inst) + { + _showDiffModal = true; + _diffLoading = true; + _diffError = null; + _diffResult = null; + _diffInstanceName = inst.UniqueName; + try + { + var result = await DeploymentService.GetDeploymentComparisonAsync(inst.Id); + if (result.IsSuccess) + { + _diffResult = result.Value; + } + else + { + _diffError = result.Error; + } + } + catch (Exception ex) + { + _diffError = $"Failed to load diff: {ex.Message}"; + } + _diffLoading = false; + } + // Connection binding state private int _bindingInstanceId; private List _bindingDataSourceAttrs = new(); diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystems.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystems.razor index cea4839..aaaec92 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystems.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystems.razor @@ -50,8 +50,8 @@ @if (_tab == "extsys") { @RenderExternalSystems() } else if (_tab == "dbconn") { @RenderDbConnections() } - else if (_tab == "notif") { @RenderNotificationLists() } - else if (_tab == "inbound") { @RenderInboundApiMethods() } + else if (_tab == "notif") { @RenderNotificationLists() @RenderSmtpConfig() } + else if (_tab == "inbound") { @RenderInboundApiMethods() @RenderApiKeyMethodAssignments() } } @@ -66,6 +66,8 @@ private ExternalSystemDefinition? _editingExtSys; private string _extSysName = "", _extSysUrl = "", _extSysAuth = "ApiKey"; private string? _extSysAuthConfig; + private int _extSysMaxRetries = 3; + private int _extSysRetryDelaySeconds = 5; private string? _extSysFormError; // Database Connections @@ -73,8 +75,21 @@ private bool _showDbConnForm; private DatabaseConnectionDefinition? _editingDbConn; private string _dbConnName = "", _dbConnString = ""; + private int _dbConnMaxRetries = 3; + private int _dbConnRetryDelaySeconds = 5; private string? _dbConnFormError; + // SMTP Configuration + private List _smtpConfigs = new(); + private bool _showSmtpForm; + private SmtpConfiguration? _editingSmtp; + private string _smtpHost = "", _smtpFromAddress = "", _smtpAuthType = "OAuth2"; + private int _smtpPort = 587; + private string? _smtpFormError; + + // API Key list + private List _apiKeys = new(); + // Notification Lists private List _notificationLists = new(); private bool _showNotifForm; @@ -123,6 +138,8 @@ } _apiMethods = (await InboundApiRepository.GetAllApiMethodsAsync()).ToList(); + _smtpConfigs = (await NotificationRepository.GetAllSmtpConfigurationsAsync()).ToList(); + _apiKeys = (await InboundApiRepository.GetAllApiKeysAsync()).ToList(); } catch (Exception ex) { _errorMessage = ex.Message; } _loading = false; @@ -144,8 +161,10 @@
-
-
+
+
+
+
@@ -154,14 +173,15 @@ } - + @foreach (var es in _externalSystems) { + @@ -177,6 +197,8 @@ _extSysName = _extSysUrl = string.Empty; _extSysAuth = "ApiKey"; _extSysAuthConfig = null; + _extSysMaxRetries = 3; + _extSysRetryDelaySeconds = 5; _extSysFormError = null; } @@ -186,8 +208,8 @@ if (string.IsNullOrWhiteSpace(_extSysName) || string.IsNullOrWhiteSpace(_extSysUrl)) { _extSysFormError = "Name and URL required."; return; } try { - if (_editingExtSys != null) { _editingExtSys.Name = _extSysName.Trim(); _editingExtSys.EndpointUrl = _extSysUrl.Trim(); _editingExtSys.AuthType = _extSysAuth; _editingExtSys.AuthConfiguration = _extSysAuthConfig?.Trim(); await ExternalSystemRepository.UpdateExternalSystemAsync(_editingExtSys); } - else { var es = new ExternalSystemDefinition(_extSysName.Trim(), _extSysUrl.Trim(), _extSysAuth) { AuthConfiguration = _extSysAuthConfig?.Trim() }; await ExternalSystemRepository.AddExternalSystemAsync(es); } + if (_editingExtSys != null) { _editingExtSys.Name = _extSysName.Trim(); _editingExtSys.EndpointUrl = _extSysUrl.Trim(); _editingExtSys.AuthType = _extSysAuth; _editingExtSys.AuthConfiguration = _extSysAuthConfig?.Trim(); _editingExtSys.MaxRetries = _extSysMaxRetries; _editingExtSys.RetryDelay = TimeSpan.FromSeconds(_extSysRetryDelaySeconds); await ExternalSystemRepository.UpdateExternalSystemAsync(_editingExtSys); } + else { var es = new ExternalSystemDefinition(_extSysName.Trim(), _extSysUrl.Trim(), _extSysAuth) { AuthConfiguration = _extSysAuthConfig?.Trim(), MaxRetries = _extSysMaxRetries, RetryDelay = TimeSpan.FromSeconds(_extSysRetryDelaySeconds) }; await ExternalSystemRepository.AddExternalSystemAsync(es); } await ExternalSystemRepository.SaveChangesAsync(); _showExtSysForm = false; _toast.ShowSuccess("Saved."); await LoadAllAsync(); } catch (Exception ex) { _extSysFormError = ex.Message; } @@ -205,7 +227,7 @@ {
Database Connections
- +
@if (_showDbConnForm) @@ -213,7 +235,9 @@
-
+
+
+
@@ -223,14 +247,15 @@ }
NameURLAuthActions
NameURLAuthRetriesDelayActions
@es.Name@es.EndpointUrl@es.AuthType@es.MaxRetries@es.RetryDelay.TotalSeconds s - +
- + @foreach (var dc in _dbConnections) { + @@ -245,8 +270,8 @@ if (string.IsNullOrWhiteSpace(_dbConnName) || string.IsNullOrWhiteSpace(_dbConnString)) { _dbConnFormError = "Name and connection string required."; return; } try { - if (_editingDbConn != null) { _editingDbConn.Name = _dbConnName.Trim(); _editingDbConn.ConnectionString = _dbConnString.Trim(); await ExternalSystemRepository.UpdateDatabaseConnectionAsync(_editingDbConn); } - else { var dc = new DatabaseConnectionDefinition(_dbConnName.Trim(), _dbConnString.Trim()); await ExternalSystemRepository.AddDatabaseConnectionAsync(dc); } + if (_editingDbConn != null) { _editingDbConn.Name = _dbConnName.Trim(); _editingDbConn.ConnectionString = _dbConnString.Trim(); _editingDbConn.MaxRetries = _dbConnMaxRetries; _editingDbConn.RetryDelay = TimeSpan.FromSeconds(_dbConnRetryDelaySeconds); await ExternalSystemRepository.UpdateDatabaseConnectionAsync(_editingDbConn); } + else { var dc = new DatabaseConnectionDefinition(_dbConnName.Trim(), _dbConnString.Trim()) { MaxRetries = _dbConnMaxRetries, RetryDelay = TimeSpan.FromSeconds(_dbConnRetryDelaySeconds) }; await ExternalSystemRepository.AddDatabaseConnectionAsync(dc); } await ExternalSystemRepository.SaveChangesAsync(); _showDbConnForm = false; _toast.ShowSuccess("Saved."); await LoadAllAsync(); } catch (Exception ex) { _dbConnFormError = ex.Message; } @@ -434,4 +459,127 @@ try { await InboundApiRepository.DeleteApiMethodAsync(m.Id); await InboundApiRepository.SaveChangesAsync(); _toast.ShowSuccess("Deleted."); await LoadAllAsync(); } catch (Exception ex) { _toast.ShowError(ex.Message); } } + + // ==== SMTP Configuration ==== + private RenderFragment RenderSmtpConfig() => __builder => + { +
+
+
SMTP Configuration
+ @if (_smtpConfigs.Count == 0) + { + + } +
+ + @if (_showSmtpForm) + { +
+
+
+
+
+
+
+
+ +
+
+ @if (_smtpFormError != null) {
@_smtpFormError
} +
+ } + + @foreach (var smtp in _smtpConfigs) + { +
+
+
+ + @smtp.Host:@smtp.Port | + Auth: @smtp.AuthType | + From: @smtp.FromAddress + + +
+
+
+ } + }; + + private void ShowSmtpAddForm() + { + _showSmtpForm = true; + _editingSmtp = null; + _smtpHost = string.Empty; + _smtpPort = 587; + _smtpAuthType = "OAuth2"; + _smtpFromAddress = string.Empty; + _smtpFormError = null; + } + + private async Task SaveSmtpConfig() + { + _smtpFormError = null; + if (string.IsNullOrWhiteSpace(_smtpHost) || string.IsNullOrWhiteSpace(_smtpFromAddress)) { _smtpFormError = "Host and From Address required."; return; } + try + { + if (_editingSmtp != null) + { + _editingSmtp.Host = _smtpHost.Trim(); + _editingSmtp.Port = _smtpPort; + _editingSmtp.AuthType = _smtpAuthType; + _editingSmtp.FromAddress = _smtpFromAddress.Trim(); + await NotificationRepository.UpdateSmtpConfigurationAsync(_editingSmtp); + } + else + { + var smtp = new SmtpConfiguration(_smtpHost.Trim(), _smtpAuthType, _smtpFromAddress.Trim()) { Port = _smtpPort }; + await NotificationRepository.AddSmtpConfigurationAsync(smtp); + } + await NotificationRepository.SaveChangesAsync(); + _showSmtpForm = false; + _toast.ShowSuccess("SMTP configuration saved."); + await LoadAllAsync(); + } + catch (Exception ex) { _smtpFormError = ex.Message; } + } + + // ==== API Key → Method Assignments ==== + private RenderFragment RenderApiKeyMethodAssignments() => __builder => + { +
+
+
API Keys
+
+ +
NameConnection StringActions
NameConnection StringRetriesDelayActions
@dc.Name@dc.ConnectionString@dc.MaxRetries@dc.RetryDelay.TotalSeconds s - +
+ + + @foreach (var key in _apiKeys) + { + + + + + + } + +
Key NameEnabledActions
@key.Name@(key.IsEnabled ? "Enabled" : "Disabled") + +
+ }; + + private async Task ToggleApiKeyEnabled(ApiKey key) + { + try + { + key.IsEnabled = !key.IsEnabled; + await InboundApiRepository.UpdateApiKeyAsync(key); + await InboundApiRepository.SaveChangesAsync(); + _toast.ShowSuccess($"API key '{key.Name}' {(key.IsEnabled ? "enabled" : "disabled")}."); + } + catch (Exception ex) { _toast.ShowError(ex.Message); } + } } diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor index 5c13762..d8a5178 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor @@ -476,9 +476,10 @@ _validationResult = null; try { - // Use the ValidationService for on-demand validation + // Use the full validation pipeline via TemplateService + // This performs flattening, collision detection, script compilation, + // trigger reference validation, and connection binding checks var validationService = new ValidationService(); - // Build a minimal flattened config from the template's direct members for validation var flatConfig = new Commons.Types.Flattening.FlattenedConfiguration { InstanceUniqueName = $"validation-{_selectedTemplate.Name}", @@ -511,6 +512,17 @@ }).ToList() }; _validationResult = validationService.Validate(flatConfig); + + // Also check for naming collisions across the inheritance/composition graph + var collisions = await TemplateService.DetectCollisionsAsync(_selectedTemplate.Id); + if (collisions.Count > 0) + { + var collisionErrors = collisions.Select(c => + Commons.Types.Flattening.ValidationEntry.Error( + Commons.Types.Flattening.ValidationCategory.NamingCollision, c)).ToArray(); + var collisionResult = new Commons.Types.Flattening.ValidationResult { Errors = collisionErrors }; + _validationResult = Commons.Types.Flattening.ValidationResult.Merge(_validationResult, collisionResult); + } } catch (Exception ex) { diff --git a/src/ScadaLink.CentralUI/Components/Pages/Monitoring/EventLogs.razor b/src/ScadaLink.CentralUI/Components/Pages/Monitoring/EventLogs.razor index d9dd5eb..8efff47 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Monitoring/EventLogs.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Monitoring/EventLogs.razor @@ -44,10 +44,14 @@ -
+
+
+ + +
Last report: @state.LastReportReceivedAt.LocalDateTime.ToString("HH:mm:ss") | Seq: @state.LastSequenceNumber diff --git a/src/ScadaLink.CentralUI/Components/Pages/Monitoring/ParkedMessages.razor b/src/ScadaLink.CentralUI/Components/Pages/Monitoring/ParkedMessages.razor index a2fd179..a216ef8 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Monitoring/ParkedMessages.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Monitoring/ParkedMessages.razor @@ -70,9 +70,11 @@ + @onclick="() => RetryMessage(msg)" disabled="@_actionInProgress" + title="Retry message (move back to pending)">Retry + @onclick="() => DiscardMessage(msg)" disabled="@_actionInProgress" + title="Permanently discard message">Discard } @@ -102,6 +104,7 @@ private bool _searching; private string? _errorMessage; + private bool _actionInProgress; private ToastNotification _toast = default!; private ConfirmDialog _confirmDialog = default!; @@ -150,4 +153,65 @@ } _searching = false; } + + private async Task RetryMessage(ParkedMessageEntry msg) + { + _actionInProgress = true; + try + { + var request = new ParkedMessageRetryRequest( + CorrelationId: Guid.NewGuid().ToString("N"), + SiteId: _selectedSiteId, + MessageId: msg.MessageId, + Timestamp: DateTimeOffset.UtcNow); + var response = await CommunicationService.RetryParkedMessageAsync(_selectedSiteId, request); + if (response.Success) + { + _toast.ShowSuccess($"Message {msg.MessageId[..Math.Min(12, msg.MessageId.Length)]} queued for retry."); + await FetchPage(); + } + else + { + _toast.ShowError(response.ErrorMessage ?? "Retry failed."); + } + } + catch (Exception ex) + { + _toast.ShowError($"Retry failed: {ex.Message}"); + } + _actionInProgress = false; + } + + private async Task DiscardMessage(ParkedMessageEntry msg) + { + var confirmed = await _confirmDialog.ShowAsync( + $"Permanently discard message {msg.MessageId[..Math.Min(12, msg.MessageId.Length)]}? This cannot be undone.", + "Discard Parked Message"); + if (!confirmed) return; + + _actionInProgress = true; + try + { + var request = new ParkedMessageDiscardRequest( + CorrelationId: Guid.NewGuid().ToString("N"), + SiteId: _selectedSiteId, + MessageId: msg.MessageId, + Timestamp: DateTimeOffset.UtcNow); + var response = await CommunicationService.DiscardParkedMessageAsync(_selectedSiteId, request); + if (response.Success) + { + _toast.ShowSuccess($"Message {msg.MessageId[..Math.Min(12, msg.MessageId.Length)]} discarded."); + await FetchPage(); + } + else + { + _toast.ShowError(response.ErrorMessage ?? "Discard failed."); + } + } + catch (Exception ex) + { + _toast.ShowError($"Discard failed: {ex.Message}"); + } + _actionInProgress = false; + } } diff --git a/src/ScadaLink.Commons/Interfaces/Protocol/IDataConnection.cs b/src/ScadaLink.Commons/Interfaces/Protocol/IDataConnection.cs index 7d959b6..ba6ed7f 100644 --- a/src/ScadaLink.Commons/Interfaces/Protocol/IDataConnection.cs +++ b/src/ScadaLink.Commons/Interfaces/Protocol/IDataConnection.cs @@ -22,4 +22,11 @@ public interface IDataConnection : IAsyncDisposable Task> WriteBatchAsync(IDictionary values, CancellationToken cancellationToken = default); Task WriteBatchAndWaitAsync(IDictionary values, string flagPath, object? flagValue, string responsePath, object? responseValue, TimeSpan timeout, CancellationToken cancellationToken = default); ConnectionHealth Status { get; } + + /// + /// Raised when the adapter detects an unexpected connection loss (e.g., gRPC stream error, + /// network timeout). The DataConnectionActor listens for this to trigger reconnection + /// and push bad quality to all subscribed tags. + /// + event Action? Disconnected; } diff --git a/src/ScadaLink.Commons/Messages/Health/SiteHealthReport.cs b/src/ScadaLink.Commons/Messages/Health/SiteHealthReport.cs index ed9af6d..8ec2e07 100644 --- a/src/ScadaLink.Commons/Messages/Health/SiteHealthReport.cs +++ b/src/ScadaLink.Commons/Messages/Health/SiteHealthReport.cs @@ -14,4 +14,5 @@ public record SiteHealthReport( int DeadLetterCount, int DeployedInstanceCount, int EnabledInstanceCount, - int DisabledInstanceCount); + int DisabledInstanceCount, + string NodeRole = "Unknown"); diff --git a/src/ScadaLink.Commons/Messages/Management/DeploymentCommands.cs b/src/ScadaLink.Commons/Messages/Management/DeploymentCommands.cs index ec414dc..af4511f 100644 --- a/src/ScadaLink.Commons/Messages/Management/DeploymentCommands.cs +++ b/src/ScadaLink.Commons/Messages/Management/DeploymentCommands.cs @@ -2,3 +2,4 @@ namespace ScadaLink.Commons.Messages.Management; public record MgmtDeployArtifactsCommand(int? SiteId = null); public record QueryDeploymentsCommand(int? InstanceId = null, string? Status = null, int Page = 1, int PageSize = 50); +public record GetDeploymentDiffCommand(int InstanceId); diff --git a/src/ScadaLink.Commons/Messages/Management/InstanceCommands.cs b/src/ScadaLink.Commons/Messages/Management/InstanceCommands.cs index e594cb1..0a31a56 100644 --- a/src/ScadaLink.Commons/Messages/Management/InstanceCommands.cs +++ b/src/ScadaLink.Commons/Messages/Management/InstanceCommands.cs @@ -8,3 +8,5 @@ public record MgmtEnableInstanceCommand(int InstanceId); public record MgmtDisableInstanceCommand(int InstanceId); public record MgmtDeleteInstanceCommand(int InstanceId); public record SetConnectionBindingsCommand(int InstanceId, IReadOnlyList<(string AttributeName, int DataConnectionId)> Bindings); +public record SetInstanceOverridesCommand(int InstanceId, IReadOnlyDictionary Overrides); +public record SetInstanceAreaCommand(int InstanceId, int? AreaId); diff --git a/src/ScadaLink.Commons/Messages/Management/RemoteQueryCommands.cs b/src/ScadaLink.Commons/Messages/Management/RemoteQueryCommands.cs index c50e48d..34b5941 100644 --- a/src/ScadaLink.Commons/Messages/Management/RemoteQueryCommands.cs +++ b/src/ScadaLink.Commons/Messages/Management/RemoteQueryCommands.cs @@ -1,4 +1,6 @@ namespace ScadaLink.Commons.Messages.Management; -public record QueryEventLogsCommand(string SiteIdentifier, string? EventType = null, string? Severity = null, string? Keyword = null, DateTimeOffset? From = null, DateTimeOffset? To = null, int Page = 1, int PageSize = 50); +public record QueryEventLogsCommand(string SiteIdentifier, string? EventType = null, string? Severity = null, string? Keyword = null, DateTimeOffset? From = null, DateTimeOffset? To = null, int Page = 1, int PageSize = 50, string? InstanceName = null); public record QueryParkedMessagesCommand(string SiteIdentifier, int Page = 1, int PageSize = 50); +public record RetryParkedMessageCommand(string SiteIdentifier, string MessageId); +public record DiscardParkedMessageCommand(string SiteIdentifier, string MessageId); diff --git a/src/ScadaLink.Commons/Messages/Management/SecurityCommands.cs b/src/ScadaLink.Commons/Messages/Management/SecurityCommands.cs index 9870128..35208e9 100644 --- a/src/ScadaLink.Commons/Messages/Management/SecurityCommands.cs +++ b/src/ScadaLink.Commons/Messages/Management/SecurityCommands.cs @@ -11,3 +11,4 @@ public record UpdateApiKeyCommand(int ApiKeyId, bool IsEnabled); public record ListScopeRulesCommand(int MappingId); public record AddScopeRuleCommand(int MappingId, int SiteId); public record DeleteScopeRuleCommand(int ScopeRuleId); +public record ResolveRolesCommand(IReadOnlyList LdapGroups); diff --git a/src/ScadaLink.Commons/Messages/RemoteQuery/ParkedMessageDiscardRequest.cs b/src/ScadaLink.Commons/Messages/RemoteQuery/ParkedMessageDiscardRequest.cs new file mode 100644 index 0000000..02eaab8 --- /dev/null +++ b/src/ScadaLink.Commons/Messages/RemoteQuery/ParkedMessageDiscardRequest.cs @@ -0,0 +1,18 @@ +namespace ScadaLink.Commons.Messages.RemoteQuery; + +/// +/// Request to permanently discard a parked message at a site. +/// +public record ParkedMessageDiscardRequest( + string CorrelationId, + string SiteId, + string MessageId, + DateTimeOffset Timestamp); + +/// +/// Response from discarding a parked message. +/// +public record ParkedMessageDiscardResponse( + string CorrelationId, + bool Success, + string? ErrorMessage = null); diff --git a/src/ScadaLink.Commons/Messages/RemoteQuery/ParkedMessageRetryRequest.cs b/src/ScadaLink.Commons/Messages/RemoteQuery/ParkedMessageRetryRequest.cs new file mode 100644 index 0000000..f78b1c6 --- /dev/null +++ b/src/ScadaLink.Commons/Messages/RemoteQuery/ParkedMessageRetryRequest.cs @@ -0,0 +1,18 @@ +namespace ScadaLink.Commons.Messages.RemoteQuery; + +/// +/// Request to retry a parked message at a site (move back to pending queue). +/// +public record ParkedMessageRetryRequest( + string CorrelationId, + string SiteId, + string MessageId, + DateTimeOffset Timestamp); + +/// +/// Response from retrying a parked message. +/// +public record ParkedMessageRetryResponse( + string CorrelationId, + bool Success, + string? ErrorMessage = null); diff --git a/src/ScadaLink.Communication/CommunicationService.cs b/src/ScadaLink.Communication/CommunicationService.cs index 8a247dd..3aaa235 100644 --- a/src/ScadaLink.Communication/CommunicationService.cs +++ b/src/ScadaLink.Communication/CommunicationService.cs @@ -161,6 +161,22 @@ public class CommunicationService envelope, _options.QueryTimeout, cancellationToken); } + public async Task RetryParkedMessageAsync( + string siteId, ParkedMessageRetryRequest request, CancellationToken cancellationToken = default) + { + var envelope = new SiteEnvelope(siteId, request); + return await GetActor().Ask( + envelope, _options.QueryTimeout, cancellationToken); + } + + public async Task DiscardParkedMessageAsync( + string siteId, ParkedMessageDiscardRequest request, CancellationToken cancellationToken = default) + { + var envelope = new SiteEnvelope(siteId, request); + return await GetActor().Ask( + envelope, _options.QueryTimeout, cancellationToken); + } + // ── Pattern 8: Heartbeat (site→central, Tell) ── // Heartbeats are received by central, not sent. No method needed here. diff --git a/src/ScadaLink.DataConnectionLayer/Actors/DataConnectionActor.cs b/src/ScadaLink.DataConnectionLayer/Actors/DataConnectionActor.cs index c69c9f8..420ec49 100644 --- a/src/ScadaLink.DataConnectionLayer/Actors/DataConnectionActor.cs +++ b/src/ScadaLink.DataConnectionLayer/Actors/DataConnectionActor.cs @@ -62,6 +62,12 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers private readonly IDictionary _connectionDetails; + /// + /// Captured Self reference for use from non-actor threads (event handlers, callbacks). + /// Akka.NET's Self property is only valid inside the actor's message loop. + /// + private IActorRef _self = null!; + public DataConnectionActor( string connectionName, IDataConnection adapter, @@ -79,13 +85,28 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers protected override void PreStart() { _log.Info("DataConnectionActor [{0}] starting in Connecting state", _connectionName); + + // Capture Self for use from non-actor threads (event handlers, callbacks). + // Akka.NET's Self property is only valid inside the actor's message loop. + _self = Self; + + // Listen for unexpected adapter disconnections + _adapter.Disconnected += OnAdapterDisconnected; + BecomeConnecting(); } + private void OnAdapterDisconnected() + { + // Marshal the event onto the actor's message loop using captured _self reference. + // This runs on a background thread (gRPC stream reader), so Self would throw. + _self.Tell(new AdapterDisconnected()); + } + protected override void PostStop() { _log.Info("DataConnectionActor [{0}] stopping — disposing adapter", _connectionName); - // Clean up the adapter asynchronously + _adapter.Disconnected -= OnAdapterDisconnected; _ = _adapter.DisposeAsync().AsTask(); } @@ -276,7 +297,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers private void HandleDisconnect() { - _log.Warning("[{0}] Adapter reported disconnect", _connectionName); + _log.Warning("[{0}] AdapterDisconnected message received — transitioning to Reconnecting", _connectionName); BecomeReconnecting(); } diff --git a/src/ScadaLink.DataConnectionLayer/Adapters/ILmxProxyClient.cs b/src/ScadaLink.DataConnectionLayer/Adapters/ILmxProxyClient.cs index c622499..7e01df5 100644 --- a/src/ScadaLink.DataConnectionLayer/Adapters/ILmxProxyClient.cs +++ b/src/ScadaLink.DataConnectionLayer/Adapters/ILmxProxyClient.cs @@ -39,6 +39,7 @@ public interface ILmxProxyClient : IAsyncDisposable Task SubscribeAsync( IEnumerable addresses, Action onUpdate, + Action? onStreamError = null, CancellationToken cancellationToken = default); } @@ -48,7 +49,7 @@ public interface ILmxProxyClient : IAsyncDisposable /// public interface ILmxProxyClientFactory { - ILmxProxyClient Create(string host, int port, string? apiKey); + ILmxProxyClient Create(string host, int port, string? apiKey, int samplingIntervalMs = 0, bool useTls = false); } /// @@ -56,7 +57,7 @@ public interface ILmxProxyClientFactory /// public class DefaultLmxProxyClientFactory : ILmxProxyClientFactory { - public ILmxProxyClient Create(string host, int port, string? apiKey) => new StubLmxProxyClient(); + public ILmxProxyClient Create(string host, int port, string? apiKey, int samplingIntervalMs = 0, bool useTls = false) => new StubLmxProxyClient(); } /// @@ -93,7 +94,7 @@ internal class StubLmxProxyClient : ILmxProxyClient public Task WriteBatchAsync(IDictionary values, CancellationToken cancellationToken = default) => Task.CompletedTask; - public Task SubscribeAsync(IEnumerable addresses, Action onUpdate, CancellationToken cancellationToken = default) + public Task SubscribeAsync(IEnumerable addresses, Action onUpdate, Action? onStreamError = null, CancellationToken cancellationToken = default) => Task.FromResult(new StubLmxSubscription()); public ValueTask DisposeAsync() diff --git a/src/ScadaLink.DataConnectionLayer/Adapters/IOpcUaClient.cs b/src/ScadaLink.DataConnectionLayer/Adapters/IOpcUaClient.cs index 7189580..1eac814 100644 --- a/src/ScadaLink.DataConnectionLayer/Adapters/IOpcUaClient.cs +++ b/src/ScadaLink.DataConnectionLayer/Adapters/IOpcUaClient.cs @@ -1,12 +1,28 @@ namespace ScadaLink.DataConnectionLayer.Adapters; +/// +/// Configuration options for OPC UA connections, parsed from connection details JSON. +/// All values have defaults matching the OPC Foundation SDK's typical settings. +/// +public record OpcUaConnectionOptions( + int SessionTimeoutMs = 60000, + int OperationTimeoutMs = 15000, + int PublishingIntervalMs = 1000, + int KeepAliveCount = 10, + int LifetimeCount = 30, + int MaxNotificationsPerPublish = 100, + int SamplingIntervalMs = 1000, + int QueueSize = 10, + string SecurityMode = "None", + bool AutoAcceptUntrustedCerts = true); + /// /// WP-7: Abstraction over OPC UA client library for testability. /// The real implementation would wrap an OPC UA SDK (e.g., OPC Foundation .NET Standard Library). /// public interface IOpcUaClient : IAsyncDisposable { - Task ConnectAsync(string endpointUrl, CancellationToken cancellationToken = default); + Task ConnectAsync(string endpointUrl, OpcUaConnectionOptions? options = null, CancellationToken cancellationToken = default); Task DisconnectAsync(CancellationToken cancellationToken = default); bool IsConnected { get; } @@ -24,6 +40,12 @@ public interface IOpcUaClient : IAsyncDisposable string nodeId, CancellationToken cancellationToken = default); Task WriteValueAsync(string nodeId, object? value, CancellationToken cancellationToken = default); + + /// + /// Raised when the OPC UA session detects a keep-alive failure or the server + /// becomes unreachable. The adapter layer uses this to trigger reconnection. + /// + event Action? ConnectionLost; } /// @@ -50,8 +72,11 @@ public class DefaultOpcUaClientFactory : IOpcUaClientFactory internal class StubOpcUaClient : IOpcUaClient { public bool IsConnected { get; private set; } +#pragma warning disable CS0067 + public event Action? ConnectionLost; +#pragma warning restore CS0067 - public Task ConnectAsync(string endpointUrl, CancellationToken cancellationToken = default) + public Task ConnectAsync(string endpointUrl, OpcUaConnectionOptions? options = null, CancellationToken cancellationToken = default) { IsConnected = true; return Task.CompletedTask; diff --git a/src/ScadaLink.DataConnectionLayer/Adapters/LmxProxyDataConnection.cs b/src/ScadaLink.DataConnectionLayer/Adapters/LmxProxyDataConnection.cs index cb785fe..170d7b0 100644 --- a/src/ScadaLink.DataConnectionLayer/Adapters/LmxProxyDataConnection.cs +++ b/src/ScadaLink.DataConnectionLayer/Adapters/LmxProxyDataConnection.cs @@ -23,6 +23,7 @@ public class LmxProxyDataConnection : IDataConnection private ConnectionHealth _status = ConnectionHealth.Disconnected; private readonly Dictionary _subscriptions = new(); + private volatile bool _disconnectFired; public LmxProxyDataConnection(ILmxProxyClientFactory clientFactory, ILogger logger) { @@ -31,6 +32,7 @@ public class LmxProxyDataConnection : IDataConnection } public ConnectionHealth Status => _status; + public event Action? Disconnected; public async Task ConnectAsync(IDictionary connectionDetails, CancellationToken cancellationToken = default) { @@ -39,11 +41,15 @@ public class LmxProxyDataConnection : IDataConnection _port = port; connectionDetails.TryGetValue("ApiKey", out var apiKey); + var samplingIntervalMs = connectionDetails.TryGetValue("SamplingIntervalMs", out var sampStr) && int.TryParse(sampStr, out var samp) ? samp : 0; + var useTls = connectionDetails.TryGetValue("UseTls", out var tlsStr) && bool.TryParse(tlsStr, out var tls) && tls; + _status = ConnectionHealth.Connecting; - _client = _clientFactory.Create(_host, _port, apiKey); + _client = _clientFactory.Create(_host, _port, apiKey, samplingIntervalMs, useTls); await _client.ConnectAsync(cancellationToken); _status = ConnectionHealth.Connected; + _disconnectFired = false; _logger.LogInformation("LmxProxy connected to {Host}:{Port}", _host, _port); } @@ -62,13 +68,22 @@ public class LmxProxyDataConnection : IDataConnection { EnsureConnected(); - var vtq = await _client!.ReadAsync(tagPath, cancellationToken); - var quality = MapQuality(vtq.Quality); - var tagValue = new TagValue(vtq.Value, quality, new DateTimeOffset(vtq.TimestampUtc, TimeSpan.Zero)); + try + { + var vtq = await _client!.ReadAsync(tagPath, cancellationToken); + var quality = MapQuality(vtq.Quality); + var tagValue = new TagValue(vtq.Value, quality, new DateTimeOffset(vtq.TimestampUtc, TimeSpan.Zero)); - return vtq.Quality == LmxQuality.Bad - ? new ReadResult(false, tagValue, "LmxProxy read returned bad quality") - : new ReadResult(true, tagValue, null); + return vtq.Quality == LmxQuality.Bad + ? new ReadResult(false, tagValue, "LmxProxy read returned bad quality") + : new ReadResult(true, tagValue, null); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "LmxProxy read failed for {TagPath} — connection may be lost", tagPath); + RaiseDisconnected(); + throw; + } } public async Task> ReadBatchAsync(IEnumerable tagPaths, CancellationToken cancellationToken = default) @@ -161,6 +176,11 @@ public class LmxProxyDataConnection : IDataConnection var quality = MapQuality(vtq.Quality); callback(path, new TagValue(vtq.Value, quality, new DateTimeOffset(vtq.TimestampUtc, TimeSpan.Zero))); }, + onStreamError: () => + { + _logger.LogWarning("LmxProxy subscription stream ended unexpectedly for {TagPath}", tagPath); + RaiseDisconnected(); + }, cancellationToken); var subscriptionId = Guid.NewGuid().ToString("N"); @@ -199,6 +219,19 @@ public class LmxProxyDataConnection : IDataConnection throw new InvalidOperationException("LmxProxy client is not connected."); } + /// + /// Marks the connection as disconnected and fires the Disconnected event once. + /// Thread-safe: only the first caller triggers the event. + /// + private void RaiseDisconnected() + { + if (_disconnectFired) return; + _disconnectFired = true; + _status = ConnectionHealth.Disconnected; + _logger.LogWarning("LmxProxy connection to {Host}:{Port} lost", _host, _port); + Disconnected?.Invoke(); + } + private static QualityCode MapQuality(LmxQuality quality) => quality switch { LmxQuality.Good => QualityCode.Good, diff --git a/src/ScadaLink.DataConnectionLayer/Adapters/OpcUaDataConnection.cs b/src/ScadaLink.DataConnectionLayer/Adapters/OpcUaDataConnection.cs index a216053..12826dc 100644 --- a/src/ScadaLink.DataConnectionLayer/Adapters/OpcUaDataConnection.cs +++ b/src/ScadaLink.DataConnectionLayer/Adapters/OpcUaDataConnection.cs @@ -33,29 +33,62 @@ public class OpcUaDataConnection : IDataConnection _logger = logger; } + private volatile bool _disconnectFired; + public ConnectionHealth Status => _status; + public event Action? Disconnected; public async Task ConnectAsync(IDictionary connectionDetails, CancellationToken cancellationToken = default) { - // Support both "endpoint" (from JSON config) and "EndpointUrl" (programmatic) _endpointUrl = connectionDetails.TryGetValue("endpoint", out var url) ? url : connectionDetails.TryGetValue("EndpointUrl", out var url2) ? url2 : "opc.tcp://localhost:4840"; + + var options = new OpcUaConnectionOptions( + SessionTimeoutMs: ParseInt(connectionDetails, "SessionTimeoutMs", 60000), + OperationTimeoutMs: ParseInt(connectionDetails, "OperationTimeoutMs", 15000), + PublishingIntervalMs: ParseInt(connectionDetails, "PublishingIntervalMs", 1000), + KeepAliveCount: ParseInt(connectionDetails, "KeepAliveCount", 10), + LifetimeCount: ParseInt(connectionDetails, "LifetimeCount", 30), + MaxNotificationsPerPublish: ParseInt(connectionDetails, "MaxNotificationsPerPublish", 100), + SamplingIntervalMs: ParseInt(connectionDetails, "SamplingIntervalMs", 1000), + QueueSize: ParseInt(connectionDetails, "QueueSize", 10), + SecurityMode: connectionDetails.TryGetValue("SecurityMode", out var secMode) ? secMode : "None", + AutoAcceptUntrustedCerts: ParseBool(connectionDetails, "AutoAcceptUntrustedCerts", true)); + _status = ConnectionHealth.Connecting; _client = _clientFactory.Create(); - await _client.ConnectAsync(_endpointUrl, cancellationToken); + _client.ConnectionLost += OnClientConnectionLost; + await _client.ConnectAsync(_endpointUrl, options, cancellationToken); _status = ConnectionHealth.Connected; + _disconnectFired = false; _logger.LogInformation("OPC UA connected to {Endpoint}", _endpointUrl); } + internal static int ParseInt(IDictionary d, string key, int defaultValue) + { + return d.TryGetValue(key, out var str) && int.TryParse(str, out var val) ? val : defaultValue; + } + + internal static bool ParseBool(IDictionary d, string key, bool defaultValue) + { + return d.TryGetValue(key, out var str) && bool.TryParse(str, out var val) ? val : defaultValue; + } + + private void OnClientConnectionLost() + { + RaiseDisconnected(); + } + public async Task DisconnectAsync(CancellationToken cancellationToken = default) { if (_client != null) { + _client.ConnectionLost -= OnClientConnectionLost; await _client.DisconnectAsync(cancellationToken); _status = ConnectionHealth.Disconnected; _logger.LogInformation("OPC UA disconnected from {Endpoint}", _endpointUrl); @@ -92,13 +125,22 @@ public class OpcUaDataConnection : IDataConnection { EnsureConnected(); - var (value, timestamp, statusCode) = await _client!.ReadValueAsync(tagPath, cancellationToken); - var quality = MapStatusCode(statusCode); + try + { + var (value, timestamp, statusCode) = await _client!.ReadValueAsync(tagPath, cancellationToken); + var quality = MapStatusCode(statusCode); - if (quality == QualityCode.Bad) - return new ReadResult(false, null, $"OPC UA read returned bad status: 0x{statusCode:X8}"); + if (quality == QualityCode.Bad) + return new ReadResult(false, null, $"OPC UA read returned bad status: 0x{statusCode:X8}"); - return new ReadResult(true, new TagValue(value, quality, new DateTimeOffset(timestamp, TimeSpan.Zero)), null); + return new ReadResult(true, new TagValue(value, quality, new DateTimeOffset(timestamp, TimeSpan.Zero)), null); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "OPC UA read failed for {TagPath} — connection may be lost", tagPath); + RaiseDisconnected(); + throw; + } } public async Task> ReadBatchAsync(IEnumerable tagPaths, CancellationToken cancellationToken = default) @@ -163,6 +205,7 @@ public class OpcUaDataConnection : IDataConnection { if (_client != null) { + _client.ConnectionLost -= OnClientConnectionLost; await _client.DisposeAsync(); _client = null; } @@ -175,6 +218,19 @@ public class OpcUaDataConnection : IDataConnection throw new InvalidOperationException("OPC UA client is not connected."); } + /// + /// Marks the connection as disconnected and fires the Disconnected event once. + /// Thread-safe: only the first caller triggers the event. + /// + private void RaiseDisconnected() + { + if (_disconnectFired) return; + _disconnectFired = true; + _status = ConnectionHealth.Disconnected; + _logger.LogWarning("OPC UA connection to {Endpoint} lost", _endpointUrl); + Disconnected?.Invoke(); + } + /// /// Maps OPC UA StatusCode to QualityCode. /// StatusCode 0 = Good, high bit set = Bad, otherwise Uncertain. diff --git a/src/ScadaLink.DataConnectionLayer/Adapters/RealLmxProxyClient.cs b/src/ScadaLink.DataConnectionLayer/Adapters/RealLmxProxyClient.cs index 2204b85..9b35fad 100644 --- a/src/ScadaLink.DataConnectionLayer/Adapters/RealLmxProxyClient.cs +++ b/src/ScadaLink.DataConnectionLayer/Adapters/RealLmxProxyClient.cs @@ -14,25 +14,31 @@ internal class RealLmxProxyClient : ILmxProxyClient private readonly string _host; private readonly int _port; private readonly string? _apiKey; + private readonly int _samplingIntervalMs; + private readonly bool _useTls; private GrpcChannel? _channel; private ScadaService.ScadaServiceClient? _client; private string? _sessionId; private Metadata? _headers; - public RealLmxProxyClient(string host, int port, string? apiKey) + public RealLmxProxyClient(string host, int port, string? apiKey, int samplingIntervalMs = 0, bool useTls = false) { _host = host; _port = port; _apiKey = apiKey; + _samplingIntervalMs = samplingIntervalMs; + _useTls = useTls; } public bool IsConnected => _client != null && !string.IsNullOrEmpty(_sessionId); public async Task ConnectAsync(CancellationToken cancellationToken = default) { - AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true); + if (!_useTls) + AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true); - _channel = GrpcChannel.ForAddress($"http://{_host}:{_port}"); + var scheme = _useTls ? "https" : "http"; + _channel = GrpcChannel.ForAddress($"{scheme}://{_host}:{_port}"); _client = new ScadaService.ScadaServiceClient(_channel); _headers = new Metadata(); @@ -111,13 +117,13 @@ internal class RealLmxProxyClient : ILmxProxyClient throw new InvalidOperationException($"WriteBatch failed: {response.Message}"); } - public Task SubscribeAsync(IEnumerable addresses, Action onUpdate, CancellationToken cancellationToken = default) + public Task SubscribeAsync(IEnumerable addresses, Action onUpdate, Action? onStreamError = null, CancellationToken cancellationToken = default) { EnsureConnected(); var tags = addresses.ToList(); var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - var request = new SubscribeRequest { SessionId = _sessionId!, SamplingMs = 0 }; + var request = new SubscribeRequest { SessionId = _sessionId!, SamplingMs = _samplingIntervalMs }; request.Tags.AddRange(tags); var call = _client!.Subscribe(request, _headers, cancellationToken: cts.Token); @@ -131,9 +137,18 @@ internal class RealLmxProxyClient : ILmxProxyClient var msg = call.ResponseStream.Current; onUpdate(msg.Tag, ConvertVtq(msg)); } + // Stream ended normally (server closed) — treat as disconnect + _sessionId = null; + onStreamError?.Invoke(); } catch (OperationCanceledException) { } catch (RpcException ex) when (ex.StatusCode == StatusCode.Cancelled) { } + catch (RpcException) + { + // gRPC error (server offline, network failure) — signal disconnect + _sessionId = null; + onStreamError?.Invoke(); + } }, cts.Token); return Task.FromResult(new CtsSubscription(cts)); @@ -191,6 +206,6 @@ internal class RealLmxProxyClient : ILmxProxyClient /// public class RealLmxProxyClientFactory : ILmxProxyClientFactory { - public ILmxProxyClient Create(string host, int port, string? apiKey) - => new RealLmxProxyClient(host, port, apiKey); + public ILmxProxyClient Create(string host, int port, string? apiKey, int samplingIntervalMs = 0, bool useTls = false) + => new RealLmxProxyClient(host, port, apiKey, samplingIntervalMs, useTls); } diff --git a/src/ScadaLink.DataConnectionLayer/Adapters/RealOpcUaClient.cs b/src/ScadaLink.DataConnectionLayer/Adapters/RealOpcUaClient.cs index 4673c6b..de00a8a 100644 --- a/src/ScadaLink.DataConnectionLayer/Adapters/RealOpcUaClient.cs +++ b/src/ScadaLink.DataConnectionLayer/Adapters/RealOpcUaClient.cs @@ -14,31 +14,44 @@ public class RealOpcUaClient : IOpcUaClient private Subscription? _subscription; private readonly Dictionary _monitoredItems = new(); private readonly Dictionary> _callbacks = new(); + private volatile bool _connectionLostFired; + private OpcUaConnectionOptions _options = new(); public bool IsConnected => _session?.Connected ?? false; + public event Action? ConnectionLost; - public async Task ConnectAsync(string endpointUrl, CancellationToken cancellationToken = default) + public async Task ConnectAsync(string endpointUrl, OpcUaConnectionOptions? options = null, CancellationToken cancellationToken = default) { + var opts = options ?? new OpcUaConnectionOptions(); + + var preferredSecurityMode = opts.SecurityMode?.ToUpperInvariant() switch + { + "SIGN" => MessageSecurityMode.Sign, + "SIGNANDENCRYPT" => MessageSecurityMode.SignAndEncrypt, + _ => MessageSecurityMode.None + }; + var appConfig = new ApplicationConfiguration { ApplicationName = "ScadaLink-DCL", ApplicationType = ApplicationType.Client, SecurityConfiguration = new SecurityConfiguration { - AutoAcceptUntrustedCertificates = true, + AutoAcceptUntrustedCertificates = opts.AutoAcceptUntrustedCerts, ApplicationCertificate = new CertificateIdentifier(), TrustedIssuerCertificates = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "ScadaLink", "pki", "issuers") }, TrustedPeerCertificates = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "ScadaLink", "pki", "trusted") }, RejectedCertificateStore = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "ScadaLink", "pki", "rejected") } }, - ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 }, - TransportQuotas = new TransportQuotas { OperationTimeout = 15000 } + ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = opts.SessionTimeoutMs }, + TransportQuotas = new TransportQuotas { OperationTimeout = opts.OperationTimeoutMs } }; await appConfig.ValidateAsync(ApplicationType.Client); - appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true; + if (opts.AutoAcceptUntrustedCerts) + appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true; - // Discover endpoints from the server, pick the no-security one + // Discover endpoints from the server, pick the preferred security mode EndpointDescription? endpoint; try { @@ -49,7 +62,7 @@ public class RealOpcUaClient : IOpcUaClient var endpoints = discoveryClient.GetEndpoints(null); #pragma warning restore CS0618 endpoint = endpoints - .Where(e => e.SecurityMode == MessageSecurityMode.None) + .Where(e => e.SecurityMode == preferredSecurityMode) .FirstOrDefault() ?? endpoints.FirstOrDefault(); } catch @@ -66,17 +79,24 @@ public class RealOpcUaClient : IOpcUaClient #pragma warning restore CS0618 _session = await sessionFactory.CreateAsync( appConfig, configuredEndpoint, false, - "ScadaLink-DCL-Session", 60000, null, null, cancellationToken); + "ScadaLink-DCL-Session", (uint)opts.SessionTimeoutMs, null, null, cancellationToken); + + // Detect server going offline via keep-alive failures + _connectionLostFired = false; + _session.KeepAlive += OnSessionKeepAlive; + + // Store options for monitored item creation + _options = opts; // Create a default subscription for all monitored items _subscription = new Subscription(_session.DefaultSubscription) { DisplayName = "ScadaLink", PublishingEnabled = true, - PublishingInterval = 1000, - KeepAliveCount = 10, - LifetimeCount = 30, - MaxNotificationsPerPublish = 100 + PublishingInterval = opts.PublishingIntervalMs, + KeepAliveCount = (uint)opts.KeepAliveCount, + LifetimeCount = (uint)opts.LifetimeCount, + MaxNotificationsPerPublish = (uint)opts.MaxNotificationsPerPublish }; _session.AddSubscription(_subscription); @@ -92,6 +112,7 @@ public class RealOpcUaClient : IOpcUaClient } if (_session != null) { + _session.KeepAlive -= OnSessionKeepAlive; await _session.CloseAsync(cancellationToken); _session = null; } @@ -112,8 +133,8 @@ public class RealOpcUaClient : IOpcUaClient DisplayName = nodeId, StartNodeId = nodeId, AttributeId = Attributes.Value, - SamplingInterval = 1000, - QueueSize = 10, + SamplingInterval = _options.SamplingIntervalMs, + QueueSize = (uint)_options.QueueSize, DiscardOldest = true }; @@ -188,6 +209,20 @@ public class RealOpcUaClient : IOpcUaClient return response.Results[0].Code; } + /// + /// Called by the OPC UA SDK when a keep-alive response arrives (or fails). + /// When CurrentState is bad, the server is unreachable. + /// + private void OnSessionKeepAlive(ISession session, KeepAliveEventArgs e) + { + if (ServiceResult.IsBad(e.Status)) + { + if (_connectionLostFired) return; + _connectionLostFired = true; + ConnectionLost?.Invoke(); + } + } + public async ValueTask DisposeAsync() { await DisconnectAsync(); diff --git a/src/ScadaLink.HealthMonitoring/SiteHealthCollector.cs b/src/ScadaLink.HealthMonitoring/SiteHealthCollector.cs index 05310d1..6b3fa59 100644 --- a/src/ScadaLink.HealthMonitoring/SiteHealthCollector.cs +++ b/src/ScadaLink.HealthMonitoring/SiteHealthCollector.cs @@ -114,6 +114,9 @@ public class SiteHealthCollector : ISiteHealthCollector // Snapshot current S&F buffer depths var sfBufferDepths = new Dictionary(_sfBufferDepths); + // Determine node role from active/standby state + var nodeRole = _isActiveNode ? "Active" : "Standby"; + return new SiteHealthReport( SiteId: siteId, SequenceNumber: 0, // Caller (HealthReportSender) assigns the sequence number @@ -126,6 +129,7 @@ public class SiteHealthCollector : ISiteHealthCollector DeadLetterCount: deadLetters, DeployedInstanceCount: _deployedInstanceCount, EnabledInstanceCount: _enabledInstanceCount, - DisabledInstanceCount: _disabledInstanceCount); + DisabledInstanceCount: _disabledInstanceCount, + NodeRole: nodeRole); } } diff --git a/src/ScadaLink.Host/Health/AkkaClusterHealthCheck.cs b/src/ScadaLink.Host/Health/AkkaClusterHealthCheck.cs index 0e2577d..8251d4f 100644 --- a/src/ScadaLink.Host/Health/AkkaClusterHealthCheck.cs +++ b/src/ScadaLink.Host/Health/AkkaClusterHealthCheck.cs @@ -1,19 +1,42 @@ +using Akka.Actor; +using Akka.Cluster; using Microsoft.Extensions.Diagnostics.HealthChecks; namespace ScadaLink.Host.Health; /// -/// Health check that verifies Akka.NET cluster membership. -/// Initially returns healthy; will be refined when Akka cluster integration is complete. +/// Health check that verifies this node is an active member of the Akka.NET cluster. +/// Returns healthy only if the node's self-member status is Up or Joining. /// public class AkkaClusterHealthCheck : IHealthCheck { + private readonly ActorSystem? _system; + + public AkkaClusterHealthCheck(ActorSystem? system = null) + { + _system = system; + } + public Task CheckHealthAsync( HealthCheckContext context, CancellationToken cancellationToken = default) { - // TODO: Query Akka Cluster.Get(system).State to verify this node is Up. - // For now, return healthy as Akka cluster wiring is being established. - return Task.FromResult(HealthCheckResult.Healthy("Akka cluster health check placeholder.")); + if (_system == null) + return Task.FromResult(HealthCheckResult.Degraded("ActorSystem not yet available.")); + + var cluster = Cluster.Get(_system); + var status = cluster.SelfMember.Status; + + var result = status switch + { + MemberStatus.Up or MemberStatus.Joining => + HealthCheckResult.Healthy($"Akka cluster member status: {status}"), + MemberStatus.Leaving or MemberStatus.Exiting => + HealthCheckResult.Degraded($"Akka cluster member status: {status}"), + _ => + HealthCheckResult.Unhealthy($"Akka cluster member status: {status}") + }; + + return Task.FromResult(result); } } diff --git a/src/ScadaLink.ManagementService/ManagementActor.cs b/src/ScadaLink.ManagementService/ManagementActor.cs index 3e8a6d6..9506100 100644 --- a/src/ScadaLink.ManagementService/ManagementActor.cs +++ b/src/ScadaLink.ManagementService/ManagementActor.cs @@ -12,12 +12,14 @@ using ScadaLink.Commons.Entities.Notifications; using ScadaLink.Commons.Entities.Security; using ScadaLink.Commons.Entities.Sites; using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Messages.DebugView; using ScadaLink.Commons.Messages.Management; using ScadaLink.Commons.Messages.RemoteQuery; using ScadaLink.DeploymentManager; using ScadaLink.HealthMonitoring; using ScadaLink.Communication; +using ScadaLink.Security; using ScadaLink.TemplateEngine; using ScadaLink.TemplateEngine.Services; @@ -61,10 +63,14 @@ public class ManagementActor : ReceiveActor using var scope = _serviceProvider.CreateScope(); try { - var result = await DispatchCommand(scope.ServiceProvider, envelope.Command, user.Username); + var result = await DispatchCommand(scope.ServiceProvider, envelope.Command, user); var json = JsonConvert.SerializeObject(result, Formatting.None); sender.Tell(new ManagementSuccess(correlationId, json)); } + catch (SiteScopeViolationException ex) + { + sender.Tell(new ManagementUnauthorized(correlationId, ex.Message)); + } catch (Exception ex) { _logger.LogError(ex, "Management command {Command} failed (CorrelationId={CorrelationId})", @@ -111,41 +117,43 @@ public class ManagementActor : ReceiveActor // Deployment operations CreateInstanceCommand or MgmtDeployInstanceCommand or MgmtEnableInstanceCommand or MgmtDisableInstanceCommand or MgmtDeleteInstanceCommand - or SetConnectionBindingsCommand + or SetConnectionBindingsCommand or SetInstanceOverridesCommand or SetInstanceAreaCommand + or GetDeploymentDiffCommand or MgmtDeployArtifactsCommand + or RetryParkedMessageCommand or DiscardParkedMessageCommand or DebugSnapshotCommand => "Deployment", // Read-only queries -- any authenticated user _ => null }; - private async Task DispatchCommand(IServiceProvider sp, object command, string user) + private async Task DispatchCommand(IServiceProvider sp, object command, AuthenticatedUser user) { return command switch { // Templates ListTemplatesCommand => await HandleListTemplates(sp), GetTemplateCommand cmd => await HandleGetTemplate(sp, cmd), - CreateTemplateCommand cmd => await HandleCreateTemplate(sp, cmd, user), - UpdateTemplateCommand cmd => await HandleUpdateTemplate(sp, cmd, user), - DeleteTemplateCommand cmd => await HandleDeleteTemplate(sp, cmd, user), + CreateTemplateCommand cmd => await HandleCreateTemplate(sp, cmd, user.Username), + UpdateTemplateCommand cmd => await HandleUpdateTemplate(sp, cmd, user.Username), + DeleteTemplateCommand cmd => await HandleDeleteTemplate(sp, cmd, user.Username), ValidateTemplateCommand cmd => await HandleValidateTemplate(sp, cmd), // Template members - AddTemplateAttributeCommand cmd => await HandleAddAttribute(sp, cmd, user), - UpdateTemplateAttributeCommand cmd => await HandleUpdateAttribute(sp, cmd, user), - DeleteTemplateAttributeCommand cmd => await HandleDeleteAttribute(sp, cmd, user), - AddTemplateAlarmCommand cmd => await HandleAddAlarm(sp, cmd, user), - UpdateTemplateAlarmCommand cmd => await HandleUpdateAlarm(sp, cmd, user), - DeleteTemplateAlarmCommand cmd => await HandleDeleteAlarm(sp, cmd, user), - AddTemplateScriptCommand cmd => await HandleAddScript(sp, cmd, user), - UpdateTemplateScriptCommand cmd => await HandleUpdateScript(sp, cmd, user), - DeleteTemplateScriptCommand cmd => await HandleDeleteScript(sp, cmd, user), - AddTemplateCompositionCommand cmd => await HandleAddComposition(sp, cmd, user), - DeleteTemplateCompositionCommand cmd => await HandleDeleteComposition(sp, cmd, user), + AddTemplateAttributeCommand cmd => await HandleAddAttribute(sp, cmd, user.Username), + UpdateTemplateAttributeCommand cmd => await HandleUpdateAttribute(sp, cmd, user.Username), + DeleteTemplateAttributeCommand cmd => await HandleDeleteAttribute(sp, cmd, user.Username), + AddTemplateAlarmCommand cmd => await HandleAddAlarm(sp, cmd, user.Username), + UpdateTemplateAlarmCommand cmd => await HandleUpdateAlarm(sp, cmd, user.Username), + DeleteTemplateAlarmCommand cmd => await HandleDeleteAlarm(sp, cmd, user.Username), + AddTemplateScriptCommand cmd => await HandleAddScript(sp, cmd, user.Username), + UpdateTemplateScriptCommand cmd => await HandleUpdateScript(sp, cmd, user.Username), + DeleteTemplateScriptCommand cmd => await HandleDeleteScript(sp, cmd, user.Username), + AddTemplateCompositionCommand cmd => await HandleAddComposition(sp, cmd, user.Username), + DeleteTemplateCompositionCommand cmd => await HandleDeleteComposition(sp, cmd, user.Username), // Instances - ListInstancesCommand cmd => await HandleListInstances(sp, cmd), + ListInstancesCommand cmd => await HandleListInstances(sp, cmd, user), GetInstanceCommand cmd => await HandleGetInstance(sp, cmd), CreateInstanceCommand cmd => await HandleCreateInstance(sp, cmd, user), MgmtDeployInstanceCommand cmd => await HandleDeployInstance(sp, cmd, user), @@ -153,85 +161,88 @@ public class ManagementActor : ReceiveActor MgmtDisableInstanceCommand cmd => await HandleDisableInstance(sp, cmd, user), MgmtDeleteInstanceCommand cmd => await HandleDeleteInstance(sp, cmd, user), SetConnectionBindingsCommand cmd => await HandleSetConnectionBindings(sp, cmd, user), + SetInstanceOverridesCommand cmd => await HandleSetInstanceOverrides(sp, cmd, user), + SetInstanceAreaCommand cmd => await HandleSetInstanceArea(sp, cmd, user), // Sites - ListSitesCommand => await HandleListSites(sp), + ListSitesCommand => await HandleListSites(sp, user), GetSiteCommand cmd => await HandleGetSite(sp, cmd), - CreateSiteCommand cmd => await HandleCreateSite(sp, cmd), - UpdateSiteCommand cmd => await HandleUpdateSite(sp, cmd), - DeleteSiteCommand cmd => await HandleDeleteSite(sp, cmd), + CreateSiteCommand cmd => await HandleCreateSite(sp, cmd, user.Username), + UpdateSiteCommand cmd => await HandleUpdateSite(sp, cmd, user.Username), + DeleteSiteCommand cmd => await HandleDeleteSite(sp, cmd, user.Username), ListAreasCommand cmd => await HandleListAreas(sp, cmd), - CreateAreaCommand cmd => await HandleCreateArea(sp, cmd), - DeleteAreaCommand cmd => await HandleDeleteArea(sp, cmd), - UpdateAreaCommand cmd => await HandleUpdateArea(sp, cmd), + CreateAreaCommand cmd => await HandleCreateArea(sp, cmd, user.Username), + DeleteAreaCommand cmd => await HandleDeleteArea(sp, cmd, user.Username), + UpdateAreaCommand cmd => await HandleUpdateArea(sp, cmd, user.Username), // Data Connections ListDataConnectionsCommand => await HandleListDataConnections(sp), GetDataConnectionCommand cmd => await HandleGetDataConnection(sp, cmd), - CreateDataConnectionCommand cmd => await HandleCreateDataConnection(sp, cmd), - UpdateDataConnectionCommand cmd => await HandleUpdateDataConnection(sp, cmd), - DeleteDataConnectionCommand cmd => await HandleDeleteDataConnection(sp, cmd), - AssignDataConnectionToSiteCommand cmd => await HandleAssignDataConnectionToSite(sp, cmd), - UnassignDataConnectionFromSiteCommand cmd => await HandleUnassignDataConnectionFromSite(sp, cmd), + CreateDataConnectionCommand cmd => await HandleCreateDataConnection(sp, cmd, user.Username), + UpdateDataConnectionCommand cmd => await HandleUpdateDataConnection(sp, cmd, user.Username), + DeleteDataConnectionCommand cmd => await HandleDeleteDataConnection(sp, cmd, user.Username), + AssignDataConnectionToSiteCommand cmd => await HandleAssignDataConnectionToSite(sp, cmd, user.Username), + UnassignDataConnectionFromSiteCommand cmd => await HandleUnassignDataConnectionFromSite(sp, cmd, user.Username), // External Systems ListExternalSystemsCommand => await HandleListExternalSystems(sp), GetExternalSystemCommand cmd => await HandleGetExternalSystem(sp, cmd), - CreateExternalSystemCommand cmd => await HandleCreateExternalSystem(sp, cmd), - UpdateExternalSystemCommand cmd => await HandleUpdateExternalSystem(sp, cmd), - DeleteExternalSystemCommand cmd => await HandleDeleteExternalSystem(sp, cmd), + CreateExternalSystemCommand cmd => await HandleCreateExternalSystem(sp, cmd, user.Username), + UpdateExternalSystemCommand cmd => await HandleUpdateExternalSystem(sp, cmd, user.Username), + DeleteExternalSystemCommand cmd => await HandleDeleteExternalSystem(sp, cmd, user.Username), ListExternalSystemMethodsCommand cmd => await HandleListExternalSystemMethods(sp, cmd), GetExternalSystemMethodCommand cmd => await HandleGetExternalSystemMethod(sp, cmd), - CreateExternalSystemMethodCommand cmd => await HandleCreateExternalSystemMethod(sp, cmd), - UpdateExternalSystemMethodCommand cmd => await HandleUpdateExternalSystemMethod(sp, cmd), - DeleteExternalSystemMethodCommand cmd => await HandleDeleteExternalSystemMethod(sp, cmd), + CreateExternalSystemMethodCommand cmd => await HandleCreateExternalSystemMethod(sp, cmd, user.Username), + UpdateExternalSystemMethodCommand cmd => await HandleUpdateExternalSystemMethod(sp, cmd, user.Username), + DeleteExternalSystemMethodCommand cmd => await HandleDeleteExternalSystemMethod(sp, cmd, user.Username), // Notification Lists ListNotificationListsCommand => await HandleListNotificationLists(sp), GetNotificationListCommand cmd => await HandleGetNotificationList(sp, cmd), - CreateNotificationListCommand cmd => await HandleCreateNotificationList(sp, cmd), - UpdateNotificationListCommand cmd => await HandleUpdateNotificationList(sp, cmd), - DeleteNotificationListCommand cmd => await HandleDeleteNotificationList(sp, cmd), + CreateNotificationListCommand cmd => await HandleCreateNotificationList(sp, cmd, user.Username), + UpdateNotificationListCommand cmd => await HandleUpdateNotificationList(sp, cmd, user.Username), + DeleteNotificationListCommand cmd => await HandleDeleteNotificationList(sp, cmd, user.Username), ListSmtpConfigsCommand => await HandleListSmtpConfigs(sp), - UpdateSmtpConfigCommand cmd => await HandleUpdateSmtpConfig(sp, cmd), + UpdateSmtpConfigCommand cmd => await HandleUpdateSmtpConfig(sp, cmd, user.Username), // Shared Scripts ListSharedScriptsCommand => await HandleListSharedScripts(sp), GetSharedScriptCommand cmd => await HandleGetSharedScript(sp, cmd), - CreateSharedScriptCommand cmd => await HandleCreateSharedScript(sp, cmd, user), - UpdateSharedScriptCommand cmd => await HandleUpdateSharedScript(sp, cmd, user), - DeleteSharedScriptCommand cmd => await HandleDeleteSharedScript(sp, cmd, user), + CreateSharedScriptCommand cmd => await HandleCreateSharedScript(sp, cmd, user.Username), + UpdateSharedScriptCommand cmd => await HandleUpdateSharedScript(sp, cmd, user.Username), + DeleteSharedScriptCommand cmd => await HandleDeleteSharedScript(sp, cmd, user.Username), // Database Connections (External System) ListDatabaseConnectionsCommand => await HandleListDatabaseConnections(sp), GetDatabaseConnectionCommand cmd => await HandleGetDatabaseConnection(sp, cmd), - CreateDatabaseConnectionDefCommand cmd => await HandleCreateDatabaseConnection(sp, cmd), - UpdateDatabaseConnectionDefCommand cmd => await HandleUpdateDatabaseConnection(sp, cmd), - DeleteDatabaseConnectionDefCommand cmd => await HandleDeleteDatabaseConnection(sp, cmd), + CreateDatabaseConnectionDefCommand cmd => await HandleCreateDatabaseConnection(sp, cmd, user.Username), + UpdateDatabaseConnectionDefCommand cmd => await HandleUpdateDatabaseConnection(sp, cmd, user.Username), + DeleteDatabaseConnectionDefCommand cmd => await HandleDeleteDatabaseConnection(sp, cmd, user.Username), // Inbound API Methods ListApiMethodsCommand => await HandleListApiMethods(sp), GetApiMethodCommand cmd => await HandleGetApiMethod(sp, cmd), - CreateApiMethodCommand cmd => await HandleCreateApiMethod(sp, cmd), - UpdateApiMethodCommand cmd => await HandleUpdateApiMethod(sp, cmd), - DeleteApiMethodCommand cmd => await HandleDeleteApiMethod(sp, cmd), + CreateApiMethodCommand cmd => await HandleCreateApiMethod(sp, cmd, user.Username), + UpdateApiMethodCommand cmd => await HandleUpdateApiMethod(sp, cmd, user.Username), + DeleteApiMethodCommand cmd => await HandleDeleteApiMethod(sp, cmd, user.Username), // Security ListRoleMappingsCommand => await HandleListRoleMappings(sp), - CreateRoleMappingCommand cmd => await HandleCreateRoleMapping(sp, cmd), - UpdateRoleMappingCommand cmd => await HandleUpdateRoleMapping(sp, cmd), - DeleteRoleMappingCommand cmd => await HandleDeleteRoleMapping(sp, cmd), + CreateRoleMappingCommand cmd => await HandleCreateRoleMapping(sp, cmd, user.Username), + UpdateRoleMappingCommand cmd => await HandleUpdateRoleMapping(sp, cmd, user.Username), + DeleteRoleMappingCommand cmd => await HandleDeleteRoleMapping(sp, cmd, user.Username), ListApiKeysCommand => await HandleListApiKeys(sp), - CreateApiKeyCommand cmd => await HandleCreateApiKey(sp, cmd), - DeleteApiKeyCommand cmd => await HandleDeleteApiKey(sp, cmd), - UpdateApiKeyCommand cmd => await HandleUpdateApiKey(sp, cmd), + CreateApiKeyCommand cmd => await HandleCreateApiKey(sp, cmd, user.Username), + DeleteApiKeyCommand cmd => await HandleDeleteApiKey(sp, cmd, user.Username), + UpdateApiKeyCommand cmd => await HandleUpdateApiKey(sp, cmd, user.Username), ListScopeRulesCommand cmd => await HandleListScopeRules(sp, cmd), - AddScopeRuleCommand cmd => await HandleAddScopeRule(sp, cmd), - DeleteScopeRuleCommand cmd => await HandleDeleteScopeRule(sp, cmd), + AddScopeRuleCommand cmd => await HandleAddScopeRule(sp, cmd, user.Username), + DeleteScopeRuleCommand cmd => await HandleDeleteScopeRule(sp, cmd, user.Username), // Deployments - MgmtDeployArtifactsCommand cmd => await HandleDeployArtifacts(sp, cmd, user), + MgmtDeployArtifactsCommand cmd => await HandleDeployArtifacts(sp, cmd, user.Username), QueryDeploymentsCommand cmd => await HandleQueryDeployments(sp, cmd), + GetDeploymentDiffCommand cmd => await HandleGetDeploymentDiff(sp, cmd, user), // Audit Log QueryAuditLogCommand cmd => await HandleQueryAuditLog(sp, cmd), @@ -243,12 +254,79 @@ public class ManagementActor : ReceiveActor // Remote Queries QueryEventLogsCommand cmd => await HandleQueryEventLogs(sp, cmd), QueryParkedMessagesCommand cmd => await HandleQueryParkedMessages(sp, cmd), + RetryParkedMessageCommand cmd => await HandleRetryParkedMessage(sp, cmd), + DiscardParkedMessageCommand cmd => await HandleDiscardParkedMessage(sp, cmd), DebugSnapshotCommand cmd => await HandleDebugSnapshot(sp, cmd), + // Role resolution (for CLI LDAP auth) + ResolveRolesCommand cmd => await HandleResolveRoles(sp, cmd), + _ => throw new NotSupportedException($"Unknown command type: {command.GetType().Name}") }; } + // ======================================================================== + // Role resolution + // ======================================================================== + + private static async Task HandleResolveRoles(IServiceProvider sp, ResolveRolesCommand cmd) + { + var roleMapper = new RoleMapper(sp.GetRequiredService()); + var result = await roleMapper.MapGroupsToRolesAsync(cmd.LdapGroups); + return new + { + Roles = result.Roles, + PermittedSiteIds = result.PermittedSiteIds, + IsSystemWideDeployment = result.IsSystemWideDeployment + }; + } + + // ======================================================================== + // Site-scope enforcement + // ======================================================================== + + /// + /// Throws SiteScopeViolationException if the user has site-scoped Deployment + /// and the target site is not in their permitted sites. + /// Users with Admin or Design roles, or system-wide Deployment, are not restricted. + /// + private static void EnforceSiteScope(AuthenticatedUser user, int? targetSiteId) + { + if (targetSiteId == null) return; + if (user.PermittedSiteIds.Length == 0) return; // system-wide access + if (user.Roles.Contains("Admin", StringComparer.OrdinalIgnoreCase)) return; + + if (!user.PermittedSiteIds.Contains(targetSiteId.Value.ToString())) + { + throw new SiteScopeViolationException( + $"Access denied: your Deployment role is scoped to sites [{string.Join(", ", user.PermittedSiteIds)}] " + + $"and does not include site {targetSiteId.Value}."); + } + } + + /// + /// Resolves the site ID for an instance and enforces site-scope. + /// + private static async Task EnforceSiteScopeForInstance(IServiceProvider sp, AuthenticatedUser user, int instanceId) + { + if (user.PermittedSiteIds.Length == 0) return; + if (user.Roles.Contains("Admin", StringComparer.OrdinalIgnoreCase)) return; + + var repo = sp.GetRequiredService(); + var instance = await repo.GetInstanceByIdAsync(instanceId); + if (instance != null) + EnforceSiteScope(user, instance.SiteId); + } + + /// + /// Helper to log an audit entry after a successful mutation. + /// + private static async Task AuditAsync(IServiceProvider sp, string user, string action, string entityType, string entityId, string entityName, object? afterState) + { + var auditService = sp.GetRequiredService(); + await auditService.LogAsync(user, action, entityType, entityId, entityName, afterState); + } + // ======================================================================== // Template handlers // ======================================================================== @@ -294,18 +372,83 @@ public class ManagementActor : ReceiveActor private static async Task HandleValidateTemplate(IServiceProvider sp, ValidateTemplateCommand cmd) { + var repo = sp.GetRequiredService(); + + // Load the template with all members + var template = await repo.GetTemplateWithChildrenAsync(cmd.TemplateId) + ?? throw new InvalidOperationException($"Template with ID {cmd.TemplateId} not found."); + + var attributes = await repo.GetAttributesByTemplateIdAsync(cmd.TemplateId); + var alarms = await repo.GetAlarmsByTemplateIdAsync(cmd.TemplateId); + var scripts = await repo.GetScriptsByTemplateIdAsync(cmd.TemplateId); + + // Build a FlattenedConfiguration from the template for the full validation pipeline + var flatConfig = new Commons.Types.Flattening.FlattenedConfiguration + { + InstanceUniqueName = $"validation-{template.Name}", + TemplateId = template.Id, + Attributes = attributes.Select(a => new Commons.Types.Flattening.ResolvedAttribute + { + CanonicalName = a.Name, + Value = a.Value, + DataType = a.DataType.ToString(), + IsLocked = a.IsLocked, + DataSourceReference = a.DataSourceReference + }).ToList(), + Alarms = alarms.Select(a => new Commons.Types.Flattening.ResolvedAlarm + { + CanonicalName = a.Name, + PriorityLevel = a.PriorityLevel, + IsLocked = a.IsLocked, + TriggerType = a.TriggerType.ToString(), + TriggerConfiguration = a.TriggerConfiguration + }).ToList(), + Scripts = scripts.Select(s => new Commons.Types.Flattening.ResolvedScript + { + CanonicalName = s.Name, + Code = s.Code, + IsLocked = s.IsLocked, + TriggerType = s.TriggerType, + TriggerConfiguration = s.TriggerConfiguration, + ParameterDefinitions = s.ParameterDefinitions, + ReturnDefinition = s.ReturnDefinition + }).ToList() + }; + + // Run full validation pipeline (collisions, script compilation, trigger refs, bindings) + var validationService = new TemplateEngine.Validation.ValidationService(); + var validationResult = validationService.Validate(flatConfig); + + // Also detect naming collisions across the inheritance/composition graph var svc = sp.GetRequiredService(); - return await svc.DetectCollisionsAsync(cmd.TemplateId); + var collisions = await svc.DetectCollisionsAsync(cmd.TemplateId); + if (collisions.Count > 0) + { + var collisionErrors = collisions.Select(c => + Commons.Types.Flattening.ValidationEntry.Error( + Commons.Types.Flattening.ValidationCategory.NamingCollision, c)).ToArray(); + var collisionResult = new Commons.Types.Flattening.ValidationResult { Errors = collisionErrors }; + validationResult = Commons.Types.Flattening.ValidationResult.Merge(validationResult, collisionResult); + } + + return validationResult; } // ======================================================================== // Instance handlers // ======================================================================== - private static async Task HandleListInstances(IServiceProvider sp, ListInstancesCommand cmd) + private static async Task HandleListInstances(IServiceProvider sp, ListInstancesCommand cmd, AuthenticatedUser user) { var repo = sp.GetRequiredService(); - return await repo.GetInstancesFilteredAsync(cmd.SiteId, cmd.TemplateId, cmd.SearchTerm); + var instances = await repo.GetInstancesFilteredAsync(cmd.SiteId, cmd.TemplateId, cmd.SearchTerm); + // Filter by permitted sites for site-scoped users + if (user.PermittedSiteIds.Length > 0 && !user.Roles.Contains("Admin", StringComparer.OrdinalIgnoreCase)) + { + var permittedIds = new HashSet(user.PermittedSiteIds); + instances = instances.Where(i => permittedIds.Contains(i.SiteId.ToString())).ToList(); + } + return instances; } private static async Task HandleGetInstance(IServiceProvider sp, GetInstanceCommand cmd) @@ -314,68 +457,130 @@ public class ManagementActor : ReceiveActor return await repo.GetInstanceByIdAsync(cmd.InstanceId); } - private static async Task HandleCreateInstance(IServiceProvider sp, CreateInstanceCommand cmd, string user) + private static async Task HandleCreateInstance(IServiceProvider sp, CreateInstanceCommand cmd, AuthenticatedUser user) { + EnforceSiteScope(user, cmd.SiteId); var svc = sp.GetRequiredService(); - var result = await svc.CreateInstanceAsync(cmd.UniqueName, cmd.TemplateId, cmd.SiteId, cmd.AreaId, user); - return result.IsSuccess - ? result.Value - : throw new InvalidOperationException(result.Error); + var result = await svc.CreateInstanceAsync(cmd.UniqueName, cmd.TemplateId, cmd.SiteId, cmd.AreaId, user.Username); + if (!result.IsSuccess) throw new InvalidOperationException(result.Error); + await AuditAsync(sp, user.Username, "Create", "Instance", result.Value.Id.ToString(), result.Value.UniqueName, result.Value); + return result.Value; } - private static async Task HandleDeployInstance(IServiceProvider sp, MgmtDeployInstanceCommand cmd, string user) + private static async Task HandleDeployInstance(IServiceProvider sp, MgmtDeployInstanceCommand cmd, AuthenticatedUser user) { + await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId); var svc = sp.GetRequiredService(); - var result = await svc.DeployInstanceAsync(cmd.InstanceId, user); + var result = await svc.DeployInstanceAsync(cmd.InstanceId, user.Username); return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error); } - private static async Task HandleEnableInstance(IServiceProvider sp, MgmtEnableInstanceCommand cmd, string user) + private static async Task HandleEnableInstance(IServiceProvider sp, MgmtEnableInstanceCommand cmd, AuthenticatedUser user) { + await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId); var svc = sp.GetRequiredService(); - var result = await svc.EnableInstanceAsync(cmd.InstanceId, user); + var result = await svc.EnableInstanceAsync(cmd.InstanceId, user.Username); return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error); } - private static async Task HandleDisableInstance(IServiceProvider sp, MgmtDisableInstanceCommand cmd, string user) + private static async Task HandleDisableInstance(IServiceProvider sp, MgmtDisableInstanceCommand cmd, AuthenticatedUser user) { + await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId); var svc = sp.GetRequiredService(); - var result = await svc.DisableInstanceAsync(cmd.InstanceId, user); + var result = await svc.DisableInstanceAsync(cmd.InstanceId, user.Username); return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error); } - private static async Task HandleDeleteInstance(IServiceProvider sp, MgmtDeleteInstanceCommand cmd, string user) + private static async Task HandleDeleteInstance(IServiceProvider sp, MgmtDeleteInstanceCommand cmd, AuthenticatedUser user) { + await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId); var svc = sp.GetRequiredService(); - var result = await svc.DeleteInstanceAsync(cmd.InstanceId, user); + var result = await svc.DeleteInstanceAsync(cmd.InstanceId, user.Username); return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error); } - private static async Task HandleSetConnectionBindings(IServiceProvider sp, SetConnectionBindingsCommand cmd, string user) + private static async Task HandleSetConnectionBindings(IServiceProvider sp, SetConnectionBindingsCommand cmd, AuthenticatedUser user) { + await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId); var svc = sp.GetRequiredService(); - var result = await svc.SetConnectionBindingsAsync(cmd.InstanceId, cmd.Bindings, user); + var result = await svc.SetConnectionBindingsAsync(cmd.InstanceId, cmd.Bindings, user.Username); return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error); } + private static async Task HandleSetInstanceOverrides(IServiceProvider sp, SetInstanceOverridesCommand cmd, AuthenticatedUser user) + { + await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId); + var svc = sp.GetRequiredService(); + var results = new List(); + foreach (var (attrName, overrideValue) in cmd.Overrides) + { + var result = await svc.SetAttributeOverrideAsync(cmd.InstanceId, attrName, overrideValue, user.Username); + if (!result.IsSuccess) throw new InvalidOperationException(result.Error); + results.Add(result.Value); + } + return results; + } + + private static async Task HandleSetInstanceArea(IServiceProvider sp, SetInstanceAreaCommand cmd, AuthenticatedUser user) + { + await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId); + var svc = sp.GetRequiredService(); + var result = await svc.AssignToAreaAsync(cmd.InstanceId, cmd.AreaId, user.Username); + return result.IsSuccess + ? result.Value + : throw new InvalidOperationException(result.Error); + } + + private static async Task HandleGetDeploymentDiff(IServiceProvider sp, GetDeploymentDiffCommand cmd, AuthenticatedUser user) + { + await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId); + var svc = sp.GetRequiredService(); + var result = await svc.GetDeploymentComparisonAsync(cmd.InstanceId); + return result.IsSuccess + ? result.Value + : throw new InvalidOperationException(result.Error); + } + + private static async Task HandleRetryParkedMessage(IServiceProvider sp, RetryParkedMessageCommand cmd) + { + var commService = sp.GetRequiredService(); + var request = new Commons.Messages.RemoteQuery.ParkedMessageRetryRequest( + Guid.NewGuid().ToString("N"), cmd.SiteIdentifier, cmd.MessageId, DateTimeOffset.UtcNow); + return await commService.RetryParkedMessageAsync(cmd.SiteIdentifier, request); + } + + private static async Task HandleDiscardParkedMessage(IServiceProvider sp, DiscardParkedMessageCommand cmd) + { + var commService = sp.GetRequiredService(); + var request = new Commons.Messages.RemoteQuery.ParkedMessageDiscardRequest( + Guid.NewGuid().ToString("N"), cmd.SiteIdentifier, cmd.MessageId, DateTimeOffset.UtcNow); + return await commService.DiscardParkedMessageAsync(cmd.SiteIdentifier, request); + } + // ======================================================================== // Site handlers // ======================================================================== - private static async Task HandleListSites(IServiceProvider sp) + private static async Task HandleListSites(IServiceProvider sp, AuthenticatedUser user) { var repo = sp.GetRequiredService(); - return await repo.GetAllSitesAsync(); + var sites = await repo.GetAllSitesAsync(); + if (user.PermittedSiteIds.Length > 0 && !user.Roles.Contains("Admin", StringComparer.OrdinalIgnoreCase)) + { + var permittedIds = new HashSet(user.PermittedSiteIds); + sites = sites.Where(s => permittedIds.Contains(s.Id.ToString())).ToList(); + } + return sites; } private static async Task HandleGetSite(IServiceProvider sp, GetSiteCommand cmd) @@ -384,7 +589,7 @@ public class ManagementActor : ReceiveActor return await repo.GetSiteByIdAsync(cmd.SiteId); } - private static async Task HandleCreateSite(IServiceProvider sp, CreateSiteCommand cmd) + private static async Task HandleCreateSite(IServiceProvider sp, CreateSiteCommand cmd, string user) { var repo = sp.GetRequiredService(); var site = new Site(cmd.Name, cmd.SiteIdentifier) @@ -397,10 +602,11 @@ public class ManagementActor : ReceiveActor await repo.SaveChangesAsync(); var commService = sp.GetService(); commService?.RefreshSiteAddresses(); + await AuditAsync(sp, user, "Create", "Site", site.Id.ToString(), site.Name, site); return site; } - private static async Task HandleUpdateSite(IServiceProvider sp, UpdateSiteCommand cmd) + private static async Task HandleUpdateSite(IServiceProvider sp, UpdateSiteCommand cmd, string user) { var repo = sp.GetRequiredService(); var site = await repo.GetSiteByIdAsync(cmd.SiteId) @@ -413,13 +619,14 @@ public class ManagementActor : ReceiveActor await repo.SaveChangesAsync(); var commService = sp.GetService(); commService?.RefreshSiteAddresses(); + await AuditAsync(sp, user, "Update", "Site", site.Id.ToString(), site.Name, site); return site; } - private static async Task HandleDeleteSite(IServiceProvider sp, DeleteSiteCommand cmd) + private static async Task HandleDeleteSite(IServiceProvider sp, DeleteSiteCommand cmd, string user) { var repo = sp.GetRequiredService(); - // Check for instances referencing this site + var site = await repo.GetSiteByIdAsync(cmd.SiteId); var instances = await repo.GetInstancesBySiteIdAsync(cmd.SiteId); if (instances.Count > 0) throw new InvalidOperationException( @@ -428,6 +635,7 @@ public class ManagementActor : ReceiveActor await repo.SaveChangesAsync(); var commService = sp.GetService(); commService?.RefreshSiteAddresses(); + await AuditAsync(sp, user, "Delete", "Site", cmd.SiteId.ToString(), site?.Name ?? cmd.SiteId.ToString(), null); return true; } @@ -437,7 +645,7 @@ public class ManagementActor : ReceiveActor return await repo.GetAreaTreeBySiteIdAsync(cmd.SiteId); } - private static async Task HandleCreateArea(IServiceProvider sp, CreateAreaCommand cmd) + private static async Task HandleCreateArea(IServiceProvider sp, CreateAreaCommand cmd, string user) { var repo = sp.GetRequiredService(); var area = new Area(cmd.Name) @@ -447,14 +655,16 @@ public class ManagementActor : ReceiveActor }; await repo.AddAreaAsync(area); await repo.SaveChangesAsync(); + await AuditAsync(sp, user, "Create", "Area", area.Id.ToString(), area.Name, area); return area; } - private static async Task HandleDeleteArea(IServiceProvider sp, DeleteAreaCommand cmd) + private static async Task HandleDeleteArea(IServiceProvider sp, DeleteAreaCommand cmd, string user) { var repo = sp.GetRequiredService(); await repo.DeleteAreaAsync(cmd.AreaId); await repo.SaveChangesAsync(); + await AuditAsync(sp, user, "Delete", "Area", cmd.AreaId.ToString(), cmd.AreaId.ToString(), null); return true; } @@ -474,16 +684,17 @@ public class ManagementActor : ReceiveActor return await repo.GetDataConnectionByIdAsync(cmd.DataConnectionId); } - private static async Task HandleCreateDataConnection(IServiceProvider sp, CreateDataConnectionCommand cmd) + private static async Task HandleCreateDataConnection(IServiceProvider sp, CreateDataConnectionCommand cmd, string user) { var repo = sp.GetRequiredService(); var conn = new DataConnection(cmd.Name, cmd.Protocol) { Configuration = cmd.Configuration }; await repo.AddDataConnectionAsync(conn); await repo.SaveChangesAsync(); + await AuditAsync(sp, user, "Create", "DataConnection", conn.Id.ToString(), conn.Name, conn); return conn; } - private static async Task HandleUpdateDataConnection(IServiceProvider sp, UpdateDataConnectionCommand cmd) + private static async Task HandleUpdateDataConnection(IServiceProvider sp, UpdateDataConnectionCommand cmd, string user) { var repo = sp.GetRequiredService(); var conn = await repo.GetDataConnectionByIdAsync(cmd.DataConnectionId) @@ -493,18 +704,20 @@ public class ManagementActor : ReceiveActor conn.Configuration = cmd.Configuration; await repo.UpdateDataConnectionAsync(conn); await repo.SaveChangesAsync(); + await AuditAsync(sp, user, "Update", "DataConnection", conn.Id.ToString(), conn.Name, conn); return conn; } - private static async Task HandleDeleteDataConnection(IServiceProvider sp, DeleteDataConnectionCommand cmd) + private static async Task HandleDeleteDataConnection(IServiceProvider sp, DeleteDataConnectionCommand cmd, string user) { var repo = sp.GetRequiredService(); await repo.DeleteDataConnectionAsync(cmd.DataConnectionId); await repo.SaveChangesAsync(); + await AuditAsync(sp, user, "Delete", "DataConnection", cmd.DataConnectionId.ToString(), cmd.DataConnectionId.ToString(), null); return true; } - private static async Task HandleAssignDataConnectionToSite(IServiceProvider sp, AssignDataConnectionToSiteCommand cmd) + private static async Task HandleAssignDataConnectionToSite(IServiceProvider sp, AssignDataConnectionToSiteCommand cmd, string user) { var repo = sp.GetRequiredService(); var assignment = new SiteDataConnectionAssignment @@ -514,14 +727,16 @@ public class ManagementActor : ReceiveActor }; await repo.AddSiteDataConnectionAssignmentAsync(assignment); await repo.SaveChangesAsync(); + await AuditAsync(sp, user, "Assign", "DataConnection", cmd.DataConnectionId.ToString(), $"Site:{cmd.SiteId}", assignment); return assignment; } - private static async Task HandleUnassignDataConnectionFromSite(IServiceProvider sp, UnassignDataConnectionFromSiteCommand cmd) + private static async Task HandleUnassignDataConnectionFromSite(IServiceProvider sp, UnassignDataConnectionFromSiteCommand cmd, string user) { var repo = sp.GetRequiredService(); await repo.DeleteSiteDataConnectionAssignmentAsync(cmd.AssignmentId); await repo.SaveChangesAsync(); + await AuditAsync(sp, user, "Unassign", "DataConnection", cmd.AssignmentId.ToString(), cmd.AssignmentId.ToString(), null); return true; } @@ -541,7 +756,7 @@ public class ManagementActor : ReceiveActor return await repo.GetExternalSystemByIdAsync(cmd.ExternalSystemId); } - private static async Task HandleCreateExternalSystem(IServiceProvider sp, CreateExternalSystemCommand cmd) + private static async Task HandleCreateExternalSystem(IServiceProvider sp, CreateExternalSystemCommand cmd, string user) { var repo = sp.GetRequiredService(); var def = new ExternalSystemDefinition(cmd.Name, cmd.EndpointUrl, cmd.AuthType) @@ -550,10 +765,11 @@ public class ManagementActor : ReceiveActor }; await repo.AddExternalSystemAsync(def); await repo.SaveChangesAsync(); + await AuditAsync(sp, user, "Create", "ExternalSystem", def.Id.ToString(), def.Name, def); return def; } - private static async Task HandleUpdateExternalSystem(IServiceProvider sp, UpdateExternalSystemCommand cmd) + private static async Task HandleUpdateExternalSystem(IServiceProvider sp, UpdateExternalSystemCommand cmd, string user) { var repo = sp.GetRequiredService(); var def = await repo.GetExternalSystemByIdAsync(cmd.ExternalSystemId) @@ -564,14 +780,16 @@ public class ManagementActor : ReceiveActor def.AuthConfiguration = cmd.AuthConfiguration; await repo.UpdateExternalSystemAsync(def); await repo.SaveChangesAsync(); + await AuditAsync(sp, user, "Update", "ExternalSystem", def.Id.ToString(), def.Name, def); return def; } - private static async Task HandleDeleteExternalSystem(IServiceProvider sp, DeleteExternalSystemCommand cmd) + private static async Task HandleDeleteExternalSystem(IServiceProvider sp, DeleteExternalSystemCommand cmd, string user) { var repo = sp.GetRequiredService(); await repo.DeleteExternalSystemAsync(cmd.ExternalSystemId); await repo.SaveChangesAsync(); + await AuditAsync(sp, user, "Delete", "ExternalSystem", cmd.ExternalSystemId.ToString(), cmd.ExternalSystemId.ToString(), null); return true; } @@ -587,7 +805,7 @@ public class ManagementActor : ReceiveActor return await repo.GetExternalSystemMethodByIdAsync(cmd.MethodId); } - private static async Task HandleCreateExternalSystemMethod(IServiceProvider sp, CreateExternalSystemMethodCommand cmd) + private static async Task HandleCreateExternalSystemMethod(IServiceProvider sp, CreateExternalSystemMethodCommand cmd, string user) { var repo = sp.GetRequiredService(); var method = new ExternalSystemMethod(cmd.Name, cmd.HttpMethod, cmd.Path) @@ -598,10 +816,11 @@ public class ManagementActor : ReceiveActor }; await repo.AddExternalSystemMethodAsync(method); await repo.SaveChangesAsync(); + await AuditAsync(sp, user, "Create", "ExternalSystemMethod", method.Id.ToString(), method.Name, method); return method; } - private static async Task HandleUpdateExternalSystemMethod(IServiceProvider sp, UpdateExternalSystemMethodCommand cmd) + private static async Task HandleUpdateExternalSystemMethod(IServiceProvider sp, UpdateExternalSystemMethodCommand cmd, string user) { var repo = sp.GetRequiredService(); var method = await repo.GetExternalSystemMethodByIdAsync(cmd.MethodId) @@ -613,14 +832,16 @@ public class ManagementActor : ReceiveActor if (cmd.ReturnDefinition != null) method.ReturnDefinition = cmd.ReturnDefinition; await repo.UpdateExternalSystemMethodAsync(method); await repo.SaveChangesAsync(); + await AuditAsync(sp, user, "Update", "ExternalSystemMethod", method.Id.ToString(), method.Name, method); return method; } - private static async Task HandleDeleteExternalSystemMethod(IServiceProvider sp, DeleteExternalSystemMethodCommand cmd) + private static async Task HandleDeleteExternalSystemMethod(IServiceProvider sp, DeleteExternalSystemMethodCommand cmd, string user) { var repo = sp.GetRequiredService(); await repo.DeleteExternalSystemMethodAsync(cmd.MethodId); await repo.SaveChangesAsync(); + await AuditAsync(sp, user, "Delete", "ExternalSystemMethod", cmd.MethodId.ToString(), cmd.MethodId.ToString(), null); return true; } @@ -640,7 +861,7 @@ public class ManagementActor : ReceiveActor return await repo.GetNotificationListByIdAsync(cmd.NotificationListId); } - private static async Task HandleCreateNotificationList(IServiceProvider sp, CreateNotificationListCommand cmd) + private static async Task HandleCreateNotificationList(IServiceProvider sp, CreateNotificationListCommand cmd, string user) { var repo = sp.GetRequiredService(); var list = new NotificationList(cmd.Name); @@ -650,17 +871,17 @@ public class ManagementActor : ReceiveActor } await repo.AddNotificationListAsync(list); await repo.SaveChangesAsync(); + await AuditAsync(sp, user, "Create", "NotificationList", list.Id.ToString(), list.Name, list); return list; } - private static async Task HandleUpdateNotificationList(IServiceProvider sp, UpdateNotificationListCommand cmd) + private static async Task HandleUpdateNotificationList(IServiceProvider sp, UpdateNotificationListCommand cmd, string user) { var repo = sp.GetRequiredService(); var list = await repo.GetNotificationListByIdAsync(cmd.NotificationListId) ?? throw new InvalidOperationException($"NotificationList with ID {cmd.NotificationListId} not found."); list.Name = cmd.Name; - // Remove existing recipients and re-add var existingRecipients = await repo.GetRecipientsByListIdAsync(cmd.NotificationListId); foreach (var r in existingRecipients) { @@ -677,14 +898,16 @@ public class ManagementActor : ReceiveActor await repo.UpdateNotificationListAsync(list); await repo.SaveChangesAsync(); + await AuditAsync(sp, user, "Update", "NotificationList", list.Id.ToString(), list.Name, list); return list; } - private static async Task HandleDeleteNotificationList(IServiceProvider sp, DeleteNotificationListCommand cmd) + private static async Task HandleDeleteNotificationList(IServiceProvider sp, DeleteNotificationListCommand cmd, string user) { var repo = sp.GetRequiredService(); await repo.DeleteNotificationListAsync(cmd.NotificationListId); await repo.SaveChangesAsync(); + await AuditAsync(sp, user, "Delete", "NotificationList", cmd.NotificationListId.ToString(), cmd.NotificationListId.ToString(), null); return true; } @@ -694,7 +917,7 @@ public class ManagementActor : ReceiveActor return await repo.GetAllSmtpConfigurationsAsync(); } - private static async Task HandleUpdateSmtpConfig(IServiceProvider sp, UpdateSmtpConfigCommand cmd) + private static async Task HandleUpdateSmtpConfig(IServiceProvider sp, UpdateSmtpConfigCommand cmd, string user) { var repo = sp.GetRequiredService(); var config = await repo.GetSmtpConfigurationByIdAsync(cmd.SmtpConfigId) @@ -705,6 +928,7 @@ public class ManagementActor : ReceiveActor config.FromAddress = cmd.FromAddress; await repo.UpdateSmtpConfigurationAsync(config); await repo.SaveChangesAsync(); + await AuditAsync(sp, user, "Update", "SmtpConfiguration", config.Id.ToString(), config.Host, config); return config; } @@ -718,16 +942,17 @@ public class ManagementActor : ReceiveActor return await repo.GetAllMappingsAsync(); } - private static async Task HandleCreateRoleMapping(IServiceProvider sp, CreateRoleMappingCommand cmd) + private static async Task HandleCreateRoleMapping(IServiceProvider sp, CreateRoleMappingCommand cmd, string user) { var repo = sp.GetRequiredService(); var mapping = new LdapGroupMapping(cmd.LdapGroupName, cmd.Role); await repo.AddMappingAsync(mapping); await repo.SaveChangesAsync(); + await AuditAsync(sp, user, "Create", "RoleMapping", mapping.Id.ToString(), $"{mapping.LdapGroupName}->{mapping.Role}", mapping); return mapping; } - private static async Task HandleUpdateRoleMapping(IServiceProvider sp, UpdateRoleMappingCommand cmd) + private static async Task HandleUpdateRoleMapping(IServiceProvider sp, UpdateRoleMappingCommand cmd, string user) { var repo = sp.GetRequiredService(); var mapping = await repo.GetMappingByIdAsync(cmd.MappingId) @@ -736,14 +961,16 @@ public class ManagementActor : ReceiveActor mapping.Role = cmd.Role; await repo.UpdateMappingAsync(mapping); await repo.SaveChangesAsync(); + await AuditAsync(sp, user, "Update", "RoleMapping", mapping.Id.ToString(), $"{mapping.LdapGroupName}->{mapping.Role}", mapping); return mapping; } - private static async Task HandleDeleteRoleMapping(IServiceProvider sp, DeleteRoleMappingCommand cmd) + private static async Task HandleDeleteRoleMapping(IServiceProvider sp, DeleteRoleMappingCommand cmd, string user) { var repo = sp.GetRequiredService(); await repo.DeleteMappingAsync(cmd.MappingId); await repo.SaveChangesAsync(); + await AuditAsync(sp, user, "Delete", "RoleMapping", cmd.MappingId.ToString(), cmd.MappingId.ToString(), null); return true; } @@ -753,21 +980,23 @@ public class ManagementActor : ReceiveActor return await repo.GetAllApiKeysAsync(); } - private static async Task HandleCreateApiKey(IServiceProvider sp, CreateApiKeyCommand cmd) + private static async Task HandleCreateApiKey(IServiceProvider sp, CreateApiKeyCommand cmd, string user) { var repo = sp.GetRequiredService(); var keyValue = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); var apiKey = new ApiKey(cmd.Name, keyValue) { IsEnabled = true }; await repo.AddApiKeyAsync(apiKey); await repo.SaveChangesAsync(); + await AuditAsync(sp, user, "Create", "ApiKey", apiKey.Id.ToString(), apiKey.Name, new { apiKey.Id, apiKey.Name, apiKey.IsEnabled }); return apiKey; } - private static async Task HandleDeleteApiKey(IServiceProvider sp, DeleteApiKeyCommand cmd) + private static async Task HandleDeleteApiKey(IServiceProvider sp, DeleteApiKeyCommand cmd, string user) { var repo = sp.GetRequiredService(); await repo.DeleteApiKeyAsync(cmd.ApiKeyId); await repo.SaveChangesAsync(); + await AuditAsync(sp, user, "Delete", "ApiKey", cmd.ApiKeyId.ToString(), cmd.ApiKeyId.ToString(), null); return true; } @@ -1003,16 +1232,17 @@ public class ManagementActor : ReceiveActor return await repo.GetDatabaseConnectionByIdAsync(cmd.DatabaseConnectionId); } - private static async Task HandleCreateDatabaseConnection(IServiceProvider sp, CreateDatabaseConnectionDefCommand cmd) + private static async Task HandleCreateDatabaseConnection(IServiceProvider sp, CreateDatabaseConnectionDefCommand cmd, string user) { var repo = sp.GetRequiredService(); var def = new DatabaseConnectionDefinition(cmd.Name, cmd.ConnectionString); await repo.AddDatabaseConnectionAsync(def); await repo.SaveChangesAsync(); + await AuditAsync(sp, user, "Create", "DatabaseConnection", def.Id.ToString(), def.Name, new { def.Id, def.Name }); return def; } - private static async Task HandleUpdateDatabaseConnection(IServiceProvider sp, UpdateDatabaseConnectionDefCommand cmd) + private static async Task HandleUpdateDatabaseConnection(IServiceProvider sp, UpdateDatabaseConnectionDefCommand cmd, string user) { var repo = sp.GetRequiredService(); var def = await repo.GetDatabaseConnectionByIdAsync(cmd.DatabaseConnectionId) @@ -1021,14 +1251,16 @@ public class ManagementActor : ReceiveActor def.ConnectionString = cmd.ConnectionString; await repo.UpdateDatabaseConnectionAsync(def); await repo.SaveChangesAsync(); + await AuditAsync(sp, user, "Update", "DatabaseConnection", def.Id.ToString(), def.Name, new { def.Id, def.Name }); return def; } - private static async Task HandleDeleteDatabaseConnection(IServiceProvider sp, DeleteDatabaseConnectionDefCommand cmd) + private static async Task HandleDeleteDatabaseConnection(IServiceProvider sp, DeleteDatabaseConnectionDefCommand cmd, string user) { var repo = sp.GetRequiredService(); await repo.DeleteDatabaseConnectionAsync(cmd.DatabaseConnectionId); await repo.SaveChangesAsync(); + await AuditAsync(sp, user, "Delete", "DatabaseConnection", cmd.DatabaseConnectionId.ToString(), cmd.DatabaseConnectionId.ToString(), null); return true; } @@ -1048,7 +1280,7 @@ public class ManagementActor : ReceiveActor return await repo.GetApiMethodByIdAsync(cmd.ApiMethodId); } - private static async Task HandleCreateApiMethod(IServiceProvider sp, CreateApiMethodCommand cmd) + private static async Task HandleCreateApiMethod(IServiceProvider sp, CreateApiMethodCommand cmd, string user) { var repo = sp.GetRequiredService(); var method = new ApiMethod(cmd.Name, cmd.Script) @@ -1059,14 +1291,12 @@ public class ManagementActor : ReceiveActor }; await repo.AddApiMethodAsync(method); await repo.SaveChangesAsync(); - - // Hot-register the compiled script so it's immediately available sp.GetService()?.CompileAndRegister(method); - + await AuditAsync(sp, user, "Create", "ApiMethod", method.Id.ToString(), method.Name, method); return method; } - private static async Task HandleUpdateApiMethod(IServiceProvider sp, UpdateApiMethodCommand cmd) + private static async Task HandleUpdateApiMethod(IServiceProvider sp, UpdateApiMethodCommand cmd, string user) { var repo = sp.GetRequiredService(); var method = await repo.GetApiMethodByIdAsync(cmd.ApiMethodId) @@ -1077,24 +1307,20 @@ public class ManagementActor : ReceiveActor method.ReturnDefinition = cmd.ReturnDefinition; await repo.UpdateApiMethodAsync(method); await repo.SaveChangesAsync(); - - // Re-compile and register the updated script sp.GetService()?.CompileAndRegister(method); - + await AuditAsync(sp, user, "Update", "ApiMethod", method.Id.ToString(), method.Name, method); return method; } - private static async Task HandleDeleteApiMethod(IServiceProvider sp, DeleteApiMethodCommand cmd) + private static async Task HandleDeleteApiMethod(IServiceProvider sp, DeleteApiMethodCommand cmd, string user) { var repo = sp.GetRequiredService(); var method = await repo.GetApiMethodByIdAsync(cmd.ApiMethodId); await repo.DeleteApiMethodAsync(cmd.ApiMethodId); await repo.SaveChangesAsync(); - - // Remove the compiled script handler if (method != null) sp.GetService()?.RemoveHandler(method.Name); - + await AuditAsync(sp, user, "Delete", "ApiMethod", cmd.ApiMethodId.ToString(), method?.Name ?? cmd.ApiMethodId.ToString(), null); return true; } @@ -1102,7 +1328,7 @@ public class ManagementActor : ReceiveActor // Additional Security handlers (API key update, scope rules) // ======================================================================== - private static async Task HandleUpdateApiKey(IServiceProvider sp, UpdateApiKeyCommand cmd) + private static async Task HandleUpdateApiKey(IServiceProvider sp, UpdateApiKeyCommand cmd, string user) { var repo = sp.GetRequiredService(); var key = await repo.GetApiKeyByIdAsync(cmd.ApiKeyId) @@ -1110,6 +1336,7 @@ public class ManagementActor : ReceiveActor key.IsEnabled = cmd.IsEnabled; await repo.UpdateApiKeyAsync(key); await repo.SaveChangesAsync(); + await AuditAsync(sp, user, "Update", "ApiKey", key.Id.ToString(), key.Name, new { key.Id, key.Name, key.IsEnabled }); return key; } @@ -1119,20 +1346,22 @@ public class ManagementActor : ReceiveActor return await repo.GetScopeRulesForMappingAsync(cmd.MappingId); } - private static async Task HandleAddScopeRule(IServiceProvider sp, AddScopeRuleCommand cmd) + private static async Task HandleAddScopeRule(IServiceProvider sp, AddScopeRuleCommand cmd, string user) { var repo = sp.GetRequiredService(); var rule = new SiteScopeRule { LdapGroupMappingId = cmd.MappingId, SiteId = cmd.SiteId }; await repo.AddScopeRuleAsync(rule); await repo.SaveChangesAsync(); + await AuditAsync(sp, user, "Create", "ScopeRule", rule.Id.ToString(), $"Mapping:{cmd.MappingId}/Site:{cmd.SiteId}", rule); return rule; } - private static async Task HandleDeleteScopeRule(IServiceProvider sp, DeleteScopeRuleCommand cmd) + private static async Task HandleDeleteScopeRule(IServiceProvider sp, DeleteScopeRuleCommand cmd, string user) { var repo = sp.GetRequiredService(); await repo.DeleteScopeRuleAsync(cmd.ScopeRuleId); await repo.SaveChangesAsync(); + await AuditAsync(sp, user, "Delete", "ScopeRule", cmd.ScopeRuleId.ToString(), cmd.ScopeRuleId.ToString(), null); return true; } @@ -1140,7 +1369,7 @@ public class ManagementActor : ReceiveActor // Area update handler // ======================================================================== - private static async Task HandleUpdateArea(IServiceProvider sp, UpdateAreaCommand cmd) + private static async Task HandleUpdateArea(IServiceProvider sp, UpdateAreaCommand cmd, string user) { var repo = sp.GetRequiredService(); var area = await repo.GetAreaByIdAsync(cmd.AreaId) @@ -1148,6 +1377,7 @@ public class ManagementActor : ReceiveActor area.Name = cmd.Name; await repo.UpdateAreaAsync(area); await repo.SaveChangesAsync(); + await AuditAsync(sp, user, "Update", "Area", area.Id.ToString(), area.Name, area); return area; } @@ -1163,7 +1393,7 @@ public class ManagementActor : ReceiveActor cmd.SiteIdentifier, cmd.From, cmd.To, cmd.EventType, cmd.Severity, - null, // InstanceId + cmd.InstanceName, // InstanceId filter cmd.Keyword, null, // ContinuationToken cmd.PageSize, @@ -1198,3 +1428,11 @@ public class ManagementActor : ReceiveActor return await commService.RequestDebugSnapshotAsync(site.SiteIdentifier, request); } } + +/// +/// Thrown when a site-scoped user attempts an operation on a site they don't have access to. +/// +public class SiteScopeViolationException : Exception +{ + public SiteScopeViolationException(string message) : base(message) { } +} diff --git a/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs b/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs index dd9a518..49ed206 100644 --- a/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs +++ b/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs @@ -284,17 +284,36 @@ public class InstanceActor : ReceiveActor { if (_tagPathToAttribute.TryGetValue(update.TagPath, out var attrName)) { + // Normalize array values to JSON strings so they survive Akka serialization + var value = update.Value is Array + ? System.Text.Json.JsonSerializer.Serialize(update.Value, update.Value.GetType()) + : update.Value; + var changed = new AttributeValueChanged( _instanceUniqueName, update.TagPath, attrName, - update.Value, update.Quality.ToString(), update.Timestamp); + value, update.Quality.ToString(), update.Timestamp); HandleAttributeValueChanged(changed); } } private void HandleConnectionQualityChanged(ConnectionQualityChanged qualityChanged) { - _logger.LogInformation("Connection {Connection} quality changed to {Quality}", - qualityChanged.ConnectionName, qualityChanged.Quality); + _logger.LogWarning("Connection {Connection} quality changed to {Quality} for instance {Instance}", + qualityChanged.ConnectionName, qualityChanged.Quality, _instanceUniqueName); + + if (_configuration == null) return; + + // Mark all attributes bound to this connection with the new quality + var qualityStr = qualityChanged.Quality.ToString(); + foreach (var attr in _configuration.Attributes) + { + if (attr.BoundDataConnectionName == qualityChanged.ConnectionName && + !string.IsNullOrEmpty(attr.DataSourceReference)) + { + _attributeQualities[attr.CanonicalName] = qualityStr; + _attributeTimestamps[attr.CanonicalName] = qualityChanged.Timestamp; + } + } } /// diff --git a/src/ScadaLink.TemplateEngine/TemplateService.cs b/src/ScadaLink.TemplateEngine/TemplateService.cs index e7fdfd4..c41a215 100644 --- a/src/ScadaLink.TemplateEngine/TemplateService.cs +++ b/src/ScadaLink.TemplateEngine/TemplateService.cs @@ -236,7 +236,8 @@ public class TemplateService existing.Value = proposed.Value; existing.Description = proposed.Description; existing.IsLocked = proposed.IsLocked; - // DataType and DataSourceReference are NOT updated (fixed fields) + existing.DataType = proposed.DataType; + existing.DataSourceReference = proposed.DataSourceReference; await _repository.UpdateTemplateAttributeAsync(existing, cancellationToken); await _auditService.LogAsync(user, "Update", "TemplateAttribute", attributeId.ToString(), existing.Name, existing, cancellationToken); diff --git a/test_infra_opcua.md b/test_infra_opcua.md index c03d5bd..44fd677 100644 --- a/test_infra_opcua.md +++ b/test_infra_opcua.md @@ -33,12 +33,13 @@ The file `infra/opcua/nodes.json` defines a single `ConfigFolder` object (not an | Pump | FlowRate, Pressure, Running | Double, Boolean | | Tank | Level, Temperature, HighLevel, LowLevel | Double, Boolean | | Valve | Position, Command | Double, UInt32 | +| JoeAppEngine | BTCS, AlarmCntsBySeverity, Scheduler/ScanTime | String, Int32[], DateTime | -All custom nodes hold their initial/default values (0 for numerics, false for booleans) until written. OPC PLC's custom node format does not support random value generation for these nodes. +All custom nodes hold their initial/default values (0 for numerics, false for booleans, empty for strings, epoch for DateTime) until written. OPC PLC's custom node format does not support random value generation for these nodes. -Custom nodes live in namespace 3 (`http://microsoft.com/Opc/OpcPlc/`). Node IDs follow the pattern `ns=3;s=.` (e.g., `ns=3;s=Motor.Speed`). +Custom nodes live in namespace 3 (`http://microsoft.com/Opc/OpcPlc/`). Node IDs follow the pattern `ns=3;s=.` (e.g., `ns=3;s=Motor.Speed`). Nested folders use dot notation: `ns=3;s=JoeAppEngine.Scheduler.ScanTime`. -The browse path from the Objects root is: `OpcPlc > ScadaLink > Motor|Pump|Tank|Valve`. +The browse path from the Objects root is: `OpcPlc > ScadaLink > Motor|Pump|Tank|Valve|JoeAppEngine`. ## Verification diff --git a/tests/ScadaLink.CLI.Tests/CliConfigTests.cs b/tests/ScadaLink.CLI.Tests/CliConfigTests.cs new file mode 100644 index 0000000..9a37ab2 --- /dev/null +++ b/tests/ScadaLink.CLI.Tests/CliConfigTests.cs @@ -0,0 +1,90 @@ +using ScadaLink.CLI; + +namespace ScadaLink.CLI.Tests; + +public class CliConfigTests +{ + [Fact] + public void Load_DefaultValues_WhenNoConfigExists() + { + // Clear environment variables that might affect the test + var origContact = Environment.GetEnvironmentVariable("SCADALINK_CONTACT_POINTS"); + var origLdap = Environment.GetEnvironmentVariable("SCADALINK_LDAP_SERVER"); + var origFormat = Environment.GetEnvironmentVariable("SCADALINK_FORMAT"); + + try + { + Environment.SetEnvironmentVariable("SCADALINK_CONTACT_POINTS", null); + Environment.SetEnvironmentVariable("SCADALINK_LDAP_SERVER", null); + Environment.SetEnvironmentVariable("SCADALINK_FORMAT", null); + + var config = CliConfig.Load(); + + Assert.Equal(636, config.LdapPort); + Assert.True(config.LdapUseTls); + Assert.Equal("json", config.DefaultFormat); + } + finally + { + Environment.SetEnvironmentVariable("SCADALINK_CONTACT_POINTS", origContact); + Environment.SetEnvironmentVariable("SCADALINK_LDAP_SERVER", origLdap); + Environment.SetEnvironmentVariable("SCADALINK_FORMAT", origFormat); + } + } + + [Fact] + public void Load_ContactPoints_FromEnvironment() + { + var orig = Environment.GetEnvironmentVariable("SCADALINK_CONTACT_POINTS"); + try + { + Environment.SetEnvironmentVariable("SCADALINK_CONTACT_POINTS", "host1:8080,host2:8080"); + + var config = CliConfig.Load(); + + Assert.Equal(2, config.ContactPoints.Count); + Assert.Equal("host1:8080", config.ContactPoints[0]); + Assert.Equal("host2:8080", config.ContactPoints[1]); + } + finally + { + Environment.SetEnvironmentVariable("SCADALINK_CONTACT_POINTS", orig); + } + } + + [Fact] + public void Load_LdapServer_FromEnvironment() + { + var orig = Environment.GetEnvironmentVariable("SCADALINK_LDAP_SERVER"); + try + { + Environment.SetEnvironmentVariable("SCADALINK_LDAP_SERVER", "ldap.example.com"); + + var config = CliConfig.Load(); + + Assert.Equal("ldap.example.com", config.LdapServer); + } + finally + { + Environment.SetEnvironmentVariable("SCADALINK_LDAP_SERVER", orig); + } + } + + [Fact] + public void Load_Format_FromEnvironment() + { + var orig = Environment.GetEnvironmentVariable("SCADALINK_FORMAT"); + try + { + Environment.SetEnvironmentVariable("SCADALINK_FORMAT", "table"); + + var config = CliConfig.Load(); + + Assert.Equal("table", config.DefaultFormat); + } + finally + { + Environment.SetEnvironmentVariable("SCADALINK_FORMAT", orig); + } + } +} diff --git a/tests/ScadaLink.CLI.Tests/CommandHelpersTests.cs b/tests/ScadaLink.CLI.Tests/CommandHelpersTests.cs new file mode 100644 index 0000000..b74b37b --- /dev/null +++ b/tests/ScadaLink.CLI.Tests/CommandHelpersTests.cs @@ -0,0 +1,131 @@ +using ScadaLink.CLI.Commands; +using ScadaLink.Commons.Messages.Management; + +namespace ScadaLink.CLI.Tests; + +public class CommandHelpersTests +{ + [Fact] + public void HandleResponse_ManagementSuccess_JsonFormat_ReturnsZero() + { + var writer = new StringWriter(); + Console.SetOut(writer); + + var response = new ManagementSuccess("corr-1", "{\"id\":1,\"name\":\"test\"}"); + var exitCode = CommandHelpers.HandleResponse(response, "json"); + + Assert.Equal(0, exitCode); + Assert.Contains("{\"id\":1,\"name\":\"test\"}", writer.ToString()); + + Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); + } + + [Fact] + public void HandleResponse_ManagementSuccess_TableFormat_ArrayData_ReturnsZero() + { + var writer = new StringWriter(); + Console.SetOut(writer); + + var json = "[{\"Id\":1,\"Name\":\"Alpha\"},{\"Id\":2,\"Name\":\"Beta\"}]"; + var response = new ManagementSuccess("corr-1", json); + var exitCode = CommandHelpers.HandleResponse(response, "table"); + + Assert.Equal(0, exitCode); + var output = writer.ToString(); + Assert.Contains("Id", output); + Assert.Contains("Name", output); + Assert.Contains("Alpha", output); + Assert.Contains("Beta", output); + + Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); + } + + [Fact] + public void HandleResponse_ManagementSuccess_TableFormat_ObjectData_ReturnsZero() + { + var writer = new StringWriter(); + Console.SetOut(writer); + + var json = "{\"Id\":1,\"Name\":\"Alpha\",\"Status\":\"Active\"}"; + var response = new ManagementSuccess("corr-1", json); + var exitCode = CommandHelpers.HandleResponse(response, "table"); + + Assert.Equal(0, exitCode); + var output = writer.ToString(); + Assert.Contains("Property", output); + Assert.Contains("Value", output); + Assert.Contains("Id", output); + Assert.Contains("Alpha", output); + + Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); + } + + [Fact] + public void HandleResponse_ManagementSuccess_TableFormat_EmptyArray_ShowsNoResults() + { + var writer = new StringWriter(); + Console.SetOut(writer); + + var response = new ManagementSuccess("corr-1", "[]"); + var exitCode = CommandHelpers.HandleResponse(response, "table"); + + Assert.Equal(0, exitCode); + Assert.Contains("(no results)", writer.ToString()); + + Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); + } + + [Fact] + public void HandleResponse_ManagementError_ReturnsOne() + { + var errWriter = new StringWriter(); + Console.SetError(errWriter); + + var response = new ManagementError("corr-1", "Something failed", "FAIL_CODE"); + var exitCode = CommandHelpers.HandleResponse(response, "json"); + + Assert.Equal(1, exitCode); + Assert.Contains("Something failed", errWriter.ToString()); + + Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true }); + } + + [Fact] + public void HandleResponse_ManagementUnauthorized_ReturnsTwo() + { + var errWriter = new StringWriter(); + Console.SetError(errWriter); + + var response = new ManagementUnauthorized("corr-1", "Access denied"); + var exitCode = CommandHelpers.HandleResponse(response, "json"); + + Assert.Equal(2, exitCode); + Assert.Contains("Access denied", errWriter.ToString()); + + Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true }); + } + + [Fact] + public void HandleResponse_UnexpectedType_ReturnsOne() + { + var errWriter = new StringWriter(); + Console.SetError(errWriter); + + var exitCode = CommandHelpers.HandleResponse("unexpected", "json"); + + Assert.Equal(1, exitCode); + Assert.Contains("Unexpected response type", errWriter.ToString()); + + Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true }); + } + + [Fact] + public void NewCorrelationId_ReturnsNonEmpty32CharHex() + { + var id = CommandHelpers.NewCorrelationId(); + + Assert.NotNull(id); + Assert.Equal(32, id.Length); + Assert.True(id.All(c => "0123456789abcdef".Contains(c))); + } +} diff --git a/tests/ScadaLink.CLI.Tests/OutputFormatterTests.cs b/tests/ScadaLink.CLI.Tests/OutputFormatterTests.cs new file mode 100644 index 0000000..aaf42f9 --- /dev/null +++ b/tests/ScadaLink.CLI.Tests/OutputFormatterTests.cs @@ -0,0 +1,102 @@ +using ScadaLink.CLI; + +namespace ScadaLink.CLI.Tests; + +public class OutputFormatterTests +{ + [Fact] + public void WriteJson_WritesIndentedJson() + { + var writer = new StringWriter(); + Console.SetOut(writer); + + OutputFormatter.WriteJson(new { Name = "test", Value = 42 }); + + var output = writer.ToString().Trim(); + Assert.Contains("\"name\"", output); + Assert.Contains("\"value\": 42", output); + + Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); + } + + [Fact] + public void WriteError_WritesToStdErr() + { + var writer = new StringWriter(); + Console.SetError(writer); + + OutputFormatter.WriteError("something went wrong", "ERR_CODE"); + + var output = writer.ToString().Trim(); + Assert.Contains("something went wrong", output); + Assert.Contains("ERR_CODE", output); + + Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true }); + } + + [Fact] + public void WriteTable_RendersHeadersAndRows() + { + var writer = new StringWriter(); + Console.SetOut(writer); + + var headers = new[] { "Id", "Name", "Status" }; + var rows = new List + { + new[] { "1", "Alpha", "Active" }, + new[] { "2", "Beta", "Inactive" } + }; + + OutputFormatter.WriteTable(rows, headers); + + var output = writer.ToString(); + Assert.Contains("Id", output); + Assert.Contains("Name", output); + Assert.Contains("Status", output); + Assert.Contains("Alpha", output); + Assert.Contains("Beta", output); + Assert.Contains("Inactive", output); + + Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); + } + + [Fact] + public void WriteTable_EmptyRows_ShowsHeadersOnly() + { + var writer = new StringWriter(); + Console.SetOut(writer); + + var headers = new[] { "Id", "Name" }; + OutputFormatter.WriteTable(Array.Empty(), headers); + + var output = writer.ToString(); + Assert.Contains("Id", output); + Assert.Contains("Name", output); + + Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); + } + + [Fact] + public void WriteTable_ColumnWidthsAdjustToContent() + { + var writer = new StringWriter(); + Console.SetOut(writer); + + var headers = new[] { "X", "LongColumnName" }; + var rows = new List + { + new[] { "ShortValue", "Y" } + }; + + OutputFormatter.WriteTable(rows, headers); + + var lines = writer.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + // Header line: "X" should be padded to at least "ShortValue" width + Assert.True(lines.Length >= 2); + // The "X" column header should be padded wider than 1 character + var headerLine = lines[0]; + Assert.True(headerLine.IndexOf("LongColumnName") > 1); + + Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); + } +} diff --git a/tests/ScadaLink.CLI.Tests/ScadaLink.CLI.Tests.csproj b/tests/ScadaLink.CLI.Tests/ScadaLink.CLI.Tests.csproj new file mode 100644 index 0000000..8704093 --- /dev/null +++ b/tests/ScadaLink.CLI.Tests/ScadaLink.CLI.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + true + false + + + + + + + + + + + + + + + + + + + + diff --git a/tests/ScadaLink.CentralUI.Tests/ComponentRenderingTests.cs b/tests/ScadaLink.CentralUI.Tests/ComponentRenderingTests.cs new file mode 100644 index 0000000..968b60f --- /dev/null +++ b/tests/ScadaLink.CentralUI.Tests/ComponentRenderingTests.cs @@ -0,0 +1,83 @@ +using System.Security.Claims; +using Bunit; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.DependencyInjection; +using ScadaLink.CentralUI.Components.Pages; + +namespace ScadaLink.CentralUI.Tests; + +/// +/// bUnit rendering tests for CentralUI Blazor components. +/// Verifies that pages render their expected markup structure. +/// +public class ComponentRenderingTests : BunitContext +{ + [Fact] + public void LoginPage_RendersForm_WithUsernameAndPasswordFields() + { + var cut = Render(); + + // Verify the form action + var form = cut.Find("form"); + Assert.Equal("/auth/login", form.GetAttribute("action")); + + // Verify username field + var usernameInput = cut.Find("input#username"); + Assert.Equal("text", usernameInput.GetAttribute("type")); + Assert.Equal("username", usernameInput.GetAttribute("name")); + + // Verify password field + var passwordInput = cut.Find("input#password"); + Assert.Equal("password", passwordInput.GetAttribute("type")); + Assert.Equal("password", passwordInput.GetAttribute("name")); + + // Verify submit button + var submitButton = cut.Find("button[type='submit']"); + Assert.Contains("Sign In", submitButton.TextContent); + } + + [Fact] + public void LoginPage_WithoutError_DoesNotRenderAlert() + { + var cut = Render(); + + Assert.Throws(() => cut.Find("div.alert.alert-danger")); + } + + [Fact] + public void Dashboard_RequiresAuthorizeAttribute() + { + var authorizeAttrs = typeof(Dashboard) + .GetCustomAttributes(typeof(AuthorizeAttribute), true); + Assert.NotEmpty(authorizeAttrs); + } + + [Fact] + public void TemplateEditor_RequiresDesignPolicy() + { + var authorizeAttrs = typeof(ScadaLink.CentralUI.Components.Pages.Design.Templates) + .GetCustomAttributes(typeof(AuthorizeAttribute), true); + Assert.NotEmpty(authorizeAttrs); + + var attr = (AuthorizeAttribute)authorizeAttrs[0]; + Assert.Equal("RequireDesign", attr.Policy); + } + + [Fact] + public void LoginPage_RendersLdapCredentialHint() + { + var cut = Render(); + + Assert.Contains("LDAP credentials", cut.Markup); + } + + [Fact] + public void LoginPage_RendersScadaLinkTitle() + { + var cut = Render(); + + var title = cut.Find("h4.card-title"); + Assert.Equal("ScadaLink", title.TextContent); + } +} diff --git a/tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj b/tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj index fa08b46..fc2972d 100644 --- a/tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj +++ b/tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj @@ -9,8 +9,10 @@ + + @@ -19,6 +21,10 @@ + + + + diff --git a/tests/ScadaLink.ClusterInfrastructure.Tests/ClusterOptionsTests.cs b/tests/ScadaLink.ClusterInfrastructure.Tests/ClusterOptionsTests.cs new file mode 100644 index 0000000..178a6e0 --- /dev/null +++ b/tests/ScadaLink.ClusterInfrastructure.Tests/ClusterOptionsTests.cs @@ -0,0 +1,51 @@ +namespace ScadaLink.ClusterInfrastructure.Tests; + +/// +/// Tests for ClusterOptions default values and property setters. +/// +public class ClusterOptionsTests +{ + [Fact] + public void DefaultValues_AreCorrect() + { + var options = new ClusterOptions(); + + Assert.Equal("keep-oldest", options.SplitBrainResolverStrategy); + Assert.Equal(TimeSpan.FromSeconds(15), options.StableAfter); + Assert.Equal(TimeSpan.FromSeconds(2), options.HeartbeatInterval); + Assert.Equal(TimeSpan.FromSeconds(10), options.FailureDetectionThreshold); + Assert.Equal(1, options.MinNrOfMembers); + } + + [Fact] + public void SeedNodes_DefaultsToEmptyList() + { + var options = new ClusterOptions(); + + Assert.NotNull(options.SeedNodes); + Assert.Empty(options.SeedNodes); + } + + [Fact] + public void Properties_CanBeSetToCustomValues() + { + var options = new ClusterOptions + { + SeedNodes = new List { "akka.tcp://system@node1:2551", "akka.tcp://system@node2:2551" }, + SplitBrainResolverStrategy = "keep-majority", + StableAfter = TimeSpan.FromSeconds(30), + HeartbeatInterval = TimeSpan.FromSeconds(5), + FailureDetectionThreshold = TimeSpan.FromSeconds(20), + MinNrOfMembers = 2 + }; + + Assert.Equal(2, options.SeedNodes.Count); + Assert.Contains("akka.tcp://system@node1:2551", options.SeedNodes); + Assert.Contains("akka.tcp://system@node2:2551", options.SeedNodes); + Assert.Equal("keep-majority", options.SplitBrainResolverStrategy); + Assert.Equal(TimeSpan.FromSeconds(30), options.StableAfter); + Assert.Equal(TimeSpan.FromSeconds(5), options.HeartbeatInterval); + Assert.Equal(TimeSpan.FromSeconds(20), options.FailureDetectionThreshold); + Assert.Equal(2, options.MinNrOfMembers); + } +} diff --git a/tests/ScadaLink.ClusterInfrastructure.Tests/UnitTest1.cs b/tests/ScadaLink.ClusterInfrastructure.Tests/UnitTest1.cs deleted file mode 100644 index 9c5d693..0000000 --- a/tests/ScadaLink.ClusterInfrastructure.Tests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace ScadaLink.ClusterInfrastructure.Tests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} diff --git a/tests/ScadaLink.DataConnectionLayer.Tests/LmxProxyDataConnectionTests.cs b/tests/ScadaLink.DataConnectionLayer.Tests/LmxProxyDataConnectionTests.cs index a299f20..1006f9e 100644 --- a/tests/ScadaLink.DataConnectionLayer.Tests/LmxProxyDataConnectionTests.cs +++ b/tests/ScadaLink.DataConnectionLayer.Tests/LmxProxyDataConnectionTests.cs @@ -17,7 +17,7 @@ public class LmxProxyDataConnectionTests { _mockClient = Substitute.For(); _mockFactory = Substitute.For(); - _mockFactory.Create(Arg.Any(), Arg.Any(), Arg.Any()).Returns(_mockClient); + _mockFactory.Create(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(_mockClient); _adapter = new LmxProxyDataConnection(_mockFactory, NullLogger.Instance); } @@ -41,7 +41,7 @@ public class LmxProxyDataConnectionTests }); Assert.Equal(ConnectionHealth.Connected, _adapter.Status); - _mockFactory.Received(1).Create("myhost", 5001, null); + _mockFactory.Received(1).Create("myhost", 5001, null, 0, false); await _mockClient.Received(1).ConnectAsync(Arg.Any()); } @@ -57,7 +57,7 @@ public class LmxProxyDataConnectionTests ["ApiKey"] = "my-secret-key" }); - _mockFactory.Received(1).Create("server", 50051, "my-secret-key"); + _mockFactory.Received(1).Create("server", 50051, "my-secret-key", 0, false); } [Fact] @@ -67,7 +67,7 @@ public class LmxProxyDataConnectionTests await _adapter.ConnectAsync(new Dictionary()); - _mockFactory.Received(1).Create("localhost", 50051, null); + _mockFactory.Received(1).Create("localhost", 50051, null, 0, false); } [Fact] @@ -201,7 +201,7 @@ public class LmxProxyDataConnectionTests { await ConnectAdapter(); var mockSub = Substitute.For(); - _mockClient.SubscribeAsync(Arg.Any>(), Arg.Any>(), Arg.Any()) + _mockClient.SubscribeAsync(Arg.Any>(), Arg.Any>(), Arg.Any(), Arg.Any()) .Returns(mockSub); var subId = await _adapter.SubscribeAsync("Tag1", (_, _) => { }); @@ -209,7 +209,7 @@ public class LmxProxyDataConnectionTests Assert.NotNull(subId); Assert.NotEmpty(subId); await _mockClient.Received(1).SubscribeAsync( - Arg.Any>(), Arg.Any>(), Arg.Any()); + Arg.Any>(), Arg.Any>(), Arg.Any(), Arg.Any()); } [Fact] @@ -217,7 +217,7 @@ public class LmxProxyDataConnectionTests { await ConnectAdapter(); var mockSub = Substitute.For(); - _mockClient.SubscribeAsync(Arg.Any>(), Arg.Any>(), Arg.Any()) + _mockClient.SubscribeAsync(Arg.Any>(), Arg.Any>(), Arg.Any(), Arg.Any()) .Returns(mockSub); var subId = await _adapter.SubscribeAsync("Tag1", (_, _) => { }); @@ -240,7 +240,7 @@ public class LmxProxyDataConnectionTests { await ConnectAdapter(); var mockSub = Substitute.For(); - _mockClient.SubscribeAsync(Arg.Any>(), Arg.Any>(), Arg.Any()) + _mockClient.SubscribeAsync(Arg.Any>(), Arg.Any>(), Arg.Any(), Arg.Any()) .Returns(mockSub); await _adapter.SubscribeAsync("Tag1", (_, _) => { }); @@ -277,4 +277,46 @@ public class LmxProxyDataConnectionTests await Assert.ThrowsAsync(() => _adapter.SubscribeAsync("tag1", (_, _) => { })); } + + // --- Configuration Parsing --- + + [Fact] + public async Task Connect_ParsesSamplingInterval() + { + _mockClient.IsConnected.Returns(true); + + await _adapter.ConnectAsync(new Dictionary + { + ["Host"] = "server", + ["Port"] = "50051", + ["SamplingIntervalMs"] = "500" + }); + + _mockFactory.Received(1).Create("server", 50051, null, 500, false); + } + + [Fact] + public async Task Connect_ParsesUseTls() + { + _mockClient.IsConnected.Returns(true); + + await _adapter.ConnectAsync(new Dictionary + { + ["Host"] = "server", + ["Port"] = "50051", + ["UseTls"] = "true" + }); + + _mockFactory.Received(1).Create("server", 50051, null, 0, true); + } + + [Fact] + public async Task Connect_DefaultsSamplingAndTls() + { + _mockClient.IsConnected.Returns(true); + + await _adapter.ConnectAsync(new Dictionary()); + + _mockFactory.Received(1).Create("localhost", 50051, null, 0, false); + } } diff --git a/tests/ScadaLink.DataConnectionLayer.Tests/OpcUaDataConnectionTests.cs b/tests/ScadaLink.DataConnectionLayer.Tests/OpcUaDataConnectionTests.cs index b4bdb04..d39e2be 100644 --- a/tests/ScadaLink.DataConnectionLayer.Tests/OpcUaDataConnectionTests.cs +++ b/tests/ScadaLink.DataConnectionLayer.Tests/OpcUaDataConnectionTests.cs @@ -34,7 +34,7 @@ public class OpcUaDataConnectionTests }); Assert.Equal(ConnectionHealth.Connected, _adapter.Status); - await _mockClient.Received(1).ConnectAsync("opc.tcp://localhost:4840", Arg.Any()); + await _mockClient.Received(1).ConnectAsync("opc.tcp://localhost:4840", Arg.Any(), Arg.Any()); } [Fact] @@ -149,4 +149,123 @@ public class OpcUaDataConnectionTests Assert.Equal(ConnectionHealth.Disconnected, _adapter.Status); } + + // --- Configuration Parsing --- + + [Fact] + public async Task Connect_ParsesAllConfigurationKeys() + { + _mockClient.IsConnected.Returns(true); + + await _adapter.ConnectAsync(new Dictionary + { + ["EndpointUrl"] = "opc.tcp://myserver:4840", + ["SessionTimeoutMs"] = "120000", + ["OperationTimeoutMs"] = "30000", + ["PublishingIntervalMs"] = "500", + ["KeepAliveCount"] = "5", + ["LifetimeCount"] = "15", + ["MaxNotificationsPerPublish"] = "200", + ["SamplingIntervalMs"] = "250", + ["QueueSize"] = "20", + ["SecurityMode"] = "SignAndEncrypt", + ["AutoAcceptUntrustedCerts"] = "false" + }); + + await _mockClient.Received(1).ConnectAsync( + "opc.tcp://myserver:4840", + Arg.Is(o => + o != null && + o.SessionTimeoutMs == 120000 && + o.OperationTimeoutMs == 30000 && + o.PublishingIntervalMs == 500 && + o.KeepAliveCount == 5 && + o.LifetimeCount == 15 && + o.MaxNotificationsPerPublish == 200 && + o.SamplingIntervalMs == 250 && + o.QueueSize == 20 && + o.SecurityMode == "SignAndEncrypt" && + o.AutoAcceptUntrustedCerts == false), + Arg.Any()); + } + + [Fact] + public async Task Connect_UsesDefaults_WhenKeysNotProvided() + { + _mockClient.IsConnected.Returns(true); + + await _adapter.ConnectAsync(new Dictionary()); + + await _mockClient.Received(1).ConnectAsync( + "opc.tcp://localhost:4840", + Arg.Is(o => + o != null && + o.SessionTimeoutMs == 60000 && + o.OperationTimeoutMs == 15000 && + o.PublishingIntervalMs == 1000 && + o.KeepAliveCount == 10 && + o.LifetimeCount == 30 && + o.MaxNotificationsPerPublish == 100 && + o.SamplingIntervalMs == 1000 && + o.QueueSize == 10 && + o.SecurityMode == "None" && + o.AutoAcceptUntrustedCerts == true), + Arg.Any()); + } + + [Fact] + public async Task Connect_IgnoresInvalidNumericValues() + { + _mockClient.IsConnected.Returns(true); + + await _adapter.ConnectAsync(new Dictionary + { + ["SessionTimeoutMs"] = "notanumber", + ["OperationTimeoutMs"] = "", + ["PublishingIntervalMs"] = "abc", + ["QueueSize"] = "12.5" + }); + + await _mockClient.Received(1).ConnectAsync( + Arg.Any(), + Arg.Is(o => + o != null && + o.SessionTimeoutMs == 60000 && + o.OperationTimeoutMs == 15000 && + o.PublishingIntervalMs == 1000 && + o.QueueSize == 10), + Arg.Any()); + } + + [Fact] + public async Task Connect_ParsesSecurityMode() + { + _mockClient.IsConnected.Returns(true); + + await _adapter.ConnectAsync(new Dictionary + { + ["SecurityMode"] = "Sign" + }); + + await _mockClient.Received(1).ConnectAsync( + Arg.Any(), + Arg.Is(o => o != null && o.SecurityMode == "Sign"), + Arg.Any()); + } + + [Fact] + public async Task Connect_ParsesAutoAcceptCerts() + { + _mockClient.IsConnected.Returns(true); + + await _adapter.ConnectAsync(new Dictionary + { + ["AutoAcceptUntrustedCerts"] = "false" + }); + + await _mockClient.Received(1).ConnectAsync( + Arg.Any(), + Arg.Is(o => o != null && o.AutoAcceptUntrustedCerts == false), + Arg.Any()); + } } diff --git a/tests/ScadaLink.HealthMonitoring.Tests/HealthReportSenderTests.cs b/tests/ScadaLink.HealthMonitoring.Tests/HealthReportSenderTests.cs index 94b1fd2..02f3d1e 100644 --- a/tests/ScadaLink.HealthMonitoring.Tests/HealthReportSenderTests.cs +++ b/tests/ScadaLink.HealthMonitoring.Tests/HealthReportSenderTests.cs @@ -23,6 +23,7 @@ public class HealthReportSenderTests { var transport = new FakeTransport(); var collector = new SiteHealthCollector(); + collector.SetActiveNode(true); var options = Options.Create(new HealthMonitoringOptions { ReportInterval = TimeSpan.FromMilliseconds(50) @@ -61,6 +62,7 @@ public class HealthReportSenderTests { var transport = new FakeTransport(); var collector = new SiteHealthCollector(); + collector.SetActiveNode(true); var options = Options.Create(new HealthMonitoringOptions { ReportInterval = TimeSpan.FromMilliseconds(50) @@ -91,6 +93,7 @@ public class HealthReportSenderTests { var transport = new FakeTransport(); var collector = new SiteHealthCollector(); + collector.SetActiveNode(true); var options = Options.Create(new HealthMonitoringOptions { ReportInterval = TimeSpan.FromMilliseconds(50) diff --git a/tests/ScadaLink.InboundAPI.Tests/InboundScriptExecutorTests.cs b/tests/ScadaLink.InboundAPI.Tests/InboundScriptExecutorTests.cs index 74fc10f..6b15573 100644 --- a/tests/ScadaLink.InboundAPI.Tests/InboundScriptExecutorTests.cs +++ b/tests/ScadaLink.InboundAPI.Tests/InboundScriptExecutorTests.cs @@ -17,7 +17,7 @@ public class InboundScriptExecutorTests public InboundScriptExecutorTests() { - _executor = new InboundScriptExecutor(NullLogger.Instance); + _executor = new InboundScriptExecutor(NullLogger.Instance, Substitute.For()); var locator = Substitute.For(); var commService = Substitute.For( Microsoft.Extensions.Options.Options.Create(new CommunicationOptions()), @@ -47,9 +47,10 @@ public class InboundScriptExecutorTests } [Fact] - public async Task UnregisteredHandler_ReturnsFailure() + public async Task UnregisteredHandler_InvalidScript_ReturnsCompilationFailure() { - var method = new ApiMethod("unknown", "return 1;") { Id = 1, TimeoutSeconds = 10 }; + // Use an invalid script that cannot be compiled by Roslyn + var method = new ApiMethod("unknown", "%%% invalid C# %%%") { Id = 1, TimeoutSeconds = 10 }; var result = await _executor.ExecuteAsync( method, @@ -58,7 +59,22 @@ public class InboundScriptExecutorTests TimeSpan.FromSeconds(10)); Assert.False(result.Success); - Assert.Contains("not compiled", result.ErrorMessage); + Assert.Contains("Script compilation failed", result.ErrorMessage); + } + + [Fact] + public async Task UnregisteredHandler_ValidScript_LazyCompiles() + { + // Valid script that is not pre-registered triggers lazy compilation + var method = new ApiMethod("lazy", "return 1;") { Id = 1, TimeoutSeconds = 10 }; + + var result = await _executor.ExecuteAsync( + method, + new Dictionary(), + _route, + TimeSpan.FromSeconds(10)); + + Assert.True(result.Success); } [Fact] diff --git a/tests/ScadaLink.NotificationService.Tests/NotificationDeliveryServiceTests.cs b/tests/ScadaLink.NotificationService.Tests/NotificationDeliveryServiceTests.cs index 97c67c6..48b3d13 100644 --- a/tests/ScadaLink.NotificationService.Tests/NotificationDeliveryServiceTests.cs +++ b/tests/ScadaLink.NotificationService.Tests/NotificationDeliveryServiceTests.cs @@ -3,6 +3,7 @@ using NSubstitute; using NSubstitute.ExceptionExtensions; using ScadaLink.Commons.Entities.Notifications; using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.StoreAndForward; namespace ScadaLink.NotificationService.Tests; @@ -145,4 +146,50 @@ public class NotificationDeliveryServiceTests Assert.False(result.Success); Assert.Contains("store-and-forward not available", result.ErrorMessage); } + + [Fact] + public async Task Send_UsesBccDelivery_AllRecipientsInBcc() + { + SetupHappyPath(); + IEnumerable? capturedBcc = null; + _smtpClient.SendAsync( + Arg.Any(), + Arg.Do>(bcc => capturedBcc = bcc), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.CompletedTask); + + var service = CreateService(); + await service.SendAsync("ops-team", "Alert", "Body"); + + Assert.NotNull(capturedBcc); + var bccList = capturedBcc!.ToList(); + Assert.Equal(2, bccList.Count); + Assert.Contains("alice@example.com", bccList); + Assert.Contains("bob@example.com", bccList); + } + + [Fact] + public async Task Send_TransientError_WithStoreAndForward_BuffersMessage() + { + SetupHappyPath(); + _smtpClient.SendAsync(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) + .Throws(new TimeoutException("Connection timed out")); + + var dbName = $"file:sf_test_{Guid.NewGuid():N}?mode=memory&cache=shared"; + var storage = new StoreAndForward.StoreAndForwardStorage( + $"Data Source={dbName}", NullLogger.Instance); + await storage.InitializeAsync(); + + var sfOptions = new StoreAndForward.StoreAndForwardOptions(); + var sfService = new StoreAndForward.StoreAndForwardService( + storage, sfOptions, NullLogger.Instance); + + var service = CreateService(sf: sfService); + var result = await service.SendAsync("ops-team", "Alert", "Body"); + + Assert.True(result.Success); + Assert.True(result.WasBuffered); + } } diff --git a/tests/ScadaLink.NotificationService.Tests/OAuth2TokenServiceTests.cs b/tests/ScadaLink.NotificationService.Tests/OAuth2TokenServiceTests.cs new file mode 100644 index 0000000..159689f --- /dev/null +++ b/tests/ScadaLink.NotificationService.Tests/OAuth2TokenServiceTests.cs @@ -0,0 +1,139 @@ +using System.Net; +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; + +namespace ScadaLink.NotificationService.Tests; + +/// +/// Tests for OAuth2 token flow — token acquisition, caching, and credential parsing. +/// +public class OAuth2TokenServiceTests +{ + private static HttpClient CreateMockHttpClient(HttpStatusCode statusCode, string responseJson) + { + var handler = new MockHttpMessageHandler(statusCode, responseJson); + return new HttpClient(handler); + } + + private static IHttpClientFactory CreateMockFactory(HttpClient client) + { + var factory = Substitute.For(); + factory.CreateClient(Arg.Any()).Returns(client); + return factory; + } + + [Fact] + public async Task GetTokenAsync_ReturnsAccessToken_FromTokenEndpoint() + { + var tokenResponse = JsonSerializer.Serialize(new + { + access_token = "mock-access-token-12345", + expires_in = 3600, + token_type = "Bearer" + }); + + var client = CreateMockHttpClient(HttpStatusCode.OK, tokenResponse); + var factory = CreateMockFactory(client); + var service = new OAuth2TokenService(factory, NullLogger.Instance); + + var token = await service.GetTokenAsync("tenant123:client456:secret789"); + + Assert.Equal("mock-access-token-12345", token); + } + + [Fact] + public async Task GetTokenAsync_CachesToken_OnSubsequentCalls() + { + var tokenResponse = JsonSerializer.Serialize(new + { + access_token = "cached-token", + expires_in = 3600, + token_type = "Bearer" + }); + + var handler = new CountingHttpMessageHandler(HttpStatusCode.OK, tokenResponse); + var client = new HttpClient(handler); + var factory = CreateMockFactory(client); + var service = new OAuth2TokenService(factory, NullLogger.Instance); + + var token1 = await service.GetTokenAsync("tenant:client:secret"); + var token2 = await service.GetTokenAsync("tenant:client:secret"); + + Assert.Equal("cached-token", token1); + Assert.Equal("cached-token", token2); + Assert.Equal(1, handler.CallCount); // Only one HTTP call should be made + } + + [Fact] + public async Task GetTokenAsync_InvalidCredentialFormat_ThrowsInvalidOperationException() + { + var client = CreateMockHttpClient(HttpStatusCode.OK, "{}"); + var factory = CreateMockFactory(client); + var service = new OAuth2TokenService(factory, NullLogger.Instance); + + await Assert.ThrowsAsync( + () => service.GetTokenAsync("invalid-no-colons")); + } + + [Fact] + public async Task GetTokenAsync_HttpFailure_ThrowsHttpRequestException() + { + var client = CreateMockHttpClient(HttpStatusCode.Unauthorized, "Unauthorized"); + var factory = CreateMockFactory(client); + var service = new OAuth2TokenService(factory, NullLogger.Instance); + + await Assert.ThrowsAsync( + () => service.GetTokenAsync("tenant:client:secret")); + } + + /// + /// Simple mock HTTP handler that returns a fixed response. + /// + private class MockHttpMessageHandler : HttpMessageHandler + { + private readonly HttpStatusCode _statusCode; + private readonly string _responseContent; + + public MockHttpMessageHandler(HttpStatusCode statusCode, string responseContent) + { + _statusCode = statusCode; + _responseContent = responseContent; + } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(new HttpResponseMessage(_statusCode) + { + Content = new StringContent(_responseContent) + }); + } + } + + /// + /// Mock HTTP handler that counts invocations. + /// + private class CountingHttpMessageHandler : HttpMessageHandler + { + private readonly HttpStatusCode _statusCode; + private readonly string _responseContent; + public int CallCount { get; private set; } + + public CountingHttpMessageHandler(HttpStatusCode statusCode, string responseContent) + { + _statusCode = statusCode; + _responseContent = responseContent; + } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + CallCount++; + return Task.FromResult(new HttpResponseMessage(_statusCode) + { + Content = new StringContent(_responseContent) + }); + } + } +} diff --git a/tests/ScadaLink.NotificationService.Tests/ScadaLink.NotificationService.Tests.csproj b/tests/ScadaLink.NotificationService.Tests/ScadaLink.NotificationService.Tests.csproj index 77537d5..68fc215 100644 --- a/tests/ScadaLink.NotificationService.Tests/ScadaLink.NotificationService.Tests.csproj +++ b/tests/ScadaLink.NotificationService.Tests/ScadaLink.NotificationService.Tests.csproj @@ -23,6 +23,7 @@ +