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
|
### Security & Auth
|
||||||
- Authentication: direct LDAP bind (username/password), no Kerberos/NTLM. LDAPS/StartTLS required.
|
- 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.
|
- 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
|
### Cluster & Failover
|
||||||
- Keep-oldest split-brain resolver with `down-if-alone = on`, 15s stable-after.
|
- 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
|
### 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.
|
- 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.
|
- 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.
|
- Health reports: 30s interval, 60s offline threshold, monotonic sequence numbers, raw error counts per interval.
|
||||||
- Dead letter monitoring as a health metric.
|
- Dead letter monitoring as a health metric.
|
||||||
- Site Event Logging: 30-day retention, 1GB storage cap, daily purge, paginated queries with keyword search.
|
- 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 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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 streams and in-progress real-time subscriptions are lost on failover and must be re-opened by the user.
|
- 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.
|
- 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
|
## Real-Time Updates
|
||||||
|
|
||||||
All real-time features use **server push via SignalR** (built into Blazor Server):
|
- **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.
|
||||||
- **Debug view**: Attribute value and alarm state changes streamed live from sites.
|
- **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.
|
||||||
- **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.
|
|
||||||
|
|
||||||
## Responsibilities
|
## Responsibilities
|
||||||
|
|
||||||
@@ -104,8 +100,8 @@ No manual refresh or polling is required for any of these features.
|
|||||||
|
|
||||||
### Debug View (Deployment Role)
|
### Debug View (Deployment Role)
|
||||||
- Select a deployed instance and open a live debug view.
|
- 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.
|
- 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 streaming updates via the site-wide Akka stream.
|
- 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]`.
|
- 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.
|
- 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
|
## 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/`
|
`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.
|
- 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.
|
- Delegate to the appropriate services and repositories for each operation.
|
||||||
- Return structured response messages for all commands and queries.
|
- 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
|
## 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.
|
- **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.
|
- **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.
|
- **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.
|
- **All service components**: The ManagementActor delegates to the same services used by the Central UI — Template Engine, Deployment Manager, etc.
|
||||||
|
|
||||||
## Interactions
|
## Interactions
|
||||||
|
|||||||
@@ -24,17 +24,21 @@ Central cluster. Sites do not have user-facing interfaces and do not perform ind
|
|||||||
|
|
||||||
## Session Management
|
## 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.
|
- 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
|
### 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.
|
- **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.
|
- **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
|
### 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
|
## LDAP Connection Failure
|
||||||
|
|
||||||
|
|||||||
@@ -185,12 +185,38 @@ curl -s http://localhost:9002/health/ready | python3 -m json.tool
|
|||||||
|
|
||||||
### CLI Access
|
### 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
|
```bash
|
||||||
dotnet run --project src/ScadaLink.CLI -- \
|
dotnet run --project src/ScadaLink.CLI -- \
|
||||||
--contact-points akka.tcp://scadalink@localhost:9011 \
|
--contact-points akka.tcp://scadalink@scadalink-central-a:8081 \
|
||||||
--username admin --password password \
|
--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
|
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
|
WORKDIR /src
|
||||||
COPY LmxFakeProxy.csproj .
|
COPY LmxFakeProxy.csproj .
|
||||||
RUN dotnet restore
|
RUN dotnet restore
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Collections;
|
||||||
|
using System.Text.Json;
|
||||||
using LmxFakeProxy.Grpc;
|
using LmxFakeProxy.Grpc;
|
||||||
|
|
||||||
namespace LmxFakeProxy;
|
namespace LmxFakeProxy;
|
||||||
@@ -30,12 +32,20 @@ public class TagMapper
|
|||||||
return "Uncertain";
|
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)
|
public static VtqMessage ToVtqMessage(string tag, object? value, DateTime timestampUtc, uint statusCode)
|
||||||
{
|
{
|
||||||
return new VtqMessage
|
return new VtqMessage
|
||||||
{
|
{
|
||||||
Tag = tag,
|
Tag = tag,
|
||||||
Value = value?.ToString() ?? string.Empty,
|
Value = FormatValue(value),
|
||||||
TimestampUtcTicks = timestampUtc.Ticks,
|
TimestampUtcTicks = timestampUtc.Ticks,
|
||||||
Quality = MapQuality(statusCode)
|
Quality = MapQuality(statusCode)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -133,6 +133,43 @@
|
|||||||
"Description": "Valve command (0=Close, 1=Open, 2=Stop)"
|
"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 string? LdapServer { get; set; }
|
||||||
public int LdapPort { get; set; } = 636;
|
public int LdapPort { get; set; } = 636;
|
||||||
public bool LdapUseTls { get; set; } = true;
|
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 string DefaultFormat { get; set; } = "json";
|
||||||
|
|
||||||
public static CliConfig Load()
|
public static CliConfig Load()
|
||||||
@@ -31,6 +34,12 @@ public class CliConfig
|
|||||||
config.LdapServer = fileConfig.Ldap.Server;
|
config.LdapServer = fileConfig.Ldap.Server;
|
||||||
config.LdapPort = fileConfig.Ldap.Port;
|
config.LdapPort = fileConfig.Ldap.Port;
|
||||||
config.LdapUseTls = fileConfig.Ldap.UseTls;
|
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;
|
if (!string.IsNullOrEmpty(fileConfig.DefaultFormat)) config.DefaultFormat = fileConfig.DefaultFormat;
|
||||||
}
|
}
|
||||||
@@ -62,5 +71,8 @@ public class CliConfig
|
|||||||
public string? Server { get; set; }
|
public string? Server { get; set; }
|
||||||
public int Port { get; set; } = 636;
|
public int Port { get; set; } = 636;
|
||||||
public bool UseTls { get; set; } = true;
|
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 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" };
|
var command = new Command("api-method") { Description = "Manage inbound API methods" };
|
||||||
|
|
||||||
command.Add(BuildList(contactPointsOption, formatOption));
|
command.Add(BuildList(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildGet(contactPointsOption, formatOption));
|
command.Add(BuildGet(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildCreate(contactPointsOption, formatOption));
|
command.Add(BuildCreate(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildUpdate(contactPointsOption, formatOption));
|
command.Add(BuildUpdate(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildDelete(contactPointsOption, formatOption));
|
command.Add(BuildDelete(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
|
|
||||||
return command;
|
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" };
|
var cmd = new Command("list") { Description = "List all API methods" };
|
||||||
cmd.SetAction(async (ParseResult result) =>
|
cmd.SetAction(async (ParseResult result) =>
|
||||||
{
|
{
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new ListApiMethodsCommand());
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new ListApiMethodsCommand());
|
||||||
});
|
});
|
||||||
return cmd;
|
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 idOption = new Option<int>("--id") { Description = "API method ID", Required = true };
|
||||||
var cmd = new Command("get") { Description = "Get an API method by ID" };
|
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);
|
var id = result.GetValue(idOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new GetApiMethodCommand(id));
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new GetApiMethodCommand(id));
|
||||||
});
|
});
|
||||||
return cmd;
|
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 nameOption = new Option<string>("--name") { Description = "Method name", Required = true };
|
||||||
var scriptOption = new Option<string>("--script") { Description = "Script code", 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 parameters = result.GetValue(parametersOption);
|
||||||
var returnDef = result.GetValue(returnDefOption);
|
var returnDef = result.GetValue(returnDefOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new CreateApiMethodCommand(name, script, timeout, parameters, returnDef));
|
new CreateApiMethodCommand(name, script, timeout, parameters, returnDef));
|
||||||
});
|
});
|
||||||
return cmd;
|
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 idOption = new Option<int>("--id") { Description = "API method ID", Required = true };
|
||||||
var scriptOption = new Option<string>("--script") { Description = "Script code", 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 parameters = result.GetValue(parametersOption);
|
||||||
var returnDef = result.GetValue(returnDefOption);
|
var returnDef = result.GetValue(returnDefOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new UpdateApiMethodCommand(id, script, timeout, parameters, returnDef));
|
new UpdateApiMethodCommand(id, script, timeout, parameters, returnDef));
|
||||||
});
|
});
|
||||||
return cmd;
|
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 idOption = new Option<int>("--id") { Description = "API method ID", Required = true };
|
||||||
var cmd = new Command("delete") { Description = "Delete an API method" };
|
var cmd = new Command("delete") { Description = "Delete an API method" };
|
||||||
@@ -111,7 +111,7 @@ public static class ApiMethodCommands
|
|||||||
{
|
{
|
||||||
var id = result.GetValue(idOption);
|
var id = result.GetValue(idOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new DeleteApiMethodCommand(id));
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new DeleteApiMethodCommand(id));
|
||||||
});
|
});
|
||||||
return cmd;
|
return cmd;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,16 +6,16 @@ namespace ScadaLink.CLI.Commands;
|
|||||||
|
|
||||||
public static class AuditLogCommands
|
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" };
|
var command = new Command("audit-log") { Description = "Query audit logs" };
|
||||||
|
|
||||||
command.Add(BuildQuery(contactPointsOption, formatOption));
|
command.Add(BuildQuery(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
|
|
||||||
return command;
|
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 userOption = new Option<string?>("--user") { Description = "Filter by username" };
|
||||||
var entityTypeOption = new Option<string?>("--entity-type") { Description = "Filter by entity type" };
|
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 page = result.GetValue(pageOption);
|
||||||
var pageSize = result.GetValue(pageSizeOption);
|
var pageSize = result.GetValue(pageSizeOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new QueryAuditLogCommand(user, entityType, action, from, to, page, pageSize));
|
new QueryAuditLogCommand(user, entityType, action, from, to, page, pageSize));
|
||||||
});
|
});
|
||||||
return cmd;
|
return cmd;
|
||||||
|
|||||||
@@ -1,27 +1,33 @@
|
|||||||
using System.CommandLine;
|
using System.CommandLine;
|
||||||
using System.CommandLine.Parsing;
|
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.Commons.Messages.Management;
|
||||||
|
using ScadaLink.Security;
|
||||||
|
|
||||||
namespace ScadaLink.CLI.Commands;
|
namespace ScadaLink.CLI.Commands;
|
||||||
|
|
||||||
internal static class CommandHelpers
|
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 string NewCorrelationId() => Guid.NewGuid().ToString("N");
|
||||||
|
|
||||||
internal static async Task<int> ExecuteCommandAsync(
|
internal static async Task<int> ExecuteCommandAsync(
|
||||||
ParseResult result,
|
ParseResult result,
|
||||||
Option<string> contactPointsOption,
|
Option<string> contactPointsOption,
|
||||||
Option<string> formatOption,
|
Option<string> formatOption,
|
||||||
|
Option<string> usernameOption,
|
||||||
|
Option<string> passwordOption,
|
||||||
object command)
|
object command)
|
||||||
{
|
{
|
||||||
var contactPointsRaw = result.GetValue(contactPointsOption);
|
var contactPointsRaw = result.GetValue(contactPointsOption);
|
||||||
|
var format = result.GetValue(formatOption) ?? "json";
|
||||||
|
|
||||||
|
var config = CliConfig.Load();
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(contactPointsRaw))
|
if (string.IsNullOrWhiteSpace(contactPointsRaw))
|
||||||
{
|
{
|
||||||
var config = CliConfig.Load();
|
|
||||||
if (config.ContactPoints.Count > 0)
|
if (config.ContactPoints.Count > 0)
|
||||||
contactPointsRaw = string.Join(",", config.ContactPoints);
|
contactPointsRaw = string.Join(",", config.ContactPoints);
|
||||||
}
|
}
|
||||||
@@ -34,21 +40,97 @@ internal static class CommandHelpers
|
|||||||
|
|
||||||
var contactPoints = contactPointsRaw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
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 using var connection = new ClusterConnection();
|
||||||
await connection.ConnectAsync(contactPoints, TimeSpan.FromSeconds(10));
|
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));
|
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)
|
switch (response)
|
||||||
{
|
{
|
||||||
case ManagementSuccess success:
|
case ManagementSuccess success:
|
||||||
Console.WriteLine(success.JsonData);
|
if (string.Equals(format, "table", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
WriteAsTable(success.JsonData);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine(success.JsonData);
|
||||||
|
}
|
||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
case ManagementError error:
|
case ManagementError error:
|
||||||
@@ -64,4 +146,51 @@ internal static class CommandHelpers
|
|||||||
return 1;
|
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 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" };
|
var command = new Command("data-connection") { Description = "Manage data connections" };
|
||||||
|
|
||||||
command.Add(BuildList(contactPointsOption, formatOption));
|
command.Add(BuildList(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildGet(contactPointsOption, formatOption));
|
command.Add(BuildGet(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildCreate(contactPointsOption, formatOption));
|
command.Add(BuildCreate(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildUpdate(contactPointsOption, formatOption));
|
command.Add(BuildUpdate(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildDelete(contactPointsOption, formatOption));
|
command.Add(BuildDelete(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildAssign(contactPointsOption, formatOption));
|
command.Add(BuildAssign(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildUnassign(contactPointsOption, formatOption));
|
command.Add(BuildUnassign(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
|
|
||||||
return command;
|
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 idOption = new Option<int>("--id") { Description = "Data connection ID", Required = true };
|
||||||
var cmd = new Command("get") { Description = "Get a data connection by ID" };
|
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);
|
var id = result.GetValue(idOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new GetDataConnectionCommand(id));
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new GetDataConnectionCommand(id));
|
||||||
});
|
});
|
||||||
return cmd;
|
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 idOption = new Option<int>("--id") { Description = "Data connection ID", Required = true };
|
||||||
var nameOption = new Option<string>("--name") { Description = "Connection name", 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 protocol = result.GetValue(protocolOption)!;
|
||||||
var config = result.GetValue(configOption);
|
var config = result.GetValue(configOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new UpdateDataConnectionCommand(id, name, protocol, config));
|
new UpdateDataConnectionCommand(id, name, protocol, config));
|
||||||
});
|
});
|
||||||
return cmd;
|
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 idOption = new Option<int>("--assignment-id") { Description = "Assignment ID", Required = true };
|
||||||
var cmd = new Command("unassign") { Description = "Unassign a data connection from a site" };
|
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);
|
var id = result.GetValue(idOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new UnassignDataConnectionFromSiteCommand(id));
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new UnassignDataConnectionFromSiteCommand(id));
|
||||||
});
|
});
|
||||||
return cmd;
|
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" };
|
var cmd = new Command("list") { Description = "List all data connections" };
|
||||||
cmd.SetAction(async (ParseResult result) =>
|
cmd.SetAction(async (ParseResult result) =>
|
||||||
{
|
{
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new ListDataConnectionsCommand());
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new ListDataConnectionsCommand());
|
||||||
});
|
});
|
||||||
return cmd;
|
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 nameOption = new Option<string>("--name") { Description = "Connection name", Required = true };
|
||||||
var protocolOption = new Option<string>("--protocol") { Description = "Protocol (e.g. OpcUa)", 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 protocol = result.GetValue(protocolOption)!;
|
||||||
var config = result.GetValue(configOption);
|
var config = result.GetValue(configOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new CreateDataConnectionCommand(name, protocol, config));
|
new CreateDataConnectionCommand(name, protocol, config));
|
||||||
});
|
});
|
||||||
return cmd;
|
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 idOption = new Option<int>("--id") { Description = "Data connection ID", Required = true };
|
||||||
var cmd = new Command("delete") { Description = "Delete a data connection" };
|
var cmd = new Command("delete") { Description = "Delete a data connection" };
|
||||||
@@ -116,12 +116,12 @@ public static class DataConnectionCommands
|
|||||||
{
|
{
|
||||||
var id = result.GetValue(idOption);
|
var id = result.GetValue(idOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new DeleteDataConnectionCommand(id));
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new DeleteDataConnectionCommand(id));
|
||||||
});
|
});
|
||||||
return cmd;
|
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 connectionIdOption = new Option<int>("--connection-id") { Description = "Data connection ID", Required = true };
|
||||||
var siteIdOption = new Option<int>("--site-id") { Description = "Site 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 connectionId = result.GetValue(connectionIdOption);
|
||||||
var siteId = result.GetValue(siteIdOption);
|
var siteId = result.GetValue(siteIdOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new AssignDataConnectionToSiteCommand(connectionId, siteId));
|
new AssignDataConnectionToSiteCommand(connectionId, siteId));
|
||||||
});
|
});
|
||||||
return cmd;
|
return cmd;
|
||||||
|
|||||||
@@ -6,31 +6,31 @@ namespace ScadaLink.CLI.Commands;
|
|||||||
|
|
||||||
public static class DbConnectionCommands
|
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" };
|
var command = new Command("db-connection") { Description = "Manage database connections" };
|
||||||
|
|
||||||
command.Add(BuildList(contactPointsOption, formatOption));
|
command.Add(BuildList(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildGet(contactPointsOption, formatOption));
|
command.Add(BuildGet(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildCreate(contactPointsOption, formatOption));
|
command.Add(BuildCreate(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildUpdate(contactPointsOption, formatOption));
|
command.Add(BuildUpdate(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildDelete(contactPointsOption, formatOption));
|
command.Add(BuildDelete(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
|
|
||||||
return command;
|
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" };
|
var cmd = new Command("list") { Description = "List all database connections" };
|
||||||
cmd.SetAction(async (ParseResult result) =>
|
cmd.SetAction(async (ParseResult result) =>
|
||||||
{
|
{
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new ListDatabaseConnectionsCommand());
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new ListDatabaseConnectionsCommand());
|
||||||
});
|
});
|
||||||
return cmd;
|
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 idOption = new Option<int>("--id") { Description = "Database connection ID", Required = true };
|
||||||
var cmd = new Command("get") { Description = "Get a database connection by ID" };
|
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);
|
var id = result.GetValue(idOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new GetDatabaseConnectionCommand(id));
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new GetDatabaseConnectionCommand(id));
|
||||||
});
|
});
|
||||||
return cmd;
|
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 nameOption = new Option<string>("--name") { Description = "Connection name", Required = true };
|
||||||
var connStrOption = new Option<string>("--connection-string") { Description = "Connection string", 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 name = result.GetValue(nameOption)!;
|
||||||
var connStr = result.GetValue(connStrOption)!;
|
var connStr = result.GetValue(connStrOption)!;
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new CreateDatabaseConnectionDefCommand(name, connStr));
|
new CreateDatabaseConnectionDefCommand(name, connStr));
|
||||||
});
|
});
|
||||||
return cmd;
|
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 idOption = new Option<int>("--id") { Description = "Database connection ID", Required = true };
|
||||||
var nameOption = new Option<string>("--name") { Description = "Connection name", 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 name = result.GetValue(nameOption)!;
|
||||||
var connStr = result.GetValue(connStrOption)!;
|
var connStr = result.GetValue(connStrOption)!;
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new UpdateDatabaseConnectionDefCommand(id, name, connStr));
|
new UpdateDatabaseConnectionDefCommand(id, name, connStr));
|
||||||
});
|
});
|
||||||
return cmd;
|
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 idOption = new Option<int>("--id") { Description = "Database connection ID", Required = true };
|
||||||
var cmd = new Command("delete") { Description = "Delete a database connection" };
|
var cmd = new Command("delete") { Description = "Delete a database connection" };
|
||||||
@@ -94,7 +94,7 @@ public static class DbConnectionCommands
|
|||||||
{
|
{
|
||||||
var id = result.GetValue(idOption);
|
var id = result.GetValue(idOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new DeleteDatabaseConnectionDefCommand(id));
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new DeleteDatabaseConnectionDefCommand(id));
|
||||||
});
|
});
|
||||||
return cmd;
|
return cmd;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,16 +6,16 @@ namespace ScadaLink.CLI.Commands;
|
|||||||
|
|
||||||
public static class DebugCommands
|
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" };
|
var command = new Command("debug") { Description = "Runtime debugging" };
|
||||||
|
|
||||||
command.Add(BuildSnapshot(contactPointsOption, formatOption));
|
command.Add(BuildSnapshot(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
|
|
||||||
return command;
|
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 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" };
|
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) =>
|
cmd.SetAction(async (ParseResult result) =>
|
||||||
{
|
{
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new DebugSnapshotCommand(result.GetValue(idOption)));
|
new DebugSnapshotCommand(result.GetValue(idOption)));
|
||||||
});
|
});
|
||||||
return cmd;
|
return cmd;
|
||||||
|
|||||||
@@ -6,18 +6,18 @@ namespace ScadaLink.CLI.Commands;
|
|||||||
|
|
||||||
public static class DeployCommands
|
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" };
|
var command = new Command("deploy") { Description = "Deployment operations" };
|
||||||
|
|
||||||
command.Add(BuildInstance(contactPointsOption, formatOption));
|
command.Add(BuildInstance(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildArtifacts(contactPointsOption, formatOption));
|
command.Add(BuildArtifacts(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildStatus(contactPointsOption, formatOption));
|
command.Add(BuildStatus(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
|
|
||||||
return command;
|
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 idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
|
||||||
var cmd = new Command("instance") { Description = "Deploy a single instance" };
|
var cmd = new Command("instance") { Description = "Deploy a single instance" };
|
||||||
@@ -26,12 +26,12 @@ public static class DeployCommands
|
|||||||
{
|
{
|
||||||
var id = result.GetValue(idOption);
|
var id = result.GetValue(idOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new MgmtDeployInstanceCommand(id));
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new MgmtDeployInstanceCommand(id));
|
||||||
});
|
});
|
||||||
return cmd;
|
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 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)" };
|
var cmd = new Command("artifacts") { Description = "Deploy artifacts to site(s)" };
|
||||||
@@ -40,12 +40,12 @@ public static class DeployCommands
|
|||||||
{
|
{
|
||||||
var siteId = result.GetValue(siteIdOption);
|
var siteId = result.GetValue(siteIdOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new MgmtDeployArtifactsCommand(siteId));
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new MgmtDeployArtifactsCommand(siteId));
|
||||||
});
|
});
|
||||||
return cmd;
|
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 instanceIdOption = new Option<int?>("--instance-id") { Description = "Filter by instance ID" };
|
||||||
var statusOption = new Option<string?>("--status") { Description = "Filter by status" };
|
var statusOption = new Option<string?>("--status") { Description = "Filter by status" };
|
||||||
@@ -66,7 +66,7 @@ public static class DeployCommands
|
|||||||
var page = result.GetValue(pageOption);
|
var page = result.GetValue(pageOption);
|
||||||
var pageSize = result.GetValue(pageSizeOption);
|
var pageSize = result.GetValue(pageSizeOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new QueryDeploymentsCommand(instanceId, status, page, pageSize));
|
new QueryDeploymentsCommand(instanceId, status, page, pageSize));
|
||||||
});
|
});
|
||||||
return cmd;
|
return cmd;
|
||||||
|
|||||||
@@ -6,21 +6,21 @@ namespace ScadaLink.CLI.Commands;
|
|||||||
|
|
||||||
public static class ExternalSystemCommands
|
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" };
|
var command = new Command("external-system") { Description = "Manage external systems" };
|
||||||
|
|
||||||
command.Add(BuildList(contactPointsOption, formatOption));
|
command.Add(BuildList(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildGet(contactPointsOption, formatOption));
|
command.Add(BuildGet(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildCreate(contactPointsOption, formatOption));
|
command.Add(BuildCreate(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildUpdate(contactPointsOption, formatOption));
|
command.Add(BuildUpdate(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildDelete(contactPointsOption, formatOption));
|
command.Add(BuildDelete(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildMethodGroup(contactPointsOption, formatOption));
|
command.Add(BuildMethodGroup(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
|
|
||||||
return command;
|
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 idOption = new Option<int>("--id") { Description = "External system ID", Required = true };
|
||||||
var cmd = new Command("get") { Description = "Get an external system by ID" };
|
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);
|
var id = result.GetValue(idOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new GetExternalSystemCommand(id));
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new GetExternalSystemCommand(id));
|
||||||
});
|
});
|
||||||
return cmd;
|
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 idOption = new Option<int>("--id") { Description = "External system ID", Required = true };
|
||||||
var nameOption = new Option<string>("--name") { Description = "System name", 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 authType = result.GetValue(authTypeOption)!;
|
||||||
var authConfig = result.GetValue(authConfigOption);
|
var authConfig = result.GetValue(authConfigOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new UpdateExternalSystemCommand(id, name, url, authType, authConfig));
|
new UpdateExternalSystemCommand(id, name, url, authType, authConfig));
|
||||||
});
|
});
|
||||||
return cmd;
|
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" };
|
var cmd = new Command("list") { Description = "List all external systems" };
|
||||||
cmd.SetAction(async (ParseResult result) =>
|
cmd.SetAction(async (ParseResult result) =>
|
||||||
{
|
{
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new ListExternalSystemsCommand());
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new ListExternalSystemsCommand());
|
||||||
});
|
});
|
||||||
return cmd;
|
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 nameOption = new Option<string>("--name") { Description = "System name", Required = true };
|
||||||
var urlOption = new Option<string>("--endpoint-url") { Description = "Endpoint URL", 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 authType = result.GetValue(authTypeOption)!;
|
||||||
var authConfig = result.GetValue(authConfigOption);
|
var authConfig = result.GetValue(authConfigOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new CreateExternalSystemCommand(name, url, authType, authConfig));
|
new CreateExternalSystemCommand(name, url, authType, authConfig));
|
||||||
});
|
});
|
||||||
return cmd;
|
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 idOption = new Option<int>("--id") { Description = "External system ID", Required = true };
|
||||||
var cmd = new Command("delete") { Description = "Delete an external system" };
|
var cmd = new Command("delete") { Description = "Delete an external system" };
|
||||||
@@ -107,25 +107,25 @@ public static class ExternalSystemCommands
|
|||||||
{
|
{
|
||||||
var id = result.GetValue(idOption);
|
var id = result.GetValue(idOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new DeleteExternalSystemCommand(id));
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new DeleteExternalSystemCommand(id));
|
||||||
});
|
});
|
||||||
return cmd;
|
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" };
|
var group = new Command("method") { Description = "Manage external system methods" };
|
||||||
group.Add(BuildMethodList(contactPointsOption, formatOption));
|
group.Add(BuildMethodList(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
group.Add(BuildMethodGet(contactPointsOption, formatOption));
|
group.Add(BuildMethodGet(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
group.Add(BuildMethodCreate(contactPointsOption, formatOption));
|
group.Add(BuildMethodCreate(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
group.Add(BuildMethodUpdate(contactPointsOption, formatOption));
|
group.Add(BuildMethodUpdate(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
group.Add(BuildMethodDelete(contactPointsOption, formatOption));
|
group.Add(BuildMethodDelete(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
return group;
|
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 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" };
|
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) =>
|
cmd.SetAction(async (ParseResult result) =>
|
||||||
{
|
{
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new ListExternalSystemMethodsCommand(result.GetValue(sysIdOption)));
|
new ListExternalSystemMethodsCommand(result.GetValue(sysIdOption)));
|
||||||
});
|
});
|
||||||
return cmd;
|
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 idOption = new Option<int>("--id") { Description = "Method ID", Required = true };
|
||||||
var cmd = new Command("get") { Description = "Get an external system method by ID" };
|
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) =>
|
cmd.SetAction(async (ParseResult result) =>
|
||||||
{
|
{
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new GetExternalSystemMethodCommand(result.GetValue(idOption)));
|
new GetExternalSystemMethodCommand(result.GetValue(idOption)));
|
||||||
});
|
});
|
||||||
return cmd;
|
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 sysIdOption = new Option<int>("--external-system-id") { Description = "External system ID", Required = true };
|
||||||
var nameOption = new Option<string>("--name") { Description = "Method name", 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) =>
|
cmd.SetAction(async (ParseResult result) =>
|
||||||
{
|
{
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new CreateExternalSystemMethodCommand(
|
new CreateExternalSystemMethodCommand(
|
||||||
result.GetValue(sysIdOption),
|
result.GetValue(sysIdOption),
|
||||||
result.GetValue(nameOption)!,
|
result.GetValue(nameOption)!,
|
||||||
@@ -184,7 +184,7 @@ public static class ExternalSystemCommands
|
|||||||
return cmd;
|
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 idOption = new Option<int>("--id") { Description = "Method ID", Required = true };
|
||||||
var nameOption = new Option<string?>("--name") { Description = "Method name" };
|
var nameOption = new Option<string?>("--name") { Description = "Method name" };
|
||||||
@@ -203,7 +203,7 @@ public static class ExternalSystemCommands
|
|||||||
cmd.SetAction(async (ParseResult result) =>
|
cmd.SetAction(async (ParseResult result) =>
|
||||||
{
|
{
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new UpdateExternalSystemMethodCommand(
|
new UpdateExternalSystemMethodCommand(
|
||||||
result.GetValue(idOption),
|
result.GetValue(idOption),
|
||||||
result.GetValue(nameOption),
|
result.GetValue(nameOption),
|
||||||
@@ -215,7 +215,7 @@ public static class ExternalSystemCommands
|
|||||||
return cmd;
|
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 idOption = new Option<int>("--id") { Description = "Method ID", Required = true };
|
||||||
var cmd = new Command("delete") { Description = "Delete an external system method" };
|
var cmd = new Command("delete") { Description = "Delete an external system method" };
|
||||||
@@ -223,7 +223,7 @@ public static class ExternalSystemCommands
|
|||||||
cmd.SetAction(async (ParseResult result) =>
|
cmd.SetAction(async (ParseResult result) =>
|
||||||
{
|
{
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new DeleteExternalSystemMethodCommand(result.GetValue(idOption)));
|
new DeleteExternalSystemMethodCommand(result.GetValue(idOption)));
|
||||||
});
|
});
|
||||||
return cmd;
|
return cmd;
|
||||||
|
|||||||
@@ -6,30 +6,30 @@ namespace ScadaLink.CLI.Commands;
|
|||||||
|
|
||||||
public static class HealthCommands
|
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" };
|
var command = new Command("health") { Description = "Health monitoring" };
|
||||||
|
|
||||||
command.Add(BuildSummary(contactPointsOption, formatOption));
|
command.Add(BuildSummary(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildSite(contactPointsOption, formatOption));
|
command.Add(BuildSite(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildEventLog(contactPointsOption, formatOption));
|
command.Add(BuildEventLog(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildParkedMessages(contactPointsOption, formatOption));
|
command.Add(BuildParkedMessages(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
|
|
||||||
return command;
|
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" };
|
var cmd = new Command("summary") { Description = "Get system health summary" };
|
||||||
cmd.SetAction(async (ParseResult result) =>
|
cmd.SetAction(async (ParseResult result) =>
|
||||||
{
|
{
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new GetHealthSummaryCommand());
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new GetHealthSummaryCommand());
|
||||||
});
|
});
|
||||||
return cmd;
|
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 identifierOption = new Option<string>("--identifier") { Description = "Site identifier", Required = true };
|
||||||
var cmd = new Command("site") { Description = "Get health for a specific site" };
|
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)!;
|
var identifier = result.GetValue(identifierOption)!;
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new GetSiteHealthCommand(identifier));
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new GetSiteHealthCommand(identifier));
|
||||||
});
|
});
|
||||||
return cmd;
|
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 siteOption = new Option<string>("--site") { Description = "Site identifier", Required = true };
|
||||||
var eventTypeOption = new Option<string?>("--event-type") { Description = "Filter by event type" };
|
var eventTypeOption = new Option<string?>("--event-type") { Description = "Filter by event type" };
|
||||||
@@ -55,6 +55,7 @@ public static class HealthCommands
|
|||||||
pageOption.DefaultValueFactory = _ => 1;
|
pageOption.DefaultValueFactory = _ => 1;
|
||||||
var pageSizeOption = new Option<int>("--page-size") { Description = "Page size" };
|
var pageSizeOption = new Option<int>("--page-size") { Description = "Page size" };
|
||||||
pageSizeOption.DefaultValueFactory = _ => 50;
|
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" };
|
var cmd = new Command("event-log") { Description = "Query site event logs" };
|
||||||
cmd.Add(siteOption);
|
cmd.Add(siteOption);
|
||||||
@@ -65,10 +66,11 @@ public static class HealthCommands
|
|||||||
cmd.Add(toOption);
|
cmd.Add(toOption);
|
||||||
cmd.Add(pageOption);
|
cmd.Add(pageOption);
|
||||||
cmd.Add(pageSizeOption);
|
cmd.Add(pageSizeOption);
|
||||||
|
cmd.Add(instanceNameOption);
|
||||||
cmd.SetAction(async (ParseResult result) =>
|
cmd.SetAction(async (ParseResult result) =>
|
||||||
{
|
{
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new QueryEventLogsCommand(
|
new QueryEventLogsCommand(
|
||||||
result.GetValue(siteOption)!,
|
result.GetValue(siteOption)!,
|
||||||
result.GetValue(eventTypeOption),
|
result.GetValue(eventTypeOption),
|
||||||
@@ -77,12 +79,13 @@ public static class HealthCommands
|
|||||||
result.GetValue(fromOption),
|
result.GetValue(fromOption),
|
||||||
result.GetValue(toOption),
|
result.GetValue(toOption),
|
||||||
result.GetValue(pageOption),
|
result.GetValue(pageOption),
|
||||||
result.GetValue(pageSizeOption)));
|
result.GetValue(pageSizeOption),
|
||||||
|
result.GetValue(instanceNameOption)));
|
||||||
});
|
});
|
||||||
return cmd;
|
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 siteOption = new Option<string>("--site") { Description = "Site identifier", Required = true };
|
||||||
var pageOption = new Option<int>("--page") { Description = "Page number" };
|
var pageOption = new Option<int>("--page") { Description = "Page number" };
|
||||||
@@ -97,7 +100,7 @@ public static class HealthCommands
|
|||||||
cmd.SetAction(async (ParseResult result) =>
|
cmd.SetAction(async (ParseResult result) =>
|
||||||
{
|
{
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new QueryParkedMessagesCommand(
|
new QueryParkedMessagesCommand(
|
||||||
result.GetValue(siteOption)!,
|
result.GetValue(siteOption)!,
|
||||||
result.GetValue(pageOption),
|
result.GetValue(pageOption),
|
||||||
|
|||||||
@@ -6,23 +6,26 @@ namespace ScadaLink.CLI.Commands;
|
|||||||
|
|
||||||
public static class InstanceCommands
|
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" };
|
var command = new Command("instance") { Description = "Manage instances" };
|
||||||
|
|
||||||
command.Add(BuildList(contactPointsOption, formatOption));
|
command.Add(BuildList(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildGet(contactPointsOption, formatOption));
|
command.Add(BuildGet(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildCreate(contactPointsOption, formatOption));
|
command.Add(BuildCreate(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildSetBindings(contactPointsOption, formatOption));
|
command.Add(BuildSetBindings(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildDeploy(contactPointsOption, formatOption));
|
command.Add(BuildSetOverrides(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildEnable(contactPointsOption, formatOption));
|
command.Add(BuildSetArea(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildDisable(contactPointsOption, formatOption));
|
command.Add(BuildDiff(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildDelete(contactPointsOption, formatOption));
|
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;
|
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 idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
|
||||||
var cmd = new Command("get") { Description = "Get an instance by ID" };
|
var cmd = new Command("get") { Description = "Get an instance by ID" };
|
||||||
@@ -31,12 +34,12 @@ public static class InstanceCommands
|
|||||||
{
|
{
|
||||||
var id = result.GetValue(idOption);
|
var id = result.GetValue(idOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new GetInstanceCommand(id));
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new GetInstanceCommand(id));
|
||||||
});
|
});
|
||||||
return cmd;
|
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 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 };
|
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 =>
|
var bindings = pairs.Select(p =>
|
||||||
(p[0].ToString()!, int.Parse(p[1].ToString()!))).ToList();
|
(p[0].ToString()!, int.Parse(p[1].ToString()!))).ToList();
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new SetConnectionBindingsCommand(id, bindings));
|
new SetConnectionBindingsCommand(id, bindings));
|
||||||
});
|
});
|
||||||
return cmd;
|
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 siteIdOption = new Option<int?>("--site-id") { Description = "Filter by site ID" };
|
||||||
var templateIdOption = new Option<int?>("--template-id") { Description = "Filter by template 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 templateId = result.GetValue(templateIdOption);
|
||||||
var search = result.GetValue(searchOption);
|
var search = result.GetValue(searchOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new ListInstancesCommand(siteId, templateId, search));
|
new ListInstancesCommand(siteId, templateId, search));
|
||||||
});
|
});
|
||||||
return cmd;
|
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 nameOption = new Option<string>("--name") { Description = "Unique instance name", Required = true };
|
||||||
var templateIdOption = new Option<int>("--template-id") { Description = "Template ID", 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 siteId = result.GetValue(siteIdOption);
|
||||||
var areaId = result.GetValue(areaIdOption);
|
var areaId = result.GetValue(areaIdOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new CreateInstanceCommand(name, templateId, siteId, areaId));
|
new CreateInstanceCommand(name, templateId, siteId, areaId));
|
||||||
});
|
});
|
||||||
return cmd;
|
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 idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
|
||||||
var cmd = new Command("deploy") { Description = "Deploy an instance" };
|
var cmd = new Command("deploy") { Description = "Deploy an instance" };
|
||||||
@@ -115,12 +118,12 @@ public static class InstanceCommands
|
|||||||
{
|
{
|
||||||
var id = result.GetValue(idOption);
|
var id = result.GetValue(idOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new MgmtDeployInstanceCommand(id));
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new MgmtDeployInstanceCommand(id));
|
||||||
});
|
});
|
||||||
return cmd;
|
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 idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
|
||||||
var cmd = new Command("enable") { Description = "Enable an instance" };
|
var cmd = new Command("enable") { Description = "Enable an instance" };
|
||||||
@@ -129,12 +132,12 @@ public static class InstanceCommands
|
|||||||
{
|
{
|
||||||
var id = result.GetValue(idOption);
|
var id = result.GetValue(idOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new MgmtEnableInstanceCommand(id));
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new MgmtEnableInstanceCommand(id));
|
||||||
});
|
});
|
||||||
return cmd;
|
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 idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
|
||||||
var cmd = new Command("disable") { Description = "Disable an instance" };
|
var cmd = new Command("disable") { Description = "Disable an instance" };
|
||||||
@@ -143,12 +146,12 @@ public static class InstanceCommands
|
|||||||
{
|
{
|
||||||
var id = result.GetValue(idOption);
|
var id = result.GetValue(idOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new MgmtDisableInstanceCommand(id));
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new MgmtDisableInstanceCommand(id));
|
||||||
});
|
});
|
||||||
return cmd;
|
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 idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
|
||||||
var cmd = new Command("delete") { Description = "Delete an instance" };
|
var cmd = new Command("delete") { Description = "Delete an instance" };
|
||||||
@@ -157,7 +160,63 @@ public static class InstanceCommands
|
|||||||
{
|
{
|
||||||
var id = result.GetValue(idOption);
|
var id = result.GetValue(idOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
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;
|
return cmd;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,21 +6,21 @@ namespace ScadaLink.CLI.Commands;
|
|||||||
|
|
||||||
public static class NotificationCommands
|
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" };
|
var command = new Command("notification") { Description = "Manage notification lists" };
|
||||||
|
|
||||||
command.Add(BuildList(contactPointsOption, formatOption));
|
command.Add(BuildList(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildGet(contactPointsOption, formatOption));
|
command.Add(BuildGet(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildCreate(contactPointsOption, formatOption));
|
command.Add(BuildCreate(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildUpdate(contactPointsOption, formatOption));
|
command.Add(BuildUpdate(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildDelete(contactPointsOption, formatOption));
|
command.Add(BuildDelete(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildSmtp(contactPointsOption, formatOption));
|
command.Add(BuildSmtp(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
|
|
||||||
return command;
|
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 idOption = new Option<int>("--id") { Description = "Notification list ID", Required = true };
|
||||||
var cmd = new Command("get") { Description = "Get a notification list by ID" };
|
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);
|
var id = result.GetValue(idOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new GetNotificationListCommand(id));
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new GetNotificationListCommand(id));
|
||||||
});
|
});
|
||||||
return cmd;
|
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 idOption = new Option<int>("--id") { Description = "Notification list ID", Required = true };
|
||||||
var nameOption = new Option<string>("--name") { Description = "List name", 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 emailsRaw = result.GetValue(emailsOption)!;
|
||||||
var emails = emailsRaw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
var emails = emailsRaw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new UpdateNotificationListCommand(id, name, emails));
|
new UpdateNotificationListCommand(id, name, emails));
|
||||||
});
|
});
|
||||||
return cmd;
|
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" };
|
var group = new Command("smtp") { Description = "Manage SMTP configuration" };
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ public static class NotificationCommands
|
|||||||
listCmd.SetAction(async (ParseResult result) =>
|
listCmd.SetAction(async (ParseResult result) =>
|
||||||
{
|
{
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new ListSmtpConfigsCommand());
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new ListSmtpConfigsCommand());
|
||||||
});
|
});
|
||||||
group.Add(listCmd);
|
group.Add(listCmd);
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ public static class NotificationCommands
|
|||||||
var authMode = result.GetValue(authModeOption)!;
|
var authMode = result.GetValue(authModeOption)!;
|
||||||
var from = result.GetValue(fromOption)!;
|
var from = result.GetValue(fromOption)!;
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new UpdateSmtpConfigCommand(id, server, port, authMode, from));
|
new UpdateSmtpConfigCommand(id, server, port, authMode, from));
|
||||||
});
|
});
|
||||||
group.Add(updateCmd);
|
group.Add(updateCmd);
|
||||||
@@ -96,18 +96,18 @@ public static class NotificationCommands
|
|||||||
return group;
|
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" };
|
var cmd = new Command("list") { Description = "List all notification lists" };
|
||||||
cmd.SetAction(async (ParseResult result) =>
|
cmd.SetAction(async (ParseResult result) =>
|
||||||
{
|
{
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new ListNotificationListsCommand());
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new ListNotificationListsCommand());
|
||||||
});
|
});
|
||||||
return cmd;
|
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 nameOption = new Option<string>("--name") { Description = "Notification list name", Required = true };
|
||||||
var emailsOption = new Option<string>("--emails") { Description = "Comma-separated recipient emails", 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 emailsRaw = result.GetValue(emailsOption)!;
|
||||||
var emails = emailsRaw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
var emails = emailsRaw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new CreateNotificationListCommand(name, emails));
|
new CreateNotificationListCommand(name, emails));
|
||||||
});
|
});
|
||||||
return cmd;
|
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 idOption = new Option<int>("--id") { Description = "Notification list ID", Required = true };
|
||||||
var cmd = new Command("delete") { Description = "Delete a notification list" };
|
var cmd = new Command("delete") { Description = "Delete a notification list" };
|
||||||
@@ -136,7 +136,7 @@ public static class NotificationCommands
|
|||||||
{
|
{
|
||||||
var id = result.GetValue(idOption);
|
var id = result.GetValue(idOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new DeleteNotificationListCommand(id));
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new DeleteNotificationListCommand(id));
|
||||||
});
|
});
|
||||||
return cmd;
|
return cmd;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,18 +6,18 @@ namespace ScadaLink.CLI.Commands;
|
|||||||
|
|
||||||
public static class SecurityCommands
|
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" };
|
var command = new Command("security") { Description = "Manage security settings" };
|
||||||
|
|
||||||
command.Add(BuildApiKey(contactPointsOption, formatOption));
|
command.Add(BuildApiKey(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildRoleMapping(contactPointsOption, formatOption));
|
command.Add(BuildRoleMapping(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildScopeRule(contactPointsOption, formatOption));
|
command.Add(BuildScopeRule(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
|
|
||||||
return command;
|
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" };
|
var group = new Command("api-key") { Description = "Manage API keys" };
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ public static class SecurityCommands
|
|||||||
listCmd.SetAction(async (ParseResult result) =>
|
listCmd.SetAction(async (ParseResult result) =>
|
||||||
{
|
{
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new ListApiKeysCommand());
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new ListApiKeysCommand());
|
||||||
});
|
});
|
||||||
group.Add(listCmd);
|
group.Add(listCmd);
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ public static class SecurityCommands
|
|||||||
{
|
{
|
||||||
var name = result.GetValue(nameOption)!;
|
var name = result.GetValue(nameOption)!;
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new CreateApiKeyCommand(name));
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new CreateApiKeyCommand(name));
|
||||||
});
|
});
|
||||||
group.Add(createCmd);
|
group.Add(createCmd);
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ public static class SecurityCommands
|
|||||||
{
|
{
|
||||||
var id = result.GetValue(idOption);
|
var id = result.GetValue(idOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new DeleteApiKeyCommand(id));
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new DeleteApiKeyCommand(id));
|
||||||
});
|
});
|
||||||
group.Add(deleteCmd);
|
group.Add(deleteCmd);
|
||||||
|
|
||||||
@@ -61,14 +61,14 @@ public static class SecurityCommands
|
|||||||
var id = result.GetValue(updateIdOption);
|
var id = result.GetValue(updateIdOption);
|
||||||
var enabled = result.GetValue(enabledOption);
|
var enabled = result.GetValue(enabledOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new UpdateApiKeyCommand(id, enabled));
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new UpdateApiKeyCommand(id, enabled));
|
||||||
});
|
});
|
||||||
group.Add(updateCmd);
|
group.Add(updateCmd);
|
||||||
|
|
||||||
return group;
|
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" };
|
var group = new Command("role-mapping") { Description = "Manage LDAP role mappings" };
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ public static class SecurityCommands
|
|||||||
listCmd.SetAction(async (ParseResult result) =>
|
listCmd.SetAction(async (ParseResult result) =>
|
||||||
{
|
{
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new ListRoleMappingsCommand());
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new ListRoleMappingsCommand());
|
||||||
});
|
});
|
||||||
group.Add(listCmd);
|
group.Add(listCmd);
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ public static class SecurityCommands
|
|||||||
var ldapGroup = result.GetValue(ldapGroupOption)!;
|
var ldapGroup = result.GetValue(ldapGroupOption)!;
|
||||||
var role = result.GetValue(roleOption)!;
|
var role = result.GetValue(roleOption)!;
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new CreateRoleMappingCommand(ldapGroup, role));
|
new CreateRoleMappingCommand(ldapGroup, role));
|
||||||
});
|
});
|
||||||
group.Add(createCmd);
|
group.Add(createCmd);
|
||||||
@@ -102,7 +102,7 @@ public static class SecurityCommands
|
|||||||
{
|
{
|
||||||
var id = result.GetValue(idOption);
|
var id = result.GetValue(idOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new DeleteRoleMappingCommand(id));
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new DeleteRoleMappingCommand(id));
|
||||||
});
|
});
|
||||||
group.Add(deleteCmd);
|
group.Add(deleteCmd);
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@ public static class SecurityCommands
|
|||||||
var ldapGroup = result.GetValue(updateLdapGroupOption)!;
|
var ldapGroup = result.GetValue(updateLdapGroupOption)!;
|
||||||
var role = result.GetValue(updateRoleOption)!;
|
var role = result.GetValue(updateRoleOption)!;
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new UpdateRoleMappingCommand(id, ldapGroup, role));
|
new UpdateRoleMappingCommand(id, ldapGroup, role));
|
||||||
});
|
});
|
||||||
group.Add(updateCmd);
|
group.Add(updateCmd);
|
||||||
@@ -127,7 +127,7 @@ public static class SecurityCommands
|
|||||||
return group;
|
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" };
|
var group = new Command("scope-rule") { Description = "Manage LDAP scope rules" };
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ public static class SecurityCommands
|
|||||||
{
|
{
|
||||||
var mappingId = result.GetValue(mappingIdOption);
|
var mappingId = result.GetValue(mappingIdOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new ListScopeRulesCommand(mappingId));
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new ListScopeRulesCommand(mappingId));
|
||||||
});
|
});
|
||||||
group.Add(listCmd);
|
group.Add(listCmd);
|
||||||
|
|
||||||
@@ -152,7 +152,7 @@ public static class SecurityCommands
|
|||||||
var mappingId = result.GetValue(addMappingIdOption);
|
var mappingId = result.GetValue(addMappingIdOption);
|
||||||
var siteId = result.GetValue(siteIdOption);
|
var siteId = result.GetValue(siteIdOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new AddScopeRuleCommand(mappingId, siteId));
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new AddScopeRuleCommand(mappingId, siteId));
|
||||||
});
|
});
|
||||||
group.Add(addCmd);
|
group.Add(addCmd);
|
||||||
|
|
||||||
@@ -163,7 +163,7 @@ public static class SecurityCommands
|
|||||||
{
|
{
|
||||||
var id = result.GetValue(deleteIdOption);
|
var id = result.GetValue(deleteIdOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new DeleteScopeRuleCommand(id));
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new DeleteScopeRuleCommand(id));
|
||||||
});
|
});
|
||||||
group.Add(deleteCmd);
|
group.Add(deleteCmd);
|
||||||
|
|
||||||
|
|||||||
@@ -6,31 +6,31 @@ namespace ScadaLink.CLI.Commands;
|
|||||||
|
|
||||||
public static class SharedScriptCommands
|
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" };
|
var command = new Command("shared-script") { Description = "Manage shared scripts" };
|
||||||
|
|
||||||
command.Add(BuildList(contactPointsOption, formatOption));
|
command.Add(BuildList(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildGet(contactPointsOption, formatOption));
|
command.Add(BuildGet(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildCreate(contactPointsOption, formatOption));
|
command.Add(BuildCreate(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildUpdate(contactPointsOption, formatOption));
|
command.Add(BuildUpdate(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildDelete(contactPointsOption, formatOption));
|
command.Add(BuildDelete(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
|
|
||||||
return command;
|
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" };
|
var cmd = new Command("list") { Description = "List all shared scripts" };
|
||||||
cmd.SetAction(async (ParseResult result) =>
|
cmd.SetAction(async (ParseResult result) =>
|
||||||
{
|
{
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new ListSharedScriptsCommand());
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new ListSharedScriptsCommand());
|
||||||
});
|
});
|
||||||
return cmd;
|
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 idOption = new Option<int>("--id") { Description = "Shared script ID", Required = true };
|
||||||
var cmd = new Command("get") { Description = "Get a shared script by ID" };
|
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);
|
var id = result.GetValue(idOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new GetSharedScriptCommand(id));
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new GetSharedScriptCommand(id));
|
||||||
});
|
});
|
||||||
return cmd;
|
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 nameOption = new Option<string>("--name") { Description = "Script name", Required = true };
|
||||||
var codeOption = new Option<string>("--code") { Description = "Script code", 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 parameters = result.GetValue(parametersOption);
|
||||||
var returnDef = result.GetValue(returnDefOption);
|
var returnDef = result.GetValue(returnDefOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new CreateSharedScriptCommand(name, code, parameters, returnDef));
|
new CreateSharedScriptCommand(name, code, parameters, returnDef));
|
||||||
});
|
});
|
||||||
return cmd;
|
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 idOption = new Option<int>("--id") { Description = "Shared script ID", Required = true };
|
||||||
var nameOption = new Option<string>("--name") { Description = "Script name", 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 parameters = result.GetValue(parametersOption);
|
||||||
var returnDef = result.GetValue(returnDefOption);
|
var returnDef = result.GetValue(returnDefOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new UpdateSharedScriptCommand(id, name, code, parameters, returnDef));
|
new UpdateSharedScriptCommand(id, name, code, parameters, returnDef));
|
||||||
});
|
});
|
||||||
return cmd;
|
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 idOption = new Option<int>("--id") { Description = "Shared script ID", Required = true };
|
||||||
var cmd = new Command("delete") { Description = "Delete a shared script" };
|
var cmd = new Command("delete") { Description = "Delete a shared script" };
|
||||||
@@ -106,7 +106,7 @@ public static class SharedScriptCommands
|
|||||||
{
|
{
|
||||||
var id = result.GetValue(idOption);
|
var id = result.GetValue(idOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new DeleteSharedScriptCommand(id));
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new DeleteSharedScriptCommand(id));
|
||||||
});
|
});
|
||||||
return cmd;
|
return cmd;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,22 +6,22 @@ namespace ScadaLink.CLI.Commands;
|
|||||||
|
|
||||||
public static class SiteCommands
|
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" };
|
var command = new Command("site") { Description = "Manage sites" };
|
||||||
|
|
||||||
command.Add(BuildList(contactPointsOption, formatOption));
|
command.Add(BuildList(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildGet(contactPointsOption, formatOption));
|
command.Add(BuildGet(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildCreate(contactPointsOption, formatOption));
|
command.Add(BuildCreate(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildUpdate(contactPointsOption, formatOption));
|
command.Add(BuildUpdate(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildDelete(contactPointsOption, formatOption));
|
command.Add(BuildDelete(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildDeployArtifacts(contactPointsOption, formatOption));
|
command.Add(BuildDeployArtifacts(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildArea(contactPointsOption, formatOption));
|
command.Add(BuildArea(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
|
|
||||||
return command;
|
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 idOption = new Option<int>("--id") { Description = "Site ID", Required = true };
|
||||||
var cmd = new Command("get") { Description = "Get a site by ID" };
|
var cmd = new Command("get") { Description = "Get a site by ID" };
|
||||||
@@ -30,23 +30,23 @@ public static class SiteCommands
|
|||||||
{
|
{
|
||||||
var id = result.GetValue(idOption);
|
var id = result.GetValue(idOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new GetSiteCommand(id));
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new GetSiteCommand(id));
|
||||||
});
|
});
|
||||||
return cmd;
|
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" };
|
var cmd = new Command("list") { Description = "List all sites" };
|
||||||
cmd.SetAction(async (ParseResult result) =>
|
cmd.SetAction(async (ParseResult result) =>
|
||||||
{
|
{
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new ListSitesCommand());
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new ListSitesCommand());
|
||||||
});
|
});
|
||||||
return cmd;
|
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 nameOption = new Option<string>("--name") { Description = "Site name", Required = true };
|
||||||
var identifierOption = new Option<string>("--identifier") { Description = "Site identifier", 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 nodeA = result.GetValue(nodeAOption);
|
||||||
var nodeB = result.GetValue(nodeBOption);
|
var nodeB = result.GetValue(nodeBOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new CreateSiteCommand(name, identifier, desc, nodeA, nodeB));
|
new CreateSiteCommand(name, identifier, desc, nodeA, nodeB));
|
||||||
});
|
});
|
||||||
return cmd;
|
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 idOption = new Option<int>("--id") { Description = "Site ID", Required = true };
|
||||||
var nameOption = new Option<string>("--name") { Description = "Site name", 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 nodeA = result.GetValue(nodeAOption);
|
||||||
var nodeB = result.GetValue(nodeBOption);
|
var nodeB = result.GetValue(nodeBOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new UpdateSiteCommand(id, name, desc, nodeA, nodeB));
|
new UpdateSiteCommand(id, name, desc, nodeA, nodeB));
|
||||||
});
|
});
|
||||||
return cmd;
|
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 idOption = new Option<int>("--id") { Description = "Site ID", Required = true };
|
||||||
var cmd = new Command("delete") { Description = "Delete a site" };
|
var cmd = new Command("delete") { Description = "Delete a site" };
|
||||||
@@ -111,12 +111,12 @@ public static class SiteCommands
|
|||||||
{
|
{
|
||||||
var id = result.GetValue(idOption);
|
var id = result.GetValue(idOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new DeleteSiteCommand(id));
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new DeleteSiteCommand(id));
|
||||||
});
|
});
|
||||||
return cmd;
|
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" };
|
var group = new Command("area") { Description = "Manage areas" };
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ public static class SiteCommands
|
|||||||
{
|
{
|
||||||
var siteId = result.GetValue(siteIdOption);
|
var siteId = result.GetValue(siteIdOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new ListAreasCommand(siteId));
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new ListAreasCommand(siteId));
|
||||||
});
|
});
|
||||||
group.Add(listCmd);
|
group.Add(listCmd);
|
||||||
|
|
||||||
@@ -144,7 +144,7 @@ public static class SiteCommands
|
|||||||
var name = result.GetValue(nameOption)!;
|
var name = result.GetValue(nameOption)!;
|
||||||
var parentId = result.GetValue(parentOption);
|
var parentId = result.GetValue(parentOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new CreateAreaCommand(siteId, name, parentId));
|
new CreateAreaCommand(siteId, name, parentId));
|
||||||
});
|
});
|
||||||
group.Add(createCmd);
|
group.Add(createCmd);
|
||||||
@@ -159,7 +159,7 @@ public static class SiteCommands
|
|||||||
var id = result.GetValue(updateIdOption);
|
var id = result.GetValue(updateIdOption);
|
||||||
var name = result.GetValue(updateNameOption)!;
|
var name = result.GetValue(updateNameOption)!;
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new UpdateAreaCommand(id, name));
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new UpdateAreaCommand(id, name));
|
||||||
});
|
});
|
||||||
group.Add(updateCmd);
|
group.Add(updateCmd);
|
||||||
|
|
||||||
@@ -170,14 +170,14 @@ public static class SiteCommands
|
|||||||
{
|
{
|
||||||
var id = result.GetValue(deleteIdOption);
|
var id = result.GetValue(deleteIdOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new DeleteAreaCommand(id));
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new DeleteAreaCommand(id));
|
||||||
});
|
});
|
||||||
group.Add(deleteCmd);
|
group.Add(deleteCmd);
|
||||||
|
|
||||||
return group;
|
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 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)" };
|
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);
|
var siteId = result.GetValue(siteIdOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new MgmtDeployArtifactsCommand(siteId));
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new MgmtDeployArtifactsCommand(siteId));
|
||||||
});
|
});
|
||||||
return cmd;
|
return cmd;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,36 +6,36 @@ namespace ScadaLink.CLI.Commands;
|
|||||||
|
|
||||||
public static class TemplateCommands
|
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" };
|
var command = new Command("template") { Description = "Manage templates" };
|
||||||
|
|
||||||
command.Add(BuildList(contactPointsOption, formatOption));
|
command.Add(BuildList(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildGet(contactPointsOption, formatOption));
|
command.Add(BuildGet(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildCreate(contactPointsOption, formatOption));
|
command.Add(BuildCreate(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildUpdate(contactPointsOption, formatOption));
|
command.Add(BuildUpdate(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildValidate(contactPointsOption, formatOption));
|
command.Add(BuildValidate(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildDelete(contactPointsOption, formatOption));
|
command.Add(BuildDelete(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildAttribute(contactPointsOption, formatOption));
|
command.Add(BuildAttribute(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildAlarm(contactPointsOption, formatOption));
|
command.Add(BuildAlarm(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildScript(contactPointsOption, formatOption));
|
command.Add(BuildScript(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildComposition(contactPointsOption, formatOption));
|
command.Add(BuildComposition(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
|
|
||||||
return command;
|
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" };
|
var cmd = new Command("list") { Description = "List all templates" };
|
||||||
cmd.SetAction(async (ParseResult result) =>
|
cmd.SetAction(async (ParseResult result) =>
|
||||||
{
|
{
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new ListTemplatesCommand());
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new ListTemplatesCommand());
|
||||||
});
|
});
|
||||||
return cmd;
|
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 idOption = new Option<int>("--id") { Description = "Template ID", Required = true };
|
||||||
var cmd = new Command("get") { Description = "Get a template by ID" };
|
var cmd = new Command("get") { Description = "Get a template by ID" };
|
||||||
@@ -44,12 +44,12 @@ public static class TemplateCommands
|
|||||||
{
|
{
|
||||||
var id = result.GetValue(idOption);
|
var id = result.GetValue(idOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new GetTemplateCommand(id));
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new GetTemplateCommand(id));
|
||||||
});
|
});
|
||||||
return cmd;
|
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 nameOption = new Option<string>("--name") { Description = "Template name", Required = true };
|
||||||
var descOption = new Option<string?>("--description") { Description = "Template description" };
|
var descOption = new Option<string?>("--description") { Description = "Template description" };
|
||||||
@@ -65,13 +65,13 @@ public static class TemplateCommands
|
|||||||
var desc = result.GetValue(descOption);
|
var desc = result.GetValue(descOption);
|
||||||
var parentId = result.GetValue(parentOption);
|
var parentId = result.GetValue(parentOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new CreateTemplateCommand(name, desc, parentId));
|
new CreateTemplateCommand(name, desc, parentId));
|
||||||
});
|
});
|
||||||
return cmd;
|
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 idOption = new Option<int>("--id") { Description = "Template ID", Required = true };
|
||||||
var nameOption = new Option<string>("--name") { Description = "Template name", 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 desc = result.GetValue(descOption);
|
||||||
var parentId = result.GetValue(parentOption);
|
var parentId = result.GetValue(parentOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new UpdateTemplateCommand(id, name, desc, parentId));
|
new UpdateTemplateCommand(id, name, desc, parentId));
|
||||||
});
|
});
|
||||||
return cmd;
|
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 idOption = new Option<int>("--id") { Description = "Template ID", Required = true };
|
||||||
var cmd = new Command("validate") { Description = "Validate a template" };
|
var cmd = new Command("validate") { Description = "Validate a template" };
|
||||||
@@ -105,12 +105,12 @@ public static class TemplateCommands
|
|||||||
{
|
{
|
||||||
var id = result.GetValue(idOption);
|
var id = result.GetValue(idOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new ValidateTemplateCommand(id));
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new ValidateTemplateCommand(id));
|
||||||
});
|
});
|
||||||
return cmd;
|
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 idOption = new Option<int>("--id") { Description = "Template ID", Required = true };
|
||||||
var cmd = new Command("delete") { Description = "Delete a template" };
|
var cmd = new Command("delete") { Description = "Delete a template" };
|
||||||
@@ -119,12 +119,12 @@ public static class TemplateCommands
|
|||||||
{
|
{
|
||||||
var id = result.GetValue(idOption);
|
var id = result.GetValue(idOption);
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption, new DeleteTemplateCommand(id));
|
result, contactPointsOption, formatOption, usernameOption, passwordOption, new DeleteTemplateCommand(id));
|
||||||
});
|
});
|
||||||
return cmd;
|
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" };
|
var group = new Command("attribute") { Description = "Manage template attributes" };
|
||||||
|
|
||||||
@@ -148,7 +148,7 @@ public static class TemplateCommands
|
|||||||
addCmd.SetAction(async (ParseResult result) =>
|
addCmd.SetAction(async (ParseResult result) =>
|
||||||
{
|
{
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new AddTemplateAttributeCommand(
|
new AddTemplateAttributeCommand(
|
||||||
result.GetValue(templateIdOption),
|
result.GetValue(templateIdOption),
|
||||||
result.GetValue(nameOption)!,
|
result.GetValue(nameOption)!,
|
||||||
@@ -180,7 +180,7 @@ public static class TemplateCommands
|
|||||||
updateCmd.SetAction(async (ParseResult result) =>
|
updateCmd.SetAction(async (ParseResult result) =>
|
||||||
{
|
{
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new UpdateTemplateAttributeCommand(
|
new UpdateTemplateAttributeCommand(
|
||||||
result.GetValue(updateIdOption),
|
result.GetValue(updateIdOption),
|
||||||
result.GetValue(updateNameOption)!,
|
result.GetValue(updateNameOption)!,
|
||||||
@@ -198,7 +198,7 @@ public static class TemplateCommands
|
|||||||
deleteCmd.SetAction(async (ParseResult result) =>
|
deleteCmd.SetAction(async (ParseResult result) =>
|
||||||
{
|
{
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new DeleteTemplateAttributeCommand(result.GetValue(deleteIdOption)));
|
new DeleteTemplateAttributeCommand(result.GetValue(deleteIdOption)));
|
||||||
});
|
});
|
||||||
group.Add(deleteCmd);
|
group.Add(deleteCmd);
|
||||||
@@ -206,7 +206,7 @@ public static class TemplateCommands
|
|||||||
return group;
|
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" };
|
var group = new Command("alarm") { Description = "Manage template alarms" };
|
||||||
|
|
||||||
@@ -230,7 +230,7 @@ public static class TemplateCommands
|
|||||||
addCmd.SetAction(async (ParseResult result) =>
|
addCmd.SetAction(async (ParseResult result) =>
|
||||||
{
|
{
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new AddTemplateAlarmCommand(
|
new AddTemplateAlarmCommand(
|
||||||
result.GetValue(templateIdOption),
|
result.GetValue(templateIdOption),
|
||||||
result.GetValue(nameOption)!,
|
result.GetValue(nameOption)!,
|
||||||
@@ -262,7 +262,7 @@ public static class TemplateCommands
|
|||||||
updateCmd.SetAction(async (ParseResult result) =>
|
updateCmd.SetAction(async (ParseResult result) =>
|
||||||
{
|
{
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new UpdateTemplateAlarmCommand(
|
new UpdateTemplateAlarmCommand(
|
||||||
result.GetValue(updateIdOption),
|
result.GetValue(updateIdOption),
|
||||||
result.GetValue(updateNameOption)!,
|
result.GetValue(updateNameOption)!,
|
||||||
@@ -280,7 +280,7 @@ public static class TemplateCommands
|
|||||||
deleteCmd.SetAction(async (ParseResult result) =>
|
deleteCmd.SetAction(async (ParseResult result) =>
|
||||||
{
|
{
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new DeleteTemplateAlarmCommand(result.GetValue(deleteIdOption)));
|
new DeleteTemplateAlarmCommand(result.GetValue(deleteIdOption)));
|
||||||
});
|
});
|
||||||
group.Add(deleteCmd);
|
group.Add(deleteCmd);
|
||||||
@@ -288,7 +288,7 @@ public static class TemplateCommands
|
|||||||
return group;
|
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" };
|
var group = new Command("script") { Description = "Manage template scripts" };
|
||||||
|
|
||||||
@@ -315,7 +315,7 @@ public static class TemplateCommands
|
|||||||
addCmd.SetAction(async (ParseResult result) =>
|
addCmd.SetAction(async (ParseResult result) =>
|
||||||
{
|
{
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new AddTemplateScriptCommand(
|
new AddTemplateScriptCommand(
|
||||||
result.GetValue(templateIdOption),
|
result.GetValue(templateIdOption),
|
||||||
result.GetValue(nameOption)!,
|
result.GetValue(nameOption)!,
|
||||||
@@ -351,7 +351,7 @@ public static class TemplateCommands
|
|||||||
updateCmd.SetAction(async (ParseResult result) =>
|
updateCmd.SetAction(async (ParseResult result) =>
|
||||||
{
|
{
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new UpdateTemplateScriptCommand(
|
new UpdateTemplateScriptCommand(
|
||||||
result.GetValue(updateIdOption),
|
result.GetValue(updateIdOption),
|
||||||
result.GetValue(updateNameOption)!,
|
result.GetValue(updateNameOption)!,
|
||||||
@@ -370,7 +370,7 @@ public static class TemplateCommands
|
|||||||
deleteCmd.SetAction(async (ParseResult result) =>
|
deleteCmd.SetAction(async (ParseResult result) =>
|
||||||
{
|
{
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new DeleteTemplateScriptCommand(result.GetValue(deleteIdOption)));
|
new DeleteTemplateScriptCommand(result.GetValue(deleteIdOption)));
|
||||||
});
|
});
|
||||||
group.Add(deleteCmd);
|
group.Add(deleteCmd);
|
||||||
@@ -378,7 +378,7 @@ public static class TemplateCommands
|
|||||||
return group;
|
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" };
|
var group = new Command("composition") { Description = "Manage template compositions" };
|
||||||
|
|
||||||
@@ -393,7 +393,7 @@ public static class TemplateCommands
|
|||||||
addCmd.SetAction(async (ParseResult result) =>
|
addCmd.SetAction(async (ParseResult result) =>
|
||||||
{
|
{
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new AddTemplateCompositionCommand(
|
new AddTemplateCompositionCommand(
|
||||||
result.GetValue(templateIdOption),
|
result.GetValue(templateIdOption),
|
||||||
result.GetValue(instanceNameOption)!,
|
result.GetValue(instanceNameOption)!,
|
||||||
@@ -407,7 +407,7 @@ public static class TemplateCommands
|
|||||||
deleteCmd.SetAction(async (ParseResult result) =>
|
deleteCmd.SetAction(async (ParseResult result) =>
|
||||||
{
|
{
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, contactPointsOption, formatOption,
|
result, contactPointsOption, formatOption, usernameOption, passwordOption,
|
||||||
new DeleteTemplateCompositionCommand(result.GetValue(deleteIdOption)));
|
new DeleteTemplateCompositionCommand(result.GetValue(deleteIdOption)));
|
||||||
});
|
});
|
||||||
group.Add(deleteCmd);
|
group.Add(deleteCmd);
|
||||||
|
|||||||
@@ -16,20 +16,20 @@ rootCommand.Add(passwordOption);
|
|||||||
rootCommand.Add(formatOption);
|
rootCommand.Add(formatOption);
|
||||||
|
|
||||||
// Register command groups
|
// Register command groups
|
||||||
rootCommand.Add(TemplateCommands.Build(contactPointsOption, formatOption));
|
rootCommand.Add(TemplateCommands.Build(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
rootCommand.Add(InstanceCommands.Build(contactPointsOption, formatOption));
|
rootCommand.Add(InstanceCommands.Build(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
rootCommand.Add(SiteCommands.Build(contactPointsOption, formatOption));
|
rootCommand.Add(SiteCommands.Build(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
rootCommand.Add(DeployCommands.Build(contactPointsOption, formatOption));
|
rootCommand.Add(DeployCommands.Build(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
rootCommand.Add(DataConnectionCommands.Build(contactPointsOption, formatOption));
|
rootCommand.Add(DataConnectionCommands.Build(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
rootCommand.Add(ExternalSystemCommands.Build(contactPointsOption, formatOption));
|
rootCommand.Add(ExternalSystemCommands.Build(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
rootCommand.Add(NotificationCommands.Build(contactPointsOption, formatOption));
|
rootCommand.Add(NotificationCommands.Build(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
rootCommand.Add(SecurityCommands.Build(contactPointsOption, formatOption));
|
rootCommand.Add(SecurityCommands.Build(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
rootCommand.Add(AuditLogCommands.Build(contactPointsOption, formatOption));
|
rootCommand.Add(AuditLogCommands.Build(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
rootCommand.Add(HealthCommands.Build(contactPointsOption, formatOption));
|
rootCommand.Add(HealthCommands.Build(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
rootCommand.Add(DebugCommands.Build(contactPointsOption, formatOption));
|
rootCommand.Add(DebugCommands.Build(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
rootCommand.Add(SharedScriptCommands.Build(contactPointsOption, formatOption));
|
rootCommand.Add(SharedScriptCommands.Build(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
rootCommand.Add(DbConnectionCommands.Build(contactPointsOption, formatOption));
|
rootCommand.Add(DbConnectionCommands.Build(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
rootCommand.Add(ApiMethodCommands.Build(contactPointsOption, formatOption));
|
rootCommand.Add(ApiMethodCommands.Build(contactPointsOption, formatOption, usernameOption, passwordOption));
|
||||||
|
|
||||||
rootCommand.SetAction(_ =>
|
rootCommand.SetAction(_ =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ These options are accepted by the root command and inherited by all subcommands.
|
|||||||
| Option | Description |
|
| Option | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `--contact-points <value>` | Comma-separated Akka cluster contact point URIs |
|
| `--contact-points <value>` | Comma-separated Akka cluster contact point URIs |
|
||||||
| `--username <value>` | LDAP username (reserved for future auth integration) |
|
| `--username <value>` | LDAP username for authentication |
|
||||||
| `--password <value>` | LDAP password (reserved for future auth integration) |
|
| `--password <value>` | LDAP password for authentication |
|
||||||
| `--format <json\|table>` | Output format (default: `json`) |
|
| `--format <json\|table>` | Output format (default: `json`) |
|
||||||
|
|
||||||
## Configuration File
|
## Configuration File
|
||||||
@@ -55,12 +55,19 @@ These options are accepted by the root command and inherited by all subcommands.
|
|||||||
"ldap": {
|
"ldap": {
|
||||||
"server": "ldap.company.com",
|
"server": "ldap.company.com",
|
||||||
"port": 636,
|
"port": 636,
|
||||||
"useTls": true
|
"useTls": true,
|
||||||
|
"searchBase": "dc=example,dc=com",
|
||||||
|
"serviceAccountDn": "cn=admin,dc=example,dc=com",
|
||||||
|
"serviceAccountPassword": "secret"
|
||||||
},
|
},
|
||||||
"defaultFormat": "json"
|
"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
|
## Environment Variables
|
||||||
|
|
||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
@@ -435,7 +442,7 @@ scadalink --contact-points <uri> instance set-bindings --id <int> --bindings <js
|
|||||||
| Option | Required | Description |
|
| Option | Required | Description |
|
||||||
|--------|----------|-------------|
|
|--------|----------|-------------|
|
||||||
| `--id` | yes | Instance ID |
|
| `--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.
|
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
|
## Issues & Missing Features
|
||||||
|
|
||||||
|
|||||||
@@ -7,14 +7,20 @@
|
|||||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
<AssemblyName>scadalink</AssemblyName>
|
<AssemblyName>scadalink</AssemblyName>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="ScadaLink.CLI.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Akka" Version="1.5.62" />
|
<PackageReference Include="Akka" Version="1.5.62" />
|
||||||
<PackageReference Include="Akka.Remote" Version="1.5.62" />
|
<PackageReference Include="Akka.Remote" Version="1.5.62" />
|
||||||
<PackageReference Include="Akka.Cluster.Tools" Version="1.5.62" />
|
<PackageReference Include="Akka.Cluster.Tools" Version="1.5.62" />
|
||||||
<PackageReference Include="System.CommandLine" Version="2.0.5" />
|
<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" />
|
<PackageReference Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="../ScadaLink.Commons/ScadaLink.Commons.csproj" />
|
<ProjectReference Include="../ScadaLink.Commons/ScadaLink.Commons.csproj" />
|
||||||
|
<ProjectReference Include="../ScadaLink.Security/ScadaLink.Security.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -65,6 +65,16 @@
|
|||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div class="col-md-2">
|
||||||
<button class="btn btn-success btn-sm me-1" @onclick="CreateInstance">Create</button>
|
<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>
|
<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"
|
<button class="btn btn-outline-info btn-sm py-0 px-1 me-1"
|
||||||
@onclick="() => ToggleBindings(inst)">Bindings</button>
|
@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"
|
<button class="btn btn-outline-danger btn-sm py-0 px-1"
|
||||||
@onclick="() => DeleteInstance(inst)" disabled="@_actionInProgress">Delete</button>
|
@onclick="() => DeleteInstance(inst)" disabled="@_actionInProgress">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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)
|
@if (_bindingInstanceId == inst.Id)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
@@ -268,6 +330,55 @@
|
|||||||
<div class="text-muted small">
|
<div class="text-muted small">
|
||||||
@_filteredInstances.Count instance(s) total
|
@_filteredInstances.Count instance(s) total
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
@@ -508,6 +619,7 @@
|
|||||||
private string _createName = string.Empty;
|
private string _createName = string.Empty;
|
||||||
private int _createTemplateId;
|
private int _createTemplateId;
|
||||||
private int _createSiteId;
|
private int _createSiteId;
|
||||||
|
private int _createAreaId;
|
||||||
private string? _createError;
|
private string? _createError;
|
||||||
|
|
||||||
private void ShowCreateForm()
|
private void ShowCreateForm()
|
||||||
@@ -515,6 +627,7 @@
|
|||||||
_createName = string.Empty;
|
_createName = string.Empty;
|
||||||
_createTemplateId = 0;
|
_createTemplateId = 0;
|
||||||
_createSiteId = 0;
|
_createSiteId = 0;
|
||||||
|
_createAreaId = 0;
|
||||||
_createError = null;
|
_createError = null;
|
||||||
_showCreateForm = true;
|
_showCreateForm = true;
|
||||||
}
|
}
|
||||||
@@ -530,7 +643,7 @@
|
|||||||
{
|
{
|
||||||
var user = await GetCurrentUserAsync();
|
var user = await GetCurrentUserAsync();
|
||||||
var result = await InstanceService.CreateInstanceAsync(
|
var result = await InstanceService.CreateInstanceAsync(
|
||||||
_createName.Trim(), _createTemplateId, _createSiteId, null, user);
|
_createName.Trim(), _createTemplateId, _createSiteId, _createAreaId == 0 ? null : _createAreaId, user);
|
||||||
if (result.IsSuccess)
|
if (result.IsSuccess)
|
||||||
{
|
{
|
||||||
_showCreateForm = false;
|
_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
|
// Connection binding state
|
||||||
private int _bindingInstanceId;
|
private int _bindingInstanceId;
|
||||||
private List<TemplateAttribute> _bindingDataSourceAttrs = new();
|
private List<TemplateAttribute> _bindingDataSourceAttrs = new();
|
||||||
|
|||||||
@@ -50,8 +50,8 @@
|
|||||||
|
|
||||||
@if (_tab == "extsys") { @RenderExternalSystems() }
|
@if (_tab == "extsys") { @RenderExternalSystems() }
|
||||||
else if (_tab == "dbconn") { @RenderDbConnections() }
|
else if (_tab == "dbconn") { @RenderDbConnections() }
|
||||||
else if (_tab == "notif") { @RenderNotificationLists() }
|
else if (_tab == "notif") { @RenderNotificationLists() @RenderSmtpConfig() }
|
||||||
else if (_tab == "inbound") { @RenderInboundApiMethods() }
|
else if (_tab == "inbound") { @RenderInboundApiMethods() @RenderApiKeyMethodAssignments() }
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -66,6 +66,8 @@
|
|||||||
private ExternalSystemDefinition? _editingExtSys;
|
private ExternalSystemDefinition? _editingExtSys;
|
||||||
private string _extSysName = "", _extSysUrl = "", _extSysAuth = "ApiKey";
|
private string _extSysName = "", _extSysUrl = "", _extSysAuth = "ApiKey";
|
||||||
private string? _extSysAuthConfig;
|
private string? _extSysAuthConfig;
|
||||||
|
private int _extSysMaxRetries = 3;
|
||||||
|
private int _extSysRetryDelaySeconds = 5;
|
||||||
private string? _extSysFormError;
|
private string? _extSysFormError;
|
||||||
|
|
||||||
// Database Connections
|
// Database Connections
|
||||||
@@ -73,8 +75,21 @@
|
|||||||
private bool _showDbConnForm;
|
private bool _showDbConnForm;
|
||||||
private DatabaseConnectionDefinition? _editingDbConn;
|
private DatabaseConnectionDefinition? _editingDbConn;
|
||||||
private string _dbConnName = "", _dbConnString = "";
|
private string _dbConnName = "", _dbConnString = "";
|
||||||
|
private int _dbConnMaxRetries = 3;
|
||||||
|
private int _dbConnRetryDelaySeconds = 5;
|
||||||
private string? _dbConnFormError;
|
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
|
// Notification Lists
|
||||||
private List<NotificationList> _notificationLists = new();
|
private List<NotificationList> _notificationLists = new();
|
||||||
private bool _showNotifForm;
|
private bool _showNotifForm;
|
||||||
@@ -123,6 +138,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
_apiMethods = (await InboundApiRepository.GetAllApiMethodsAsync()).ToList();
|
_apiMethods = (await InboundApiRepository.GetAllApiMethodsAsync()).ToList();
|
||||||
|
_smtpConfigs = (await NotificationRepository.GetAllSmtpConfigurationsAsync()).ToList();
|
||||||
|
_apiKeys = (await InboundApiRepository.GetAllApiKeysAsync()).ToList();
|
||||||
}
|
}
|
||||||
catch (Exception ex) { _errorMessage = ex.Message; }
|
catch (Exception ex) { _errorMessage = ex.Message; }
|
||||||
_loading = false;
|
_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-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>
|
<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>
|
<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"><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-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-success btn-sm me-1" @onclick="SaveExtSys">Save</button>
|
||||||
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showExtSysForm = false">Cancel</button></div>
|
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showExtSysForm = false">Cancel</button></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -154,14 +173,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<table class="table table-sm table-striped">
|
<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>
|
<tbody>
|
||||||
@foreach (var es in _externalSystems)
|
@foreach (var es in _externalSystems)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td>@es.Name</td><td class="small">@es.EndpointUrl</td><td><span class="badge bg-secondary">@es.AuthType</span></td>
|
<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>
|
<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>
|
<button class="btn btn-outline-danger btn-sm py-0 px-1" @onclick="() => DeleteExtSys(es)">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -177,6 +197,8 @@
|
|||||||
_extSysName = _extSysUrl = string.Empty;
|
_extSysName = _extSysUrl = string.Empty;
|
||||||
_extSysAuth = "ApiKey";
|
_extSysAuth = "ApiKey";
|
||||||
_extSysAuthConfig = null;
|
_extSysAuthConfig = null;
|
||||||
|
_extSysMaxRetries = 3;
|
||||||
|
_extSysRetryDelaySeconds = 5;
|
||||||
_extSysFormError = null;
|
_extSysFormError = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,8 +208,8 @@
|
|||||||
if (string.IsNullOrWhiteSpace(_extSysName) || string.IsNullOrWhiteSpace(_extSysUrl)) { _extSysFormError = "Name and URL required."; return; }
|
if (string.IsNullOrWhiteSpace(_extSysName) || string.IsNullOrWhiteSpace(_extSysUrl)) { _extSysFormError = "Name and URL required."; return; }
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (_editingExtSys != null) { _editingExtSys.Name = _extSysName.Trim(); _editingExtSys.EndpointUrl = _extSysUrl.Trim(); _editingExtSys.AuthType = _extSysAuth; _editingExtSys.AuthConfiguration = _extSysAuthConfig?.Trim(); await ExternalSystemRepository.UpdateExternalSystemAsync(_editingExtSys); }
|
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() }; await ExternalSystemRepository.AddExternalSystemAsync(es); }
|
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();
|
await ExternalSystemRepository.SaveChangesAsync(); _showExtSysForm = false; _toast.ShowSuccess("Saved."); await LoadAllAsync();
|
||||||
}
|
}
|
||||||
catch (Exception ex) { _extSysFormError = ex.Message; }
|
catch (Exception ex) { _extSysFormError = ex.Message; }
|
||||||
@@ -205,7 +227,7 @@
|
|||||||
{
|
{
|
||||||
<div class="d-flex justify-content-between mb-2">
|
<div class="d-flex justify-content-between mb-2">
|
||||||
<h6 class="mb-0">Database Connections</h6>
|
<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>
|
</div>
|
||||||
|
|
||||||
@if (_showDbConnForm)
|
@if (_showDbConnForm)
|
||||||
@@ -213,7 +235,9 @@
|
|||||||
<div class="card mb-2"><div class="card-body">
|
<div class="card mb-2"><div class="card-body">
|
||||||
<div class="row g-2 align-items-end">
|
<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-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">
|
<div class="col-md-3">
|
||||||
<button class="btn btn-success btn-sm me-1" @onclick="SaveDbConn">Save</button>
|
<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>
|
<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">
|
<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>
|
<tbody>
|
||||||
@foreach (var dc in _dbConnections)
|
@foreach (var dc in _dbConnections)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td>@dc.Name</td><td class="small text-muted text-truncate" style="max-width:400px;">@dc.ConnectionString</td>
|
<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>
|
<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>
|
<button class="btn btn-outline-danger btn-sm py-0 px-1" @onclick="() => DeleteDbConn(dc)">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -245,8 +270,8 @@
|
|||||||
if (string.IsNullOrWhiteSpace(_dbConnName) || string.IsNullOrWhiteSpace(_dbConnString)) { _dbConnFormError = "Name and connection string required."; return; }
|
if (string.IsNullOrWhiteSpace(_dbConnName) || string.IsNullOrWhiteSpace(_dbConnString)) { _dbConnFormError = "Name and connection string required."; return; }
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (_editingDbConn != null) { _editingDbConn.Name = _dbConnName.Trim(); _editingDbConn.ConnectionString = _dbConnString.Trim(); await ExternalSystemRepository.UpdateDatabaseConnectionAsync(_editingDbConn); }
|
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()); await ExternalSystemRepository.AddDatabaseConnectionAsync(dc); }
|
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();
|
await ExternalSystemRepository.SaveChangesAsync(); _showDbConnForm = false; _toast.ShowSuccess("Saved."); await LoadAllAsync();
|
||||||
}
|
}
|
||||||
catch (Exception ex) { _dbConnFormError = ex.Message; }
|
catch (Exception ex) { _dbConnFormError = ex.Message; }
|
||||||
@@ -434,4 +459,127 @@
|
|||||||
try { await InboundApiRepository.DeleteApiMethodAsync(m.Id); await InboundApiRepository.SaveChangesAsync(); _toast.ShowSuccess("Deleted."); await LoadAllAsync(); }
|
try { await InboundApiRepository.DeleteApiMethodAsync(m.Id); await InboundApiRepository.SaveChangesAsync(); _toast.ShowSuccess("Deleted."); await LoadAllAsync(); }
|
||||||
catch (Exception ex) { _toast.ShowError(ex.Message); }
|
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;
|
_validationResult = null;
|
||||||
try
|
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();
|
var validationService = new ValidationService();
|
||||||
// Build a minimal flattened config from the template's direct members for validation
|
|
||||||
var flatConfig = new Commons.Types.Flattening.FlattenedConfiguration
|
var flatConfig = new Commons.Types.Flattening.FlattenedConfiguration
|
||||||
{
|
{
|
||||||
InstanceUniqueName = $"validation-{_selectedTemplate.Name}",
|
InstanceUniqueName = $"validation-{_selectedTemplate.Name}",
|
||||||
@@ -511,6 +512,17 @@
|
|||||||
}).ToList()
|
}).ToList()
|
||||||
};
|
};
|
||||||
_validationResult = validationService.Validate(flatConfig);
|
_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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -44,10 +44,14 @@
|
|||||||
<label class="form-label small">To</label>
|
<label class="form-label small">To</label>
|
||||||
<input type="datetime-local" class="form-control form-control-sm" @bind="_filterTo" />
|
<input type="datetime-local" class="form-control form-control-sm" @bind="_filterTo" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-1">
|
||||||
<label class="form-label small">Keyword</label>
|
<label class="form-label small">Keyword</label>
|
||||||
<input type="text" class="form-control form-control-sm" @bind="_filterKeyword" />
|
<input type="text" class="form-control form-control-sm" @bind="_filterKeyword" />
|
||||||
</div>
|
</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">
|
<div class="col-md-1 d-flex align-items-end">
|
||||||
<button class="btn btn-primary btn-sm" @onclick="Search" disabled="@(string.IsNullOrEmpty(_selectedSiteId) || _searching)">
|
<button class="btn btn-primary btn-sm" @onclick="Search" disabled="@(string.IsNullOrEmpty(_selectedSiteId) || _searching)">
|
||||||
@if (_searching) { <span class="spinner-border spinner-border-sm"></span> }
|
@if (_searching) { <span class="spinner-border spinner-border-sm"></span> }
|
||||||
@@ -111,6 +115,7 @@
|
|||||||
private DateTime? _filterFrom;
|
private DateTime? _filterFrom;
|
||||||
private DateTime? _filterTo;
|
private DateTime? _filterTo;
|
||||||
private string? _filterKeyword;
|
private string? _filterKeyword;
|
||||||
|
private string? _filterInstanceName;
|
||||||
|
|
||||||
private List<EventLogEntry>? _entries;
|
private List<EventLogEntry>? _entries;
|
||||||
private bool _hasMore;
|
private bool _hasMore;
|
||||||
@@ -146,7 +151,7 @@
|
|||||||
To: _filterTo.HasValue ? new DateTimeOffset(_filterTo.Value, TimeSpan.Zero) : null,
|
To: _filterTo.HasValue ? new DateTimeOffset(_filterTo.Value, TimeSpan.Zero) : null,
|
||||||
EventType: string.IsNullOrWhiteSpace(_filterEventType) ? null : _filterEventType.Trim(),
|
EventType: string.IsNullOrWhiteSpace(_filterEventType) ? null : _filterEventType.Trim(),
|
||||||
Severity: string.IsNullOrWhiteSpace(_filterSeverity) ? null : _filterSeverity,
|
Severity: string.IsNullOrWhiteSpace(_filterSeverity) ? null : _filterSeverity,
|
||||||
InstanceId: null,
|
InstanceId: string.IsNullOrWhiteSpace(_filterInstanceName) ? null : _filterInstanceName.Trim(),
|
||||||
KeywordFilter: string.IsNullOrWhiteSpace(_filterKeyword) ? null : _filterKeyword.Trim(),
|
KeywordFilter: string.IsNullOrWhiteSpace(_filterKeyword) ? null : _filterKeyword.Trim(),
|
||||||
ContinuationToken: _continuationToken,
|
ContinuationToken: _continuationToken,
|
||||||
PageSize: 50,
|
PageSize: 50,
|
||||||
|
|||||||
@@ -71,6 +71,10 @@
|
|||||||
<span class="badge bg-danger me-2">Offline</span>
|
<span class="badge bg-danger me-2">Offline</span>
|
||||||
}
|
}
|
||||||
<strong>@siteId</strong>
|
<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>
|
</div>
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
Last report: @state.LastReportReceivedAt.LocalDateTime.ToString("HH:mm:ss") | Seq: @state.LastSequenceNumber
|
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 class="small"><TimestampDisplay Value="@msg.LastAttemptTimestamp" /></td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn btn-outline-success btn-sm py-0 px-1 me-1"
|
<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"
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@@ -102,6 +104,7 @@
|
|||||||
private bool _searching;
|
private bool _searching;
|
||||||
private string? _errorMessage;
|
private string? _errorMessage;
|
||||||
|
|
||||||
|
private bool _actionInProgress;
|
||||||
private ToastNotification _toast = default!;
|
private ToastNotification _toast = default!;
|
||||||
private ConfirmDialog _confirmDialog = default!;
|
private ConfirmDialog _confirmDialog = default!;
|
||||||
|
|
||||||
@@ -150,4 +153,65 @@
|
|||||||
}
|
}
|
||||||
_searching = false;
|
_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<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);
|
Task<bool> WriteBatchAndWaitAsync(IDictionary<string, object?> values, string flagPath, object? flagValue, string responsePath, object? responseValue, TimeSpan timeout, CancellationToken cancellationToken = default);
|
||||||
ConnectionHealth Status { get; }
|
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 DeadLetterCount,
|
||||||
int DeployedInstanceCount,
|
int DeployedInstanceCount,
|
||||||
int EnabledInstanceCount,
|
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 MgmtDeployArtifactsCommand(int? SiteId = null);
|
||||||
public record QueryDeploymentsCommand(int? InstanceId = null, string? Status = null, int Page = 1, int PageSize = 50);
|
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 MgmtDisableInstanceCommand(int InstanceId);
|
||||||
public record MgmtDeleteInstanceCommand(int InstanceId);
|
public record MgmtDeleteInstanceCommand(int InstanceId);
|
||||||
public record SetConnectionBindingsCommand(int InstanceId, IReadOnlyList<(string AttributeName, int DataConnectionId)> Bindings);
|
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;
|
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 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 ListScopeRulesCommand(int MappingId);
|
||||||
public record AddScopeRuleCommand(int MappingId, int SiteId);
|
public record AddScopeRuleCommand(int MappingId, int SiteId);
|
||||||
public record DeleteScopeRuleCommand(int ScopeRuleId);
|
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);
|
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) ──
|
// ── Pattern 8: Heartbeat (site→central, Tell) ──
|
||||||
// Heartbeats are received by central, not sent. No method needed here.
|
// 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;
|
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(
|
public DataConnectionActor(
|
||||||
string connectionName,
|
string connectionName,
|
||||||
IDataConnection adapter,
|
IDataConnection adapter,
|
||||||
@@ -79,13 +85,28 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
|||||||
protected override void PreStart()
|
protected override void PreStart()
|
||||||
{
|
{
|
||||||
_log.Info("DataConnectionActor [{0}] starting in Connecting state", _connectionName);
|
_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();
|
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()
|
protected override void PostStop()
|
||||||
{
|
{
|
||||||
_log.Info("DataConnectionActor [{0}] stopping — disposing adapter", _connectionName);
|
_log.Info("DataConnectionActor [{0}] stopping — disposing adapter", _connectionName);
|
||||||
// Clean up the adapter asynchronously
|
_adapter.Disconnected -= OnAdapterDisconnected;
|
||||||
_ = _adapter.DisposeAsync().AsTask();
|
_ = _adapter.DisposeAsync().AsTask();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,7 +297,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
|||||||
|
|
||||||
private void HandleDisconnect()
|
private void HandleDisconnect()
|
||||||
{
|
{
|
||||||
_log.Warning("[{0}] Adapter reported disconnect", _connectionName);
|
_log.Warning("[{0}] AdapterDisconnected message received — transitioning to Reconnecting", _connectionName);
|
||||||
BecomeReconnecting();
|
BecomeReconnecting();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ public interface ILmxProxyClient : IAsyncDisposable
|
|||||||
Task<ILmxSubscription> SubscribeAsync(
|
Task<ILmxSubscription> SubscribeAsync(
|
||||||
IEnumerable<string> addresses,
|
IEnumerable<string> addresses,
|
||||||
Action<string, LmxVtq> onUpdate,
|
Action<string, LmxVtq> onUpdate,
|
||||||
|
Action? onStreamError = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +49,7 @@ public interface ILmxProxyClient : IAsyncDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public interface ILmxProxyClientFactory
|
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>
|
/// <summary>
|
||||||
@@ -56,7 +57,7 @@ public interface ILmxProxyClientFactory
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class DefaultLmxProxyClientFactory : ILmxProxyClientFactory
|
public class DefaultLmxProxyClientFactory : ILmxProxyClientFactory
|
||||||
{
|
{
|
||||||
public ILmxProxyClient Create(string host, int port, string? apiKey) => new StubLmxProxyClient();
|
public ILmxProxyClient Create(string host, int port, string? apiKey, int samplingIntervalMs = 0, bool useTls = false) => new StubLmxProxyClient();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -93,7 +94,7 @@ internal class StubLmxProxyClient : ILmxProxyClient
|
|||||||
public Task WriteBatchAsync(IDictionary<string, object> values, CancellationToken cancellationToken = default)
|
public Task WriteBatchAsync(IDictionary<string, object> values, CancellationToken cancellationToken = default)
|
||||||
=> Task.CompletedTask;
|
=> 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());
|
=> Task.FromResult<ILmxSubscription>(new StubLmxSubscription());
|
||||||
|
|
||||||
public ValueTask DisposeAsync()
|
public ValueTask DisposeAsync()
|
||||||
|
|||||||
@@ -1,12 +1,28 @@
|
|||||||
namespace ScadaLink.DataConnectionLayer.Adapters;
|
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>
|
/// <summary>
|
||||||
/// WP-7: Abstraction over OPC UA client library for testability.
|
/// 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).
|
/// The real implementation would wrap an OPC UA SDK (e.g., OPC Foundation .NET Standard Library).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IOpcUaClient : IAsyncDisposable
|
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);
|
Task DisconnectAsync(CancellationToken cancellationToken = default);
|
||||||
bool IsConnected { get; }
|
bool IsConnected { get; }
|
||||||
|
|
||||||
@@ -24,6 +40,12 @@ public interface IOpcUaClient : IAsyncDisposable
|
|||||||
string nodeId, CancellationToken cancellationToken = default);
|
string nodeId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<uint> WriteValueAsync(string nodeId, object? value, 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>
|
/// <summary>
|
||||||
@@ -50,8 +72,11 @@ public class DefaultOpcUaClientFactory : IOpcUaClientFactory
|
|||||||
internal class StubOpcUaClient : IOpcUaClient
|
internal class StubOpcUaClient : IOpcUaClient
|
||||||
{
|
{
|
||||||
public bool IsConnected { get; private set; }
|
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;
|
IsConnected = true;
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ public class LmxProxyDataConnection : IDataConnection
|
|||||||
private ConnectionHealth _status = ConnectionHealth.Disconnected;
|
private ConnectionHealth _status = ConnectionHealth.Disconnected;
|
||||||
|
|
||||||
private readonly Dictionary<string, ILmxSubscription> _subscriptions = new();
|
private readonly Dictionary<string, ILmxSubscription> _subscriptions = new();
|
||||||
|
private volatile bool _disconnectFired;
|
||||||
|
|
||||||
public LmxProxyDataConnection(ILmxProxyClientFactory clientFactory, ILogger<LmxProxyDataConnection> logger)
|
public LmxProxyDataConnection(ILmxProxyClientFactory clientFactory, ILogger<LmxProxyDataConnection> logger)
|
||||||
{
|
{
|
||||||
@@ -31,6 +32,7 @@ public class LmxProxyDataConnection : IDataConnection
|
|||||||
}
|
}
|
||||||
|
|
||||||
public ConnectionHealth Status => _status;
|
public ConnectionHealth Status => _status;
|
||||||
|
public event Action? Disconnected;
|
||||||
|
|
||||||
public async Task ConnectAsync(IDictionary<string, string> connectionDetails, CancellationToken cancellationToken = default)
|
public async Task ConnectAsync(IDictionary<string, string> connectionDetails, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -39,11 +41,15 @@ public class LmxProxyDataConnection : IDataConnection
|
|||||||
_port = port;
|
_port = port;
|
||||||
connectionDetails.TryGetValue("ApiKey", out var apiKey);
|
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;
|
_status = ConnectionHealth.Connecting;
|
||||||
_client = _clientFactory.Create(_host, _port, apiKey);
|
_client = _clientFactory.Create(_host, _port, apiKey, samplingIntervalMs, useTls);
|
||||||
|
|
||||||
await _client.ConnectAsync(cancellationToken);
|
await _client.ConnectAsync(cancellationToken);
|
||||||
_status = ConnectionHealth.Connected;
|
_status = ConnectionHealth.Connected;
|
||||||
|
_disconnectFired = false;
|
||||||
|
|
||||||
_logger.LogInformation("LmxProxy connected to {Host}:{Port}", _host, _port);
|
_logger.LogInformation("LmxProxy connected to {Host}:{Port}", _host, _port);
|
||||||
}
|
}
|
||||||
@@ -62,13 +68,22 @@ public class LmxProxyDataConnection : IDataConnection
|
|||||||
{
|
{
|
||||||
EnsureConnected();
|
EnsureConnected();
|
||||||
|
|
||||||
var vtq = await _client!.ReadAsync(tagPath, cancellationToken);
|
try
|
||||||
var quality = MapQuality(vtq.Quality);
|
{
|
||||||
var tagValue = new TagValue(vtq.Value, quality, new DateTimeOffset(vtq.TimestampUtc, TimeSpan.Zero));
|
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
|
return vtq.Quality == LmxQuality.Bad
|
||||||
? new ReadResult(false, tagValue, "LmxProxy read returned bad quality")
|
? new ReadResult(false, tagValue, "LmxProxy read returned bad quality")
|
||||||
: new ReadResult(true, tagValue, null);
|
: 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)
|
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);
|
var quality = MapQuality(vtq.Quality);
|
||||||
callback(path, new TagValue(vtq.Value, quality, new DateTimeOffset(vtq.TimestampUtc, TimeSpan.Zero)));
|
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);
|
cancellationToken);
|
||||||
|
|
||||||
var subscriptionId = Guid.NewGuid().ToString("N");
|
var subscriptionId = Guid.NewGuid().ToString("N");
|
||||||
@@ -199,6 +219,19 @@ public class LmxProxyDataConnection : IDataConnection
|
|||||||
throw new InvalidOperationException("LmxProxy client is not connected.");
|
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
|
private static QualityCode MapQuality(LmxQuality quality) => quality switch
|
||||||
{
|
{
|
||||||
LmxQuality.Good => QualityCode.Good,
|
LmxQuality.Good => QualityCode.Good,
|
||||||
|
|||||||
@@ -33,29 +33,62 @@ public class OpcUaDataConnection : IDataConnection
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private volatile bool _disconnectFired;
|
||||||
|
|
||||||
public ConnectionHealth Status => _status;
|
public ConnectionHealth Status => _status;
|
||||||
|
public event Action? Disconnected;
|
||||||
|
|
||||||
public async Task ConnectAsync(IDictionary<string, string> connectionDetails, CancellationToken cancellationToken = default)
|
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)
|
_endpointUrl = connectionDetails.TryGetValue("endpoint", out var url)
|
||||||
? url
|
? url
|
||||||
: connectionDetails.TryGetValue("EndpointUrl", out var url2)
|
: connectionDetails.TryGetValue("EndpointUrl", out var url2)
|
||||||
? url2
|
? url2
|
||||||
: "opc.tcp://localhost:4840";
|
: "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;
|
_status = ConnectionHealth.Connecting;
|
||||||
|
|
||||||
_client = _clientFactory.Create();
|
_client = _clientFactory.Create();
|
||||||
await _client.ConnectAsync(_endpointUrl, cancellationToken);
|
_client.ConnectionLost += OnClientConnectionLost;
|
||||||
|
await _client.ConnectAsync(_endpointUrl, options, cancellationToken);
|
||||||
|
|
||||||
_status = ConnectionHealth.Connected;
|
_status = ConnectionHealth.Connected;
|
||||||
|
_disconnectFired = false;
|
||||||
_logger.LogInformation("OPC UA connected to {Endpoint}", _endpointUrl);
|
_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)
|
public async Task DisconnectAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (_client != null)
|
if (_client != null)
|
||||||
{
|
{
|
||||||
|
_client.ConnectionLost -= OnClientConnectionLost;
|
||||||
await _client.DisconnectAsync(cancellationToken);
|
await _client.DisconnectAsync(cancellationToken);
|
||||||
_status = ConnectionHealth.Disconnected;
|
_status = ConnectionHealth.Disconnected;
|
||||||
_logger.LogInformation("OPC UA disconnected from {Endpoint}", _endpointUrl);
|
_logger.LogInformation("OPC UA disconnected from {Endpoint}", _endpointUrl);
|
||||||
@@ -92,13 +125,22 @@ public class OpcUaDataConnection : IDataConnection
|
|||||||
{
|
{
|
||||||
EnsureConnected();
|
EnsureConnected();
|
||||||
|
|
||||||
var (value, timestamp, statusCode) = await _client!.ReadValueAsync(tagPath, cancellationToken);
|
try
|
||||||
var quality = MapStatusCode(statusCode);
|
{
|
||||||
|
var (value, timestamp, statusCode) = await _client!.ReadValueAsync(tagPath, cancellationToken);
|
||||||
|
var quality = MapStatusCode(statusCode);
|
||||||
|
|
||||||
if (quality == QualityCode.Bad)
|
if (quality == QualityCode.Bad)
|
||||||
return new ReadResult(false, null, $"OPC UA read returned bad status: 0x{statusCode:X8}");
|
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)
|
public async Task<IReadOnlyDictionary<string, ReadResult>> ReadBatchAsync(IEnumerable<string> tagPaths, CancellationToken cancellationToken = default)
|
||||||
@@ -163,6 +205,7 @@ public class OpcUaDataConnection : IDataConnection
|
|||||||
{
|
{
|
||||||
if (_client != null)
|
if (_client != null)
|
||||||
{
|
{
|
||||||
|
_client.ConnectionLost -= OnClientConnectionLost;
|
||||||
await _client.DisposeAsync();
|
await _client.DisposeAsync();
|
||||||
_client = null;
|
_client = null;
|
||||||
}
|
}
|
||||||
@@ -175,6 +218,19 @@ public class OpcUaDataConnection : IDataConnection
|
|||||||
throw new InvalidOperationException("OPC UA client is not connected.");
|
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>
|
/// <summary>
|
||||||
/// Maps OPC UA StatusCode to QualityCode.
|
/// Maps OPC UA StatusCode to QualityCode.
|
||||||
/// StatusCode 0 = Good, high bit set = Bad, otherwise Uncertain.
|
/// StatusCode 0 = Good, high bit set = Bad, otherwise Uncertain.
|
||||||
|
|||||||
@@ -14,25 +14,31 @@ internal class RealLmxProxyClient : ILmxProxyClient
|
|||||||
private readonly string _host;
|
private readonly string _host;
|
||||||
private readonly int _port;
|
private readonly int _port;
|
||||||
private readonly string? _apiKey;
|
private readonly string? _apiKey;
|
||||||
|
private readonly int _samplingIntervalMs;
|
||||||
|
private readonly bool _useTls;
|
||||||
private GrpcChannel? _channel;
|
private GrpcChannel? _channel;
|
||||||
private ScadaService.ScadaServiceClient? _client;
|
private ScadaService.ScadaServiceClient? _client;
|
||||||
private string? _sessionId;
|
private string? _sessionId;
|
||||||
private Metadata? _headers;
|
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;
|
_host = host;
|
||||||
_port = port;
|
_port = port;
|
||||||
_apiKey = apiKey;
|
_apiKey = apiKey;
|
||||||
|
_samplingIntervalMs = samplingIntervalMs;
|
||||||
|
_useTls = useTls;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsConnected => _client != null && !string.IsNullOrEmpty(_sessionId);
|
public bool IsConnected => _client != null && !string.IsNullOrEmpty(_sessionId);
|
||||||
|
|
||||||
public async Task ConnectAsync(CancellationToken cancellationToken = default)
|
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);
|
_client = new ScadaService.ScadaServiceClient(_channel);
|
||||||
|
|
||||||
_headers = new Metadata();
|
_headers = new Metadata();
|
||||||
@@ -111,13 +117,13 @@ internal class RealLmxProxyClient : ILmxProxyClient
|
|||||||
throw new InvalidOperationException($"WriteBatch failed: {response.Message}");
|
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();
|
EnsureConnected();
|
||||||
var tags = addresses.ToList();
|
var tags = addresses.ToList();
|
||||||
var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
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);
|
request.Tags.AddRange(tags);
|
||||||
|
|
||||||
var call = _client!.Subscribe(request, _headers, cancellationToken: cts.Token);
|
var call = _client!.Subscribe(request, _headers, cancellationToken: cts.Token);
|
||||||
@@ -131,9 +137,18 @@ internal class RealLmxProxyClient : ILmxProxyClient
|
|||||||
var msg = call.ResponseStream.Current;
|
var msg = call.ResponseStream.Current;
|
||||||
onUpdate(msg.Tag, ConvertVtq(msg));
|
onUpdate(msg.Tag, ConvertVtq(msg));
|
||||||
}
|
}
|
||||||
|
// Stream ended normally (server closed) — treat as disconnect
|
||||||
|
_sessionId = null;
|
||||||
|
onStreamError?.Invoke();
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) { }
|
catch (OperationCanceledException) { }
|
||||||
catch (RpcException ex) when (ex.StatusCode == StatusCode.Cancelled) { }
|
catch (RpcException ex) when (ex.StatusCode == StatusCode.Cancelled) { }
|
||||||
|
catch (RpcException)
|
||||||
|
{
|
||||||
|
// gRPC error (server offline, network failure) — signal disconnect
|
||||||
|
_sessionId = null;
|
||||||
|
onStreamError?.Invoke();
|
||||||
|
}
|
||||||
}, cts.Token);
|
}, cts.Token);
|
||||||
|
|
||||||
return Task.FromResult<ILmxSubscription>(new CtsSubscription(cts));
|
return Task.FromResult<ILmxSubscription>(new CtsSubscription(cts));
|
||||||
@@ -191,6 +206,6 @@ internal class RealLmxProxyClient : ILmxProxyClient
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class RealLmxProxyClientFactory : ILmxProxyClientFactory
|
public class RealLmxProxyClientFactory : ILmxProxyClientFactory
|
||||||
{
|
{
|
||||||
public ILmxProxyClient Create(string host, int port, string? apiKey)
|
public ILmxProxyClient Create(string host, int port, string? apiKey, int samplingIntervalMs = 0, bool useTls = false)
|
||||||
=> new RealLmxProxyClient(host, port, apiKey);
|
=> new RealLmxProxyClient(host, port, apiKey, samplingIntervalMs, useTls);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,31 +14,44 @@ public class RealOpcUaClient : IOpcUaClient
|
|||||||
private Subscription? _subscription;
|
private Subscription? _subscription;
|
||||||
private readonly Dictionary<string, MonitoredItem> _monitoredItems = new();
|
private readonly Dictionary<string, MonitoredItem> _monitoredItems = new();
|
||||||
private readonly Dictionary<string, Action<string, object?, DateTime, uint>> _callbacks = 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 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
|
var appConfig = new ApplicationConfiguration
|
||||||
{
|
{
|
||||||
ApplicationName = "ScadaLink-DCL",
|
ApplicationName = "ScadaLink-DCL",
|
||||||
ApplicationType = ApplicationType.Client,
|
ApplicationType = ApplicationType.Client,
|
||||||
SecurityConfiguration = new SecurityConfiguration
|
SecurityConfiguration = new SecurityConfiguration
|
||||||
{
|
{
|
||||||
AutoAcceptUntrustedCertificates = true,
|
AutoAcceptUntrustedCertificates = opts.AutoAcceptUntrustedCerts,
|
||||||
ApplicationCertificate = new CertificateIdentifier(),
|
ApplicationCertificate = new CertificateIdentifier(),
|
||||||
TrustedIssuerCertificates = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "ScadaLink", "pki", "issuers") },
|
TrustedIssuerCertificates = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "ScadaLink", "pki", "issuers") },
|
||||||
TrustedPeerCertificates = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "ScadaLink", "pki", "trusted") },
|
TrustedPeerCertificates = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "ScadaLink", "pki", "trusted") },
|
||||||
RejectedCertificateStore = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "ScadaLink", "pki", "rejected") }
|
RejectedCertificateStore = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "ScadaLink", "pki", "rejected") }
|
||||||
},
|
},
|
||||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
|
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = opts.SessionTimeoutMs },
|
||||||
TransportQuotas = new TransportQuotas { OperationTimeout = 15000 }
|
TransportQuotas = new TransportQuotas { OperationTimeout = opts.OperationTimeoutMs }
|
||||||
};
|
};
|
||||||
|
|
||||||
await appConfig.ValidateAsync(ApplicationType.Client);
|
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;
|
EndpointDescription? endpoint;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -49,7 +62,7 @@ public class RealOpcUaClient : IOpcUaClient
|
|||||||
var endpoints = discoveryClient.GetEndpoints(null);
|
var endpoints = discoveryClient.GetEndpoints(null);
|
||||||
#pragma warning restore CS0618
|
#pragma warning restore CS0618
|
||||||
endpoint = endpoints
|
endpoint = endpoints
|
||||||
.Where(e => e.SecurityMode == MessageSecurityMode.None)
|
.Where(e => e.SecurityMode == preferredSecurityMode)
|
||||||
.FirstOrDefault() ?? endpoints.FirstOrDefault();
|
.FirstOrDefault() ?? endpoints.FirstOrDefault();
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -66,17 +79,24 @@ public class RealOpcUaClient : IOpcUaClient
|
|||||||
#pragma warning restore CS0618
|
#pragma warning restore CS0618
|
||||||
_session = await sessionFactory.CreateAsync(
|
_session = await sessionFactory.CreateAsync(
|
||||||
appConfig, configuredEndpoint, false,
|
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
|
// Create a default subscription for all monitored items
|
||||||
_subscription = new Subscription(_session.DefaultSubscription)
|
_subscription = new Subscription(_session.DefaultSubscription)
|
||||||
{
|
{
|
||||||
DisplayName = "ScadaLink",
|
DisplayName = "ScadaLink",
|
||||||
PublishingEnabled = true,
|
PublishingEnabled = true,
|
||||||
PublishingInterval = 1000,
|
PublishingInterval = opts.PublishingIntervalMs,
|
||||||
KeepAliveCount = 10,
|
KeepAliveCount = (uint)opts.KeepAliveCount,
|
||||||
LifetimeCount = 30,
|
LifetimeCount = (uint)opts.LifetimeCount,
|
||||||
MaxNotificationsPerPublish = 100
|
MaxNotificationsPerPublish = (uint)opts.MaxNotificationsPerPublish
|
||||||
};
|
};
|
||||||
|
|
||||||
_session.AddSubscription(_subscription);
|
_session.AddSubscription(_subscription);
|
||||||
@@ -92,6 +112,7 @@ public class RealOpcUaClient : IOpcUaClient
|
|||||||
}
|
}
|
||||||
if (_session != null)
|
if (_session != null)
|
||||||
{
|
{
|
||||||
|
_session.KeepAlive -= OnSessionKeepAlive;
|
||||||
await _session.CloseAsync(cancellationToken);
|
await _session.CloseAsync(cancellationToken);
|
||||||
_session = null;
|
_session = null;
|
||||||
}
|
}
|
||||||
@@ -112,8 +133,8 @@ public class RealOpcUaClient : IOpcUaClient
|
|||||||
DisplayName = nodeId,
|
DisplayName = nodeId,
|
||||||
StartNodeId = nodeId,
|
StartNodeId = nodeId,
|
||||||
AttributeId = Attributes.Value,
|
AttributeId = Attributes.Value,
|
||||||
SamplingInterval = 1000,
|
SamplingInterval = _options.SamplingIntervalMs,
|
||||||
QueueSize = 10,
|
QueueSize = (uint)_options.QueueSize,
|
||||||
DiscardOldest = true
|
DiscardOldest = true
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -188,6 +209,20 @@ public class RealOpcUaClient : IOpcUaClient
|
|||||||
return response.Results[0].Code;
|
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()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
await DisconnectAsync();
|
await DisconnectAsync();
|
||||||
|
|||||||
@@ -114,6 +114,9 @@ public class SiteHealthCollector : ISiteHealthCollector
|
|||||||
// Snapshot current S&F buffer depths
|
// Snapshot current S&F buffer depths
|
||||||
var sfBufferDepths = new Dictionary<string, int>(_sfBufferDepths);
|
var sfBufferDepths = new Dictionary<string, int>(_sfBufferDepths);
|
||||||
|
|
||||||
|
// Determine node role from active/standby state
|
||||||
|
var nodeRole = _isActiveNode ? "Active" : "Standby";
|
||||||
|
|
||||||
return new SiteHealthReport(
|
return new SiteHealthReport(
|
||||||
SiteId: siteId,
|
SiteId: siteId,
|
||||||
SequenceNumber: 0, // Caller (HealthReportSender) assigns the sequence number
|
SequenceNumber: 0, // Caller (HealthReportSender) assigns the sequence number
|
||||||
@@ -126,6 +129,7 @@ public class SiteHealthCollector : ISiteHealthCollector
|
|||||||
DeadLetterCount: deadLetters,
|
DeadLetterCount: deadLetters,
|
||||||
DeployedInstanceCount: _deployedInstanceCount,
|
DeployedInstanceCount: _deployedInstanceCount,
|
||||||
EnabledInstanceCount: _enabledInstanceCount,
|
EnabledInstanceCount: _enabledInstanceCount,
|
||||||
DisabledInstanceCount: _disabledInstanceCount);
|
DisabledInstanceCount: _disabledInstanceCount,
|
||||||
|
NodeRole: nodeRole);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,42 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using Akka.Cluster;
|
||||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
|
||||||
namespace ScadaLink.Host.Health;
|
namespace ScadaLink.Host.Health;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Health check that verifies Akka.NET cluster membership.
|
/// Health check that verifies this node is an active member of the Akka.NET cluster.
|
||||||
/// Initially returns healthy; will be refined when Akka cluster integration is complete.
|
/// Returns healthy only if the node's self-member status is Up or Joining.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AkkaClusterHealthCheck : IHealthCheck
|
public class AkkaClusterHealthCheck : IHealthCheck
|
||||||
{
|
{
|
||||||
|
private readonly ActorSystem? _system;
|
||||||
|
|
||||||
|
public AkkaClusterHealthCheck(ActorSystem? system = null)
|
||||||
|
{
|
||||||
|
_system = system;
|
||||||
|
}
|
||||||
|
|
||||||
public Task<HealthCheckResult> CheckHealthAsync(
|
public Task<HealthCheckResult> CheckHealthAsync(
|
||||||
HealthCheckContext context,
|
HealthCheckContext context,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// TODO: Query Akka Cluster.Get(system).State to verify this node is Up.
|
if (_system == null)
|
||||||
// For now, return healthy as Akka cluster wiring is being established.
|
return Task.FromResult(HealthCheckResult.Degraded("ActorSystem not yet available."));
|
||||||
return Task.FromResult(HealthCheckResult.Healthy("Akka cluster health check placeholder."));
|
|
||||||
|
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))
|
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(
|
var changed = new AttributeValueChanged(
|
||||||
_instanceUniqueName, update.TagPath, attrName,
|
_instanceUniqueName, update.TagPath, attrName,
|
||||||
update.Value, update.Quality.ToString(), update.Timestamp);
|
value, update.Quality.ToString(), update.Timestamp);
|
||||||
HandleAttributeValueChanged(changed);
|
HandleAttributeValueChanged(changed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void HandleConnectionQualityChanged(ConnectionQualityChanged qualityChanged)
|
private void HandleConnectionQualityChanged(ConnectionQualityChanged qualityChanged)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Connection {Connection} quality changed to {Quality}",
|
_logger.LogWarning("Connection {Connection} quality changed to {Quality} for instance {Instance}",
|
||||||
qualityChanged.ConnectionName, qualityChanged.Quality);
|
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>
|
/// <summary>
|
||||||
|
|||||||
@@ -236,7 +236,8 @@ public class TemplateService
|
|||||||
existing.Value = proposed.Value;
|
existing.Value = proposed.Value;
|
||||||
existing.Description = proposed.Description;
|
existing.Description = proposed.Description;
|
||||||
existing.IsLocked = proposed.IsLocked;
|
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 _repository.UpdateTemplateAttributeAsync(existing, cancellationToken);
|
||||||
await _auditService.LogAsync(user, "Update", "TemplateAttribute", attributeId.ToString(), existing.Name, 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 |
|
| Pump | FlowRate, Pressure, Running | Double, Boolean |
|
||||||
| Tank | Level, Temperature, HighLevel, LowLevel | Double, Boolean |
|
| Tank | Level, Temperature, HighLevel, LowLevel | Double, Boolean |
|
||||||
| Valve | Position, Command | Double, UInt32 |
|
| 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
|
## 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>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="bunit" Version="2.0.33-preview" />
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
<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" Version="2.9.3" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
@@ -19,6 +21,10 @@
|
|||||||
<Using Include="Xunit" />
|
<Using Include="Xunit" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="../../src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj" />
|
<ProjectReference Include="../../src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj" />
|
||||||
</ItemGroup>
|
</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>();
|
_mockClient = Substitute.For<ILmxProxyClient>();
|
||||||
_mockFactory = Substitute.For<ILmxProxyClientFactory>();
|
_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);
|
_adapter = new LmxProxyDataConnection(_mockFactory, NullLogger<LmxProxyDataConnection>.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ public class LmxProxyDataConnectionTests
|
|||||||
});
|
});
|
||||||
|
|
||||||
Assert.Equal(ConnectionHealth.Connected, _adapter.Status);
|
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>());
|
await _mockClient.Received(1).ConnectAsync(Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ public class LmxProxyDataConnectionTests
|
|||||||
["ApiKey"] = "my-secret-key"
|
["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]
|
[Fact]
|
||||||
@@ -67,7 +67,7 @@ public class LmxProxyDataConnectionTests
|
|||||||
|
|
||||||
await _adapter.ConnectAsync(new Dictionary<string, string>());
|
await _adapter.ConnectAsync(new Dictionary<string, string>());
|
||||||
|
|
||||||
_mockFactory.Received(1).Create("localhost", 50051, null);
|
_mockFactory.Received(1).Create("localhost", 50051, null, 0, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -201,7 +201,7 @@ public class LmxProxyDataConnectionTests
|
|||||||
{
|
{
|
||||||
await ConnectAdapter();
|
await ConnectAdapter();
|
||||||
var mockSub = Substitute.For<ILmxSubscription>();
|
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);
|
.Returns(mockSub);
|
||||||
|
|
||||||
var subId = await _adapter.SubscribeAsync("Tag1", (_, _) => { });
|
var subId = await _adapter.SubscribeAsync("Tag1", (_, _) => { });
|
||||||
@@ -209,7 +209,7 @@ public class LmxProxyDataConnectionTests
|
|||||||
Assert.NotNull(subId);
|
Assert.NotNull(subId);
|
||||||
Assert.NotEmpty(subId);
|
Assert.NotEmpty(subId);
|
||||||
await _mockClient.Received(1).SubscribeAsync(
|
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]
|
[Fact]
|
||||||
@@ -217,7 +217,7 @@ public class LmxProxyDataConnectionTests
|
|||||||
{
|
{
|
||||||
await ConnectAdapter();
|
await ConnectAdapter();
|
||||||
var mockSub = Substitute.For<ILmxSubscription>();
|
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);
|
.Returns(mockSub);
|
||||||
|
|
||||||
var subId = await _adapter.SubscribeAsync("Tag1", (_, _) => { });
|
var subId = await _adapter.SubscribeAsync("Tag1", (_, _) => { });
|
||||||
@@ -240,7 +240,7 @@ public class LmxProxyDataConnectionTests
|
|||||||
{
|
{
|
||||||
await ConnectAdapter();
|
await ConnectAdapter();
|
||||||
var mockSub = Substitute.For<ILmxSubscription>();
|
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);
|
.Returns(mockSub);
|
||||||
await _adapter.SubscribeAsync("Tag1", (_, _) => { });
|
await _adapter.SubscribeAsync("Tag1", (_, _) => { });
|
||||||
|
|
||||||
@@ -277,4 +277,46 @@ public class LmxProxyDataConnectionTests
|
|||||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
_adapter.SubscribeAsync("tag1", (_, _) => { }));
|
_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);
|
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]
|
[Fact]
|
||||||
@@ -149,4 +149,123 @@ public class OpcUaDataConnectionTests
|
|||||||
|
|
||||||
Assert.Equal(ConnectionHealth.Disconnected, _adapter.Status);
|
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 transport = new FakeTransport();
|
||||||
var collector = new SiteHealthCollector();
|
var collector = new SiteHealthCollector();
|
||||||
|
collector.SetActiveNode(true);
|
||||||
var options = Options.Create(new HealthMonitoringOptions
|
var options = Options.Create(new HealthMonitoringOptions
|
||||||
{
|
{
|
||||||
ReportInterval = TimeSpan.FromMilliseconds(50)
|
ReportInterval = TimeSpan.FromMilliseconds(50)
|
||||||
@@ -61,6 +62,7 @@ public class HealthReportSenderTests
|
|||||||
{
|
{
|
||||||
var transport = new FakeTransport();
|
var transport = new FakeTransport();
|
||||||
var collector = new SiteHealthCollector();
|
var collector = new SiteHealthCollector();
|
||||||
|
collector.SetActiveNode(true);
|
||||||
var options = Options.Create(new HealthMonitoringOptions
|
var options = Options.Create(new HealthMonitoringOptions
|
||||||
{
|
{
|
||||||
ReportInterval = TimeSpan.FromMilliseconds(50)
|
ReportInterval = TimeSpan.FromMilliseconds(50)
|
||||||
@@ -91,6 +93,7 @@ public class HealthReportSenderTests
|
|||||||
{
|
{
|
||||||
var transport = new FakeTransport();
|
var transport = new FakeTransport();
|
||||||
var collector = new SiteHealthCollector();
|
var collector = new SiteHealthCollector();
|
||||||
|
collector.SetActiveNode(true);
|
||||||
var options = Options.Create(new HealthMonitoringOptions
|
var options = Options.Create(new HealthMonitoringOptions
|
||||||
{
|
{
|
||||||
ReportInterval = TimeSpan.FromMilliseconds(50)
|
ReportInterval = TimeSpan.FromMilliseconds(50)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ public class InboundScriptExecutorTests
|
|||||||
|
|
||||||
public InboundScriptExecutorTests()
|
public InboundScriptExecutorTests()
|
||||||
{
|
{
|
||||||
_executor = new InboundScriptExecutor(NullLogger<InboundScriptExecutor>.Instance);
|
_executor = new InboundScriptExecutor(NullLogger<InboundScriptExecutor>.Instance, Substitute.For<IServiceProvider>());
|
||||||
var locator = Substitute.For<IInstanceLocator>();
|
var locator = Substitute.For<IInstanceLocator>();
|
||||||
var commService = Substitute.For<CommunicationService>(
|
var commService = Substitute.For<CommunicationService>(
|
||||||
Microsoft.Extensions.Options.Options.Create(new CommunicationOptions()),
|
Microsoft.Extensions.Options.Options.Create(new CommunicationOptions()),
|
||||||
@@ -47,9 +47,10 @@ public class InboundScriptExecutorTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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(
|
var result = await _executor.ExecuteAsync(
|
||||||
method,
|
method,
|
||||||
@@ -58,7 +59,22 @@ public class InboundScriptExecutorTests
|
|||||||
TimeSpan.FromSeconds(10));
|
TimeSpan.FromSeconds(10));
|
||||||
|
|
||||||
Assert.False(result.Success);
|
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]
|
[Fact]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using NSubstitute;
|
|||||||
using NSubstitute.ExceptionExtensions;
|
using NSubstitute.ExceptionExtensions;
|
||||||
using ScadaLink.Commons.Entities.Notifications;
|
using ScadaLink.Commons.Entities.Notifications;
|
||||||
using ScadaLink.Commons.Interfaces.Repositories;
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
using ScadaLink.StoreAndForward;
|
||||||
|
|
||||||
namespace ScadaLink.NotificationService.Tests;
|
namespace ScadaLink.NotificationService.Tests;
|
||||||
|
|
||||||
@@ -145,4 +146,50 @@ public class NotificationDeliveryServiceTests
|
|||||||
Assert.False(result.Success);
|
Assert.False(result.Success);
|
||||||
Assert.Contains("store-and-forward not available", result.ErrorMessage);
|
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>
|
<ItemGroup>
|
||||||
<ProjectReference Include="../../src/ScadaLink.NotificationService/ScadaLink.NotificationService.csproj" />
|
<ProjectReference Include="../../src/ScadaLink.NotificationService/ScadaLink.NotificationService.csproj" />
|
||||||
<ProjectReference Include="../../src/ScadaLink.Commons/ScadaLink.Commons.csproj" />
|
<ProjectReference Include="../../src/ScadaLink.Commons/ScadaLink.Commons.csproj" />
|
||||||
|
<ProjectReference Include="../../src/ScadaLink.StoreAndForward/ScadaLink.StoreAndForward.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
Reference in New Issue
Block a user