feat: add JoeAppEngine OPC UA nodes, fix DCL auto-reconnect and quality push
- Add JoeAppEngine folder to OPC UA nodes.json (BTCS, AlarmCntsBySeverity, Scheduler/ScanTime) - Fix DataConnectionActor: capture Self in PreStart for use from non-actor threads, preventing Self.Tell failure in Disconnected event handler - Implement InstanceActor.HandleConnectionQualityChanged to mark attributes Bad on disconnect - Fix LmxFakeProxy TagMapper to serialize arrays as JSON instead of "System.Int32[]" - Allow DataType and DataSourceReference updates in TemplateService.UpdateAttributeAsync - Update test_infra_opcua.md with JoeAppEngine documentation
This commit is contained in:
15
CLAUDE.md
15
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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,31 +6,31 @@ namespace ScadaLink.CLI.Commands;
|
||||
|
||||
public static class ApiMethodCommands
|
||||
{
|
||||
public static Command Build(Option<string> contactPointsOption, Option<string> formatOption)
|
||||
public static Command Build(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildList(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildGet(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildCreate(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var nameOption = new Option<string>("--name") { Description = "Method name", Required = true };
|
||||
var scriptOption = new Option<string>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildUpdate(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "API method ID", Required = true };
|
||||
var scriptOption = new Option<string>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildDelete(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--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;
|
||||
}
|
||||
|
||||
@@ -6,16 +6,16 @@ namespace ScadaLink.CLI.Commands;
|
||||
|
||||
public static class AuditLogCommands
|
||||
{
|
||||
public static Command Build(Option<string> contactPointsOption, Option<string> formatOption)
|
||||
public static Command Build(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildQuery(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var userOption = new Option<string?>("--user") { Description = "Filter by username" };
|
||||
var entityTypeOption = new Option<string?>("--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;
|
||||
|
||||
@@ -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<string>());
|
||||
|
||||
internal static string NewCorrelationId() => Guid.NewGuid().ToString("N");
|
||||
|
||||
internal static async Task<int> ExecuteCommandAsync(
|
||||
ParseResult result,
|
||||
Option<string> contactPointsOption,
|
||||
Option<string> formatOption,
|
||||
Option<string> usernameOption,
|
||||
Option<string> 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<LdapAuthService>.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<string>(), Array.Empty<string>()),
|
||||
new ResolveRolesCommand(authResult.Groups ?? (IReadOnlyList<string>)Array.Empty<string>()),
|
||||
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<string>();
|
||||
permittedSiteIds = rolesDoc.RootElement.TryGetProperty("PermittedSiteIds", out var sitesEl)
|
||||
? sitesEl.EnumerateArray().Select(e => e.GetString()!).ToArray()
|
||||
: Array.Empty<string>();
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,22 +6,22 @@ namespace ScadaLink.CLI.Commands;
|
||||
|
||||
public static class DataConnectionCommands
|
||||
{
|
||||
public static Command Build(Option<string> contactPointsOption, Option<string> formatOption)
|
||||
public static Command Build(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildGet(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildUpdate(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Data connection ID", Required = true };
|
||||
var nameOption = new Option<string>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildUnassign(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildList(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildCreate(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var nameOption = new Option<string>("--name") { Description = "Connection name", Required = true };
|
||||
var protocolOption = new Option<string>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildDelete(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildAssign(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var connectionIdOption = new Option<int>("--connection-id") { Description = "Data connection ID", Required = true };
|
||||
var siteIdOption = new Option<int>("--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;
|
||||
|
||||
@@ -6,31 +6,31 @@ namespace ScadaLink.CLI.Commands;
|
||||
|
||||
public static class DbConnectionCommands
|
||||
{
|
||||
public static Command Build(Option<string> contactPointsOption, Option<string> formatOption)
|
||||
public static Command Build(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildList(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildGet(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildCreate(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var nameOption = new Option<string>("--name") { Description = "Connection name", Required = true };
|
||||
var connStrOption = new Option<string>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildUpdate(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Database connection ID", Required = true };
|
||||
var nameOption = new Option<string>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildDelete(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--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;
|
||||
}
|
||||
|
||||
@@ -6,16 +6,16 @@ namespace ScadaLink.CLI.Commands;
|
||||
|
||||
public static class DebugCommands
|
||||
{
|
||||
public static Command Build(Option<string> contactPointsOption, Option<string> formatOption)
|
||||
public static Command Build(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildSnapshot(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--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;
|
||||
|
||||
@@ -6,18 +6,18 @@ namespace ScadaLink.CLI.Commands;
|
||||
|
||||
public static class DeployCommands
|
||||
{
|
||||
public static Command Build(Option<string> contactPointsOption, Option<string> formatOption)
|
||||
public static Command Build(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildInstance(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildArtifacts(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var siteIdOption = new Option<int?>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildStatus(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var instanceIdOption = new Option<int?>("--instance-id") { Description = "Filter by instance ID" };
|
||||
var statusOption = new Option<string?>("--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;
|
||||
|
||||
@@ -6,21 +6,21 @@ namespace ScadaLink.CLI.Commands;
|
||||
|
||||
public static class ExternalSystemCommands
|
||||
{
|
||||
public static Command Build(Option<string> contactPointsOption, Option<string> formatOption)
|
||||
public static Command Build(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildGet(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildUpdate(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "External system ID", Required = true };
|
||||
var nameOption = new Option<string>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildList(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildCreate(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var nameOption = new Option<string>("--name") { Description = "System name", Required = true };
|
||||
var urlOption = new Option<string>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildDelete(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildMethodGroup(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildMethodList(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var sysIdOption = new Option<int>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildMethodGet(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildMethodCreate(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var sysIdOption = new Option<int>("--external-system-id") { Description = "External system ID", Required = true };
|
||||
var nameOption = new Option<string>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildMethodUpdate(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Method ID", Required = true };
|
||||
var nameOption = new Option<string?>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildMethodDelete(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--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;
|
||||
|
||||
@@ -6,30 +6,30 @@ namespace ScadaLink.CLI.Commands;
|
||||
|
||||
public static class HealthCommands
|
||||
{
|
||||
public static Command Build(Option<string> contactPointsOption, Option<string> formatOption)
|
||||
public static Command Build(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildSummary(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildSite(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var identifierOption = new Option<string>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildEventLog(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var siteOption = new Option<string>("--site") { Description = "Site identifier", Required = true };
|
||||
var eventTypeOption = new Option<string?>("--event-type") { Description = "Filter by event type" };
|
||||
@@ -55,6 +55,7 @@ public static class HealthCommands
|
||||
pageOption.DefaultValueFactory = _ => 1;
|
||||
var pageSizeOption = new Option<int>("--page-size") { Description = "Page size" };
|
||||
pageSizeOption.DefaultValueFactory = _ => 50;
|
||||
var instanceNameOption = new Option<string?>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildParkedMessages(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var siteOption = new Option<string>("--site") { Description = "Site identifier", Required = true };
|
||||
var pageOption = new Option<int>("--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),
|
||||
|
||||
@@ -6,23 +6,26 @@ namespace ScadaLink.CLI.Commands;
|
||||
|
||||
public static class InstanceCommands
|
||||
{
|
||||
public static Command Build(Option<string> contactPointsOption, Option<string> formatOption)
|
||||
public static Command Build(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildGet(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildSetBindings(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
|
||||
var bindingsOption = new Option<string>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildList(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var siteIdOption = new Option<int?>("--site-id") { Description = "Filter by site ID" };
|
||||
var templateIdOption = new Option<int?>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildCreate(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var nameOption = new Option<string>("--name") { Description = "Unique instance name", Required = true };
|
||||
var templateIdOption = new Option<int>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildDeploy(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildEnable(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildDisable(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildDelete(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--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<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
|
||||
var overridesOption = new Option<string>("--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<Dictionary<string, string?>>(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<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
|
||||
var areaIdOption = new Option<int?>("--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<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--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;
|
||||
}
|
||||
|
||||
@@ -6,21 +6,21 @@ namespace ScadaLink.CLI.Commands;
|
||||
|
||||
public static class NotificationCommands
|
||||
{
|
||||
public static Command Build(Option<string> contactPointsOption, Option<string> formatOption)
|
||||
public static Command Build(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildGet(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildUpdate(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Notification list ID", Required = true };
|
||||
var nameOption = new Option<string>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildSmtp(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildList(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildCreate(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var nameOption = new Option<string>("--name") { Description = "Notification list name", Required = true };
|
||||
var emailsOption = new Option<string>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildDelete(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--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;
|
||||
}
|
||||
|
||||
@@ -6,18 +6,18 @@ namespace ScadaLink.CLI.Commands;
|
||||
|
||||
public static class SecurityCommands
|
||||
{
|
||||
public static Command Build(Option<string> contactPointsOption, Option<string> formatOption)
|
||||
public static Command Build(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildApiKey(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildRoleMapping(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildScopeRule(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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);
|
||||
|
||||
|
||||
@@ -6,31 +6,31 @@ namespace ScadaLink.CLI.Commands;
|
||||
|
||||
public static class SharedScriptCommands
|
||||
{
|
||||
public static Command Build(Option<string> contactPointsOption, Option<string> formatOption)
|
||||
public static Command Build(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildList(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildGet(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildCreate(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var nameOption = new Option<string>("--name") { Description = "Script name", Required = true };
|
||||
var codeOption = new Option<string>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildUpdate(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Shared script ID", Required = true };
|
||||
var nameOption = new Option<string>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildDelete(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--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;
|
||||
}
|
||||
|
||||
@@ -6,22 +6,22 @@ namespace ScadaLink.CLI.Commands;
|
||||
|
||||
public static class SiteCommands
|
||||
{
|
||||
public static Command Build(Option<string> contactPointsOption, Option<string> formatOption)
|
||||
public static Command Build(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildGet(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildList(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildCreate(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var nameOption = new Option<string>("--name") { Description = "Site name", Required = true };
|
||||
var identifierOption = new Option<string>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildUpdate(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Site ID", Required = true };
|
||||
var nameOption = new Option<string>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildDelete(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildArea(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildDeployArtifacts(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var siteIdOption = new Option<int?>("--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;
|
||||
}
|
||||
|
||||
@@ -6,36 +6,36 @@ namespace ScadaLink.CLI.Commands;
|
||||
|
||||
public static class TemplateCommands
|
||||
{
|
||||
public static Command Build(Option<string> contactPointsOption, Option<string> formatOption)
|
||||
public static Command Build(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildList(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildGet(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildCreate(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var nameOption = new Option<string>("--name") { Description = "Template name", Required = true };
|
||||
var descOption = new Option<string?>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildUpdate(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Template ID", Required = true };
|
||||
var nameOption = new Option<string>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildValidate(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildDelete(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildAttribute(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildAlarm(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildScript(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string> contactPointsOption, Option<string> formatOption)
|
||||
private static Command BuildComposition(Option<string> contactPointsOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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);
|
||||
|
||||
@@ -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(_ =>
|
||||
{
|
||||
|
||||
@@ -41,8 +41,8 @@ These options are accepted by the root command and inherited by all subcommands.
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--contact-points <value>` | Comma-separated Akka cluster contact point URIs |
|
||||
| `--username <value>` | LDAP username (reserved for future auth integration) |
|
||||
| `--password <value>` | LDAP password (reserved for future auth integration) |
|
||||
| `--username <value>` | LDAP username for authentication |
|
||||
| `--password <value>` | LDAP password for authentication |
|
||||
| `--format <json\|table>` | 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 <uri> instance set-bindings --id <int> --bindings <js
|
||||
| Option | Required | Description |
|
||||
|--------|----------|-------------|
|
||||
| `--id` | yes | Instance ID |
|
||||
| `--bindings` | yes | JSON string mapping attribute names to data connection IDs (e.g. `{"attr1": 1, "attr2": 2}`) |
|
||||
| `--bindings` | yes | JSON array of `[attributeName, dataConnectionId]` pairs (e.g. `[["Speed",7],["Temperature",7]]`) |
|
||||
|
||||
---
|
||||
|
||||
@@ -1270,7 +1277,7 @@ The CLI connects to the Central cluster using Akka.NET's `ClusterClient`. It doe
|
||||
|
||||
The connection is established per-command invocation and torn down cleanly via `CoordinatedShutdown` when the command completes.
|
||||
|
||||
Role enforcement is applied by the ManagementActor on the server side. The current CLI placeholder user carries `Admin`, `Design`, and `Deployment` roles; production use will integrate LDAP authentication via `--username` / `--password`.
|
||||
Role enforcement is applied by the ManagementActor on the server side. The CLI authenticates against LDAP using `--username` / `--password`, resolves LDAP group memberships, then maps groups to ScadaLink roles (Admin, Design, Deployment) via role mappings configured in the security settings. Operations require the appropriate role — for example, creating templates requires `Design`, deploying requires `Deployment`. In the test environment, use the `multi-role` user (password: `password`) which has all three roles.
|
||||
|
||||
## Issues & Missing Features
|
||||
|
||||
|
||||
@@ -7,14 +7,20 @@
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<AssemblyName>scadalink</AssemblyName>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ScadaLink.CLI.Tests" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Akka" Version="1.5.62" />
|
||||
<PackageReference Include="Akka.Remote" Version="1.5.62" />
|
||||
<PackageReference Include="Akka.Cluster.Tools" Version="1.5.62" />
|
||||
<PackageReference Include="System.CommandLine" Version="2.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.5" />
|
||||
<PackageReference Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../ScadaLink.Commons/ScadaLink.Commons.csproj" />
|
||||
<ProjectReference Include="../ScadaLink.Security/ScadaLink.Security.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -65,6 +65,16 @@
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Area</label>
|
||||
<select class="form-select form-select-sm" @bind="_createAreaId">
|
||||
<option value="0">No area</option>
|
||||
@foreach (var a in _allAreas.Where(a => a.SiteId == _createSiteId))
|
||||
{
|
||||
<option value="@a.Id">@a.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button class="btn btn-success btn-sm me-1" @onclick="CreateInstance">Create</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showCreateForm = false">Cancel</button>
|
||||
@@ -181,10 +191,62 @@
|
||||
}
|
||||
<button class="btn btn-outline-info btn-sm py-0 px-1 me-1"
|
||||
@onclick="() => ToggleBindings(inst)">Bindings</button>
|
||||
<button class="btn btn-outline-secondary btn-sm py-0 px-1 me-1"
|
||||
@onclick="() => ToggleOverrides(inst)">Overrides</button>
|
||||
<button class="btn btn-outline-info btn-sm py-0 px-1 me-1"
|
||||
@onclick="() => ShowDiff(inst)" disabled="@(_actionInProgress || inst.State == InstanceState.NotDeployed)">Diff</button>
|
||||
<button class="btn btn-outline-danger btn-sm py-0 px-1"
|
||||
@onclick="() => DeleteInstance(inst)" disabled="@_actionInProgress">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
@if (_overrideInstanceId == inst.Id)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="7" class="bg-light p-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<strong>Attribute Overrides for @inst.UniqueName</strong>
|
||||
<div>
|
||||
<label class="form-label small d-inline me-1">Reassign Area:</label>
|
||||
<select class="form-select form-select-sm d-inline-block me-1" style="width:auto;" @bind="_reassignAreaId">
|
||||
<option value="0">No area</option>
|
||||
@foreach (var a in _allAreas.Where(a => a.SiteId == inst.SiteId))
|
||||
{
|
||||
<option value="@a.Id">@a.Name</option>
|
||||
}
|
||||
</select>
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="() => ReassignArea(inst)" disabled="@_actionInProgress">Set Area</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (_overrideAttrs.Count == 0)
|
||||
{
|
||||
<p class="text-muted small mb-0">No overridable (non-locked) attributes in this template.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm table-bordered mb-2">
|
||||
<thead class="table-light">
|
||||
<tr><th>Attribute</th><th>Template Value</th><th>Override Value</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var attr in _overrideAttrs)
|
||||
{
|
||||
<tr>
|
||||
<td class="small">@attr.Name <span class="badge bg-light text-dark">@attr.DataType</span></td>
|
||||
<td class="small text-muted">@(attr.Value ?? "—")</td>
|
||||
<td>
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
value="@GetOverrideValue(attr.Name)"
|
||||
@onchange="(e) => OnOverrideChanged(attr.Name, e)" />
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<button class="btn btn-success btn-sm" @onclick="SaveOverrides" disabled="@_actionInProgress">Save Overrides</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (_bindingInstanceId == inst.Id)
|
||||
{
|
||||
<tr>
|
||||
@@ -268,6 +330,55 @@
|
||||
<div class="text-muted small">
|
||||
@_filteredInstances.Count instance(s) total
|
||||
</div>
|
||||
|
||||
@* Diff Modal *@
|
||||
@if (_showDiffModal)
|
||||
{
|
||||
<div class="modal d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Deployment Diff — @_diffInstanceName</h5>
|
||||
<button type="button" class="btn-close" @onclick="() => _showDiffModal = false"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (_diffLoading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else if (_diffError != null)
|
||||
{
|
||||
<div class="alert alert-danger">@_diffError</div>
|
||||
}
|
||||
else if (_diffResult != null)
|
||||
{
|
||||
<div class="mb-2">
|
||||
<span class="badge @(_diffResult.IsStale ? "bg-warning text-dark" : "bg-success")">
|
||||
@(_diffResult.IsStale ? "Stale — changes pending" : "Current")
|
||||
</span>
|
||||
<span class="text-muted small ms-2">
|
||||
Deployed: @_diffResult.DeployedRevisionHash[..8]
|
||||
| Current: @_diffResult.CurrentRevisionHash[..8]
|
||||
| Deployed at: @_diffResult.DeployedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm")
|
||||
</span>
|
||||
</div>
|
||||
@if (!_diffResult.IsStale)
|
||||
{
|
||||
<p class="text-muted">No differences between deployed and current configuration.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted small">The deployed revision hash differs from the current template-derived hash. Redeploy to apply changes.</p>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary btn-sm" @onclick="() => _showDiffModal = false">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -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<TemplateAttribute> _overrideAttrs = new();
|
||||
private Dictionary<string, string?> _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<TemplateAttribute> _bindingDataSourceAttrs = new();
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -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<SmtpConfiguration> _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<ApiKey> _apiKeys = new();
|
||||
|
||||
// Notification Lists
|
||||
private List<NotificationList> _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 @@
|
||||
<div class="col-md-3"><label class="form-label small">Endpoint URL</label><input type="text" class="form-control form-control-sm" @bind="_extSysUrl" /></div>
|
||||
<div class="col-md-2"><label class="form-label small">Auth Type</label>
|
||||
<select class="form-select form-select-sm" @bind="_extSysAuth"><option>ApiKey</option><option>BasicAuth</option></select></div>
|
||||
<div class="col-md-3"><label class="form-label small">Auth Config (JSON)</label><input type="text" class="form-control form-control-sm" @bind="_extSysAuthConfig" /></div>
|
||||
<div class="col-md-2">
|
||||
<div class="col-md-2"><label class="form-label small">Auth Config (JSON)</label><input type="text" class="form-control form-control-sm" @bind="_extSysAuthConfig" /></div>
|
||||
<div class="col-md-1"><label class="form-label small">Max Retries</label><input type="number" class="form-control form-control-sm" @bind="_extSysMaxRetries" min="0" /></div>
|
||||
<div class="col-md-1"><label class="form-label small">Retry Delay (s)</label><input type="number" class="form-control form-control-sm" @bind="_extSysRetryDelaySeconds" min="0" /></div>
|
||||
<div class="col-md-1">
|
||||
<button class="btn btn-success btn-sm me-1" @onclick="SaveExtSys">Save</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showExtSysForm = false">Cancel</button></div>
|
||||
</div>
|
||||
@@ -154,14 +173,15 @@
|
||||
}
|
||||
|
||||
<table class="table table-sm table-striped">
|
||||
<thead class="table-dark"><tr><th>Name</th><th>URL</th><th>Auth</th><th style="width:120px;">Actions</th></tr></thead>
|
||||
<thead class="table-dark"><tr><th>Name</th><th>URL</th><th>Auth</th><th>Retries</th><th>Delay</th><th style="width:120px;">Actions</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var es in _externalSystems)
|
||||
{
|
||||
<tr>
|
||||
<td>@es.Name</td><td class="small">@es.EndpointUrl</td><td><span class="badge bg-secondary">@es.AuthType</span></td>
|
||||
<td class="small">@es.MaxRetries</td><td class="small">@es.RetryDelay.TotalSeconds s</td>
|
||||
<td>
|
||||
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1" @onclick="() => { _editingExtSys = es; _extSysName = es.Name; _extSysUrl = es.EndpointUrl; _extSysAuth = es.AuthType; _extSysAuthConfig = es.AuthConfiguration; _showExtSysForm = true; }">Edit</button>
|
||||
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1" @onclick="() => { _editingExtSys = es; _extSysName = es.Name; _extSysUrl = es.EndpointUrl; _extSysAuth = es.AuthType; _extSysAuthConfig = es.AuthConfiguration; _extSysMaxRetries = es.MaxRetries; _extSysRetryDelaySeconds = (int)es.RetryDelay.TotalSeconds; _showExtSysForm = true; }">Edit</button>
|
||||
<button class="btn btn-outline-danger btn-sm py-0 px-1" @onclick="() => DeleteExtSys(es)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -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 @@
|
||||
{
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<h6 class="mb-0">Database Connections</h6>
|
||||
<button class="btn btn-primary btn-sm" @onclick="() => { _showDbConnForm = true; _editingDbConn = null; _dbConnName = _dbConnString = string.Empty; _dbConnFormError = null; }">Add</button>
|
||||
<button class="btn btn-primary btn-sm" @onclick="() => { _showDbConnForm = true; _editingDbConn = null; _dbConnName = _dbConnString = string.Empty; _dbConnMaxRetries = 3; _dbConnRetryDelaySeconds = 5; _dbConnFormError = null; }">Add</button>
|
||||
</div>
|
||||
|
||||
@if (_showDbConnForm)
|
||||
@@ -213,7 +235,9 @@
|
||||
<div class="card mb-2"><div class="card-body">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-3"><label class="form-label small">Name</label><input type="text" class="form-control form-control-sm" @bind="_dbConnName" /></div>
|
||||
<div class="col-md-6"><label class="form-label small">Connection String</label><input type="text" class="form-control form-control-sm" @bind="_dbConnString" /></div>
|
||||
<div class="col-md-4"><label class="form-label small">Connection String</label><input type="text" class="form-control form-control-sm" @bind="_dbConnString" /></div>
|
||||
<div class="col-md-1"><label class="form-label small">Max Retries</label><input type="number" class="form-control form-control-sm" @bind="_dbConnMaxRetries" min="0" /></div>
|
||||
<div class="col-md-1"><label class="form-label small">Retry Delay (s)</label><input type="number" class="form-control form-control-sm" @bind="_dbConnRetryDelaySeconds" min="0" /></div>
|
||||
<div class="col-md-3">
|
||||
<button class="btn btn-success btn-sm me-1" @onclick="SaveDbConn">Save</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showDbConnForm = false">Cancel</button></div>
|
||||
@@ -223,14 +247,15 @@
|
||||
}
|
||||
|
||||
<table class="table table-sm table-striped">
|
||||
<thead class="table-dark"><tr><th>Name</th><th>Connection String</th><th style="width:120px;">Actions</th></tr></thead>
|
||||
<thead class="table-dark"><tr><th>Name</th><th>Connection String</th><th>Retries</th><th>Delay</th><th style="width:120px;">Actions</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var dc in _dbConnections)
|
||||
{
|
||||
<tr>
|
||||
<td>@dc.Name</td><td class="small text-muted text-truncate" style="max-width:400px;">@dc.ConnectionString</td>
|
||||
<td class="small">@dc.MaxRetries</td><td class="small">@dc.RetryDelay.TotalSeconds s</td>
|
||||
<td>
|
||||
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1" @onclick="() => { _editingDbConn = dc; _dbConnName = dc.Name; _dbConnString = dc.ConnectionString; _showDbConnForm = true; }">Edit</button>
|
||||
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1" @onclick="() => { _editingDbConn = dc; _dbConnName = dc.Name; _dbConnString = dc.ConnectionString; _dbConnMaxRetries = dc.MaxRetries; _dbConnRetryDelaySeconds = (int)dc.RetryDelay.TotalSeconds; _showDbConnForm = true; }">Edit</button>
|
||||
<button class="btn btn-outline-danger btn-sm py-0 px-1" @onclick="() => DeleteDbConn(dc)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -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 =>
|
||||
{
|
||||
<hr class="my-3" />
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<h6 class="mb-0">SMTP Configuration</h6>
|
||||
@if (_smtpConfigs.Count == 0)
|
||||
{
|
||||
<button class="btn btn-primary btn-sm" @onclick="ShowSmtpAddForm">Add SMTP Config</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_showSmtpForm)
|
||||
{
|
||||
<div class="card mb-2"><div class="card-body">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-3"><label class="form-label small">Host</label><input type="text" class="form-control form-control-sm" @bind="_smtpHost" /></div>
|
||||
<div class="col-md-1"><label class="form-label small">Port</label><input type="number" class="form-control form-control-sm" @bind="_smtpPort" /></div>
|
||||
<div class="col-md-2"><label class="form-label small">Auth Type</label>
|
||||
<select class="form-select form-select-sm" @bind="_smtpAuthType"><option>OAuth2</option><option>Basic</option></select></div>
|
||||
<div class="col-md-3"><label class="form-label small">From Address</label><input type="email" class="form-control form-control-sm" @bind="_smtpFromAddress" /></div>
|
||||
<div class="col-md-3">
|
||||
<button class="btn btn-success btn-sm me-1" @onclick="SaveSmtpConfig">Save</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showSmtpForm = false">Cancel</button></div>
|
||||
</div>
|
||||
@if (_smtpFormError != null) { <div class="text-danger small mt-1">@_smtpFormError</div> }
|
||||
</div></div>
|
||||
}
|
||||
|
||||
@foreach (var smtp in _smtpConfigs)
|
||||
{
|
||||
<div class="card mb-2">
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span class="small">
|
||||
<strong>@smtp.Host</strong>:@smtp.Port |
|
||||
Auth: <span class="badge bg-secondary">@smtp.AuthType</span> |
|
||||
From: @smtp.FromAddress
|
||||
</span>
|
||||
<button class="btn btn-outline-primary btn-sm py-0 px-1" @onclick="() => { _editingSmtp = smtp; _smtpHost = smtp.Host; _smtpPort = smtp.Port; _smtpAuthType = smtp.AuthType; _smtpFromAddress = smtp.FromAddress; _showSmtpForm = true; }">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
};
|
||||
|
||||
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 =>
|
||||
{
|
||||
<hr class="my-3" />
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<h6 class="mb-0">API Keys</h6>
|
||||
</div>
|
||||
|
||||
<table class="table table-sm table-striped">
|
||||
<thead class="table-dark"><tr><th>Key Name</th><th>Enabled</th><th style="width:120px;">Actions</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var key in _apiKeys)
|
||||
{
|
||||
<tr>
|
||||
<td>@key.Name</td>
|
||||
<td><span class="badge @(key.IsEnabled ? "bg-success" : "bg-secondary")">@(key.IsEnabled ? "Enabled" : "Disabled")</span></td>
|
||||
<td>
|
||||
<button class="btn btn-outline-primary btn-sm py-0 px-1" @onclick="() => ToggleApiKeyEnabled(key)">
|
||||
@(key.IsEnabled ? "Disable" : "Enable")
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
};
|
||||
|
||||
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); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -44,10 +44,14 @@
|
||||
<label class="form-label small">To</label>
|
||||
<input type="datetime-local" class="form-control form-control-sm" @bind="_filterTo" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small">Keyword</label>
|
||||
<input type="text" class="form-control form-control-sm" @bind="_filterKeyword" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Instance</label>
|
||||
<input type="text" class="form-control form-control-sm" @bind="_filterInstanceName" placeholder="Instance name" />
|
||||
</div>
|
||||
<div class="col-md-1 d-flex align-items-end">
|
||||
<button class="btn btn-primary btn-sm" @onclick="Search" disabled="@(string.IsNullOrEmpty(_selectedSiteId) || _searching)">
|
||||
@if (_searching) { <span class="spinner-border spinner-border-sm"></span> }
|
||||
@@ -111,6 +115,7 @@
|
||||
private DateTime? _filterFrom;
|
||||
private DateTime? _filterTo;
|
||||
private string? _filterKeyword;
|
||||
private string? _filterInstanceName;
|
||||
|
||||
private List<EventLogEntry>? _entries;
|
||||
private bool _hasMore;
|
||||
@@ -146,7 +151,7 @@
|
||||
To: _filterTo.HasValue ? new DateTimeOffset(_filterTo.Value, TimeSpan.Zero) : null,
|
||||
EventType: string.IsNullOrWhiteSpace(_filterEventType) ? null : _filterEventType.Trim(),
|
||||
Severity: string.IsNullOrWhiteSpace(_filterSeverity) ? null : _filterSeverity,
|
||||
InstanceId: null,
|
||||
InstanceId: string.IsNullOrWhiteSpace(_filterInstanceName) ? null : _filterInstanceName.Trim(),
|
||||
KeywordFilter: string.IsNullOrWhiteSpace(_filterKeyword) ? null : _filterKeyword.Trim(),
|
||||
ContinuationToken: _continuationToken,
|
||||
PageSize: 50,
|
||||
|
||||
@@ -71,6 +71,10 @@
|
||||
<span class="badge bg-danger me-2">Offline</span>
|
||||
}
|
||||
<strong>@siteId</strong>
|
||||
@if (state.LatestReport?.NodeRole != null)
|
||||
{
|
||||
<span class="badge @(state.LatestReport.NodeRole == "Active" ? "bg-primary" : "bg-secondary") ms-2">@state.LatestReport.NodeRole</span>
|
||||
}
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
Last report: @state.LastReportReceivedAt.LocalDateTime.ToString("HH:mm:ss") | Seq: @state.LastSequenceNumber
|
||||
|
||||
@@ -70,9 +70,11 @@
|
||||
<td class="small"><TimestampDisplay Value="@msg.LastAttemptTimestamp" /></td>
|
||||
<td>
|
||||
<button class="btn btn-outline-success btn-sm py-0 px-1 me-1"
|
||||
title="Retry message (not yet implemented)">Retry</button>
|
||||
@onclick="() => RetryMessage(msg)" disabled="@_actionInProgress"
|
||||
title="Retry message (move back to pending)">Retry</button>
|
||||
<button class="btn btn-outline-danger btn-sm py-0 px-1"
|
||||
title="Discard message (not yet implemented)">Discard</button>
|
||||
@onclick="() => DiscardMessage(msg)" disabled="@_actionInProgress"
|
||||
title="Permanently discard message">Discard</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,4 +22,11 @@ public interface IDataConnection : IAsyncDisposable
|
||||
Task<IReadOnlyDictionary<string, WriteResult>> WriteBatchAsync(IDictionary<string, object?> values, CancellationToken cancellationToken = default);
|
||||
Task<bool> WriteBatchAndWaitAsync(IDictionary<string, object?> values, string flagPath, object? flagValue, string responsePath, object? responseValue, TimeSpan timeout, CancellationToken cancellationToken = default);
|
||||
ConnectionHealth Status { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
event Action? Disconnected;
|
||||
}
|
||||
|
||||
@@ -14,4 +14,5 @@ public record SiteHealthReport(
|
||||
int DeadLetterCount,
|
||||
int DeployedInstanceCount,
|
||||
int EnabledInstanceCount,
|
||||
int DisabledInstanceCount);
|
||||
int DisabledInstanceCount,
|
||||
string NodeRole = "Unknown");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, string?> Overrides);
|
||||
public record SetInstanceAreaCommand(int InstanceId, int? AreaId);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string> LdapGroups);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace ScadaLink.Commons.Messages.RemoteQuery;
|
||||
|
||||
/// <summary>
|
||||
/// Request to permanently discard a parked message at a site.
|
||||
/// </summary>
|
||||
public record ParkedMessageDiscardRequest(
|
||||
string CorrelationId,
|
||||
string SiteId,
|
||||
string MessageId,
|
||||
DateTimeOffset Timestamp);
|
||||
|
||||
/// <summary>
|
||||
/// Response from discarding a parked message.
|
||||
/// </summary>
|
||||
public record ParkedMessageDiscardResponse(
|
||||
string CorrelationId,
|
||||
bool Success,
|
||||
string? ErrorMessage = null);
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace ScadaLink.Commons.Messages.RemoteQuery;
|
||||
|
||||
/// <summary>
|
||||
/// Request to retry a parked message at a site (move back to pending queue).
|
||||
/// </summary>
|
||||
public record ParkedMessageRetryRequest(
|
||||
string CorrelationId,
|
||||
string SiteId,
|
||||
string MessageId,
|
||||
DateTimeOffset Timestamp);
|
||||
|
||||
/// <summary>
|
||||
/// Response from retrying a parked message.
|
||||
/// </summary>
|
||||
public record ParkedMessageRetryResponse(
|
||||
string CorrelationId,
|
||||
bool Success,
|
||||
string? ErrorMessage = null);
|
||||
@@ -161,6 +161,22 @@ public class CommunicationService
|
||||
envelope, _options.QueryTimeout, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<ParkedMessageRetryResponse> RetryParkedMessageAsync(
|
||||
string siteId, ParkedMessageRetryRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var envelope = new SiteEnvelope(siteId, request);
|
||||
return await GetActor().Ask<ParkedMessageRetryResponse>(
|
||||
envelope, _options.QueryTimeout, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<ParkedMessageDiscardResponse> DiscardParkedMessageAsync(
|
||||
string siteId, ParkedMessageDiscardRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var envelope = new SiteEnvelope(siteId, request);
|
||||
return await GetActor().Ask<ParkedMessageDiscardResponse>(
|
||||
envelope, _options.QueryTimeout, cancellationToken);
|
||||
}
|
||||
|
||||
// ── Pattern 8: Heartbeat (site→central, Tell) ──
|
||||
// Heartbeats are received by central, not sent. No method needed here.
|
||||
|
||||
|
||||
@@ -62,6 +62,12 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
|
||||
private readonly IDictionary<string, string> _connectionDetails;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ public interface ILmxProxyClient : IAsyncDisposable
|
||||
Task<ILmxSubscription> SubscribeAsync(
|
||||
IEnumerable<string> addresses,
|
||||
Action<string, LmxVtq> onUpdate,
|
||||
Action? onStreamError = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -48,7 +49,7 @@ public interface ILmxProxyClient : IAsyncDisposable
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -56,7 +57,7 @@ public interface ILmxProxyClientFactory
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -93,7 +94,7 @@ internal class StubLmxProxyClient : ILmxProxyClient
|
||||
public Task WriteBatchAsync(IDictionary<string, object> values, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task<ILmxSubscription> SubscribeAsync(IEnumerable<string> addresses, Action<string, LmxVtq> onUpdate, CancellationToken cancellationToken = default)
|
||||
public Task<ILmxSubscription> SubscribeAsync(IEnumerable<string> addresses, Action<string, LmxVtq> onUpdate, Action? onStreamError = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<ILmxSubscription>(new StubLmxSubscription());
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
namespace ScadaLink.DataConnectionLayer.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for OPC UA connections, parsed from connection details JSON.
|
||||
/// All values have defaults matching the OPC Foundation SDK's typical settings.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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<uint> WriteValueAsync(string nodeId, object? value, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the OPC UA session detects a keep-alive failure or the server
|
||||
/// becomes unreachable. The adapter layer uses this to trigger reconnection.
|
||||
/// </summary>
|
||||
event Action? ConnectionLost;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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;
|
||||
|
||||
@@ -23,6 +23,7 @@ public class LmxProxyDataConnection : IDataConnection
|
||||
private ConnectionHealth _status = ConnectionHealth.Disconnected;
|
||||
|
||||
private readonly Dictionary<string, ILmxSubscription> _subscriptions = new();
|
||||
private volatile bool _disconnectFired;
|
||||
|
||||
public LmxProxyDataConnection(ILmxProxyClientFactory clientFactory, ILogger<LmxProxyDataConnection> logger)
|
||||
{
|
||||
@@ -31,6 +32,7 @@ public class LmxProxyDataConnection : IDataConnection
|
||||
}
|
||||
|
||||
public ConnectionHealth Status => _status;
|
||||
public event Action? Disconnected;
|
||||
|
||||
public async Task ConnectAsync(IDictionary<string, string> 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<IReadOnlyDictionary<string, ReadResult>> ReadBatchAsync(IEnumerable<string> 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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks the connection as disconnected and fires the Disconnected event once.
|
||||
/// Thread-safe: only the first caller triggers the event.
|
||||
/// </summary>
|
||||
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,
|
||||
|
||||
@@ -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<string, string> 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<string, string> 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<string, string> 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<IReadOnlyDictionary<string, ReadResult>> ReadBatchAsync(IEnumerable<string> 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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks the connection as disconnected and fires the Disconnected event once.
|
||||
/// Thread-safe: only the first caller triggers the event.
|
||||
/// </summary>
|
||||
private void RaiseDisconnected()
|
||||
{
|
||||
if (_disconnectFired) return;
|
||||
_disconnectFired = true;
|
||||
_status = ConnectionHealth.Disconnected;
|
||||
_logger.LogWarning("OPC UA connection to {Endpoint} lost", _endpointUrl);
|
||||
Disconnected?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps OPC UA StatusCode to QualityCode.
|
||||
/// StatusCode 0 = Good, high bit set = Bad, otherwise Uncertain.
|
||||
|
||||
@@ -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<ILmxSubscription> SubscribeAsync(IEnumerable<string> addresses, Action<string, LmxVtq> onUpdate, CancellationToken cancellationToken = default)
|
||||
public Task<ILmxSubscription> SubscribeAsync(IEnumerable<string> addresses, Action<string, LmxVtq> 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<ILmxSubscription>(new CtsSubscription(cts));
|
||||
@@ -191,6 +206,6 @@ internal class RealLmxProxyClient : ILmxProxyClient
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -14,31 +14,44 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
private Subscription? _subscription;
|
||||
private readonly Dictionary<string, MonitoredItem> _monitoredItems = new();
|
||||
private readonly Dictionary<string, Action<string, object?, DateTime, uint>> _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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called by the OPC UA SDK when a keep-alive response arrives (or fails).
|
||||
/// When CurrentState is bad, the server is unreachable.
|
||||
/// </summary>
|
||||
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();
|
||||
|
||||
@@ -114,6 +114,9 @@ public class SiteHealthCollector : ISiteHealthCollector
|
||||
// Snapshot current S&F buffer depths
|
||||
var sfBufferDepths = new Dictionary<string, int>(_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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,42 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
|
||||
namespace ScadaLink.Host.Health;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class AkkaClusterHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly ActorSystem? _system;
|
||||
|
||||
public AkkaClusterHealthCheck(ActorSystem? system = null)
|
||||
{
|
||||
_system = system;
|
||||
}
|
||||
|
||||
public Task<HealthCheckResult> 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);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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=<Folder>.<Tag>` (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=<Folder>.<Tag>` (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
|
||||
|
||||
|
||||
90
tests/ScadaLink.CLI.Tests/CliConfigTests.cs
Normal file
90
tests/ScadaLink.CLI.Tests/CliConfigTests.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
131
tests/ScadaLink.CLI.Tests/CommandHelpersTests.cs
Normal file
131
tests/ScadaLink.CLI.Tests/CommandHelpersTests.cs
Normal file
@@ -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)));
|
||||
}
|
||||
}
|
||||
102
tests/ScadaLink.CLI.Tests/OutputFormatterTests.cs
Normal file
102
tests/ScadaLink.CLI.Tests/OutputFormatterTests.cs
Normal file
@@ -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<string[]>
|
||||
{
|
||||
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<string[]>(), 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<string[]>
|
||||
{
|
||||
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 });
|
||||
}
|
||||
}
|
||||
28
tests/ScadaLink.CLI.Tests/ScadaLink.CLI.Tests.csproj
Normal file
28
tests/ScadaLink.CLI.Tests/ScadaLink.CLI.Tests.csproj
Normal file
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/ScadaLink.CLI/ScadaLink.CLI.csproj" />
|
||||
<ProjectReference Include="../../src/ScadaLink.Commons/ScadaLink.Commons.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
83
tests/ScadaLink.CentralUI.Tests/ComponentRenderingTests.cs
Normal file
83
tests/ScadaLink.CentralUI.Tests/ComponentRenderingTests.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// bUnit rendering tests for CentralUI Blazor components.
|
||||
/// Verifies that pages render their expected markup structure.
|
||||
/// </summary>
|
||||
public class ComponentRenderingTests : BunitContext
|
||||
{
|
||||
[Fact]
|
||||
public void LoginPage_RendersForm_WithUsernameAndPasswordFields()
|
||||
{
|
||||
var cut = Render<Login>();
|
||||
|
||||
// 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<Login>();
|
||||
|
||||
Assert.Throws<Bunit.ElementNotFoundException>(() => 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<Login>();
|
||||
|
||||
Assert.Contains("LDAP credentials", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoginPage_RendersScadaLinkTitle()
|
||||
{
|
||||
var cut = Render<Login>();
|
||||
|
||||
var title = cut.Find("h4.card-title");
|
||||
Assert.Equal("ScadaLink", title.TextContent);
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,10 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="bunit" Version="2.0.33-preview" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
@@ -19,6 +21,10 @@
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace ScadaLink.ClusterInfrastructure.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for ClusterOptions default values and property setters.
|
||||
/// </summary>
|
||||
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<string> { "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);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace ScadaLink.ClusterInfrastructure.Tests;
|
||||
|
||||
public class UnitTest1
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ public class LmxProxyDataConnectionTests
|
||||
{
|
||||
_mockClient = Substitute.For<ILmxProxyClient>();
|
||||
_mockFactory = Substitute.For<ILmxProxyClientFactory>();
|
||||
_mockFactory.Create(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<string?>()).Returns(_mockClient);
|
||||
_mockFactory.Create(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<string?>(), Arg.Any<int>(), Arg.Any<bool>()).Returns(_mockClient);
|
||||
_adapter = new LmxProxyDataConnection(_mockFactory, NullLogger<LmxProxyDataConnection>.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<CancellationToken>());
|
||||
}
|
||||
|
||||
@@ -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<string, string>());
|
||||
|
||||
_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<ILmxSubscription>();
|
||||
_mockClient.SubscribeAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<Action<string, LmxVtq>>(), Arg.Any<CancellationToken>())
|
||||
_mockClient.SubscribeAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<Action<string, LmxVtq>>(), Arg.Any<Action?>(), Arg.Any<CancellationToken>())
|
||||
.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<IEnumerable<string>>(), Arg.Any<Action<string, LmxVtq>>(), Arg.Any<CancellationToken>());
|
||||
Arg.Any<IEnumerable<string>>(), Arg.Any<Action<string, LmxVtq>>(), Arg.Any<Action?>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -217,7 +217,7 @@ public class LmxProxyDataConnectionTests
|
||||
{
|
||||
await ConnectAdapter();
|
||||
var mockSub = Substitute.For<ILmxSubscription>();
|
||||
_mockClient.SubscribeAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<Action<string, LmxVtq>>(), Arg.Any<CancellationToken>())
|
||||
_mockClient.SubscribeAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<Action<string, LmxVtq>>(), Arg.Any<Action?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(mockSub);
|
||||
|
||||
var subId = await _adapter.SubscribeAsync("Tag1", (_, _) => { });
|
||||
@@ -240,7 +240,7 @@ public class LmxProxyDataConnectionTests
|
||||
{
|
||||
await ConnectAdapter();
|
||||
var mockSub = Substitute.For<ILmxSubscription>();
|
||||
_mockClient.SubscribeAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<Action<string, LmxVtq>>(), Arg.Any<CancellationToken>())
|
||||
_mockClient.SubscribeAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<Action<string, LmxVtq>>(), Arg.Any<Action?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(mockSub);
|
||||
await _adapter.SubscribeAsync("Tag1", (_, _) => { });
|
||||
|
||||
@@ -277,4 +277,46 @@ public class LmxProxyDataConnectionTests
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_adapter.SubscribeAsync("tag1", (_, _) => { }));
|
||||
}
|
||||
|
||||
// --- Configuration Parsing ---
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_ParsesSamplingInterval()
|
||||
{
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>
|
||||
{
|
||||
["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<string, string>
|
||||
{
|
||||
["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<string, string>());
|
||||
|
||||
_mockFactory.Received(1).Create("localhost", 50051, null, 0, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ public class OpcUaDataConnectionTests
|
||||
});
|
||||
|
||||
Assert.Equal(ConnectionHealth.Connected, _adapter.Status);
|
||||
await _mockClient.Received(1).ConnectAsync("opc.tcp://localhost:4840", Arg.Any<CancellationToken>());
|
||||
await _mockClient.Received(1).ConnectAsync("opc.tcp://localhost:4840", Arg.Any<OpcUaConnectionOptions?>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[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<string, string>
|
||||
{
|
||||
["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<OpcUaConnectionOptions?>(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<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_UsesDefaults_WhenKeysNotProvided()
|
||||
{
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>());
|
||||
|
||||
await _mockClient.Received(1).ConnectAsync(
|
||||
"opc.tcp://localhost:4840",
|
||||
Arg.Is<OpcUaConnectionOptions?>(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<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_IgnoresInvalidNumericValues()
|
||||
{
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>
|
||||
{
|
||||
["SessionTimeoutMs"] = "notanumber",
|
||||
["OperationTimeoutMs"] = "",
|
||||
["PublishingIntervalMs"] = "abc",
|
||||
["QueueSize"] = "12.5"
|
||||
});
|
||||
|
||||
await _mockClient.Received(1).ConnectAsync(
|
||||
Arg.Any<string>(),
|
||||
Arg.Is<OpcUaConnectionOptions?>(o =>
|
||||
o != null &&
|
||||
o.SessionTimeoutMs == 60000 &&
|
||||
o.OperationTimeoutMs == 15000 &&
|
||||
o.PublishingIntervalMs == 1000 &&
|
||||
o.QueueSize == 10),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_ParsesSecurityMode()
|
||||
{
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>
|
||||
{
|
||||
["SecurityMode"] = "Sign"
|
||||
});
|
||||
|
||||
await _mockClient.Received(1).ConnectAsync(
|
||||
Arg.Any<string>(),
|
||||
Arg.Is<OpcUaConnectionOptions?>(o => o != null && o.SecurityMode == "Sign"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_ParsesAutoAcceptCerts()
|
||||
{
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>
|
||||
{
|
||||
["AutoAcceptUntrustedCerts"] = "false"
|
||||
});
|
||||
|
||||
await _mockClient.Received(1).ConnectAsync(
|
||||
Arg.Any<string>(),
|
||||
Arg.Is<OpcUaConnectionOptions?>(o => o != null && o.AutoAcceptUntrustedCerts == false),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -17,7 +17,7 @@ public class InboundScriptExecutorTests
|
||||
|
||||
public InboundScriptExecutorTests()
|
||||
{
|
||||
_executor = new InboundScriptExecutor(NullLogger<InboundScriptExecutor>.Instance);
|
||||
_executor = new InboundScriptExecutor(NullLogger<InboundScriptExecutor>.Instance, Substitute.For<IServiceProvider>());
|
||||
var locator = Substitute.For<IInstanceLocator>();
|
||||
var commService = Substitute.For<CommunicationService>(
|
||||
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<string, object?>(),
|
||||
_route,
|
||||
TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.True(result.Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -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<string>? capturedBcc = null;
|
||||
_smtpClient.SendAsync(
|
||||
Arg.Any<string>(),
|
||||
Arg.Do<IEnumerable<string>>(bcc => capturedBcc = bcc),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.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<string>(), Arg.Any<IEnumerable<string>>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.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<StoreAndForward.StoreAndForwardStorage>.Instance);
|
||||
await storage.InitializeAsync();
|
||||
|
||||
var sfOptions = new StoreAndForward.StoreAndForwardOptions();
|
||||
var sfService = new StoreAndForward.StoreAndForwardService(
|
||||
storage, sfOptions, NullLogger<StoreAndForward.StoreAndForwardService>.Instance);
|
||||
|
||||
var service = CreateService(sf: sfService);
|
||||
var result = await service.SendAsync("ops-team", "Alert", "Body");
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.True(result.WasBuffered);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
|
||||
namespace ScadaLink.NotificationService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for OAuth2 token flow — token acquisition, caching, and credential parsing.
|
||||
/// </summary>
|
||||
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<IHttpClientFactory>();
|
||||
factory.CreateClient(Arg.Any<string>()).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<OAuth2TokenService>.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<OAuth2TokenService>.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<OAuth2TokenService>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => 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<OAuth2TokenService>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(
|
||||
() => service.GetTokenAsync("tenant:client:secret"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simple mock HTTP handler that returns a fixed response.
|
||||
/// </summary>
|
||||
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<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage(_statusCode)
|
||||
{
|
||||
Content = new StringContent(_responseContent)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock HTTP handler that counts invocations.
|
||||
/// </summary>
|
||||
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<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
CallCount++;
|
||||
return Task.FromResult(new HttpResponseMessage(_statusCode)
|
||||
{
|
||||
Content = new StringContent(_responseContent)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/ScadaLink.NotificationService/ScadaLink.NotificationService.csproj" />
|
||||
<ProjectReference Include="../../src/ScadaLink.Commons/ScadaLink.Commons.csproj" />
|
||||
<ProjectReference Include="../../src/ScadaLink.StoreAndForward/ScadaLink.StoreAndForward.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user